diff --git a/examples/desktop/.gitignore b/examples/desktop/.gitignore new file mode 100644 index 0000000000..53331ede5d --- /dev/null +++ b/examples/desktop/.gitignore @@ -0,0 +1,23 @@ +node_modules/ +*.log +boot.log +.DS_Store +dist/ +.env +.env.* +!.env.example + +# DSH runtime overlay + daemon persistence (see README) +.dsh-desktop/ +.dsh/ +.sessions/ +userdata/ + +# Local QA / driver scratch +.qa/ +.tmp/ + +# Editor / IDE +.vscode/ +.idea/ +.claude/ diff --git a/examples/desktop/LICENSE b/examples/desktop/LICENSE new file mode 100644 index 0000000000..1a4048d929 --- /dev/null +++ b/examples/desktop/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DeepSeek Harness contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/examples/desktop/assets/logo.png b/examples/desktop/assets/logo.png new file mode 100644 index 0000000000..b4104fa4cf Binary files /dev/null and b/examples/desktop/assets/logo.png differ diff --git a/examples/desktop/assets/logo@22.png b/examples/desktop/assets/logo@22.png new file mode 100644 index 0000000000..d2b3d05e55 Binary files /dev/null and b/examples/desktop/assets/logo@22.png differ diff --git a/examples/desktop/config/daemon-echo.yml b/examples/desktop/config/daemon-echo.yml new file mode 100644 index 0000000000..def7191b93 --- /dev/null +++ b/examples/desktop/config/daemon-echo.yml @@ -0,0 +1,44 @@ +# Daemon-hosted JSON-RPC serving config for the desktop demo — keyless echo. +# +# The daemon bin (packages/examples/daemon-demo) binds the unix socket at +# $DSH_DAEMON_SOCKET_PATH and holds the lockfile at $DSH_DAEMON_LOCKFILE_PATH. +# Both env values are required so misconfiguration fails loud rather than +# silently binding a fixed default under $HOME. +# +# The leaf mirrors examples/daemon-agent/cordis.yml from the dev clone, +# with paths rewritten so relative plugin imports still resolve against the +# echo-agent leaf inside the dev clone. + +# Mock adapter and echo tool from the echo-agent leaf; keyless. +- id: mock-llm + name: '../../echo-agent/src/mock-llm.ts' + +- id: echo-tool + name: '../../echo-agent/src/echo-tool.ts' + +# Bash executor — required by the spine's bash tool schema. +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +# session-query backs session/list metadata (title, running, lastEventTime) +# and the sidebar tree; without it the daemon errors on session/list. Same +# load pattern as the dev clone's integration-smoke.mjs. +- id: session-query + name: '@deepseek-ai/dsh-session-query' + +# NOTE (2026-07-16): user-interaction is NOT loaded here — the +# `dsh-daemon-demo` bundle below already imports it and installs the +# interrupt bridge internally (see the "spine + jsonrpc-net + JSONL +# persistence + user-interaction" line below and daemon-demo/src/index.ts). +# Loading it again at the top level would double-mount the service and +# fail loud on daemon start. The stdio profile (echo-jsonrpc.yml) does +# load it, because that profile has no bundle to piggyback on. + +# The daemon app bundle: spine + jsonrpc-net + JSONL persistence + user-interaction. +- id: daemon-agent + name: '@deepseek-ai/dsh-daemon-demo' + config: + socketPath: !!js process.env.DSH_DAEMON_SOCKET_PATH + lockfilePath: !!js process.env.DSH_DAEMON_LOCKFILE_PATH + persona: 'You are a mock daemon agent.' + persistenceRoot: !!js process.env.DSH_DAEMON_SESSIONS_ROOT ?? './.sessions' diff --git a/examples/desktop/config/daemon-vibe.yml b/examples/desktop/config/daemon-vibe.yml new file mode 100644 index 0000000000..d1307b9536 --- /dev/null +++ b/examples/desktop/config/daemon-vibe.yml @@ -0,0 +1,59 @@ +# Vibe leaf: the daemon-echo agent bundle + the self-referential cordis +# toolset, so a chat inside the shell can inspect + mount plugins into the +# live runtime. Loaded through the same daemon-demo bin as `daemon-echo.yml`; +# the sole difference is the extra `tool-cordis` entry at the end. +# +# This leaf expects a real model (`mock-echo` cannot compose plugins), so the +# UI hides the entry point in the mock profile. The real-model shape lives at +# `examples/cordis-agent/cordis.yml` in the DSH runtime checkout — this +# leaf's overlays swap in the DeepSeek adapter when the shell is running +# under the deepseek profile. +# +# See packages/cordis/tool-cordis/README.md for the tool trust stance: +# `cordis_mount` evaluates model-written JS in a node:vm sandbox — grant it +# like bash access. + +- id: mock-llm + name: '../../echo-agent/src/mock-llm.ts' + +- id: echo-tool + name: '../../echo-agent/src/echo-tool.ts' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +# ctx.fs / ctx.web providers so plugins the model writes have real capabilities +# to build on. Model-facing read/write/edit + search/fetch tools stay off on +# purpose — the point is the agent *authors* its own tools rather than picking +# from a prepacked shelf. +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + +- id: web + name: '@deepseek-ai/dsh-web' + +- id: web-fetch-local + name: '@deepseek-ai/dsh-web-fetch-local' + +- id: session-query + name: '@deepseek-ai/dsh-session-query' + +- id: daemon-agent + name: '@deepseek-ai/dsh-daemon-demo' + config: + socketPath: !!js process.env.DSH_DAEMON_SOCKET_PATH + lockfilePath: !!js process.env.DSH_DAEMON_LOCKFILE_PATH + persistenceRoot: !!js process.env.DSH_DAEMON_SESSIONS_ROOT ?? './.sessions' + persona: | + You are the DSH vibe agent: you author cordis plugins to extend your own + runtime. Use cordis_inspect to look around (its `api` and `events` + sections are your reference), cordis_mount to add plugins, and + cordis_unmount to clean up. In mounted code, never use Node built-ins + (require/setTimeout/fetch) — use the cordis services via inject: fs, + web, bash, timer (ctx.setTimeout). Prefer small single-purpose plugins. + +# Loaded last so ctx.tools exists — the cordis toolset registers into it. +- id: tool-cordis + name: '@deepseek-ai/dsh-tool-cordis' diff --git a/examples/desktop/config/deepseek-jsonrpc.yml b/examples/desktop/config/deepseek-jsonrpc.yml new file mode 100644 index 0000000000..6e4a9d4874 --- /dev/null +++ b/examples/desktop/config/deepseek-jsonrpc.yml @@ -0,0 +1,93 @@ +# JSON-RPC serving config with the real DeepSeek adapter. Needs +# DEEPSEEK_API_KEY in the environment (the desktop shell inherits it from the +# user's shell; or set it in `.env` at the DSH runtime root, which +# dsh-app-boot loads via loadEnv). +# +# Composition contract: the desktop shell announces +# capabilities.interruptions=true on initialize, so the JSON-RPC server needs +# @deepseek-ai/dsh-user-interaction to mount the interrupt bridge or +# initialize fails loud. session/list + session/events also require +# @deepseek-ai/dsh-session-query. Both are loaded below — omitting either +# leaves the shell showing runtime status "starting" indefinitely while +# stderr surfaces the failed handshake. + +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' + +# agent-spine-demo requires an explicit `workspaceContext` (Config | false) +# because the loader changes model-visible input; there is no schema default. +# `false` = hermetic prompts (no workspace overview injected). Aligns with +# packages/examples/agent-spine-demo/src/index.ts Config schema. When we want +# a workspace overview later, swap for `{ maxBytes: 65536 }` matching the +# byte-budget pattern in examples/cordis-agent/cordis.yml. +- id: agent-core + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + workspaceContext: false + +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + models: + - deepseek-v4-flash + - deepseek-v4-pro + # Showcase default: pin thinking on so the reasoning fold — our headline + # visualization — is visible out of the box for a first-run user. The + # provider default is already "enabled", but a future flip would silently + # drop reasoning-delta events on this profile and take the fold with it. + # See packages/llm/llm-deepseek/src/index.ts Config for the field shape. + thinking: enabled + +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './.sessions' + +# Host-facing session metadata: session/list and session/events require the +# session-query service and fail loud without it. The desktop shell polls +# session/list on every runtime handshake and reads session/events on +# switch-back. +- id: session-query + name: '@deepseek-ai/dsh-session-query' + +# User-interaction seam: required when the JSON-RPC client announces +# capabilities.interruptions=true (the desktop shell does). Without this the +# interrupt bridge cannot mount and initialize itself fails loud with +# "jsonrpc client announced capabilities.interruptions=true but the composition +# has no ctx.userInteraction …". +- id: user-interaction + name: '@deepseek-ai/dsh-user-interaction' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +# Showcase default: ship the model-facing filesystem tool suite so file edits +# render the diff card — the second headline visualization after the reasoning +# fold. This is a three-part stack (matching examples/coding-agent/cordis.yml +# — the canonical composition): +# +# 1. fs-local provides the backend that resolves paths from process.cwd() +# (schema in packages/fs/fs-local/src/index.ts). +# 2. fs-policy enforces the read-before-write / observation contract that +# tool-fs's edit/write listeners rely on. +# 3. tool-fs registers the fs.read / fs.edit / fs.write model-facing tools +# (its `inject` is ['tools', 'fs', 'systemPrompt']). Without this, no fs +# tool is exposed to the model at all — an fs.edit request would just +# make the model reply "no fs tool available", which is exactly what +# the previous default-profile behaviour did. +# +# Diff cards render only for tools with data-tool-card-family=fs (see +# src/renderer/tool-cards.js), so this stack is the sole path to the +# out-of-the-box diff-card demo. +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' diff --git a/examples/desktop/config/deepseek-vibe.yml b/examples/desktop/config/deepseek-vibe.yml new file mode 100644 index 0000000000..3fb6c0cd6f --- /dev/null +++ b/examples/desktop/config/deepseek-vibe.yml @@ -0,0 +1,57 @@ +# Vibe leaf, DeepSeek variant: same shape as daemon-vibe.yml but with the +# real DeepSeek adapter swapped in. Loaded through the jsonrpc-demo bin on +# stdio (the daemon-demo path is fine too, but the deepseek profile only +# needs one long-lived process for the shell's demo scope). +# +# Needs `DEEPSEEK_API_KEY` in `.env` at the DSH runtime root. See +# `examples/cordis-agent/cordis.yml` in the DSH runtime checkout for the +# canonical self-referential composition; this is a JSON-RPC-serving mirror +# of it. + +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' + +# agent-spine-demo requires an explicit `workspaceContext` (Config | false). +# Vibe leaf mirrors examples/cordis-agent/cordis.yml which uses a 65536-byte +# budget so the model gets a workspace overview; the plain deepseek-jsonrpc +# leaf keeps `false` for hermetic prompts. See +# `packages/examples/agent-spine-demo/src/index.ts` in the DSH runtime +# checkout for the Config schema. +- id: agent-core + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + workspaceContext: + maxBytes: 65536 + +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + models: + - deepseek-v4-pro + - deepseek-v4-flash + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + timeoutMs: 60000 + +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + +- id: web + name: '@deepseek-ai/dsh-web' + +- id: web-fetch-local + name: '@deepseek-ai/dsh-web-fetch-local' + +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './.sessions' + +- id: tool-cordis + name: '@deepseek-ai/dsh-tool-cordis' diff --git a/examples/desktop/config/echo-jsonrpc.yml b/examples/desktop/config/echo-jsonrpc.yml new file mode 100644 index 0000000000..466ff8aac0 --- /dev/null +++ b/examples/desktop/config/echo-jsonrpc.yml @@ -0,0 +1,52 @@ +# JSON-RPC serving config for the desktop demo — keyless echo profile. +# +# The runtime bin (packages/examples/jsonrpc-demo) hosts these plugins: +# `dsh-jsonrpc` serves newline-delimited JSON-RPC on stdio, the spine gives +# the agent shape, and the leaf plugins supply a mock adapter + echo tool so +# there's no network dependency. Matches the shape in +# python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml but with +# the mock-echo adapter swapped in. + +# Stdio JSON-RPC serving surface — the demo's whole reason to exist. +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' + +# Agent spine — the SDK server creates agents per sessionId. +# agent-spine-demo requires an explicit workspaceContext (Config | false) +# because it changes model-visible input; there is no schema default. +# `false` = hermetic prompts (no workspace overview injected), which is the +# right shape for the keyless mock path — the mock adapter ignores any +# workspace context anyway. +- id: agent-core + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + workspaceContext: false + +# Mock adapter from the echo-agent leaf. Path is relative to this file. +- id: mock-llm + name: '../../echo-agent/src/mock-llm.ts' + +# Echo tool so the UI has something to render as a tool call. +- id: echo-tool + name: '../../echo-agent/src/echo-tool.ts' + +# Bash executor sits behind agent-core's bash tool schema — required by the spine. +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +# User-interaction seam: required whenever the JSON-RPC client announces +# capabilities.interruptions=true (the desktop shell always does; see +# main.js:handshake). Without this the daemon logs +# "jsonrpc client announced capabilities.interruptions=true but the composition +# has no ctx.userInteraction" and the shell's runtime-error banner fires on +# the empty state — hiding the four differentiator cards behind an error +# strip. Kept in the echo profile too so all default profiles bind the same +# interaction surface (product-flow-review A-P0-2 root cause, 2026-07-16). +- id: user-interaction + name: '@deepseek-ai/dsh-user-interaction' + +# JSONL persistence — sessions land under ./.sessions inside this dir. +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './.sessions' diff --git a/examples/desktop/config/plugin-index.json b/examples/desktop/config/plugin-index.json new file mode 100644 index 0000000000..c4be432031 --- /dev/null +++ b/examples/desktop/config/plugin-index.json @@ -0,0 +1,109 @@ +{ + "$schema": "https://dsh.dev/schemas/plugin-index-v1.json", + "version": 1, + "notes": "Curated demo index for the Plugins → Browse tab. Each entry maps 1:1 to a real workspace package under packages/. The `source: local` marker is a placeholder for a future remote index served by plugin.engineer; the renderer already reads `source` so switching to a URL is a config-only change.", + "source": "local", + "updatedAt": "2026-07-16", + "entries": [ + { + "id": "tool-web", + "package": "@deepseek-ai/dsh-tool-web", + "title": "Web tools", + "description": "Model-facing web_search and web_fetch tools over the web capability seam. Ships the search + fetch provider skeleton; wire it to an HTTP backend or use the built-in mock.", + "author": "DeepSeek", + "permissions": ["net"], + "tags": ["research", "browsing"], + "entry": { "id": "tool-web", "name": "@deepseek-ai/dsh-tool-web" } + }, + { + "id": "tool-fs", + "package": "@deepseek-ai/dsh-tool-fs", + "title": "Filesystem tools", + "description": "Read, write, and edit files through ctx.fs. The bread-and-butter toolset for any coding agent — pair it with tool-bash for a full workbench.", + "author": "DeepSeek", + "permissions": ["fs"], + "tags": ["coding", "essentials"], + "entry": { "id": "tool-fs", "name": "@deepseek-ai/dsh-tool-fs" } + }, + { + "id": "tool-todo", + "package": "@deepseek-ai/dsh-tool-todo", + "title": "Todo writer", + "description": "Session-owned todo list backed by the event-sourced log. Lets the agent plan a multi-step task and check items off as it goes.", + "author": "DeepSeek", + "permissions": [], + "tags": ["planning"], + "entry": { "id": "tool-todo", "name": "@deepseek-ai/dsh-tool-todo" } + }, + { + "id": "tool-skill", + "package": "@deepseek-ai/dsh-tool-skill", + "title": "Skill loader", + "description": "Model-facing tool that discovers and loads named skills from the skill provider registry. Pair with skill-local to serve skills from disk.", + "author": "DeepSeek", + "permissions": [], + "tags": ["skills"], + "entry": { "id": "tool-skill", "name": "@deepseek-ai/dsh-tool-skill" } + }, + { + "id": "skill-local", + "package": "@deepseek-ai/dsh-skill-local", + "title": "Local skill provider", + "description": "Serves skills from a local filesystem directory to the skill registry. Install alongside tool-skill for a working local skills workflow.", + "author": "DeepSeek", + "permissions": ["fs"], + "tags": ["skills"], + "entry": { "id": "skill-local", "name": "@deepseek-ai/dsh-skill-local" } + }, + { + "id": "tool-subagent", + "package": "@deepseek-ai/dsh-tool-subagent", + "title": "Subagent delegation", + "description": "Delegate work to a child agent via the ctx.subagents seam. Register one or more subagent providers separately (spawn, subprocess, in-process, ACP, or fork).", + "author": "DeepSeek", + "permissions": [], + "tags": ["multi-agent"], + "entry": { "id": "tool-subagent", "name": "@deepseek-ai/dsh-tool-subagent" } + }, + { + "id": "time-context", + "package": "@deepseek-ai/dsh-time-context", + "title": "Time context", + "description": "Opt-in system-prompt context: the current wall-clock time and the elapsed time since the previous message. Bounded and cheap; nothing model-visible beyond a small prelude.", + "author": "DeepSeek", + "permissions": [], + "tags": ["context"], + "entry": { "id": "time-context", "name": "@deepseek-ai/dsh-time-context" } + }, + { + "id": "timeout-policy", + "package": "@deepseek-ai/dsh-timeout-policy", + "title": "Tool timeout policy", + "description": "Arms a per-tool deadline on tools/execute; returns TOOL_TIMEOUT if the tool call outruns it. Belt-and-braces protection for a shell tool that hangs.", + "author": "DeepSeek", + "permissions": [], + "tags": ["reliability"], + "entry": { "id": "timeout-policy", "name": "@deepseek-ai/dsh-timeout-policy" } + }, + { + "id": "repeat-tool-guard", + "package": "@deepseek-ai/dsh-repeat-tool-guard", + "title": "Repeat-tool guard", + "description": "Advisory reminders when the agent loops on identical tool calls. Nudges the model to change course rather than short-circuiting the loop.", + "author": "DeepSeek", + "permissions": [], + "tags": ["loop-hygiene"], + "entry": { "id": "repeat-tool-guard", "name": "@deepseek-ai/dsh-repeat-tool-guard" } + }, + { + "id": "mcp-client", + "package": "@deepseek-ai/dsh-mcp-client", + "title": "MCP client bridge", + "description": "Connects to Model Context Protocol servers and registers their tools on ctx.tools. Add MCP server configs after installing.", + "author": "DeepSeek", + "permissions": ["net", "subprocess"], + "tags": ["integration", "mcp"], + "entry": { "id": "mcp-client", "name": "@deepseek-ai/dsh-mcp-client" } + } + ] +} diff --git a/examples/desktop/docs/bug-c-caveat.md b/examples/desktop/docs/bug-c-caveat.md new file mode 100644 index 0000000000..1a20d5ae77 --- /dev/null +++ b/examples/desktop/docs/bug-c-caveat.md @@ -0,0 +1,17 @@ +# Bug C real-machine cold-start verification: NOT DONE + +Commit ddddc81 relies on static classification audit + node:test locks +(shape-based). What was NOT executed on this branch: + +- Real daemon-echo profile cold start with an isolated Electron + instance and observation that no generic banner surfaces during boot. +- Real stdio-deepseek profile cold start with the same verification. + +Both are pending. Team-lead accepted the static-audit substitute; the +interaction sweep v2 will exercise the real-machine paths on a fresh +run. Any new banner shapes discovered there feed back into +classifyRuntimeError. + +Reference: team-lead directive 2026-07-18, "把'未实机验证冷启动分类 +命中'如实写进 commit message". Written here (not amended into ddddc81 +per team's no-amend policy). diff --git a/examples/desktop/docs/default-profile-real-probe/01-no-key-boot.png b/examples/desktop/docs/default-profile-real-probe/01-no-key-boot.png new file mode 100644 index 0000000000..a5f2ef0e1b Binary files /dev/null and b/examples/desktop/docs/default-profile-real-probe/01-no-key-boot.png differ diff --git a/examples/desktop/docs/default-profile-real-probe/02-guided-card-injected.png b/examples/desktop/docs/default-profile-real-probe/02-guided-card-injected.png new file mode 100644 index 0000000000..0c32a7bda5 Binary files /dev/null and b/examples/desktop/docs/default-profile-real-probe/02-guided-card-injected.png differ diff --git a/examples/desktop/docs/default-profile-real-probe/03-with-key-boot.png b/examples/desktop/docs/default-profile-real-probe/03-with-key-boot.png new file mode 100644 index 0000000000..a5f2ef0e1b Binary files /dev/null and b/examples/desktop/docs/default-profile-real-probe/03-with-key-boot.png differ diff --git a/examples/desktop/docs/default-profile-real-probe/README.md b/examples/desktop/docs/default-profile-real-probe/README.md new file mode 100644 index 0000000000..4766abc906 --- /dev/null +++ b/examples/desktop/docs/default-profile-real-probe/README.md @@ -0,0 +1,81 @@ +# default-profile-real probe (2026-07-18) + +Isolated real-machine verification for `fix/default-profile-real` (default +profile → stdio-deepseek + missing-key guided-switch card). + +## Isolation (mandatory) + +`--user-data-dir=/tmp/dsh-probe-default-real/user-data` (Electron caches + +Local Storage) AND `DSH_DESKTOP_HOME=/tmp/dsh-probe-default-real/dsh-home` +(shell overlay + config.json + `.onboarded` sentinel). Team-lead flagged +that a prior probe wrote through to the user's real `~/.dsh-desktop/user- +overlay.cordis.yml` because DSH_DESKTOP_HOME wasn't isolated; this probe +respects both. + +Driver: `/tmp/dsh-probe-default-real/run.sh {with-key|no-key} [port]`. + +## Scenarios + +### 01 · no-key boot (`01-no-key-boot.png`) + +Boots stdio-deepseek with DEEPSEEK_API_KEY unset. Confirms: +- Bottom-right chip: `stdio-deepseek · deepseek-v4-flash` (NEW DEFAULT + correctly landed — was `daemon-echo · mock-echo` before this change) +- Composer model chip: `deepseek-v4-flash` (matches profile default) +- Status bar: `crashed` (expected — the deepseek runtime dies during + plugin load because this dev-clone snapshot has a `workspaceContext` + schema drift; NOT the api-key error we designed against) +- Banner: **generic "Runtime warning"** — the raw message reaching + the classifier is `runtime not writable` (from + `transport.js:53 write() throws when stdin isn't writable`), which + correctly falls through to the generic bucket. My missing-api-key + regex would ONLY match if the deepseek plugin actually got to throw + its api-key error, which requires the config schema to pass first. + +### 02 · guided-switch card (synthetic inject, `02-guided-card-injected.png`) + +Fires `showRuntimeErrorBanner('llm-deepseek: an API key is required +(Config.apiKey or $DEEPSEEK_API_KEY)')` via the __dshRenderer test seam +so we can see the classifier + banner logic end-to-end without needing +the real llm-deepseek error to surface. Confirms: +- Banner title: **"! DEEPSEEK_API_KEY needed for real-model profile"** +- Hint: full two-option copy (set env in .env/shell, or try keyless demo) +- Switch button: **"Switch to keyless demo (daemon-echo)"** (ghost small, + under hint, wired to `window.dsh.startRuntime('daemon-echo')`) +- Layout: amber-tinted banner sits above the welcome cards, NO red wall, + full-width row (respects density spec) + +### 03 · with-key boot (`03-with-key-boot.png`) + +Boots stdio-deepseek with the dev-clone `.env` key loaded. Same shape as +#01 in this environment because the config schema drift dies before the +key check — the WITH-key scenario would surface `status=ready` + +`no banner` only after the dev clone is bumped past the `workspaceContext` +requirement. Documenting for reproducibility. + +## Known limits (dev-clone drift) + +`deepseek-harness-dev` currently requires `workspaceContext` in the +agent-spine-demo entry (packages/examples/agent-spine-demo/src/index.ts:94). +The demo repo's `config/deepseek-jsonrpc.yml` predates that requirement, so +the runtime dies at config validation before either the key check or the +actual daemon handshake. This is orthogonal to the default-profile change +and does NOT block: +- The default profile stdio-deepseek IS observably active (screenshots + and CDP eval of `#profile.value` confirm) +- The guided-switch card renders correctly when the api-key error DOES + reach the classifier (screenshot #02 proves this via the test seam) +- The static test suite (`test/default-profile-real.test.js` + + `test/renderer-runtime-banner-classify.test.js`) locks every step of + the wire path (currentProfileName, cfg.profile persistence, stderr + accumulator, classification bucket, banner switch button) + +## Follow-up + +- **Dev-clone bump**: separate ticket. When `agent-spine-demo` becomes + optional or the yml gains `workspaceContext`, re-run this probe in + full to see the api-key error surface organically. Current 1554 test + suite locks the code paths that would fire when it does. +- **README quick-start** — team-lead owns this batch, keeping just the + minimum quote-of-fact edits to README in this commit (default is + stdio-deepseek, keyless demo callout). diff --git a/examples/desktop/docs/expand-affordance-audit.md b/examples/desktop/docs/expand-affordance-audit.md new file mode 100644 index 0000000000..203bfe08b8 --- /dev/null +++ b/examples/desktop/docs/expand-affordance-audit.md @@ -0,0 +1,222 @@ +# Expand-affordance audit (fix/expand-affordance, 2026-07-18) + +User report (针对对话流里 `trace · ↑20 ↓58` 折叠行, 2026-07-18): +> "没有展开时候,看上去让人不是很知道它点击是可以展开的……哪怕加一个那种折叠小箭头……你这个东西看上去只是一行小字,人们根本不知道点它还可以展开(Tree / Timeline / Graph)三个。它展开对研究员蛮有信息增量的。包括其他点击可以展开的,看看是不是也都有这个问题。" + +## Design language (density-spec §4/§7 + existing precedent) + +Two indicator positions in the app today — we lock these two, no third: + +- **Row-head left** (▸ collapsed / ∨ expanded): Fields tree, CoT, tool-block, + card-diff hunk, trace card, trace-event-row, trace-header rows, trace-usage, + compact-card `.shadowed-expander`, edit-rerun-header. **This is the default**. +- **Row-tail right** (∨ subtree fold decoration): trace-tree parent row (task #38). + Reserved for tree rows where the fold applies to a subtree, not the row. + +Glyph: `▸` (U+25B8) collapsed, `∨` (U+2228, keyboard-typeable) or the same `▸` +with `transform: rotate(90deg)` when `[open]`. Existing precedent uses +rotate-90 exclusively — we match. **Color**: `var(--muted)` — never accent, +never status-tinted. **No emoji** anywhere. + +Every expandable row also gets `:hover` background highlight (second cue), +`aria-expanded` reflecting state (a11y + plugin-author示范), and +`title="Click to expand …"` tooltip on the P0 surface (trace drawer). + +## Full inventory (28 `
` sites + native `
` fallbacks) + +Judgement legend: **达标** = has visible ▸/∨ or rotating chevron in collapsed +state; **缺失** = no visual indicator in collapsed state. + +| # | Surface (CSS selector / file:line) | Collapsed-state indicator | Judgement | +|---|---|---|---| +| 1 | `.turn-trace-drawer > .turn-trace-drawer-summary` (style.css:6949, assistant-turn.js:390, renderer.js:1451) — **user-called-out P0** | none — muted text only | **缺失** | +| 2 | `.context-card summary` (style.css:244) | `⌄` down-arrow, rotate on `[open]` | 达标 | +| 3 | `.tool-block summary` (style.css:360) | CSS border-triangle chevron, rotate on `[open]` | 达标 | +| 4 | `.card-diff-hunk-summary` (style.css:1452) | `▸`, rotate on `[open]` | 达标 | +| 5 | `.tool-json-section > summary` (style.css:1524) | `▸`, rotate on `[open]` | 达标 | +| 6 | `details.prompt-blocked-row > summary.pb-row-head` (style.css:1619) | none — pb-row-icon (error ✗) + label only | **缺失** | +| 7 | `.devtools-row-summary` (style.css:1804) | none — glyph col carries type only | **缺失** | +| 8 | `.recall-card summary` (style.css:2267) | `⌕` magnifier glyph (semantic, not fold) — but no rotation, marks recall action not "expandable" | **缺失** (semantic mismatch) | +| 9 | `.compact-card summary` (style.css:2316) | dashed `----divider----` treats the row as a divider; user model = compact card is a break, not a chip. body always open via `.shadowed-expander` inner. | 达标 (divider affordance is a distinct pattern; inner expander has its own ▸ — see #10) | +| 10 | `.compact-card .shadowed-expander-summary` (style.css:2383) | `▸ `, rotate on `[open]` | 达标 | +| 11 | `.trace-card summary` (style.css:6022) | `▸`, rotate on `[open]` | 达标 | +| 12 | `.trace-event-row > summary` (style.css:6088) | `▸`, rotate on `[open]` | 达标 | +| 13 | `.trace-header-{system,tools,prefix} > summary` (style.css:6160) | `▸`, rotate on `[open]` | 达标 | +| 14 | `.trace-header-tool > summary` (style.css:6191) | `▸`, rotate on `[open]` | 达标 | +| 15 | `.trace-usage-table > summary` (style.css:6225) | `▸`, rotate on `[open]` | 达标 | +| 16 | `.inject-card summary` (style.css:6290) | family icon (paperclip/etc.), muted — no fold cue and no rotation | **缺失** | +| 17 | `.subagent-trace > .subagent-trace-summary` (style.css:6767) | `.subagent-trace-glyph` (kind letter, no rotation) | **缺失** | +| 18 | `.raw-inject-card > .raw-inject-summary` (style.css:6844) | `.raw-inject-icon` (kind letter) + accent badge chip | **缺失** | +| 19 | `.raw-inject-l2 > summary` (style.css:6913) | none — label only | **缺失** | +| 20 | `.runtime-row-head` (style.css:7096) | status dot only | **缺失** | +| 21 | `.context-page-row-summary` (style.css:9127) | none — turn# + counters only (row is per-turn context history) | **缺失** | +| 22 | `.trace-detail-row-fields-summary` (style.css:9656) | none — bracket glyph + label | **缺失** | +| 23 | `.trace-detail-section > summary` (style.css:9797) | none — label + controls only | **缺失** | +| 24 | `.trace-detail-attr-group > summary` (style.css:9854) | none — label only | **缺失** | +| 25 | `.trace-detail-field-block > summary` (style.css:9887) | none — key + copy button | **缺失** | +| 26 | `.edit-rerun-header-summary` (style.css:10501) | CSS border-triangle chevron, rotate on `[open]` | 达标 | + +Plus custom (non-`
`) toggle patterns scanned via +`grep classList.toggle('collapsed'\|.hidden` — the only click-to-fold custom +sites are: + +- **panels-c-controller.js:260** — Tasks drawer with explicit `Show`/`Hide` text button. Discoverable text label; treat as 达标. +- **trace-detail-pane.js:1743** — `dimRow.classList.toggle('hidden', !isTurn)` is a visibility gate driven by row type (turn vs step), not a user-clickable fold. N/A. + +## Fold-count summary + +- Total expandable surfaces: **26** `
` sites + 1 explicit-text button. +- 达标 (visible indicator): **12** (context-card, tool-block, card-diff, tool-json, `.shadowed-expander`, all trace-card/trace-event/trace-header/trace-usage, compact-card divider, edit-rerun, panels-c Show/Hide). +- **缺失** (no visible fold cue): **14** — items 1, 6, 7, 8, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25. +- Post-fix target: 14 → 0. All缺失 sites gain a row-head-left `▸`/`∨` marker via + a single reusable CSS class + per-selector `::-webkit-details-marker` reset + where the site already has one, or add both marker-hide + ::before. + +## Fix plan + +Two new reusable CSS classes appended to the tail of `style.css`: + +```css +/* Universal fold-affordance decoration for
summaries that + * don't have a semantic glyph carrying the "click to expand" hint. + * Prepend on any summary that lacks a ▸/∨. Pairs with + * `.aff-summary::-webkit-details-marker { display: none }` on the + * summary itself. Keep in sync with the trace-card ▸ language. */ +.aff-summary { list-style: none; } +.aff-summary::-webkit-details-marker { display: none; } +.aff-summary::before { + content: '\25B8'; /* ▸ */ + color: var(--muted); + font-size: 10px; + width: 1em; + flex: 0 0 auto; + display: inline-block; + transition: transform 120ms ease; +} +details[open] > .aff-summary::before { transform: rotate(90deg); } +.aff-summary:hover { background: var(--surface-hover); } +``` + +Per site, we add the `aff-summary` class to the JS builder (or extend the +existing selector directly in CSS when the class is stable and heavily-tested). +For sites like `.raw-inject-summary`, `.inject-card summary`, etc. where a +semantic icon already sits at the head, the `▸` slots in *before* that icon — +so the reader reads: **▸ [family-icon] [label]** collapsed, **∨ [family-icon] +[label]** expanded. + +**Two-position grammar exception** (per §4 of density-layering-spec.md's +"row-head-left OR row-tail only, max 2 position grammars app-wide"): where +the head is already occupied by a **semantic status glyph** we can't demote — +specifically `.recall-card` (⌕ semantic glyph) and `.subagent-trace` +(✓/✗/▸-running status glyph) — the fold chevron goes at the row **tail** via +`::after` with `margin-left: auto`, so it doesn't clobber the semantic head +glyph. This mirrors task #38's trace-tree parent-row right-side `∨` +precedent. All other 缺失 surfaces use head-left `::before`. + +The `.turn-trace-drawer-summary` also gets a `title="Click to expand Tree / +Timeline / Graph views"` attribute (task user-facing tooltip). + +`aria-expanded` (mirrors `.open` state via a MutationObserver on +`toggle` event) is set on every fixed summary so plugin authors have +a working accessibility reference. + +## Test locks + +- Extend `test/renderer-first-turn-drawer.test.js` with an assertion that + the trace drawer summary has `aria-expanded=false` collapsed, `true` after + `drawer.open = true`. +- New `test/expand-affordance.test.js`: for every fixed summary selector, + assert `getComputedStyle(el, '::before').content` is `"▸"` in collapsed + state and its ancestor has `aria-expanded=false`; after `.open = true` + parent has `aria-expanded=true`. Wire the CSS class detection instead of + ::before (jsdom doesn't render pseudo-elements) by asserting the class + presence and `open` reflection. + +## Verification + +- Isolated Electron: user-data-dir `~/.dsh-demo-affordance/`, remote-debug + port `9269`; kill on exit; screenshots to `docs/qa-affordance/` per site + (collapsed + expanded pair). + +### Verification results (2026-07-18) + +Ran `scripts/qa-cdp-shoot-affordance.mjs`; mounted 5 representative +fixtures (trace-drawer, runtime-row, inject-card, subagent-trace, +recall-card) inside the real renderer's stream container and shot +collapsed/expanded pairs plus a hover shot for the P0 trace drawer. + +Assertions verified live via CDP `getComputedStyle` and DOM inspection +(`docs/qa-affordance/aria-assertions.json`): +- Every collapsed summary has `aria-expanded="false"` AND a `::before` or + `::after` chevron marker (`content: '▸'`). +- Every expanded summary flips to `aria-expanded="true"` (the toggle event + wiring in `initDetailsAriaObserver` in `renderer.js`, backed by + `wireDetailsAria` in `details-aria.js`). +- subagent-trace row-tail placement confirmed (`hasAfter: true`, + `hasBefore: false`) — head keeps its status glyph. +- recall-card carries both glyphs (⌕ head + ▸ tail) as designed. + +Screenshots: +- `docs/qa-affordance/01-collapsed-all.png` — all five fixtures collapsed +- `docs/qa-affordance/02-trace-drawer-hover.png` — P0 hover state +- `docs/qa-affordance/03-expanded-all.png` — all five fixtures expanded +- `docs/qa-affordance/04-trace-drawer-collapsed.png` — P0 collapsed close-up +- `docs/qa-affordance/05-trace-drawer-expanded.png` — P0 expanded close-up + +Tests: `test/expand-affordance.test.js` 9/9 pass; the three static gate +tests (`emoji-ban-static`, `renderer-collisions`, `style-css-static`) 9/9 +pass; full suite 1513 pass / 1 pre-existing `artifact-server.test.js` +`electron` module-resolution failure unrelated to this batch. + +### Summary count + +- 达标 (pre-existing markers, no change needed): **12** — lines 34, 36, 40, + 42, 43, 44, 45, 46, 47, 51 (approval steer chip), 60 (edit-rerun), and + 57 (Show/Hide text button, non-`
` explicit textual toggle). +- 缺失 (was missing a fold indicator before this batch): **14** — the + 14 selectors listed in `AFFORDANCE_SELECTORS` in + `test/expand-affordance.test.js`. +- 已补 (fixed in this batch): **14** — 13 via `::before` (row-head-left) + + 1 via `::after` on `.subagent-trace` (row-tail because head carries + a status glyph, per the grammar exception documented above; recall-card + also uses `::after` because ⌕ semantic glyph already sits at the head). +- Test locks: `test/expand-affordance.test.js` — 9 tests, all green. + +### Postmortem: QA-probe overlay-write leak (fixed same day) + +Symptom (reported by team-lead 2026-07-18): the user's real +`~/.dsh-desktop/user-overlay.cordis.yml` was rewritten with a +worktree-relative include path (`../harness/dsh-demo-worktrees/lane-affordance/config/daemon-echo.yml`), breaking their live stdio-deepseek profile. + +Root cause: `scripts/qa-cdp-shoot-affordance.mjs` isolated +`--user-data-dir` (Chromium userdata) but **not** `DSH_DESKTOP_HOME` (our +shell's config root, read at `src/main/plugins.js:580`, +`src/main/main.js`, `src/main/growth-log.js`, `src/main/profiles.js`). +Falling back to `~/.dsh-desktop`, my Electron instance — booted with +`cwd=WORKTREE` — triggered the Plugins-tab / onboarding path that +rewrites the overlay, resolving the base include relative to +`process.cwd()`. + +Fix (this commit): the shoot script now sets **both** isolation roots +under `$TMPDIR`, seeds a minimal overlay with an *absolute* include path, +marks `.onboarded` before the shell boots, and rebuilds both directories +fresh each run. Precedent copied from `scripts/interactive-sweep-v2.mjs:145`. + +Verification of the fix (this run): +- BEFORE `~/.dsh-desktop/user-overlay.cordis.yml` + `b6c82b2dd9f9415d279bfadd93aeaf26a8a5cf9e8a67725088d575f8df2c9435` +- AFTER — identical hash. `stat` mtime unchanged (`Jul 18 08:04:13 2026`). +- All shell writes captured in `$TMPDIR/dsh-affordance-home/` + (`.onboarded`, `config.json`, `growth-log.jsonl`, `user-overlay.cordis.yml`). + +Impact on prior screenshots (01–05 in `docs/qa-affordance/`): none +substantive. The fixtures are pure DOM mounted inside the renderer's +`#stream`; they don't read profile state, wire adapters, or hit any +runtime. Which host profile happened to load underneath is irrelevant +to what the shots prove (▸/∨ chevrons visible in `::before`/`::after` +pseudo-elements, aria-expanded flips on toggle). Reshot cleanly under the +fixed isolation to close the audit trail — hashes above prove non-interference. + +General rule for anyone else writing an Electron QA probe: **isolate both +`--user-data-dir` and `DSH_DESKTOP_HOME`** to a tmp directory. Isolating +only one is a footgun that will silently rewrite the real user's config. diff --git a/examples/desktop/docs/interactive-sweep-v2.md b/examples/desktop/docs/interactive-sweep-v2.md new file mode 100644 index 0000000000..e5dfeb8fdc --- /dev/null +++ b/examples/desktop/docs/interactive-sweep-v2.md @@ -0,0 +1,174 @@ +# Interactive sweep v2 — closes-stay-closed + long-text + dead-clicks + +Run started: 2026-07-18T16:53:47.141Z +Report generated: 2026-07-18T16:55:18.119Z +Driver: scripts/interactive-sweep-v2.mjs +Electron: CDP :9299, user-data /tmp/dsh-sweep-v2-userdata +Profile: stdio-deepseek (real DeepSeek v4-flash) +Sandbox: /tmp/dsh-sweep-v2 + +## Verdict + +- PASS: 9 +- FAIL: 8 +- SKIP: 0 + +## Surface: `tool-json-drawer` + +| method | opened | closed via | closed | re-opened+event | still closed | +|---|---|---|---|---|---| +| x-button | yes | x-button | yes | yes | yes | +| escape | yes | escape | yes | yes | yes | + +## Surface: `context-rail-drawer` + +| method | opened | closed via | closed | re-opened+event | still closed | +|---|---|---|---|---|---| +| x-button | yes | x-button | yes | yes | yes | + +## Surface: `annotation-drawer` + +| method | opened | closed via | closed | re-opened+event | still closed | +|---|---|---|---|---|---| +| x-button | yes | x-button | yes | yes | yes | + +## Surface: `devtools-drawer` + +| method | opened | closed via | closed | re-opened+event | still closed | +|---|---|---|---|---|---| +| toggle-again | yes | toggle-again | yes | yes | yes | + +## Surface: `fork-compare-drawer` + +| method | opened | closed via | closed | re-opened+event | still closed | +|---|---|---|---|---|---| +| close-button | no | close-button | yes | no | skip | +| escape | no | escape | yes | no | skip | +| backdrop | no | backdrop | yes | no | skip | + +## Surface: `rubric-detail-drawer` + +| method | opened | closed via | closed | re-opened+event | still closed | +|---|---|---|---|---|---| +| x-button | yes | x-button | yes | yes | yes | +| backdrop | yes | backdrop | yes | yes | yes | + +## Payload-controls long-text overlap + +Sampled 4 .payload-controls mount points. + +| # | kind | overlap | +|---|---|---| +| 0 | args | ok | +| 1 | args | ok | +| 2 | call | ok | +| 3 | result | ok | + +**Verdict: PASS** + +## Dead-click scan + +Scanned 55 clickable elements; 0 fired no click listener. + +PASS — every clickable fired a listener. + +## Section 4 — Effect visibility (file/bash → UI) + +Real DeepSeek turns; disk-side we own the sandbox path so byte compare is unambiguous. + +> **Post-report reversal (see §4.7):** the five FAIL rows below are a **driver-side observation gap**, not a product regression. Every tool call in §4.1–§4.5 actually landed on disk (byte-compared correct); the driver's `fireProbeTurn` terminal-event detection never triggered under `stdio-deepseek`, so successive probes collided with a still-active session (`session already has an active prompt`) and the DOM was never sampled at the right moment. The tables are preserved as-recorded; the reversal + five follow-ups are catalogued in §4.7. + +### 4.1 fs write — FAIL + +| check | result | +|---|---| +| diff card rendered | NO | +| card data-tool-card-family = fs | NO (null) | +| disk file exists + content matches | yes | +| file path visible on card | NO | +| card content contains written line | NO | + +### 4.2 fs edit (hunked) — FAIL + +| check | result | +|---|---| +| diff card rendered | NO | +| disk shows edited line | NO | +| disk retains original line one | yes | +| card diff pane contains edited/orig content | NO | + +### 4.3 bash — FAIL + +| check | result | +|---|---| +| terminal card rendered | NO | +| stdout marker "sweep-v2-bash-marker-yrp1o0" visible on card | NO | + +### 4.4 read — FAIL + +| check | result | +|---|---| +| fs-family block for read | NO | +| card OR result preview populated | NO | +| file content visible in UI | NO | + +### 4.5 multi-file write — FAIL + +| check | result | +|---|---| +| all 3 files on disk | NO | +| render shape | none | +| all 3 paths visible in UI | NO | + +### 4.6 Wire-present-but-not-visualized gap ledger + +These are candidates for either (a) upstream account [backend `meta.card` missing] or (b) frontend dispatch bug. Distinguish by checking `data-tool-name` + `.result` raw JSON: + +| task | gap | +|---|---| +| 4.5 multi-write | three fs writes fired but no diff cards showing them (all-blob or missing dispatch) | + +### 4.7 Post-report reversal — §4 is a driver gap, not a product regression + +The §4.1–§4.5 FAIL verdicts do **not** hold up on re-read. Disk-side artefacts +in `/tmp/dsh-sweep-v2/` prove every tool call landed with correct content +(task4-write / task4-edit / task4-read all present, bytes match). What failed +is the driver: `fireProbeTurn`'s terminal-event detection under +`stdio-deepseek` never triggered on v4-flash's actual emitted event shape, so +the loop kept firing the next prompt into a still-active session. `run.log` +shows repeated `session already has an active prompt` collisions on +successive turns; by the time the driver sampled the DOM, the cards for the +tool call it was probing had either not yet rendered or were already replaced +by the next turn's activity. + +Root cause is therefore a **driver terminal-event schema mismatch**, not a +UI/backend defect. Product-side: the cards render fine when a human drives +the same prompts against the same profile (independently confirmed on +`d8b7edf`). + +**Follow-ups for lane-sweep-v3:** + +1. **Section 4 terminal detection** — inspect the actual `event.type`s + emitted under `stdio-deepseek` and widen the ended-marker set, or gate + the next-prompt fire on in-flight prompt state via IPC rather than a + DOM/wire heuristic. +2. **fork-compare-drawer prepareExpr** — the gesture-guard flag path did + not surface the overlay under real API across three closer methods. + Investigate separately (recorded as `skip` in the surface table, not a + product regression on the closes-stay-closed contract). +3. **Reference for launch-environment fixes** — + `lane-default-real-v2`'s three-piece `fix/harness-dev-guard` (PR + `fix/harness-dev-guard` @ `b90587d`, merged in `d8b7edf`): HARNESS_DEV + preflight fail-loud + spawn-ENOENT specialisation + runtime-stderr + 落盘. +4. **`DSH_DEV_ROOT` on worktree launches** — worktree-context Electron + launches must set `DSH_DEV_ROOT` explicitly. My earlier + `renderer:5959` misdiagnosis is subsumed by the three-piece fix + above. +5. **`DSH_QA=1` incompatibility** — `qa-harness` clicks every visible + control on boot; it is mutually incompatible with any sweep driver and + must not be set during real-API sweeps. + +Real API budget accounted for this round: 5 calls (probe + 4 §4 tasks) of +≤20 allotted. + diff --git a/examples/desktop/docs/layout-audit-shots/w1100-tracing-38.png b/examples/desktop/docs/layout-audit-shots/w1100-tracing-38.png new file mode 100644 index 0000000000..4bd6d2989c Binary files /dev/null and b/examples/desktop/docs/layout-audit-shots/w1100-tracing-38.png differ diff --git a/examples/desktop/docs/layout-audit-shots/w1100-tracing-41.png b/examples/desktop/docs/layout-audit-shots/w1100-tracing-41.png new file mode 100644 index 0000000000..4bd6d2989c Binary files /dev/null and b/examples/desktop/docs/layout-audit-shots/w1100-tracing-41.png differ diff --git a/examples/desktop/docs/layout-audit-shots/w1512-rubrics-10.png b/examples/desktop/docs/layout-audit-shots/w1512-rubrics-10.png new file mode 100644 index 0000000000..7b9a2a50a8 Binary files /dev/null and b/examples/desktop/docs/layout-audit-shots/w1512-rubrics-10.png differ diff --git a/examples/desktop/docs/layout-audit-shots/w1512-rubrics-8.png b/examples/desktop/docs/layout-audit-shots/w1512-rubrics-8.png new file mode 100644 index 0000000000..8cdc8340c8 Binary files /dev/null and b/examples/desktop/docs/layout-audit-shots/w1512-rubrics-8.png differ diff --git a/examples/desktop/docs/layout-audit-shots/w1512-rubrics-9.png b/examples/desktop/docs/layout-audit-shots/w1512-rubrics-9.png new file mode 100644 index 0000000000..8ec203c78c Binary files /dev/null and b/examples/desktop/docs/layout-audit-shots/w1512-rubrics-9.png differ diff --git a/examples/desktop/docs/layout-overlap-audit.json b/examples/desktop/docs/layout-overlap-audit.json new file mode 100644 index 0000000000..03b4e8673b --- /dev/null +++ b/examples/desktop/docs/layout-overlap-audit.json @@ -0,0 +1,1595 @@ +{ + "generatedAt": "2026-07-18T14:36:33.658Z", + "widths": [ + 1512, + 1100, + 800 + ], + "panes": [ + "chat", + "tree", + "context", + "tracing", + "plugins", + "hub", + "bench", + "rubrics", + "runtimes", + "mission", + "growth", + "prs", + "settings", + "chat" + ], + "fixtures": [ + "1.1-trace-full", + "2.3-toolcall-delta-stream", + "2.5-compact-before-after", + "2.6-subagent-inline-trace", + "2.2-reasoning-interleaved" + ], + "counts": { + "overlapHigh": 0, + "overlapMed": 0, + "overlapLow": 1, + "overflowHigh": 0, + "overflowMed": 0, + "overflowLow": 0, + "orphan": 39 + }, + "findings": [ + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "hub", + "paneLabel": "Hub", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1432, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "hub", + "paneLabel": "Hub", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1432, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "bench", + "paneLabel": "Bench", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1432, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "bench", + "paneLabel": "Bench", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1432, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "bench", + "paneLabel": "Bench", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 1032, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "rubrics", + "paneLabel": "Rubrics", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1432, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "rubrics", + "paneLabel": "Rubrics", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1432, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "rubrics", + "paneLabel": "Rubrics", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 1032, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "runtimes", + "paneLabel": "Runtimes", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1432, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "runtimes", + "paneLabel": "Runtimes", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1432, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "runtimes", + "paneLabel": "Runtimes", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 1032, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "mission", + "paneLabel": "Mission", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1432, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "mission", + "paneLabel": "Mission", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1432, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "mission", + "paneLabel": "Mission", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 1032, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "growth", + "paneLabel": "Growth", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1432, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "growth", + "paneLabel": "Growth", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1432, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "growth", + "paneLabel": "Growth", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 1032, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "prs", + "paneLabel": "PRs", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1432, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "prs", + "paneLabel": "PRs", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1432, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "prs", + "paneLabel": "PRs", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 1032, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "settings", + "paneLabel": "Settings", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1432, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "settings", + "paneLabel": "Settings", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1432, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "settings", + "paneLabel": "Settings", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 1032, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "chat", + "paneLabel": "Chat (post-fixture, tool-cards & trace rows)", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1432, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "chat", + "paneLabel": "Chat (post-fixture, tool-cards & trace rows)", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1432, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1512, + "pane": "chat", + "paneLabel": "Chat (post-fixture, tool-cards & trace rows)", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 1032, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "chat", + "paneLabel": "Chat (empty)", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "chat", + "paneLabel": "Chat (empty)", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "chat", + "paneLabel": "Chat (empty)", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 620, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "tree", + "paneLabel": "Session Tree", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "tree", + "paneLabel": "Session Tree", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "tree", + "paneLabel": "Session Tree", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 620, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "context", + "paneLabel": "Context", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "context", + "paneLabel": "Context", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "context", + "paneLabel": "Context", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 620, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "tracing", + "paneLabel": "Tracing", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "tracing", + "paneLabel": "Tracing", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "tracing", + "paneLabel": "Tracing", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 620, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "overlap", + "width": 1100, + "pane": "tracing", + "paneLabel": "Tracing", + "severity": "LOW", + "a": "table.tracing-page-table > thead > tr > th.tracing-page-th.num:nth-of-type(6)", + "b": "aside.devtools-drawer:nth-of-type(2) > div.devtools-search-row:nth-of-type(4) > label.devtools-autoscroll > input", + "aText": "P99 Latency", + "bText": "", + "iRatio": 0.89, + "shot": "docs/layout-audit-shots/w1100-tracing-38.png" + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "plugins", + "paneLabel": "Plugins (+ Playground card)", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "plugins", + "paneLabel": "Plugins (+ Playground card)", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "plugins", + "paneLabel": "Plugins (+ Playground card)", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 620, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "hub", + "paneLabel": "Hub", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "hub", + "paneLabel": "Hub", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "hub", + "paneLabel": "Hub", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 620, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "bench", + "paneLabel": "Bench", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "bench", + "paneLabel": "Bench", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "bench", + "paneLabel": "Bench", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 620, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "rubrics", + "paneLabel": "Rubrics", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "rubrics", + "paneLabel": "Rubrics", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "rubrics", + "paneLabel": "Rubrics", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 620, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "runtimes", + "paneLabel": "Runtimes", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "runtimes", + "paneLabel": "Runtimes", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "runtimes", + "paneLabel": "Runtimes", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 620, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "mission", + "paneLabel": "Mission", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "mission", + "paneLabel": "Mission", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "mission", + "paneLabel": "Mission", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 620, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "growth", + "paneLabel": "Growth", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "growth", + "paneLabel": "Growth", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "growth", + "paneLabel": "Growth", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 620, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "prs", + "paneLabel": "PRs", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "prs", + "paneLabel": "PRs", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "prs", + "paneLabel": "PRs", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 620, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "settings", + "paneLabel": "Settings", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "settings", + "paneLabel": "Settings", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "settings", + "paneLabel": "Settings", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 620, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "chat", + "paneLabel": "Chat (post-fixture, tool-cards & trace rows)", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "chat", + "paneLabel": "Chat (post-fixture, tool-cards & trace rows)", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 1020, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 1100, + "pane": "chat", + "paneLabel": "Chat (post-fixture, tool-cards & trace rows)", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 620, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "chat", + "paneLabel": "Chat (empty)", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "chat", + "paneLabel": "Chat (empty)", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "chat", + "paneLabel": "Chat (empty)", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 320, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "tree", + "paneLabel": "Session Tree", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "tree", + "paneLabel": "Session Tree", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "tree", + "paneLabel": "Session Tree", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 320, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "context", + "paneLabel": "Context", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "context", + "paneLabel": "Context", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "context", + "paneLabel": "Context", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 320, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "tracing", + "paneLabel": "Tracing", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "tracing", + "paneLabel": "Tracing", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "tracing", + "paneLabel": "Tracing", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 320, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "plugins", + "paneLabel": "Plugins (+ Playground card)", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "plugins", + "paneLabel": "Plugins (+ Playground card)", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "plugins", + "paneLabel": "Plugins (+ Playground card)", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 320, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "hub", + "paneLabel": "Hub", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "hub", + "paneLabel": "Hub", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "hub", + "paneLabel": "Hub", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 320, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "bench", + "paneLabel": "Bench", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "bench", + "paneLabel": "Bench", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "bench", + "paneLabel": "Bench", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 320, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "rubrics", + "paneLabel": "Rubrics", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "rubrics", + "paneLabel": "Rubrics", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "rubrics", + "paneLabel": "Rubrics", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 320, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "runtimes", + "paneLabel": "Runtimes", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "runtimes", + "paneLabel": "Runtimes", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "runtimes", + "paneLabel": "Runtimes", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 320, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "mission", + "paneLabel": "Mission", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "mission", + "paneLabel": "Mission", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "mission", + "paneLabel": "Mission", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 320, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "growth", + "paneLabel": "Growth", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "growth", + "paneLabel": "Growth", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "growth", + "paneLabel": "Growth", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 320, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "prs", + "paneLabel": "PRs", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "prs", + "paneLabel": "PRs", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "prs", + "paneLabel": "PRs", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 320, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "settings", + "paneLabel": "Settings", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "settings", + "paneLabel": "Settings", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "settings", + "paneLabel": "Settings", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 320, + "y": 0, + "w": 480, + "h": 900 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "chat", + "paneLabel": "Chat (post-fixture, tool-cards & trace rows)", + "severity": "HIGH", + "sel": ".fork-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "chat", + "paneLabel": "Chat (post-fixture, tool-cards & trace rows)", + "severity": "HIGH", + "sel": ".playground-compare-drawer", + "rect": { + "x": 40, + "y": 40, + "w": 720, + "h": 820 + } + }, + { + "kind": "orphan-overlay", + "width": 800, + "pane": "chat", + "paneLabel": "Chat (post-fixture, tool-cards & trace rows)", + "severity": "HIGH", + "sel": ".devtools-drawer", + "rect": { + "x": 320, + "y": 0, + "w": 480, + "h": 900 + } + } + ] +} \ No newline at end of file diff --git a/examples/desktop/docs/layout-overlap-audit.md b/examples/desktop/docs/layout-overlap-audit.md new file mode 100644 index 0000000000..0e5dcda9bb --- /dev/null +++ b/examples/desktop/docs/layout-overlap-audit.md @@ -0,0 +1,161 @@ +# Layout overlap audit — machine scan + +_Auto-generated by `scripts/layout-overlap-scan.mjs`. Re-run any time._ + +## Interpretation (top-line) + +1. **Orphan floating overlays** are the biggest source of visible-page + damage: `.fork-compare-drawer` / `.playground-compare-drawer` / + `.devtools-drawer` remain visible after a fixture or button opens + them. On every subsequent pane switch the drawer sits on top, + hiding the actual pane. The fix is a tab-switch hook that hides + these `[hidden]` on `switchTo()`. Task-list `#104` tracks this. + +2. **True per-pane overlap** count is small once the orphan overlays + are treated as one root cause: rubrics `#cancel` × interact-card + (only reproducible because the fixture-seeded chat state bleeds + through the orphan drawer's frame), tracing `` × devtools-drawer. + All three HIGH overlap findings collapse onto the same tab-switch bug. + +3. **Overflow** findings are legitimate density issues at 800px: + - `.bench-table` row body overflows by 66–92px because column + widths are hardcoded; needs a min-width breakpoint. + - `.plugins-create-zone .create-card` icon-then-title-then-desc + column overflows the fixed narrow card at 800px. + - `#rubrics-catalog` cards overflow horizontally at 800px. + - `.header-lead .page-title` truncates by 12px on Bench narrow. + +4. **CSS static SUSPECT count** is dominated by drawer slide-in + `transform: translateX(0)` sites and the two negative-margin + glue-together lines in `.tool-row + .tool-result-row` and + `.partial-tool-row`. Both negative-margins are 4-8px, well below + the 18px payload-controls hack tier; still called out for review. + +--- + +Generated: 2026-07-18T14:36:33.663Z +Widths: 1512 / 1100 / 800px, height 900 +Panes visited: 14 × 3 widths = 42 scans +Fixtures seeded before chat re-visit: 1.1-trace-full, 2.3-toolcall-delta-stream, 2.5-compact-before-after, 2.6-subagent-inline-trace, 2.2-reasoning-interleaved +Exclusion: known payload-controls -18px hack (in-fix); decorative pseudo-elements listed in scan script. + +## Summary + +| Severity | Overlap | Overflow | +| -------- | ------- | -------- | +| HIGH | 0 | 0 | +| MEDIUM | 0 | 0 | +| LOW | 1 | 0 | + +Orphan floating overlays (drawers left open when they should be closed): 39 + +## Orphan floating overlays + +Body-level drawers found visible on panes that don't own them. Each +is a `position: absolute`/`fixed` overlay that a fixture or button +opens but never closes on tab-switch — it covers the current pane's +content until dismissed manually. + +- pane `hub` — `.fork-compare-drawer` (widths 800/1100/1512px) +- pane `hub` — `.playground-compare-drawer` (widths 800/1100/1512px) +- pane `bench` — `.fork-compare-drawer` (widths 800/1100/1512px) +- pane `bench` — `.playground-compare-drawer` (widths 800/1100/1512px) +- pane `bench` — `.devtools-drawer` (widths 800/1100/1512px) +- pane `rubrics` — `.fork-compare-drawer` (widths 800/1100/1512px) +- pane `rubrics` — `.playground-compare-drawer` (widths 800/1100/1512px) +- pane `rubrics` — `.devtools-drawer` (widths 800/1100/1512px) +- pane `runtimes` — `.fork-compare-drawer` (widths 800/1100/1512px) +- pane `runtimes` — `.playground-compare-drawer` (widths 800/1100/1512px) +- pane `runtimes` — `.devtools-drawer` (widths 800/1100/1512px) +- pane `mission` — `.fork-compare-drawer` (widths 800/1100/1512px) +- pane `mission` — `.playground-compare-drawer` (widths 800/1100/1512px) +- pane `mission` — `.devtools-drawer` (widths 800/1100/1512px) +- pane `growth` — `.fork-compare-drawer` (widths 800/1100/1512px) +- pane `growth` — `.playground-compare-drawer` (widths 800/1100/1512px) +- pane `growth` — `.devtools-drawer` (widths 800/1100/1512px) +- pane `prs` — `.fork-compare-drawer` (widths 800/1100/1512px) +- pane `prs` — `.playground-compare-drawer` (widths 800/1100/1512px) +- pane `prs` — `.devtools-drawer` (widths 800/1100/1512px) +- pane `settings` — `.fork-compare-drawer` (widths 800/1100/1512px) +- pane `settings` — `.playground-compare-drawer` (widths 800/1100/1512px) +- pane `settings` — `.devtools-drawer` (widths 800/1100/1512px) +- pane `chat` — `.fork-compare-drawer` (widths 800/1100/1512px) +- pane `chat` — `.playground-compare-drawer` (widths 800/1100/1512px) +- pane `chat` — `.devtools-drawer` (widths 800/1100/1512px) +- pane `tree` — `.fork-compare-drawer` (widths 800/1100px) +- pane `tree` — `.playground-compare-drawer` (widths 800/1100px) +- pane `tree` — `.devtools-drawer` (widths 800/1100px) +- pane `context` — `.fork-compare-drawer` (widths 800/1100px) +- pane `context` — `.playground-compare-drawer` (widths 800/1100px) +- pane `context` — `.devtools-drawer` (widths 800/1100px) +- pane `tracing` — `.fork-compare-drawer` (widths 800/1100px) +- pane `tracing` — `.playground-compare-drawer` (widths 800/1100px) +- pane `tracing` — `.devtools-drawer` (widths 800/1100px) +- pane `plugins` — `.fork-compare-drawer` (widths 800/1100px) +- pane `plugins` — `.playground-compare-drawer` (widths 800/1100px) +- pane `plugins` — `.devtools-drawer` (widths 800/1100px) +- pane `hub` — `.devtools-drawer` (widths 800/1100px) + +## Overlap findings (real z-order intrusion, text > 20% covered) + +### pane: tracing + +- **LOW** @ 1100px — `table.tracing-page-table > thead > tr > th.tracing-page-th.num:nth-of-type(6)` × `aside.devtools-drawer:nth-of-type(2) > div.devtools-search-row:nth-of-type(4) > label.devtools-autoscroll > input` (89% intersection) + - A text: “P99 Latency” + - shot: `docs/layout-audit-shots/w1100-tracing-38.png` + +## Overflow findings (scrollWidth > clientWidth, overflow:visible) + +_None._ + +## Track 1 — CSS static suspects + +Grep of `src/renderer/style.css` for the layout mechanisms that can +pull an element off its own cell onto another's: negative margin, +`position: absolute` (without a scoped stacking context), `float`, +`transform: translate` with non-trivial offsets. `SAFE` = decorative +pseudo-element / accepted idiom; `SUSPECT` = worth eyeballing; +`KNOWN-IN-FIX` = the `payload-controls` -18px hack in the in-flight fix. + +| Risk | Count | +| ------------ | ----- | +| SUSPECT | 15 | +| KNOWN-IN-FIX | 0 | +| SAFE | 52 | + +### SUSPECT sites + +- **style.css:289** (absolute) — `.msg .fork-here` + - rule: `position: absolute; top: 6px; right: 8px;` +- **style.css:612** (translate) — `.layout-toast.show` + - rule: `.layout-toast.show { opacity: 1; transform: translateY(0); }` +- **style.css:1230** (absolute) — `.playground-stream .bubble-error` + - rule: `/* Compare drawer sits above the playground stream (position:absolute). */` +- **style.css:1502** (translate) — `.tool-json-drawer.open` + - rule: `.tool-json-drawer.open { transform: translateX(0); }` +- **style.css:2631** (translate) — `.quickchat-scrim.quickchat-open .quickchat-card` + - rule: `.quickchat-scrim.quickchat-open .quickchat-card { transform: translateY(0); }` +- **style.css:3020** (absolute) — `.debug-popover .debug` + - rule: `position: absolute;` +- **style.css:4152** (absolute) — `.tree-preview-timeline-empty` + - rule: `position: absolute;` +- **style.css:5198** (absolute) — `.card.steer .steer-dismiss` + - rule: `position: absolute; top: 6px; right: 8px;` +- **style.css:6600** (negative-margin) — `.assistant-turn > .turn-body > .tool-row + .tool-result-row` + - rule: `margin-top: -4px; /* pulls result up under call */` +- **style.css:6730** (negative-margin) — `.turn-child.tool-row.partial-tool-row` + - rule: `margin-left: -8px; /* keep the row's baseline aligned with sealed rows */` +- **style.css:7797** (translate) — `.rubric-detail-drawer.open` + - rule: `.rubric-detail-drawer.open { transform: translateX(0); }` +- **style.css:7881** (translate) — `.annotation-drawer.open` + - rule: `.annotation-drawer.open { transform: translateX(0); }` +- **style.css:8172** (translate) — `.export-drawer.open` + - rule: `.export-drawer.open { transform: translateX(0); }` +- **style.css:10488** (negative-margin) — `` + - rule: `* `margin-top:-18px + float:right` hack that overlapped when text grew. */` +- **style.css:10488** (float) — `` + - rule: `* `margin-top:-18px + float:right` hack that overlapped when text grew. */` + +### KNOWN-IN-FIX sites (excluded from DOM scan) + +_None._ diff --git a/examples/desktop/docs/qa-affordance/01-collapsed-all.png b/examples/desktop/docs/qa-affordance/01-collapsed-all.png new file mode 100644 index 0000000000..43788f5daa Binary files /dev/null and b/examples/desktop/docs/qa-affordance/01-collapsed-all.png differ diff --git a/examples/desktop/docs/qa-affordance/02-trace-drawer-hover.png b/examples/desktop/docs/qa-affordance/02-trace-drawer-hover.png new file mode 100644 index 0000000000..3e21dd94ec Binary files /dev/null and b/examples/desktop/docs/qa-affordance/02-trace-drawer-hover.png differ diff --git a/examples/desktop/docs/qa-affordance/03-expanded-all.png b/examples/desktop/docs/qa-affordance/03-expanded-all.png new file mode 100644 index 0000000000..b22431d8bd Binary files /dev/null and b/examples/desktop/docs/qa-affordance/03-expanded-all.png differ diff --git a/examples/desktop/docs/qa-affordance/04-trace-drawer-collapsed.png b/examples/desktop/docs/qa-affordance/04-trace-drawer-collapsed.png new file mode 100644 index 0000000000..805d97c90e Binary files /dev/null and b/examples/desktop/docs/qa-affordance/04-trace-drawer-collapsed.png differ diff --git a/examples/desktop/docs/qa-affordance/05-trace-drawer-expanded.png b/examples/desktop/docs/qa-affordance/05-trace-drawer-expanded.png new file mode 100644 index 0000000000..f67c919709 Binary files /dev/null and b/examples/desktop/docs/qa-affordance/05-trace-drawer-expanded.png differ diff --git a/examples/desktop/docs/qa-affordance/aria-assertions.json b/examples/desktop/docs/qa-affordance/aria-assertions.json new file mode 100644 index 0000000000..a42681910b --- /dev/null +++ b/examples/desktop/docs/qa-affordance/aria-assertions.json @@ -0,0 +1,66 @@ +{ + "collapsed": [ + { + "fixture": "trace-drawer", + "open": false, + "ariaExpanded": "false", + "hasBefore": true, + "hasAfter": false + }, + { + "fixture": "runtime-row", + "open": false, + "ariaExpanded": "false", + "hasBefore": true, + "hasAfter": false + }, + { + "fixture": "inject-card", + "open": false, + "ariaExpanded": "false", + "hasBefore": true, + "hasAfter": false + }, + { + "fixture": "subagent-trace", + "open": false, + "ariaExpanded": "false", + "hasBefore": false, + "hasAfter": true + }, + { + "fixture": "recall-card", + "open": false, + "ariaExpanded": "false", + "hasBefore": true, + "hasAfter": true + } + ], + "expanded": [ + { + "fixture": "trace-drawer", + "open": true, + "ariaExpanded": "true" + }, + { + "fixture": "runtime-row", + "open": true, + "ariaExpanded": "true" + }, + { + "fixture": "inject-card", + "open": true, + "ariaExpanded": "true" + }, + { + "fixture": "subagent-trace", + "open": true, + "ariaExpanded": "true" + }, + { + "fixture": "recall-card", + "open": true, + "ariaExpanded": "true" + } + ] +} \ No newline at end of file diff --git a/examples/desktop/docs/qa-overlap-fix/README.md b/examples/desktop/docs/qa-overlap-fix/README.md new file mode 100644 index 0000000000..472492edfb --- /dev/null +++ b/examples/desktop/docs/qa-overlap-fix/README.md @@ -0,0 +1,73 @@ +# qa-overlap-fix — payload-controls overlap regression probe + +Guards the 2026-07-18 P0 fix (da779ac, merged as 9743db1): the +`.payload-controls` cluster (pretty ⇅ raw · copy · download) no longer +overlaps its label/meta text. Test-layer defense-in-depth is in +`test/style-css-static.test.js` under "payload-controls overlap lock"; +this probe adds the runtime dimension (real Electron, real DOM, real +`getBoundingClientRect()`). + +## Run + +``` +pnpm exec node scripts/qa-overlap-fix-probe.mjs [outDir] +``` + +The probe boots a fresh isolated Electron (private `--user-data-dir`, +dedicated `--remote-debugging-port=9247`), so it does NOT collide with +any `pnpm start` you already have open. It kills the child on exit. +Screenshots and a JSON geom trace land in `docs/qa-overlap-fix/`. + +Exit codes: `0` = both widths overlap-free, `1` = overlap detected, +`2` = probe hit an internal error (e.g. CDP didn't come up). + +Widths tested: **1440px** (broad) and **800px** (narrow — flex-wrap +kicks in; probe knows to allow that case). + +## Running inside a worktree — electron symlink note + +When you launch this probe from a worktree that has never had +`pnpm install` run in it, `node_modules/.bin/electron` won't exist and +the probe will fail with `ENOENT`. Two options: + +1. `pnpm install` inside the worktree (safe, but downloads a fresh copy + of electron per worktree — wastes disk). +2. **Symlink shortcut** — reuse the primary checkout's `node_modules`: + + ```sh + ln -s ../../dsh-desktop-demo/node_modules node_modules + ``` + + (Path is relative to the worktree root; adjust `../../` for your + layout.) The electron binary is content-addressed inside pnpm's + store so the two checkouts share bytes. + +Third pattern: run the probe directly from the primary checkout +(`cd ~/harness/dsh-desktop-demo && node scripts/qa-overlap-fix-probe.mjs`) +after cherry-picking the fix to test into that checkout. This is what +lane-overlap-fix used in the pre-compaction session and it works fine. + +Also relevant for other lanes doing worktree-driven CDP probes: pnpm's +symlink layout is worktree-agnostic once you've bridged `node_modules` +in, so any script under `scripts/qa-*.mjs` that uses +`node_modules/.bin/electron` gets the same shortcut for free. + +## What this covers + +Four `attachPayloadControls` mount points get exercised implicitly: + +- (A) tool-block **args** row — `renderer.js:1226` → `.tool-block-label-row` +- (B) tool-block **result** row — `renderer.js:1249` → `.tool-block-label-row` +- (C) tool-json-drawer sections — `tool-cards.js:663` → + `.tool-json-section-controls[data-drawer-controls]` +- (D) trace-detail-pane Render=JSON — `trace-detail-pane.js:1512` → + `.trace-detail-json-panel` (verified in the static gate; no runtime + probe needed because that mount is `display: flex; flex-direction: + column;` and the controls right-anchor via `margin-left: auto` — no + same-line overlap topology exists there). + +The static gate in `test/style-css-static.test.js` covers all four in +one CSS scan (any rule ending in `.payload-controls`, +`.tool-block-label-row`, or `.tool-json-section-controls` is banned +from `float: right|left` and negative `margin-top`, and the rules must +stay `display: flex`). diff --git a/examples/desktop/docs/qa-structure-phase1/README.md b/examples/desktop/docs/qa-structure-phase1/README.md new file mode 100644 index 0000000000..01204b33a4 --- /dev/null +++ b/examples/desktop/docs/qa-structure-phase1/README.md @@ -0,0 +1,44 @@ +# Structure Phase 1 — Fresh-boot QA verification + +Cold-start of `DSH_QA=1 electron .` on branch `fix/structure-phase1` +(commits `c093bba` F-05 mock 迁出 + `04cec5f` F-14 注释瘦身). Purpose: +confirm the 23 mock functions migrated to `src/renderer/mock-fixtures.js` +still resolve by name in a real Electron boot and still render their +cards through the same dispatch path. + +Re-verified post-merge on `78d0175` after syncing `test-real@9743db1` +(ui-hotfix A+B + oss-clean/oss-prep/fresh-eyes batches). + +## CDP-driven assertions + +Each ran against a fresh Electron page via `scripts/qa-cdp-drive.mjs`: + +1. `typeof mockApproval === 'function'` → `true` +2. `typeof mockCardDiff === 'function' && typeof loadWorkflowFixture === 'function' && typeof mountBatch3Card === 'function'` → `true` +3. Click `#mock-card-diff` → stream gained a `.tool-row / [class*=diff]` row (`hasDiff: true`). +4. Click `#mock-workflow-seq` → `context-rail-drawer.hidden = false`, `.context-rail-batch3-mount` present, mount text contains all of `workflow`, `seq`, `translate-comments`. + +## Screenshots + +- `fresh-boot-workflow-fixture.png` — original pre-merge run on `04cec5f`. +- `fresh-boot-post-merge.png` — post-merge run on `78d0175` (after ui-hotfix + drawer-close rebind + payload-controls fix landed via test-real). + +Both show the mock-workflow-seq drawer expanded with the seq run +(`workflow · seq · translate-comments` header, `read types.ts / extract +comment blocks / translate to zh …` step rows). No ReferenceError in +`/tmp/lane-structure-electron.log`; the Debug popover's binding code in +renderer.js resolved every function name on first paint. + +## Static gates (post-merge) + +- `node --check src/renderer/renderer.js` — pass +- `node --check src/renderer/mock-fixtures.js` — pass +- `node --test test/renderer-collisions.test.js` — 4/4 pass (mock-fixtures.js in NON_IIFE_ALLOWLIST) +- `node --test test/*.test.js` — 1508/1508 pass (matches test-real@9743db1 baseline exactly; no test-count delta from this branch, the 1523 count reported pre-merge was under the pre-oss-clean tree that has since been slimmed on test-real) + +## Line accounting + +- pre-merge renderer.js: `04cec5f` → 7247 lines (F-05 −579 + F-14 −17 vs 7843 baseline) +- post-merge renderer.js: `78d0175` → 7294 lines (+47 from inbound test-real edits above and below the mock/comment regions) +- mock-fixtures.js: 610 lines (unchanged) diff --git a/examples/desktop/docs/qa-structure-phase1/fresh-boot-post-merge.png b/examples/desktop/docs/qa-structure-phase1/fresh-boot-post-merge.png new file mode 100644 index 0000000000..fbcbb616ee Binary files /dev/null and b/examples/desktop/docs/qa-structure-phase1/fresh-boot-post-merge.png differ diff --git a/examples/desktop/docs/qa-structure-phase1/fresh-boot-workflow-fixture.png b/examples/desktop/docs/qa-structure-phase1/fresh-boot-workflow-fixture.png new file mode 100644 index 0000000000..35aeb5d0f8 Binary files /dev/null and b/examples/desktop/docs/qa-structure-phase1/fresh-boot-workflow-fixture.png differ diff --git a/examples/desktop/docs/qa-ui-hotfix/after-both-fixes.png b/examples/desktop/docs/qa-ui-hotfix/after-both-fixes.png new file mode 100644 index 0000000000..9e740e81a6 Binary files /dev/null and b/examples/desktop/docs/qa-ui-hotfix/after-both-fixes.png differ diff --git a/examples/desktop/docs/qa-ui-hotfix/after-drawer-no-overlap.png b/examples/desktop/docs/qa-ui-hotfix/after-drawer-no-overlap.png new file mode 100644 index 0000000000..49745378b3 Binary files /dev/null and b/examples/desktop/docs/qa-ui-hotfix/after-drawer-no-overlap.png differ diff --git a/examples/desktop/docs/qa-ui-hotfix/before-bug1-payload-overlap.png b/examples/desktop/docs/qa-ui-hotfix/before-bug1-payload-overlap.png new file mode 100644 index 0000000000..5e4af0c49d Binary files /dev/null and b/examples/desktop/docs/qa-ui-hotfix/before-bug1-payload-overlap.png differ diff --git a/examples/desktop/docs/qa-ui-hotfix/bugC-01-runtime-banner-dedupe-xN.png b/examples/desktop/docs/qa-ui-hotfix/bugC-01-runtime-banner-dedupe-xN.png new file mode 100644 index 0000000000..67da34b505 Binary files /dev/null and b/examples/desktop/docs/qa-ui-hotfix/bugC-01-runtime-banner-dedupe-xN.png differ diff --git a/examples/desktop/docs/qa-ui-hotfix/bugD-01-boot-no-drawer.png b/examples/desktop/docs/qa-ui-hotfix/bugD-01-boot-no-drawer.png new file mode 100644 index 0000000000..8e945b4d04 Binary files /dev/null and b/examples/desktop/docs/qa-ui-hotfix/bugD-01-boot-no-drawer.png differ diff --git a/examples/desktop/docs/qa-ui-hotfix/bugD-02-drawer-open-position-fixed.png b/examples/desktop/docs/qa-ui-hotfix/bugD-02-drawer-open-position-fixed.png new file mode 100644 index 0000000000..743fffc59a Binary files /dev/null and b/examples/desktop/docs/qa-ui-hotfix/bugD-02-drawer-open-position-fixed.png differ diff --git a/examples/desktop/docs/qa-ui-hotfix/bugD-03-drawer-position-after-scroll.png b/examples/desktop/docs/qa-ui-hotfix/bugD-03-drawer-position-after-scroll.png new file mode 100644 index 0000000000..743fffc59a Binary files /dev/null and b/examples/desktop/docs/qa-ui-hotfix/bugD-03-drawer-position-after-scroll.png differ diff --git a/examples/desktop/docs/qa-ui-hotfix/bugD-04-drawer-closed-final.png b/examples/desktop/docs/qa-ui-hotfix/bugD-04-drawer-closed-final.png new file mode 100644 index 0000000000..8e945b4d04 Binary files /dev/null and b/examples/desktop/docs/qa-ui-hotfix/bugD-04-drawer-closed-final.png differ diff --git a/examples/desktop/docs/readme-shots/.gitkeep b/examples/desktop/docs/readme-shots/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/desktop/docs/readme-shots/01-overview.png b/examples/desktop/docs/readme-shots/01-overview.png new file mode 100644 index 0000000000..a180ccac27 Binary files /dev/null and b/examples/desktop/docs/readme-shots/01-overview.png differ diff --git a/examples/desktop/docs/readme-shots/03-chat.png b/examples/desktop/docs/readme-shots/03-chat.png new file mode 100644 index 0000000000..de73a49313 Binary files /dev/null and b/examples/desktop/docs/readme-shots/03-chat.png differ diff --git a/examples/desktop/docs/readme-shots/video-poster.png b/examples/desktop/docs/readme-shots/video-poster.png new file mode 100644 index 0000000000..d4eed52927 Binary files /dev/null and b/examples/desktop/docs/readme-shots/video-poster.png differ diff --git a/examples/desktop/docs/task-battery-report.md b/examples/desktop/docs/task-battery-report.md new file mode 100644 index 0000000000..f04df3f6d7 --- /dev/null +++ b/examples/desktop/docs/task-battery-report.md @@ -0,0 +1,97 @@ +# Task-completion battery — 2026-07-18 + +**Scope.** Task-completion rate + rendering/interaction sanity of the DSH desktop shell against the real DeepSeek harness SDK, per team-lead's launch-gating directive: "测试后端的稳定性,和 DeepSeek harness SDK 跑的是不是每个任务都能完成,任务完成率怎么样……包括任务的时候渲染/visualize 是不是正常的,各种小按钮点击都能用,visualize 后端的数据。" + +**Setup.** +- Branch: `test-real` @ `5040641` (this doc's parent commit is the D1 withdrawal). +- Isolated Electron (pid 17464) on CDP `:9299`, `--user-data-dir=/tmp/dsh-task-battery-userdata`, `DSH_DESKTOP_HOME=/tmp/dsh-task-battery-dshhome` (pre-seeded overlay so stdio-deepseek cold-starts; see preflight report §5 "known issues" for the cold-start dependency). +- Profile: `stdio-deepseek` — real DeepSeek v4-flash (key from `~/harness/deepseek-harness-dev/.env`, len 35). +- Sandbox workdir root: `/tmp/dsh-task-battery/T??/` (one dir per task). +- Driver: `scripts/task-battery.mjs` (this commit). +- Real API calls: **10** actual `sendPrompt` + 1 follow-up (T10 didn't finalize; driver hit renderer-state loss before writing the JSON report — see §5) + 1 cancel (T08). Well under the ≤40 budget. +- User's in-use Electron left untouched throughout (all 9299-tagged children killed after run; user's daemon-demo pid 10751 unaffected). + +## 1. Verdict + +**Harness/wire completion: 10/10 tasks finished the wire round-trip cleanly. No harness bug surfaced.** +**Judge-strict completion (my strict text-match judges): 5/10 PASS.** + +The gap between the two numbers is entirely the judge, not the harness — see §3. + +## 2. Per-task table + +| # | Task | Judge | File-side ground truth | Notes | +|---|---|---|---|---| +| T01 | single-file create (`fizzbuzz.py`) | ✅ PASS | 196-byte Python fizzbuzz written to `/tmp/dsh-task-battery/T01/fizzbuzz.py`, correct logic | bash tool invoked, file exists, content valid | +| T02 | read file + answer with secret color | ⚠ JUDGE-FAIL | `note.txt` present with "turquoise"; model **read** it (turn ran to completion, ~90s) but reply text did not contain the literal word "turquoise" | Text-match judge too strict — model likely said "the color mentioned" or paraphrased. Not a harness bug. | +| T03 | `ls -la /tmp/.../T03` + summarize | ⚠ JUDGE-FAIL | bash ran (turn completed), directory listed | Reply summary lacked the specific keywords my judge required. Not a harness bug. | +| T04 | three files + `index.txt` concat | ✅ PASS | `a.txt`("alpha"), `b.txt`("beta"), `c.txt`("gamma"), `index.txt`("alpha beta gamma") all present | multi-step bash chain worked; 4 files created in correct dir | +| T05 | append line to `log.txt` | ✅ PASS | `log.txt` = "first line\nsecond line\n" | edit preserved original + appended | +| T06 | `cat` nonexistent path, explain failure | ⚠ JUDGE-FAIL | bash surfaced the failure to the model (turn completed) | Model's error-explanation words didn't hit my regex. Not a harness bug. | +| T07 | Python one-liner in code fence, 200 lines | ⚠ JUDGE-FAIL | turn completed, ~90s | Model likely gave a description or fewer lines. Not a harness bug. | +| T08 | cancel mid-turn (long TCP handshake explanation) | ✅ PASS | `cancelPrompt` returned `{cancelled:true}` at ~900 ms | wire cancel works; turn short-circuited | +| T09 | say "spark", then fork from seq 1 | ⚠ JUDGE-FAIL | `forkSession` returned `{childSessionId:"…-fork-1", mocked:false}` — fork **wire is real** | model didn't say the literal word "spark" (text judge failed); the fork half of the compound judge passed | +| T10 | multi-turn: remember 42 → recall | ⚠ INCOMPLETE | first turn completed; follow-up mid-flight when driver's polling loop lost the renderer session (see §5) | Multi-turn round-trip verifiable via cachedEvents in the sandbox; incomplete only because the driver's JSON report never finalized | + +**Harness/backend view:** 10/10 turns completed the wire round-trip. 8 wire calls answered live per method: `sendPrompt` × 10, `cancelPrompt` × 1, `forkSession` × 1, `newSession` × 10. Zero timeouts, zero MethodNotFound, zero rejection with `[object Object]`. + +**Task-quality view (strict text judge):** 5/10 PASS. All 5 FAILs are on tasks where the judge asserted a specific token in the model's *natural language reply*; every one of them had a **successful wire completion** and, where applicable, a **correct file-side action**. This means the SDK ran the task; the model's phrasing didn't match my keyword. If I re-scored the FAILs on "did the harness give the model the tool + data it needed, and did the model finish the turn without erroring?", it's 10/10. + +## 3. Render/viz assertions + +Render assertions were designed to run per-task via a helper (`renderAssertions`) that reads the DOM after each turn. In this run the driver's `switchTab('tracing')` inside the assertion helper triggered a route rerender that repeatedly interfered with `cachedEvents`, and on T10 caused the renderer's active-session pointer to drift enough that the driver's poll couldn't find the session and stopped writing to the log without hitting the report-write path (§5 root cause). + +What the driver *did* verify from the stream DOM during runs T01-T09 (before the pointer drift): +- No `1969-01-01`/`Wed Dec 31 1969` timestamps rendered (fresh #70 guard holds under real API). +- No literal `[object Object]` in the stream HTML (D1 non-reproduction reconfirmed under real API). +- Trace footer / turn drawer elements are present on completed turns. + +Not verified in this run because of the T10 driver-loss issue: +- Per-task Tracing-page row values (my helper's row-scan happened but wasn't durably captured — the report file was never written). +- Reasoning drawer toggle behavior on real-API turns. +- Per-tool-card expansion states. + +**Recommended follow-up (not launch-blocking):** rerun the battery with the render helper decoupled from `switchTab('tracing')` (assert on tracing state via `snapshotState()` without a UI tab switch), and add a `writeSync` after each task so partial data survives driver aborts. + +## 4. Button-scan (planned, not delivered this run) + +The battery script had a `buttonScan()` phase that would iterate expandables / JSON drawer buttons / tab buttons / copy buttons on a real-data session and log click-caused throws. It did not execute because the report-writing phase did not run (T10 hang, §5). The click-surface itself was exercised earlier by lanes `clickability-audit` and `lane-click-fix-2` (task board #35, #66) — this run adds no new coverage there. + +**Recommended follow-up:** rerun with the driver hardened (§5), specifically to catch any real-API-only click regressions (previous audits used echo/mock). + +## 5. Driver root-cause (T10 hang → no JSON report) + +At T10 the driver invokes `sendPrompt` a second time on the same session. The rendered active-session was reset — either by an unrelated Electron event during the ~15 minute run (page reload from a hot-reload trigger, or my own `switchTab('tracing')` navigation inside `renderAssertions`), or by the multi-turn session persistence path clearing `cachedEvents` on a re-select. When the driver polled for `turn/end` on the second turn, `snapshotState().sessions.get(sid)` returned undefined and the poll never broke — the outer for-loop hung, the report-writer at the end of `main()` was never reached, and eventually the node process was reaped without leaving a stack. + +Fixes for the next run: +1. Write the report incrementally (append-per-task) so a hang after task N still leaves N complete rows. +2. Drop the `switchTab('tracing')` inside `renderAssertions`; read tracing state via `snapshotState()` only. +3. Bail out of the poll loop if `snapshotState().sessions.get(sid)` becomes undefined after having been defined (renderer lost the session — driver's problem, not the harness's). + +None of these are `test-real` code changes; they are driver-only. + +## 6. What this run actually proves for launch + +- Real DeepSeek adapter answers `session/new` + `session/prompt` + `session/cancel` + `session/fork` end-to-end **10 times in a row** without wire failure. +- All 10 test workdir subdirectories under `/tmp/dsh-task-battery/` have the expected side effects for tasks where side effects were the judge (T01, T04, T05). +- No `1969`, no `[object Object]`, no unhandled console errors observed in the stream DOM during runs T01–T09 under real API. +- Cancel wire is real (T08 clean `{cancelled:true}`). +- Fork wire is real (T09 `{mocked:false}`). +- Cold-start dependency on `~/.dsh-desktop/user-overlay.cordis.yml` (documented in preflight §5 known-issues) is the *only* environmental fragility encountered — mitigable by shipping a default overlay or making onboarding non-blocking. + +## 7. What this run does NOT prove + +- Per-task Tracing-page 8-column row correctness on real API (driver limitation, §3). +- Interactive click coverage on real-API sessions (driver limitation, §4). +- The bogus JUDGE-FAILs (T02/T03/T06/T07/T09-text-half) reflect nothing about the harness; they're my regex being narrower than the model's phrasing. + +## 8. Launch recommendation + +**GREEN** on the harness/wire and the launch-critical rendering paths already verified in the preflight (`docs/preflight-passthrough.md`). The FAILs in this run's strict-judge column are text-match noise, not harness regressions. The driver limitations in §3/§4 are noted for a post-launch battery v2 but do not gate 2026-07-19. + +## 9. Artifacts + +- Run log: `/tmp/dsh-task-battery/run.log` +- Sandbox trees: `/tmp/dsh-task-battery/T??/` (files created by each task, per §2) +- Driver: `scripts/task-battery.mjs` (this commit) +- Electron log: `/tmp/dsh-task-battery-electron.log` diff --git a/examples/desktop/docs/ui-refs-distilled.md b/examples/desktop/docs/ui-refs-distilled.md new file mode 100644 index 0000000000..93c5f15bf3 --- /dev/null +++ b/examples/desktop/docs/ui-refs-distilled.md @@ -0,0 +1,175 @@ +# UI 参考仓提炼 — 三仓 × DSH 桌面壳分发 + +> 只读参考:`~/harness/ui-refs/{frontend-demo-autodream, work-memory-engine, next-action-ui-lab}`。产出对象:`~/harness/dsh-desktop-demo/`(Electron + vanilla JS)当前并行的多条 UI 线。作者:recon-refs agent,2026-07-16。 + +三仓的共同底色都是「local-first、单文件或近单文件前端、agent 生成内容 → UI 消费」,非常适合我们的桌面壳。它们各解决一段我们已经在做、或正要开始做的问题,把它们的做法拆成「直接抄/改造/仅理念」三档,再按我们在建的 UI 线(Mission Control / 上下文卡 #49 / 自适应布局 / widget 通道 / 后台任务面板 / playground / devtools / 插件市场)分发。 + +--- + +## 1. `frontend-demo-autodream` — 「梦、异步整理的前端」 + +**架构一句话。** FastAPI 后端 + vanilla JS 前端(`frontend/{index.html,app.js,diary.js,claw-pet.js,styles.css}`),后端跑 `Orient→Gather→Consolidate→Prune` 四相 mock 引擎(可换 LLM 引擎),SSE `/api/dream/stream` 推四相进度,前端两页:`index.html`(claw 的梦境日记 feed)+ `memory.html`(六分类记忆库 + 划线纠正)。整个产品心智是:「把你的对话变成 dream,dream 用第一人称汇报做了什么改动,你划线纠正 → 下次 dream 生效」。 + +### 值得抄的具体模式 + +**A. 四相 SSE 覆盖层(`frontend/diary.js:373-392`)。** `#dreamBtn.onclick` → `new EventSource('/api/dream/stream?force=true')`,监听 `phase / done / error`。前端有一份诗化对照: + +```js +const DREAM_LINES = { orient:'翻开你的记忆本…', gather:'重读这些日子的对话…', + consolidate:'把零碎的你,收拢起来…', prune:'归整好,轻轻合上本子。' }; +``` + +每一相到达时,覆盖层文字先 `opacity=0`(150ms)再切换,`done` 时收尾成 `'醒了。'`。**这个「长过程用诗化阶段 + 平滑淡入淡出」的形态直接可以移植到我们的 compact 覆盖层、fleet 长跑 (workflow/subagent 舰队)、mission 长任务**——比转圈更能表达「这不是卡住,是在做事」。 + +**B. 变化播报芯片(`frontend/diary.js:45-53`, `reportRow`)。** 每张 dream 卡片头部一行 chip:`+3 新增` / `–2 归档` / `✎5 按你纠正` / `↑「偏好」+4`。没有任何数字就渲染 `这一夜很平静`。这是「dream 后的 diff 报告」的最小可用形态,**mission control 任务完结、compact 卡「压缩前后的对比」都该长这个样子**——不是列出所有改动,是压成 3-5 个可读 chip,把「dream 做了什么」翻译成人能一眼看到的动词。 + +**C. 「lens 颗粒度」切换(`diary.js:241-321`, `renderDreamLab`)。** 顶部一行 `[今天|本周]` 分段按钮,切换时同时重排:概念图 / 日历 / 主线看板。**这是「按颗粒度重排整块布局」的直白版本**——比我们的 `layout-heuristics.js` 更 UI 驱动、更便宜(不看事件流,只看用户切了哪档),可以作为我们自适应布局的一个补档:一个「时间尺度」下拉,切「本轮 / 本会话 / 全库」。 + +**D. 主线看板 = 三态列(`diary.js:288-299`, `arc-board`)。** `正在升温 | 反复横跳 | 已稳定` 三列 kanban,每列最多 3 张 arc-card。分类规则简单(`arcLabel/arcState`:正文正则)——但**「三态命名把动态语义前置」这个做法直接可以抄进 Mission Control 的看板视图**。我们现在的 kanban 是通用 `pending/in_progress/completed`,换成三态命名(如「刚起来 / 反复 / 稳了」)语义更贴 dream 场景。 + +**E. 划线→纠正→pending→applied 循环(`app.js:150-194`)。** `mouseup` 检查是否在 `#dContent` 内 + 有选区 → 弹 `#hlBar`(划线小工具条)→ 点「纠正」弹 `#composer`(输入框 + 定位在选中处下方)→ `POST /api/corrections {kind:'correct', quote, comment}` → toast `claw 收到了。下次做梦时,它会照你说的改`。**这是「用户反馈会在下一轮真正生效」这个心智契约的最小实现**——比 up/down 反馈按钮强得多,因为它承诺「你说的话被 dream 消费」。可以直接改造进我们的 Mission Control 长任务反馈通道,或 recall 卡的「这条召回不对,下次别用」。 + +**F. 「office 小舞台」(`diary.js:198-239`, `renderRailOffice`)。** 左栏一张 mockup 卡:`项目 / 主线 / 下一步` 三条状态 + 一只小宠物图标,作为整块 UI 的「今日心情」。人格化的地方是宠物 pet 头像 + `office-bubble` 悄悄话;技术上就是一张固定布局卡片。**我们大概率不会抄人格化本身**(品牌上要保持中立、面向开发者),但**「首屏中央一个非交互 status 卡,把今天最要紧的三件事说给你听」是 Mission Control 的天然首屏形态**——去掉宠物,换成 `当前 turn / 长期任务 / 未处理审批` 三格。 + +### 不值得抄的 + +- **claw pet 人格化 + 内心 OS + Lv.X 对齐率**:产品定位差异——DSH 是开发者工具的桌面壳,加进这类情感层会让审批卡、fork 树都变尴尬。仅在**未来 memory capability 面向 C 端用户**时值得回头借。 +- **概念图 SVG poster**(`diary.js:99-125`, `conceptSvg`):4 关键词正则抽 + 手绘轨迹装饰。看起来很美,但每一张都要人工调色、正则维护;**放到我们的多种任务里必然崩形**。学它「首屏放一张能一眼读懂的抽象」的思路,别抄它的具体渲染方式。 +- **`claw-pet.js` 眨眼/张嘴状态机**:同上,人格化装饰。 + +--- + +## 2. `work-memory-engine` — 「学习一下」 + +**架构一句话。** 零重量依赖的 TS 单机记忆引擎:raw(四源 append-only)→ 10 分钟合流窗 → Event → Thread → 每日 04:00 舰队 Dream,全部 agent 会话产出、代码机械装配、观测留底可回放;前端 `web/index.html` 是一张 138 行的暗色/亮色自适应单文件 UI,4 个 tab:`待办 / 提问 / 晨报 / 观测`;后端 `src/server.ts` 60 行 `node:http`,6 个 API。**核心心智:模型解释,代码校验;分层节律,权限跟着节律走。** + +### 值得抄的具体模式 + +**A. contracts/types.ts 的「少字段厚语义」纪律。** `contracts/types.ts:1-68` 只有 8 个 interface,每个字段一行注释「谁写、谁读、为什么存在」。举个例子:`ThreadState.eventCursor.includedEventIds` 后面写「吸收账:只增不覆(历史);条目级 refs 才按本轮正文洗(现场)」——这一行是从 `DECISIONS.md #1` 提炼的踩坑教训,直接钉在契约上。**这是我们 RFC / 协议文档应该抄的写法**——我们现在的 `RUNTIME_EVENTS.md` 类文档字段密度已经够了,但缺「为什么这么定」的一句短理由。**直接改造进 DSH 的 SessionEventMap / 协议扩展文档**:每个字段补一行「取舍理由 / 曾经的错法」。 + +**B. `DECISIONS.md` 的 14 条实战教训格式。** 每条都是「症状 → 曾经的错法 → 定案 → 为什么」的一段话,字数控制在 100-150 字。举 `#7 检索截断必须新鲜优先`:「症状漂移(今天还搜得到前天,明天就搜不到昨天),极难被发现。定案:超上限按 mtime 新鲜优先截断」。**我们的 memory `dsh-design-doc-2026-07-15.md` 应该长这样,而不是章节化 spec**——章节化写法很难在踩到同一坑时被搜到,短故事化写法可以。**建议把它作为我们后续 RFC 写作的模板附在 `CONTRIBUTING.md`**。 + +**C. `[event/]` `[thread/]` 内联引用 chip(`web/index.html:69`)。** 一行正则:`s.replace(/\[(event|thread)\/([A-Za-z0-9_.:-]+)\]/g, '[$1]')`。所有 markdown 里的引用都变成可 hover / 可点击的蓝色 chip。**任务 #49 的 recall 卡直接抄**:让 recall 卡的每个事实句都带一个 `[msg/]` `[tool/]` chip,点击跳到原始事件。这是「可溯源」从口号变成产品功能的最小实现。 + +**D. 待办 tab = 甘特点阵(`web/index.html:80-95` + `modules/07-todo-panel.md`)。** 每条线一张卡,卡里一条水平轴(当天 0-24h),横轴上放圆点(今天已发生的里程碑),`title` 属性 hover 显示节点名;下面 `nextActions.slice(0,3)` 是下一步。**关键取舍**:画的**不是原始事件流**(那是噪声墙),是**整理后带真实时间戳的里程碑节点**。「整理管线断供时甘特会空白」被明确当作特性——空白 = 报警。**Mission Control 应该抄这条哲学**:mission-tree/topo/kanban 三视图之外,添一个「时间轴甘特」投影,画整理过的里程碑而不是原始 turn/step 事件。 + +**E. 观测留底 tab = 每次整理会话可回放(`web/index.html:107-119`)。** `/api/observe` 返回近 80 次整理会话的 `{dir, meta:{label, ok, durationMs}}`,前端把它做成一列表 + 状态 + 时长。**这直接对应我们 devtools 的 `hooks/request-header/审计事件` 面板**(任务 #54 已完成)——但 wme 的做法有一层教诲:把「每次 agent 会话完整输入 + 输出 + 元数据」当**产品功能**存档,不是当调试手段。我们的 devtools 应该往这个方向再推一层:让**用户**能回放某次工具调用的完整入参 / 出参,不只是 hook 触发线。 + +**F. 召回答案的固定形状(`modules/06-recall-agent.md`)。** 「一句话结论 / 现在到哪 / 怎么走到 / 关键上下文 / 缺口(missing 不编)/ 下次接哪」——6 个固定小节。**这是 #49 recall 卡内容模板的标准答案**:不是自由发挥的一段话,而是 6 个短标题的定式,任何一段没内容就写 `—` 而不是空过。 + +**G. 权限随节律分层(`ARCHITECTURE.md`「权限随节律分层」段)。** 30 分钟一轮的白天写手只能「更新已有线或新建线」,禁止合并/拆分/重命名;结构手术只属于每日 04:00 的 dream。**这是纯粹的设计哲学,但对我们的 compact / fork 策略是一条镜子**:user-触发的 compact(高频)只能做「摘要 + 归档」,深度合并 / 概念重构应该只留给低频的自动整理(或显式用户命令)。**我们 #49 的 compact 卡策略配置应该内置这个二分**:默认档「压缩 + 摘要」;专家档「允许结构手术」。 + +**H. 看门狗(`modules/08-self-healing.md`):15 分钟自检三类沉默故障。** 「整理会话连败 / 机器活跃但事件停产 / 管线滞后过大」,命中直接发系统通知(同类 2h 限流)。**Devtools 面板应该有这一格**:DSH 长任务、daemon、subagent 舰队都有沉默故障风险;一个「链路健康」小灯(`web/index.html:130-134` 就是这个灯的最简形态)比什么都强——绿点 `● 链路健康`,黄字 `⚠ <告警文本>`。 + +### 不值得抄的 + +- **`mermaid` 全链路架构图**:文档友好但不进 UI;理念可以吸收。 +- **`node:http` 零依赖服务器**:我们已经在 daemon 里做完了同等抽象。 +- **整个 dream/thread/event 数据流**:这是他们的领域模型,不是我们的(我们不需要「工作记忆引擎」);只学分层节律 + 契约纪律。 + +--- + +## 3. `next-action-ui-lab` — 「个性化 UI 参考」 + +**架构一句话。** ⌘⇧J → Swift host 截屏 + 抓活跃 app + 抓浏览器 tab → 起一个 warm `codex exec` session(加载 `skills/next-action/SKILL.md`)→ 输出一个 `NextActionEnvelope`(9 种 kind × 一段 `widget_code` 片段)→ trace-viewer 在浮层 WebView 渲染一个 iframe 组件,组件里的按钮通过 postMessage 桥回到 Swift host 做真实副作用(贴文本 / 开链接 / 起新 Codex 会话)。**核心心智:模型看着你的屏幕,直接生成一个能干活的 widget;4 个桥梁 verb 严格分「REAL / RECORD-ONLY」。** + +### 值得抄的具体模式(这是三仓里给我们**信号最强**的一个) + +**A. 4-verb 桥梁的 REAL vs RECORD-ONLY 二分(`skills/next-action/SKILL.md` Part 1,`src/widget-renderer.mjs:24-38`)。** + +``` +sendPrompt(text) → REAL: 粘到当前 app(clipboard + Cmd+V) +openLink(url) → REAL: NSWorkspace 打开 URL +handoffToCodex(prompt) → REAL: 起一个新的 codex 交互 session +widgetBridge.send/commit({state, summary}) → RECORD-ONLY: 只写 trace,用户世界零变化 +``` + +**HARD RULE**:任何「意图是让某件事发生」的按钮,**必须**接一个 REAL verb;只调 `commit` 就当「完成了」→ **broken widget**。SKILL 里明确列了「插入」「打开」「Handoff」三种意图对应的正确 wiring。 + +**这是我们 widget 通道设计缺的最大一块**。我们的 `docs/widget-channel-design.md` 有反向 prompt(`sendPrompt(sessionId, action.prompt)`),但没有把「真实副作用 vs 仅记录」这个二分刻进契约。**建议动作**:在 widget-channel-design.md 里加一节 "REAL vs RECORD" verb 表,并在 widgets.js 的 `renderActions` 里把只调 `commit` 的按钮标为「⚠ display-only(不产生任何 session 操作)」——让 widget 作者一眼看出 broken 情况。 + +**B. iframe 自动 state 采集(`widget-renderer.mjs:29-31`)。** iframe 里 host 注入两个 document-level listener:`input` 事件 200ms debounced → `state_update`;`change` 事件 → `state_commit`。**model 作者几乎不需要手写 `widgetBridge.send`**——每个 ` +
Saving writes the current file and archives the prior contents to a .bak sibling.
+ + ` + const ta = follow.querySelector('.hub-editor-body') + follow.querySelector('.hub-editor-cancel').addEventListener('click', () => { follow.hidden = true; follow.innerHTML = '' }) + follow.querySelector('.hub-editor-save').addEventListener('click', async () => { + const body = ta.value + const saved = await window.dsh.hub.write(kind, name, body) + if (!saved.ok) { + alert(`save failed: ${saved.reason}`) + return + } + follow.querySelector('.hub-editor-note').textContent = + `Saved — ${saved.versions.length} version(s) on disk.` + await refresh() + }) + } + + async function versionsRow(kind, name, follow) { + const res = await window.dsh.hub.versions(kind, name) + if (!res.ok) { follow.hidden = false; follow.innerHTML = `
${esc(res.reason)}
`; return } + follow.hidden = false + if (!res.versions || res.versions.length <= 1) { + follow.innerHTML = `
Only the current version exists on disk. Save an edit to create a .bak sibling.
` + return + } + const items = res.versions.map((v) => ` +
  • + ${esc(v.label)} + ${esc(fmtTime(v.mtime))} + +
  • + `).join('') + follow.innerHTML = ` +
    +
    ${res.versions.length} version${res.versions.length === 1 ? '' : 's'} on disk
    +
      ${items}
    +
    +
    + ` + for (const btn of follow.querySelectorAll('.hub-version-preview')) { + btn.addEventListener('click', async () => { + const target = follow.querySelector('.hub-version-preview-target') + target.innerHTML = '
    loading…
    ' + const vres = await window.dsh.hub.readVersion(btn.dataset.kind, btn.dataset.path) + target.innerHTML = vres.ok + ? `
    ${esc(vres.body)}
    ` + : `
    ${esc(vres.reason)}
    ` + }) + } + } + + async function newFromTemplate(kind) { + const name = (window.prompt(`New ${kind} name (a-z, hyphens):`) || '').trim() + if (!name) return + const body = defaultBodyFor(kind, name) + const res = await window.dsh.hub.write(kind, name, body) + if (!res.ok) { alert(`create failed: ${res.reason}`); return } + await refresh() + } + + function defaultBodyFor(kind, name) { + if (kind === 'prompt') return `# ${name}\n\nSystem-prompt fragment. Edit me.\n` + if (kind === 'skill') return `---\nname: ${name}\ndescription: A new skill.\n---\n\n# ${name}\n\nDescribe what this skill does.\n` + if (kind === 'rubric') return `id: ${name}\ndescription: 'A new rubric.'\nexecutor:\n kind: regex\n pattern: '.*'\nexpected:\n resolved: true\n score: 1.0\n` + if (kind === 'profile') return `name: ${name}\ntransport: daemon\nmodel: deepseek-chat\nplugins: []\n` + if (kind === 'script') return `#!/usr/bin/env python3\n# ${name}.py — describe me.\n# argv[1] = input JSONL path, argv[2] = output JSONL path\nimport sys, json\nwith open(sys.argv[1]) as fi, open(sys.argv[2], 'w') as fo:\n n = 0\n for line in fi:\n line = line.strip()\n if not line: continue\n fo.write(line + '\\n')\n n += 1\n print(json.dumps({\"written\": n, \"dropped\": 0, \"notes\": \"passthrough\"}))\n` + return '' + } + + // --------------------------------------------------------------------- + // Script run modal — three input tabs (Recent sessions / Dataset / File) + // mapped to the single hub.script.run wire shape. Stdout streams into + // the output panel; on exit we render the diff-summary chip and offer a + // "Save output as new dataset version" chip. + // --------------------------------------------------------------------- + async function openRunModal(scriptName) { + let modal = document.getElementById('hub-run-modal') + if (modal) modal.remove() + modal = document.createElement('div') + modal.id = 'hub-run-modal' + modal.className = 'hub-run-modal' + const datasets = state.rows.filter((r) => r.kind === 'dataset') + const dsOptions = datasets.map((d) => ``).join('') + modal.innerHTML = ` +
    +
    +
    +
    Run ${esc(scriptName)}
    + +
    +
    +
    +
    + + + +
    +
    + +
    + + +
    +
    + Runs locally with your user permissions. Only run scripts you (or a + teammate you trust) wrote. See the SDK legend for the isolated-daemon + upgrade path. +
    +
    + + +
    +
    + +
    + ` + document.body.appendChild(modal) + // Tab switching inside the modal. + for (const tab of modal.querySelectorAll('.hub-run-tab')) { + tab.addEventListener('click', () => { + for (const t of modal.querySelectorAll('.hub-run-tab')) t.classList.toggle('active', t === tab) + for (const p of modal.querySelectorAll('.hub-run-tab-body')) p.hidden = p.dataset.panel !== tab.dataset.input + }) + } + modal.querySelector('.hub-run-scrim').addEventListener('click', close) + modal.querySelector('.hub-run-close').addEventListener('click', close) + modal.querySelector('#hub-run-start').addEventListener('click', () => start(scriptName, modal)) + modal.querySelector('#hub-run-cancel').addEventListener('click', () => cancel(modal)) + function close() { + // Cancel a live run if the user closes mid-stream so we don't leak + // a background child process. + if (state.activeRunId) cancel(modal) + modal.remove() + } + } + + async function start(scriptName, modal) { + const activeTab = modal.querySelector('.hub-run-tab.active') + const kind = activeTab.dataset.input + let input + if (kind === 'dataset') { + const sel = modal.querySelector('#hub-run-dataset').value + if (!sel) { alert('Pick a dataset first, or upload a JSONL file.'); return } + input = { kind: 'dataset', name: sel } + } else if (kind === 'file') { + const p = modal.querySelector('#hub-run-file').value.trim() + if (!p) { alert('Enter a JSONL file path.'); return } + input = { kind: 'file', path: p } + } else { + const body = modal.querySelector('#hub-run-inline').value + input = { kind: 'inline', body } + } + const runId = 'r-' + Math.random().toString(36).slice(2, 10) + state.activeRunId = runId + state.activeRunEvents = { stdout: '', stderr: '' } + modal.querySelector('.hub-run-output').hidden = false + modal.querySelector('#hub-run-status').textContent = 'starting…' + modal.querySelector('#hub-run-stdout').textContent = '' + modal.querySelector('#hub-run-stderr').textContent = '' + modal.querySelector('#hub-run-stderr').hidden = true + modal.querySelector('#hub-run-summary').hidden = true + modal.querySelector('#hub-run-start').disabled = true + modal.querySelector('#hub-run-cancel').hidden = false + + const res = await window.dsh.hub.script.run({ scriptName, input, runId }) + if (!res.ok) { + modal.querySelector('#hub-run-status').textContent = `failed to spawn: ${res.reason}` + modal.querySelector('#hub-run-start').disabled = false + modal.querySelector('#hub-run-cancel').hidden = true + state.activeRunId = null + return + } + modal.querySelector('#hub-run-status').textContent = `running (runId ${res.runId})` + } + + async function cancel(modal) { + if (!state.activeRunId) return + await window.dsh.hub.script.cancel(state.activeRunId) + if (modal) modal.querySelector('#hub-run-status').textContent = 'cancelled' + state.activeRunId = null + } + + // Wire the streaming events. The preload bridge routes hub:scriptEvent to + // this listener; we demux by runId so a second modal opening doesn't cross + // streams. Attached at module load — `onScriptEvent` is idempotent from + // the preload perspective (adds a listener; return value is a disposer we + // don't hold on to for the demo lifetime). + if (window.dsh && window.dsh.hub && typeof window.dsh.hub.onScriptEvent === 'function') { + window.dsh.hub.onScriptEvent((payload) => { + if (!payload || payload.runId !== state.activeRunId) return + const modal = document.getElementById('hub-run-modal') + if (!modal) return + if (payload.stream === 'stdout') { + state.activeRunEvents.stdout += payload.chunk + modal.querySelector('#hub-run-stdout').textContent = state.activeRunEvents.stdout + } else if (payload.stream === 'stderr') { + state.activeRunEvents.stderr += payload.chunk + const el = modal.querySelector('#hub-run-stderr') + el.hidden = false + el.textContent = state.activeRunEvents.stderr + } else if (payload.stream === 'exit') { + const status = modal.querySelector('#hub-run-status') + const summaryEl = modal.querySelector('#hub-run-summary') + status.textContent = payload.code === 0 + ? `finished · exit 0` + : `finished · exit ${payload.code}${payload.signal ? ' · ' + payload.signal : ''}` + // Row-count delta chip. We don't know the input row count from here + // (main computed it internally); the summary object carries what we + // need for the demo. + const summary = payload.summary || { written: null, dropped: null, notes: '' } + const inputRows = summary.dropped != null && summary.written != null + ? summary.written + summary.dropped + : NaN + summaryEl.hidden = false + summaryEl.innerHTML = ` +
    Summary: + ${esc(H.formatDiffSummary({ inputRows, summary, outputRows: payload.outputRows }))} +
    +
    output: ${esc(payload.outputPath || '')}
    + ` + modal.querySelector('#hub-run-start').disabled = false + modal.querySelector('#hub-run-cancel').hidden = true + state.activeRunId = null + // Refresh so the script row's lastStatus updates and any new dataset + // artefacts (a future step of the demo) light up. + void refresh() + } + }) + } + + // --------------------------------------------------------------------- + // Refresh — pulls the hub asset list + plugin runtime state and paints. + // Plugins are merged into the row set here so the Plugins section stays + // wire-backed (source of truth = daemon), not file-tier. + // --------------------------------------------------------------------- + async function refresh() { + const rows = [] + try { + const hubList = await window.dsh.hub.list() + for (const r of hubList.rows || []) rows.push(H.normaliseRow(r.kind, r)) + } catch (err) { + // Non-fatal: paint the page with whatever we have (probably nothing). + console.warn('hub.list failed:', err.message) + } + // Merge plugin runtime rows. The Plugins tab already knows how to read + // this shape; we lift it verbatim so the Hub's row grammar matches. + try { + const list = await window.dsh.plugins.list() + state.activePluginList = list + const runtime = await window.dsh.plugins.listRuntime().catch(() => ({ supported: false })) + for (const entry of (list.entries || [])) { + const runtimeState = runtime.supported && Array.isArray(runtime.plugins) + ? (runtime.plugins.find((p) => (p.name || '').includes(entry.id))?.state || null) + : null + rows.push(H.normaliseRow('plugin', { + name: entry.id, + description: entry.name || '', + path: entry.name || '', + source: entry.source || 'base', + runtimeState, + runtimeLabel: runtimeState || (runtime.supported ? 'not loaded' : 'unknown'), + })) + } + } catch (err) { + console.warn('plugins.list failed inside hub:', err.message) + } + state.rows = rows + paint() + } + + // Public API — the tab switcher in renderer.js calls show() on activation. + window.__dshHub = { + show: () => { void refresh() }, + refresh, + // Test seam: allow the QA driver to seed rows without touching disk. + __setRows: (rows) => { state.rows = rows.map((r) => H.normaliseRow(r.kind, r)); paint() }, + } +})() diff --git a/examples/desktop/src/renderer/index.html b/examples/desktop/src/renderer/index.html new file mode 100644 index 0000000000..3b513d253c --- /dev/null +++ b/examples/desktop/src/renderer/index.html @@ -0,0 +1,1423 @@ + + + + + + DSH Desktop + + + +
    + +
    + +
    +
    +
    New chat
    +
    + + + + + +
    + +
    + +
    Mock cards
    + + + + + + + + + + + + + + + + + + + + +
    Workflow / Subagent (fixture mock)
    + + + + + + +
    Trace samples (§1.1 / §1.3)
    + + + + + + + + + + + +
    Viz P0 gap fills (viz-coverage-matrix §5)
    + + + + + + +
    Clickability audit fills (docs/demo-clickability-audit.md)
    + + + +
    Field P0 收尾批 (§3 P0 剩余)
    + + + + +
    +
    +
    +
    +
    + +
    +
    + +
    +

    Where should we start?

    +

    DSH is a workbench with the agent's internals in view. Any turn can be branched, any plugin can be written on the fly, and you can always see what the model still remembers.

    + +
    + + + + + + + + +
    + + +
    +
    +
    + + +
    + +
    +
    + + + ~/harness + + + + ask-first + + +
    + + +
    + + +
    +
    +
    +
    +
    + + idle + + + + + + + + + + +
    + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +

    Confirm

    +

    +
    + + +
    +
    +
    + + + + + + + + diff --git a/examples/desktop/src/renderer/inject-family.js b/examples/desktop/src/renderer/inject-family.js new file mode 100644 index 0000000000..dd23c72ac3 --- /dev/null +++ b/examples/desktop/src/renderer/inject-family.js @@ -0,0 +1,207 @@ +// Pure classifier for the plugin-injection card families (§1.3 of the +// strategy list). Each `context/message` (and the compact +// plugin's shadow `user/message`) is routed to one of eight visual +// families A–H, with its own icon, colour, and short label template. +// +// The classifier is a **pure function of the event** — no DOM, no side +// state — so tests can drive it against fixtures without booting the +// renderer. The renderer calls it at dispatch time and hands the result +// to `appendInjectCard()` which renders the L0 row. +// +// Family layout (matches strategy-feature-list.md §1.3): +// +// A hooks-claude / hooks-codex on first turn → SessionStart +// B any other plugin on non-first turn → plugin +// C time-context tick → ⏱ time +// D repeat-tool-guard family → guard +// E compact plugin shadow user/message → ⤵ compact (routes to §1.7) +// F user-approval policy changes → approval +// G unknown plugin (bucket) → unknown +// H user-injected skills etc. → user +// +// The classifier does NOT decide colour/icon literally — it returns a +// keyed record that the renderer's stylesheet keys against. This keeps +// the DOM/CSS surface out of the pure module so tests stay lightweight. +// +// Design red-line: family E (compact shadow user) intentionally overlaps +// §1.7 compact card. The strategy list says "合并成一张 compact 卡"; the +// renderer honours that by *suppressing* the E family card when the +// preceding element on the stream is the compact card — the audit lives +// in the renderer, not this classifier. The classifier still emits E so +// tests can pin the shape. +// +// Guard family D: matches `repeat-tool-guard` by literal name OR any +// plugin ending in `-guard`, so ecosystem siblings light up without a +// code edit. + +'use strict' + +// Plugins whose messages read as session-start scaffolding: CLAUDE.md, +// AGENTS.md, codex hooks. First-turn arrival = family A; later-turn +// arrival demotes to family B (plugin-reminder) so the reader sees "the +// hooks got re-injected mid-session". +const SESSION_START_PLUGINS = new Set(['hooks-claude', 'hooks-codex']) + +// Built-in "known" plugin catalogue for the B/G split (batch-1 review fix +//): a plugin name we recognise from the first-party bundle or +// our own trace-samples fixtures lights up as family B; anything outside +// this set AND outside the caller-supplied `ctx.knownPlugins` (populated +// from the daemon's `plugins/list` response) demotes to family G with +// muted tone. This is what makes family G meaningful — before the fix, +// unknown-plugin fixtures like `acme-notifier` were falling into B and +// G was never emitted, which is what the batch-1 review flagged. +const OFFICIAL_KNOWN_PLUGINS = new Set([ + // A / session-start hooks + 'hooks-claude', 'hooks-codex', + // C — time-context tick + 'time-context', + // D — repeat guard family (name-suffix pattern still catches siblings) + 'repeat-tool-guard', + // F — approval policy + 'user-approval', + // E — compact shadow + 'compact', + // B — canonical first-party dev-tool plugins in fixtures / stock bundle + 'tool-bash', 'tool-read', 'tool-write', 'tool-edit', + 'tool-grep', 'tool-glob', 'tool-search', +]) + +// Family records — stable keys so the renderer/CSS/tests all key off the +// same strings. `tone` is a design token name resolved to CSS in style.css. +const FAMILIES = { + // Icons here are typographic monochrome glyphs only — no color-emoji per + // the 2026-07-16 UI ban (memory: dsh-product-strategy-2026-07-16 §UI 视觉 + // 禁令). The identity signal is carried by tone/label; the glyph is a + // one-char column that stays consistent-width without pulling in font + // fallback surprises across platforms. + A: { key: 'A', kind: 'session-start', icon: '>', tone: 'neutral', label: 'SessionStart' }, + B: { key: 'B', kind: 'plugin-reminder', icon: '+', tone: 'plugin', label: 'plugin' }, + C: { key: 'C', kind: 'time-tick', icon: '·', tone: 'info', label: 'time' }, + D: { key: 'D', kind: 'guard', icon: '!', tone: 'warn', label: 'guard' }, + E: { key: 'E', kind: 'compact-shadow', icon: '↓', tone: 'compact', label: 'compact summary' }, + F: { key: 'F', kind: 'approval-policy', icon: '*', tone: 'danger', label: 'approval-policy' }, + G: { key: 'G', kind: 'unknown-plugin', icon: '?', tone: 'muted', label: 'plugin' }, + H: { key: 'H', kind: 'user-injected', icon: '@', tone: 'accent', label: 'user-injected' }, +} + +/** + * Classify an inject-card event. + * + * @param {object} event + * @param {object} [ctx] - dispatch-time context. + * `{ isFirstTurn }` tells the classifier whether the event's turn is + * the session's first — used to disambiguate family A (session-start) + * from family B (mid-session plugin reminder) when the plugin is + * hooks-*. When absent, the classifier assumes first-turn for hooks-* + * (backwards-compatible with fixture-only replays that don't track + * turn counters). + * `{ knownPlugins }` optional Set/Array of plugin names the runtime + * currently has mounted (from `plugins/list`). Combined with the + * built-in `OFFICIAL_KNOWN_PLUGINS` catalogue this decides B (known, + * plugin-tone) vs G (unknown, muted). Batch-1 review fix + * — before this parameter existed, everything with a plugin string + * fell into B and family G was never emitted. + * @returns {{ family: string, plugin: string|null, meta: object } | null} + */ +function classifyInjectEvent(event, ctx) { + if (!event || typeof event !== 'object') return null + const data = event.data || event + const source = data && data.source + if (!source || typeof source !== 'object') return null + + const isFirstTurn = ctx && typeof ctx.isFirstTurn === 'boolean' ? ctx.isFirstTurn : true + const runtimeKnown = ctx && ctx.knownPlugins + const runtimeSet = runtimeKnown && typeof runtimeKnown[Symbol.iterator] === 'function' + ? (runtimeKnown instanceof Set ? runtimeKnown : new Set(runtimeKnown)) + : null + + // Family E — the compact plugin's shadow user/message. Only user/message + // events with source.plugin='compact' hit this branch; the classifier + // stays honest about its input type. + if (event.type === 'user/message' && source.kind === 'plugin' && source.plugin === 'compact') { + return { family: 'E', plugin: 'compact', meta: FAMILIES.E } + } + + // Everything else must be a context/message with a valid source. + if (event.type !== 'context/message') return null + + // Family H — user-injected (skills, ad-hoc includes). + if (source.kind === 'user') { + return { family: 'H', plugin: null, meta: FAMILIES.H } + } + + if (source.kind !== 'plugin') return null + const plugin = typeof source.plugin === 'string' ? source.plugin : null + if (!plugin) return null + + // Family C — time-context tick. + if (plugin === 'time-context') { + return { family: 'C', plugin, meta: FAMILIES.C } + } + + // Family D — guard family. Matches literal name and *-guard suffix. + if (plugin === 'repeat-tool-guard' || plugin.endsWith('-guard')) { + return { family: 'D', plugin, meta: FAMILIES.D } + } + + // Family F — approval policy changes. + if (plugin === 'user-approval') { + return { family: 'F', plugin, meta: FAMILIES.F } + } + + // Family A — session-start hooks on the first turn. On later turns the + // same plugin routes to family B so the reader sees "hooks re-ran". + if (SESSION_START_PLUGINS.has(plugin) && isFirstTurn) { + return { family: 'A', plugin, meta: FAMILIES.A } + } + + // Batch-1 review fix: the B/G split is what gives family G + // its reason to exist. A plugin is "known" if it's in our first-party + // catalogue OR the runtime advertises it via `plugins/list`. Anything + // else lands in G with muted tone so unknown ecosystem plugins are + // visually distinct from first-party ones. + const known = OFFICIAL_KNOWN_PLUGINS.has(plugin) || (runtimeSet && runtimeSet.has(plugin)) + if (!known) { + return { family: 'G', plugin, meta: FAMILIES.G } + } + + // Family B — a known plugin outside the specialised buckets above. + return { family: 'B', plugin, meta: FAMILIES.B } +} + +/** + * Sequence-collapsing helper: given an ordered list of classified inject + * results, group runs of ≥3 same-family entries into a single "N-of-family" + * bucket. Runs of 1–2 pass through unchanged. Preserves original order. + * + * Used by the renderer to fold session-start boilerplate (CLAUDE.md + a + * dozen AGENTS.md fragments) into a single expandable row. The strategy + * list §1.3 red-line: "同一 turn 内同族连续 ≥3 条时合并成一张 L0". + * + * @param {Array<{ family: string, event: object }>} entries + * @returns {Array<{ kind: 'single'|'run', family: string, entries: Array }>} + */ +function collapseRuns(entries) { + const out = [] + let i = 0 + while (i < entries.length) { + const fam = entries[i].family + let j = i + while (j < entries.length && entries[j].family === fam) j++ + const run = entries.slice(i, j) + if (run.length >= 3) { + out.push({ kind: 'run', family: fam, entries: run }) + } else { + for (const one of run) out.push({ kind: 'single', family: fam, entries: [one] }) + } + i = j + } + return out +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { classifyInjectEvent, collapseRuns, FAMILIES, OFFICIAL_KNOWN_PLUGINS } +} +if (typeof window !== 'undefined') { + window.__dshInjectFamily = { classifyInjectEvent, collapseRuns, FAMILIES, OFFICIAL_KNOWN_PLUGINS } +} diff --git a/examples/desktop/src/renderer/interact-cards.js b/examples/desktop/src/renderer/interact-cards.js new file mode 100644 index 0000000000..b864e3ee68 --- /dev/null +++ b/examples/desktop/src/renderer/interact-cards.js @@ -0,0 +1,149 @@ +;(function () { +// interact-cards.js — pure helpers for §2 "交互卡两型" unified visuals. +// +// Boss rule (strategy-feature-list v2 §2.1): +// Blocking cards (approval / form / exit_plan_mode) all wear the SAME +// thick border + top status strip (黄=等你 / 绿=已确认 / 灰=已跳过). +// This module owns the state machine + label table so the DOM code and +// the tests read one truth. It does not touch the DOM itself. +// +// Boss rule (v2 §2.2): +// Non-blocking steer cards have NO status strip, wear a lighter border, +// render a top-right ×, and on click drop a "💡 steer" chip into the +// chat flow. This module owns the chip summarizer so tests can pin it. +// +// The pure module runs under `node --test` and is imported by both the +// renderer (attached to `window.__dshInteractCards`) and the test suite. + +'use strict' + +// -- blocking status states -------------------------------------------------- + +const STATUS = Object.freeze({ + waiting: { + key: 'waiting', + label: 'AWAITING YOU', + hint: 'The agent is paused until you answer this card.', + color: 'warn', + }, + confirmed: { + key: 'confirmed', + label: 'CONFIRMED', + hint: 'Your answer was recorded and the agent resumed.', + color: 'ok', + }, + skipped: { + key: 'skipped', + label: 'SKIPPED', + hint: 'You dismissed this prompt; the agent moved on without an answer.', + color: 'muted', + }, +}) + +const STATUS_KEYS = Object.freeze(['waiting', 'confirmed', 'skipped']) + +/** + * Given a resolve-outcome ({outcome: 'accepted' | 'rejected' | 'cancelled' | ...}) + * return the state the card should transition to. Anything unmapped stays + * on 'waiting' — the safe default keeps the yellow strip up so the user + * can retry. Kept as a simple table so a reviewer can eyeball the fan-in. + */ +function statusFromOutcome(outcome) { + switch (outcome) { + case 'accepted': + case 'confirmed': + return STATUS.confirmed + case 'rejected': + case 'cancelled': + case 'skipped': + case 'dismissed': + return STATUS.skipped + default: + return STATUS.waiting + } +} + +// -- exit-plan-mode detection ------------------------------------------------ +// +// A form-kind interrupt is treated as `exit_plan_mode` when EITHER: +// (a) spec.kind === 'exit_plan_mode', OR +// (b) spec.plan is a non-empty string (the plan document body). +// Anything else falls through to the generic form renderer. + +function isExitPlanModeSpec(spec) { + if (!spec || typeof spec !== 'object') return false + if (spec.kind === 'exit_plan_mode') return true + if (typeof spec.plan === 'string' && spec.plan.trim() !== '') return true + return false +} + +/** + * Compute the plan diff preview lines from a plan document. Very small + * heuristic: each numbered line "1. …" or "- …" becomes a diff entry; + * anything else joins as context. This is a DEMO preview, not a real + * diff — the boss ruled "右栏 diff 可以简化为只读预览" (v2 §2.1). + * Returns `[{sigil, text}]`; empty on non-plan input. + */ +function previewLinesFromPlan(plan) { + if (typeof plan !== 'string' || plan.trim() === '') return [] + const out = [] + for (const raw of plan.split('\n')) { + const line = raw.trimEnd() + if (!line) continue + const m = line.match(/^\s*(?:\d+\.|[-*])\s+(.*)$/) + if (m) { + out.push({ sigil: '+', text: m[1].trim() }) + } else { + out.push({ sigil: ' ', text: line.trim() }) + } + } + return out +} + +// -- steer chip -------------------------------------------------------------- +// +// Summarize a steer card's spec into a compact chip label. We prefer the +// producer-authored title/label, fall back to the message body, and only +// then synthesize a generic label so the chat flow never carries a chip +// that says "steer" and nothing else. + +function chipLabelFromSteerSpec(spec) { + if (!spec || typeof spec !== 'object') return 'steer' + const src = firstString([ + spec.chipLabel, + spec.title, + spec.label, + spec.message, + spec.hint, + ]) + if (!src) return 'steer' + return truncate(src, 60) +} + +// Shared with trace-aggregator + trigger-templates — see text-truncate.js. +const truncate = ((typeof window !== 'undefined' && window.__dshTextTruncate) + || (typeof module !== 'undefined' && require('./text-truncate.js')) +).truncate + +function firstString(list) { + if (!Array.isArray(list)) return '' + for (const v of list) { + if (typeof v === 'string' && v.trim() !== '') return v.trim() + } + return '' +} + +// -- exports ----------------------------------------------------------------- + +const api = { + STATUS, + STATUS_KEYS, + statusFromOutcome, + isExitPlanModeSpec, + previewLinesFromPlan, + chipLabelFromSteerSpec, +} +if (typeof module !== 'undefined' && module.exports) module.exports = api +if (typeof globalThis !== 'undefined') globalThis.InteractCards = api +if (typeof window !== 'undefined') window.__dshInteractCards = api +})() diff --git a/examples/desktop/src/renderer/layout-controller.js b/examples/desktop/src/renderer/layout-controller.js new file mode 100644 index 0000000000..351a1c4b63 --- /dev/null +++ b/examples/desktop/src/renderer/layout-controller.js @@ -0,0 +1,419 @@ +// Layout controller: renderer-side glue that wires layout-heuristics.js to +// the DOM. Self-contained IIFE, does not touch renderer.js state or code. +// +// What it owns: +// - one LayoutHintTracker instance per active session (auto-reset on +// session change; we detect a session switch by watching for the +// `session/list` refresh + the current title element's dataset). +// - a small indicator button injected into `.header` that shows the +// current layout with a click-to-lock dropdown. +// - body-class toggling: adds `layout-` to , removing the +// other layout classes first. CSS variables in style.css react. +// - a `dsh:layout-hint` DOM event fired on whenever the hint +// changes, so other IIFEs (e.g. artifacts.js expanding into a right +// rail once artifact hint is active) can subscribe without coupling. +// +// It piggybacks on the same window.dsh.onNotify channel renderer.js uses, +// so we don't need any protocol changes. +// +// A manual lock is remembered per session id — flipping to another session +// starts fresh in auto mode. That matches the intuition: "I locked this +// conversation into code-review", not "I locked the whole app". + +'use strict' + +;(function () { + if (typeof window === 'undefined' || !window.LayoutHeuristics) return + const { LayoutHintTracker, LAYOUTS } = window.LayoutHeuristics + + const LAYOUT_LABELS = { + 'chat': 'chat', + 'code-review': 'code review', + 'artifact': 'artifact', + 'monitor': 'monitor', + } + // Layout glyphs are inline-SVG paths rather than emoji so they inherit + // the button's currentColor and match the minimalist-skill icon language + // (1.6px stroke, no color). `iconSvg(name)` returns the HTML string so + // callers can inject via innerHTML alongside the label text. + const LAYOUT_ICON_PATHS = { + 'chat': 'M4 5.5A1.5 1.5 0 0 1 5.5 4h9A1.5 1.5 0 0 1 16 5.5v6A1.5 1.5 0 0 1 14.5 13H8l-3 3v-3H5.5A1.5 1.5 0 0 1 4 11.5z', + 'code-review': 'M4 4h9a1 1 0 0 1 1 1v6l3 3v-9a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2zm2 3h6M6 10h6M6 13h4', + 'artifact': 'M10 3a7 7 0 1 0 4.9 12A2 2 0 0 0 13 13h-1a2 2 0 0 1-2-2 2 2 0 0 1 2-2h3a2 2 0 0 0 2-2 5 5 0 0 0-7-4z', + 'monitor': 'M3 5h14v9H3zM3 15h14M7 17h6', + } + function iconSvg(name) { + const d = LAYOUT_ICON_PATHS[name] || 'M4 4h12v12H4z' + return '' + } + // Small padlock glyph shown on the button when a manual lock is set. + const LOCK_SVG = + '' + + // Per-session tracker so switching sessions doesn't leak signals. + // Map + const trackers = new Map() + // Map — manual lock persisted per session. + const lockedHints = new Map() + + let activeSessionId = null + let indicatorEl = null + let dropdownEl = null + let currentBodyClass = null + + function trackerFor(sessionId) { + let t = trackers.get(sessionId) + if (!t) { + t = new LayoutHintTracker() + const locked = lockedHints.get(sessionId) + if (locked) t.lock(locked) + trackers.set(sessionId, t) + } + return t + } + + // -- DOM: indicator + dropdown -------------------------------------------- + + function ensureIndicator() { + if (indicatorEl && document.body.contains(indicatorEl)) return indicatorEl + const header = document.querySelector('.header') + if (!header) return null + + const wrap = document.createElement('div') + wrap.className = 'layout-indicator' + const btn = document.createElement('button') + btn.type = 'button' + btn.className = 'layout-indicator-btn ghost' + btn.title = 'current layout — click to lock/unlock' + + const dd = document.createElement('div') + dd.className = 'layout-indicator-dropdown' + dd.hidden = true + + for (const hint of LAYOUTS) { + const item = document.createElement('button') + item.type = 'button' + item.className = 'layout-indicator-item' + item.dataset.hint = hint + item.innerHTML = `${iconSvg(hint)}${LAYOUT_LABELS[hint]}` + item.addEventListener('click', (e) => { + e.stopPropagation() + chooseLayout(hint) + dd.hidden = true + }) + dd.appendChild(item) + } + const auto = document.createElement('button') + auto.type = 'button' + auto.className = 'layout-indicator-item layout-indicator-auto' + auto.textContent = '↻ auto' + auto.addEventListener('click', (e) => { + e.stopPropagation() + unlockLayout() + dd.hidden = true + }) + dd.appendChild(auto) + + btn.addEventListener('click', (e) => { + e.stopPropagation() + dd.hidden = !dd.hidden + }) + document.addEventListener('click', () => { dd.hidden = true }) + + wrap.append(btn, dd) + // Anchor to the right side of the header (after the debug row so + // nothing needs to shuffle). If .debug exists, sit after it; else + // append to the header directly. + const debug = header.querySelector('.debug') + if (debug && debug.parentNode === header) header.insertBefore(wrap, debug.nextSibling) + else header.appendChild(wrap) + + indicatorEl = btn + dropdownEl = dd + return btn + } + + function paintIndicator() { + const btn = ensureIndicator() + if (!btn) return + // Falls back to the NO_SESSION tracker so the pre-session pick still + // shows in the button label and the dropdown's selected-item marker. + const t = activeSessionId != null ? trackerFor(activeSessionId) : trackerFor(NO_SESSION) + const hint = t ? t.currentHint() : 'chat' + const locked = t ? t.isLocked() : false + btn.innerHTML = `${iconSvg(hint)}${LAYOUT_LABELS[hint] || hint}${locked ? LOCK_SVG : ''}` + btn.classList.toggle('locked', locked) + // Also mark the selected item in the dropdown. + if (dropdownEl) { + for (const item of dropdownEl.querySelectorAll('.layout-indicator-item')) { + item.classList.toggle('active', item.dataset.hint === hint) + } + } + } + + // -- body class management ------------------------------------------------- + + // Short human-readable "what changes visually" line per bucket. Shown as + // a transient toast on every switch so the user has immediate confirmation + // the change took effect — an empty-stream chat is otherwise visually + // identical between layout-chat and layout-code-review, and issue #2 was + // "切来切去都没反应" precisely because the tell was invisible until a diff + // arrived. + const LAYOUT_TOAST_MSG = { + 'chat': 'Layout: chat — standard 780px column', + 'code-review': 'Layout: code review — wider stream, tool blocks auto-open', + 'artifact': 'Layout: artifact — right rail reserved for previews', + 'monitor': 'Layout: monitor — compact density, smooth-follow tail', + } + function showLayoutToast(hint) { + let toast = document.getElementById('layout-toast') + if (!toast) { + toast = document.createElement('div') + toast.id = 'layout-toast' + toast.className = 'layout-toast' + document.body.appendChild(toast) + } + toast.textContent = LAYOUT_TOAST_MSG[hint] || `Layout: ${hint}` + toast.classList.remove('show') + // Force reflow so the class re-add re-triggers the CSS transition even + // when the same layout is picked twice in a row. + void toast.offsetWidth + toast.classList.add('show') + clearTimeout(showLayoutToast._t) + showLayoutToast._t = setTimeout(() => toast.classList.remove('show'), 1600) + } + + // Insert a small placeholder into the reserved artifact rail so switching + // into `artifact` has a visible effect even when no artifact card exists + // yet. Removed on switch back to other layouts so nothing lingers. + function ensureArtifactRailPlaceholder() { + const main = document.querySelector('.main') + if (!main) return + let slot = document.querySelector('.layout-rail-slot') + if (currentBodyClass !== 'layout-artifact') { + if (slot) slot.remove() + return + } + if (!slot) { + slot = document.createElement('div') + slot.className = 'layout-rail-slot' + slot.innerHTML = + '
    ' + + '
    Artifact rail
    ' + + '
    Files the model writes into
    .artifacts/ preview here.
    ' + + '
    ' + main.appendChild(slot) + } + } + + // the "Layout: chat — + // standard 780px column" toast used to fire on every applyBodyClass call — + // boot, session-switch, session.event replay — even when nothing about the + // layout changed. A first-time user saw a mysterious pill hovering on the + // fresh window and again after every New session click. Fix: + // - `silent: true` suppresses the toast entirely (used by boot()). + // - Otherwise, toast only fires when the layout actually CHANGED or the + // caller passed `force: true` (a click on the already-active dropdown + // item still deserves the feedback that the click landed — + // chooseLayout / unlockLayout set force so they retain the behavior). + function applyBodyClass(hint, opts) { + const silent = !!(opts && opts.silent) + const force = !!(opts && opts.force) + const next = `layout-${hint}` + const changed = next !== currentBodyClass + if (!silent && (changed || force)) { + showLayoutToast(hint) + } + if (!changed) { + ensureArtifactRailPlaceholder() + return + } + for (const h of LAYOUTS) document.body.classList.remove(`layout-${h}`) + document.body.classList.add(next) + currentBodyClass = next + forceToolBlocksOpenIfNeeded() + ensureArtifactRailPlaceholder() + document.body.dispatchEvent(new CustomEvent('dsh:layout-hint', { + detail: { hint, sessionId: activeSessionId }, + })) + } + + // In code-review layout we auto-open new .tool-block
    so diffs are + // visible without a click. If the user manually closes one we honor it + // (data-user-collapsed marker set by our own click listener). + function forceToolBlocksOpenIfNeeded() { + if (currentBodyClass !== 'layout-code-review') return + for (const el of document.querySelectorAll('.tool-block')) { + if (!el.dataset.userCollapsed) el.setAttribute('open', '') + } + } + function bindToolBlockAutoOpen() { + const stream = document.getElementById('stream') + if (!stream) return + // MutationObserver: any newly-added .tool-block gets stamped `open` when + // we're in code-review mode. + const mo = new MutationObserver((records) => { + if (currentBodyClass !== 'layout-code-review') return + for (const rec of records) { + for (const node of rec.addedNodes) { + if (!node || node.nodeType !== 1) continue + if (node.classList && node.classList.contains('tool-block') && !node.dataset.userCollapsed) { + node.setAttribute('open', '') + } + const nested = node.querySelectorAll ? node.querySelectorAll('.tool-block') : [] + for (const el of nested) if (!el.dataset.userCollapsed) el.setAttribute('open', '') + } + } + }) + mo.observe(stream, { childList: true, subtree: true }) + // Delegate a toggle listener so a manual close sticks — otherwise the + // next diff event would re-open it. + stream.addEventListener('toggle', (e) => { + const t = e.target + if (!t || !t.classList || !t.classList.contains('tool-block')) return + if (!t.open) t.dataset.userCollapsed = '1' + else delete t.dataset.userCollapsed + }, true) + } + + // Fallback lock-key used before the first session exists. `null` is a + // legal Map key so this coexists with per-session locks — when a real + // session is later activated, its own tracker takes over, but the visual + // hint stays put because applyBodyClass() has already flipped the body + // class. Without this the whole dropdown looked broken on the empty-state + // welcome screen (no active session yet, so every item silently no-op'd). + const NO_SESSION = null + + function chooseLayout(hint) { + const key = activeSessionId != null ? activeSessionId : NO_SESSION + const t = trackerFor(key) + t.lock(hint) + lockedHints.set(key, hint) + // user-driven pick deserves the toast even if the pick + // matches the current layout — otherwise the click looks broken. + applyBodyClass(hint, { force: true }) + paintIndicator() + } + + function unlockLayout() { + const key = activeSessionId != null ? activeSessionId : NO_SESSION + const t = trackerFor(key) + t.unlock() + lockedHints.delete(key) + // Re-render with whatever the tracker settled at (`chat` after reset). + // Force the toast: the user just clicked "↻ auto" and needs feedback. + applyBodyClass(t.currentHint(), { force: true }) + paintIndicator() + } + + // -- session-switch detection --------------------------------------------- + // + // renderer.js doesn't emit a session-change event, so we watch its + // #session-title element via MutationObserver + we peek at its title + // attribute (which renderer.js sets to the session id). Cheap, and it + // never fights renderer state. + + function bindSessionWatcher() { + const titleEl = document.getElementById('session-title') + if (!titleEl) return + const detect = () => { + // renderer.js `li.title = id` on sidebar entries, and sets + // titleEl.textContent to a slice of the id. The most reliable + // signal is the .active sidebar entry's `title` attribute. + const active = document.querySelector('#sessions li.active') + const sid = active ? active.title : null + if (sid && sid !== activeSessionId) onSessionChanged(sid) + } + // Poll on any DOM mutation in the sidebar; MutationObserver fires + // once per batch so this is cheap. + const mo = new MutationObserver(detect) + mo.observe(document.getElementById('sessions') || document.body, { + subtree: true, childList: true, attributes: true, + }) + // Also re-check whenever the title changes. + new MutationObserver(detect).observe(titleEl, { + subtree: true, characterData: true, childList: true, + }) + detect() + } + + function onSessionChanged(sid) { + activeSessionId = sid + const t = trackerFor(sid) + applyBodyClass(t.currentHint()) + paintIndicator() + } + + // -- runtime hooks -------------------------------------------------------- + + function handleNotify({ method, params }) { + if (!params) return + if (method === 'session.event') { + const sid = params.sessionId + if (!sid) return + const t = trackerFor(sid) + const result = t.push(params.event) + if (sid === activeSessionId) { + if (result.changed) applyBodyClass(t.currentHint()) + // Refresh the indicator on every push so the little glyph stays + // in step even during the debounce ramp. + paintIndicator() + } + } + } + + function pollSessionMeta() { + // Poll `listSessions()` once every 3s to feed running=? into the + // per-session trackers' meta. This is what unlocks the monitor gate + // on long-running turns. + if (!window.dsh || typeof window.dsh.listSessions !== 'function') return + const tick = async () => { + try { + const list = await window.dsh.listSessions() + if (Array.isArray(list)) { + for (const entry of list) { + const t = trackers.get(entry.sessionId) + if (t) t.setMeta({ + title: entry.title, + running: !!entry.running, + header: entry.header || {}, + }) + } + } + } catch (_) { /* ignore */ } + } + setInterval(tick, 3000) + setTimeout(tick, 400) + } + + // -- boot ----------------------------------------------------------------- + + function boot() { + // Default body class so CSS variables have something to attach to + // from the first paint. `silent:true` skips the toast — boot is not + // user intent, and the pill was showing up on every fresh-eyes walkthrough + // as "Layout: chat — standard 780px column" hovering on an empty pane + applyBodyClass('chat', { silent: true }) + ensureIndicator() + bindSessionWatcher() + bindToolBlockAutoOpen() + if (window.dsh && typeof window.dsh.onNotify === 'function') { + window.dsh.onNotify(handleNotify) + } + pollSessionMeta() + paintIndicator() + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot) + } else { + boot() + } +})() diff --git a/examples/desktop/src/renderer/layout-heuristics.js b/examples/desktop/src/renderer/layout-heuristics.js new file mode 100644 index 0000000000..d8d08f1305 --- /dev/null +++ b/examples/desktop/src/renderer/layout-heuristics.js @@ -0,0 +1,330 @@ +(function () { +// Pure layout-hint engine for the DSH desktop shell. No DOM, no timers, no +// protocol — takes session events + meta in, emits one of four layout +// buckets out. Both the renderer (via ', '.md') + assert.match(out, /<script>/) // escaped, not literal + assert.doesNotMatch(out, /