diff --git a/.changeset/model-selection.md b/.changeset/model-selection.md new file mode 100644 index 0000000..6984e9f --- /dev/null +++ b/.changeset/model-selection.md @@ -0,0 +1,22 @@ +--- +'@agent-devtools/core': minor +'@agent-devtools/widget-core': minor +--- + +Add model selection so a prompt can run on the same models the Claude Code +terminal offers. A new `model` setting exposes the terminal's `/model` menu — +`default`, `opus`, `sonnet`, `haiku` — in the settings panel, persists in +localStorage alongside the provider, permission mode and theme, and rides on +each request body. `default` is a sentinel that sends no model on the wire, so +the chosen provider keeps its own default exactly as it does today. + +Both providers resolve the alias through the shared Claude Agent SDK resolver, +so no live model-discovery round-trip is needed. The SDK provider forwards the +alias as the `query()` `model` option. The ACP provider applies it with +`session/set_model` after the session is established and before the prompt is +dispatched; it remembers the last applied model per session to skip a redundant +round-trip when the model is unchanged across turns, and surfaces an error +(rather than silently running on the wrong model) if the agent rejects the +request. The server validates only that `model` is a non-empty string and +forwards it verbatim, leaving the model set open for full date-pinned ids or +future tiers without a protocol change. diff --git a/.changeset/network-resilience-retry.md b/.changeset/network-resilience-retry.md new file mode 100644 index 0000000..5d24d19 --- /dev/null +++ b/.changeset/network-resilience-retry.md @@ -0,0 +1,24 @@ +--- +'@agent-devtools/widget-core': minor +--- + +Absorb the dev-server respawn window so a hot reload no longer surfaces a +spurious network error on the next prompt. The default transport already +retried when `fetch()` rejected before any Response (the request never left +the client); it now treats a `503` from the dev-server proxy the same way, +because the proxy returns `503 "agent server not ready"` _before_ forwarding +anything upstream while the agent server respawns — so the prompt never +reached the agent and a retry can't duplicate the turn. This is the common +"network error right after a dev-server restart / hot reload" case. + +Retries now use capped exponential backoff (base `300ms`, cap `2000ms`, +default four retries ≈ 4.1s total) so a multi-second respawn is waited out +while a genuinely dead server still fails within a bounded window. A new +`preResponseRetryMaxBackoffMs` option exposes the cap, and the default +retry count rose from 1 to 4. + +The idempotency boundary is unchanged: any failure that proves the prompt +reached the agent — a `2xx` stream that later drops mid-flight, `500`, +`502`, `401`, or a silent-stream timeout — is never auto-retried, since the +agent may have already started editing files and re-sending would re-run the +LLM. Those still surface as an error for the user to retry deliberately. diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 0000000..7ff9b27 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,42 @@ +{ + "mode": "pre", + "tag": "beta", + "initialVersions": { + "@agent-devtools/docs": "0.0.2", + "@agent-devtools/example-angular-vite": "0.0.0", + "@agent-devtools/example-html": "0.0.0", + "@agent-devtools/example-next": "0.0.0", + "@agent-devtools/example-next-pages": "0.0.0", + "@agent-devtools/example-nuxt": "0.0.0", + "@agent-devtools/example-nuxt2": "0.0.0", + "@agent-devtools/example-react-vite": "0.0.0", + "@agent-devtools/example-svelte-vite": "0.0.0", + "@agent-devtools/example-sveltekit": "0.0.0", + "@agent-devtools/example-vue-vite": "0.0.0", + "@agent-devtools/example-vue2-vite": "0.0.0", + "@agent-devtools/angular": "1.0.0", + "@agent-devtools/core": "1.0.0", + "@agent-devtools/e2e": "0.0.0", + "@agent-devtools/harness-core": "1.0.0", + "@agent-devtools/html": "1.0.0", + "@agent-devtools/next": "1.0.0", + "@agent-devtools/next-pages": "1.0.0", + "@agent-devtools/nuxt": "1.0.0", + "@agent-devtools/nuxt2": "1.0.0", + "@agent-devtools/react": "1.0.0", + "@agent-devtools/svelte": "1.0.0", + "@agent-devtools/sveltekit": "1.0.0", + "@agent-devtools/vite": "1.0.0", + "@agent-devtools/vue": "1.0.0", + "@agent-devtools/vue2": "1.0.0", + "@agent-devtools/widget-core": "1.0.0" + }, + "changesets": [ + "model-selection", + "network-resilience-retry", + "sdk-claude-code-preset", + "widget-theme", + "widget-visibility-persistence", + "working-indicator-full-coverage" + ] +} diff --git a/.changeset/sdk-claude-code-preset.md b/.changeset/sdk-claude-code-preset.md new file mode 100644 index 0000000..0ec1dbe --- /dev/null +++ b/.changeset/sdk-claude-code-preset.md @@ -0,0 +1,5 @@ +--- +'@agent-devtools/core': patch +--- + +Fix the in-process SDK provider being rejected with "API Error: 400 role 'system' is not supported on this model". The provider omitted `systemPrompt`, so the Claude Agent SDK fell back to its minimal default prompt instead of the full Claude Code prompt that `claude -p` uses by default. It now opts into the `claude_code` preset, restoring terminal parity, and pins `settingSources` so project `CLAUDE.md` context cannot be silently dropped by a future SDK default change. diff --git a/.changeset/widget-theme.md b/.changeset/widget-theme.md new file mode 100644 index 0000000..e4decc2 --- /dev/null +++ b/.changeset/widget-theme.md @@ -0,0 +1,27 @@ +--- +'@agent-devtools/widget-core': minor +--- + +Add a theme to the floating chat and every widget surface: a new `theme` +setting with `auto` (the default), `light`, and `dark`. `auto` follows the +operating system's `prefers-color-scheme`; `light` and `dark` pin the choice. +The setting persists in localStorage alongside the provider and permission mode, +and switching it flips a single `data-theme` attribute on the closed shadow +host, so the browser recomputes every colour through CSS custom properties with +no per-component re-render. + +The dark palette is the only set of tokens defined centrally on the host. Light +is the absence of tokens: every surface reads its colour as +`var(--adt-token, )`, where the literal fallback is that element's +original light colour. So light stays byte-identical to the previous look and +each surface keeps its own light nuance, while dark is single-sourced — the same +token can resolve to a different light value per surface (a user bubble's text +is white in light, body text is near-black, and both become the same light grey +in dark). Surfaces that are intentionally dark in both themes (the picked-element +code card) keep their dark treatment by reading a raised-surface token rather +than inverting with the accent. + +Every widget surface participates: the composer, launcher, message stream, +picked-element evidence, tool output, handoff modal, and settings panel. The +launcher and accent controls invert correctly so dark mode reads as a true dark +theme rather than a tinted light one. diff --git a/.changeset/widget-visibility-persistence.md b/.changeset/widget-visibility-persistence.md new file mode 100644 index 0000000..e11fde5 --- /dev/null +++ b/.changeset/widget-visibility-persistence.md @@ -0,0 +1,16 @@ +--- +'@agent-devtools/widget-core': minor +--- + +Persist the widget's visibility across page reloads. The orchestrator now +remembers two on/off axes in localStorage and restores them on mount: the +composer panel's open/closed state (toggled by the launcher, the close button, +Escape, or picking an element) and the widget-level visibility (toggled by the +Ctrl/Cmd+Shift+; hotkey). This matches the standard devtools convention where +the tool reopens in the state you left it. Persistence lives in the +orchestrator rather than the composer because only the orchestrator can tell a +user-driven open/close apart from a system-driven transient collapse (the panel +hiding during element-picking, or the whole surface going dark), so a transient +collapse never clobbers the user's remembered choice. Storage access is wrapped +in try/catch and degrades silently where localStorage is unavailable (file://, +private mode, sandboxed iframes, quota-exceeded). diff --git a/.changeset/working-indicator-full-coverage.md b/.changeset/working-indicator-full-coverage.md new file mode 100644 index 0000000..df4b0ae --- /dev/null +++ b/.changeset/working-indicator-full-coverage.md @@ -0,0 +1,15 @@ +--- +'@agent-devtools/widget-core': patch +--- + +Show the working ("typing") indicator during every idle period of a turn, not +only while waiting for the first response. Previously the three-dot indicator +was a one-shot placeholder pushed when the user submitted and removed on the +first assistant event, so in an agentic turn the surface looked frozen while a +tool executed and while the model round-tripped on a tool result. The indicator +is now a derived view of the conversation state: it sits at the tail whenever a +turn is in flight and the assistant is between visible actions (after submit, +while a tool runs, and during the model round-trip after a tool result), and is +dropped the moment text or tool input streams again or the turn ends. It is +deliberately not shown after a finished text block, since a turn that ends on +text emits its completion immediately and a dot there would only flash. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 76dc8d2..754de1b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -90,10 +90,17 @@ jobs: # Commit + push the version bump immediately so the next run's # `changeset publish` sees the same tree the npm registry will see. # [skip ci] prevents this commit from re-triggering the workflow. + # Retry once on non-fast-forward: between checkout and push another + # job (or a manual push) may have advanced the branch — pull --rebase + # and re-push so the version commit lands on top instead of failing. if [ -n "$(git status --porcelain)" ]; then git add -A git commit -m "chore: version agent-devtools beta prerelease [skip ci]" - git push origin "${GITHUB_REF_NAME}" + if ! git push origin "${GITHUB_REF_NAME}"; then + echo "non-fast-forward on first push; rebasing and retrying" + git pull --rebase --autostash origin "${GITHUB_REF_NAME}" + git push origin "${GITHUB_REF_NAME}" + fi fi # Pre mode publish: DO NOT pass --tag here. Changesets routes @@ -105,7 +112,10 @@ jobs: # versioned; annotated per-package tags are created and pushed # together with --follow-tags. pnpm exec changeset publish - git push --follow-tags origin "${GITHUB_REF_NAME}" + if ! git push --follow-tags origin "${GITHUB_REF_NAME}"; then + git pull --rebase --autostash origin "${GITHUB_REF_NAME}" + git push --follow-tags origin "${GITHUB_REF_NAME}" + fi - name: Publish Release (main branch) if: github.ref == 'refs/heads/main' @@ -126,25 +136,59 @@ jobs: pnpm exec changeset version git add -A git commit -m "chore: release agent-devtools packages [skip ci]" - git push origin "${GITHUB_REF_NAME}" + if ! git push origin "${GITHUB_REF_NAME}"; then + echo "non-fast-forward on first push; rebasing and retrying" + git pull --rebase --autostash origin "${GITHUB_REF_NAME}" + git push origin "${GITHUB_REF_NAME}" + fi fi pnpm exec changeset publish - git push --follow-tags origin "${GITHUB_REF_NAME}" + if ! git push --follow-tags origin "${GITHUB_REF_NAME}"; then + git pull --rebase --autostash origin "${GITHUB_REF_NAME}" + git push --follow-tags origin "${GITHUB_REF_NAME}" + fi - name: Sync main to develop if: github.ref == 'refs/heads/main' run: | # Single-workflow back-merge — mirrors the canonical template's # closing step. After a stable release main owns the release - # bookkeeping (package versions, CHANGELOGs, pre-mode state). Take - # main's side on conflicts; develop re-enters pre mode on its next - # push. The normal back-merge is conflict-free; the --theirs block - # only fires on a race (a develop push overlapping the release). + # bookkeeping (package versions, CHANGELOGs, pre-mode state). The + # normal back-merge is conflict-free; the resolution block below only + # fires on a race (a develop push overlapping the release). + # + # Race-loss-safe conflict policy: + # - For packages/*/package.json and CHANGELOG.md: take whichever + # side has the higher SemVer version. This preserves a forward + # prerelease line on develop (e.g. 1.1.0-beta.N) instead of + # regressing it to the stable version that just landed on main. + # A naive `--theirs` (main always wins) would drag develop's + # versions backwards — the exact regression observed when PR #9's + # push lost a non-fast-forward race. The next stable release + # reconciles the prerelease line into the final X.Y.Z anyway. + # - For .changeset/pre.json and consumed .changeset/*.md: accept + # main's deletion. These are modify/delete conflicts; develop + # re-enters pre mode on its next push and recreates pre.json. git fetch origin develop git checkout develop if ! git merge --no-ff main -m "chore: sync main into develop after stable release [skip ci]"; then - git checkout --theirs packages/*/package.json packages/*/CHANGELOG.md 2>/dev/null || true - git add packages/*/package.json packages/*/CHANGELOG.md 2>/dev/null || true + for f in $(git diff --name-only --diff-filter=U -- 'packages/*/package.json' 'packages/*/CHANGELOG.md'); do + ours_ver=$(git show :2:"$f" 2>/dev/null | grep -m1 '"version"' | sed -E 's/.*"version"[^"]*"([^"]+)".*/\1/' || true) + theirs_ver=$(git show :3:"$f" 2>/dev/null | grep -m1 '"version"' | sed -E 's/.*"version"[^"]*"([^"]+)".*/\1/' || true) + if [ -n "$ours_ver" ] && [ -n "$theirs_ver" ]; then + higher=$(printf "%s\n%s\n" "$ours_ver" "$theirs_ver" | sort -V | tail -1) + if [ "$higher" = "$ours_ver" ]; then + git checkout --ours "$f" + else + git checkout --theirs "$f" + fi + else + # No version field on one side (e.g. CHANGELOG.md): keep the + # forward branch (develop) for narrative continuity. + git checkout --ours "$f" + fi + git add "$f" + done # The stable release deletes the changesets pre-mode bookkeeping # on main: pre.json is removed once `changeset version` finalises # the exit, and every applied .md is consumed. These surface as @@ -161,4 +205,7 @@ jobs: fi git commit --no-edit fi - git push origin develop + if ! git push origin develop; then + git pull --rebase --autostash origin develop + git push origin develop + fi diff --git a/docs/src/content/docs/en/guides/permission-modes.md b/docs/src/content/docs/en/guides/permission-modes.md index 4cfb92e..bc0de7f 100644 --- a/docs/src/content/docs/en/guides/permission-modes.md +++ b/docs/src/content/docs/en/guides/permission-modes.md @@ -6,7 +6,7 @@ description: The five permission modes — `default`, `acceptEdits`, `plan`, `by You can switch permission mode from the widget's settings panel. When the widget mounts without a stored preference, **the initial mode is `acceptEdits`** (see `DEFAULT_SETTINGS.permissionMode` at -`packages/react/src/settings/types.ts:39`). `bypassPermissions` is exposed +`packages/widget-core/src/settings/types.ts:84`). `bypassPermissions` is exposed only inside the settings panel — never from the chat composer — and should be used sparingly. diff --git a/docs/src/content/docs/en/guides/security.md b/docs/src/content/docs/en/guides/security.md index 9e22a1a..7f8891d 100644 --- a/docs/src/content/docs/en/guides/security.md +++ b/docs/src/content/docs/en/guides/security.md @@ -33,7 +33,7 @@ In a production build, the agent-devtools code path **never enters the module gr Even if Layer 1 is bypassed, the code refuses to run. **Fail-loud (throw) is the default** — silent no-op would hide a misdeployment. -- `mountAgentDevtools()` throws when `process.env.NODE_ENV === 'production'` (see `isProductionBuild` in [`packages/react/src/orchestrator/mount.ts:464`](https://github.com/Seungwoo321/agent-devtools/blob/main/packages/react/src/orchestrator/mount.ts)). The explicit override `{ force: true }` is the only escape hatch and is intended for justified operational debugging. +- `mountAgentDevtools()` throws when `process.env.NODE_ENV === 'production'` (see `isProductionBuild` in [`packages/widget-core/src/orchestrator/mount.ts:689`](https://github.com/Seungwoo321/agent-devtools/blob/main/packages/widget-core/src/orchestrator/mount.ts)). The explicit override `{ force: true }` is the only escape hatch and is intended for justified operational debugging. - `startAgentDevtoolsServer` performs the same check — the server will never `listen` in production. - `enabled: false` and similar runtime opt-out options are a **separate layer** from Layer 2. Opt-out is a dev-time off switch and does not substitute for the production block. diff --git a/docs/src/content/docs/en/guides/troubleshooting.md b/docs/src/content/docs/en/guides/troubleshooting.md index 1b275d5..fe24b2e 100644 --- a/docs/src/content/docs/en/guides/troubleshooting.md +++ b/docs/src/content/docs/en/guides/troubleshooting.md @@ -200,9 +200,9 @@ gate the import behind `import.meta.env.DEV`.` - The first check in `mountAgentDevtools` throws on the spot when `isProductionBuild()` returns true - (`packages/react/src/orchestrator/mount.ts:156-159`). + (`packages/widget-core/src/orchestrator/mount.ts:230-232`). - The judgement is a `process.env.NODE_ENV === 'production'` comparison - (`mount.ts:464-471`). Vite replaces this token with a literal at build + (`mount.ts:689-696`). Vite replaces this token with a literal at build time, so the standard dev/prod split works as intended. - At the same time, the Vite plugin is declared with `apply: 'serve'` so the plugin code itself is gated out of production builds as a first diff --git a/docs/src/content/docs/en/guides/widget.md b/docs/src/content/docs/en/guides/widget.md index ff2f16f..0d3a398 100644 --- a/docs/src/content/docs/en/guides/widget.md +++ b/docs/src/content/docs/en/guides/widget.md @@ -22,26 +22,26 @@ page DOM is what determines the isolation model. inherited styles, so the host app's CSS cannot repaint the widget and the widget's CSS cannot leak into the host. Because the shadow root is closed, scripts on the host page cannot peek inside the widget via - `element.shadowRoot` (`packages/react/src/widget/shadow-root.ts:80`). + `element.shadowRoot` (`packages/widget-core/src/widget/shadow-root.ts:147`). - **Launcher / composer / settings live inside the shadow root.** The launcher button, the composer panel, and the settings panel are all appended to the `[data-widget-container]` element inside the shadow root. They use `position: fixed` and `z-index: 2147483646` to sit at the very top of the stacking context so they never overlap with the host layout - (`packages/react/src/widget/shadow-root.ts:57`). + (`packages/widget-core/src/widget/shadow-root.ts:121`). - **The element picker overlay lives outside the shadow root.** The outline that the picker draws over hovered elements is intentionally appended directly to `document.body`. It uses `pointer-events: none` so clicks pass through, and it is positioned in a way that keeps it out of `elementFromPoint` results — that way users actually grab the real host element instead of "the element the widget is covering" - (`packages/react/src/picker/overlay.ts:1`). + (`packages/widget-core/src/picker/overlay.ts:69`). - **Dual React tree.** The widget UI renders inside a React root that is completely separate from the host app's React root. The widget does not depend on the host's Provider/Context tree, and conversely the widget's state never leaks into the host. `mountAgentDevtools()` mounts the launcher, composer, settings panel, stream renderer, and picker directly - onto its own shadow root (`packages/react/src/orchestrator/mount.ts:156`). + onto its own shadow root (`packages/widget-core/src/orchestrator/mount.ts:302`). ## Launcher @@ -51,27 +51,29 @@ and toggles the composer open or closed on click. - **Position and size.** The default position is `{ x: 24, y: 24 }` away from the bottom-right of the viewport, and the button is a 48px circle. It anchors via `right` / `bottom` - (`packages/react/src/launcher/launcher.ts:27`). + (`packages/widget-core/src/launcher/launcher.ts:27`). - **Drag-to-move + persistence.** Holding the button and dragging lets you reposition it; on pointerup the coordinates — clamped to the viewport bounds — are saved to `localStorage`. On the next mount the launcher - reappears in the same spot (`launcher.ts:108`). At mount time the saved - position is clamped against the current viewport once more, so it never - ends up off-screen after a window resize. + reappears in the same spot + (`packages/widget-core/src/launcher/launcher.ts:119`). At mount time the + saved position is clamped against the current viewport once more, so it + never ends up off-screen after a window resize. - **Click vs drag.** Pointer input is routed through a pure reducer (`launcher/state.ts`) that distinguishes click from drag, and the `onClick` callback only fires for a real click effect. The synthetic click that browsers fire at the end of a drag is swallowed by the reducer — so the composer never pops open by accident - (`launcher.ts:149`). + (`packages/widget-core/src/launcher/launcher.ts:117`). - **Click behavior.** The orchestrator checks the current visibility via `composer.element.style.display === 'none'` and toggles. When opening, it also calls `composer.focus()` so the user can start typing immediately - (`packages/react/src/orchestrator/mount.ts:254`). + (`packages/widget-core/src/orchestrator/mount.ts:379`). - **The composer follows the launcher.** During a drag, `onPositionChange` is called on every move and updates the composer's `setAnchor`. The panel's right edge aligns with the launcher's right edge, and the panel's - bottom edge sits 16px above the launcher (`mount.ts:262`). + bottom edge sits 16px above the launcher + (`packages/widget-core/src/orchestrator/mount.ts:398`). - **No global shortcut.** There is no global keyboard shortcut to toggle the launcher. The only way to close from the keyboard is to press `Escape` while the composer is open (this closes the composer only — @@ -86,32 +88,33 @@ live inside one panel. default, with a minimum of 280×240. Eight-direction resize handles let the user drag the panel larger, and the resulting size is persisted to `localStorage` under `agent-devtools:panelSize` - (`packages/react/src/composer/composer.ts:75`). + (`packages/widget-core/src/composer/composer.ts:85`). - **Keyboard behavior.** - `Enter` (without Shift) → submit if the text is non-empty and a request is not already in flight. - `Shift + Enter` → newline. - `Escape` → close the composer only (launcher stays) - (`packages/react/src/composer/composer.ts:341`). + (`packages/widget-core/src/composer/composer.ts:461`). - **Submit payload.** Sent to the orchestrator as `{ text, picked }`. The `picked` field is the most recent `PickedEvidence` captured by the picker (`null` if nothing was picked). The orchestrator combines this with the prompt and the result of `buildPageContext()` and hands it to the - transport (`mount.ts:271`). + transport (`packages/widget-core/src/orchestrator/mount.ts:419`). - **In-flight UI state.** When the transport starts replying it calls `setSending(true)`, which disables the textarea and the send button. On success or failure it calls `setSending(false)`. To prevent concurrent requests an in-flight `AbortController` aborts the previous request when - a new submit happens (`mount.ts:282`). + a new submit happens (`packages/widget-core/src/orchestrator/mount.ts:413`). - **Streaming response.** A stream renderer is inserted into the composer panel above the textarea. As the transport pipes SSE/JSON chunks into `MessageStore.applyEvent()`, the renderer draws them straight onto the - screen (`mount.ts:188`). + screen (`packages/widget-core/src/orchestrator/mount.ts:313`). - **Extra actions.** The composer header has buttons for the picker toggle, settings (gear), terminal handoff (continue the conversation in the Claude CLI), and new conversation (reset the session). New conversation clears the message store and asks the transport's `resetSession()` to - hand out a fresh server-side ACP session (`mount.ts:332`). + hand out a fresh server-side ACP session + (`packages/widget-core/src/orchestrator/mount.ts:486`). ## Settings panel @@ -119,9 +122,9 @@ Clicking the gear button swaps the composer body from the stream view to the settings view in-place. It is not a separate floating dialog but a detail view inside the same panel — the same UX pattern used by React DevTools and TanStack Query DevTools -(`packages/react/src/settings/panel.ts:1`). +(`packages/widget-core/src/settings/panel.ts:1`). -There are two settings. +There are four settings. - **Provider** — which runtime handles the next prompt. - `acp` — spawn Claude Code as a subprocess and talk to it via the ACP @@ -131,7 +134,20 @@ There are two settings. Providers that are not listed in the server's `/v1/agent/info` response are rendered as disabled (greyed-out) radio buttons, so users cannot pick a combination that would return 422 - (`packages/react/src/settings/panel.ts:152`). + (`packages/widget-core/src/settings/panel.ts:222`). + +- **Model** — which model handles the prompt. It exposes the same choices + as the Claude Code terminal's `/model` menu. + - `default` _(default)_ — a sentinel that sends no model on the wire, so + the chosen provider keeps its own default model. + - `opus` / `sonnet` / `haiku` — pin to that alias. + + Both providers resolve the alias through the shared Claude Agent SDK + resolver, so no live model-discovery round-trip is needed. The SDK + provider forwards the alias as the `query()` `model` option; the ACP + provider applies it with `session/set_model` after the session is + established and before the prompt is dispatched + (`packages/widget-core/src/settings/types.ts:31`). - **Permission Mode** — the blanket policy for the `requestPermission` callback. There are five options: @@ -142,9 +158,10 @@ There are two settings. - `bypassPermissions` — unconditionally allow every permission request. Because of its risk profile, this option is only reachable from the settings panel and cannot be selected from any button in the chat - composer (`packages/react/src/settings/types.ts:10`, - `packages/react/src/settings/panel.ts:118`). The row itself is - highlighted with a red background (`settings/panel.ts:374`). + composer (`packages/widget-core/src/settings/types.ts:10`, + `packages/widget-core/src/settings/panel.ts:259`). The row itself is + highlighted with a red background + (`packages/widget-core/src/settings/panel.ts:163`). - `plan` — read-only plan mode. - `dontAsk` — the same allow path as `acceptEdits`, but suppresses every permission prompt from surfacing. @@ -152,17 +169,23 @@ There are two settings. See [Permission modes](/en/guides/permission-modes/) for the detailed semantics. +- **Theme** — the widget's appearance. It picks the widget's own theme + independently of the host page. + - `auto` _(default)_ — follows the OS / host `prefers-color-scheme`. + - `light` / `dark` — pin to light or dark mode. + One additional read-only piece of information is displayed at the bottom of the panel. - **Workspace Root** — the absolute workspace path reported by the server (`workspaceRoot` from `/v1/agent/info`). This lets the user confirm which root the agent is actually reading from and writing to - (`settings/panel.ts:133`). + (`packages/widget-core/src/settings/panel.ts:187`). -**Persistence scope.** Provider and permissionMode are serialised as JSON -under the `localStorage` key `agent-devtools:settings` and survive across -mounts (`packages/react/src/settings/storage.ts:11`). The panel size (the +**Persistence scope.** Provider, model, permissionMode, and theme are +serialised as JSON under the `localStorage` key `agent-devtools:settings` +and survive across mounts +(`packages/widget-core/src/settings/storage.ts:22`). The panel size (the result of drag-resizing the composer) is stored under a separate key, `agent-devtools:panelSize`. The launcher position uses `agent-devtools:launcherPosition` (`launcher/storage.ts`). The server info @@ -177,11 +200,11 @@ Even if the user does not explicitly pick an element, every submit automatically attaches a snapshot of the page context. The orchestrator calls `buildPageContext()` on each submit and bundles the following block into the transport payload -(`packages/react/src/orchestrator/mount.ts:287`, -`packages/react/src/context/build.ts:33`). +(`packages/widget-core/src/orchestrator/mount.ts:419`, +`packages/widget-core/src/context/build.ts:53`). Fields carried by `PageContext` -(`packages/react/src/context/types.ts:114`): +(`packages/widget-core/src/context/types.ts:164`): - `schemaVersion` — currently `2`. Compatibility marker for the server-side prompt formatter. @@ -189,16 +212,16 @@ Fields carried by `PageContext` - `url` — the full `location.href`. - `route` — `{ pathname, search, hash }`. Extracted from `window.location` regardless of which router (if any) is in use - (`packages/react/src/context/route.ts:9`). + (`packages/widget-core/src/context/route.ts:19`). - `pageFiles` — the list of component source files `{ fileName, componentName, lineNumber, columnNumber? }` collected by walking the current page's React fiber tree. Duplicate files are deduplicated and the list is capped at 50 entries. The walk starts from the React root passed in via the `rootContainer` option - (`packages/react/src/context/build.ts:66`). + (`packages/react/src/context/build.ts:19`). - `errors` — the most recent 50 console error / exception records that `createErrorObserver()` has been collecting since mount time - (`mount.ts:237`). + (`packages/widget-core/src/orchestrator/mount.ts:354`). - `picked` — the `PickedEvidence` captured by the picker, present only when an element has actually been picked (see the section below). @@ -215,7 +238,7 @@ picker's active / idle state directly. - **State machine.** The picker runs on a 3-state pure reducer: `idle → active → picked → idle` - (`packages/react/src/picker/state.ts:8`). A click during the active + (`packages/widget-core/src/picker/state.ts:8`). A click during the active state transitions straight to `picked` and the reducer falls back to `idle` — this is a **single-selection** model where only **one element at a time** can be picked. Multi-element selection is not supported. @@ -223,23 +246,24 @@ picker's active / idle state directly. `document.elementFromPoint` to find the element under the pointer and the overlay draws an outline on top of it. Because the overlay has `pointer-events: none`, it never includes itself in hit-test results - (`packages/react/src/picker/picker.ts:99`). + (`packages/widget-core/src/picker/picker.ts:102`). - **Click to confirm.** A click while active is prevented from reaching the host app via `preventDefault` + `stopPropagation`. The orchestrator's `onPick` callback receives the element, runs `describePicked()` to build a `PickedEvidence`, and surfaces it as the picked chip on the composer - (`mount.ts:240`). + (`packages/widget-core/src/orchestrator/mount.ts:359`). - **Escape to cancel.** Pressing `Escape` while the picker is active - cancels it and returns to `idle` (`picker.ts:92`). + cancels it and returns to `idle` + (`packages/widget-core/src/picker/picker.ts:93`). - **The picker never picks the widget itself.** When the picker starts, the widget shadow host and its descendants are filtered out via `shouldSkip`. This prevents the picker from accidentally selecting - itself (`picker.ts:33`). + itself (`packages/widget-core/src/picker/picker.ts:33`). A confirmed `PickedEvidence` is not just metadata — it is an evidence-grade snapshot -(`packages/react/src/context/picked.ts:47`, -`packages/react/src/context/types.ts:62`): +(`packages/react/src/context/picked.ts:52`, +`packages/widget-core/src/context/types.ts:79`): - **Identity** — `componentName`, `tagName`, a best-effort CSS `selector`, and `{ fileName, lineNumber, columnNumber? }` extracted from the JSX diff --git a/docs/src/content/docs/guides/permission-modes.md b/docs/src/content/docs/guides/permission-modes.md index 6bbb066..a8a9aa1 100644 --- a/docs/src/content/docs/guides/permission-modes.md +++ b/docs/src/content/docs/guides/permission-modes.md @@ -4,7 +4,7 @@ description: agent-devtools 의 5가지 권한 모드 — `default`, `acceptEdit --- 권한 모드는 위젯 설정 패널에서 전환할 수 있다. 위젯이 별도 저장값 없이 -마운트될 때의 **초기 모드는 `acceptEdits`** 다 (`packages/react/src/settings/types.ts:39` +마운트될 때의 **초기 모드는 `acceptEdits`** 다 (`packages/widget-core/src/settings/types.ts:84` 의 `DEFAULT_SETTINGS.permissionMode`). `bypassPermissions` 는 설정 패널 안에서만 노출되고 채팅 컴포저에서는 선택할 수 없다. diff --git a/docs/src/content/docs/guides/security.md b/docs/src/content/docs/guides/security.md index fe690f1..453b9fc 100644 --- a/docs/src/content/docs/guides/security.md +++ b/docs/src/content/docs/guides/security.md @@ -33,7 +33,7 @@ production build 시 agent-devtools 의 코드 경로가 모듈 그래프에 ** Layer 1 이 우회되더라도 런타임에서 코드가 자기 자신을 차단한다. **fail-loud (throw) 가 디폴트** — silent no-op 보다 잘못된 배포를 즉시 드러낸다. -- `mountAgentDevtools()` 는 `process.env.NODE_ENV === 'production'` 일 때 throw 한다 ([`packages/react/src/orchestrator/mount.ts:464`](https://github.com/Seungwoo321/agent-devtools/blob/main/packages/react/src/orchestrator/mount.ts) 의 `isProductionBuild`). 명시 override `{ force: true }` 만 허용 (정당화가 필요한 운영 디버깅 용도). +- `mountAgentDevtools()` 는 `process.env.NODE_ENV === 'production'` 일 때 throw 한다 ([`packages/widget-core/src/orchestrator/mount.ts:689`](https://github.com/Seungwoo321/agent-devtools/blob/main/packages/widget-core/src/orchestrator/mount.ts) 의 `isProductionBuild`). 명시 override `{ force: true }` 만 허용 (정당화가 필요한 운영 디버깅 용도). - `startAgentDevtoolsServer` 도 동일 검사를 수행 — production 환경에서는 절대 `listen` 하지 않는다. - `enabled: false` 같은 dev 안 opt-out 옵션은 Layer 2 와 **별개 layer** 다. opt-out 은 production 차단을 대체하지 않는다. diff --git a/docs/src/content/docs/guides/troubleshooting.md b/docs/src/content/docs/guides/troubleshooting.md index 8101226..a743e2e 100644 --- a/docs/src/content/docs/guides/troubleshooting.md +++ b/docs/src/content/docs/guides/troubleshooting.md @@ -160,8 +160,8 @@ gate the import behind `import.meta.env.DEV`.` **원인** - `mountAgentDevtools` 의 첫 검사에서 `isProductionBuild()` 가 true 면 그 자리에서 - throw 한다 (`packages/react/src/orchestrator/mount.ts:156-159`). -- 판정은 `process.env.NODE_ENV === 'production'` 비교 (`mount.ts:464-471`). Vite 가 + throw 한다 (`packages/widget-core/src/orchestrator/mount.ts:230-232`). +- 판정은 `process.env.NODE_ENV === 'production'` 비교 (`mount.ts:689-696`). Vite 가 build 시 이 토큰을 리터럴로 치환하므로 정상적인 dev/prod 분기에서는 의도대로 작동한다. - 동시에 Vite 플러그인은 `apply: 'serve'` 로 production build 자체에서는 플러그인 diff --git a/docs/src/content/docs/guides/widget.md b/docs/src/content/docs/guides/widget.md index 15ee4b0..61f3db3 100644 --- a/docs/src/content/docs/guides/widget.md +++ b/docs/src/content/docs/guides/widget.md @@ -19,22 +19,22 @@ overlay 한 개로 구성된다. 각 조각이 호스트 페이지 DOM 안에서 shadow root 안에서는 `:host { all: initial }` 으로 상속 스타일을 끊기 때문에, 호스트 앱의 CSS 가 위젯을 다시 칠하거나 위젯의 CSS 가 호스트로 새지 않는다. closed 모드라 호스트 페이지의 스크립트는 `element.shadowRoot` 로 위젯 내부를 - 들여다볼 수 없다 (`packages/react/src/widget/shadow-root.ts:80`). + 들여다볼 수 없다 (`packages/widget-core/src/widget/shadow-root.ts:147`). - **Launcher / composer / settings 는 shadow root 안.** launcher 버튼과 composer 패널, settings panel 모두 shadow root 안의 `[data-widget-container]` 요소에 append 된다. `position: fixed` 와 `z-index: 2147483646` 으로 호스트 레이아웃과 겹치지 않게 stacking context 최상단에 둔다 - (`packages/react/src/widget/shadow-root.ts:57`). + (`packages/widget-core/src/widget/shadow-root.ts:121`). - **Element picker overlay 는 shadow root 밖.** picker 가 hover 요소 위에 그리는 outline 만은 일부러 `document.body` 직속에 둔다. `pointer-events: none` 으로 클릭이 통과되도록 하고, `elementFromPoint` 결과에 자기 자신이 끼지 않도록 배치해서 사용자가 "위젯이 가로막은 요소" 가 아니라 진짜 호스트 요소를 잡을 수 - 있게 한다 (`packages/react/src/picker/overlay.ts:1`). + 있게 한다 (`packages/widget-core/src/picker/overlay.ts:69`). - **Dual React tree.** 위젯 UI 는 호스트 앱의 React root 와 별개의 root 안에서 렌더된다. 위젯이 호스트의 Provider/Context 트리에 의존하지 않고, 반대로 위젯의 state 도 호스트로 새지 않는다. `mountAgentDevtools()` 가 launcher / composer / settings panel / stream renderer / picker 를 모두 자기 shadow root 위에 직접 - 올린다 (`packages/react/src/orchestrator/mount.ts:156`). + 올린다 (`packages/widget-core/src/orchestrator/mount.ts:302`). ## Launcher @@ -43,23 +43,24 @@ overlay 한 개로 구성된다. 각 조각이 호스트 페이지 DOM 안에서 - **위치와 크기.** 기본 위치는 viewport 우측 하단에서 `{ x: 24, y: 24 }` 만큼 떨어진 곳, 크기는 48px 원형. `right` / `bottom` 으로 anchor 한다 - (`packages/react/src/launcher/launcher.ts:27`). + (`packages/widget-core/src/launcher/launcher.ts:27`). - **드래그 이동 + 영구화.** 버튼을 누른 채로 드래그하면 위치를 옮길 수 있고, pointerup 시 viewport 경계로 clamp 된 좌표가 `localStorage` 에 저장된다. - 다음 mount 때 같은 자리에서 다시 떠오른다 (`launcher.ts:108`). 창 크기가 + 다음 mount 때 같은 자리에서 다시 떠오른다 + (`packages/widget-core/src/launcher/launcher.ts:119`). 창 크기가 줄어도 화면 밖으로 사라지지 않도록 mount 시점에 viewport 로 한 번 더 clamp 된다. - **Click vs drag.** pointer 입력은 순수 reducer (`launcher/state.ts`) 에서 click / drag 로 분기되고, 실제 click effect 일 때만 `onClick` 콜백이 호출된다. 드래그 끝의 합성 click 은 reducer 가 삼킨다 — composer 가 의도치 않게 열리지 - 않는다 (`launcher.ts:149`). + 않는다 (`packages/widget-core/src/launcher/launcher.ts:117`). - **클릭 동작.** orchestrator 가 `composer.element.style.display === 'none'` 으로 현재 가시성을 보고 토글한다. 열릴 때는 `composer.focus()` 까지 호출해서 - 바로 입력 가능한 상태로 만든다 (`packages/react/src/orchestrator/mount.ts:254`). + 바로 입력 가능한 상태로 만든다 (`packages/widget-core/src/orchestrator/mount.ts:379`). - **Composer 가 launcher 를 따라간다.** 드래그 중에 `onPositionChange` 가 매번 호출되며 composer 의 `setAnchor` 를 갱신한다. 패널의 우측 모서리는 launcher 의 우측 모서리와 정렬되고, 패널 하단은 launcher 위로 16px 위에 붙어 있다 - (`mount.ts:262`). + (`packages/widget-core/src/orchestrator/mount.ts:398`). - **단축키는 별도 없음.** launcher 토글 전역 단축키는 현재 없다. 키보드로 닫는 유일한 방법은 composer 가 열린 상태에서 `Escape` 를 누르는 것 (composer 만 닫힘, launcher 는 그대로) 이다. @@ -71,36 +72,36 @@ overlay 한 개로 구성된다. 각 조각이 호스트 페이지 DOM 안에서 - **기본 크기와 anchor.** 기본 패널 폭 320px, 높이 420px, 최소 280×240. 8 방향 resize handle 이 있어 사용자가 잡아당겨 키울 수 있고, 결과 크기는 `localStorage` 의 `agent-devtools:panelSize` 에 저장된다 - (`packages/react/src/composer/composer.ts:75`). + (`packages/widget-core/src/composer/composer.ts:85`). - **키보드 동작.** - `Enter` (Shift 없이) → 텍스트 비어 있지 않고 전송 중이 아니면 submit. - `Shift + Enter` → 줄바꿈. - `Escape` → composer 만 닫음 (launcher 는 유지) - (`packages/react/src/composer/composer.ts:341`). + (`packages/widget-core/src/composer/composer.ts:461`). - **Submit 페이로드.** `{ text, picked }` 형태로 orchestrator 에게 전달된다. `picked` 는 picker 가 최근에 잡아둔 `PickedEvidence` (없으면 `null`). orchestrator 가 prompt 와 `buildPageContext()` 결과를 합쳐 transport 로 - 보낸다 (`mount.ts:271`). + 보낸다 (`packages/widget-core/src/orchestrator/mount.ts:419`). - **전송 중 UI 상태.** transport 가 응답을 시작하면 `setSending(true)` 가 호출되어 textarea 와 send 버튼이 비활성화된다. 완료/실패 시 `setSending(false)`. 동시에 여러 요청이 가지 않도록 in-flight `AbortController` 가 새 submit 시 이전 - 요청을 abort 한다 (`mount.ts:282`). + 요청을 abort 한다 (`packages/widget-core/src/orchestrator/mount.ts:413`). - **스트리밍 응답.** stream renderer 가 composer 패널 안 textarea 위쪽에 insert 된다. transport 가 SSE/JSON 청크를 `MessageStore.applyEvent()` 로 흘리면 - renderer 가 그대로 그린다 (`mount.ts:188`). + renderer 가 그대로 그린다 (`packages/widget-core/src/orchestrator/mount.ts:313`). - **추가 액션.** 컴포저 헤더에는 picker 토글, settings (톱니바퀴), terminal handoff (Claude CLI 로 대화 이어받기), new conversation (세션 리셋) 버튼이 있다. new conversation 은 message store 를 비우고 transport 의 `resetSession()` - 으로 서버측 ACP 세션을 새로 발급한다 (`mount.ts:332`). + 으로 서버측 ACP 세션을 새로 발급한다 (`packages/widget-core/src/orchestrator/mount.ts:486`). ## Settings panel 톱니바퀴 버튼을 누르면 composer 본문이 stream view 에서 settings 로 슬롯 교체된다. 별도 floating dialog 가 아니라 같은 패널 안의 detail view 형태다 (React DevTools / TanStack Query DevTools 의 settings UX 와 같은 패턴) -(`packages/react/src/settings/panel.ts:1`). +(`packages/widget-core/src/settings/panel.ts:1`). -설정 항목은 두 종이다. +설정 항목은 네 종이다. - **Provider** — 다음 프롬프트를 어느 런타임이 처리할지. - `acp` — Claude Code 를 subprocess 로 띄워 ACP 프로토콜로 대화 (기본값). @@ -108,7 +109,18 @@ overlay 한 개로 구성된다. 각 조각이 호스트 페이지 DOM 안에서 서버의 `/v1/agent/info` 응답에 등록되지 않은 provider 는 라디오가 회색으로 disabled 처리되어, 422 가 뜰 조합을 사용자가 고를 수 없다 - (`packages/react/src/settings/panel.ts:152`). + (`packages/widget-core/src/settings/panel.ts:222`). + +- **Model** — 프롬프트를 처리할 모델. Claude Code 터미널의 `/model` 메뉴와 + 같은 선택지를 노출한다. + - `default` _(기본값)_ — 모델을 wire 에 싣지 않는 sentinel. 고른 provider + 가 자신의 기본 모델을 그대로 쓴다. + - `opus` / `sonnet` / `haiku` — 해당 alias 로 고정. + + 두 provider 모두 이 alias 를 공유 Claude Agent SDK resolver 로 풀어내므로 + 별도의 모델 discovery 왕복이 필요 없다. SDK provider 는 alias 를 `query()` + 의 `model` 옵션으로 넘기고, ACP provider 는 세션 성립 후 프롬프트 전에 + `session/set_model` 로 적용한다 (`packages/widget-core/src/settings/types.ts:31`). - **Permission Mode** — `requestPermission` 콜백에 대한 일괄 정책. 다섯 가지: - `default` — 모든 권한 요청 거절. @@ -116,23 +128,27 @@ overlay 한 개로 구성된다. 각 조각이 호스트 페이지 DOM 안에서 fetch 등은 별도 동의 필요. - `bypassPermissions` — 모든 권한 요청 무조건 허용. 위험도가 높아 settings panel 에서만 노출되고 chat composer 의 어떤 버튼으로도 도달할 수 없다 - (`packages/react/src/settings/types.ts:10`, - `packages/react/src/settings/panel.ts:118`). 행 자체가 빨간 배경으로 - 강조된다 (`settings/panel.ts:374`). + (`packages/widget-core/src/settings/types.ts:10`, + `packages/widget-core/src/settings/panel.ts:259`). 행 자체가 빨간 배경으로 + 강조된다 (`packages/widget-core/src/settings/panel.ts:163`). - `plan` — 읽기 전용 plan 모드. - `dontAsk` — `acceptEdits` 와 동일 허용 경로, 모든 프롬프트 표면화 금지. 자세한 의미는 [권한 모드](/guides/permission-modes/) 참고. +- **Theme** — 위젯 외관. 호스트 페이지와 무관하게 위젯 자신의 테마를 고른다. + - `auto` _(기본값)_ — OS / 호스트의 `prefers-color-scheme` 을 따라간다. + - `light` / `dark` — 라이트 / 다크 모드로 고정. + 또 한 가지 read-only 정보가 패널 하단에 표시된다. - **Workspace Root** — 서버가 보고한 워크스페이스 절대경로 (`/v1/agent/info` 의 `workspaceRoot`). 에이전트가 실제로 읽고 쓰는 루트가 어디인지를 사용자가 - 확인하는 용도 (`settings/panel.ts:133`). + 확인하는 용도 (`packages/widget-core/src/settings/panel.ts:187`). -**영구화 범위.** provider 와 permissionMode 는 `localStorage` 키 +**영구화 범위.** provider, model, permissionMode, theme 는 `localStorage` 키 `agent-devtools:settings` 에 JSON 으로 저장되어 다음 mount 까지 살아남는다 -(`packages/react/src/settings/storage.ts:11`). 패널 크기 (composer drag-resize 결과) +(`packages/widget-core/src/settings/storage.ts:22`). 패널 크기 (composer drag-resize 결과) 는 별도 키 `agent-devtools:panelSize` 에 저장된다. launcher 위치는 `agent-devtools:launcherPosition` 키 (`launcher/storage.ts`). server info (workspace root, 등록된 provider 목록) 는 매 mount 마다 다시 fetch 되며 @@ -144,22 +160,22 @@ devtools 의 Application 패널에서 해당 키를 직접 지운다. 사용자가 Pick 으로 따로 element 를 잡지 않아도, 모든 submit 에는 페이지 컨텍스트 스냅샷이 자동으로 첨부된다. orchestrator 가 submit 마다 `buildPageContext()` 를 호출해서 다음 한 묶음을 transport 페이로드에 넣는다 -(`packages/react/src/orchestrator/mount.ts:287`, -`packages/react/src/context/build.ts:33`). +(`packages/widget-core/src/orchestrator/mount.ts:419`, +`packages/widget-core/src/context/build.ts:53`). -`PageContext` 가 담는 필드 (`packages/react/src/context/types.ts:114`): +`PageContext` 가 담는 필드 (`packages/widget-core/src/context/types.ts:164`): - `schemaVersion` — 현재 `2`. 서버 prompt formatter 와의 호환성 표시. - `capturedAt` — 컨텍스트가 모인 epoch ms. - `url` — `location.href` 통째. - `route` — `{ pathname, search, hash }`. router 와 무관하게 `window.location` - 에서 추출한다 (`packages/react/src/context/route.ts:9`). + 에서 추출한다 (`packages/widget-core/src/context/route.ts:19`). - `pageFiles` — 현재 페이지의 React fiber tree 를 walk 해서 모은 component source 파일 목록 `{ fileName, componentName, lineNumber, columnNumber? }`. 중복 파일은 dedup, 최대 50개로 잘린다. `rootContainer` 옵션으로 받은 React - root 부터 fiber 를 따라간다 (`packages/react/src/context/build.ts:66`). + root 부터 fiber 를 따라간다 (`packages/react/src/context/build.ts:19`). - `errors` — `createErrorObserver()` 가 mount 시점부터 수집해 둔 콘솔 에러/예외 - 레코드의 최근 50개 (`mount.ts:237`). + 레코드의 최근 50개 (`packages/widget-core/src/orchestrator/mount.ts:354`). - `picked` — Pick 으로 잡힌 element 가 있을 때만 채워지는 `PickedEvidence` (아래 항목 참고). @@ -174,27 +190,28 @@ viewport size 는 page context 에 포함되지 않는다. 반영한다. - **State machine.** picker 는 `idle → active → picked → idle` 의 3-state - 순수 reducer 위에서 돈다 (`packages/react/src/picker/state.ts:8`). active + 순수 reducer 위에서 돈다 (`packages/widget-core/src/picker/state.ts:8`). active 중에 click 이 일어나면 곧바로 `picked` 로 전이하고 reducer 는 다시 `idle` 로 떨어진다 — **한 번에 한 element 만** 잡을 수 있는 단일 선택 모델이다. 다중 element 동시 선택은 지원하지 않는다. - **Hover 동작.** active 중에는 mousemove 마다 `document.elementFromPoint` 로 pointer 아래 요소를 잡고, overlay 가 그 위에 outline 을 그린다. overlay 는 `pointer-events: none` 이라 hit-test 결과에 자기 자신이 끼지 않는다 - (`packages/react/src/picker/picker.ts:99`). + (`packages/widget-core/src/picker/picker.ts:102`). - **Click 으로 확정.** active 상태에서의 click 은 `preventDefault` + `stopPropagation` 으로 호스트 앱에 전달되지 않는다. orchestrator 의 `onPick` 콜백이 element 를 받아 `describePicked()` 로 `PickedEvidence` 를 - 만들고 composer 의 picked chip 으로 노출한다 (`mount.ts:240`). + 만들고 composer 의 picked chip 으로 노출한다 + (`packages/widget-core/src/orchestrator/mount.ts:359`). - **Escape 로 취소.** active 중 Escape 키는 picker 를 cancel 시키고 idle 로 - 복귀시킨다 (`picker.ts:92`). + 복귀시킨다 (`packages/widget-core/src/picker/picker.ts:93`). - **Picker 가 위젯 자체를 잡지 않게.** picker 시작 시점에 widget shadow host 와 그 하위 요소는 `shouldSkip` 으로 걸러진다. picker 가 자기 자신을 잡는 - 사고를 막는다 (`picker.ts:33`). + 사고를 막는다 (`packages/widget-core/src/picker/picker.ts:33`). 확정된 `PickedEvidence` 는 단순한 메타데이터가 아니라 evidence-grade 스냅샷이다 -(`packages/react/src/context/picked.ts:47`, -`packages/react/src/context/types.ts:62`): +(`packages/react/src/context/picked.ts:52`, +`packages/widget-core/src/context/types.ts:79`): - **Identity** — `componentName`, `tagName`, 최선 노력의 CSS `selector`, JSX `__source` pragma 에서 뽑은 `{ fileName, lineNumber, columnNumber? }`. diff --git a/packages/angular/CHANGELOG.md b/packages/angular/CHANGELOG.md index 8cb00e1..8106c15 100644 --- a/packages/angular/CHANGELOG.md +++ b/packages/angular/CHANGELOG.md @@ -1,5 +1,13 @@ # @agent-devtools/angular +## 1.1.0-beta.0 + +### Patch Changes + +- Updated dependencies [[`4cdbe4b`](https://github.com/Seungwoo321/agent-devtools/commit/4cdbe4b2e2103c015dd8fda2278ce683c1ece0a5), [`b621331`](https://github.com/Seungwoo321/agent-devtools/commit/b621331110dac125484d223b0e9aee3b82ab052d), [`6317aa3`](https://github.com/Seungwoo321/agent-devtools/commit/6317aa3fdc501738aa89fcae6a660384e3f7bc15), [`cd230a6`](https://github.com/Seungwoo321/agent-devtools/commit/cd230a6de9ce4ac267ef18446edaea75bc56ddd2), [`3fbaf3b`](https://github.com/Seungwoo321/agent-devtools/commit/3fbaf3b611760793a2932955f4a5ebd70f3bb70b), [`3fbaf3b`](https://github.com/Seungwoo321/agent-devtools/commit/3fbaf3b611760793a2932955f4a5ebd70f3bb70b)]: + - @agent-devtools/core@1.1.0-beta.0 + - @agent-devtools/widget-core@1.1.0-beta.0 + ## 1.0.0 ### Patch Changes diff --git a/packages/angular/package.json b/packages/angular/package.json index 94a8a3f..d8b2f40 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@agent-devtools/angular", - "version": "1.0.0", + "version": "1.1.0-beta.0", "description": "Angular adapter for agent-devtools — Ivy component walker + DOM picker + closed Shadow DOM widget", "keywords": [ "agent-devtools", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 41e21a3..0066496 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## 1.1.0-beta.0 + +### Minor Changes + +- [#11](https://github.com/Seungwoo321/agent-devtools/pull/11) [`4cdbe4b`](https://github.com/Seungwoo321/agent-devtools/commit/4cdbe4b2e2103c015dd8fda2278ce683c1ece0a5) Thanks [@Seungwoo321](https://github.com/Seungwoo321)! - Add model selection so a prompt can run on the same models the Claude Code + terminal offers. A new `model` setting exposes the terminal's `/model` menu — + `default`, `opus`, `sonnet`, `haiku` — in the settings panel, persists in + localStorage alongside the provider, permission mode and theme, and rides on + each request body. `default` is a sentinel that sends no model on the wire, so + the chosen provider keeps its own default exactly as it does today. + + Both providers resolve the alias through the shared Claude Agent SDK resolver, + so no live model-discovery round-trip is needed. The SDK provider forwards the + alias as the `query()` `model` option. The ACP provider applies it with + `session/set_model` after the session is established and before the prompt is + dispatched; it remembers the last applied model per session to skip a redundant + round-trip when the model is unchanged across turns, and surfaces an error + (rather than silently running on the wrong model) if the agent rejects the + request. The server validates only that `model` is a non-empty string and + forwards it verbatim, leaving the model set open for full date-pinned ids or + future tiers without a protocol change. + +### Patch Changes + +- [#11](https://github.com/Seungwoo321/agent-devtools/pull/11) [`6317aa3`](https://github.com/Seungwoo321/agent-devtools/commit/6317aa3fdc501738aa89fcae6a660384e3f7bc15) Thanks [@Seungwoo321](https://github.com/Seungwoo321)! - Fix the in-process SDK provider being rejected with "API Error: 400 role 'system' is not supported on this model". The provider omitted `systemPrompt`, so the Claude Agent SDK fell back to its minimal default prompt instead of the full Claude Code prompt that `claude -p` uses by default. It now opts into the `claude_code` preset, restoring terminal parity, and pins `settingSources` so project `CLAUDE.md` context cannot be silently dropped by a future SDK default change. + ## 1.0.0 ### Major Changes diff --git a/packages/core/package.json b/packages/core/package.json index 0b2a732..e577afd 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@agent-devtools/core", - "version": "1.0.0", + "version": "1.1.0-beta.0", "description": "Framework-agnostic core for agent-devtools — server, agent engine, widget shell", "keywords": [ "agent-devtools", diff --git a/packages/core/src/providers/acp-runtime.test.ts b/packages/core/src/providers/acp-runtime.test.ts index 3992611..aefe783 100644 Binary files a/packages/core/src/providers/acp-runtime.test.ts and b/packages/core/src/providers/acp-runtime.test.ts differ diff --git a/packages/core/src/providers/acp-runtime.ts b/packages/core/src/providers/acp-runtime.ts index 0c66455..f56732c 100644 --- a/packages/core/src/providers/acp-runtime.ts +++ b/packages/core/src/providers/acp-runtime.ts @@ -192,6 +192,13 @@ interface SessionEntry { current: RunState | null; /** Resolves when the previous prompt on this session finishes, so the next can start. */ lastDone: Promise; + /** + * Last model applied to this session via `session/set_model`, or undefined + * if none has been applied (the session runs on the agent's default). Used + * to skip a redundant `set_model` round-trip when the requested model is + * unchanged across turns. + */ + appliedModel?: string; } /** @@ -291,6 +298,25 @@ class AcpChild { return; } + // Apply the requested model before the prompt. `session/set_model` + // mutates shared session state, so it runs inside the per-session + // serialization (after `await previous`) and only when the model + // actually changes, to avoid a redundant round-trip every turn. The + // agent resolves aliases like `opus` to canonical ids. A failure is + // surfaced rather than silently running the prompt on the wrong model. + if (params.model !== undefined && params.model !== entry.appliedModel) { + try { + await this.conn.unstable_setSessionModel({ + sessionId: entry.acpSessionId, + modelId: params.model, + }); + entry.appliedModel = params.model; + } catch (error) { + yield { kind: 'error', error: toErrorPayload(error) }; + return; + } + } + const queue = new EventQueue(); entry.current = { queue, diff --git a/packages/core/src/providers/acp.test.ts b/packages/core/src/providers/acp.test.ts index 8bd564c..af45bf4 100644 --- a/packages/core/src/providers/acp.test.ts +++ b/packages/core/src/providers/acp.test.ts @@ -217,6 +217,45 @@ describe('createAcpProvider', () => { } }); + it('forwards a context model to the runtime params', async () => { + const ws = makeWorkspace(); + try { + const seen: Array = []; + const runtime: AcpRuntime = { + run: async function* (params): AsyncIterable { + seen.push(params.model); + yield { kind: 'result', stopReason: 'end_turn' }; + }, + }; + const provider = createAcpProvider({ runtime }); + await collect(provider({ prompt: 'p' }, makeCtx({ workspace: ws, model: 'opus' }))); + expect(seen).toEqual(['opus']); + } finally { + (ws as Workspace & { [Symbol.dispose]: () => void })[Symbol.dispose](); + } + }); + + it('omits model from runtime params when the context carries none', async () => { + const ws = makeWorkspace(); + try { + let seenModel: unknown = 'not-called'; + let modelWasInParams = true; + const runtime: AcpRuntime = { + run: async function* (params): AsyncIterable { + seenModel = params.model; + modelWasInParams = 'model' in params; + yield { kind: 'result', stopReason: 'end_turn' }; + }, + }; + const provider = createAcpProvider({ runtime }); + await collect(provider({ prompt: 'p' }, makeCtx({ workspace: ws }))); + expect(seenModel).toBeUndefined(); + expect(modelWasInParams).toBe(false); + } finally { + (ws as Workspace & { [Symbol.dispose]: () => void })[Symbol.dispose](); + } + }); + it('lets a request-scoped permissionPolicy override the provider default', async () => { const ws = makeWorkspace(); try { diff --git a/packages/core/src/providers/acp.ts b/packages/core/src/providers/acp.ts index 5e2c605..a78723a 100644 --- a/packages/core/src/providers/acp.ts +++ b/packages/core/src/providers/acp.ts @@ -141,6 +141,15 @@ export interface AcpRunParams { * follow-up Read tool call. Omitted when no workspace is configured. */ files?: FileTools; + /** + * Model to run this turn on, e.g. an alias (`'opus'`, `'sonnet'`, + * `'haiku'`) or a full model id. The runtime applies it with + * `session/set_model` before the prompt; the agent resolves aliases to + * canonical ids. Omitted means "keep the session's current model" — for a + * fresh session that is the agent's default, matching the widget's + * `Default` choice. + */ + model?: string; /** Aborts when the HTTP client disconnects. The runtime must propagate this. */ signal: AbortSignal; } @@ -192,6 +201,7 @@ export function createAcpProvider(options: CreateAcpProviderOptions = {}): Agent ...(effectivePolicy !== undefined && { permissionPolicy: effectivePolicy }), ...(request.context !== undefined && { context: request.context }), ...(context.files !== undefined && { files: context.files }), + ...(context.model !== undefined && { model: context.model }), signal: context.signal, }); diff --git a/packages/core/src/providers/sdk.test.ts b/packages/core/src/providers/sdk.test.ts index 6dc0a41..f8c3c7f 100644 --- a/packages/core/src/providers/sdk.test.ts +++ b/packages/core/src/providers/sdk.test.ts @@ -97,6 +97,49 @@ describe('createSdkProvider', () => { expect(seen?.options?.abortController).toBeInstanceOf(AbortController); }); + it('sends the claude_code preset and pinned setting sources for terminal parity', async () => { + // Regression guard for the "400 role 'system' is not supported on this + // model" error: omitting systemPrompt makes the SDK use its minimal + // default instead of the full Claude Code prompt the terminal uses. + let seen: { prompt: string; options?: SdkOptions } | undefined; + const query = vi.fn((params: { prompt: string; options?: SdkOptions }) => { + seen = params; + return makeQuery([{ type: 'result' }]); + }); + const provider = createSdkProvider({ query }); + + await collect(provider({ prompt: 'hello' }, makeCtx())); + + expect(seen?.options?.systemPrompt).toEqual({ type: 'preset', preset: 'claude_code' }); + expect(seen?.options?.settingSources).toEqual(['user', 'project', 'local']); + }); + + it('forwards a context model to the SDK options', async () => { + let seen: SdkOptions | undefined; + const query = vi.fn((params: { prompt: string; options?: SdkOptions }) => { + seen = params.options; + return makeQuery([{ type: 'result' }]); + }); + const provider = createSdkProvider({ query }); + + await collect(provider({ prompt: 'p' }, makeCtx({ model: 'opus' }))); + + expect(seen?.model).toBe('opus'); + }); + + it('omits model from the SDK options when the context carries none', async () => { + let seen: SdkOptions | undefined; + const query = vi.fn((params: { prompt: string; options?: SdkOptions }) => { + seen = params.options; + return makeQuery([{ type: 'result' }]); + }); + const provider = createSdkProvider({ query }); + + await collect(provider({ prompt: 'p' }, makeCtx())); + + expect(seen?.model).toBeUndefined(); + }); + it("sets allowDangerouslySkipPermissions when permissionMode is 'bypassPermissions'", async () => { let seen: SdkOptions | undefined; const query = vi.fn((params: { prompt: string; options?: SdkOptions }) => { diff --git a/packages/core/src/providers/sdk.ts b/packages/core/src/providers/sdk.ts index 87bdf49..e16f489 100644 --- a/packages/core/src/providers/sdk.ts +++ b/packages/core/src/providers/sdk.ts @@ -81,6 +81,26 @@ export function createSdkProvider(options: CreateSdkProviderOptions = {}): Agent const sdkOptions: SdkOptions = { abortController: controller, + // Terminal parity. `claude -p` runs with the full Claude Code system + // prompt; the Agent SDK, when `systemPrompt` is omitted, falls back to a + // *minimal* default that drops Claude Code's identity. On the + // subscription / model endpoint that minimal request is rejected with + // "400 role 'system' is not supported on this model" — the exact error + // dogfooding surfaced. Opting into the `claude_code` preset sends the + // same prompt the terminal sends, restoring parity. + systemPrompt: { type: 'preset', preset: 'claude_code' }, + // Pin the filesystem setting sources. The SDK default already loads all + // sources (matching the CLI), but that default is version-dependent; + // were it ever flipped to isolation mode (`[]`), the project CLAUDE.md + // context would silently vanish and dogfooding parity would regress. + // `'project'` is required for CLAUDE.md to load. + settingSources: ['user', 'project', 'local'], + // Terminal-parity model selection. The SDK `model` option takes the same + // aliases the terminal's `/model` menu uses (`opus`, `sonnet`, `haiku`) + // or a full model id, and resolves them against the account's real + // models. Omitted when the request carries no model, so the SDK falls + // back to the CLI default — matching the widget's `Default` choice. + ...(context.model !== undefined && { model: context.model }), permissionMode: context.permissionMode, ...(context.permissionMode === 'bypassPermissions' && { allowDangerouslySkipPermissions: true, diff --git a/packages/core/src/server/app.test.ts b/packages/core/src/server/app.test.ts index 4c9128d..0801e86 100644 --- a/packages/core/src/server/app.test.ts +++ b/packages/core/src/server/app.test.ts @@ -780,6 +780,65 @@ describe('createApp', () => { }); }); + describe('model routing', () => { + it('forwards a request model to the factory context', async () => { + let seen: string | undefined; + const factory: AgentStreamFactory = async function* (_req, ctx) { + seen = ctx.model; + yield { type: 'complete' }; + }; + const app = await startApp(factory); + const { events, readResponse } = postStream(`${app.url}/v1/agent/stream`, { + prompt: 'x', + model: 'opus', + }); + await readResponse; + await events; + expect(seen).toBe('opus'); + }); + + it('leaves model undefined on the context when the request omits one', async () => { + let seen: string | undefined = 'sentinel'; + const factory: AgentStreamFactory = async function* (_req, ctx) { + seen = ctx.model; + yield { type: 'complete' }; + }; + const app = await startApp(factory); + const { events, readResponse } = postStream(`${app.url}/v1/agent/stream`, { + prompt: 'x', + }); + await readResponse; + await events; + expect(seen).toBeUndefined(); + }); + + it('returns 400 when model is an empty string', async () => { + const factory: AgentStreamFactory = async function* () { + yield 'unreachable'; + }; + const app = await startApp(factory); + const res = await postJson(`${app.url}/v1/agent/stream`, { + prompt: 'x', + model: '', + }); + expect(res.status).toBe(400); + expect((res.body as { error: string }).error).toMatch(/model must be a non-empty string/); + }); + + it('returns 400 when model is not a string', async () => { + const factory: AgentStreamFactory = async function* () { + yield 'unreachable'; + }; + const app = await startApp(factory); + const res = await postJson(`${app.url}/v1/agent/stream`, { + prompt: 'x', + model: 42, + }); + expect(res.status).toBe(400); + expect((res.body as { error: string }).error).toMatch(/model must be a non-empty string/); + }); + }); + describe('POST /v1/agent/handoff', () => { function makeRecorder(): { writeHandoffArtifact: ( diff --git a/packages/core/src/server/app.ts b/packages/core/src/server/app.ts index 3a53e41..f629e43 100644 --- a/packages/core/src/server/app.ts +++ b/packages/core/src/server/app.ts @@ -72,6 +72,17 @@ export interface AgentStreamRequest { * the dev server. */ permissionPolicy?: PermissionPolicy; + /** + * Model the runtime should use for this turn, e.g. a Claude Code alias + * (`'opus'`, `'sonnet'`, `'haiku'`) or a full model id. Both providers + * resolve aliases against the account's real models through the same + * Claude Agent SDK resolver the terminal uses, so the widget's model menu + * mirrors the terminal's. Omitted means "use the provider's default model" + * — the widget sends nothing for its `Default` choice. Non-widget callers + * may pass any model string; the server validates only that it is a + * non-empty string and leaves semantic resolution to the provider. + */ + model?: string; } const PERMISSION_POLICY_KEYS: readonly (keyof PermissionPolicy)[] = [ @@ -104,6 +115,12 @@ export interface AgentRequestContext { * provider then falls back to its own safe-by-default policy. */ permissionPolicy?: PermissionPolicy; + /** + * Resolved model for this turn, forwarded verbatim to the provider. Present + * when the request body carried a non-empty `model`; absent otherwise, in + * which case the provider uses its own default model. + */ + model?: string; } export type AgentStreamFactory = ( @@ -285,6 +302,21 @@ export function createApp(options: AppOptions = {}) { resolvedPolicy = defaultPermissionPolicy; } + // The model set is open (aliases, full date-pinned ids, future tiers) and + // each provider resolves semantics itself, so the server validates only + // the shape: a non-empty string. An empty or non-string `model` is a + // malformed request rather than an unsupported value, hence 400. + let resolvedModel: string | undefined; + if (body.model !== undefined) { + if (typeof body.model !== 'string' || body.model.length === 0) { + res.statusCode = 400; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: 'model must be a non-empty string' })); + return; + } + resolvedModel = body.model; + } + const writer = startSse(res); const iterable = factory(body, { signal, @@ -292,6 +324,7 @@ export function createApp(options: AppOptions = {}) { ...(files !== undefined && { files }), permissionMode: requestedPermissionMode, ...(resolvedPolicy !== undefined && { permissionPolicy: resolvedPolicy }), + ...(resolvedModel !== undefined && { model: resolvedModel }), }); await pumpToSse(writer, iterable, toEvent ? { signal, toEvent } : { signal }); }; diff --git a/packages/harness-core/CHANGELOG.md b/packages/harness-core/CHANGELOG.md index a582062..73393ce 100644 --- a/packages/harness-core/CHANGELOG.md +++ b/packages/harness-core/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## 1.1.0-beta.0 + ## 1.0.0 ## 1.0.0-beta.1 diff --git a/packages/harness-core/package.json b/packages/harness-core/package.json index 8c1da53..1c05530 100644 --- a/packages/harness-core/package.json +++ b/packages/harness-core/package.json @@ -1,6 +1,6 @@ { "name": "@agent-devtools/harness-core", - "version": "1.0.0", + "version": "1.1.0-beta.0", "description": "Generic agent harness — domain-agnostic loop strategies + LLM provider abstraction. Source for both @agent-devtools and external SaaS consumers.", "license": "MIT", "type": "module", diff --git a/packages/html/CHANGELOG.md b/packages/html/CHANGELOG.md index 5ef193b..49bb22e 100644 --- a/packages/html/CHANGELOG.md +++ b/packages/html/CHANGELOG.md @@ -1,5 +1,13 @@ # @agent-devtools/html +## 1.1.0-beta.0 + +### Patch Changes + +- Updated dependencies [[`4cdbe4b`](https://github.com/Seungwoo321/agent-devtools/commit/4cdbe4b2e2103c015dd8fda2278ce683c1ece0a5), [`b621331`](https://github.com/Seungwoo321/agent-devtools/commit/b621331110dac125484d223b0e9aee3b82ab052d), [`cd230a6`](https://github.com/Seungwoo321/agent-devtools/commit/cd230a6de9ce4ac267ef18446edaea75bc56ddd2), [`3fbaf3b`](https://github.com/Seungwoo321/agent-devtools/commit/3fbaf3b611760793a2932955f4a5ebd70f3bb70b), [`3fbaf3b`](https://github.com/Seungwoo321/agent-devtools/commit/3fbaf3b611760793a2932955f4a5ebd70f3bb70b)]: + - @agent-devtools/widget-core@1.1.0-beta.0 + - @agent-devtools/vite@1.1.0-beta.0 + ## 1.0.0 ### Patch Changes diff --git a/packages/html/package.json b/packages/html/package.json index 735e2bc..9d4e2a9 100644 --- a/packages/html/package.json +++ b/packages/html/package.json @@ -1,6 +1,6 @@ { "name": "@agent-devtools/html", - "version": "1.0.0", + "version": "1.1.0-beta.0", "description": "npx runner for agent-devtools — serve a plain HTML folder with the dev-only widget injected, no framework, no config", "keywords": [ "agent-devtools", diff --git a/packages/next-pages/CHANGELOG.md b/packages/next-pages/CHANGELOG.md index 5190ae3..f493594 100644 --- a/packages/next-pages/CHANGELOG.md +++ b/packages/next-pages/CHANGELOG.md @@ -1,5 +1,13 @@ # @agent-devtools/next-pages +## 1.1.0-beta.0 + +### Patch Changes + +- Updated dependencies [[`4cdbe4b`](https://github.com/Seungwoo321/agent-devtools/commit/4cdbe4b2e2103c015dd8fda2278ce683c1ece0a5), [`6317aa3`](https://github.com/Seungwoo321/agent-devtools/commit/6317aa3fdc501738aa89fcae6a660384e3f7bc15)]: + - @agent-devtools/core@1.1.0-beta.0 + - @agent-devtools/react@1.1.0-beta.0 + ## 1.0.0 ### Patch Changes diff --git a/packages/next-pages/package.json b/packages/next-pages/package.json index 16b581e..8b88828 100644 --- a/packages/next-pages/package.json +++ b/packages/next-pages/package.json @@ -1,6 +1,6 @@ { "name": "@agent-devtools/next-pages", - "version": "1.0.0", + "version": "1.1.0-beta.0", "description": "Next.js Pages Router adapter for agent-devtools — wraps the React widget for legacy pages/_app.tsx hosts", "keywords": [ "agent-devtools", diff --git a/packages/next/CHANGELOG.md b/packages/next/CHANGELOG.md index 7126e86..7733554 100644 --- a/packages/next/CHANGELOG.md +++ b/packages/next/CHANGELOG.md @@ -1,5 +1,13 @@ # @agent-devtools/next +## 1.1.0-beta.0 + +### Patch Changes + +- Updated dependencies [[`4cdbe4b`](https://github.com/Seungwoo321/agent-devtools/commit/4cdbe4b2e2103c015dd8fda2278ce683c1ece0a5), [`6317aa3`](https://github.com/Seungwoo321/agent-devtools/commit/6317aa3fdc501738aa89fcae6a660384e3f7bc15)]: + - @agent-devtools/core@1.1.0-beta.0 + - @agent-devtools/react@1.1.0-beta.0 + ## 1.0.0 ### Patch Changes diff --git a/packages/next/package.json b/packages/next/package.json index 745632c..29934e4 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "@agent-devtools/next", - "version": "1.0.0", + "version": "1.1.0-beta.0", "description": "Next.js 15 adapter for agent-devtools — re-exports the React widget + adds App / Pages router dev-only injection", "keywords": [ "agent-devtools", diff --git a/packages/nuxt/CHANGELOG.md b/packages/nuxt/CHANGELOG.md index 9386e30..1462fa1 100644 --- a/packages/nuxt/CHANGELOG.md +++ b/packages/nuxt/CHANGELOG.md @@ -1,5 +1,14 @@ # @agent-devtools/nuxt +## 1.1.0-beta.0 + +### Patch Changes + +- Updated dependencies [[`4cdbe4b`](https://github.com/Seungwoo321/agent-devtools/commit/4cdbe4b2e2103c015dd8fda2278ce683c1ece0a5), [`b621331`](https://github.com/Seungwoo321/agent-devtools/commit/b621331110dac125484d223b0e9aee3b82ab052d), [`6317aa3`](https://github.com/Seungwoo321/agent-devtools/commit/6317aa3fdc501738aa89fcae6a660384e3f7bc15), [`cd230a6`](https://github.com/Seungwoo321/agent-devtools/commit/cd230a6de9ce4ac267ef18446edaea75bc56ddd2), [`3fbaf3b`](https://github.com/Seungwoo321/agent-devtools/commit/3fbaf3b611760793a2932955f4a5ebd70f3bb70b), [`3fbaf3b`](https://github.com/Seungwoo321/agent-devtools/commit/3fbaf3b611760793a2932955f4a5ebd70f3bb70b)]: + - @agent-devtools/core@1.1.0-beta.0 + - @agent-devtools/widget-core@1.1.0-beta.0 + - @agent-devtools/vue@1.1.0-beta.0 + ## 1.0.0 ### Patch Changes diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 029da26..208f67b 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -1,6 +1,6 @@ { "name": "@agent-devtools/nuxt", - "version": "1.0.0", + "version": "1.1.0-beta.0", "description": "Nuxt 3 module for agent-devtools — re-exports the Vue widget + adds dev-only injection through defineNuxtModule", "keywords": [ "agent-devtools", diff --git a/packages/nuxt2/CHANGELOG.md b/packages/nuxt2/CHANGELOG.md index 50cf4ea..3375959 100644 --- a/packages/nuxt2/CHANGELOG.md +++ b/packages/nuxt2/CHANGELOG.md @@ -1,5 +1,14 @@ # @agent-devtools/nuxt2 +## 1.1.0-beta.0 + +### Patch Changes + +- Updated dependencies [[`4cdbe4b`](https://github.com/Seungwoo321/agent-devtools/commit/4cdbe4b2e2103c015dd8fda2278ce683c1ece0a5), [`b621331`](https://github.com/Seungwoo321/agent-devtools/commit/b621331110dac125484d223b0e9aee3b82ab052d), [`6317aa3`](https://github.com/Seungwoo321/agent-devtools/commit/6317aa3fdc501738aa89fcae6a660384e3f7bc15), [`cd230a6`](https://github.com/Seungwoo321/agent-devtools/commit/cd230a6de9ce4ac267ef18446edaea75bc56ddd2), [`3fbaf3b`](https://github.com/Seungwoo321/agent-devtools/commit/3fbaf3b611760793a2932955f4a5ebd70f3bb70b), [`3fbaf3b`](https://github.com/Seungwoo321/agent-devtools/commit/3fbaf3b611760793a2932955f4a5ebd70f3bb70b)]: + - @agent-devtools/core@1.1.0-beta.0 + - @agent-devtools/widget-core@1.1.0-beta.0 + - @agent-devtools/vue2@1.1.0-beta.0 + ## 1.0.0 ### Patch Changes diff --git a/packages/nuxt2/package.json b/packages/nuxt2/package.json index b1d0989..3b9798c 100644 --- a/packages/nuxt2/package.json +++ b/packages/nuxt2/package.json @@ -1,6 +1,6 @@ { "name": "@agent-devtools/nuxt2", - "version": "1.0.0", + "version": "1.1.0-beta.0", "description": "Nuxt 2 module for agent-devtools — re-exports the Vue 2 widget + adds dev-only injection through the Nuxt 2 module API", "keywords": [ "agent-devtools", diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index c54e24c..1db7eeb 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.1.0-beta.0 + +### Patch Changes + +- Updated dependencies [[`4cdbe4b`](https://github.com/Seungwoo321/agent-devtools/commit/4cdbe4b2e2103c015dd8fda2278ce683c1ece0a5), [`b621331`](https://github.com/Seungwoo321/agent-devtools/commit/b621331110dac125484d223b0e9aee3b82ab052d), [`6317aa3`](https://github.com/Seungwoo321/agent-devtools/commit/6317aa3fdc501738aa89fcae6a660384e3f7bc15), [`cd230a6`](https://github.com/Seungwoo321/agent-devtools/commit/cd230a6de9ce4ac267ef18446edaea75bc56ddd2), [`3fbaf3b`](https://github.com/Seungwoo321/agent-devtools/commit/3fbaf3b611760793a2932955f4a5ebd70f3bb70b), [`3fbaf3b`](https://github.com/Seungwoo321/agent-devtools/commit/3fbaf3b611760793a2932955f4a5ebd70f3bb70b)]: + - @agent-devtools/core@1.1.0-beta.0 + - @agent-devtools/widget-core@1.1.0-beta.0 + ## 1.0.0 ### Patch Changes diff --git a/packages/react/README.ko.md b/packages/react/README.ko.md index 8a8eaaf..6cbb91a 100644 --- a/packages/react/README.ko.md +++ b/packages/react/README.ko.md @@ -80,18 +80,19 @@ if (import.meta.env.DEV) { ### `createDefaultTransport(options)` -| 옵션 | 타입 | 기본값 | 설명 | -| --------------------------- | ------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `baseUrl` | `string` | (필수) | 에이전트 서버 origin. 예: `http://127.0.0.1:4317`. | -| `pairingToken` | `string` | (필수) | 에이전트 서버가 시작 시 발급한 Bearer 토큰. | -| `fetch` | `typeof fetch` | `globalThis.fetch` | 커스텀 fetch 구현 (테스트, SSR shim 등). | -| `getSettings` | `() => SettingsSnapshot \| undefined` | (없음) | 설정 store 에서 `provider`, `model`, `permissionMode` 를 읽는 함수. | -| `sessionIdStorage` | `Storage \| 'memory'` | `sessionStorage` | ACP 세션 ID 가 저장될 스토리지. | -| `sessionIdStorageKey` | `string` | `agent-devtools:sid` | ACP 세션 ID 의 스토리지 키. | -| `generateSessionId` | `() => string` | `crypto.randomUUID` | 세션 ID 생성 함수. | -| `streamSilentMs` | `number` | `60_000` | 리더가 이 시간 이상 침묵하면 스트림을 abort 하고 `StreamSilentError` 로 reject. `0` 이면 비활성화. | -| `preResponseRetries` | `number` | `1` | 첫 fetch 가 Response 도착 전 네트워크 에러로 reject 될 때 추가 시도 횟수. AbortError 와 HTTP 에러는 재시도 안 함. | -| `preResponseRetryBackoffMs` | `number` | `300` | 실패한 첫 fetch 와 재시도 사이 대기 시간. | +| 옵션 | 타입 | 기본값 | 설명 | +| ------------------------------ | ------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `baseUrl` | `string` | (필수) | 에이전트 서버 origin. 예: `http://127.0.0.1:4317`. | +| `pairingToken` | `string` | (필수) | 에이전트 서버가 시작 시 발급한 Bearer 토큰. | +| `fetch` | `typeof fetch` | `globalThis.fetch` | 커스텀 fetch 구현 (테스트, SSR shim 등). | +| `getSettings` | `() => SettingsSnapshot \| undefined` | (없음) | 설정 store 에서 `provider`, `model`, `permissionMode` 를 읽는 함수. | +| `sessionIdStorage` | `Storage \| 'memory'` | `sessionStorage` | ACP 세션 ID 가 저장될 스토리지. | +| `sessionIdStorageKey` | `string` | `agent-devtools:sid` | ACP 세션 ID 의 스토리지 키. | +| `generateSessionId` | `() => string` | `crypto.randomUUID` | 세션 ID 생성 함수. | +| `streamSilentMs` | `number` | `60_000` | 리더가 이 시간 이상 침묵하면 스트림을 abort 하고 `StreamSilentError` 로 reject. `0` 이면 비활성화. | +| `preResponseRetries` | `number` | `4` | agent 에 도달하지 못한 실패의 재시도 횟수 — Response 도착 전 `fetch` reject, 또는 dev 서버 proxy 의 `503` "agent not ready"(hot reload 직후 respawn 창). AbortError, 끊긴 `2xx` 스트림, 그 외 HTTP 에러는 재시도 안 함. | +| `preResponseRetryBackoffMs` | `number` | `300` | 재시도 사이 기본 backoff. 지수적으로 증가(`base · 2^(n-1)`)하며 `preResponseRetryMaxBackoffMs` 로 상한. | +| `preResponseRetryMaxBackoffMs` | `number` | `2000` | 단일 backoff 대기의 상한. 전체 재시도 창을 제한(기본 4회 재시도 기준 ~4.1s). | 트랜스포트는 브라우저 탭마다 하나의 ACP 세션을 유지하고, 새로고침 후에도 이어집니다. `sessionStorage` 가 탭 스코프라 다른 탭에서는 새 세션 ID 를 발급합니다. diff --git a/packages/react/README.md b/packages/react/README.md index 526c8d6..a332a16 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -91,18 +91,19 @@ if (import.meta.env.DEV) { ### `createDefaultTransport(options)` -| Option | Type | Default | Description | -| --------------------------- | ------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `baseUrl` | `string` | (required) | Agent server origin, e.g. `http://127.0.0.1:4317`. | -| `pairingToken` | `string` | (required) | Bearer token minted by the agent server at startup. | -| `fetch` | `typeof fetch` | `globalThis.fetch` | Custom fetch implementation (testing, SSR shim). | -| `getSettings` | `() => SettingsSnapshot \| undefined` | (none) | Reads the current `provider`, `model`, and `permissionMode` from a settings store. | -| `sessionIdStorage` | `Storage \| 'memory'` | `sessionStorage` | Where the per-tab ACP session id is persisted. | -| `sessionIdStorageKey` | `string` | `agent-devtools:sid` | Storage key for the ACP session id. | -| `generateSessionId` | `() => string` | `crypto.randomUUID` | Custom session id minter. | -| `streamSilentMs` | `number` | `60_000` | Reader silence past this many ms aborts the stream and rejects with `StreamSilentError`. Pass `0` to disable. | -| `preResponseRetries` | `number` | `1` | Extra fetch attempts when the initial request rejects with a network error before any Response. Never retries aborts or HTTP errors. | -| `preResponseRetryBackoffMs` | `number` | `300` | Delay between the failed initial fetch and the retry attempt. | +| Option | Type | Default | Description | +| ------------------------------ | ------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `baseUrl` | `string` | (required) | Agent server origin, e.g. `http://127.0.0.1:4317`. | +| `pairingToken` | `string` | (required) | Bearer token minted by the agent server at startup. | +| `fetch` | `typeof fetch` | `globalThis.fetch` | Custom fetch implementation (testing, SSR shim). | +| `getSettings` | `() => SettingsSnapshot \| undefined` | (none) | Reads the current `provider`, `model`, and `permissionMode` from a settings store. | +| `sessionIdStorage` | `Storage \| 'memory'` | `sessionStorage` | Where the per-tab ACP session id is persisted. | +| `sessionIdStorageKey` | `string` | `agent-devtools:sid` | Storage key for the ACP session id. | +| `generateSessionId` | `() => string` | `crypto.randomUUID` | Custom session id minter. | +| `streamSilentMs` | `number` | `60_000` | Reader silence past this many ms aborts the stream and rejects with `StreamSilentError`. Pass `0` to disable. | +| `preResponseRetries` | `number` | `4` | Retry attempts for failures that never reached the agent — a `fetch` rejection before any Response, or a `503` "agent not ready" from the dev-server proxy (the respawn window after a hot reload). Never retries aborts, a dropped `2xx` stream, or other HTTP errors. | +| `preResponseRetryBackoffMs` | `number` | `300` | Base backoff between retries; grows exponentially (`base · 2^(n-1)`), capped by `preResponseRetryMaxBackoffMs`. | +| `preResponseRetryMaxBackoffMs` | `number` | `2000` | Upper bound on a single backoff wait, keeping the total retry window bounded (~4.1s across the four default retries). | The transport keeps one ACP session per browser tab and resumes it after a reload. A second tab gets a fresh id because `sessionStorage` is tab-scoped. diff --git a/packages/react/package.json b/packages/react/package.json index 92b0dad..54314bf 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@agent-devtools/react", - "version": "1.0.0", + "version": "1.1.0-beta.0", "description": "React 19 adapter for agent-devtools — fiber walker + DOM picker + closed Shadow DOM widget", "keywords": [ "agent-devtools", diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index abefbc9..783b77f 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,13 @@ # @agent-devtools/svelte +## 1.1.0-beta.0 + +### Patch Changes + +- Updated dependencies [[`4cdbe4b`](https://github.com/Seungwoo321/agent-devtools/commit/4cdbe4b2e2103c015dd8fda2278ce683c1ece0a5), [`b621331`](https://github.com/Seungwoo321/agent-devtools/commit/b621331110dac125484d223b0e9aee3b82ab052d), [`6317aa3`](https://github.com/Seungwoo321/agent-devtools/commit/6317aa3fdc501738aa89fcae6a660384e3f7bc15), [`cd230a6`](https://github.com/Seungwoo321/agent-devtools/commit/cd230a6de9ce4ac267ef18446edaea75bc56ddd2), [`3fbaf3b`](https://github.com/Seungwoo321/agent-devtools/commit/3fbaf3b611760793a2932955f4a5ebd70f3bb70b), [`3fbaf3b`](https://github.com/Seungwoo321/agent-devtools/commit/3fbaf3b611760793a2932955f4a5ebd70f3bb70b)]: + - @agent-devtools/core@1.1.0-beta.0 + - @agent-devtools/widget-core@1.1.0-beta.0 + ## 1.0.0 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 1622a5e..e74100b 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@agent-devtools/svelte", - "version": "1.0.0", + "version": "1.1.0-beta.0", "description": "Svelte adapter for agent-devtools — Svelte 4/5 component walker + DOM picker + closed Shadow DOM widget", "keywords": [ "agent-devtools", diff --git a/packages/sveltekit/CHANGELOG.md b/packages/sveltekit/CHANGELOG.md index f887972..aed8b77 100644 --- a/packages/sveltekit/CHANGELOG.md +++ b/packages/sveltekit/CHANGELOG.md @@ -1,5 +1,13 @@ # @agent-devtools/sveltekit +## 1.1.0-beta.0 + +### Patch Changes + +- Updated dependencies [[`4cdbe4b`](https://github.com/Seungwoo321/agent-devtools/commit/4cdbe4b2e2103c015dd8fda2278ce683c1ece0a5), [`6317aa3`](https://github.com/Seungwoo321/agent-devtools/commit/6317aa3fdc501738aa89fcae6a660384e3f7bc15)]: + - @agent-devtools/core@1.1.0-beta.0 + - @agent-devtools/svelte@1.1.0-beta.0 + ## 1.0.0 ### Patch Changes diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index 06cea2d..ced4722 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -1,6 +1,6 @@ { "name": "@agent-devtools/sveltekit", - "version": "1.0.0", + "version": "1.1.0-beta.0", "description": "SvelteKit adapter for agent-devtools — dev-only handle hook + Svelte adapter mount", "keywords": [ "agent-devtools", diff --git a/packages/vite/CHANGELOG.md b/packages/vite/CHANGELOG.md index ed09435..fa417ba 100644 --- a/packages/vite/CHANGELOG.md +++ b/packages/vite/CHANGELOG.md @@ -1,5 +1,12 @@ # @agent-devtools/vite +## 1.1.0-beta.0 + +### Patch Changes + +- Updated dependencies [[`4cdbe4b`](https://github.com/Seungwoo321/agent-devtools/commit/4cdbe4b2e2103c015dd8fda2278ce683c1ece0a5), [`6317aa3`](https://github.com/Seungwoo321/agent-devtools/commit/6317aa3fdc501738aa89fcae6a660384e3f7bc15)]: + - @agent-devtools/core@1.1.0-beta.0 + ## 1.0.0 ### Patch Changes diff --git a/packages/vite/package.json b/packages/vite/package.json index a926b67..cfbdc34 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -1,6 +1,6 @@ { "name": "@agent-devtools/vite", - "version": "1.0.0", + "version": "1.1.0-beta.0", "description": "Vite plugin for agent-devtools — auto-spawn agent server + dev-only widget injection (Vite 5+)", "keywords": [ "agent-devtools", diff --git a/packages/vite/src/build-integration.test.ts b/packages/vite/src/build-integration.test.ts index cae09f2..887b0af 100644 --- a/packages/vite/src/build-integration.test.ts +++ b/packages/vite/src/build-integration.test.ts @@ -3,10 +3,10 @@ * verify nothing related to the widget reaches the output bundle. * * Two layers are tested here: - * 1. The PLUGIN guarantee (ADT-25 / `apply: 'serve'`) — even when the + * 1. The PLUGIN guarantee (`apply: 'serve'`) — even when the * plugin is wired into a Vite config, a production build emits zero * bootstrap. - * 2. The USER PATTERN guarantee (ADT-29) — the recommended + * 2. The USER PATTERN guarantee — the recommended * `if (import.meta.env.DEV) { await import('@agent-devtools/react') }` * gate around the widget tree-shakes out of production. The fixture * uses a local stub module with a unique sentinel identifier so the diff --git a/packages/vue/CHANGELOG.md b/packages/vue/CHANGELOG.md index 906b977..8c44308 100644 --- a/packages/vue/CHANGELOG.md +++ b/packages/vue/CHANGELOG.md @@ -1,5 +1,13 @@ # @agent-devtools/vue +## 1.1.0-beta.0 + +### Patch Changes + +- Updated dependencies [[`4cdbe4b`](https://github.com/Seungwoo321/agent-devtools/commit/4cdbe4b2e2103c015dd8fda2278ce683c1ece0a5), [`b621331`](https://github.com/Seungwoo321/agent-devtools/commit/b621331110dac125484d223b0e9aee3b82ab052d), [`6317aa3`](https://github.com/Seungwoo321/agent-devtools/commit/6317aa3fdc501738aa89fcae6a660384e3f7bc15), [`cd230a6`](https://github.com/Seungwoo321/agent-devtools/commit/cd230a6de9ce4ac267ef18446edaea75bc56ddd2), [`3fbaf3b`](https://github.com/Seungwoo321/agent-devtools/commit/3fbaf3b611760793a2932955f4a5ebd70f3bb70b), [`3fbaf3b`](https://github.com/Seungwoo321/agent-devtools/commit/3fbaf3b611760793a2932955f4a5ebd70f3bb70b)]: + - @agent-devtools/core@1.1.0-beta.0 + - @agent-devtools/widget-core@1.1.0-beta.0 + ## 1.0.0 ### Patch Changes diff --git a/packages/vue/package.json b/packages/vue/package.json index bcec4d9..d9f92fe 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@agent-devtools/vue", - "version": "1.0.0", + "version": "1.1.0-beta.0", "description": "Vue 3 adapter for agent-devtools — vnode walker + DOM picker + closed Shadow DOM widget", "keywords": [ "agent-devtools", diff --git a/packages/vue2/CHANGELOG.md b/packages/vue2/CHANGELOG.md index 03d9407..ba9381f 100644 --- a/packages/vue2/CHANGELOG.md +++ b/packages/vue2/CHANGELOG.md @@ -1,5 +1,13 @@ # @agent-devtools/vue2 +## 1.1.0-beta.0 + +### Patch Changes + +- Updated dependencies [[`4cdbe4b`](https://github.com/Seungwoo321/agent-devtools/commit/4cdbe4b2e2103c015dd8fda2278ce683c1ece0a5), [`b621331`](https://github.com/Seungwoo321/agent-devtools/commit/b621331110dac125484d223b0e9aee3b82ab052d), [`6317aa3`](https://github.com/Seungwoo321/agent-devtools/commit/6317aa3fdc501738aa89fcae6a660384e3f7bc15), [`cd230a6`](https://github.com/Seungwoo321/agent-devtools/commit/cd230a6de9ce4ac267ef18446edaea75bc56ddd2), [`3fbaf3b`](https://github.com/Seungwoo321/agent-devtools/commit/3fbaf3b611760793a2932955f4a5ebd70f3bb70b), [`3fbaf3b`](https://github.com/Seungwoo321/agent-devtools/commit/3fbaf3b611760793a2932955f4a5ebd70f3bb70b)]: + - @agent-devtools/core@1.1.0-beta.0 + - @agent-devtools/widget-core@1.1.0-beta.0 + ## 1.0.0 ### Patch Changes diff --git a/packages/vue2/package.json b/packages/vue2/package.json index 1516a94..6eff8cd 100644 --- a/packages/vue2/package.json +++ b/packages/vue2/package.json @@ -1,6 +1,6 @@ { "name": "@agent-devtools/vue2", - "version": "1.0.0", + "version": "1.1.0-beta.0", "description": "Vue 2 adapter for agent-devtools — Vue 2.7 component walker + DOM picker + closed Shadow DOM widget", "keywords": [ "agent-devtools", diff --git a/packages/widget-core/CHANGELOG.md b/packages/widget-core/CHANGELOG.md index 4d31661..ad2e13a 100644 --- a/packages/widget-core/CHANGELOG.md +++ b/packages/widget-core/CHANGELOG.md @@ -1,5 +1,101 @@ # @agent-devtools/widget-core +## 1.1.0-beta.0 + +### Minor Changes + +- [#11](https://github.com/Seungwoo321/agent-devtools/pull/11) [`4cdbe4b`](https://github.com/Seungwoo321/agent-devtools/commit/4cdbe4b2e2103c015dd8fda2278ce683c1ece0a5) Thanks [@Seungwoo321](https://github.com/Seungwoo321)! - Add model selection so a prompt can run on the same models the Claude Code + terminal offers. A new `model` setting exposes the terminal's `/model` menu — + `default`, `opus`, `sonnet`, `haiku` — in the settings panel, persists in + localStorage alongside the provider, permission mode and theme, and rides on + each request body. `default` is a sentinel that sends no model on the wire, so + the chosen provider keeps its own default exactly as it does today. + + Both providers resolve the alias through the shared Claude Agent SDK resolver, + so no live model-discovery round-trip is needed. The SDK provider forwards the + alias as the `query()` `model` option. The ACP provider applies it with + `session/set_model` after the session is established and before the prompt is + dispatched; it remembers the last applied model per session to skip a redundant + round-trip when the model is unchanged across turns, and surfaces an error + (rather than silently running on the wrong model) if the agent rejects the + request. The server validates only that `model` is a non-empty string and + forwards it verbatim, leaving the model set open for full date-pinned ids or + future tiers without a protocol change. + +- [#11](https://github.com/Seungwoo321/agent-devtools/pull/11) [`b621331`](https://github.com/Seungwoo321/agent-devtools/commit/b621331110dac125484d223b0e9aee3b82ab052d) Thanks [@Seungwoo321](https://github.com/Seungwoo321)! - Absorb the dev-server respawn window so a hot reload no longer surfaces a + spurious network error on the next prompt. The default transport already + retried when `fetch()` rejected before any Response (the request never left + the client); it now treats a `503` from the dev-server proxy the same way, + because the proxy returns `503 "agent server not ready"` _before_ forwarding + anything upstream while the agent server respawns — so the prompt never + reached the agent and a retry can't duplicate the turn. This is the common + "network error right after a dev-server restart / hot reload" case. + + Retries now use capped exponential backoff (base `300ms`, cap `2000ms`, + default four retries ≈ 4.1s total) so a multi-second respawn is waited out + while a genuinely dead server still fails within a bounded window. A new + `preResponseRetryMaxBackoffMs` option exposes the cap, and the default + retry count rose from 1 to 4. + + The idempotency boundary is unchanged: any failure that proves the prompt + reached the agent — a `2xx` stream that later drops mid-flight, `500`, + `502`, `401`, or a silent-stream timeout — is never auto-retried, since the + agent may have already started editing files and re-sending would re-run the + LLM. Those still surface as an error for the user to retry deliberately. + +- [#11](https://github.com/Seungwoo321/agent-devtools/pull/11) [`cd230a6`](https://github.com/Seungwoo321/agent-devtools/commit/cd230a6de9ce4ac267ef18446edaea75bc56ddd2) Thanks [@Seungwoo321](https://github.com/Seungwoo321)! - Add a theme to the floating chat and every widget surface: a new `theme` + setting with `auto` (the default), `light`, and `dark`. `auto` follows the + operating system's `prefers-color-scheme`; `light` and `dark` pin the choice. + The setting persists in localStorage alongside the provider and permission mode, + and switching it flips a single `data-theme` attribute on the closed shadow + host, so the browser recomputes every colour through CSS custom properties with + no per-component re-render. + + The dark palette is the only set of tokens defined centrally on the host. Light + is the absence of tokens: every surface reads its colour as + `var(--adt-token, )`, where the literal fallback is that element's + original light colour. So light stays byte-identical to the previous look and + each surface keeps its own light nuance, while dark is single-sourced — the same + token can resolve to a different light value per surface (a user bubble's text + is white in light, body text is near-black, and both become the same light grey + in dark). Surfaces that are intentionally dark in both themes (the picked-element + code card) keep their dark treatment by reading a raised-surface token rather + than inverting with the accent. + + Every widget surface participates: the composer, launcher, message stream, + picked-element evidence, tool output, handoff modal, and settings panel. The + launcher and accent controls invert correctly so dark mode reads as a true dark + theme rather than a tinted light one. + +- [#11](https://github.com/Seungwoo321/agent-devtools/pull/11) [`3fbaf3b`](https://github.com/Seungwoo321/agent-devtools/commit/3fbaf3b611760793a2932955f4a5ebd70f3bb70b) Thanks [@Seungwoo321](https://github.com/Seungwoo321)! - Persist the widget's visibility across page reloads. The orchestrator now + remembers two on/off axes in localStorage and restores them on mount: the + composer panel's open/closed state (toggled by the launcher, the close button, + Escape, or picking an element) and the widget-level visibility (toggled by the + Ctrl/Cmd+Shift+; hotkey). This matches the standard devtools convention where + the tool reopens in the state you left it. Persistence lives in the + orchestrator rather than the composer because only the orchestrator can tell a + user-driven open/close apart from a system-driven transient collapse (the panel + hiding during element-picking, or the whole surface going dark), so a transient + collapse never clobbers the user's remembered choice. Storage access is wrapped + in try/catch and degrades silently where localStorage is unavailable (file://, + private mode, sandboxed iframes, quota-exceeded). + +### Patch Changes + +- [#11](https://github.com/Seungwoo321/agent-devtools/pull/11) [`3fbaf3b`](https://github.com/Seungwoo321/agent-devtools/commit/3fbaf3b611760793a2932955f4a5ebd70f3bb70b) Thanks [@Seungwoo321](https://github.com/Seungwoo321)! - Show the working ("typing") indicator during every idle period of a turn, not + only while waiting for the first response. Previously the three-dot indicator + was a one-shot placeholder pushed when the user submitted and removed on the + first assistant event, so in an agentic turn the surface looked frozen while a + tool executed and while the model round-tripped on a tool result. The indicator + is now a derived view of the conversation state: it sits at the tail whenever a + turn is in flight and the assistant is between visible actions (after submit, + while a tool runs, and during the model round-trip after a tool result), and is + dropped the moment text or tool input streams again or the turn ends. It is + deliberately not shown after a finished text block, since a turn that ends on + text emits its completion immediately and a dot there would only flash. +- Updated dependencies [[`4cdbe4b`](https://github.com/Seungwoo321/agent-devtools/commit/4cdbe4b2e2103c015dd8fda2278ce683c1ece0a5), [`6317aa3`](https://github.com/Seungwoo321/agent-devtools/commit/6317aa3fdc501738aa89fcae6a660384e3f7bc15)]: + - @agent-devtools/core@1.1.0-beta.0 + ## 1.0.0 ### Patch Changes diff --git a/packages/widget-core/README.ko.md b/packages/widget-core/README.ko.md index 99d3d78..08cd46e 100644 --- a/packages/widget-core/README.ko.md +++ b/packages/widget-core/README.ko.md @@ -80,18 +80,19 @@ if (import.meta.env.DEV) { ### `createDefaultTransport(options)` -| 옵션 | 타입 | 기본값 | 설명 | -| --------------------------- | ------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `baseUrl` | `string` | (필수) | 에이전트 서버 origin. 예: `http://127.0.0.1:4317`. | -| `pairingToken` | `string` | (필수) | 에이전트 서버가 시작 시 발급한 Bearer 토큰. | -| `fetch` | `typeof fetch` | `globalThis.fetch` | 커스텀 fetch 구현 (테스트, SSR shim 등). | -| `getSettings` | `() => SettingsSnapshot \| undefined` | (없음) | 설정 store 에서 `provider`, `model`, `permissionMode` 를 읽는 함수. | -| `sessionIdStorage` | `Storage \| 'memory'` | `sessionStorage` | ACP 세션 ID 가 저장될 스토리지. | -| `sessionIdStorageKey` | `string` | `agent-devtools:sid` | ACP 세션 ID 의 스토리지 키. | -| `generateSessionId` | `() => string` | `crypto.randomUUID` | 세션 ID 생성 함수. | -| `streamSilentMs` | `number` | `60_000` | 리더가 이 시간 이상 침묵하면 스트림을 abort 하고 `StreamSilentError` 로 reject. `0` 이면 비활성화. | -| `preResponseRetries` | `number` | `1` | 첫 fetch 가 Response 도착 전 네트워크 에러로 reject 될 때 추가 시도 횟수. AbortError 와 HTTP 에러는 재시도 안 함. | -| `preResponseRetryBackoffMs` | `number` | `300` | 실패한 첫 fetch 와 재시도 사이 대기 시간. | +| 옵션 | 타입 | 기본값 | 설명 | +| ------------------------------ | ------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `baseUrl` | `string` | (필수) | 에이전트 서버 origin. 예: `http://127.0.0.1:4317`. | +| `pairingToken` | `string` | (필수) | 에이전트 서버가 시작 시 발급한 Bearer 토큰. | +| `fetch` | `typeof fetch` | `globalThis.fetch` | 커스텀 fetch 구현 (테스트, SSR shim 등). | +| `getSettings` | `() => SettingsSnapshot \| undefined` | (없음) | 설정 store 에서 `provider`, `model`, `permissionMode` 를 읽는 함수. | +| `sessionIdStorage` | `Storage \| 'memory'` | `sessionStorage` | ACP 세션 ID 가 저장될 스토리지. | +| `sessionIdStorageKey` | `string` | `agent-devtools:sid` | ACP 세션 ID 의 스토리지 키. | +| `generateSessionId` | `() => string` | `crypto.randomUUID` | 세션 ID 생성 함수. | +| `streamSilentMs` | `number` | `60_000` | 리더가 이 시간 이상 침묵하면 스트림을 abort 하고 `StreamSilentError` 로 reject. `0` 이면 비활성화. | +| `preResponseRetries` | `number` | `4` | agent 에 도달하지 못한 실패의 재시도 횟수 — Response 도착 전 `fetch` reject, 또는 dev 서버 proxy 의 `503` "agent not ready"(hot reload 직후 respawn 창). AbortError, 끊긴 `2xx` 스트림, 그 외 HTTP 에러는 재시도 안 함. | +| `preResponseRetryBackoffMs` | `number` | `300` | 재시도 사이 기본 backoff. 지수적으로 증가(`base · 2^(n-1)`)하며 `preResponseRetryMaxBackoffMs` 로 상한. | +| `preResponseRetryMaxBackoffMs` | `number` | `2000` | 단일 backoff 대기의 상한. 전체 재시도 창을 제한(기본 4회 재시도 기준 ~4.1s). | ### `createShadowWidgetRoot(options)` diff --git a/packages/widget-core/README.md b/packages/widget-core/README.md index dc5bb6c..15de572 100644 --- a/packages/widget-core/README.md +++ b/packages/widget-core/README.md @@ -80,18 +80,19 @@ if (import.meta.env.DEV) { ### `createDefaultTransport(options)` -| Option | Type | Default | Description | -| --------------------------- | ------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `baseUrl` | `string` | (required) | Agent server origin, e.g. `http://127.0.0.1:4317`. | -| `pairingToken` | `string` | (required) | Bearer token minted by the agent server at startup. | -| `fetch` | `typeof fetch` | `globalThis.fetch` | Custom fetch implementation (testing, SSR shim). | -| `getSettings` | `() => SettingsSnapshot \| undefined` | (none) | Reads the current `provider`, `model`, and `permissionMode` from a settings store. | -| `sessionIdStorage` | `Storage \| 'memory'` | `sessionStorage` | Where the per-tab ACP session id is persisted. | -| `sessionIdStorageKey` | `string` | `agent-devtools:sid` | Storage key for the ACP session id. | -| `generateSessionId` | `() => string` | `crypto.randomUUID` | Custom session id minter. | -| `streamSilentMs` | `number` | `60_000` | Reader silence past this many ms aborts the stream and rejects with `StreamSilentError`. Pass `0` to disable. | -| `preResponseRetries` | `number` | `1` | Extra fetch attempts when the initial request rejects with a network error before any Response. Never retries aborts or HTTP errors. | -| `preResponseRetryBackoffMs` | `number` | `300` | Delay between the failed initial fetch and the retry attempt. | +| Option | Type | Default | Description | +| ------------------------------ | ------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `baseUrl` | `string` | (required) | Agent server origin, e.g. `http://127.0.0.1:4317`. | +| `pairingToken` | `string` | (required) | Bearer token minted by the agent server at startup. | +| `fetch` | `typeof fetch` | `globalThis.fetch` | Custom fetch implementation (testing, SSR shim). | +| `getSettings` | `() => SettingsSnapshot \| undefined` | (none) | Reads the current `provider`, `model`, and `permissionMode` from a settings store. | +| `sessionIdStorage` | `Storage \| 'memory'` | `sessionStorage` | Where the per-tab ACP session id is persisted. | +| `sessionIdStorageKey` | `string` | `agent-devtools:sid` | Storage key for the ACP session id. | +| `generateSessionId` | `() => string` | `crypto.randomUUID` | Custom session id minter. | +| `streamSilentMs` | `number` | `60_000` | Reader silence past this many ms aborts the stream and rejects with `StreamSilentError`. Pass `0` to disable. | +| `preResponseRetries` | `number` | `4` | Retry attempts for failures that never reached the agent — a `fetch` rejection before any Response, or a `503` "agent not ready" from the dev-server proxy (the respawn window after a hot reload). Never retries aborts, a dropped `2xx` stream, or other HTTP errors. | +| `preResponseRetryBackoffMs` | `number` | `300` | Base backoff between retries; grows exponentially (`base · 2^(n-1)`), capped by `preResponseRetryMaxBackoffMs`. | +| `preResponseRetryMaxBackoffMs` | `number` | `2000` | Upper bound on a single backoff wait, keeping the total retry window bounded (~4.1s across the four default retries). | ### `createShadowWidgetRoot(options)` diff --git a/packages/widget-core/package.json b/packages/widget-core/package.json index f7a92eb..858d840 100644 --- a/packages/widget-core/package.json +++ b/packages/widget-core/package.json @@ -1,6 +1,6 @@ { "name": "@agent-devtools/widget-core", - "version": "1.0.0", + "version": "1.1.0-beta.0", "description": "Framework-agnostic widget shell for agent-devtools — closed Shadow DOM mount, composer, picker overlay, SSE transport", "keywords": [ "agent-devtools", diff --git a/packages/widget-core/src/composer/composer.test.ts b/packages/widget-core/src/composer/composer.test.ts index cdff84d..c697bbd 100644 --- a/packages/widget-core/src/composer/composer.test.ts +++ b/packages/widget-core/src/composer/composer.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createComposer } from './composer.js'; +import { createComposer, CHIP_BG, CHIP_BORDER } from './composer.js'; import type { PickedEvidence } from '../context/types.js'; let container: HTMLElement; @@ -186,21 +186,27 @@ describe('createComposer', () => { }); it('renders the chip with an opaque background so the conversation stream cannot bleed through', () => { - const handle = createComposer({ container, onSubmit: vi.fn(), picked: makePicked() }); - const chip = handle.element.querySelector( - '[data-agent-devtools-composer-chip] > span', - )!; - const bg = chip.style.background; + // The chip fill is now a CSS var() theme token, which the browser resolves + // but the headless CSS engine drops from inline styles — so the opacity + // contract is asserted against the source-of-truth constant (the light + // literal fallback) rather than the un-resolvable rendered value. The dark + // token's opacity is covered in shadow-root.test.ts. // The chip MUST NOT use an alpha-channel background — rgba/hsla with // alpha < 1 lets stream text show through. Solid hex / named colors are // the contract. - expect(bg.toLowerCase()).not.toMatch(/rgba?\(.*0\.\d/); - expect(bg.toLowerCase()).not.toMatch(/hsla?\(.*0\.\d/); - expect(bg).not.toBe('transparent'); - expect(bg).not.toBe(''); + expect(CHIP_BG.toLowerCase()).not.toMatch(/rgba?\([^)]*0?\.\d/); + expect(CHIP_BG.toLowerCase()).not.toMatch(/hsla?\([^)]*0?\.\d/); + expect(CHIP_BG).not.toBe('transparent'); + expect(CHIP_BG).not.toBe(''); // And the chip should have a visible border so it still reads as a // discrete affordance against a similarly-colored panel. - expect(chip.style.border).not.toBe(''); + expect(CHIP_BORDER).not.toBe(''); + // The chip element still actually renders with the marker so the slot is + // wired up. + const handle = createComposer({ container, onSubmit: vi.fn(), picked: makePicked() }); + expect( + handle.element.querySelector('[data-agent-devtools-composer-chip] > span'), + ).not.toBeNull(); handle.destroy(); }); @@ -446,11 +452,13 @@ describe('createComposer', () => { '[data-agent-devtools-composer-settings]', ); if (!gear) throw new Error('gear not found'); - const inactiveBg = gear.style.background; + // The active state is painted with a var() token (dropped by the headless + // CSS engine) and mirrored onto aria-pressed, which is the stable signal. + expect(gear.getAttribute('aria-pressed')).toBe('false'); handle.setSettingsActive(true); - expect(gear.style.background).not.toBe(inactiveBg); + expect(gear.getAttribute('aria-pressed')).toBe('true'); handle.setSettingsActive(false); - expect(gear.style.background).toBe(inactiveBg); + expect(gear.getAttribute('aria-pressed')).toBe('false'); handle.destroy(); }); @@ -699,21 +707,24 @@ describe('createComposer', () => { handle.destroy(); }); + // The lit colour is now a CSS var() theme token (resolved by the browser), + // and non-browser CSS engines drop var() from inline styles — so "is the + // affordance showing" is tracked by a dedicated state attribute rather than + // the painted background value. + const LIT_ATTR = 'data-agent-devtools-composer-resize-lit'; + it('pointerenter on a resize handle paints a hover affordance; leave resets it', () => { const handle = createComposer({ container, onSubmit: vi.fn(), sizeStorage: null }); const left = getHandle(handle.element, 'left'); - expect(left.style.background).toBe('transparent'); + expect(left.hasAttribute(LIT_ATTR)).toBe(false); left.dispatchEvent( pointerEvent('pointerenter' as 'pointerdown', { pointerId: 20, clientX: 0, clientY: 0 }), ); - // Any non-empty, non-transparent background — the exact rgba form is an - // implementation detail; the contract is "visible to the user". - expect(left.style.background).not.toBe(''); - expect(left.style.background).not.toBe('transparent'); + expect(left.hasAttribute(LIT_ATTR)).toBe(true); left.dispatchEvent( pointerEvent('pointerleave' as 'pointerdown', { pointerId: 20, clientX: 0, clientY: 0 }), ); - expect(left.style.background).toBe('transparent'); + expect(left.hasAttribute(LIT_ATTR)).toBe(false); handle.destroy(); }); @@ -723,17 +734,16 @@ describe('createComposer', () => { const handle = createComposer({ container, onSubmit: vi.fn(), sizeStorage: null }); const left = getHandle(handle.element, 'left'); left.dispatchEvent(pointerEvent('pointerdown', { pointerId: 21, clientX: 500, clientY: 300 })); - const litDuringDrag = left.style.background; - expect(litDuringDrag).not.toBe('transparent'); + expect(left.hasAttribute(LIT_ATTR)).toBe(true); // Simulate the cursor leaving the 6px strip while pointer capture keeps // the drag alive — the handle must stay lit so the user can still see // what they're dragging. left.dispatchEvent( pointerEvent('pointerleave' as 'pointerdown', { pointerId: 21, clientX: 300, clientY: 300 }), ); - expect(left.style.background).toBe(litDuringDrag); + expect(left.hasAttribute(LIT_ATTR)).toBe(true); left.dispatchEvent(pointerEvent('pointerup', { pointerId: 21, clientX: 300, clientY: 300 })); - expect(left.style.background).toBe('transparent'); + expect(left.hasAttribute(LIT_ATTR)).toBe(false); handle.destroy(); }); diff --git a/packages/widget-core/src/composer/composer.ts b/packages/widget-core/src/composer/composer.ts index a73d0e9..67639b3 100644 --- a/packages/widget-core/src/composer/composer.ts +++ b/packages/widget-core/src/composer/composer.ts @@ -86,8 +86,37 @@ const PANEL_SIZE_STORAGE_KEY = 'agent-devtools:panelSize'; // Reveal-on-hover tint for the otherwise-transparent resize handles. Matches // the toolbar icon hover convention so the affordance reads as "interactive -// edge of the panel" rather than a stray UI element. -const RESIZE_HANDLE_HOVER_BG = 'rgba(0, 0, 0, 0.08)'; +// edge of the panel" rather than a stray UI element. The colour is a theme +// token resolved by the browser; light keeps the original black tint via the +// literal fallback, dark flips to the white `--adt-overlay-weak`. +const RESIZE_HANDLE_HOVER_BG = 'var(--adt-overlay-weak, rgba(0, 0, 0, 0.08))'; + +// Marks a handle as currently lit (hovered or mid-drag). The painted colour +// lives in the inline `background` token above, but `var()` is not resolvable +// in non-browser CSS engines, so this attribute is the framework-agnostic +// signal for "is this affordance showing" — used by tests and available to +// any future stylesheet rule. +const RESIZE_HANDLE_LIT_ATTR = 'data-agent-devtools-composer-resize-lit'; + +function setHandleLit(handle: HTMLElement, lit: boolean): void { + if (lit) { + handle.style.background = RESIZE_HANDLE_HOVER_BG; + handle.setAttribute(RESIZE_HANDLE_LIT_ATTR, ''); + } else { + handle.style.background = 'transparent'; + handle.removeAttribute(RESIZE_HANDLE_LIT_ATTR); + } +} + +// Picked-element chip fill + border. The fill MUST be alpha-free in every +// theme so the conversation stream rendered behind the chip slot cannot bleed +// through it (a prior 6% alpha tint vanished once messages stacked up). Both +// the light literal fallback (`#eef0f3`) and the dark token (`--adt-chip-bg: +// #2f2f33`) are opaque hex. The border may carry alpha — it is a hairline, not +// a fill. Exported for white-box opacity assertions (not part of the package's +// public entry). +export const CHIP_BG = 'var(--adt-chip-bg, #eef0f3)'; +export const CHIP_BORDER = '1px solid var(--adt-border, rgba(0, 0, 0, 0.08))'; export interface ComposerSubmitPayload { readonly text: string; @@ -280,6 +309,9 @@ export function createComposer(options: CreateComposerOptions): ComposerHandle { settingsButton.type = 'button'; settingsButton.setAttribute(SETTINGS_TOGGLE_ATTR, ''); settingsButton.setAttribute('aria-label', 'Open settings'); + // The gear toggles the settings panel — opt into aria-pressed so the active + // state is announced (and observable) independent of the painted colour. + settingsButton.setAttribute('aria-pressed', String(settingsActive)); // Plain U+2699 GEAR rather than an SVG to dodge a font-rendering edge case // in shadow roots and keep the bundle tiny. settingsButton.textContent = '⚙'; @@ -517,7 +549,7 @@ export function createComposer(options: CreateComposerOptions): ComposerHandle { // Keep the affordance lit while the drag is in flight — pointer // capture can suppress hover transitions once the cursor leaves the // 6px strip, so we paint it explicitly. - handle.style.background = RESIZE_HANDLE_HOVER_BG; + setHandleLit(handle, true); try { handle.setPointerCapture(event.pointerId); } catch { @@ -594,7 +626,7 @@ export function createComposer(options: CreateComposerOptions): ComposerHandle { // Drop the lit background — if the cursor is still over the handle the // next pointerenter will repaint it; otherwise the affordance correctly // disappears. - handle.style.background = 'transparent'; + setHandleLit(handle, false); activeDrag = null; const width = panel.offsetWidth || parseFloat(panel.style.width) || PANEL_DEFAULT_WIDTH; const height = panel.offsetHeight || parseFloat(panel.style.height) || PANEL_DEFAULT_HEIGHT; @@ -603,7 +635,7 @@ export function createComposer(options: CreateComposerOptions): ComposerHandle { function onHandlePointerEnter(event: PointerEvent): void { const handle = event.currentTarget as HTMLElement; - handle.style.background = RESIZE_HANDLE_HOVER_BG; + setHandleLit(handle, true); } function onHandlePointerLeave(event: PointerEvent): void { @@ -612,7 +644,7 @@ export function createComposer(options: CreateComposerOptions): ComposerHandle { // cursor can wander far off the 6px strip while pointer capture keeps // the drag alive. if (activeDrag && activeDrag.pointerId === event.pointerId) return; - handle.style.background = 'transparent'; + setHandleLit(handle, false); } const handleListeners: Array<[HTMLElement, (event: PointerEvent) => void, () => void]> = []; @@ -729,11 +761,11 @@ function applyPanelStyles(panel: HTMLElement): void { s.bottom = '88px'; // Width / height are owned by the resize subsystem (`applyPanelSize`) // so the user's drag-resized dimensions persist across reloads. - s.background = '#ffffff'; - s.color = '#1a1a1a'; - s.border = '1px solid rgba(0, 0, 0, 0.08)'; + s.background = 'var(--adt-surface, #ffffff)'; + s.color = 'var(--adt-text, #1a1a1a)'; + s.border = '1px solid var(--adt-border, rgba(0, 0, 0, 0.08))'; s.borderRadius = '12px'; - s.boxShadow = '0 12px 32px rgba(0, 0, 0, 0.18)'; + s.boxShadow = '0 12px 32px var(--adt-shadow, rgba(0, 0, 0, 0.18))'; s.flexDirection = 'column'; s.overflow = 'hidden'; s.fontFamily = 'inherit'; @@ -937,7 +969,7 @@ function applyHeaderStyles(header: HTMLElement): void { s.alignItems = 'center'; s.gap = '8px'; s.padding = '10px 12px'; - s.borderBottom = '1px solid rgba(0, 0, 0, 0.06)'; + s.borderBottom = '1px solid var(--adt-border, rgba(0, 0, 0, 0.06))'; } function applyTitleStyles(title: HTMLElement): void { @@ -951,9 +983,11 @@ function applyPickButtonStyles(button: HTMLButtonElement, active: boolean): void const s = button.style; s.padding = '4px 10px'; s.borderRadius = '999px'; - s.border = active ? '1px solid #1a1a1a' : '1px solid rgba(0, 0, 0, 0.16)'; - s.background = active ? '#1a1a1a' : 'transparent'; - s.color = active ? '#ffffff' : '#1a1a1a'; + s.border = active + ? '1px solid var(--adt-accent, #1a1a1a)' + : '1px solid var(--adt-border, rgba(0, 0, 0, 0.16))'; + s.background = active ? 'var(--adt-accent, #1a1a1a)' : 'transparent'; + s.color = active ? 'var(--adt-accent-text, #ffffff)' : 'var(--adt-text, #1a1a1a)'; s.fontSize = '12px'; s.cursor = 'pointer'; } @@ -973,9 +1007,11 @@ function applySafeModeButtonState(button: HTMLButtonElement, safeMode: boolean): const s = button.style; s.padding = '4px 10px'; s.borderRadius = '999px'; - s.border = safeMode ? '1px solid #1a1a1a' : '1px solid rgba(0, 0, 0, 0.16)'; - s.background = safeMode ? '#1a1a1a' : 'transparent'; - s.color = safeMode ? '#ffffff' : '#1a1a1a'; + s.border = safeMode + ? '1px solid var(--adt-accent, #1a1a1a)' + : '1px solid var(--adt-border, rgba(0, 0, 0, 0.16))'; + s.background = safeMode ? 'var(--adt-accent, #1a1a1a)' : 'transparent'; + s.color = safeMode ? 'var(--adt-accent-text, #ffffff)' : 'var(--adt-text, #1a1a1a)'; s.fontSize = '12px'; s.cursor = 'pointer'; } @@ -987,11 +1023,18 @@ function applyIconButtonStyles(button: HTMLButtonElement, active: boolean): void s.padding = '0'; s.borderRadius = '6px'; s.border = '0'; - s.background = active ? 'rgba(0, 0, 0, 0.08)' : 'transparent'; - s.color = active ? '#1a1a1a' : '#666'; + s.background = active ? 'var(--adt-overlay-weak, rgba(0, 0, 0, 0.08))' : 'transparent'; + s.color = active ? 'var(--adt-text, #1a1a1a)' : 'var(--adt-text-muted, #666)'; s.cursor = 'pointer'; s.fontSize = '14px'; s.lineHeight = '1'; + // Toggle buttons (the gear) opt into aria-pressed at creation; momentary + // action buttons (handoff / new-session) never carry it and stay plain. + // This is also the framework-agnostic signal for the active state, since + // the painted background is a `var()` token that headless CSS engines drop. + if (button.hasAttribute('aria-pressed')) { + button.setAttribute('aria-pressed', String(active)); + } } function applyCloseButtonStyles(button: HTMLButtonElement): void { @@ -1002,7 +1045,7 @@ function applyCloseButtonStyles(button: HTMLButtonElement): void { s.borderRadius = '6px'; s.border = '0'; s.background = 'transparent'; - s.color = '#666'; + s.color = 'var(--adt-text-muted, #666)'; s.cursor = 'pointer'; s.fontSize = '14px'; s.lineHeight = '1'; @@ -1027,10 +1070,11 @@ function applyChipStyles(chip: HTMLElement): void { // Solid fill + subtle border so the chip never reads as transparent // against the conversation stream rendered above it inside the panel. // A previous 6% alpha tint visually disappeared once a few messages - // landed behind the chip host slot. - s.background = '#eef0f3'; - s.border = '1px solid rgba(0, 0, 0, 0.08)'; - s.color = '#1a1a1a'; + // landed behind the chip host slot. See CHIP_BG / CHIP_BORDER for the + // opacity contract. + s.background = CHIP_BG; + s.border = CHIP_BORDER; + s.color = 'var(--adt-text, #1a1a1a)'; s.fontSize = '12px'; s.maxWidth = '100%'; // overflow is hidden on the LABEL (see populateChipTooltip / chip label @@ -1079,11 +1123,11 @@ function applyChipTooltipStyles(tooltip: HTMLElement): void { s.maxWidth = '320px'; s.padding = '8px 10px'; s.borderRadius = '8px'; - s.background = '#1a1a1a'; - s.color = '#ffffff'; + s.background = 'var(--adt-accent, #1a1a1a)'; + s.color = 'var(--adt-accent-text, #ffffff)'; s.fontSize = '11px'; s.lineHeight = '1.4'; - s.boxShadow = '0 4px 14px rgba(0, 0, 0, 0.22)'; + s.boxShadow = '0 4px 14px var(--adt-shadow, rgba(0, 0, 0, 0.22))'; s.opacity = '0'; s.visibility = 'hidden'; s.transition = 'opacity 120ms ease-out'; @@ -1149,8 +1193,8 @@ function applyChipRemoveStyles(button: HTMLButtonElement): void { s.padding = '0'; s.borderRadius = '999px'; s.border = '0'; - s.background = 'rgba(0, 0, 0, 0.12)'; - s.color = '#1a1a1a'; + s.background = 'var(--adt-overlay-weak, rgba(0, 0, 0, 0.12))'; + s.color = 'var(--adt-text, #1a1a1a)'; s.cursor = 'pointer'; s.fontSize = '10px'; s.lineHeight = '1'; @@ -1160,14 +1204,14 @@ function applyTextareaStyles(textarea: HTMLTextAreaElement): void { const s = textarea.style; s.margin = '12px'; s.padding = '8px 10px'; - s.border = '1px solid rgba(0, 0, 0, 0.16)'; + s.border = '1px solid var(--adt-border, rgba(0, 0, 0, 0.16))'; s.borderRadius = '8px'; s.resize = 'none'; s.fontFamily = 'inherit'; s.fontSize = '13px'; s.lineHeight = '1.4'; - s.background = '#ffffff'; - s.color = '#1a1a1a'; + s.background = 'var(--adt-surface, #ffffff)'; + s.color = 'var(--adt-text, #1a1a1a)'; s.outline = 'none'; // Lock the input to the rows=3 box so the stream area scrolls instead of // squeezing the textarea once the conversation history fills the panel. @@ -1187,8 +1231,8 @@ function applySendButtonStyles(button: HTMLButtonElement): void { s.padding = '6px 14px'; s.borderRadius = '8px'; s.border = '0'; - s.background = '#1a1a1a'; - s.color = '#ffffff'; + s.background = 'var(--adt-accent, #1a1a1a)'; + s.color = 'var(--adt-accent-text, #ffffff)'; s.fontSize = '13px'; s.fontWeight = '500'; s.cursor = 'pointer'; diff --git a/packages/widget-core/src/handoff/modal.ts b/packages/widget-core/src/handoff/modal.ts index 78e1b2c..730db85 100644 --- a/packages/widget-core/src/handoff/modal.ts +++ b/packages/widget-core/src/handoff/modal.ts @@ -189,7 +189,8 @@ export function createHandoffModal(options: CreateHandoffModalOptions): HandoffM function setStatus(text: string, kind: 'info' | 'error' = 'info'): void { status.textContent = text; - status.style.color = kind === 'error' ? '#b00020' : '#1a7f37'; + status.style.color = + kind === 'error' ? 'var(--adt-danger, #b00020)' : 'var(--adt-success, #1a7f37)'; } function showLoading(): void { @@ -339,7 +340,7 @@ function applyBackdropStyles(el: HTMLElement): void { const s = el.style; s.position = 'fixed'; s.inset = '0'; - s.background = 'rgba(0, 0, 0, 0.45)'; + s.background = 'var(--adt-backdrop, rgba(0, 0, 0, 0.45))'; s.display = 'flex'; s.alignItems = 'center'; s.justifyContent = 'center'; @@ -351,10 +352,10 @@ function applyModalStyles(el: HTMLElement): void { const s = el.style; s.width = 'min(560px, calc(100vw - 32px))'; s.maxHeight = 'calc(100vh - 64px)'; - s.background = '#ffffff'; - s.color = '#1a1a1a'; + s.background = 'var(--adt-surface, #ffffff)'; + s.color = 'var(--adt-text, #1a1a1a)'; s.borderRadius = '12px'; - s.boxShadow = '0 24px 48px rgba(0, 0, 0, 0.32)'; + s.boxShadow = '0 24px 48px var(--adt-shadow, rgba(0, 0, 0, 0.32))'; s.display = 'flex'; s.flexDirection = 'column'; s.overflow = 'hidden'; @@ -367,7 +368,7 @@ function applyModalHeaderStyles(el: HTMLElement): void { s.alignItems = 'center'; s.gap = '8px'; s.padding = '12px 16px'; - s.borderBottom = '1px solid rgba(0, 0, 0, 0.08)'; + s.borderBottom = '1px solid var(--adt-border, rgba(0, 0, 0, 0.08))'; } function applyModalTitleStyles(el: HTMLElement): void { @@ -384,7 +385,7 @@ function applyModalCloseStyles(el: HTMLButtonElement): void { s.borderRadius = '6px'; s.border = '0'; s.background = 'transparent'; - s.color = '#1a1a1a'; + s.color = 'var(--adt-text, #1a1a1a)'; s.fontSize = '16px'; s.cursor = 'pointer'; } @@ -401,7 +402,7 @@ function applyModalBodyStyles(el: HTMLElement): void { function applyModalIntroStyles(el: HTMLElement): void { const s = el.style; s.margin = '0'; - s.color = '#3a3a3a'; + s.color = 'var(--adt-text-muted, #3a3a3a)'; s.lineHeight = '1.5'; } @@ -409,8 +410,8 @@ function applyCommandBoxStyles(el: HTMLElement): void { const s = el.style; s.margin = '0'; s.padding = '10px 12px'; - s.background = '#f5f5f5'; - s.border = '1px solid rgba(0, 0, 0, 0.08)'; + s.background = 'var(--adt-surface-raised, #f5f5f5)'; + s.border = '1px solid var(--adt-border, rgba(0, 0, 0, 0.08))'; s.borderRadius = '8px'; s.fontFamily = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'; s.fontSize = '12px'; @@ -428,10 +429,10 @@ function applyActionsStyles(el: HTMLElement): void { function applyCopyButtonStyles(el: HTMLButtonElement): void { const s = el.style; s.padding = '6px 14px'; - s.border = '1px solid rgba(0, 0, 0, 0.16)'; + s.border = '1px solid var(--adt-border, rgba(0, 0, 0, 0.16))'; s.borderRadius = '8px'; - s.background = '#1a1a1a'; - s.color = '#ffffff'; + s.background = 'var(--adt-accent, #1a1a1a)'; + s.color = 'var(--adt-accent-text, #ffffff)'; s.fontSize = '13px'; s.fontWeight = '600'; s.cursor = 'pointer'; @@ -447,7 +448,7 @@ function applyFileLabelStyles(el: HTMLElement): void { const s = el.style; s.margin = '0'; s.fontSize = '11px'; - s.color = '#6a6a6a'; + s.color = 'var(--adt-text-muted, #6a6a6a)'; s.fontFamily = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'; } @@ -457,9 +458,9 @@ function applyOptionSectionStyles(el: HTMLElement): void { s.flexDirection = 'column'; s.gap = '8px'; s.padding = '12px'; - s.border = '1px solid rgba(0, 0, 0, 0.08)'; + s.border = '1px solid var(--adt-border, rgba(0, 0, 0, 0.08))'; s.borderRadius = '8px'; - s.background = '#fafafa'; + s.background = 'var(--adt-surface-raised, #fafafa)'; } function applyOptionHeadingStyles(el: HTMLElement): void { @@ -467,7 +468,7 @@ function applyOptionHeadingStyles(el: HTMLElement): void { s.margin = '0'; s.fontSize = '13px'; s.fontWeight = '600'; - s.color = '#1a1a1a'; + s.color = 'var(--adt-text, #1a1a1a)'; } function applyOptionCaptionStyles(el: HTMLElement): void { @@ -475,5 +476,5 @@ function applyOptionCaptionStyles(el: HTMLElement): void { s.margin = '0'; s.fontSize = '12px'; s.lineHeight = '1.45'; - s.color = '#4a4a4a'; + s.color = 'var(--adt-text-muted, #4a4a4a)'; } diff --git a/packages/widget-core/src/launcher/launcher.ts b/packages/widget-core/src/launcher/launcher.ts index c1249d3..ac3fec6 100644 --- a/packages/widget-core/src/launcher/launcher.ts +++ b/packages/widget-core/src/launcher/launcher.ts @@ -2,7 +2,7 @@ * Floating launcher button. Owns three concerns: * * 1. Render a fixed-position button (anchored to the bottom-right of the - * container provided by ADT-20's shadow root) with sensible defaults. + * container provided by the widget's shadow root) with sensible defaults. * 2. Translate pointer events into reducer events so click vs. drag is * decided by a pure state machine. The wiring captures the pointer on * press so the drag survives even when the cursor leaves the button. @@ -217,9 +217,9 @@ function applyStaticStyles(button: HTMLButtonElement, size: number): void { s.padding = '0'; s.margin = '0'; s.cursor = 'grab'; - s.background = '#1a1a1a'; - s.color = '#ffffff'; - s.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.25)'; + s.background = 'var(--adt-accent, #1a1a1a)'; + s.color = 'var(--adt-accent-text, #ffffff)'; + s.boxShadow = '0 4px 16px var(--adt-shadow, rgba(0, 0, 0, 0.25))'; s.display = 'flex'; s.alignItems = 'center'; s.justifyContent = 'center'; diff --git a/packages/widget-core/src/observers/types.ts b/packages/widget-core/src/observers/types.ts index b902bef..5c3ceb8 100644 --- a/packages/widget-core/src/observers/types.ts +++ b/packages/widget-core/src/observers/types.ts @@ -1,6 +1,6 @@ /** * Structured records emitted by the page observers. The agent context layer - * (ADT-19) takes these and ships them up to the server alongside the picked + * takes these and ships them up to the server alongside the picked * element and page files. Keep the shape stable: the server-side prompt * formatter and any future report tooling read these fields directly. */ diff --git a/packages/widget-core/src/orchestrator/mount.test.ts b/packages/widget-core/src/orchestrator/mount.test.ts index 959b781..3c1cadb 100644 --- a/packages/widget-core/src/orchestrator/mount.test.ts +++ b/packages/widget-core/src/orchestrator/mount.test.ts @@ -554,6 +554,25 @@ describe('mountAgentDevtools — settings panel wiring', () => { handle.destroy(); }); + it('seeds the host data-theme from the settings store and follows changes', () => { + const settingsStore = createSettingsStore(); + settingsStore.set({ theme: 'dark' }); + const handle = mountAgentDevtools({ settingsStore }); + // Initial attribute mirrors the store value present at mount time. + expect(handle.widget.host.getAttribute('data-theme')).toBe('dark'); + // A later store change flips the single host attribute — that one write + // is what recolours the whole widget via the CSS tokens. + settingsStore.set({ theme: 'light' }); + expect(handle.widget.host.getAttribute('data-theme')).toBe('light'); + handle.destroy(); + }); + + it('defaults the host data-theme to auto when no theme was chosen', () => { + const handle = mountAgentDevtools(); + expect(handle.widget.host.getAttribute('data-theme')).toBe('auto'); + handle.destroy(); + }); + it('creates an internal settings store when none is supplied', () => { const handle = mountAgentDevtools(); expect(handle.settingsStore).toBeDefined(); @@ -1046,3 +1065,141 @@ describe('mountAgentDevtools — enrichPageContext wiring', () => { }); }); }); + +describe('mountAgentDevtools — visibility persistence', () => { + const PANEL_OPEN_KEY = 'agent-devtools:panelOpen'; + const WIDGET_VISIBLE_KEY = 'agent-devtools:widgetVisible'; + + function pressToggleHotkey(): void { + document.dispatchEvent( + new KeyboardEvent('keydown', { + ctrlKey: true, + shiftKey: true, + code: 'Semicolon', + key: ';', + bubbles: true, + cancelable: true, + }), + ); + } + + function pointer(type: 'pointerdown' | 'pointerup', x: number, y: number): Event { + const Ctor = (globalThis as unknown as { PointerEvent?: typeof PointerEvent }).PointerEvent; + if (Ctor) { + return new Ctor(type, { + bubbles: true, + cancelable: true, + pointerId: 1, + button: 0, + clientX: x, + clientY: y, + }); + } + const ev = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperties(ev, { + pointerId: { value: 1 }, + button: { value: 0 }, + clientX: { value: x }, + clientY: { value: y }, + }); + return ev; + } + + function clickLauncher(handle: ReturnType): void { + const btn = handle.launcher.element; + // A no-move pointerdown→pointerup is what the reducer treats as a click. + btn.dispatchEvent(pointer('pointerdown', 24, 24)); + btn.dispatchEvent(pointer('pointerup', 24, 24)); + } + + it('restores an open panel from storage on mount', () => { + globalThis.localStorage.setItem(PANEL_OPEN_KEY, 'true'); + const handle = mountAgentDevtools(); + expect(handle.composer.element.style.display).toBe('flex'); + handle.destroy(); + }); + + it('starts closed when nothing is persisted', () => { + const handle = mountAgentDevtools(); + expect(handle.composer.element.style.display).toBe('none'); + handle.destroy(); + }); + + it('restores a hidden widget from storage even when defaultVisible would show it', () => { + globalThis.localStorage.setItem(WIDGET_VISIBLE_KEY, 'false'); + const handle = mountAgentDevtools(); + expect(handle.launcher.isVisible()).toBe(false); + expect(handle.composer.element.style.display).toBe('none'); + handle.destroy(); + }); + + it('a persisted widget-visible flag overrides defaultVisible: false', () => { + globalThis.localStorage.setItem(WIDGET_VISIBLE_KEY, 'true'); + const handle = mountAgentDevtools({ defaultVisible: false }); + expect(handle.launcher.isVisible()).toBe(true); + handle.destroy(); + }); + + it('persists a launcher-driven open across a remount', () => { + const first = mountAgentDevtools(); + expect(first.composer.element.style.display).toBe('none'); + clickLauncher(first); + expect(first.composer.element.style.display).toBe('flex'); + expect(globalThis.localStorage.getItem(PANEL_OPEN_KEY)).toBe('true'); + first.destroy(); + + const second = mountAgentDevtools(); + expect(second.composer.element.style.display).toBe('flex'); + second.destroy(); + }); + + it('persists an Escape-driven close', () => { + globalThis.localStorage.setItem(PANEL_OPEN_KEY, 'true'); + const handle = mountAgentDevtools(); + expect(handle.composer.element.style.display).toBe('flex'); + const textarea = handle.composer.element.querySelector('textarea') as HTMLTextAreaElement; + textarea.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }), + ); + expect(handle.composer.element.style.display).toBe('none'); + expect(globalThis.localStorage.getItem(PANEL_OPEN_KEY)).toBe('false'); + handle.destroy(); + }); + + it('persists a hotkey-driven widget toggle across a remount', () => { + const first = mountAgentDevtools({ defaultVisible: false }); + expect(first.launcher.isVisible()).toBe(false); + pressToggleHotkey(); + expect(first.launcher.isVisible()).toBe(true); + expect(globalThis.localStorage.getItem(WIDGET_VISIBLE_KEY)).toBe('true'); + first.destroy(); + + const second = mountAgentDevtools({ defaultVisible: false }); + expect(second.launcher.isVisible()).toBe(true); + second.destroy(); + }); + + it('hiding the whole widget does not clobber the persisted panel-open choice', () => { + globalThis.localStorage.setItem(PANEL_OPEN_KEY, 'true'); + const handle = mountAgentDevtools(); + expect(handle.composer.element.style.display).toBe('flex'); + pressToggleHotkey(); // widget goes dark — system-driven collapse + expect(handle.composer.element.style.display).toBe('none'); + // The collapse is visual only; the user's open choice must survive. + expect(globalThis.localStorage.getItem(PANEL_OPEN_KEY)).toBe('true'); + handle.destroy(); + }); + + it('toggling the picker does not clobber the persisted panel-open choice', () => { + globalThis.localStorage.setItem(PANEL_OPEN_KEY, 'true'); + const handle = mountAgentDevtools(); + expect(handle.composer.element.style.display).toBe('flex'); + const pickButton = handle.composer.element.querySelector( + '[data-agent-devtools-composer-pick]', + ) as HTMLButtonElement; + pickButton.click(); // panel hides transiently while picking + expect(handle.composer.element.style.display).toBe('none'); + expect(globalThis.localStorage.getItem(PANEL_OPEN_KEY)).toBe('true'); + handle.destroy(); + }); +}); diff --git a/packages/widget-core/src/orchestrator/mount.ts b/packages/widget-core/src/orchestrator/mount.ts index f58da5d..94260e7 100644 --- a/packages/widget-core/src/orchestrator/mount.ts +++ b/packages/widget-core/src/orchestrator/mount.ts @@ -51,7 +51,13 @@ import { type SettingsPanelHandle, type SettingsStore, } from '../settings/index.js'; -import { createShadowWidgetRoot, type ShadowWidgetRoot } from '../widget/index.js'; +import { createShadowWidgetRoot, THEME_ATTR, type ShadowWidgetRoot } from '../widget/index.js'; +import { + loadPanelOpen, + loadWidgetVisible, + savePanelOpen, + saveWidgetVisible, +} from './visibility-storage.js'; export interface TransportPayload { readonly text: string; @@ -218,7 +224,7 @@ export interface AgentDevtoolsHandle { } const NO_TRANSPORT_MESSAGE = - 'Agent server not configured. Wire `transport` into mountAgentDevtools() or wait for ADT-26.'; + 'Agent server not configured. Wire `transport` into mountAgentDevtools().'; export function mountAgentDevtools(options: MountAgentDevtoolsOptions = {}): AgentDevtoolsHandle { if (!options.force && isProductionBuild()) { @@ -241,9 +247,28 @@ export function mountAgentDevtools(options: MountAgentDevtoolsOptions = {}): Age const settingsStore = options.settingsStore ?? createSettingsStore(); let settingsVisible = false; + // Drive the widget colour scheme off a single `data-theme` attribute on the + // shadow host. Every component reads `var(--adt-*)` tokens defined per theme + // in the shadow root, so flipping this one attribute recolours the whole + // widget without re-rendering any component. `auto` defers to the host OS + // `prefers-color-scheme` via the media query in the base styles. + widget.host.setAttribute(THEME_ATTR, settingsStore.get().theme); + let lastTheme = settingsStore.get().theme; + const unsubscribeTheme = settingsStore.subscribe((settings) => { + if (settings.theme === lastTheme) return; + lastTheme = settings.theme; + widget.host.setAttribute(THEME_ATTR, settings.theme); + }); + + // Restore the user's last open/closed choice so a refresh re-opens the + // panel they left open. Nothing persisted yet → start closed (the + // composer's own default). + const persistedPanelOpen = loadPanelOpen() ?? false; + const composer = createComposer({ container: widget.container, document: doc, + visible: persistedPanelOpen, onSubmit: handleSubmit, onTogglePicker: handleTogglePicker, onToggleSettings: () => toggleSettings(!settingsVisible), @@ -336,6 +361,9 @@ export function mountAgentDevtools(options: MountAgentDevtoolsOptions = {}): Age composer.setPicked(resolvePicked(element)); composer.setPickerActive(false); composer.setVisible(true); + // Picking lands the user in an open panel — persist that as their + // open/closed choice so a refresh keeps it open. + savePanelOpen(true); streamRenderer.scrollToBottom(); composer.focus(); }, @@ -350,6 +378,9 @@ export function mountAgentDevtools(options: MountAgentDevtoolsOptions = {}): Age onClick(): void { const willOpen = composer.element.style.display === 'none'; composer.setVisible(willOpen); + // The launcher click is the canonical user-driven open/close toggle — + // persist it so the panel reopens (or stays closed) after a refresh. + savePanelOpen(willOpen); if (willOpen) { // The list does not retain scrollTop while `display: none` because // the browser does not lay it out. Re-anchor to the latest turn @@ -434,6 +465,8 @@ export function mountAgentDevtools(options: MountAgentDevtoolsOptions = {}): Age function handleClose(): void { composer.setVisible(false); + // Escape / close button are user-driven closes — remember them. + savePanelOpen(false); } function handleNewSession(): void { @@ -518,20 +551,30 @@ export function mountAgentDevtools(options: MountAgentDevtoolsOptions = {}): Age // operators can toggle the entire devtools surface with one hotkey // — and so `defaultVisible: false` ships a fully dormant widget for // dev environments where non-frontend users load the page. - let widgetVisible = options.defaultVisible ?? true; + // + // A persisted choice (from a prior hotkey toggle) wins over + // `defaultVisible`, which is only the seed for a first-ever visit. + let widgetVisible = loadWidgetVisible() ?? options.defaultVisible ?? true; if (!widgetVisible) { launcher.setVisible(false); + // Collapse the composer's DOM without touching the persisted panel-open + // choice — this is a system-driven hide, not the user closing the panel. composer.setVisible(false); } function setWidgetVisible(next: boolean): void { if (widgetVisible === next) return; widgetVisible = next; + // The hotkey toggle is user-driven — remember it across reloads. + saveWidgetVisible(next); launcher.setVisible(next); if (!next) { // Going dark: collapse the composer and abort any picker so the - // widget surface leaves no overlay behind. Keep the message store - // intact — toggling visibility is not "new session". + // widget surface leaves no overlay behind. The composer's DOM hides + // but the persisted panel-open choice is left intact (system-driven + // collapse), so a later refresh with the widget shown can still + // restore the panel. Keep the message store intact too — toggling + // visibility is not "new session". composer.setVisible(false); picker.cancel(); } @@ -573,6 +616,7 @@ export function mountAgentDevtools(options: MountAgentDevtoolsOptions = {}): Age inflight?.abort(); handoffController?.abort(); unsubscribeSafeMode(); + unsubscribeTheme(); picker.stop(); observer.stop(); handoffModal.destroy(); diff --git a/packages/widget-core/src/orchestrator/visibility-storage.test.ts b/packages/widget-core/src/orchestrator/visibility-storage.test.ts new file mode 100644 index 0000000..5921a44 --- /dev/null +++ b/packages/widget-core/src/orchestrator/visibility-storage.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { + PANEL_OPEN_STORAGE_KEY, + WIDGET_VISIBLE_STORAGE_KEY, + loadPanelOpen, + loadWidgetVisible, + savePanelOpen, + saveWidgetVisible, +} from './visibility-storage.js'; + +function makeStorage(initial: Record = {}): Storage { + const map = new Map(Object.entries(initial)); + return { + get length(): number { + return map.size; + }, + clear(): void { + map.clear(); + }, + getItem(key: string): string | null { + return map.has(key) ? (map.get(key) as string) : null; + }, + key(index: number): string | null { + return Array.from(map.keys())[index] ?? null; + }, + removeItem(key: string): void { + map.delete(key); + }, + setItem(key: string, value: string): void { + map.set(key, value); + }, + }; +} + +function throwingStorage(op: 'read' | 'write'): Storage { + const real = makeStorage(); + return { + get length(): number { + return real.length; + }, + clear(): void { + real.clear(); + }, + getItem(key: string): string | null { + if (op === 'read') throw new Error('boom'); + return real.getItem(key); + }, + key(index: number): string | null { + return real.key(index); + }, + removeItem(key: string): void { + real.removeItem(key); + }, + setItem(key: string, value: string): void { + if (op === 'write') throw new Error('boom'); + real.setItem(key, value); + }, + }; +} + +describe('visibility-storage', () => { + it('returns null when nothing is stored', () => { + const storage = makeStorage(); + expect(loadWidgetVisible({ storage })).toBeNull(); + expect(loadPanelOpen({ storage })).toBeNull(); + }); + + it('round-trips the widget-visible flag', () => { + const storage = makeStorage(); + saveWidgetVisible(true, { storage }); + expect(storage.getItem(WIDGET_VISIBLE_STORAGE_KEY)).toBe('true'); + expect(loadWidgetVisible({ storage })).toBe(true); + saveWidgetVisible(false, { storage }); + expect(loadWidgetVisible({ storage })).toBe(false); + }); + + it('round-trips the panel-open flag under its own key', () => { + const storage = makeStorage(); + savePanelOpen(true, { storage }); + expect(storage.getItem(PANEL_OPEN_STORAGE_KEY)).toBe('true'); + // The two axes are independent — saving panel-open must not touch the + // widget-visible key. + expect(storage.getItem(WIDGET_VISIBLE_STORAGE_KEY)).toBeNull(); + expect(loadPanelOpen({ storage })).toBe(true); + }); + + it('treats a non-canonical stored value as "not stored"', () => { + const storage = makeStorage({ + [WIDGET_VISIBLE_STORAGE_KEY]: 'garbage', + [PANEL_OPEN_STORAGE_KEY]: '1', + }); + expect(loadWidgetVisible({ storage })).toBeNull(); + expect(loadPanelOpen({ storage })).toBeNull(); + }); + + it('disables persistence when storage is null', () => { + expect(loadWidgetVisible({ storage: null })).toBeNull(); + expect(() => saveWidgetVisible(true, { storage: null })).not.toThrow(); + expect(() => savePanelOpen(true, { storage: null })).not.toThrow(); + }); + + it('swallows storage exceptions on read and write', () => { + expect(loadWidgetVisible({ storage: throwingStorage('read') })).toBeNull(); + expect(() => saveWidgetVisible(true, { storage: throwingStorage('write') })).not.toThrow(); + }); +}); diff --git a/packages/widget-core/src/orchestrator/visibility-storage.ts b/packages/widget-core/src/orchestrator/visibility-storage.ts new file mode 100644 index 0000000..40d2c05 --- /dev/null +++ b/packages/widget-core/src/orchestrator/visibility-storage.ts @@ -0,0 +1,83 @@ +/** + * Visibility persistence for the two on/off axes the orchestrator owns: + * + * - `widgetVisible` — is the whole devtools surface (launcher + composer) + * present at all. Flipped by the Ctrl/Cmd+Shift+; hotkey. + * - `panelOpen` — is the chat composer panel open, while the widget is + * visible. Flipped by clicking the launcher, the close button, Escape, + * or picking an element. + * + * Both live here rather than inside the composer because only the + * orchestrator can tell a *user-driven* open/close apart from a + * *system-driven* transient collapse (the panel hides while element-picking, + * or the whole widget goes dark). Persisting inside the composer would clobber + * the user's open/closed choice on every transient flip. Mirrors the shape of + * `settings/storage.ts` / `launcher/storage.ts`: localStorage by default, + * wrapped in try/catch because storage is unavailable under file://, Safari + * private mode, sandboxed iframes, and quota-exceeded conditions. A dropped + * read falls back to the caller's default; a dropped write loses the user's + * most recent toggle, which is acceptable for a dev-only widget. + */ +export const WIDGET_VISIBLE_STORAGE_KEY = 'agent-devtools:widgetVisible'; +export const PANEL_OPEN_STORAGE_KEY = 'agent-devtools:panelOpen'; + +export interface VisibilityStorageOptions { + /** Storage backend. Defaults to `globalThis.localStorage`. Pass `null` to disable. */ + readonly storage?: Storage | null; +} + +function resolveStorage(options: VisibilityStorageOptions): Storage | null { + if (options.storage !== undefined) return options.storage; + try { + return globalThis.localStorage ?? null; + } catch { + return null; + } +} + +/** + * Read a persisted boolean. Returns `null` when nothing is stored (or the + * stored value is not one of the two canonical strings), so the caller can + * apply its own default rather than guessing. + */ +function loadBoolean(key: string, options: VisibilityStorageOptions): boolean | null { + const storage = resolveStorage(options); + if (!storage) return null; + let raw: string | null; + try { + raw = storage.getItem(key); + } catch { + return null; + } + if (raw === 'true') return true; + if (raw === 'false') return false; + return null; +} + +function saveBoolean(key: string, value: boolean, options: VisibilityStorageOptions): void { + const storage = resolveStorage(options); + if (!storage) return; + try { + storage.setItem(key, value ? 'true' : 'false'); + } catch { + /* silent — quota / disabled storage is fine */ + } +} + +/** Persisted widget-level visibility, or `null` when nothing is stored. */ +export function loadWidgetVisible(options: VisibilityStorageOptions = {}): boolean | null { + return loadBoolean(WIDGET_VISIBLE_STORAGE_KEY, options); +} + +export function saveWidgetVisible(visible: boolean, options: VisibilityStorageOptions = {}): void { + saveBoolean(WIDGET_VISIBLE_STORAGE_KEY, visible, options); +} + +/** Persisted composer-open state, or `null` when nothing is stored. */ +export function loadPanelOpen(options: VisibilityStorageOptions = {}): boolean | null { + return loadBoolean(PANEL_OPEN_STORAGE_KEY, options); +} + +export function savePanelOpen(open: boolean, options: VisibilityStorageOptions = {}): void { + saveBoolean(PANEL_OPEN_STORAGE_KEY, open, options); +} diff --git a/packages/widget-core/src/picker/overlay.ts b/packages/widget-core/src/picker/overlay.ts index f1795a7..2f8d938 100644 --- a/packages/widget-core/src/picker/overlay.ts +++ b/packages/widget-core/src/picker/overlay.ts @@ -1,7 +1,7 @@ /** * Floating outline that follows the hovered element. Implemented as a single * absolutely-positioned div parented to . We deliberately avoid Shadow - * DOM here (that's the widget's concern, ADT-20); the picker overlay must + * DOM here (that's the widget's concern); the picker overlay must * sit on the top compositing layer with `pointer-events: none` so clicks * fall through to the underlying target. * diff --git a/packages/widget-core/src/settings/panel.test.ts b/packages/widget-core/src/settings/panel.test.ts index bb575d5..4d47420 100644 --- a/packages/widget-core/src/settings/panel.test.ts +++ b/packages/widget-core/src/settings/panel.test.ts @@ -51,6 +51,22 @@ function permissionInput(mode: string): HTMLInputElement { return el; } +function themeInput(mode: 'auto' | 'light' | 'dark'): HTMLInputElement { + const el = container.querySelector( + `[data-agent-devtools-settings-theme="${mode}"]`, + ); + if (!el) throw new Error(`theme input not found: ${mode}`); + return el; +} + +function modelInput(id: 'default' | 'opus' | 'sonnet' | 'haiku'): HTMLInputElement { + const el = container.querySelector( + `[data-agent-devtools-settings-model="${id}"]`, + ); + if (!el) throw new Error(`model input not found: ${id}`); + return el; +} + describe('createSettingsPanel', () => { it('renders provider + permission radios reflecting the current settings', () => { const store = createSettingsStore({ storage: makeStorage() }); @@ -80,6 +96,57 @@ describe('createSettingsPanel', () => { expect(store.get().permissionMode).toBe('plan'); }); + it('renders the theme radios with auto selected by default', () => { + const store = createSettingsStore({ storage: makeStorage() }); + createSettingsPanel({ container, store, visible: true }); + expect(themeInput('auto').checked).toBe(true); + expect(themeInput('light').checked).toBe(false); + expect(themeInput('dark').checked).toBe(false); + }); + + it('toggling a theme radio mutates the store', () => { + const store = createSettingsStore({ storage: makeStorage() }); + createSettingsPanel({ container, store, visible: true }); + const darkRadio = themeInput('dark'); + darkRadio.checked = true; + darkRadio.dispatchEvent(new Event('change', { bubbles: true })); + expect(store.get().theme).toBe('dark'); + }); + + it('re-renders the theme selection when the store changes from outside', () => { + const store = createSettingsStore({ storage: makeStorage() }); + createSettingsPanel({ container, store, visible: true }); + store.set({ theme: 'light' }); + expect(themeInput('light').checked).toBe(true); + expect(themeInput('auto').checked).toBe(false); + }); + + it('renders the model radios with default selected by default', () => { + const store = createSettingsStore({ storage: makeStorage() }); + createSettingsPanel({ container, store, visible: true }); + expect(modelInput('default').checked).toBe(true); + expect(modelInput('opus').checked).toBe(false); + expect(modelInput('sonnet').checked).toBe(false); + expect(modelInput('haiku').checked).toBe(false); + }); + + it('toggling a model radio mutates the store', () => { + const store = createSettingsStore({ storage: makeStorage() }); + createSettingsPanel({ container, store, visible: true }); + const opusRadio = modelInput('opus'); + opusRadio.checked = true; + opusRadio.dispatchEvent(new Event('change', { bubbles: true })); + expect(store.get().model).toBe('opus'); + }); + + it('re-renders the model selection when the store changes from outside', () => { + const store = createSettingsStore({ storage: makeStorage() }); + createSettingsPanel({ container, store, visible: true }); + store.set({ model: 'sonnet' }); + expect(modelInput('sonnet').checked).toBe(true); + expect(modelInput('default').checked).toBe(false); + }); + it('selecting bypassPermissions prompts for confirmation and commits on accept', () => { const store = createSettingsStore({ storage: makeStorage() }); createSettingsPanel({ container, store, visible: true }); diff --git a/packages/widget-core/src/settings/panel.ts b/packages/widget-core/src/settings/panel.ts index c8f3241..e49fdae 100644 --- a/packages/widget-core/src/settings/panel.ts +++ b/packages/widget-core/src/settings/panel.ts @@ -11,18 +11,24 @@ * explicitly; the chat composer has no surface for it by design. */ import { + MODEL_IDS, PERMISSION_MODES, PROVIDER_IDS, + THEME_MODES, type AgentServerInfo, + type ModelId, type PermissionMode, type ProviderId, type Settings, + type ThemeMode, } from './types.js'; import type { SettingsStore } from './store.js'; const PANEL_ATTR = 'data-agent-devtools-settings'; const PROVIDER_RADIO_ATTR = 'data-agent-devtools-settings-provider'; +const MODEL_RADIO_ATTR = 'data-agent-devtools-settings-model'; const PERMISSION_RADIO_ATTR = 'data-agent-devtools-settings-permission'; +const THEME_RADIO_ATTR = 'data-agent-devtools-settings-theme'; const WORKSPACE_ATTR = 'data-agent-devtools-settings-workspace'; const CLOSE_ATTR = 'data-agent-devtools-settings-close'; @@ -31,6 +37,13 @@ const PROVIDER_LABELS: Record = { sdk: 'SDK (Claude Agent SDK, in-process)', }; +const MODEL_LABELS: Record = { + default: 'Default — use the provider default model (recommended)', + opus: 'Opus — most capable', + sonnet: 'Sonnet — balanced', + haiku: 'Haiku — fastest', +}; + const PERMISSION_LABELS: Record = { default: 'Default — reject every permission request', acceptEdits: 'Accept edits — auto-allow routine file edits (recommended)', @@ -39,6 +52,12 @@ const PERMISSION_LABELS: Record = { dontAsk: "Don't ask — same as Accept edits, never surface prompts", }; +const THEME_LABELS: Record = { + auto: 'Auto — follow the OS appearance (recommended)', + light: 'Light', + dark: 'Dark', +}; + export interface CreateSettingsPanelOptions { /** Shadow-root container to mount inside. */ readonly container: HTMLElement; @@ -111,6 +130,23 @@ export function createSettingsPanel(options: CreateSettingsPanelOptions): Settin providerSection.body.appendChild(providerFieldset); panel.appendChild(providerSection.root); + // Model section + const modelSection = buildSection(doc, 'Model'); + const modelFieldset = doc.createElement('fieldset'); + applyFieldsetStyles(modelFieldset); + const modelRadios: Record = createRadioGroup( + doc, + 'model', + MODEL_IDS, + MODEL_LABELS, + MODEL_RADIO_ATTR, + ); + for (const id of MODEL_IDS) { + modelFieldset.appendChild(modelRadios[id].parentElement as HTMLElement); + } + modelSection.body.appendChild(modelFieldset); + panel.appendChild(modelSection.root); + // Permission section const permissionSection = buildSection(doc, 'Permission Mode'); const permissionFieldset = doc.createElement('fieldset'); @@ -130,6 +166,23 @@ export function createSettingsPanel(options: CreateSettingsPanelOptions): Settin permissionSection.body.appendChild(permissionFieldset); panel.appendChild(permissionSection.root); + // Theme section + const themeSection = buildSection(doc, 'Theme'); + const themeFieldset = doc.createElement('fieldset'); + applyFieldsetStyles(themeFieldset); + const themeRadios: Record = createRadioGroup( + doc, + 'theme', + THEME_MODES, + THEME_LABELS, + THEME_RADIO_ATTR, + ); + for (const mode of THEME_MODES) { + themeFieldset.appendChild(themeRadios[mode].parentElement as HTMLElement); + } + themeSection.body.appendChild(themeFieldset); + panel.appendChild(themeSection.root); + // Workspace section (read-only) const workspaceSection = buildSection(doc, 'Workspace Root'); const workspaceValue = doc.createElement('code'); @@ -144,9 +197,15 @@ export function createSettingsPanel(options: CreateSettingsPanelOptions): Settin for (const id of PROVIDER_IDS) { providerRadios[id].checked = settings.provider === id; } + for (const id of MODEL_IDS) { + modelRadios[id].checked = settings.model === id; + } for (const mode of PERMISSION_MODES) { permissionRadios[mode].checked = settings.permissionMode === mode; } + for (const mode of THEME_MODES) { + themeRadios[mode].checked = settings.theme === mode; + } } function renderServerInfo(): void { @@ -184,6 +243,12 @@ export function createSettingsPanel(options: CreateSettingsPanelOptions): Settin const value = target.value as ProviderId; options.store.set({ provider: value }); } + function onModelChange(event: Event): void { + const target = event.target as HTMLInputElement; + if (!target.checked) return; + const value = target.value as ModelId; + options.store.set({ model: value }); + } function onPermissionChange(event: Event): void { const target = event.target as HTMLInputElement; if (!target.checked) return; @@ -208,6 +273,12 @@ export function createSettingsPanel(options: CreateSettingsPanelOptions): Settin } options.store.set({ permissionMode: value }); } + function onThemeChange(event: Event): void { + const target = event.target as HTMLInputElement; + if (!target.checked) return; + const value = target.value as ThemeMode; + options.store.set({ theme: value }); + } function onCloseClick(): void { options.onClose?.(); } @@ -215,9 +286,15 @@ export function createSettingsPanel(options: CreateSettingsPanelOptions): Settin for (const id of PROVIDER_IDS) { providerRadios[id].addEventListener('change', onProviderChange); } + for (const id of MODEL_IDS) { + modelRadios[id].addEventListener('change', onModelChange); + } for (const mode of PERMISSION_MODES) { permissionRadios[mode].addEventListener('change', onPermissionChange); } + for (const mode of THEME_MODES) { + themeRadios[mode].addEventListener('change', onThemeChange); + } closeButton.addEventListener('click', onCloseClick); return { @@ -239,9 +316,15 @@ export function createSettingsPanel(options: CreateSettingsPanelOptions): Settin for (const id of PROVIDER_IDS) { providerRadios[id].removeEventListener('change', onProviderChange); } + for (const id of MODEL_IDS) { + modelRadios[id].removeEventListener('change', onModelChange); + } for (const mode of PERMISSION_MODES) { permissionRadios[mode].removeEventListener('change', onPermissionChange); } + for (const mode of THEME_MODES) { + themeRadios[mode].removeEventListener('change', onThemeChange); + } closeButton.removeEventListener('click', onCloseClick); panel.remove(); }, @@ -302,8 +385,8 @@ function applyPanelStyles(panel: HTMLElement): void { s.inset = '0'; s.display = 'none'; s.flexDirection = 'column'; - s.background = '#ffffff'; - s.color = '#1a1a1a'; + s.background = 'var(--adt-surface, #ffffff)'; + s.color = 'var(--adt-text, #1a1a1a)'; s.overflowY = 'auto'; s.fontFamily = 'inherit'; s.fontSize = '13px'; @@ -315,7 +398,7 @@ function applyHeaderStyles(header: HTMLElement): void { s.alignItems = 'center'; s.gap = '8px'; s.padding = '10px 12px'; - s.borderBottom = '1px solid rgba(0, 0, 0, 0.06)'; + s.borderBottom = '1px solid var(--adt-border, rgba(0, 0, 0, 0.06))'; } function applyTitleStyles(el: HTMLElement): void { @@ -333,7 +416,7 @@ function applyHeaderCloseStyles(button: HTMLButtonElement): void { s.borderRadius = '6px'; s.border = '0'; s.background = 'transparent'; - s.color = '#666'; + s.color = 'var(--adt-text-muted, #666)'; s.cursor = 'pointer'; s.fontSize = '14px'; s.lineHeight = '1'; @@ -342,7 +425,7 @@ function applyHeaderCloseStyles(button: HTMLButtonElement): void { function applySectionStyles(section: HTMLElement): void { const s = section.style; s.padding = '12px'; - s.borderBottom = '1px solid rgba(0, 0, 0, 0.06)'; + s.borderBottom = '1px solid var(--adt-border, rgba(0, 0, 0, 0.06))'; } function applySectionHeadingStyles(heading: HTMLElement): void { @@ -352,7 +435,7 @@ function applySectionHeadingStyles(heading: HTMLElement): void { s.fontWeight = '600'; s.textTransform = 'uppercase'; s.letterSpacing = '0.04em'; - s.color = '#666'; + s.color = 'var(--adt-text-muted, #666)'; } function applySectionBodyStyles(body: HTMLElement): void { @@ -391,15 +474,15 @@ function applyRadioLabelStyles(label: HTMLElement): void { function applyDangerRowStyles(row: HTMLElement): void { const s = row.style; - s.color = '#a33'; - s.background = 'rgba(255, 0, 0, 0.04)'; + s.color = 'var(--adt-danger, #a33)'; + s.background = 'var(--adt-danger-bg, rgba(255, 0, 0, 0.04))'; } function applyWorkspaceValueStyles(value: HTMLElement): void { const s = value.style; s.display = 'block'; s.padding = '8px 10px'; - s.background = 'rgba(0, 0, 0, 0.04)'; + s.background = 'var(--adt-overlay-weak, rgba(0, 0, 0, 0.04))'; s.borderRadius = '6px'; s.fontFamily = 'ui-monospace, SFMono-Regular, Menlo, monospace'; s.fontSize = '12px'; diff --git a/packages/widget-core/src/settings/storage.test.ts b/packages/widget-core/src/settings/storage.test.ts index 52a279b..51d7310 100644 --- a/packages/widget-core/src/settings/storage.test.ts +++ b/packages/widget-core/src/settings/storage.test.ts @@ -69,10 +69,15 @@ describe('loadSettings', () => { it('round-trips a saved payload', () => { const storage = makeStorage(); - saveSettings({ provider: 'sdk', permissionMode: 'plan', safeMode: true }, { storage }); + saveSettings( + { provider: 'sdk', permissionMode: 'plan', theme: 'dark', model: 'opus', safeMode: true }, + { storage }, + ); expect(loadSettings({ storage })).toEqual({ provider: 'sdk', permissionMode: 'plan', + theme: 'dark', + model: 'opus', safeMode: true, }); }); @@ -80,12 +85,14 @@ describe('loadSettings', () => { it('uses a custom key when provided', () => { const storage = makeStorage(); saveSettings( - { provider: 'sdk', permissionMode: 'plan', safeMode: true }, + { provider: 'sdk', permissionMode: 'plan', theme: 'light', model: 'haiku', safeMode: true }, { storage, key: 'custom' }, ); expect(loadSettings({ storage, key: 'custom' })).toEqual({ provider: 'sdk', permissionMode: 'plan', + theme: 'light', + model: 'haiku', safeMode: true, }); // Defaults under the default key remain untouched. @@ -96,15 +103,67 @@ describe('loadSettings', () => { const storage = makeStorage(); storage.setItem( DEFAULT_SETTINGS_STORAGE_KEY, - JSON.stringify({ provider: 'mystery', permissionMode: 'plan' }), + JSON.stringify({ provider: 'mystery', permissionMode: 'plan', theme: 'dark' }), ); expect(loadSettings({ storage })).toEqual({ provider: DEFAULT_SETTINGS.provider, permissionMode: 'plan', + theme: 'dark', + model: DEFAULT_SETTINGS.model, safeMode: true, }); }); + it('resets a corrupt theme to the default while keeping the rest', () => { + const storage = makeStorage(); + storage.setItem( + DEFAULT_SETTINGS_STORAGE_KEY, + JSON.stringify({ provider: 'sdk', permissionMode: 'plan', theme: 'neon' }), + ); + expect(loadSettings({ storage })).toEqual({ + provider: 'sdk', + permissionMode: 'plan', + theme: DEFAULT_SETTINGS.theme, + model: DEFAULT_SETTINGS.model, + safeMode: true, + }); + }); + + it('round-trips the selected model and resets a corrupt one to the default', () => { + const storage = makeStorage(); + storage.setItem( + DEFAULT_SETTINGS_STORAGE_KEY, + JSON.stringify({ provider: 'sdk', permissionMode: 'plan', theme: 'dark', model: 'sonnet' }), + ); + expect(loadSettings({ storage }).model).toBe('sonnet'); + + storage.setItem( + DEFAULT_SETTINGS_STORAGE_KEY, + JSON.stringify({ provider: 'sdk', permissionMode: 'plan', theme: 'dark', model: 'gpt-9' }), + ); + expect(loadSettings({ storage }).model).toBe(DEFAULT_SETTINGS.model); + }); + + it('defaults model when the persisted payload predates model selection', () => { + const storage = makeStorage(); + // A payload written before the model field existed has no `model` key. + storage.setItem( + DEFAULT_SETTINGS_STORAGE_KEY, + JSON.stringify({ provider: 'sdk', permissionMode: 'plan', theme: 'dark' }), + ); + expect(loadSettings({ storage }).model).toBe(DEFAULT_SETTINGS.model); + }); + + it('defaults theme when the persisted payload predates theming', () => { + const storage = makeStorage(); + // A payload written before the theme field existed has no `theme` key. + storage.setItem( + DEFAULT_SETTINGS_STORAGE_KEY, + JSON.stringify({ provider: 'sdk', permissionMode: 'plan' }), + ); + expect(loadSettings({ storage }).theme).toBe(DEFAULT_SETTINGS.theme); + }); + it('falls back to defaults when the payload is not JSON', () => { const storage = makeStorage(); storage.setItem(DEFAULT_SETTINGS_STORAGE_KEY, '{not json'); @@ -139,7 +198,10 @@ describe('saveSettings', () => { describe('clearSettings', () => { it('removes the stored payload so the next load is default', () => { const storage = makeStorage(); - saveSettings({ provider: 'sdk', permissionMode: 'plan', safeMode: true }, { storage }); + saveSettings( + { provider: 'sdk', permissionMode: 'plan', theme: 'dark', model: 'opus', safeMode: true }, + { storage }, + ); clearSettings({ storage }); expect(loadSettings({ storage })).toEqual(DEFAULT_SETTINGS); }); diff --git a/packages/widget-core/src/settings/storage.ts b/packages/widget-core/src/settings/storage.ts index b2bb7f9..9a0adce 100644 --- a/packages/widget-core/src/settings/storage.ts +++ b/packages/widget-core/src/settings/storage.ts @@ -10,7 +10,14 @@ * field is in-memory only and must re-default to `true` on every widget * mount, so a fresh tab cannot silently inherit a relaxed posture. */ -import { DEFAULT_SETTINGS, isPermissionMode, isProviderId, type Settings } from './types.js'; +import { + DEFAULT_SETTINGS, + isModelId, + isPermissionMode, + isProviderId, + isThemeMode, + type Settings, +} from './types.js'; export const DEFAULT_SETTINGS_STORAGE_KEY = 'agent-devtools:settings'; @@ -51,10 +58,12 @@ export function loadSettings(options: SettingsStorageOptions = {}): Settings { const permissionMode = isPermissionMode(p.permissionMode) ? p.permissionMode : DEFAULT_SETTINGS.permissionMode; + const theme = isThemeMode(p.theme) ? p.theme : DEFAULT_SETTINGS.theme; + const model = isModelId(p.model) ? p.model : DEFAULT_SETTINGS.model; // `safeMode` is never read from storage — it is mount-scoped state that // must always start from the default (`true`). Any value present in the // persisted payload is ignored on purpose. - return { provider, permissionMode, safeMode: DEFAULT_SETTINGS.safeMode }; + return { provider, permissionMode, theme, model, safeMode: DEFAULT_SETTINGS.safeMode }; } catch { return DEFAULT_SETTINGS; } @@ -70,6 +79,8 @@ export function saveSettings(settings: Settings, options: SettingsStorageOptions JSON.stringify({ provider: settings.provider, permissionMode: settings.permissionMode, + theme: settings.theme, + model: settings.model, }), ); return true; diff --git a/packages/widget-core/src/settings/store.test.ts b/packages/widget-core/src/settings/store.test.ts index 6a039bd..98000a4 100644 --- a/packages/widget-core/src/settings/store.test.ts +++ b/packages/widget-core/src/settings/store.test.ts @@ -40,8 +40,15 @@ describe('createSettingsStore', () => { ); const store = createSettingsStore({ storage }); // `safeMode` is in-memory only — even if storage somehow held a value, - // the store must re-default it to `true` on every mount. - expect(store.get()).toEqual({ provider: 'sdk', permissionMode: 'plan', safeMode: true }); + // the store must re-default it to `true` on every mount. `theme` is + // absent from this legacy payload so it falls back to the default. + expect(store.get()).toEqual({ + provider: 'sdk', + permissionMode: 'plan', + theme: DEFAULT_SETTINGS.theme, + model: DEFAULT_SETTINGS.model, + safeMode: true, + }); }); it('applies partial patches and notifies subscribers', () => { @@ -52,6 +59,8 @@ describe('createSettingsStore', () => { expect(store.get()).toEqual({ provider: 'sdk', permissionMode: DEFAULT_SETTINGS.permissionMode, + theme: DEFAULT_SETTINGS.theme, + model: DEFAULT_SETTINGS.model, safeMode: DEFAULT_SETTINGS.safeMode, }); expect(listener).toHaveBeenCalledTimes(1); @@ -66,10 +75,13 @@ describe('createSettingsStore', () => { const raw = storage.getItem('agent-devtools:settings'); expect(raw).not.toBeNull(); // `safeMode` is intentionally absent from the persisted payload — see - // settings/storage.ts for the rationale. + // settings/storage.ts for the rationale. `theme` rides along with the + // persisted fields so a reload keeps the chosen appearance. expect(JSON.parse(raw as string)).toEqual({ provider: 'sdk', permissionMode: 'plan', + theme: DEFAULT_SETTINGS.theme, + model: DEFAULT_SETTINGS.model, }); }); @@ -104,10 +116,33 @@ describe('createSettingsStore', () => { expect(seen).toEqual({ provider: DEFAULT_SETTINGS.provider, permissionMode: 'bypassPermissions', + theme: DEFAULT_SETTINGS.theme, + model: DEFAULT_SETTINGS.model, safeMode: DEFAULT_SETTINGS.safeMode, }); }); + it('persists a theme change and re-hydrates it on the next mount', () => { + const storage = makeStorage(); + const store = createSettingsStore({ storage }); + store.set({ theme: 'dark' }); + expect(store.get().theme).toBe('dark'); + // A freshly-recreated store on the same storage backend recovers the + // persisted theme — unlike safeMode, theme survives a reload. + const next = createSettingsStore({ storage }); + expect(next.get().theme).toBe('dark'); + }); + + it('persists a model change and re-hydrates it on the next mount', () => { + const storage = makeStorage(); + const store = createSettingsStore({ storage }); + store.set({ model: 'opus' }); + expect(store.get().model).toBe('opus'); + // Like theme (and unlike safeMode), the chosen model survives a reload. + const next = createSettingsStore({ storage }); + expect(next.get().model).toBe('opus'); + }); + it('defaults safeMode to true on every store construction', () => { // Recreate the store twice against fresh storage — the second store // must still see `safeMode: true` because the field is mount-scoped diff --git a/packages/widget-core/src/settings/store.ts b/packages/widget-core/src/settings/store.ts index 3314bda..00d8e2b 100644 --- a/packages/widget-core/src/settings/store.ts +++ b/packages/widget-core/src/settings/store.ts @@ -42,19 +42,30 @@ export function createSettingsStore(options: CreateSettingsStoreOptions = {}): S const next: Settings = { provider: patch.provider ?? current.provider, permissionMode: patch.permissionMode ?? current.permissionMode, + theme: patch.theme ?? current.theme, + model: patch.model ?? current.model, safeMode: nextSafeMode, }; const providerChanged = next.provider !== current.provider; const permissionChanged = next.permissionMode !== current.permissionMode; + const themeChanged = next.theme !== current.theme; + const modelChanged = next.model !== current.model; const safeModeChanged = next.safeMode !== current.safeMode; - if (!providerChanged && !permissionChanged && !safeModeChanged) { + if ( + !providerChanged && + !permissionChanged && + !themeChanged && + !modelChanged && + !safeModeChanged + ) { return; } current = next; // `safeMode` is in-memory only — skip the persistence write when the // only change is the safety toggle so storage never carries a stale - // boolean across mounts. - if (providerChanged || permissionChanged) { + // boolean across mounts. `theme` and `model`, like provider/permission, + // are persisted. + if (providerChanged || permissionChanged || themeChanged || modelChanged) { saveSettings(current, options); } emit(); diff --git a/packages/widget-core/src/settings/types.ts b/packages/widget-core/src/settings/types.ts index 7240a10..6ca9888 100644 --- a/packages/widget-core/src/settings/types.ts +++ b/packages/widget-core/src/settings/types.ts @@ -9,6 +9,27 @@ export type ProviderId = 'acp' | 'sdk'; export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'dontAsk'; +/** + * Widget colour scheme. `auto` follows the host OS `prefers-color-scheme`; + * `light` / `dark` pin the scheme regardless of the OS preference. + */ +export type ThemeMode = 'auto' | 'light' | 'dark'; + +/** + * Model selection for the next prompt. These are the same aliases the Claude + * Code terminal's `/model` menu exposes — both the ACP agent and the Agent + * SDK resolve them against the account's real models through the shared SDK + * resolver, so the widget needs no live model-discovery round-trip. `default` + * is the sentinel for "send no model": the chosen provider then falls back to + * its own default, matching the terminal with no `/model` override. + * + * The wire field this maps to is intentionally open (the server validates + * only that it is a non-empty string), so a future full date-pinned id or a + * new tier can be threaded through without a protocol change — this closed + * union is just the widget's curated menu. + */ +export type ModelId = 'default' | 'opus' | 'sonnet' | 'haiku'; + export interface Settings { /** Which runtime backend services the next prompt. */ readonly provider: ProviderId; @@ -18,6 +39,18 @@ export interface Settings { * because it disables every safety prompt for the rest of the session. */ readonly permissionMode: PermissionMode; + /** + * Widget colour scheme. Persisted alongside `provider` so a reload keeps + * the user's chosen appearance. `auto` defers to the host OS preference. + */ + readonly theme: ThemeMode; + /** + * Model the next prompt runs on. `default` sends no model on the wire and + * lets the provider use its own default; the other values are forwarded as + * aliases the provider resolves. Persisted alongside `provider` so a reload + * keeps the user's chosen model. + */ + readonly model: ModelId; /** * Header-level safety switch. When `true` the widget asks the agent to * prompt for `bash`, `webFetch`, and `mcpTool` actions while keeping @@ -38,6 +71,10 @@ export const PERMISSION_MODES: readonly PermissionMode[] = [ 'dontAsk', ]; +export const THEME_MODES: readonly ThemeMode[] = ['auto', 'light', 'dark']; + +export const MODEL_IDS: readonly ModelId[] = ['default', 'opus', 'sonnet', 'haiku']; + /** * Match the server's defaults so a fresh widget mounted with no localStorage * doesn't accidentally diverge from the dev-server's behaviour. @@ -45,6 +82,8 @@ export const PERMISSION_MODES: readonly PermissionMode[] = [ export const DEFAULT_SETTINGS: Settings = { provider: 'acp', permissionMode: 'acceptEdits', + theme: 'auto', + model: 'default', safeMode: true, }; @@ -71,3 +110,11 @@ export function isProviderId(value: unknown): value is ProviderId { export function isPermissionMode(value: unknown): value is PermissionMode { return typeof value === 'string' && (PERMISSION_MODES as readonly string[]).includes(value); } + +export function isThemeMode(value: unknown): value is ThemeMode { + return typeof value === 'string' && (THEME_MODES as readonly string[]).includes(value); +} + +export function isModelId(value: unknown): value is ModelId { + return typeof value === 'string' && (MODEL_IDS as readonly string[]).includes(value); +} diff --git a/packages/widget-core/src/stream/renderer.test.ts b/packages/widget-core/src/stream/renderer.test.ts index 4903aba..18ba064 100644 --- a/packages/widget-core/src/stream/renderer.test.ts +++ b/packages/widget-core/src/stream/renderer.test.ts @@ -471,6 +471,27 @@ describe('createStreamRenderer', () => { handle.destroy(); }); + it('re-paints the working indicator after a tool result while the model round-trips', () => { + const store = createMessageStore({ generateId: counterIds() }); + const handle = createStreamRenderer({ container, store }); + store.appendUserMessage('go'); + store.applyEvent({ type: 'tool-use-start', blockId: 'tu1', name: 'inspect' }); + // Streaming the tool input — indicator hidden. + expect(items(handle).some((n) => n.getAttribute('data-kind') === 'assistant-pending')).toBe( + false, + ); + store.applyEvent({ type: 'tool-use-stop', blockId: 'tu1' }); + store.applyEvent({ type: 'tool-result', toolUseId: 'tu1', content: 'ok' }); + // tool-result is folded into the tool-use
, so the only + // top-level nodes are the user bubble, the tool-use block, and the + // re-painted indicator at the tail. + const rendered = items(handle); + const last = rendered[rendered.length - 1]; + expect(last?.getAttribute('data-kind')).toBe('assistant-pending'); + expect(last?.querySelectorAll('[data-agent-devtools-pending-dot]').length).toBe(3); + handle.destroy(); + }); + it('injects keyframes for the pending dot animation as a sibling of the root', () => { const store = createMessageStore({ generateId: counterIds() }); const handle = createStreamRenderer({ container, store }); diff --git a/packages/widget-core/src/stream/renderer.ts b/packages/widget-core/src/stream/renderer.ts index adaa362..a508b49 100644 --- a/packages/widget-core/src/stream/renderer.ts +++ b/packages/widget-core/src/stream/renderer.ts @@ -593,8 +593,8 @@ function applyPickedChipSummaryStyles(el: HTMLElement): void { s.gap = '6px'; s.padding = '2px 8px'; s.borderRadius = '999px'; - s.background = 'rgba(0, 0, 0, 0.06)'; - s.color = '#1a1a1a'; + s.background = 'var(--adt-overlay-weak, rgba(0, 0, 0, 0.06))'; + s.color = 'var(--adt-text, #1a1a1a)'; s.cursor = 'pointer'; s.userSelect = 'none'; s.listStyle = 'none'; @@ -620,8 +620,10 @@ function applyPickedPanelStyles(el: HTMLElement): void { s.marginTop = '6px'; s.padding = '8px 10px'; s.borderRadius = '8px'; - s.background = '#1a1a1a'; - s.color = '#f5f5f5'; + // The picked-evidence card stays a dark "code card" in both themes; in dark + // it lifts to the raised surface so it reads against the dark panel. + s.background = 'var(--adt-surface-raised, #1a1a1a)'; + s.color = 'var(--adt-text, #f5f5f5)'; s.maxWidth = '100%'; s.display = 'flex'; s.flexDirection = 'column'; @@ -670,7 +672,7 @@ function applyPickedBlockPreStyles(el: HTMLElement): void { const s = el.style; s.margin = '0'; s.padding = '6px 8px'; - s.background = 'rgba(255, 255, 255, 0.06)'; + s.background = 'var(--adt-overlay-weak, rgba(255, 255, 255, 0.06))'; s.borderRadius = '6px'; s.fontFamily = 'ui-monospace, SFMono-Regular, "Cascadia Mono", Menlo, Consolas, "Liberation Mono", monospace'; @@ -685,8 +687,11 @@ function applyUserBodyStyles(el: HTMLElement): void { const s = el.style; s.padding = '8px 12px'; s.borderRadius = '14px 14px 4px 14px'; - s.background = '#1a1a1a'; - s.color = '#ffffff'; + // Light text on the dark/translucent user bubble in both themes: light has + // no --adt-text token so the #ffffff fallback applies; dark resolves the + // token to #e8e8ea. + s.background = 'var(--adt-user-bubble-bg, #1a1a1a)'; + s.color = 'var(--adt-text, #ffffff)'; s.maxWidth = '85%'; s.whiteSpace = 'pre-wrap'; s.wordBreak = 'break-word'; @@ -696,8 +701,8 @@ function applyAssistantBodyStyles(el: HTMLElement): void { const s = el.style; s.padding = '8px 12px'; s.borderRadius = '14px 14px 14px 4px'; - s.background = 'rgba(0, 0, 0, 0.04)'; - s.color = '#1a1a1a'; + s.background = 'var(--adt-assistant-bubble-bg, rgba(0, 0, 0, 0.04))'; + s.color = 'var(--adt-assistant-bubble-text, #1a1a1a)'; s.maxWidth = '85%'; s.whiteSpace = 'pre-wrap'; s.wordBreak = 'break-word'; @@ -714,7 +719,7 @@ function applyAssistantPendingStyles(el: HTMLElement): void { const s = el.style; s.padding = '10px 14px'; s.borderRadius = '14px 14px 14px 4px'; - s.background = 'rgba(0, 0, 0, 0.04)'; + s.background = 'var(--adt-assistant-bubble-bg, rgba(0, 0, 0, 0.04))'; s.display = 'inline-flex'; s.alignItems = 'center'; s.gap = '4px'; @@ -726,7 +731,7 @@ function applyPendingDotStyles(el: HTMLElement, delayMs: number): void { s.width = '6px'; s.height = '6px'; s.borderRadius = '50%'; - s.background = '#1a1a1a'; + s.background = 'var(--adt-assistant-bubble-text, #1a1a1a)'; s.opacity = '0.25'; s.animation = 'agent-devtools-pending-dot 1.4s ease-in-out infinite'; s.animationDelay = `${delayMs}ms`; @@ -746,7 +751,7 @@ function applyToolSummaryStyles(el: HTMLElement, isError = false): void { s.fontSize = '12px'; s.fontFamily = 'ui-monospace, SFMono-Regular, "Cascadia Mono", Menlo, Consolas, "Liberation Mono", monospace'; - s.color = isError ? '#b00020' : '#444'; + s.color = isError ? 'var(--adt-danger, #b00020)' : 'var(--adt-text-muted, #444)'; s.cursor = 'pointer'; s.userSelect = 'none'; s.listStyle = 'none'; @@ -757,16 +762,16 @@ function applyToolDividerStyles(el: HTMLElement, isError = false): void { s.fontSize = '11px'; s.fontFamily = 'ui-monospace, SFMono-Regular, "Cascadia Mono", Menlo, Consolas, "Liberation Mono", monospace'; - s.color = isError ? '#b00020' : '#666'; + s.color = isError ? 'var(--adt-danger, #b00020)' : 'var(--adt-text-muted, #666)'; s.paddingTop = '4px'; - s.borderTop = '1px dashed rgba(0, 0, 0, 0.08)'; + s.borderTop = '1px dashed var(--adt-border, rgba(0, 0, 0, 0.08))'; } function applyToolPreStyles(el: HTMLElement): void { const s = el.style; s.margin = '0'; s.padding = '8px 10px'; - s.background = 'rgba(0, 0, 0, 0.04)'; + s.background = 'var(--adt-overlay-weak, rgba(0, 0, 0, 0.04))'; s.borderRadius = '8px'; s.fontFamily = 'ui-monospace, SFMono-Regular, "Cascadia Mono", Menlo, Consolas, "Liberation Mono", monospace'; @@ -781,7 +786,7 @@ function applyErrorStyles(el: HTMLElement): void { const s = el.style; s.padding = '8px 12px'; s.borderRadius = '8px'; - s.background = 'rgba(176, 0, 32, 0.08)'; - s.color = '#b00020'; + s.background = 'var(--adt-danger-bg, rgba(176, 0, 32, 0.08))'; + s.color = 'var(--adt-danger, #b00020)'; s.fontSize = '12px'; } diff --git a/packages/widget-core/src/stream/store.test.ts b/packages/widget-core/src/stream/store.test.ts index d943a36..0a231b4 100644 --- a/packages/widget-core/src/stream/store.test.ts +++ b/packages/widget-core/src/stream/store.test.ts @@ -119,6 +119,91 @@ describe('createMessageStore', () => { expect(pendings).toHaveLength(1); }); + it('keeps the working indicator up while a tool executes (input finished streaming)', () => { + const store = createMessageStore({ generateId: counterIds(), persist: false }); + store.appendUserMessage('do it'); + store.applyEvent({ type: 'tool-use-start', blockId: 'tu1', name: 'bash' }); + // While the tool input streams, the streaming tool bubble is the signal — + // no separate indicator. + expect(store.getItems().some((it) => it.kind === 'assistant-pending')).toBe(false); + store.applyEvent({ type: 'tool-use-stop', blockId: 'tu1' }); + // Input done → the tool is now executing → indicator returns at the tail. + const items = store.getItems(); + expect(items[items.length - 1]?.kind).toBe('assistant-pending'); + }); + + it('shows the working indicator after a tool result while the model round-trips', () => { + const store = createMessageStore({ generateId: counterIds(), persist: false }); + store.appendUserMessage('do it'); + store.applyEvent({ type: 'tool-use-start', blockId: 'tu1', name: 'bash' }); + store.applyEvent({ type: 'tool-use-stop', blockId: 'tu1' }); + store.applyEvent({ type: 'tool-result', toolUseId: 'tu1', content: 'ok' }); + const items = store.getItems(); + // Exactly one indicator, and it sits AFTER the tool-result (the model is + // about to be called again with the result in hand). + const pendings = items.filter((it) => it.kind === 'assistant-pending'); + expect(pendings).toHaveLength(1); + const resultIdx = items.findIndex((it) => it.kind === 'tool-result'); + const pendingIdx = items.findIndex((it) => it.kind === 'assistant-pending'); + expect(pendingIdx).toBeGreaterThan(resultIdx); + }); + + it('drops the working indicator when the model resumes with text after a tool result', () => { + const store = createMessageStore({ generateId: counterIds(), persist: false }); + store.appendUserMessage('do it'); + store.applyEvent({ type: 'tool-use-start', blockId: 'tu1', name: 'bash' }); + store.applyEvent({ type: 'tool-use-stop', blockId: 'tu1' }); + store.applyEvent({ type: 'tool-result', toolUseId: 'tu1', content: 'ok' }); + store.applyEvent({ type: 'text-delta', blockId: 'b1', text: 'Done' }); + expect(store.getItems().some((it) => it.kind === 'assistant-pending')).toBe(false); + }); + + it('does not show the working indicator after a finished text block', () => { + const store = createMessageStore({ generateId: counterIds(), persist: false }); + store.appendUserMessage('hi'); + store.applyEvent({ type: 'text-delta', blockId: 'b1', text: 'Hello' }); + store.applyEvent({ type: 'text-stop', blockId: 'b1' }); + // A turn that ends on text emits `done` immediately afterwards, so a dot + // here would only flash — it stays hidden. + expect(store.getItems().some((it) => it.kind === 'assistant-pending')).toBe(false); + }); + + it('never shows the working indicator outside an active turn', () => { + const store = createMessageStore({ generateId: counterIds(), persist: false }); + // Tool events with no turn in flight (replay / defensive) must not conjure + // an indicator. + store.applyEvent({ type: 'tool-use-start', blockId: 'tu1', name: 'bash' }); + store.applyEvent({ type: 'tool-use-stop', blockId: 'tu1' }); + store.applyEvent({ type: 'tool-result', toolUseId: 'tu1', content: 'ok' }); + expect(store.getItems().some((it) => it.kind === 'assistant-pending')).toBe(false); + }); + + it('keeps a working indicator through every idle gap of a multi-step turn', () => { + const store = createMessageStore({ generateId: counterIds(), persist: false }); + const pending = (): boolean => store.getItems().some((it) => it.kind === 'assistant-pending'); + + store.appendUserMessage('refactor this'); + expect(pending()).toBe(true); // warming up + + store.applyEvent({ type: 'tool-use-start', blockId: 'tu1', name: 'read' }); + expect(pending()).toBe(false); // streaming tool input + + store.applyEvent({ type: 'tool-use-stop', blockId: 'tu1' }); + expect(pending()).toBe(true); // tool executing + + store.applyEvent({ type: 'tool-result', toolUseId: 'tu1', content: 'file' }); + expect(pending()).toBe(true); // model round-trip + + store.applyEvent({ type: 'text-delta', blockId: 'b1', text: 'On it' }); + expect(pending()).toBe(false); // streaming text + + store.applyEvent({ type: 'text-stop', blockId: 'b1' }); + expect(pending()).toBe(false); // finished text, done imminent + + store.applyEvent({ type: 'done' }); + expect(pending()).toBe(false); // turn over + }); + it('accumulates assistant text deltas by blockId', () => { const store = createMessageStore({ generateId: counterIds(), persist: false }); store.applyEvent({ type: 'text-delta', blockId: 'b1', text: 'Hel' }); diff --git a/packages/widget-core/src/stream/store.ts b/packages/widget-core/src/stream/store.ts index 1580a33..99094e9 100644 --- a/packages/widget-core/src/stream/store.ts +++ b/packages/widget-core/src/stream/store.ts @@ -46,6 +46,9 @@ export function createMessageStore(options: CreateStoreOptions = {}): MessageSto const listeners = new Set<() => void>(); const blockIndex = new Map(); const generateId = options.generateId ?? defaultIdGenerator(); + // True between `appendUserMessage` and the turn's `done` / `error`. Gates the + // working indicator: it only ever shows while a turn is actually in flight. + let turnActive = false; function flush(): void { if (!persist) return; @@ -79,12 +82,23 @@ export function createMessageStore(options: CreateStoreOptions = {}): MessageSto return it && it.kind === 'tool-use' ? it : null; } - // Pending placeholders are only ever pushed at the end of `items` (right - // after the latest user message) and only ever dropped from the end (on - // the first concrete assistant content event or before the next user - // turn). Because indexed items — assistant-text and tool-use, tracked in - // `blockIndex` — always sit before any in-flight pending placeholder, - // removing pending entries never shifts an indexed item's position. + // The working indicator (`assistant-pending`) is a derived view of one fact: + // a turn is in flight and the assistant is between visible actions, about to + // incur latency worth telegraphing. It lives at the tail — and only the tail + // — and is (re)created whenever the conversation rests on a state that + // precedes a real wait: + // - right after the user submits (waiting for the first content), + // - after a tool-use finishes streaming its input (the tool is executing), + // - after a tool-result (the model round-trips on it). + // It is dropped the moment the assistant resumes emitting (text / tool-input + // streaming) and when the turn ends (done / error). It is deliberately NOT + // shown after a finished text block: a turn that ends on text emits `done` + // immediately afterwards, so a dot there would only flash. + // + // Because every indexed item (assistant-text / tool-use, tracked in + // `blockIndex`) is appended only after `clearPending` has dropped any + // trailing placeholder, the placeholder never sits before an indexed item + // and removing it never shifts an indexed position. function clearPending(): boolean { let changed = false; const next = items.filter((item) => { @@ -98,6 +112,15 @@ export function createMessageStore(options: CreateStoreOptions = {}): MessageSto return changed; } + // Append exactly one pending placeholder at the tail while a turn is active. + // No-op when the turn is over or the tail is already a placeholder. + function ensurePending(): void { + if (!turnActive) return; + const tail = items[items.length - 1]; + if (tail && tail.kind === 'assistant-pending') return; + items = [...items, { kind: 'assistant-pending', id: generateId() }]; + } + function applyEvent(event: StreamEvent): void { switch (event.type) { case 'message-start': @@ -162,10 +185,16 @@ export function createMessageStore(options: CreateStoreOptions = {}): MessageSto const prev = items[idx]; if (!prev || prev.kind !== 'tool-use') return; replaceAt(idx, { ...prev, streaming: false }); + // Input fully streamed — the tool is now executing. Telegraph the wait. + ensurePending(); notify(); return; } case 'tool-result': { + // Drop the "tool executing" indicator before recording the result so + // the result lands at the true tail, then re-show it: the model still + // has to round-trip on this result. + clearPending(); const linked = findToolUseByBlockId(event.toolUseId); const id = generateId(); pushItem({ @@ -175,10 +204,12 @@ export function createMessageStore(options: CreateStoreOptions = {}): MessageSto content: event.content, isError: event.isError === true, }); + ensurePending(); notify(); return; } case 'error': { + turnActive = false; clearPending(); const id = generateId(); pushItem({ kind: 'error', id, message: event.message }); @@ -186,9 +217,10 @@ export function createMessageStore(options: CreateStoreOptions = {}): MessageSto return; } case 'done': { - // Mark all streaming items finalized so the renderer can drop the - // cursor / pulsing indicator, and drop any in-flight pending + // The turn is over: stop telegraphing work, finalize streaming items + // so the renderer can drop the cursor, and drop any in-flight pending // placeholder (degenerate case: model returned no content blocks). + turnActive = false; let changed = clearPending(); items = items.map((item) => { if (item.kind === 'assistant-text' && item.streaming) { @@ -228,16 +260,17 @@ export function createMessageStore(options: CreateStoreOptions = {}): MessageSto text, ...(pickedEvidence !== undefined && { pickedEvidence }), }); - pushItem({ - kind: 'assistant-pending', - id: generateId(), - }); + // A fresh turn is in flight — show the working indicator until the first + // concrete assistant event arrives. + turnActive = true; + ensurePending(); notify(); return id; }, applyEvent, clear(): void { if (items.length === 0 && blockIndex.size === 0) return; + turnActive = false; items = []; blockIndex.clear(); notify(); diff --git a/packages/widget-core/src/stream/types.ts b/packages/widget-core/src/stream/types.ts index 2766c0c..3c70a1b 100644 --- a/packages/widget-core/src/stream/types.ts +++ b/packages/widget-core/src/stream/types.ts @@ -37,12 +37,13 @@ export interface AssistantTextItem { } /** - * Transient placeholder rendered between the moment the user submits a turn - * and the moment the first concrete assistant event arrives (text delta, - * tool use start, error, or done). Lets the renderer paint the conventional - * three dot typing indicator so the surface never looks frozen while the - * model is warming up. Never persisted — a re-hydrated conversation has no - * in-flight turn to wait on. + * Transient "assistant is working" placeholder that lets the renderer paint + * the conventional three dot typing indicator. It sits at the tail of the list + * during any in-flight period where the assistant is not actively emitting + * content — after the user submits (warming up), while a tool executes, and + * while the model round-trips on a tool result — and is dropped the moment + * text or tool input streams again, or the turn ends (done / error). Never + * persisted: a re-hydrated conversation has no in-flight turn to wait on. */ export interface AssistantPendingItem { readonly kind: 'assistant-pending'; diff --git a/packages/widget-core/src/transport/sse-transport.test.ts b/packages/widget-core/src/transport/sse-transport.test.ts index d0d1651..79da9e9 100644 --- a/packages/widget-core/src/transport/sse-transport.test.ts +++ b/packages/widget-core/src/transport/sse-transport.test.ts @@ -169,10 +169,14 @@ describe('createDefaultTransport', () => { let current: { provider: 'acp' | 'sdk'; permissionMode: 'acceptEdits' | 'bypassPermissions'; + theme: 'auto' | 'light' | 'dark'; + model: 'default' | 'opus' | 'sonnet' | 'haiku'; safeMode: boolean; } = { provider: 'acp', permissionMode: 'acceptEdits', + theme: 'auto', + model: 'default', safeMode: true, }; const transport = createDefaultTransport({ @@ -183,7 +187,13 @@ describe('createDefaultTransport', () => { }); await transport.send(basePayload()); // Live snapshot — the second turn should see the mutated value. - current = { provider: 'sdk', permissionMode: 'bypassPermissions', safeMode: true }; + current = { + provider: 'sdk', + permissionMode: 'bypassPermissions', + theme: 'auto', + model: 'default', + safeMode: true, + }; await transport.send(basePayload()); expect(captured).toHaveLength(2); const bodies = captured.map( @@ -193,6 +203,32 @@ describe('createDefaultTransport', () => { expect(bodies[1]).toMatchObject({ provider: 'sdk', permissionMode: 'bypassPermissions' }); }); + it('sends the selected model but omits it for the default sentinel', async () => { + const { fetch: fetchImpl, captured } = makeFetch({ textBody: '' }); + let model: 'default' | 'opus' | 'sonnet' | 'haiku' = 'opus'; + const transport = createDefaultTransport({ + baseUrl: 'http://127.0.0.1:4317', + pairingToken: 'tok', + fetch: fetchImpl, + getSettings: () => ({ + provider: 'acp', + permissionMode: 'acceptEdits', + theme: 'auto', + model, + safeMode: false, + }), + }); + await transport.send(basePayload()); + // `default` must not put a model on the wire — the provider decides. + model = 'default'; + await transport.send(basePayload()); + + const first = JSON.parse(captured[0]?.init.body as string) as Record; + const second = JSON.parse(captured[1]?.init.body as string) as Record; + expect(first).toMatchObject({ model: 'opus' }); + expect(second).not.toHaveProperty('model'); + }); + it('omits provider/permissionMode when getSettings is not supplied (server defaults apply)', async () => { const { fetch: fetchImpl, captured } = makeFetch({ textBody: '' }); const transport = createDefaultTransport({ @@ -212,7 +248,13 @@ describe('createDefaultTransport', () => { baseUrl: 'http://127.0.0.1:4317', pairingToken: 'tok', fetch: fetchImpl, - getSettings: () => ({ provider: 'acp', permissionMode: 'acceptEdits', safeMode: true }), + getSettings: () => ({ + provider: 'acp', + permissionMode: 'acceptEdits', + theme: 'auto', + model: 'default', + safeMode: true, + }), }); await transport.send(basePayload()); const body = JSON.parse(captured[0]?.init.body as string) as { @@ -232,7 +274,13 @@ describe('createDefaultTransport', () => { baseUrl: 'http://127.0.0.1:4317', pairingToken: 'tok', fetch: fetchImpl, - getSettings: () => ({ provider: 'acp', permissionMode: 'acceptEdits', safeMode: false }), + getSettings: () => ({ + provider: 'acp', + permissionMode: 'acceptEdits', + theme: 'auto', + model: 'default', + safeMode: false, + }), }); await transport.send(basePayload()); const body = JSON.parse(captured[0]?.init.body as string) as Record; @@ -246,7 +294,13 @@ describe('createDefaultTransport', () => { baseUrl: 'http://127.0.0.1:4317', pairingToken: 'tok', fetch: fetchImpl, - getSettings: () => ({ provider: 'acp', permissionMode: 'acceptEdits', safeMode }), + getSettings: () => ({ + provider: 'acp', + permissionMode: 'acceptEdits', + theme: 'auto', + model: 'default', + safeMode, + }), }); await transport.send(basePayload()); safeMode = false; @@ -878,6 +932,91 @@ describe('createDefaultTransport — pre-response fetch retry', () => { await expect(transport.send(basePayload())).rejects.toThrow(/network error/); expect(calls).toBe(1); }); + + it('retries a 503 "agent not ready" then succeeds — the dev-server respawn case', async () => { + let calls = 0; + const fetchImpl = vi.fn(async () => { + calls += 1; + if (calls <= 2) { + return new Response('{"error":"agent server not ready"}', { status: 503 }); + } + return new Response(streamFrom(['']), { status: 200 }); + }) as unknown as typeof fetch; + const transport = createDefaultTransport({ + baseUrl: 'http://127.0.0.1:4317', + pairingToken: 'tok', + fetch: fetchImpl, + preResponseRetries: 4, + preResponseRetryBackoffMs: 1, + preResponseRetryMaxBackoffMs: 4, + }); + + await transport.send(basePayload()); + expect(calls).toBe(3); + }); + + it('surfaces the 503 error once the retry budget is exhausted', async () => { + let calls = 0; + const fetchImpl = vi.fn(async () => { + calls += 1; + return new Response('{"error":"agent server not ready"}', { status: 503 }); + }) as unknown as typeof fetch; + const transport = createDefaultTransport({ + baseUrl: 'http://127.0.0.1:4317', + pairingToken: 'tok', + fetch: fetchImpl, + preResponseRetries: 2, + preResponseRetryBackoffMs: 1, + preResponseRetryMaxBackoffMs: 4, + }); + + await expect(transport.send(basePayload())).rejects.toThrow(/503/); + // initial attempt + 2 retries + expect(calls).toBe(3); + }); + + it('does not retry a 502 — the request already reached the agent', async () => { + let calls = 0; + const fetchImpl = vi.fn(async () => { + calls += 1; + return new Response('{"error":"upstream error: socket hang up"}', { status: 502 }); + }) as unknown as typeof fetch; + const transport = createDefaultTransport({ + baseUrl: 'http://127.0.0.1:4317', + pairingToken: 'tok', + fetch: fetchImpl, + preResponseRetries: 3, + preResponseRetryBackoffMs: 1, + }); + + await expect(transport.send(basePayload())).rejects.toThrow(/502/); + expect(calls).toBe(1); + }); + + it('stops retrying a 503 when the caller aborts mid-backoff', async () => { + const controller = new AbortController(); + let calls = 0; + const fetchImpl = vi.fn(async () => { + calls += 1; + // Abort during the first backoff window so the retry loop bails out + // instead of hammering the server. + controller.abort(); + return new Response('{"error":"agent server not ready"}', { status: 503 }); + }) as unknown as typeof fetch; + const transport = createDefaultTransport({ + baseUrl: 'http://127.0.0.1:4317', + pairingToken: 'tok', + fetch: fetchImpl, + preResponseRetries: 5, + preResponseRetryBackoffMs: 20, + preResponseRetryMaxBackoffMs: 20, + }); + + await expect(transport.send(basePayload({ signal: controller.signal }))).rejects.toThrow( + /aborted/, + ); + expect(calls).toBe(1); + }); }); function makeJsonFetch(options: { diff --git a/packages/widget-core/src/transport/sse-transport.ts b/packages/widget-core/src/transport/sse-transport.ts index ac925c7..6164893 100644 --- a/packages/widget-core/src/transport/sse-transport.ts +++ b/packages/widget-core/src/transport/sse-transport.ts @@ -80,26 +80,56 @@ export interface CreateDefaultTransportOptions { */ readonly streamSilentMs?: number; /** - * Pre-response fetch retry count. When `fetch()` itself rejects (network - * error before any Response is returned), the transport retries this - * many additional times with a small backoff. Once a Response arrives - * the prompt has reached the server and no retry happens — duplicating - * the prompt would re-run the LLM. AbortErrors never retry. Default `1` - * (one retry, i.e. two total attempts). Pass `0` to disable. + * Retry count for failures that provably never reached the agent, so a + * retry can't duplicate the turn. Two cases qualify and share this budget: + * + * 1. `fetch()` itself rejects (network error before any Response) — the + * request never left the client / never got a reply. + * 2. The dev server replies `503 Service Unavailable` — the Vite proxy + * rejects the request *before* forwarding it upstream while the agent + * server respawns (e.g. just after a dev-server restart). The agent + * never saw the prompt, so re-sending is idempotent. This is the + * common "network error right after a hot reload" case. + * + * Any other outcome (a `2xx` stream that later drops, `500`/`502`, `401`, + * …) means the prompt reached the agent and may have started editing + * files — retrying then would re-run the LLM, so those are never retried. + * AbortErrors never retry either. Backoff is exponential (see + * `preResponseRetryBackoffMs` / `preResponseRetryMaxBackoffMs`) so a + * multi-second respawn is waited out while a genuinely dead server still + * fails within a bounded window. Default `4`. Pass `0` to disable. */ readonly preResponseRetries?: number; /** - * Milliseconds to wait between the failed initial fetch and the retry - * attempt. Default `300`. Only used when `preResponseRetries > 0`. + * Base backoff between retry attempts, in milliseconds. The actual wait + * grows exponentially per attempt (`base · 2^(attempt-1)`), capped at + * `preResponseRetryMaxBackoffMs`. Default `300`. Only used when + * `preResponseRetries > 0`. */ readonly preResponseRetryBackoffMs?: number; + /** + * Upper bound on a single exponential backoff wait, in milliseconds. + * Keeps the total retry window bounded (with the defaults: 300 + 600 + + * 1200 + 2000 ≈ 4.1s across four retries). Default `2000`. + */ + readonly preResponseRetryMaxBackoffMs?: number; } const DEFAULT_STREAM_SILENT_MS = 60_000; -const DEFAULT_PRE_RESPONSE_RETRIES = 1; +const DEFAULT_PRE_RESPONSE_RETRIES = 4; const DEFAULT_PRE_RESPONSE_RETRY_BACKOFF_MS = 300; +const DEFAULT_PRE_RESPONSE_RETRY_MAX_BACKOFF_MS = 2_000; const DEFAULT_ENRICHMENT_TIMEOUT_MS = 3_000; +/** + * Status the dev-server proxy returns while the agent server is not yet + * reachable (still spawning, or respawning after a restart). It is emitted + * *before* the proxy forwards anything upstream, so the agent never saw the + * prompt — making a retry idempotent. Standard `503 Service Unavailable` + * "try again later" semantics. + */ +const AGENT_NOT_READY_STATUS = 503; + /** * Race the caller-provided signal (if any) against a fixed timeout. Used * by the enrichment fetchers so a hung dev server can't block the user's @@ -184,6 +214,8 @@ export function createDefaultTransport( const preResponseRetries = options.preResponseRetries ?? DEFAULT_PRE_RESPONSE_RETRIES; const preResponseRetryBackoffMs = options.preResponseRetryBackoffMs ?? DEFAULT_PRE_RESPONSE_RETRY_BACKOFF_MS; + const preResponseRetryMaxBackoffMs = + options.preResponseRetryMaxBackoffMs ?? DEFAULT_PRE_RESPONSE_RETRY_MAX_BACKOFF_MS; // One session per browser tab. Persisted to sessionStorage so a full // reload reconnects to the same server-side ACP session (the server // keeps a `clientSessionId → ACP sessionId` map for the dev-server @@ -213,6 +245,10 @@ export function createDefaultTransport( ...(settings && { provider: settings.provider, permissionMode: settings.permissionMode, + // `default` is the sentinel for "no model" — omit the field so the + // provider uses its own default. Any other value is forwarded as + // an alias the provider resolves (terminal `/model` parity). + ...(settings.model && settings.model !== 'default' && { model: settings.model }), // When the header-level "Safe mode" toggle is on, lock the // side-effecting categories to `ask` while leaving file edits // on auto. When off, omit `permissionPolicy` so the server @@ -229,6 +265,7 @@ export function createDefaultTransport( payload.signal, preResponseRetries, preResponseRetryBackoffMs, + preResponseRetryMaxBackoffMs, ); if (!response.ok) { @@ -699,11 +736,22 @@ async function readWithWatchdog( } /** - * Retry the initial fetch when it rejects with a network error before any - * Response arrives — that means the request never reached the server, so - * a retry is idempotent. Once a Response is received the prompt has hit - * the server and the LLM has likely started; retrying then would - * duplicate work. Abort errors are never retried. + * Retry the initial request only when it provably never reached the agent, + * so a retry can't duplicate the turn. Two failures qualify: + * + * - `fetch()` rejects before any Response (network error) — nothing left + * the client, or no reply came back. + * - The dev-server proxy answers `503` (agent server not ready) — it + * rejects the request before forwarding upstream while the agent + * respawns, so the prompt never hit the agent. This is the "network + * error right after a hot reload / dev-server restart" case. + * + * Once any other Response arrives (a `2xx` stream, `500`, `502`, `401`, …) + * the prompt has reached the agent and the LLM may have started editing + * files; retrying then would duplicate work, so we return it for the + * caller to handle. Abort errors are never retried. Backoff is exponential + * and capped so a multi-second respawn is waited out while a genuinely + * dead server still fails within a bounded window. */ async function fetchWithPreResponseRetry( fetchImpl: typeof fetch, @@ -712,19 +760,31 @@ async function fetchWithPreResponseRetry( signal: AbortSignal, retries: number, backoffMs: number, + maxBackoffMs: number, ): Promise { let attempt = 0; for (;;) { + let response: Response | null = null; try { - return await fetchImpl(url, init); + response = await fetchImpl(url, init); } catch (error) { if (isAbortError(error) || signal.aborted) throw error; if (attempt >= retries) throw error; - attempt += 1; - if (backoffMs > 0) { - await waitOrAbort(backoffMs, signal); - if (signal.aborted) throw new DOMException('aborted', 'AbortError'); + // fall through to backoff + retry + } + if (response) { + if (response.status !== AGENT_NOT_READY_STATUS || attempt >= retries) { + return response; } + // Drain the 503 body so the underlying socket can be reused for the + // retry instead of leaking an unread stream. + await response.body?.cancel().catch(() => undefined); + } + attempt += 1; + const wait = Math.min(backoffMs * 2 ** (attempt - 1), maxBackoffMs); + if (wait > 0) { + await waitOrAbort(wait, signal); + if (signal.aborted) throw new DOMException('aborted', 'AbortError'); } } } diff --git a/packages/widget-core/src/widget/index.ts b/packages/widget-core/src/widget/index.ts index cccfb36..d462fb1 100644 --- a/packages/widget-core/src/widget/index.ts +++ b/packages/widget-core/src/widget/index.ts @@ -1,5 +1,6 @@ export { createShadowWidgetRoot, + THEME_ATTR, type CreateShadowWidgetRootOptions, type ShadowWidgetRoot, } from './shadow-root.js'; diff --git a/packages/widget-core/src/widget/shadow-root.test.ts b/packages/widget-core/src/widget/shadow-root.test.ts index 497f845..3dbed67 100644 --- a/packages/widget-core/src/widget/shadow-root.test.ts +++ b/packages/widget-core/src/widget/shadow-root.test.ts @@ -48,6 +48,36 @@ describe('createShadowWidgetRoot', () => { root.destroy(); }); + it('declares a light color-scheme baseline and reads text from a fallback token', () => { + const root = createShadowWidgetRoot({ openMode: true }); + const text = root.shadowRoot.querySelector('style')?.textContent ?? ''; + // Light is the *absence* of dark tokens — the base only pins the scheme + // and reads the primary text colour through a literal fallback. + expect(text).toContain('color-scheme: light;'); + expect(text).toContain('color: var(--adt-text, #1a1a1a);'); + // No light token block exists; the only `--adt-surface` definition is the + // dark override. + expect(text).not.toContain('--adt-surface: #ffffff'); + }); + + it('defines the dark palette once and applies it to explicit dark + auto-dark', () => { + const root = createShadowWidgetRoot({ openMode: true }); + const text = root.shadowRoot.querySelector('style')?.textContent ?? ''; + expect(text).toContain(':host([data-theme="dark"])'); + expect(text).toContain('--adt-surface: #1e1e1e;'); + expect(text).toContain('--adt-text: #e8e8ea;'); + // The picked-element chip fill reads `--adt-chip-bg`; it must stay an + // opaque hex in dark too so the conversation stream cannot bleed through. + expect(text).toContain('--adt-chip-bg: #2f2f33;'); + expect(text).toContain('@media (prefers-color-scheme: dark)'); + expect(text).toContain(':host([data-theme="auto"])'); + // The dark palette is interpolated in both selectors, so its tokens + // appear twice — once for explicit dark, once for auto-follows-OS. + const occurrences = text.split('--adt-surface: #1e1e1e;').length - 1; + expect(occurrences).toBe(2); + root.destroy(); + }); + it('appends extraStyles after base styles', () => { const root = createShadowWidgetRoot({ openMode: true, diff --git a/packages/widget-core/src/widget/shadow-root.ts b/packages/widget-core/src/widget/shadow-root.ts index 3884df2..f0cefbf 100644 --- a/packages/widget-core/src/widget/shadow-root.ts +++ b/packages/widget-core/src/widget/shadow-root.ts @@ -12,7 +12,7 @@ * * The host element itself stays a plain `
` on the page. Layout * constraints (position: fixed, z-index, viewport pinning) live in the - * launcher (ADT-21) — the shell only owns isolation. + * launcher — the shell only owns isolation. */ const HOST_TAG = 'agent-devtools-widget'; @@ -54,15 +54,67 @@ export interface CreateShadowWidgetRootOptions { extraStyles?: string; } +/** + * Theme attribute the orchestrator flips on the host element. The selectors + * below recolour the whole widget by remapping the design tokens — a single + * DOM write swaps every component because they all read `var(--adt-*)`. + */ +export const THEME_ATTR = 'data-theme'; + +/** + * Design tokens — dark palette only. + * + * Light is the *absence* of these tokens: every component references a colour + * as `var(--adt-*, )`, so when no token is defined the literal + * fallback (the original pre-theming colour) applies. That makes the light + * theme byte-identical to the old look and preserves each element's own light + * nuance (e.g. a 0.06 vs 0.16 border alpha) without enumerating it here. + * + * Dark, by contrast, is defined once in this block and applied in two places — + * the explicit `[data-theme="dark"]` selector and the `auto` + + * `prefers-color-scheme: dark` media query — so the dark palette has a single + * source of truth. Flipping the host's `data-theme` attribute is the only + * write needed to recolour the whole widget. + */ +const DARK_TOKENS = ` + color-scheme: dark; + --adt-surface: #1e1e1e; + --adt-surface-raised: #2a2a2e; + --adt-text: #e8e8ea; + --adt-text-muted: #9ca3af; + --adt-accent: #e8e8ea; + --adt-accent-text: #1a1a1a; + --adt-border: rgba(255, 255, 255, 0.14); + --adt-chip-bg: #2f2f33; + --adt-overlay-weak: rgba(255, 255, 255, 0.08); + --adt-backdrop: rgba(0, 0, 0, 0.6); + --adt-user-bubble-bg: rgba(255, 255, 255, 0.1); + --adt-assistant-bubble-bg: #2a2a2e; + --adt-assistant-bubble-text: #e8e8ea; + --adt-danger: #ff6b6b; + --adt-danger-bg: rgba(255, 107, 107, 0.14); + --adt-success: #4ade80; + --adt-shadow: rgba(0, 0, 0, 0.5); +`; + const BASE_STYLES = ` :host { all: initial; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; font-size: 14px; line-height: 1.4; - color: #1a1a1a; + color-scheme: light; + color: var(--adt-text, #1a1a1a); contain: layout style; } +:host([${THEME_ATTR}="dark"]) { +${DARK_TOKENS} +} +@media (prefers-color-scheme: dark) { + :host([${THEME_ATTR}="auto"]) { +${DARK_TOKENS} + } +} *, *::before, *::after { box-sizing: border-box; }