77 KiB
Maka desktop smoke test plan
Archived on 2026-07-13. This snapshot mixes completed PR acceptance paths and deferred designs. The current runbook is
apps/desktop/tests/smoke.md; scripts, fixtures, and tests own scenario details.
Manual end-to-end paths that the V0.2 UI / credential / lifecycle work relies on. Each path lists the precondition, the steps, and the observable signal that proves the path is intact. If any of these regress, that's the floor we lost — fix before shipping.
Setup
Either start clean (rm -rf ~/Library/Application\ Support/maka on
macOS, equivalent path on Windows / Linux) or use an existing workspace
and follow the per-path preconditions. All paths happen in a single
launched build (npm --workspace @maka/desktop run dev or a packaged
build).
Real Electron window smoke (PR-DESKTOP-SMOKE-0)
Visual-smoke screenshots do not prove native desktop behavior. Any PR that touches the shell, sidebar, modal backdrop, window drag regions, or top-level renderer lifecycle must also run the real-window smoke gate:
npm --workspace @maka/desktop run smoke:real-window
The script builds @maka/core, @maka/ui, and desktop, launches a real
Electron window with an isolated --user-data-dir, then prompts the
reviewer to confirm the checks that screenshots cannot exercise:
- clean launch with no ErrorBoundary / crash screen;
- dragging left / right / top / bottom edges resizes the window;
- dragging all four corners resizes diagonally;
- dragging an allowed titlebar / blank header area moves the window;
- rows, buttons, inputs, and modal controls do not drag the window;
- Search modal opens and closes via close button, backdrop, and Esc;
- Tab / Shift+Tab stays inside the modal and focus returns to the trigger;
- with Search modal open, window edges and corners still resize;
- after closing the modal and switching modules, no React hook error or ErrorBoundary appears.
Reports are written to
apps/desktop/tests/real-window-smoke/<timestamp>.md and .json.
If any check fails, the script exits non-zero. A UI-shell PR is not ready
to merge until this report is attached or summarized in the review
thread.
For deterministic visual smoke, launch a dev build with an isolated fixture workspace:
MAKA_VISUAL_SMOKE_FIXTURE=all npm --workspace @maka/desktop run dev
Single-scenario launches are also supported:
MAKA_VISUAL_SMOKE_FIXTURE=first-run npm --workspace @maka/desktop run dev
MAKA_VISUAL_SMOKE_FIXTURE=provider-workspace npm --workspace @maka/desktop run dev
MAKA_VISUAL_SMOKE_FIXTURE=fallback-source npm --workspace @maka/desktop run dev
MAKA_VISUAL_SMOKE_FIXTURE=fetched-empty npm --workspace @maka/desktop run dev
MAKA_VISUAL_SMOKE_FIXTURE=connection-error npm --workspace @maka/desktop run dev
MAKA_VISUAL_SMOKE_FIXTURE=turn-narrative npm --workspace @maka/desktop run dev
MAKA_VISUAL_SMOKE_FIXTURE=streaming-sidebar npm --workspace @maka/desktop run dev
MAKA_VISUAL_SMOKE_FIXTURE=permission-destructive npm --workspace @maka/desktop run dev
MAKA_VISUAL_SMOKE_FIXTURE=artifact-pane npm --workspace @maka/desktop run dev
MAKA_VISUAL_SMOKE_FIXTURE=artifact-errors npm --workspace @maka/desktop run dev
MAKA_VISUAL_SMOKE_FIXTURE=stale-sessions npm --workspace @maka/desktop run dev
MAKA_VISUAL_SMOKE_FIXTURE=settings-data npm --workspace @maka/desktop run dev
MAKA_VISUAL_SMOKE_FIXTURE=settings-personalization npm --workspace @maka/desktop run dev
MAKA_VISUAL_SMOKE_FIXTURE=settings-network npm --workspace @maka/desktop run dev
MAKA_VISUAL_SMOKE_FIXTURE=settings-bots npm --workspace @maka/desktop run dev
MAKA_VISUAL_SMOKE_FIXTURE=settings-about npm --workspace @maka/desktop run dev
MAKA_VISUAL_SMOKE_FIXTURE=settings-theme npm --workspace @maka/desktop run dev
MAKA_VISUAL_SMOKE_FIXTURE=settings-daily-review npm --workspace @maka/desktop run dev
MAKA_VISUAL_SMOKE_FIXTURE=workstation-statuses npm --workspace @maka/desktop run dev
Fixture mode is dev/test-only and refuses packaged builds. It seeds
workspaces/visual-smoke-* from scratch on every launch, so screenshots
are repeatable and real user workspaces are not touched. visualSmoke
IPC returns null when the env var is unset; renderer smoke-only
streaming / permission state must never appear in normal usage.
Automated screenshot capture (PR-IR-01)
Capture light/dark/narrow/reduced-motion baseline PNGs for every fixture scenario using the driver script:
# Single scenario × all 8 variants (light/dark × 1280/990 × motion/reduced)
npm --workspace @maka/desktop run screenshots:single artifact-pane
# All scenarios × all variants (full regression baseline)
npm --workspace @maka/desktop run screenshots
Output: apps/desktop/tests/screenshots/<scenario>/<variant>.png.
Implementation: the script spawns electron . once per (scenario,
variant) with MAKA_VISUAL_SMOKE_FIXTURE=<scenario> +
MAKA_VISUAL_SMOKE_AUTO_CAPTURE=<variant> (+ optional
MAKA_VISUAL_SMOKE_REDUCED_MOTION=1). The renderer waits 2 RAFs + 400ms
idle after fixture settle, then calls window.maka.visualSmoke.capture().
Main process writes the PNG via webContents.capturePage() and emits
a deterministic stdout marker [visual-smoke] captured scenario=… variant=… path=…. The driver script greps for the marker, kills the
subprocess, and copies the PNG into the canonical screenshots
directory.
Screenshot diff gate (PR-IR-02 stage 1)
screenshots:diff:stable is a blocking capture sanity gate for the
stable baseline subset (artifact-pane, first-run, artifact-errors):
npm --workspace @maka/desktop run screenshots
npm --workspace @maka/desktop run screenshots:diff:stable
What this gate catches:
- Missing, corrupt, or truncated PNGs.
- Broken capture IPC or fixture startup.
- Wrong dimensions, such as a
1280variant captured at990width. - Scenario/variant matrix drift between capture and diff scripts.
What this gate does NOT catch:
- Pixel-level UI regressions inside the image.
- Layout shifts that keep total image dimensions stable.
- Color, contrast, opacity, typography, or spacing regressions.
Electron/font rasterization drift makes byte-level diff impractical as
a blocker. Human review of the screenshots is still required until
pixel-level diff with calibrated tolerance and ignored dynamic regions
(PR-IR-02 v3) is added. That future gate should pilot on the stable
subset (artifact-pane / first-run / artifact-errors) first
before expanding to all scenarios.
To promote the current stable subset after intentional visual changes:
npm --workspace @maka/desktop run screenshots:baseline:stable
After the full baseline has been reviewed and promoted, use the same scripts
without the :stable suffix. The scenario inventory and output cardinality are
owned by ALL_SCENARIOS and VARIANTS in the capture script:
npm --workspace @maka/desktop run screenshots:diff # all scenarios
npm --workspace @maka/desktop run screenshots:baseline # full promotion
Reduced-motion variant (PR-IR-04)
Combine MAKA_VISUAL_SMOKE_REDUCED_MOTION=1 with any of the above to
collapse every animation/transition to ~0.01ms regardless of the host
OS accessibility setting. Used by the screenshot pipeline (PR-IR-01) to
capture a "reduced motion" variant per surface.
MAKA_VISUAL_SMOKE_FIXTURE=artifact-pane \
MAKA_VISUAL_SMOKE_REDUCED_MOTION=1 \
npm --workspace @maka/desktop run dev
Implementation: main process passes the flag through VisualSmokeState;
renderer applies data-maka-reduced-motion="true" to <html>; CSS in
styles.css matches that attribute selector with the same overrides as
the prefers-reduced-motion: reduce media query. Real users never reach
this code path because visualSmoke.getState() returns null unless
MAKA_VISUAL_SMOKE_FIXTURE is set.
Path 1 — First launch with no real model
Precondition. Clean install, no enabled LlmConnection in settings.
Fixture scenario: first-run.
Steps.
- Launch Maka.
- Don't type into the composer; just look at the chat surface.
Pass signal.
- The chat surface renders OnboardingHero (the "Welcome to Maka"
card with six featured provider tiles), not the
EmptyChatHero("想一起做点什么?") or a blank screen. - Clicking any provider tile opens Settings · 模型.
- "先用 FakeBackend 走一遍流程 →" focuses the composer.
Fail signals.
- Empty chat hero shown despite no enabled connection.
- Onboarding hero shown forever even after connection is enabled.
Path 2 — Add a connection and verify it
Precondition. Workspace exists; you have a real provider API key (Anthropic / OpenAI / DeepSeek / Z.ai / etc.).
Steps.
- ⌘K → "设置 · 模型" → Enter (PR64 palette routing).
- Add an Anthropic connection, paste API key, save.
- Switch to "设置 · 账号" via the nav.
- Observe the new connection row: it should say 已配置 · 未验证 in an info-tone badge (no green check yet).
- Click "测试连接" on that row.
- Wait for the toast.
Pass signal.
- Success toast: "连接已验证" + latency + tested model.
- Row badge flips to 已验证可用 in green/success tone.
- Row card border + background shifts to success.
- Default connection (if set in Settings · 通用 or models flow) has a small "默认" pill on the name line.
lastTestAtformatted timestamp visible under the badge.
Fail signals.
- Test button stuck disabled or spinning forever.
- Status doesn't refresh without closing/reopening Settings.
- Badge ever shows "disabled + verified" or any mixed label.
Path 3 — Failing credential surfaces in chat header
Precondition. A previously verified connection. The session you
open uses this connection.
Fixture scenario for the chat header state: connection-error.
Steps.
- Settings · 模型 → pick the connection → corrupt the API key (replace with a clearly bogus value) → save.
- Settings · 账号 → click "测试连接" on that row.
- Wait for the failure toast.
- Close Settings, return to chat with that connection active.
Pass signal.
- Account row badge becomes 需要重新登录 (warning tone) or 连接出错 (destructive tone) depending on the underlying errorClass (401/403 → needs_reauth; 5xx/timeout/network → error).
lastTestMessageshows a generalized phrase likeAuthentication failed/Request timed out— never a raw provider body or API key.- Chat header now shows a small clickable pill matching the row tone ("需要重新登录" warning or "上次连接失败" destructive).
- Clicking the pill jumps directly to Settings · 账号.
Fail signals.
- Chat header alert missing when the row already shows the failure.
- Generalized message includes raw
sk-.../ Bearer token / URL with query secret. - Connection auto-disabled after a single failure (failure should be a status, not a lifecycle change — user disables manually).
Path 4 — Streaming + delete-active-session safety
Precondition. At least one verified connection. Active session has the model picked.
Steps.
- Send a prompt; the model starts streaming.
- Verify the composer toolbar swaps in "Maka 正在思考…" with the pulsing accent dot, the Send button disappears, and the only primary action is a red Stop button.
- Try pressing Esc inside the textarea — it should call onStop and the stream should cancel.
- Send a fresh prompt and let it run.
- Delete the currently-active session mid-stream. Options, easiest
first:
- IPC-level (preferred for automated test runs): from DevTools
console, fire
window.maka.sessions.remove(activeSessionId). Thesessions:changed { reason: 'deleted', sessionId }broadcast is the contract under test, not the right-click affordance. - GUI: from a second Maka window pointed at the same workspace (open a new BrowserWindow if needed), right-click the row → 删除 → confirm. The original window must observe the broadcast.
- IPC-level (preferred for automated test runs): from DevTools
console, fire
Pass signal.
- The sidebar removes the row (via
sessions:changedbroadcast). - The chat surface clears: active session unset, messages emptied, no stuck streaming bubble.
- No "send into a deleted session" error follows; the composer remains responsive and the user can start a new chat.
Fail signals.
- Composer keeps showing the streaming hint after the underlying session is gone.
- Renderer crashes or shows the previous session's messages on top of an empty title.
- Tool activity from the deleted session keeps streaming into the new one.
Path 5 — PermissionDialog destructive path
Precondition. A connection that lets the model invoke tools (e.g.
default agent setup). User is in Ask permission mode.
Fixture scenario: permission-destructive.
Important — do not actually run the destructive command. The goal is to verify the dialog presentation, not to delete real files. Either:
- Ask the assistant to propose the action so it surfaces a PermissionRequest, then Deny. Or
- Inject a synthetic permission request via DevTools by simulating the IPC event so the dialog mounts without any tool actually pending.
Steps.
- Cause the runtime to produce a destructive PermissionRequest
(e.g. tell the model "我会自己跑,先告诉我你打算执行什么 rm 命令"
so it issues an
fs_destructiverequest you can refuse), or inject a synthetic request in DevTools. - Wait for the PermissionDialog to appear.
Pass signal.
- Dialog icon is AlertOctagon (red), label reads 不可恢复的文件系统操作.
- Summary section shows the exact shell command in a code block + a timeout meta line if the runtime supplied one.
- Below the "本轮对话内记住选择" checkbox, the red emphasis note "这类操作不可恢复,确认前请再读一遍上面的参数。" is visible.
- The primary button reads "我已确认,允许" in destructive tone (red), not the usual blue "允许".
- The "记住本轮" caption explicitly says "(同类型工具不再询问,关闭/切换对话后失效)".
- Clicking Deny does not run the command; the assistant gets a denial signal.
Fail signals.
- The dialog renders the action with neutral / info tone (no red treatment) for an obviously destructive operation.
- "记住本轮" persists across sessions or app restarts (should be per-turn only).
- Permission dialog can be dismissed with Esc (it shouldn't be — Esc is explicitly disabled for permission decisions).
Path 6 — ModelTable workspace (UI-02)
Precondition. A verified Z.ai or OpenAI-protocol connection with
6 models available. Settings open on 模型 → click into that connection. Fixture scenarios:
provider-workspace,fallback-source, andfetched-empty.
Steps.
- Verify the source line under the model count reads "实时拉取的 N 个模型(X 拉取)" (green tone). Click "从 API 刷新" once; the line should update to "刚刚拉取" (or similar).
- With more than 6 models, type into the search box. Filter to a substring that excludes the current default.
- Observe the hidden-default hint above the list: "当前默认
…不 在搜索结果中 · 点这里清空搜索". Click it; search clears, default row visible. - Tab into the model list; press ArrowDown several times.
- Press Home, then End.
Pass signal.
- Source label tone matches: success (green) for fetched, info for fallback, fetched-empty branch for "0 models from provider".
- ArrowDown/ArrowRight moves focus AND ticks the selected default radio down by one. ArrowUp/ArrowLeft moves it up. Home jumps to first row; End jumps to last.
- The default radio dot and "默认" badge follow the active row.
- Wrapping: ArrowDown on the last row wraps to first; ArrowUp on the first wraps to last.
- Hidden-default hint mounts only while search filters out the default; disappears when search is cleared.
Fail signals.
- Source label says "实时拉取" but the cached models look stale (e.g.
glm-4.5/4.6/4.7exact fallback list) — that's the silent-fallback regression PR91 closed. - ArrowDown only moves focus without selecting (UI-04 ARIA radiogroup regression).
- Search filter hides default with no hint — the user thinks the default got deleted.
Path 7 — Chat turn narrative (UI-04)
Precondition. Any verified connection. Active session with a
multi-step exchange (user message → tool call → assistant final).
Fixture scenario: turn-narrative.
Steps.
- Ask: "读一下 README.md 并总结" (or any prompt that triggers a Read tool call).
- Wait for the full turn to land.
- Observe the structure inside the chat surface.
Pass signal.
- The user message, the tool activity panel, and the assistant
answer are visually grouped as one turn block (
<section class="maka-turn">), not three free-floating items. - Turn meta (model id e.g.
claude-sonnet-4-5, durationX.X s, cost$X.XXXX) lives in the footer's info-action tooltip, shown on hover (#546 removed the top summary chip strip). - If the model supplied thinking, a collapsed
<details>block "查看思考过程 — 模型推理草稿,不是最终答案" appears above the assistant answer; expanding it shows the reasoning with its own "复制思考过程" button. - For an in-progress turn (user sent, assistant hasn't landed), no duration is shown — the footer info tooltip omits the duration line until the assistant turn lands.
Fail signals.
- Tool activity at the very bottom of the chat instead of inside its turn (old "message stack + tools panel" layout).
- Thinking block included in the default "Copy message" button (should be exclusive to the dedicated "复制思考过程" button).
- Token cost hover shows
$0.0000when costUsd isn't known.
Path 8 — Sidebar streaming + multi-session indicator (PR85)
Precondition. At least two sessions exist. Open one of them.
Fixture scenario: streaming-sidebar.
Steps.
- Send a prompt in session A; let it start streaming.
- Without waiting for the stream to finish, switch to session B by clicking in the sidebar.
- Observe session A's row in the sidebar.
Pass signal.
- Session A's row shows a small pulsing accent-tinted dot next to the session name.
- The row preview text shows "Maka 正在思考…" (overrides the
prior
lastMessagePreview). - The unread halo dot is suppressed for streaming rows (streaming takes precedence per PR85).
- Once the stream completes, the pulse dot disappears and the row
may show the unread halo + the updated
lastMessagePreview.
Fail signals.
- Streaming session looks identical to an idle session (lost the indicator).
- Pulse + unread dot both rendered at the same time (priority violation).
Path 9 — Command palette diagnostics + export (UI-05, PR86)
Precondition. Maka running with at least one verified connection
and an active chat session with several turns.
Fixture scenario: all.
Steps.
- Press ⌘K. Scan groups: 操作 / 主题 / 设置 / 诊断 / 连接 / 会话.
- Type "测试默认". The "测试默认连接 · {name}" command should surface in the 诊断 group; press Enter.
- ⌘K again, type "导出". The "导出当前对话为 Markdown" command should surface; press Enter.
- Paste the clipboard into a markdown viewer.
- ⌘K once more, type "设置 · 模型" and press Enter (with Settings not currently open).
Pass signal.
- ⌘K palette opens with the same five-section nav (操作/主题/设置/ 诊断/连接) plus the per-session entries at the bottom.
- "测试默认连接" runs the connection test, surfaces a success or
failure toast, and the Account row's
lastTestStatusbadge refreshes without closing the palette → reopening Settings. - "导出当前对话为 Markdown" lands a structured markdown doc on the
clipboard with
# {sessionName}+## 你/## Makasections; thinking blocks are NOT included; tool calls appear as a bulleted list with names + intent (intent passes throughredactSecrets). - "设置 · 模型" opens Settings directly on the 模型 section, even if Settings was already open on a different section.
Fail signals.
- "设置 · ..." command requires a second click to actually navigate
(warm-switch via
requestedSectionregressed). - Markdown export contains thinking blocks (security regression per @kenji's PR86 review).
Path 10 — Sandbox bridge sanity
Precondition. Maka running in fixture mode (MAKA_VISUAL_SMOKE_FIXTURE=all)
or a normal dev workspace with at least one configured provider. This path
exists because the BrowserWindow renderer runs with sandbox: true,
contextIsolation: true, and nodeIntegration: false; all app behavior
must still flow through window.maka.
Steps.
- Open Settings, change a harmless appearance preference, and close.
- ⌘K → "打开工作区文件夹"; verify the OS opens the allowlisted folder.
- ⌘K → "测试默认连接" in a configured workspace, or in fixture mode click a connection test action and observe the toast path.
- In a real configured workspace, send a prompt and press Stop while
streaming. In fixture mode, verify the streaming sidebar row and
permission dialog still render from
visualSmoke.getState().
Pass signal.
window.maka.settings,window.maka.app.openPath,window.maka.connections,window.maka.sessions, andwindow.maka.visualSmokeall respond through preload IPC.- No external page opens inside the Maka BrowserWindow; allowed http(s) / mailto links go through the OS, and dropped files do not navigate the renderer.
Fail signals.
- Settings, connection test, openPath, send/stop, or fixture state breaks after sandbox hardening.
- A clicked markdown link or dropped file replaces the React app surface.
Path 11 — Artifact pane (UI-02 follow-on, §9.1)
Precondition. Fixture scenario artifact-pane — seeds a session named
"Artifact Pane 验收" with 3 live artifacts (report.html, patch.diff,
notes.md) under the workspace artifacts/ root.
MAKA_VISUAL_SMOKE_FIXTURE=artifact-pane npm --workspace @maka/desktop run dev
Steps.
- Launch Maka with the fixture above. The artifact session is activated
automatically via
visualSmoke.getState(). - Verify the right-side ArtifactPane is visible with a count badge of 3 in the header and three rows in the list (newest first).
- Click the row for report.html. Confirm the preview region renders a
sandboxed
<iframe>with the document body and a top status bar reading "此预览中已禁用外部链接 · 1 个链接". - With DevTools open, inspect the iframe element. Its
sandboxattribute must be exactlyallow-scripts— NOallow-same-origin,allow-top-navigation,allow-popups,allow-forms,allow-modals. - Click the disabled link inside the iframe. Nothing should happen (no navigation, no popup, no console error in the parent renderer).
- Click the patch.diff row. Preview switches to a diff view with
red/green line coloring (
data-line="add"/data-line="del"). - Click the notes.md row. Preview switches to the markdown file
content rendered in a monospace
<pre>. - Take screenshots in light theme, dark theme, and a narrow window (~900 px width). At narrow width, verify ArtifactPane renders as a bottom sheet below the composer instead of a right rail.
- Click the collapse toggle in the pane header. Pane should shrink to a
narrow strip; reload the page (⌘R / F5). Pane should still be
collapsed (persisted via localStorage
maka-artifact-pane-collapsed-v1). - Expand again. Verify the list still shows 3 artifacts after reload.
- With keyboard focus inside the artifact list or preview, press
Escape. The pane collapses and focus returns to the composer. With Command Palette / modal open, pressingEscapecloses that overlay normally; ArtifactPane must not steal Esc outside its own focus subtree.
Pass signal.
- ArtifactPane header shows count
3and three rows:report.html,patch.diff,notes.md. - HTML preview renders inside an iframe whose only sandbox token is
allow-scripts. The status bar reads "此预览中已禁用外部链接 · 1 个 链接" (the fixture HTML contains one<a href>). - Diff preview shows the patch with red/green line tagging.
- Markdown preview shows the raw file text in monospace.
- Toolbar shows「在 Finder 中打开」+「另存为」for all kinds; only the
text-backed kinds (file / diff / html) additionally show「复制文本」 —
image/pdfrows do NOT (review gate #5). - Collapse state persists across reload via localStorage; the list still has 3 entries after reload.
- Narrow width shows ArtifactPane as a bottom sheet below the composer; composer textarea and Send/Stop button remain visible and usable.
- Esc inside the ArtifactPane focus subtree collapses the pane and returns focus to the composer; Esc outside the pane keeps global modal/palette priority intact.
Fail signals.
- Blank pane despite the fixture seeding three artifacts (subscription / list IPC regressed).
- HTML preview shows raw HTML source as text instead of rendering inside the iframe.
- External-link status bar missing or count = 0 even though the fixture
HTML contains an
<a href="https://example.com">. - Clicking a link inside the iframe navigates the parent renderer or opens a popup (sandbox should block both).
sandboxattribute on the iframe contains any ofallow-same-origin,allow-top-navigation,allow-popups,allow-forms,allow-modals.imageorpdfrows render a 复制 button (binary kinds must not).- Narrow window keeps the pane as a squeezed right rail, covers the composer, or makes the Send/Stop button unreachable.
- ArtifactPane handles Esc while focus is in Command Palette / Settings / permission dialogs.
Path 12 — Sidebar shows "已过期" pill for stale sessions
Precondition. Fixture scenario stale-sessions:
MAKA_VISUAL_SMOKE_FIXTURE=stale-sessions npm --workspace @maka/desktop run dev
This seeds a workspace reproducing the on-disk state that triggered the P0 — three sessions in the sidebar:
- 「旧的 FakeBackend 演示」 —
backend='fake', slugfake(stale) - 「旧的 Claude backend 会话」 —
backend='claude', slugfake-claude(stale, legacy) - 「正常会话(Z.ai Live)」 —
backend='ai-sdk', slugzai-live(healthy)
The active session is intentionally the FakeBackend stale row — the fixture is designed to verify the @kenji active-stale gate (active row must still show the pill).
Steps.
- Launch Maka against the workspace.
- Open the sidebar; observe the visible session rows.
- Click into a stale session so it becomes active.
- Click into the healthy session (
backend='ai-sdk', real slug).
Pass signals.
- Each stale session row is dimmed (opacity ≈ 0.7) AND shows a small amber pill labelled 「已过期」 to the right of the session name.
- The healthy session row is fully opaque, no pill.
- When the stale session is active (clicked into):
- Row opacity is back to 1.0 (active highlight wins over dim).
- 「已过期」pill is still rendered — the active highlight must not erase the warning signal (@kenji review gate).
- Chat header surfaces the matching banner from PR108e:
backend='fake'→ "会话已过期 · ..."; missing slug → "原连接已删除..."
- Switching back to the healthy session removes both the pill and the header banner; nothing else changes about the sidebar.
Fail signals.
- Stale row looks identical to the healthy row (pill missing OR dim treatment missing).
- Active stale row HIDES the pill (regression on @kenji's gate — once a user clicks into a broken session the sidebar should still flag it as broken; without this they think the session is fine).
- Healthy row gets the pill / dim treatment (over-flagging — the
staleSessionIdsSet should NOT includeslugs that resolve to a current connection). - Pill color matches the destructive (red) tone instead of warning (amber); destructive is reserved for cases where send will actually fail despite @xuan's silent rebind.
Path 13 — Artifact pane failure states and Save As (§9.1)
Precondition. Fixture scenario artifact-errors — seeds the normal
artifact session plus three failure rows:
deleted.mdwithstatus: deletedtombstoneunsupported.binwith binary bytes that fail MIME sniffingmissing.mdmetadata whose backing file is absent
MAKA_VISUAL_SMOKE_FIXTURE=artifact-errors npm --workspace @maka/desktop run dev
Steps.
- Launch Maka with the fixture above. The "Artifact Pane 验收" session is activated automatically.
- Verify the pane count includes six rows, while deleted rows are visually marked with an "已删除" badge.
- Select
deleted.md. The preview must show the explicit deleted failure state and must not read the backing file even if it exists. - Select
unsupported.bin. The preview must show "不支持的文件类型" and must not display raw bytes or a copy button. - Select
missing.md. The preview must show "无法读取 artifact 文件". - Select
report.html, click「另存为」, cancel the save dialog. No error toast should appear. - Click「另存为」again and choose a temporary destination. The file should be copied there and a success toast should appear.
Pass signal.
deleted.md,unsupported.bin, andmissing.mdeach render distinct failure copy; no blank preview state.- Deleted artifact reads are blocked by tombstone semantics, not by file absence.
- Unsupported MIME never sends raw bytes into the renderer preview.
- Save As uses a real OS save dialog and copies the artifact file; it no longer aliases to "在 Finder 中打开".
- Canceling Save As is silent.
Fail signals.
- Any failure row renders a blank preview.
- Deleted artifact content remains readable.
- Unsupported MIME displays mojibake/raw binary.
- Save As reveals the file in Finder instead of opening a save dialog.
- Canceling Save As shows an error toast.
Path 14 — Workstation sidebar status grouping (§9.8)
Precondition. Fixture scenario workstation-statuses — seeds 11
sessions covering every SessionStatus (including aborted) plus 4
blocked variants (one per SessionBlockedReason):
MAKA_VISUAL_SMOKE_FIXTURE=workstation-statuses npm --workspace @maka/desktop run dev
The active session is the running one so the chat header status badge ("进行中") is visible in the screenshot alongside the sidebar.
Steps.
- Launch Maka with the fixture above. The "正在生成报告" session is active.
- Observe the sidebar groups in order. Expected from top to bottom:
进行中,等待你,已阻塞,会话,待审核,已完成,归档,已中止. Both归档and已中止are collapsed by default. - Hover each row's status icon. Tooltip reads the status label (and the generalized blocked-reason copy for the 4 blocked rows — never the raw enum identifier).
- Click the
归档group header to toggle expanded state. - Click any of the blocked rows. Verify the chat header status badge updates with the matching reason copy.
Pass signals.
- Group ordering matches:
进行中 / 等待你 / 已阻塞 / 会话 / 待审核 / 已完成 / 归档 / 已中止. Both归档and已中止are collapsible groups defaulting to collapsed; expanding either reveals its row(s). - Each non-active session row shows the SessionStatusIcon to the left of the session name with the matching tone (running=accent pulse, waiting=warning, blocked=destructive, review=info, done=success, archived=muted, aborted=muted).
- Blocked rows show the generalized reason via hover tooltip:
缺少可用模型连接需要重新登录等待权限确认工具调用失败运行中断
- The chat header badge "进行中" is visible (because the active session is running).
归档group is collapsed by default; clicking the header expands it; expanded state persists across re-renders within the session (but not across launches — that's intentional, archived is dormant).- All visible labels are Chinese; no raw enum strings leak to the UI.
Fail signals.
- Group ordering differs (e.g.
归档floats to the top, or已完成appears before进行中). 已中止group is silently hidden (regression on @kenji PR109b review — aborted is dormant but must remain visible).- Blocked tooltips expose the raw enum (
NO_REAL_CONNECTION,auth,permission_required,tool_failed,unknown— these are internal identifiers, not user copy). 归档group defaults to expanded (should be collapsed by default; PR108k-yj convention).- Running session shows no spinning indicator (the
Loader2icon should spin via CSSanimationunlessprefers-reduced-motionis active orMAKA_VISUAL_SMOKE_REDUCED_MOTION=1is set). - Chat header lifecycle badge missing despite the active session
having
status !== 'active'.
Path 15 — Turn control contract API + UI (§9.9, PR109c / PR109d / PR109e / PR109f)
Scope. PR109c shipped the contract/runtime; PR109d–f layer the UI on top: turn footer actions, aborted marker, failed banner, forward + reverse lineage badges, branched-session banner.
Fixture. turn-control-history state family — three scenarios
sharing one on-disk seed and differing only in active session:
| Scenario | Active session | What it verifies |
|---|---|---|
turn-control-history |
…-primary |
lineage badges, aborted marker, failed banner |
turn-control-branch-visible |
…-branch-visible |
branch banner copy 分自 ${primaryName} renders |
turn-control-branch-orphan |
…-branch-orphan |
branch banner is ABSENT (parent missing from list) |
The three variants are the same state family — only the active session
flips. The orphan branch's parentSessionId points to a session id
that is intentionally not seeded on disk, so the renderer's
deriveBranchBanner() resolves the parent as missing.
Run.
MAKA_VISUAL_SMOKE_FIXTURE=turn-control-history MAKA_VISUAL_SMOKE_THEME=light \
MAKA_VISUAL_SMOKE_AUTO_CAPTURE=primary-light-1280 npm run dev
Repeat with turn-control-branch-visible and turn-control-branch-orphan.
Path 15 acceptance matrix (6 observable signals).
- S1 — failed banner copy. The
turn-failedrow in the primary session renders a destructive banner with the Chinese generalized phrase forerrorClass='timeout'("请求超时"). The raw enum identifier (timeout) MUST NOT appear in the rendered DOM. Sub-string check on screenshot DOM:not contains(/(timeout|auth|rate_limit|network|provider_unavailable|tool_failed|permission_required|unknown)/i). - S2 — aborted turn marker. The
turn-abortedrow shows a muted inline marker "(已中断)" beside the assistant text; partial output is preserved (the user can still read what was generated before abort). - S3 — lineage badges scroll.
- Forward (descendant top): on
turn-regen-newthe badge reads "重新生成自 turn turn-regen" and clicking it scrollsturn-regen-origininto the center of the viewport. - Reverse (origin footer): on
turn-regen-originthe badge reads "已重新生成 → turn turn-regen" and clicking it scrollsturn-regen-newinto the center of the viewport.
- Forward (descendant top): on
- S4 — branch banner positive vs negative.
- In
turn-control-branch-visible, the chat header shows分自 ${primary.name}. The banner is a clickable button that navigates to the primary session. - In
turn-control-branch-orphan, NO banner appears in the chat header — and there is no disabled/dead button placeholder either. DOM check:.maka-session-branch-bannermust not exist.
- In
- S5 — visual-smoke collapses smooth scroll. Lineage badge clicks
in any of the three variants use
scrollIntoView({ behavior: 'auto' })because the fixture setsdata-maka-visual-smoke="true"on<html>. Verified byscroll-motion-policy.test.ts(visual-smoke alone is sufficient — the reduced-motion attribute is not required). - S6 — no raw enum leak. Across all three variants, the rendered
DOM contains no occurrence of
timeout,auth,rate_limit,network,provider_unavailable,tool_failed,permission_required,unknownas raw substrings. The same gate applies toSessionBlockedReason(NO_REAL_CONNECTIONetc.).
Automated coverage backing the matrix.
- Helper tests:
session-status-presentation.test.ts— S1, S6 (Chinese-only, no raw enum)turn-footer-actions.test.ts— footer matrix per TurnStatusbranch-banner.test.ts— S4 (missing parent → undefined)scroll-motion-policy.test.ts— S5 (visual-smoke alone →auto)turn-control-matrix.test.ts— cross-cutting matrix gate
- Fixture seed tests in
visual-smoke-fixture.test.ts:- All three scenarios seed the same three sessions
- Primary session log carries the expected
turn_staterecords (retry, regenerate, aborted, failed witherrorClass='timeout') - The orphan parent is never written to disk
Original PR109c contract gates (still apply).
- Old turns are immutable after retry/regenerate; no old assistant output is overwritten.
- Branch from an aborted turn is allowed; the child session copies to the interrupted turn boundary. Current UI surfaces "从中断前" only in the branch action tooltip (PR109d); the session banner stays at "分自 ${parentName}" until parent-turn preloading lands so it never claims an aborted boundary without proof.
SessionChangedReasonincludesturn-status-changeso renderer reloads turn metadata without pretending this is a session lifecycle status change.
Path 17 — UI trust-boundary + Settings persistence contracts
Single landing page for the gate contracts behind nine UI / Settings
PRs that landed between 2026-05-23 → 2026-05-24 (final main HEAD
at consolidation time: 7a2b6eb). Each gate is intentionally short:
the contract invariants are what merge gate must enforce going
forward; implementation history lives in commit messages.
Doc convention used in every gate below:
- Contract invariant — 1–3 final-state bullets the gate enforces.
- Targeted tests — node:test files / case names that fail closed if the invariant breaks.
- Source-gate grep — patterns the merge reviewer actually runs.
- Deferred — only items still open for a future PR. Already- completed work isn't repeated here.
S1 — A3: tool output stream chokepoint
Contract invariant.
- Tool output chunks must run through
applyToolOutputChunkBEFORE reaching React state. Rawevent.textfromtool_output_deltais never appended directly tooutputChunks. - Secondary
redactSecretsalways fires at the renderer chokepoint; theredactedflag the upstream claimed never escalates downward. - Per-chunk + per-tool count + per-tool total-chars caps drop oldest
chunks; truncation is signalled via the
已截断pill on the tool item, never silent.
Targeted tests (apps/desktop/src/main/__tests__/tool-output-stream.test.ts):
Authorization: Bearer …/sk-…text → masked in stored chunk;redacted: trueregardless of upstream claim.- Single oversize chunk → tail-kept to
maxChunkCharswith marker. - 1000 small chunks → list capped at
maxChunks; oldest drops first. - Total-chars cap drops oldest until under budget.
- Dedup-by-seq + sort-by-seq still hold for out-of-order arrival.
Source-gate grep.
applyLiveTurnEventmust routetool_output_deltathroughapplyToolOutputChunk(base.outputChunks, chunk)before updating the matching tool insideLiveTurnProjection; no direct raw append shortcut.
S2 — C0: extended-thinking stream chokepoint
Contract invariant.
- Anthropic
ThinkingDeltaEvent/ThinkingCompleteEventtext only enters a live step throughapplyLiveTurnEvent→applyThinkingDelta/applyThinkingComplete; both helpers runredactSecretsBEFORE state and enforce per-delta + per-step caps. LiveTurnStepProjection.thinking.truncatedis monotonic-OR for deltas (sticks once true) and replace-on-complete (matches the source-of-truth semantics ofthinking_complete).- Terminal events complete the thinking slot through
terminalizeLiveSteps; persisted-evidence reconciliation removes the transient only after history covers it.
Targeted tests (apps/desktop/src/main/__tests__/thinking-stream.test.ts):
- Multi-MB single delta → tail-kept with head marker;
truncated: true. - Per-session accumulated over total cap → tail-kept (latest reasoning
preserved, oldest dropped);
truncated: true. - Secret embedded mid-delta → redacted before state;
redacted: true. thinking_completereplaces (does not append) the buffer.
Source-gate grep.
applyLiveTurnEventmust call the pure thinking helpers; no directstep.thinking.text + event.textappend shortcut.
S3 — C1: smoother prefix-leak gate
Contract invariant.
- The smoother (
useSmoothStreamContent) typewriters PREFIXES of its input; every prefix it sees must already be secret-free. prepareSmoothStreamText(raw)(which runsredactSecretson the FULL raw text) is called at every smoother callsite — both the streaming assistant bubble and the reasoning panel — BEFORE passing text to the smoother hook.
Targeted tests (apps/desktop/src/main/__tests__/smooth-stream.test.ts):
- Raw text containing
Authorization: Bearer sk-…is masked byprepareSmoothStreamTextbefore reaching the smoother; the intermediate prefixAuthorization: Bearer scannot reach the DOM unmasked. - Existing smoother grapheme / EMA / continuous catch-up behavior remains unchanged on already-safe text.
Source-gate grep.
- Every
useSmoothStreamContent(...)call must be wrapped inprepareSmoothStreamText(...)(or call something that does).
S4 — C2: maka:// internal markdown URI router
Contract invariant.
parseMakaUri(href)is strict (lowercasemaka:only) and closed-world: onlymaka://settings/<SettingsSection>andmaka://compose?text=...resolve to typedMakaUriDest. Any other shape returnsnulland surfaces as an inline broken-link span with a typeddata-reason.isMakaUriCandidate(href)is case-insensitive (/^maka:/i) — it exists so case-variants (Maka://settings/account) route to the broken-link inline error and never fall through to<a target=_blank>/openExternal.- External links go through
isSafeExternalScheme(href), which parses vianew URL(...)and allows ONLYhttp:/https:/mailto:.javascript:/data:/file:/vbscript:/ unknown schemes all fail closed to the broken-link span.
Targeted tests (apps/desktop/src/main/__tests__/maka-uri.test.ts,
41 cases total — 29 from PR-UI-RENDER-2 + 12 from C2 fixup):
- Lowercase
maka://settings/account→ typed destination. Maka://settings/account(case-variant) →parseMakaUrireturnsnullbutisMakaUriCandidatereturnstrue.MAKA://settings/account→ same as above.javascript:alert(1),data:text/html,…,file:///etc/passwd,vbscript:msgbox→isSafeExternalSchemereturnsfalse.mailto:user@example.com→isSafeExternalSchemereturnstrueviaURL().protocol, not naive prefix match.maka-info://...(similar prefix) → not a candidate.
Source-gate grep.
MarkdownLinkrenderer dispatches viaisMakaUriCandidate(href)→parseMakaUri(href)→ internal button OR broken span; external branch only renders<a target=_blank>whenisSafeExternalScheme(href)is true.
S5 — C3: artifact preview registry (PR-RENDER-3a)
Contract invariant.
- Pure
resolvePreviewKind(input: ArtifactPreviewInput)is the L1 classifier (kind gate + MIME allowlist + ext fallback + size cap pre-load). Input is narrow — never the fullArtifactRecord— so the registry cannot seerelativePathor any path-like data. - L2 post-load decision goes through
decideImageReadOutcome→decideImagePostLoad. Cap is enforced viaIMAGE_PAYLOAD_MAX_BASE64_LENGTHstring-length compare. Noatobdecode to check the cap. <img src="data:<mime>;base64,...">is built fromsafeMime(the main-process sniffed MIME re-validated throughnormalizeAllowedImageMime), never from the metadata MIME the resolver consulted.- The renderer hook state is
ImagePreviewLoadState—{loading} | {image, safeMime, base64} | {unsupported, reason}— closed union,unsupportedbranch carries NO base64. The L2 decision runs INSIDE the async, BEFOREsetState; rawArtifactBinaryReadResultnever lives in React state. - IPC failure routes to
reason: 'read_failed'with a distinct "加载预览失败" copy, never collapsed into'kind_disallowed'.
Targeted tests (apps/desktop/src/main/__tests__/artifact-preview-registry.test.ts):
- L1 kind gate:
file/diff/html/pdf→unsupported(kind_disallowed). - L1 MIME match:
image/png/image/jpeg/image/gif/image/webp/image/avifaccepted; SVG / HEIC rejected. - L1 ext fallback when MIME missing.
- L2 cross-layer: metadata
image/png+ sniffedimage/svg+xml→unsupported(mime_disallowed). - L2 oversize takes precedence over MIME.
- IPC failure (all 6
ArtifactBinaryReadFailureReasonvariants) →unsupported(read_failed). - Oversize / mime_disallowed outcome → NO
base64property in the outcome (kenji-gate runtime assert:outcome.base64 === undefined).
Visual-smoke fixtures (path-leak guard, visual-smoke-fixture.test.ts):
artifact-preview-image— real 1×1 transparent PNG (67 B) →image(mime_match).artifact-preview-unsupported—kind=image+mimeType=image/heic→ L1unsupported(mime_disallowed);readBinaryis never called.artifact-preview-oversize—sizeBytesOverride=3MB+skipFile→ L1unsupported(oversize); file is never written to disk.- All three:
relativePathMUST start withvisual-smoke-artifact/, MUST NOT start with/; metadata.jsonl MUST NOT contain/Users/or/private/.
Source-gate grep.
RegistryArtifactPreviewdispatch must useresolvePreviewKind;ImageArtifactPreviewhook state must beImagePreviewLoadState, notArtifactBinaryReadResult.
Deferred.
- SVG (PR-RENDER-3b): needs sandboxed
<iframe srcDoc>+ CSP + threat-model doc. - HTML (PR-RENDER-3c): same.
- Mermaid (PR-RENDER-3d): runtime evaluator threat surface.
- Screenshot baselines: capture is environment-sensitive; lock baselines from a clean CI run before declaring 3a-smoke done.
S6 — Cx: assistant streaming chokepoint
Contract invariant.
text_deltarawevent.textflows throughapplyAssistantDeltaas input only; the helper is the SINGLE sink.setStatenever receives raw text.- The helper pipeline runs per-delta redact → per-delta cap →
append → cross-delta redact → total cap. The cross-delta
redaction on the freshly-appended candidate is load-bearing —
it catches tokens that span delta seams (e.g.
"Bearer sk-"in delta N +"abcdef…"in delta N+1) that per-delta redaction cannot see. - Per-session total cap is head-keep with a trailing marker (assistant text is read top-down). Once buffer ends with the trailing marker AND is at the total cap, subsequent deltas are dropped entirely.
- Renderer state is one
LiveTurnProjectionper session. Eachtext_deltaruns through ONE functional updater and updatesLiveTurnStepProjection.text; no parallel text/truncation maps or outer-closure mutation between multiplesetStatecalls. - Terminal events complete the text slot in place. The smoother owns the final text until its visible backlog drains; persisted-evidence reconciliation then removes the transient step.
Targeted tests (apps/desktop/src/main/__tests__/assistant-stream.test.ts,
25 cases):
- Cross-delta long-opaque token split at the seam (24 hex + 22 hex = 46 hex) → L4 post-append catches it; per-delta L1 does not (verifies the gate is genuinely cross-delta, not per-delta).
- Char-by-char streaming of a 35-char
sk-…token across 50+ deltas → final accumulated state contains no raw token. - Prev
"Token: sk-"+ delta"abcdef…"→ caught by L4. - Oversize delta + embedded secret near start or tail-keep area → no raw secret survives.
- Once buffer at total cap with trailing marker, subsequent deltas
short-circuit (drop entirely;
truncated: truestill propagates). - Non-string raw delta → prev unchanged, no claimed redaction.
Source-gate grep.
applyLiveTurnEventmust callapplyAssistantDelta(step.text?.text ?? '', event.text); no direct append.- Renderer session UI state must carry one
Record<string, LiveTurnProjection>; no parallel streaming text or truncation maps.
S7 — B2: unavailable OAuth subscription providers stay out of the model catalog
Contract invariant.
<ProvidersPanel>only lists provider types that are configurable now: API key, local, and custom OpenAI-compatible endpoints.claude-subscription,codex-subscription, andgemini-clistay in the core provider type/default registry for account-state and future migration paths, but they are not present inCATALOG_PROVIDER_TYPESuntil their send path is open.- The provider catalog does not render an empty OAuth tab and does not advertise future account-login work as a model-provider affordance.
Targeted gate.
CATALOG_PROVIDER_TYPESexcludes the three subscription provider types.ProvidersPanel.tsxhas no OAuth catalog tab and no header copy telling users that subscription login is coming later.
Source-gate grep.
ProvidersPanel.tsx: no{ id: 'oauth' }.packages/core/src/llm-connections.ts: no subscription provider literal inside theCATALOG_PROVIDER_TYPESarray body.
Deferred.
- Codex/Gemini account paths remain deferred. Claude subscription still requires product/legal sign-off and a real send-path smoke before it can become a ready model provider.
S8 — Provider polish: file-wide disabled-actionable guard
Contract invariant.
- Every interactive selector on
.providerCatalogCardinapps/desktop/src/renderer/styles.cssmust be scoped to:not([data-disabled="true"]). Disabled provider tiles never receive actionable affordance (lift, accent halo, shadow, translate, accent border). - The disabled focus-visible affordance is muted (dashed
--foreground-40outline, no accent), keyboard-discoverable but reads as "currently not actionable". [data-disabled="true"]always hascursor: not-allowed.
Source-gate grep (file-wide on styles.css, not just changed
lines):
.providerCatalogCard:hover,.providerCatalogCard:focus-visible,.providerCatalogCard:active— every actionable variant must carry the:not([data-disabled="true"])guard..providerCatalogCard[data-disabled="true"]:focus-visiblemust emit only muted outline; notransform, no accent halo, no box-shadow..providerCatalogCard[data-status="unavailable"]:hoverreset is orthogonal and may remain (covers a different attribute).
S9 — Tool/Artifact polish: design-token radius alignment + collapse-toggle a11y
Contract invariant.
- Tool card / diff / terminal radius family unified at 10 px (chip 10 / card 12 / panel 14 / modal 18 design-system rhythm).
- ArtifactPane collapse toggle has a discoverable
:focus-visibleaffordance (3 px accent halo); icon button size lands at 24×24 for comfortable hit target. - No new collapse-affordance semantics; toggle behavior unchanged.
Source-gate grep.
.maka-tool,.maka-tool-diff,.maka-tool-terminal— radius 10 px (was 8 px)..maka-artifact-pane-collapse:focus-visibleselector present with accent ring tokens.
S10 — D1: theme palette settings contract
Contract invariant.
THEME_PALETTESis a closedas constallowlist of 11 string literals (default/onedark/catppuccin-mocha/tokyo-night/nord/coral/azure/forest/dusk/sand/mono);ThemePalette = typeof THEME_PALETTES[number].isThemePalette(value)is the type guard. Case-sensitive, rejects everything else (undefined / non-string / unknown string / case-variant).normalizeSettings()fallsappearance.paletteclosed to'default'on any miss (missing / unknown / non-string). The same normalize pass must NOT silently reset unrelated fields (theme / palette ↔ toastPosition / personalization / network).createDefaultSettings()seedsappearance.palette: 'default'.- Renderer path:
applyThemePalette(palette)writes<html data-maka-theme="<palette>">;'default'removes the attribute. CSS variable overrides live inmaka-tokens.cssunder static[data-maka-theme="..."]selectors.
Targeted tests (packages/core/src/__tests__/settings.test.ts,
10 cases under theme palette settings contract):
- Allowlist shape, type guard accept/reject (incl. case-variants), default seed, missing-field migration, unknown-string fail-closed, non-string fail-closed, valid value survives, no-silent-reset, patch surface round-trip, malformed-patch + normalize round-trip.
Source-gate grep.
THEME_PALETTES/isThemePalette/ singleappearance:block innormalizeSettingsvalidating BOTH palette and toastPosition.- Renderer:
applyThemePaletteconsumed only from normalized settings; no rawlocalStorage.getItem('maka-theme-palette-v1')read path today.
Deferred / future-contract lock.
- If a pre-React palette read is added later (FOUC prevention),
it MUST reuse the
THEME_PALETTESallowlist viaisThemePalette(); unknown values fall through to default / remove the attribute. Raw localStorage string MUST NOT be written directly todata-maka-theme.
S11 — D2: toast position settings contract
Contract invariant.
TOAST_POSITIONSis a closedas constallowlist of 6 grid corners (top-left/top-center/top-right/bottom-left/bottom-center/bottom-right);ToastPosition = typeof TOAST_POSITIONS[number].isToastPosition(value)is the type guard. Case-sensitive, closed-enum rejection.normalizeSettings()fallsappearance.toastPositionclosed to'bottom-right'on any miss. The singleappearance:block in the normalize return validates bothpalette(D1) andtoastPosition(D2) without resetting unrelated fields.createDefaultSettings()seedsappearance.toastPosition: 'bottom-right'(matches the v1 hardcoded behavior).- Source-of-truth for live UI is
App.toastPosition(React state), threaded throughAppShell → SettingsModal → SettingsSurface → SettingsPage → ThemeSettingsPage.ToastProvider position={...}reads it;ToastViewport data-position={position}is React- driven. NoquerySelector/ DOM mutation from anywhere. localStorage('maka-toast-position-v1')is a pre-React boot mirror. It is ONLY written from:AppShellsettings-load: the normalized value just read from disk;ThemeSettingsPagepicker click: the normalized server return from a SUCCESSFULonUpdate(...). OnonUpdatefailure, the mirror is NOT touched.
- Pre-React
readPersistedToastPosition()reads the mirror throughisToastPosition(); out-of-band raw strings can never reachdata-position.
Targeted tests (packages/core/src/__tests__/settings.test.ts,
11 cases under toast position settings contract + 1 cross-
contract case):
- Allowlist shape (6 corners), type guard accept/reject incl. case-
variants and synonyms, default seed
bottom-right, missing-field migration, unknown-string fail-closed, non-string fail-closed, valid value survives, no-silent-reset, patch surface round-trip, malformed-patch + normalize round-trip, D1 + D2 cross-contract independence (both malformed → each falls back to its own default, no interference).
Source-gate grep.
TOAST_POSITIONS/isToastPosition/ mergedappearance:normalize block (single block for both D1 + D2).localStorage.getItem('maka-toast-position-v1')→ only flows throughisToastPosition().localStorage.setItem('maka-toast-position-v1', …)writes only settings-load normalized value or post-onUpdate-success normalized return.- Active code must contain NO
querySelector('.maka-toast-viewport')/dataset.positionmutation paths.
Residual / non-blocking.
setToastPositionkeeps optimistic React state ifonUpdate(...)throws; matches existing theme/palette semantics. A stricter catch-revert path can land in a follow-up if needed; not gated by D2.
Path 18 — Computer Use overlay threat model (PR-UI-CU-0)
Status: contract-only. No Maka Computer Use implementation exists yet. This path locks the gate criteria a future PR-UI-CU-1 (overlay implementation) and PR-RUNTIME-CU (action runner) MUST satisfy before merge. The threat model is captured here so the implementation cannot be reviewed against an aspirational verbal description; the gate is what reviewers grep.
Reference: a separate signed helper bundle (LSUIElement, AX +
ScreenCaptureKit + CGEvent, NDJSON over a Unix socket) is the
architectural prior art that informs these boundaries. Maka's
eventual implementation does not have to mirror the same wire
shape, but each contract below applies regardless of whether the
runner lives in a helper process or inline in the main process.
Doc convention is the same as Path 17:
- Contract invariant — 1-3 final-state bullets the gate will enforce
- Source-gate grep — patterns the merge reviewer will run when PR-UI-CU-1 lands; today these all fail-closed by absence
- Deferred — items intentionally left to PR-UI-CU-1 or PR-RUNTIME-CU
S12 — Permission source: TCC-only, never claimed by the renderer
Contract invariant.
- Computer Use permission ALWAYS comes from macOS TCC
(
com.apple.tcc→kTCCServiceAccessibility+kTCCServiceScreenCapture). The renderer NEVER asserts CU permission; it only displays a state derived from a main-process IPC that queried TCC. - A CU action MUST NOT be initiated unless both TCC permissions are granted at the moment of the action; cached "previously granted" state is insufficient because user can revoke at any time via System Settings → Privacy & Security.
- The renderer's CU affordance (button enabled / disabled / first- run setup) reflects this live TCC status; no path lets the renderer fake a granted state.
Source-gate grep.
- Renderer never calls TCC-affecting APIs directly. Search for
ApplicationServices/kTCCService/AXIsProcessTrusted— must only appear in main process (or a separately-signed helper) source, never underapps/desktop/src/renderer/. - Renderer CU state derives from a typed IPC result; no path builds the "permission granted" state from a renderer-local boolean.
Deferred.
- Per-action TCC verification (a CU action that begins must re-check TCC at action-start) — wired in PR-RUNTIME-CU.
- TCC prompt UX (when permission missing, surface a typed
MissingPermissionStatefrom the runtime; the renderer shows a "Open System Settings → ..." affordance viaapp:openExternalwith the canonical TCC URL pre-allowlisted in the openExternal guard) — wired in PR-UI-CU-1.
S13 — Overlay lifecycle: action-scoped, never persistent
Contract invariant.
- The CU overlay (the highlight ring / target box / cursor halo that shows where Maka is about to click) exists ONLY during an in-flight CU action. It is mounted on action-begin and unmounted on action-end. No idle / "ambient" overlay state.
- Overlay teardown MUST fire on every action-terminating event:
tool_complete/tool_error/ abort / runtime crash / user closes Maka / permission revoked mid-action. - No overlay state survives across sessions, across LLM turns, or across renderer reloads. A renderer mount with no in-flight CU action MUST NOT render the overlay component at all.
Source-gate grep.
<ComputerUseOverlay>(or equivalent) renderer mount must be gated by a per-session in-flight CU action — likely aliveTurnBySession[sessionId].steps[].toolsentry whose tool name matches a known CU verb. No&& true/&& isDevoverrides.- Teardown must be wired in the same lifecycle bag as
clearTurnTransientState(sessionId)(or an equivalent dedicated action-end cleanup) in abort/error/complete branches. - No
setTimeout/setIntervalkeeps the overlay alive past the action; teardown is event-driven only.
Deferred.
- Reduced-motion: overlay animations respect
prefers-reduced-motion(samedata-maka-reduced-motionchannel Path 17 S1-S11 use) — wired in PR-UI-CU-1.
S14 — Focus + click pass-through: overlay must never steal input
Contract invariant.
- The overlay window MUST call Electron's
BrowserWindow.setIgnoreMouseEvents(true, { forward: true })(or equivalent for the chosen overlay mechanism) AND construct withfocusable: false. It is purely a visual affordance; the underlying app is what receives clicks / keystrokes from the CU runtime via the action runner'sCGEventPostToPid/ accessibility dispatch path. - Overlay never becomes a
<button>/<a>/ tab-order target / ARIA-interactive element in the renderer. The user CANNOT interact with the overlay itself; their input lands on the target app (or, if Maka window is foreground, on Maka). - Overlay
pointer-events: none(CSS) is the renderer-side mirror of the BrowserWindowsetIgnoreMouseEvents(true)constraint. Both must hold; the BrowserWindow setting is load-bearing because CSS alone doesn't stop the OS-level window from grabbing focus on click.
Source-gate grep.
<ComputerUseOverlay>root element carriespointer-events: none(CSS class or inline style) and NEVER mounts inside a<button>/<a>/ role-button context.- The overlay BrowserWindow (in the main process) is configured
with
focusable: false, and the runtime callssetIgnoreMouseEvents(true, { forward: true })on it before showing. Grep main process: anynew BrowserWindow({ ... })flagged as the CU overlay window must satisfy both invariants. Nofocusable: true; no missingsetIgnoreMouseEvents. - No keyboard event handler attaches to the overlay
(
addEventListener('keydown'/onKeyDown).
Deferred.
- Multi-monitor placement: overlay placement on the active screen the action targets — wired in PR-RUNTIME-CU.
S15 — Coordinate authority + screenshot binary display path
Contract invariant.
- Coordinate authority: ONLY the runtime (main process or signed
helper) decides where to click / type / scroll. The renderer
NEVER initiates a raw
{x, y}click. The renderer's role is display-only: it shows the runtime's planned action as overlay, and surfaces user abort. - Screenshot pixels (binary
Uint8Array/ PNG / JPEG) MUST flow through the artifact preview registry (S5) — sniffed MIME throughnormalizeAllowedImageMimeallowlist, base64 length cap viaIMAGE_PAYLOAD_MAX_BASE64_LENGTH(noatobdecode), oversize →unsupported(oversize)with a Finder-open affordance instead of inline base64. Pixels MUST NOT travel any "fast path" that skips this gate. - Text-shaped data captured ALONGSIDE a screenshot — window title,
app bundle id, focused element label, URL bar contents — IS
text and IS subject to S16's
redactSecretschokepoint before reaching React state, the artifact pane, or session log. redactSecretsis text-only and CANNOT clean screenshot pixels. Removing sensitive pixels happens upstream (per-app sensitivity blocks, OS-level screen-capture exclusion, per-frame masking the runtime applies before delivery) — never as a renderer-side pass over a base64 string.- The metadata MIME the runtime claims (e.g.
image/png) is untrusted; the renderer MUST build the final<img src="data:…">attribute from main-process sniffed MIME re-validated throughnormalizeAllowedImageMime(same gate as S5).
Source-gate grep.
- Renderer never imports a coordinate-click IPC and never calls
window.maka.computerUse.click({ x, y })from user input handlers. The only caller path is the LLM-driventool_use(computer-use:*)event flow that arrives viasession.subscribeEvents. - CU screenshot delivery into the artifact pane goes through
the artifact preview registry (S5) for the image component;
text metadata around it goes through
applyToolOutputChunk(S1). No separate "fast path" that bypasses either gate. redactSecrets(screenshot.base64)MUST NOT appear anywhere (text helper applied to binary is a sign the boundary is misunderstood).- Look for
data:image/...;base64,strings in renderer code — these may ONLY come fromsafeMime + base64post thedecideImageReadOutcomechokepoint.
Deferred.
- Per-app sensitivity policy: per-bundle-id denylist (1Password, banking apps, password fields detected via AX tree) where the runtime drops the frame BEFORE upload, BEFORE persistence, and BEFORE display — wired in PR-RUNTIME-CU.
S15b — Provider exposure boundary: screen content sent to LLM
Contract invariant.
- macOS TCC permission to capture the screen ≠ user consent to upload that capture to a remote LLM provider. The two are separate gates; the user-facing connection setup that grants CU access to a provider must surface "screenshot uploads will reach this provider" explicitly. (Renderer copy beyond this gate's scope; the gate locks the runtime side.)
- Any screenshot frame the runtime sends to an LLM/provider call
MUST be wrapped in a typed
ComputerUseScreenFrame(or equivalent) carrying:- the
actionIdthat scoped the capture (so the frame belongs to ONE in-flight action; cross-action reuse is invalid), - the source kind (
'live-capture' | 'cached-still') so the review path can distinguish a fresh frame from a stale one, - a max-size invariant matching the artifact preview cap
(
IMAGE_PAYLOAD_MAX_BYTES= 2 MB; oversize → sensitivity block, not silent downscale-and-upload).
- the
- A screenshot frame MUST NOT be persisted raw to the session log. The session log records the action's outcome + a redacted text summary; the raw frame is held in main-process memory for the duration of the action and discarded on action end. If the user saves it explicitly via the artifact pane "Save As" affordance, that's a separate write path the user opts into.
- The provider route is explicit: if a provider does not
support image input, OR if the user's account-level vision
toggle is off, the CU action returns
error: 'sensitivity_blocked'withreason: 'no_vision_route'. There is NO silent fallback that converts a planned vision call into a text-only call (which would mean the screenshot was silently dropped and the LLM made decisions without seeing it). sensitivity_blocked(the closed error from S17) applies BEFORE provider upload, not only before renderer display. A frame the runtime won't render is also a frame the runtime won't upload.
Source-gate grep.
- Provider client call sites that accept
imageContent/imageUrl/base64Imageparameters: every such call site must consume aComputerUseScreenFrame(or equivalent typed shape), never a rawstring/Uint8Arraydirectly from a capture call. appendToSessionLog(or whatever the writer is) must reject raw screenshot payloads. Look for any path that writeskind: 'image'content WITHbase64data into a session log record — that's a contract violation; the log should hold a reference to the artifact id, never the inline frame.- "no_vision_route" sensitivity-block path must exist in every
provider client that supports vision. Look for capability
checks like
if (provider.supportsVision) { … } else { … return error('sensitivity_blocked', 'no_vision_route') }. No silent fallback to text-only.
Deferred.
- Per-provider differential consent: separate the "I configured this provider" act from the "I let CU upload screenshots to this provider" act. UX surface for the second consent — wired in PR-UI-CU-1.
- Frame retention metering: how many frames the runtime is allowed to hold in memory across a single LLM turn (a long CU plan = many captures). Default and per-provider limit — wired in PR-RUNTIME-CU.
S16 — Screen-derived text redaction: runtime-side, before LLM sees it
Contract invariant.
- Any text Maka extracts from a screen — OCR output, AX tree dump,
selected-text query result, clipboard sample, window title, app
bundle id, focused element label, URL bar contents — MUST run
through the runtime's
redactSecrets(the@maka/runtimere-export of the same helper the renderer uses) BEFORE it lands in the LLM's tool result message, BEFORE it lands in the session log, and BEFORE it lands in the artifact pane. - Scope clarification (refer to S15 / S15b for the non-text path):
this gate is text-only. Screenshot pixels are not text and
flow through the artifact preview registry (S15) for display and
the
ComputerUseScreenFrameprovider boundary (S15b) for LLM exposure. ApplyingredactSecretsto a base64 image string is a category error — pixels can carry sensitive contentredactSecretscannot see. - The runtime-side redaction on screen-derived text is the
SOURCE-OF-TRUTH gate. The renderer's
applyToolOutputChunksecondary redaction (S1) is defense-in-depth, not the primary boundary — CU screen-state can contain credentials the model would never intentionally emit but a capture-without-redaction would leak.
Source-gate grep.
- Every CU action handler in
@maka/runtimethat returnsToolResultContentcontaining screen-derived TEXT must callredactSecrets(value)on that text before constructing the result. NoJSON.stringify(rawScreenState)straight into a result block. - Session log writer must call redact before
appendToLog; look for any path that bypasses the existing log redaction wrap. redactSecrets(screenshot.base64)MUST NOT appear (S15 grep also locks this; both gates flag it).
Deferred.
- Per-app redaction policies (e.g. 1Password / browser password field detection → drop entirely rather than mask) — beyond initial scope; locked here only as a known gap to revisit. Related: per-app pixel-side denylist is the S15 deferred item; these two policies should land in the same runtime PR.
S17 — Fail-closed: every gate failure aborts the action, never silently continues
Contract invariant.
- TCC permission missing at action-start → action returns
ToolResultContentwitherror: 'permission_missing'. No retry; no "best-effort" partial click. - Overlay setup failure (window creation rejected, monitor not
available, focus-pass-through assertion failed) → action
returns
error: 'overlay_failed'. The action does NOT proceed without the overlay — the user's safety affordance MUST be visible before any click lands. - Coordinate validation failure (target coordinate outside any
screen bounds, NaN, negative, > screen width/height) → action
returns
error: 'invalid_coordinate'. No clamp / no snap-to- edge; bad input means abort. - Screenshot capture failure → action returns
error: 'capture_failed'. The runtime does NOT fabricate a blank screenshot to satisfy the LLM contract; the LLM sees the error and decides whether to retry. - Sensitivity check failure (e.g. the target window is a known
password field, or the OS is in fast-user-switching mid-state)
→ action returns
error: 'sensitivity_blocked'. No bypass.
Source-gate grep.
- Every
catchinside a CU action handler converts to a typed error in theToolResultContent. Look for swallowedcatchblocks (catch (e) {}orcatch { return … defaultState }) — these MUST NOT exist in the CU path. - No
try / catchreturns "success-shaped" content with a soft error string; the result is eitherkind: 'tool_result'witherrorset OR a true success. No middle ground. - Closed error enum:
permission_missing/overlay_failed/invalid_coordinate/capture_failed/sensitivity_blocked/aborted/timeout. Adding a new error mode is a type-surgery change AND a smoke.md S17 update.
Deferred.
- Per-action timeout policy: each CU verb has a max-wall-time;
blow past it →
error: 'timeout'. Default and per-verb override table — wired in PR-RUNTIME-CU.
S18 — Abort semantics: <100ms teardown, no orphan clicks
Contract invariant.
- User-initiated abort (Esc, "Cancel" button in chat, close Maka,
switch session) MUST tear down the in-flight CU action within
100 ms. By "tear down" we mean:
- Overlay window destroyed
- Pending coordinate dispatch cancelled (the next planned
CGEventPostMUST NOT fire after the abort signal lands) - Runtime tool state marked
aborted(status enum), result block returned witherror: 'aborted' - All ephemeral state (target coordinate, planned action, screenshot in flight) cleared from main-process memory
- An action that ABORTS mid-stream (after a click landed, before
the planned next click) MUST report
error: 'aborted'AND must include in its result block the count of completed sub-steps, so the LLM knows how much of a multi-step plan was actually performed. No silently-completed partial sequences. - Permission revocation detected during action (e.g. user toggled
off Accessibility in System Settings) is a special case of
abort: same
<100msteardown,error: 'permission_missing'(not'aborted', because the cause differs from user intent).
Source-gate grep.
AbortSignal(or equivalent cancellation token) is threaded through every CU action handler — no handler is "fire and forget" without an abort hook.- Renderer abort affordance (Esc in chat, Cancel button) wires
through to
window.maka.computerUse.abort(actionId). No fake-shaped local UI revert that pretends to abort without reaching the runtime. - Permission-revoked detection: runtime checks TCC at every
action step; if revocation observed mid-action, raises the
abort signal internally with the
'permission_missing'error.
Deferred.
- Per-step granularity: long CU sequences ("open browser, search, click first result") can be a single LLM tool call. Abort granularity at sub-step level (vs whole-sequence) — wired in PR-RUNTIME-CU.
Cross-cutting notes (not gates, but record for future PR-UI-CU-1 / PR-RUNTIME-CU reviewers).
- Maka's eventual CU implementation does NOT have to use a separate signed helper bundle. Inline-in-main-process is also acceptable, provided ALL the boundaries above hold. The main- process Electron context is already a trust boundary vs the renderer; an additional process boundary is defense-in-depth, not a contract requirement.
- The Maka workspace's existing IPC allowlist patterns
(
app:openExternal,app:openPath, etc.) are the model: each surface is a named, typed IPC channel with input validation in main process. CU should follow the same shape. - The
redactSecretshelper currently lives in@maka/ui. A CU threat model implementation will need a runtime-side import path (already exists at@maka/runtimere-export); the renderer defense-in-depth in@maka/uistays unchanged. - This Path 18 is contract-only. PR-UI-CU-1 (overlay implementation) and PR-RUNTIME-CU (action runner) will land each S12-S18 gate's targeted tests + source-grep CI hooks; until then, the gates fail-closed by absence (no CU code exists).
When to run
- Before merging any large UI / runtime / credential / permission change to main.
- After any change that touches
LlmConnection,sessions:changedpayload shape,ConnectionUiStatusderivation,TurnViewModel,nextRadioId, or PermissionDialog rendering. - Before tagging a release.
Each path is < 1 minute. The full path run is ~ 11–13 minutes. Worth doing.