diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b264b07b53..d550459e0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -223,90 +223,36 @@ jobs: require_lane "Runtime Host tests" "$RUNTIME_HOST_SELECTED" "$RUNTIME_HOST_RESULT" require_lane "Headless tests" "$HEADLESS_SELECTED" "$HEADLESS_RESULT" - e2e_shard: + # One Electron job: install once, run the (now small) e2e suite, then the + # CDP alignment audit against the same built renderer. Dual shards plus a + # separate audit job each paid full npm ci + cold build after the suite + # slash; that was pure wall-clock and runner waste. + e2e: needs: changes if: needs.changes.outputs.e2e == 'true' - name: e2e_shard (${{ matrix.shard }}/2) runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - shard: [1, 2] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' cache: npm - - run: npm ci - # Each shard keeps one Playwright worker on its own runner and X display. - # This overlaps independent Electron cold starts without reintroducing - # the OS-focus races observed with multiple workers on one display. - - name: Ensure xvfb - run: command -v xvfb-run >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y xvfb; } - - name: E2E shard ${{ matrix.shard }}/2 - run: xvfb-run -a npm --workspace @maka/desktop run e2e -- --shard=${{ matrix.shard }}/2 - - # Design governance: the CDP alignment auditor walks the e2e-fixture fixtures - # and fails on same-type height mismatches, mixed-type centerline drift, or - # radius-family splits. It shares the e2e selection gate and the built - # renderer, but not the suite's critical path: it has no data dependency on - # Playwright, and riding shard 1 made that shard ~51s longer than shard 2 for - # work the shard did not need. Its own runner pays one extra checkout and - # build in exchange for balanced shards. - alignment_audit: - needs: changes - if: needs.changes.outputs.e2e == 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - name: Restore Electron artifact cache + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: - node-version: '24' - cache: npm + path: ~/.cache/electron + key: electron-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + restore-keys: electron-${{ runner.os }}- - run: npm ci - name: Ensure xvfb run: command -v xvfb-run >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y xvfb; } - - name: Build renderer - run: npm --workspace @maka/desktop run build:with-deps + - name: Desktop e2e + run: xvfb-run -a npm --workspace @maka/desktop run e2e - name: Alignment audit run: xvfb-run -a node scripts/audit-alignment.mjs - - # Preserve the existing required check while every selected shard retains - # its own failure and diagnostic output. The audit lane joins the same gate - # so splitting it out does not drop it from the required check. - e2e: - needs: [changes, e2e_shard, alignment_audit] - if: always() - runs-on: ubuntu-latest - steps: - - name: Require successful E2E shards - env: - CHANGES_RESULT: ${{ needs.changes.result }} - E2E_SELECTED: ${{ needs.changes.outputs.e2e }} - SHARDS_RESULT: ${{ needs.e2e_shard.result }} - AUDIT_RESULT: ${{ needs.alignment_audit.result }} - run: | - if [[ "$CHANGES_RESULT" != "success" ]]; then - echo "changes failed: $CHANGES_RESULT" >&2 - exit 1 - fi - expected="skipped" - if [[ "$E2E_SELECTED" == "true" ]]; then - expected="success" - fi - if [[ "$SHARDS_RESULT" != "$expected" ]]; then - echo "E2E shards expected $expected but were $SHARDS_RESULT" >&2 - exit 1 - fi - if [[ "$AUDIT_RESULT" != "$expected" ]]; then - echo "Alignment audit expected $expected but was $AUDIT_RESULT" >&2 - exit 1 - fi - - # Storybook build + render smoke. Independent of Electron e2e: the smoke - # launches Playwright Chromium, not `_electron.launch`, so it does not - # need xvfb or the desktop cold-start suite. See stories/FIDELITY.md. + # Storybook build + initial render smoke. Embedded mode disables every play + # function, so this lane never duplicates desktop interaction or layout E2E. + # It launches Chromium only to catch catalog runtime errors. See FIDELITY.md. storybook: needs: changes if: needs.changes.outputs.storybook == 'true' diff --git a/.github/workflows/windows-baseline.yml b/.github/workflows/windows-baseline.yml index a530f0e114..f2e623272a 100644 --- a/.github/workflows/windows-baseline.yml +++ b/.github/workflows/windows-baseline.yml @@ -15,7 +15,31 @@ permissions: contents: read jobs: + changes: + runs-on: ubuntu-latest + outputs: + windows: ${{ steps.plan.outputs.windows }} + windows_runtime: ${{ steps.plan.outputs.windows_runtime }} + windows_storage: ${{ steps.plan.outputs.windows_storage }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - id: plan + name: Select affected Windows surfaces + env: + BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event_name == 'push' && github.sha || github.event.pull_request.head.sha }} + run: | + if [[ "$BASE_SHA" =~ ^0+$ ]] || ! git cat-file -e "${BASE_SHA}^{commit}"; then + node scripts/ci-test-plan.mjs --full >> "$GITHUB_OUTPUT" + else + node scripts/ci-test-plan.mjs --base "$BASE_SHA" --head "$HEAD_SHA" >> "$GITHUB_OUTPUT" + fi + windows_baseline: + needs: changes + if: needs.changes.outputs.windows == 'true' name: windows_baseline (non-blocking) runs-on: windows-latest timeout-minutes: 45 @@ -101,18 +125,22 @@ jobs: - id: smoke name: Run CLI and Electron startup smoke - if: always() && steps.install.outcome == 'success' + if: always() && steps.build.outcome == 'success' continue-on-error: true shell: pwsh run: | - npm.cmd run smoke:windows *> "$env:WINDOWS_BASELINE_LOG_DIR/smoke.log" + npm.cmd --workspace @maka/desktop run build:smoke *> "$env:WINDOWS_BASELINE_LOG_DIR/smoke-build.log" + $exitCode = $LASTEXITCODE + Get-Content "$env:WINDOWS_BASELINE_LOG_DIR/smoke-build.log" + if ($exitCode -ne 0) { exit $exitCode } + npm.cmd run smoke:windows:dist *> "$env:WINDOWS_BASELINE_LOG_DIR/smoke.log" $exitCode = $LASTEXITCODE Get-Content "$env:WINDOWS_BASELINE_LOG_DIR/smoke.log" exit $exitCode - id: runtime_pty_input name: Run Runtime PTY input gates - if: always() && steps.build.outcome == 'success' + if: always() && steps.build.outcome == 'success' && needs.changes.outputs.windows_runtime == 'true' continue-on-error: true timeout-minutes: 5 shell: pwsh @@ -129,7 +157,7 @@ jobs: - id: storage name: Capture storage test baseline - if: always() && steps.build.outcome == 'success' + if: always() && steps.build.outcome == 'success' && needs.changes.outputs.windows_storage == 'true' continue-on-error: true shell: pwsh run: | @@ -140,7 +168,7 @@ jobs: - id: managed_workspace_crash name: Run managed workspace crash recovery gates - if: always() && steps.build.outcome == 'success' + if: always() && steps.build.outcome == 'success' && needs.changes.outputs.windows_storage == 'true' continue-on-error: true shell: pwsh run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a696af873..3a3de2ed0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,53 @@ ## Unreleased +## 0.1.9 - 2026-08-09 + +### Highlights + +- Completed the Runtime Host M5 production cutover (#2420), then established + the local standalone service (#2583), authenticated WebSocket access + (#2591), and Host-owned project catalog authority (#2603). +- Added external session import end to end: the shared foundation (#2500), a + Codex session adapter (#2502), and the Desktop import flow (#2507). +- Shipped Visual System 2.0 from its foundations through the full theme sweep + and detail polish (#2525, #2536, #2538), aligned high-traffic chrome with + Astryx primitives (#2580), and closed the remaining elevation, rhythm, and + primitive review debt (#2593). +- Made task submission readiness a shared product contract (#2498), exposed it + in Desktop (#2519), added CLI preflight (#2524), and consumed the readiness + result at submission time (#2523). +- Expanded implementation-agent capabilities with portable terminal input + (#2526), semantic terminal mouse input (#2533), interactive shell controls + (#2561), and OpenAI native ApplyPatch (#2532). +- Replaced the CodeMode `Self` evaluator with QuickJS (#2549). + +### Reliability and developer experience + +- Runtime recovery now handles idle and incomplete provider streams (#2535, + #2604), preserves compaction projection after overflow (#2602), retains + imported session context (#2579), and keeps full access available in Plan + mode (#2581). +- Stabilized Runtime Host session lifecycle races (#2548), added client request + backpressure (#2539), made task refusals actionable (#2527), recovered CLI + sessions after workspace moves (#2531), and kept renamed Claude model ids + stable across catalog refreshes (#2482). +- Matured the generated-files workbar (#2506), kept generated files under user + control (#2585), restored composer drafts across remounts (#2584), exposed + diagnostics from error toasts (#2540), and added per-connection model request + customization (#2565). +- Cut CI wall-clock time with impact gates and a single end-to-end job (#2589), + reduced Storybook to render smoke (#2582), and expanded Windows process, + named-pipe, artifact, workspace, and crash-recovery coverage. + +### Distribution + +- Ships for Apple Silicon macOS as a signed and notarized DMG and ZIP, and for + Windows x64 as an unsigned NSIS installer and ZIP, built and verified in the + same release run. +- The bundled Computer Use skill ships with the app, but the Computer Use + executor remains excluded from this release. + ## 0.1.8 - 2026-08-08 ### Highlights diff --git a/DESIGN.md b/DESIGN.md index 7a55006aa2..7eaceec028 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -41,11 +41,9 @@ typography: code: { fontSize: "14px", fontWeight: 400, lineHeight: 1.4286 } badge-label: { fontSize: "12px", fontWeight: 500, lineHeight: 1.6667 } rounded: - inner: "4px" control: "6px" card: "10px" container: "12px" - page: "16px" pill: "999px" spacing: { space-0-5: "2px", space-1: "4px", space-1-5: "6px", space-2: "8px", space-2-5: "10px", space-3: "12px", space-4: "16px", space-5: "20px", space-6: "24px", space-8: "32px", space-10: "40px", space-12: "48px", space-16: "64px" } components: @@ -69,44 +67,35 @@ The system is calm, native, and compact: spacious around reading and decisions, This document governs the default light and dark themes. Optional palettes may change canvas, ink, accent, and semantic colors, but must preserve their roles, contrast, and hierarchy. -**Authority:** `apps/desktop/src/renderer/astryx-theme/makaTheme.ts` owns type, neutral remaps, and theme-level component overrides; `apps/desktop/src/renderer/maka-tokens.css` owns product palettes, spacing, radii, product motion, and the Astryx bridge; Astryx owns primitive geometry, states, and internal motion; product source owns Maka-specific compositions. Generated `apps/desktop/src/renderer/astryx-theme/maka.css` is not an editing authority. +**Authority:** `apps/desktop/src/renderer/astryx-theme/makaTheme.ts` owns type, neutral remaps, and theme-level component overrides; `apps/desktop/src/renderer/maka-tokens.css` owns product palettes, spacing, radii, and product motion; Astryx owns primitive geometry, states, and internal motion; product source owns Maka-specific compositions. Generated `apps/desktop/src/renderer/astryx-theme/maka.css` is not an editing authority. The bridge into Astryx is not one file: `makaTheme.ts` remaps the neutral stack, `maka-tokens.css` bridges the accent family, and `astryx-mount.css` carries a text/icon/border seam scoped to named containers. Changing what a primitive paints means checking all three files. Frontmatter is a snapshot of the current default theme. When it diverges from source or contract tests, source and tests win and this document must be refreshed. ## 2. Surfaces -Depth is a ladder, not a decoration. Every background in the app resolves to one of four semantic tiers, each derived from `--background` with cumulative offsets (palettes override only `--background`; the ladder follows). +Depth is a ladder, not a decoration. Every background resolves to one of four semantic tiers, each derived from `--background` with cumulative offsets — palettes override only `--background` and the ladder follows. Note that `--background` is the card fill, not the page color; older surface names are aliases onto these tiers, and `maka-tokens.css` is where that mapping lives. -| Tier | Token | Role | Light | Dark | -|---|---|---|---|---| -| sunken | `--surface-sunken` | sidebar rail, recessed chrome | 237 | 9 | -| base | `--surface-base` | shell canvas behind plates | 247 | 17 | -| raised | `--surface-raised` | cards, content plates, reading surfaces | 255 | 23 | -| overlay | `--surface-overlay` | menus, popovers, dialogs, toasts | 255 | 27 | - -(Values are measured rendered pixels, not aspirations; the ink/surface contract tests hold them.) +| Tier | Token | Role | +|---|---|---| +| sunken | `--surface-sunken` | recessed chrome inside a plate | +| base | `--surface-base` | shell floor: sidebar + canvas behind plates | +| raised | `--surface-raised` | cards, content plates, reading surfaces | +| overlay | `--surface-overlay` | menus, popovers, dialogs, toasts | **The Height Rule.** Height maps monotonically to lightness in both modes, and reading surfaces always occupy the brightest tier of their mode. In light mode the ladder tops out at pure white, so `raised` and `overlay` share the fill and overlay separation hands off to the floating recipe (§5). Light mode "higher = darker" is permanently forbidden — it makes elevation shadows contradict the fill. -**The Canvas Recedes Rule** (owner decision 2026-06-20). The canvas is gray; content surfaces are white. The sidebar sits on `sunken`, the shell on `base`, and content plates on `raised`. Contrast between canvas and plate — not hairlines — is the primary separator of the shell. +**The Canvas Recedes Rule** (owner decision 2026-06-20). The canvas is gray; content surfaces are white. Shell floor is two colours: sidebar and canvas share `base`, content plates sit on `raised`. Contrast between canvas and plate is the primary separator. **Paper.** `--surface-paper` sits outside the ladder on purpose and has no dark override. It backs content whose contrast we neither author nor may invert: the sandboxed HTML-artifact iframe and the PDF embed, and QR codes, where dark-on-light is a scanning requirement rather than a preference. It is not a fifth tier and app chrome never uses it. -**Legacy names.** The semantic tiers are canonical. Old names are aliases and their resolved values never change out from under consumers: `--surface-canvas` → base, `--background` → raised (it is the card fill, not the page color), `--background-elevated`, `--color-background-card`, `--color-background-popover` → overlay, `--card-bg`, `--color-background-surface` → raised. - ## 3. Ink -Prose uses exactly three tiers, spaced at an even ~2× contrast rhythm, all above WCAG AA. Measured against `--surface-raised`: - -| Tier | Token | Light | Dark | -|---|---|---|---| -| primary | `--foreground` | 19.1:1 | 15.5:1 | -| secondary | `--foreground-secondary` | 9.8:1 | 9.2:1 | -| muted | `--muted-foreground` | 4.8:1 | 4.7:1 | +Prose uses exactly three tiers — `--foreground`, `--foreground-secondary`, `--muted-foreground` — spaced at an even ~2× contrast rhythm against `--surface-raised`, every one of them clearing WCAG AA in both modes. Any new tier must hold that rhythm and that floor. - **The Three-Tier Reading Rule.** Prose uses primary, secondary, or muted. Neutral washes are surfaces, not extra text tiers. `--foreground-dimmed` is retired — its call sites name secondary directly — and must never come back with a definition of its own (contract-tested). -- **The One Colorspace Rule.** Every derivation inside a token family uses one colorspace (`oklch` for ink, contract-tested). Mixing `srgb` and `oklch` derivations produces "same literal, different value" drift. This held one exception, dark `--surface-overlay`'s srgb mix, until T4 unified it onto oklch at its measured value; the ladder now derives every rung in one space. -- **Links use the solid accent tier** (`--accent-solid`), never raw `--accent` — the accent identifies interaction; the solid tier is the only accent variant that clears text contrast on every palette. +- **The One Colorspace Rule.** Every derivation inside a token family uses one colorspace (`oklch` for ink, contract-tested). Mixing `srgb` and `oklch` derivations produces "same literal, different value" drift, and the ladder is where it bites hardest because its rungs only mean anything relative to each other. +- **Astryx's `secondary` is not this ladder's secondary.** `Text color="secondary"` reads `--color-text-secondary`, which resolves to a fixed neutral literal outside the containers `astryx-mount.css` bridges and to `--muted-foreground` inside them. Neither is `--foreground-secondary`. One word, three results — so pick an ink tier by the token, and treat a primitive's color prop as its own vocabulary. +- **Links use the solid accent tier** (`--accent-solid`), never raw `--accent` — the accent identifies interaction, and the solid tier is the only accent variant that clears text contrast on every palette. A selection wash or outline is not link text and derives from `--accent` instead — deriving a surface from the link name lets a text-contrast rule silently govern a background. ## 4. Borders @@ -114,8 +103,8 @@ Three strengths, each a job, spaced at ~1.6× like the ink ladder: - `--border-soft` (6% ink): quiet separation inside a plate — rails, row dividers that fills can't carry. - `--border` (10% ink): structural boundaries between regions. -- `--border-strong` (16% ink): emphasis chrome only. Its legitimate jobs, from the live inventory: selected/active outlines and emphasized boundaries (onboarding, plan-mode, chat turn/quote chrome, the Astryx `--color-border-emphasized` mapping). Two call sites borrow it as a strong neutral *tint* rather than a border — a scrollbar thumb color and a separator glyph color — and are queued to migrate onto ink-derived tokens in T2–T4. It is not "the border for when you're unsure." -- `--ring-soft` is a 1px ring drawn with box-shadow (`0 0 0 1px`) at the soft tier's own 6% alpha, not an elevation step. It was called `--shadow-minimal-flat` until T4, which is why it kept attracting call sites that wanted lift; the name now states the job, and it belongs to this chapter rather than §5. +- `--border-strong` (16% ink): emphasis chrome — selected and active outlines, emphasized region boundaries, and the scrollbar thumb (§9). It is not "the border for when you're unsure," and it is not the general-purpose strong neutral: anything wanting a neutral *tint* at that weight takes `--foreground-alpha-16`. A hairline drawn with `background` is still a border and keeps it. +- `--ring-soft` is a 1px ring drawn with box-shadow (`0 0 0 1px`) at the soft tier's own 6% alpha. It belongs to this chapter, not §5: a token is filed by the job it does, not by the CSS property it happens to use, and a shadow-shaped name on a border attracts call sites that wanted lift. **The One Means Rule.** Each boundary picks one separator: a fill step, a line, or a shadow — never stacked on the same edge. @@ -123,8 +112,8 @@ Three strengths, each a job, spaced at ~1.6× like the ink ladder: Default surfaces are flat. Depth comes first from the surface ladder, then a line, then shadow only when an element genuinely floats above the plane. -- Product elevation names alias the theme scale: `--elevation-raised` (low), `--elevation-overlay` (med), `--elevation-drag` (high). A scale only gets used when product code can name it — the theme shipped three shadows for months and product CSS consumed one, because the names meant nothing at a call site. -- **The Floating Recipe.** Every portal surface (menu, popover, dialog, toast) is: `--surface-overlay` fill + `--border-soft` ring + `--elevation-overlay` + `overflow: hidden` + container radius. No portal invents its own mix. The recipe is currently enforced on Astryx's shared layer surface through a vendor patch that adds a hook class and decides no values — a transitional implementation with a stated exit condition (`patches/README.md`), not a second styling path. Tooltips are out of scope: a transient label is not a surface that holds content. +- Three steps, named for their job: `--elevation-raised`, `--elevation-overlay`, `--elevation-drag`. Product CSS names these rather than the theme scale underneath — a shadow whose name means nothing at a call site is how the scale sat unused for months while one raw atom did all the work. +- **The Floating Recipe.** Every portal surface (menu, popover, dialog, toast) is: `--surface-overlay` fill + `--border-soft` ring + `--elevation-overlay` + `overflow: hidden` + container radius. No portal invents its own mix, including Astryx's own shared layer surface — it is held to the recipe through a vendor patch that supplies a hook class and no values, on the terms recorded in `patches/README.md`. Tooltips are out of scope: a transient label is not a surface that holds content. - Dark mode relies on tone and rings before shadow. Neon edges and lifted-everything styling are forbidden. - Native shell vibrancy is allowed only in designated material; generic glassmorphism is not. @@ -134,15 +123,14 @@ Default surfaces are flat. Depth comes first from the surface ladder, then a lin Nothing interactive is square. One ladder, assigned monotonically by box height: -| Radius | Tier | Assign to | -|---|---|---| -| 4px | inner | chips, keycaps, nested inlays inside a control | -| 6px | control | buttons, inputs, segmented items (≤ 36px tall) | -| 10px | card | cards, rows-as-cards, list containers (the single card value — 8/10/12 coexistence is over) | -| 12px | container | modals, panels, portal surfaces | -| 16px | page | page-level plates and hero surfaces | -| full | pill | badges, pills, circular controls | +| Radius | Maka tier | Astryx tier | Assign to | +|---|---|---|---| +| 6px | control | inner | chips, keycaps, nested inlays, and product-drawn compact controls | +| 10px | card | element | cards, rows-as-cards, list containers, chat bubbles; Astryx `Button`, `Input`, `SegmentedControl` | +| 12px | container | container | modals, panels, portal surfaces; Astryx `Card`, `Dialog`, `DropdownMenu` | +| full | pill (999px) | full (9999px) | badges, pills, circular controls | +- **The Two-Name Rule.** These are one ladder under two vocabularies, and the names never line up: Maka's `control` is Astryx's `inner`, Maka's `card` is Astryx's `element`, Maka's `modal` is Astryx's `container`. Resolve a tier from the box, never from the token name that sounds right. The paired values agree *today* but are independent literals, not aliases — an Astryx upgrade can move one side silently, so a mismatch is a real failure mode rather than an impossibility. Astryx's `--radius-page` (28px) has no Maka tier and no product consumer; anything reaching for a page-level radius is inventing a rung. - **The Full-Bleed Rule.** `border-radius: 0` is legal only on true full-bleed rows — an element flush with its container on both sides. Radius and gap move together: if it has breathing room, it has corners. - **Proportional marks.** Product-drawn icon plates use ratio-owned radius (~25–27% of the box edge), recorded in prose because Stitch accepts only absolute units. @@ -151,8 +139,9 @@ Nothing interactive is square. One ladder, assigned monotonically by box height: Use the system UI stack with explicit platform CJK fallbacks; Geist Variable is a late fallback. Code uses Geist Mono Variable, JetBrains Mono, then platform monospace. Chinese and Latin must read as one interface. - **Display 1–3:** rare large statements and empty-state anchors. -- **Heading 1–5:** page, panel, section, and compact-title hierarchy. +- **Heading 1–6:** page, panel, section, and compact-title hierarchy, down to an 11px semibold rung. - **Body:** conversation and normal reading. +- **Large:** body-sized lead-in copy at semibold — the only role above body that is not a heading. - **Label:** controls and interactive labels. - **Supporting:** metadata and compact secondary copy. - **Code:** code, paths, commands, identifiers, and machine evidence. @@ -166,8 +155,8 @@ Use the system UI stack with explicit platform CJK fallbacks; Geist Variable is The palette is cool-neutral and quiet; color is generated to spec, not picked by eye. - **Brand mark** is fixed `#71a8fd`; it identifies Maka and is never the general CTA color. -- **Interaction accent** follows the active palette for focus, selection, and live state; **links and accent-colored text use the solid tier** (§3). -- **Status families** (success / active / attention / error / neutral — there is no "info" status semantic) are generated, not picked: one lightness per mode with each hue keeping its own chroma. Light mode is generated at L=0.50 (contrast vs white spans 5.5–6.3:1; the residual spread is hue physics — at equal L, yellow carries more luminance than blue — and flattening it would abandon the shared-L premise that makes them a family). This regeneration fixed two AA failures the old hand-picked values shipped (info 2.82:1, warning 3.29:1). Dark mode keeps its pre-2.0 values (all ≥4.5:1); regenerating dark at its own single L is a scheduled separate round. A louder band at ~90% gamut chroma exists only for 8px status dots — dots must read at a glance; washes must not shout. +- **Interaction accent** follows the active palette for focus, selection, and live state; **links and accent-colored text use the solid tier** (§3). Astryx's own semantic components are the exception — `Badge` and `StatusDot` carry fixed literals inherited from the neutral theme and follow neither the palette nor the families below. +- **Status families** — `--info`, `--success`, `--warning`, `--destructive` — are generated, not picked: one lightness per mode, each hue keeping its own chroma, every member clearing AA. The residual contrast spread within a mode is hue physics — at equal lightness, yellow carries more luminance than blue — and flattening it would abandon the shared-lightness premise that makes them a family. All four are declared in one block per mode, because the time warning sat thirty lines from its siblings is the time it lost its dark override and dropped under AA unnoticed. A louder band at ~90% gamut chroma exists only for 8px status dots: dots must read at a glance, washes must not shout. These are colors; what a state *means* is a separate vocabulary (§9). - **Tinted surfaces** (status washes behind rows and banners) derive from the same status hues; hand-rolled `oklch()` status washes at call sites are forbidden — consume the family. The family is `--{status}-wash` (0.08 fill) and `--{status}-wash-border` (0.24, ~3x the fill), every member derived with `oklch(from var(--{status}) ...)` so a status regeneration flows through it. A **strong** tier (0.12 / 0.40) exists for warnings about data destruction or an action the user cannot undo, and for nothing else — it is not the loud option for a notice that wants attention. Palette swatches are not washes: a swatch's job is to show a palette's real colour, so its literals stay. The family is kept complete even where a rung has no consumer yet: a family with holes in it sends the next author back to hand-rolling an alpha, which is the etiology of the fourteen that drifted. - **Identity colors** (avatars, channel marks) live in one 4.2–4.8:1 contrast band; desaturation for muted states happens at constant OKLab lightness. @@ -179,7 +168,8 @@ Use Astryx primitives as the default seam. New work composes product meaning thr - **Controls:** Maka uses a 20/24/28/32/36/40px height ruler with 32px as the default; Astryx owns the 28/32/36px variants. Hover is restrained; press may use `scale(0.98)`; keyboard focus is always visible. At most one inverted (filled) element per control. Hover washes come in exactly two lanes: product rows and controls take `--state-hover-bg`; chrome that must stay in lockstep with Astryx internals takes `--color-overlay-hover`. Hand-mixed hover alphas are drift. - **Fields:** labels, descriptions, and validation belong to the field primitive; input focus belongs to its control. Keep disabled reasons discoverable through the owning control's tooltip; do not rebuild field chrome around a bare input. -- **Badges and status:** Badge is 20px high and pill-shaped. Choose semantic variants by meaning, not hue; use status dots for success, active, attention, error, or neutral. +- **Badges and status:** Badge is 20px high and pill-shaped. Choose variants by meaning, not hue. +- **Status vocabulary:** what a state means is named once, in `packages/ui/src/status-vocabulary.ts`, and every status dot resolves its color through it — a surface never maps its own domain state onto a color. The semantics are `success` (proven healthy), `active` (the system is working), `attention` (waiting on a person), `error` (broken now), `neutral` (a settled fact); collapsing `active` and `attention` is the mistake that vocabulary exists to prevent. It deliberately has no `info` — two callers meant opposite things by it — but that is a statement about *dot semantics*, not about the `--info` color, which is live. Note that Astryx's `Badge` and `StatusDot` render fixed inherited literals, so an Astryx `info` pill and Maka's `--info` are two unrelated blues that merely look alike; a dot's color does not come from the family in §8. - **Counters:** a count is one step smaller and quieter than its label (supporting role, muted ink, `tabular-nums`) and follows its parent's active state back to full ink. Never bolder than the label it counts. - **Scrollbars:** one app-wide recipe — a 6px pill (10px hit area, 2px transparent inset), thumb at `--border-strong`, one step darker on hover, no painted track. Surfaces may hide their own bars; none may restyle them. - **Cards:** Astryx Card uses container radius, 12px default padding, and no resting elevation. Astryx components own their geometry. @@ -232,9 +222,9 @@ Three mutually exclusive forms, chosen by structural predictability — never by - **Do** preserve keyboard focus, disabled reasons, loading and error states, and reduced-motion behavior. - **Do** keep optional palette inventories in source while preserving documented roles and contrast. -### Don't (the forbidden list — each item is contract-tested or review-blocked): +### Don't (the forbidden list — some items are contract-tested, the rest are review-blocked): -- **Don't** write a bare `oklch()` status color or wash at a call site — consume the generated families (§8). +- **Don't** write a bare `oklch()` status color at a call site, or hand-roll a status wash — consume the families (§8). - **Don't** use `border-radius: 0` off a full-bleed row (§6). - **Don't** put an illustration in an empty-state icon slot, add anything past tier 3's single action, or ship a state attribute with no visible feedback (§10). - **Don't** hardcode `background: white` or any literal surface color — resolve a ladder tier (§2), or `--surface-paper` when the content's own contrast is not ours to control (foreign documents, QR codes) and inverting it would break the content rather than restyle it. diff --git a/apps/desktop/e2e/activity-card-sticky.spec.ts b/apps/desktop/e2e/activity-card-sticky.spec.ts deleted file mode 100644 index 467e672ed3..0000000000 --- a/apps/desktop/e2e/activity-card-sticky.spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { Locator, Page } from '@playwright/test'; -import { expect, test } from './fixtures'; - -async function takeScrollControl(page: Page, header: Locator): Promise { - await header.evaluate((element) => { - const scroller = element.closest('[data-chat-scroll-container="true"]'); - if (!scroller) throw new Error('Chat scroll container not found'); - scroller.scrollTop += - element.getBoundingClientRect().top - scroller.getBoundingClientRect().top - 120; - }); - const scrollport = await page.locator('[data-chat-scroll-container="true"]').boundingBox(); - if (!scrollport) throw new Error('Chat scroll container is not visible'); - await page.mouse.move(scrollport.x + scrollport.width / 3, scrollport.y + scrollport.height / 2); - await page.mouse.wheel(0, -24); -} - -async function scrollHeaderPastTop(header: Locator, distance = 96): Promise { - await header.evaluate((element, scrollDistance) => { - const scroller = element.closest('[data-chat-scroll-container="true"]'); - if (!scroller) throw new Error('Chat scroll container not found'); - scroller.scrollTop += - element.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scrollDistance; - }, distance); -} - -async function headerOffset(header: Locator): Promise { - return header.evaluate((element) => { - const scroller = element.closest('[data-chat-scroll-container="true"]'); - if (!scroller) throw new Error('Chat scroll container not found'); - return element.getBoundingClientRect().top - scroller.getBoundingClientRect().top; - }); -} - -async function backgroundAlpha(element: Locator): Promise { - return element.evaluate((target) => { - const canvas = document.createElement('canvas'); - canvas.width = 1; - canvas.height = 1; - const context = canvas.getContext('2d'); - if (!context) throw new Error('Canvas context not available'); - context.clearRect(0, 0, 1, 1); - context.fillStyle = getComputedStyle(target).backgroundColor; - context.fillRect(0, 0, 1, 1); - return context.getImageData(0, 0, 1, 1).data[3]; - }); -} - -test('expanded activity headers stay reachable while their long details scroll', async ({ - activityCardWindow: page, -}) => { - await page.setViewportSize({ width: 1000, height: 520 }); - - const reasoning = page.locator('.maka-deep-thinking').first(); - const reasoningHeader = reasoning.locator('[data-slot="activity-card-header"]'); - await takeScrollControl(page, reasoningHeader); - await reasoningHeader.click(); - await reasoning.locator('.maka-chat-reasoning-content').evaluate((element) => { - element.style.minHeight = '720px'; - }); - await expect.poll(() => reasoning.evaluate((element) => element.getBoundingClientRect().height)) - .toBeGreaterThan(700); - await scrollHeaderPastTop(reasoningHeader); - - await expect.poll(() => headerOffset(reasoningHeader)).toBeCloseTo(8, 0); - await reasoningHeader.hover(); - await expect.poll(() => backgroundAlpha(reasoningHeader)).toBe(255); - await reasoningHeader.click(); - await expect(reasoningHeader).toHaveAttribute('aria-expanded', 'false'); - - const toolGroup = page.locator('.maka-tool-activity-card').filter({ - has: page.locator(':scope > [role="button"]'), - }).first(); - const groupHeader = toolGroup.locator(':scope > [role="button"]'); - await groupHeader.click(); - - const callHeader = toolGroup.locator('[data-slot="chat-tool-call-row"]').first(); - await callHeader.click(); - await toolGroup.locator('.maka-chat-tool-detail-inner').first().evaluate((element) => { - element.style.minHeight = '720px'; - }); - await expect.poll(() => toolGroup.evaluate((element) => element.getBoundingClientRect().height)) - .toBeGreaterThan(700); - await scrollHeaderPastTop(callHeader); - - await expect.poll(() => headerOffset(groupHeader)).toBeCloseTo(8, 0); - await expect.poll(() => headerOffset(callHeader)).toBeCloseTo(32, 0); - await callHeader.click(); - await expect(callHeader).toHaveAttribute('aria-expanded', 'false'); - - await page.locator('.maka-chat-message-list').evaluate((element) => { - element.style.paddingBottom = '720px'; - }); - await expect.poll(() => toolGroup.evaluate((element) => element.getBoundingClientRect().height)) - .toBeLessThan(240); - await toolGroup.evaluate((element) => { - const scroller = element.closest('[data-chat-scroll-container="true"]'); - if (!scroller) throw new Error('Chat scroll container not found'); - scroller.scrollTop += - element.getBoundingClientRect().bottom - scroller.getBoundingClientRect().top + 96; - }); - await expect.poll(() => headerOffset(groupHeader)).toBeLessThan(0); -}); diff --git a/apps/desktop/e2e/ask-user-question.spec.ts b/apps/desktop/e2e/ask-user-question.spec.ts deleted file mode 100644 index c63dd07d9a..0000000000 --- a/apps/desktop/e2e/ask-user-question.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { FAKE_ASK_USER_QUESTION_PROMPT } from '@maka/runtime'; -import { test, expect, COMPOSER_INPUT } from './fixtures.js'; - -// One seeded prompt, one launch: the reload-rehydration contract and the -// answer flow are consecutive phases of the same parked turn. Answering -// *after* the reload is the stronger form of both tests — it proves the -// rehydrated prompt is not a rendering of lost state but the live turn. -test('rehydrates a prompt across reload, then answers all three questions in the same turn', async ({ - window: page, -}) => { - const composer = page.locator(COMPOSER_INPUT); - await composer.fill(FAKE_ASK_USER_QUESTION_PROMPT); - await composer.press('Enter'); - - const prompt = page.locator('.maka-user-question-prompt'); - await expect(prompt).toBeVisible(); - - // Reloading throws away every event this surface ever saw while the runtime - // keeps the turn parked on the question. Without a read-back the prompt is - // gone for good and the run can never be answered (#2072). - await page.reload(); - await page.getByRole('button', { name: '展开侧边栏' }).click(); - await page - .getByRole('navigation', { name: '对话列表' }) - .locator('[data-session-id]') - .first() - .click(); - - await expect(prompt).toBeVisible(); - await expect(page.locator('.maka-composer')).toBeHidden(); - await expect(prompt.getByText('1 / 3', { exact: true })).toBeVisible(); - await expect(prompt.getByText('先验证核心流程,再逐步扩大范围。')).toBeVisible(); - - const selectedOption = prompt.getByRole('radio', { name: /邀请制/ }); - const unselectedOption = prompt.getByRole('radio', { name: /公开测试/ }); - await selectedOption.click(); - await expect(selectedOption).toBeChecked(); - await expect(unselectedOption).not.toBeChecked(); - // The first question has nothing to go back to: no previous button at all, - // not a disabled one. - const previous = prompt.getByRole('button', { name: '上一题' }); - await expect(previous).toHaveCount(0); - const next = prompt.getByRole('button', { name: '下一题' }); - await next.click(); - - await expect(prompt.getByText('2 / 3', { exact: true })).toBeVisible(); - await expect(next).toBeFocused(); - // Going back keeps the answer already chosen on the first question. - await expect(previous).toBeVisible(); - await previous.click(); - await expect(prompt.getByText('1 / 3', { exact: true })).toBeVisible(); - await expect(selectedOption).toBeChecked(); - await expect(previous).toHaveCount(0); - await next.click(); - await expect(prompt.getByText('2 / 3', { exact: true })).toBeVisible(); - const thisWeek = prompt.getByRole('radio', { name: '本周' }); - const nextWeek = prompt.getByRole('radio', { name: '下周' }); - await thisWeek.focus(); - await thisWeek.press('ArrowDown'); - await expect(nextWeek).toBeFocused(); - await expect(nextWeek).toBeChecked(); - await nextWeek.press('ArrowUp'); - await expect(thisWeek).toBeFocused(); - await expect(thisWeek).toBeChecked(); - await next.click(); - - await expect(prompt.getByText('3 / 3', { exact: true })).toBeVisible(); - - const preset = prompt.getByRole('radio', { name: '是' }); - const customChoice = prompt.getByRole('radio', { name: /其他/ }); - const submit = prompt.getByRole('button', { name: '提交答案' }); - await preset.click(); - await expect(preset).toBeChecked(); - await expect(submit).toBeEnabled(); - await customChoice.click(); - await expect(customChoice).toBeChecked(); - await expect(preset).not.toBeChecked(); - const other = prompt.getByRole('textbox', { name: '其他答案' }); - await expect(other).toBeFocused(); - await expect(submit).toBeDisabled(); - await other.fill('自定义节奏'); - await expect(submit).toBeEnabled(); - await other.press('Home'); - await other.press('ArrowLeft'); - await expect(other).toBeFocused(); - await expect(other).toHaveValue('自定义节奏'); - await prompt.getByRole('button', { name: '提交答案' }).click(); - - await expect(prompt).toHaveCount(0); - await expect(page.getByText(/Fake question answers: 邀请制 \/ 本周 \/ 自定义节奏/)).toBeVisible(); - await expect(page.locator('.maka-composer')).toBeVisible(); -}); diff --git a/apps/desktop/e2e/attachment.spec.ts b/apps/desktop/e2e/attachment.spec.ts deleted file mode 100644 index a532b72e29..0000000000 --- a/apps/desktop/e2e/attachment.spec.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { test, expect, COMPOSER_INPUT } from './fixtures'; - -/** - * Attachment upload + ingest: enter the chat view, drop a file onto the main - * composer, confirm it shows as a pending token, then send the message and - * verify the fake backend received the attachments by name. Uses Playwright's - * DataTransfer + dispatchEvent because the composer has no . - */ -test('a mixed attachment send has the Astryx message hierarchy, and IME composition survives a file paste', async ({ window: page }) => { - // Enter chat view by sending a first message from the composer, which - // creates the session on send. - const firstSend = page.locator(COMPOSER_INPUT); - await firstSend.fill('attach-test'); - await firstSend.press('Enter'); - await expect(page.getByText(/Fake backend received: attach-test/)).toBeVisible(); - - // Drop a file onto the main composer - const composer = page.locator('.maka-composer'); - await expect(composer).toBeVisible(); - // Assistant text becomes visible before the turn's terminal event can clear - // the streaming state. The composer intentionally rejects attachments until - // that happens. Match the ready target and dispatch from the same renderer - // task so a state transition cannot land between readiness and the drop. - const dropTarget = page.locator('.maka-composer[data-maka-file-drop-target="true"]'); - await dropTarget.evaluate((element) => { - const dataTransfer = new DataTransfer(); - dataTransfer.items.add( - new File(['hello attachment content'], 'note.txt', { type: 'text/plain' }), - ); - const png = Uint8Array.from( - atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='), - (character) => character.charCodeAt(0), - ); - dataTransfer.items.add(new File([png], 'pixel.png', { type: 'image/png' })); - element.dispatchEvent( - new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer }), - ); - }); - - // Both attachment kinds stage as Token chips in the quote chips' rhythm, - // names visible on the chip itself (the full name + «EXT · size» meta ride - // the hover tooltip). - const chips = page.locator('.maka-composer-context-drawer .maka-composer-attachment-token'); - await expect(chips.filter({ hasText: 'note.txt' })).toBeVisible(); - const imageChip = chips.filter({ hasText: 'pixel.png' }); - await expect(imageChip).toBeVisible(); - // Once the preview decodes, the image chip becomes clickable (Token mounts - // its invisible button exactly then) and opens the Astryx Lightbox. - // exact: true — a bare name would also match the chip's remove button - // («移除 pixel.png»). - const imageChipButton = imageChip.getByRole('button', { name: 'pixel.png', exact: true }); - await imageChipButton.click(); - const stagedLightbox = page.getByRole('dialog'); - await expect(stagedLightbox.getByRole('img', { name: 'pixel.png' })).toBeVisible(); - await page.keyboard.press('Escape'); - await expect(stagedLightbox).not.toBeVisible(); - // Native-dialog contract: closing hands focus back to the chip button that - // opened it — keyboard users must not be dropped on . The Lightbox - // stays mounted through the close for exactly this. - await expect(imageChipButton).toBeFocused(); - // Keyboard parity with hover: the focused chip surfaces the same - // «name · EXT · size» tooltip (focusTrigger="always" — Token's root span - // is not focusable, so focusin must bubble from the inner button). - await expect( - page.getByRole('tooltip').filter({ hasText: 'pixel.png · PNG' }), - ).toBeVisible(); - - // Send the message carrying the attachment — the fake backend echoes the - // attachment name, proving the ingest-on-send path delivered it (not just - // that the UI rendered a card). - const composerInput = page.locator(COMPOSER_INPUT); - await composerInput.fill('sending mixed attachments'); - await composerInput.press('Enter'); - await expect(page.getByText(/Attachments received: note\.txt, pixel\.png/)).toBeVisible(); - - const sentMessage = page.getByLabel('你发送的消息').last(); - const fileToken = sentMessage.locator('.maka-user-attachment-tokens .astryx-token'); - const image = sentMessage.locator('.maka-user-attachments .astryx-thumbnail'); - const bubble = sentMessage.locator('.maka-chat-message-bubble-user'); - await expect(fileToken).toContainText('note.txt'); - await expect(image).toBeVisible(); - await expect(bubble).toContainText('sending mixed attachments'); - - await image.getByRole('button').click(); - const lightbox = page.locator('.astryx-lightbox'); - await expect(lightbox).toBeVisible(); - - // The lightbox close button lands inside the titlebar's drag rect, where the - // OS eats clicks unless the modal is `no-drag`. The overlap is asserted first — - // without it the app-region check would guard nothing. - const titlebar = page.locator('.maka-window-titlebar'); - const [titlebarBox, lightboxBox] = await Promise.all([titlebar.boundingBox(), lightbox.boundingBox()]); - expect(titlebarBox).not.toBeNull(); - expect(lightboxBox).not.toBeNull(); - expect(lightboxBox!.y).toBeLessThan(titlebarBox!.y + titlebarBox!.height); - await expect(lightbox).toHaveCSS('-webkit-app-region', 'no-drag'); - - await page.keyboard.press('Escape'); - await expect(page.locator('.astryx-lightbox')).not.toBeVisible(); - - // IME composition vs file paste, in the same window: pastes must not be - // intercepted mid-composition, and must be intercepted after it. Runs after - // the attachment journey because the post-composition paste stages a chip. - const editable = page - .locator('.maka-composer[data-maka-file-drop-target="true"]') - .locator('[contenteditable="true"]'); - const pasteResults = await editable.evaluate((input) => { - const dispatchFilePaste = () => { - const clipboardData = new DataTransfer(); - clipboardData.items.add(new File(['content'], 'note.txt', { type: 'text/plain' })); - const event = new Event('paste', { bubbles: true, cancelable: true }); - Object.defineProperty(event, 'clipboardData', { value: clipboardData }); - input.dispatchEvent(event); - return event.defaultPrevented; - }; - - input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); - const duringComposition = dispatchFilePaste(); - input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true })); - const afterComposition = dispatchFilePaste(); - - return { duringComposition, afterComposition }; - }); - - expect(pasteResults).toEqual({ duringComposition: false, afterComposition: true }); -}); diff --git a/apps/desktop/e2e/bot-onboarding.spec.ts b/apps/desktop/e2e/bot-onboarding.spec.ts deleted file mode 100644 index b6e644eaa6..0000000000 --- a/apps/desktop/e2e/bot-onboarding.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { test, expect } from './fixtures'; - -test('IM 快捷接入完成真实 QR session、凭据落盘,取消与过期二维码可恢复', async ({ botSettingsWindow: page }) => { - const settings = page.getByRole('main', { name: '设置内容' }); - await expect(settings.getByRole('heading', { name: '远程接入' })).toBeVisible(); - - await settings.getByRole('button', { name: '接入 钉钉' }).click(); - await expect(settings.getByRole('heading', { name: '接入方式' })).toBeVisible(); - await expect(settings.getByRole('radio', { name: '快捷接入(推荐)' })).toBeChecked(); - - await settings.getByRole('radio', { name: '手动配置' }).click(); - await expect(settings.getByRole('textbox', { name: '钉钉应用密钥' })).toBeVisible(); - await settings.getByRole('radio', { name: '快捷接入(推荐)' }).click(); - await settings.getByRole('button', { name: '使用钉钉扫码接入' }).click(); - - const dialog = page.getByRole('dialog', { name: '配置钉钉扫码接入' }); - await expect(dialog).toBeVisible(); - const qr = dialog.getByRole('img', { name: '配置钉钉二维码' }); - await expect(qr).toHaveAttribute('src', /^data:image\/png;base64,/); - await expect(dialog.getByText('请使用钉钉扫描二维码并确认授权')).toBeVisible(); - // QR square / fill-frame geometry is pinned in chat-shell-layout-contract - // (settingsBotOnboardingQrFrame CSS). This journey owns session + secret isolation. - - await expect(dialog.getByText('已扫码,请在钉钉中完成确认')).toBeVisible({ timeout: 4_000 }); - await expect(dialog.getByText('钉钉 已连接')).toBeVisible({ timeout: 5_000 }); - await expect(page.getByText('Maka 测试机器人')).toBeVisible(); - - const stored = await page.evaluate(() => window.maka.settings.get()); - expect(stored.botChat.channels.dingtalk.appId).toBe('e2e-fixture-dingtalk-client'); - expect(stored.botChat.channels.dingtalk.appSecret).not.toBe('e2e-fixture-dingtalk-secret'); - expect(JSON.stringify(stored)).not.toContain('e2e-fixture-dingtalk-secret'); - - await dialog.getByRole('button', { name: '完成' }).click(); - await expect(dialog).toBeHidden(); - // Same window, next channels: cancellation races, expiry regeneration, and - // the Lark variant are independent flows over the same seeded settings. - await settings.getByRole('button', { name: '返回远程接入' }).click(); - await settings.getByRole('button', { name: '接入 微信' }).click(); - await settings.getByRole('button', { name: '扫码登录' }).click(); - const wechatDialog = page.getByRole('dialog', { name: '微信扫码登录' }); - await expect(wechatDialog.getByRole('img', { name: '微信扫码登录二维码' })).toBeVisible(); - // Poll snapshots replace the dialog subtree. A real pointer dispatch does - // not wait for that subtree to become stable, so neither should this click. - await wechatDialog.getByRole('button', { name: '取消' }).click({ force: true }); - await expect(wechatDialog).toBeHidden(); - - const afterCancel = await page.evaluate(() => window.maka.settings.get()); - expect(afterCancel.botChat.channels.wechat.token).toBe(''); - expect(afterCancel.botChat.channels.wechat.enabled).toBe(false); - - await settings.getByRole('button', { name: '返回远程接入' }).click(); - await settings.getByRole('button', { name: '接入 企业微信' }).click(); - await settings.getByRole('button', { name: '开始快捷绑定' }).click(); - const wecomDialog = page.getByRole('dialog', { name: '配置企业微信扫码接入' }); - await expect(wecomDialog.getByText('二维码已过期,请重新生成')).toBeVisible({ timeout: 4_000 }); - await wecomDialog.getByRole('button', { name: '重新生成' }).click(); - await expect(wecomDialog.getByRole('img', { name: '配置企业微信二维码' })).toBeVisible(); - await wecomDialog.getByRole('button', { name: '取消' }).click({ force: true }); - - await settings.getByRole('button', { name: '返回远程接入' }).click(); - await settings.getByRole('button', { name: '接入 飞书' }).click(); - await settings.getByRole('radio', { name: 'Lark' }).click(); - await settings.getByRole('button', { name: '使用Lark扫码接入' }).click(); - const larkDialog = page.getByRole('dialog', { name: '配置 Lark 扫码接入' }); - await expect(larkDialog.getByRole('img', { name: '配置 Lark 二维码' })).toBeVisible(); -}); diff --git a/apps/desktop/e2e/composer-mention-token.spec.ts b/apps/desktop/e2e/composer-mention-token.spec.ts deleted file mode 100644 index f9b6ceb130..0000000000 --- a/apps/desktop/e2e/composer-mention-token.spec.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { expect, test, COMPOSER_INPUT } from './fixtures'; - -// One seeded window, four phases over the same composer. Order is dictated -// by the message-count pins: the empty-menu double-Enter phase asserts a -// total of exactly one sent message, so it runs first; the token phase sends -// last and anchors on the newest bubble. -/** - * An open menu with nothing highlighted (still loading, or no matches) leaves - * Enter unconsumed. It must not send the draft out from under the popup — and - * it must not deadlock either: "no matches" is a stable state, so swallowing - * Enter forever would leave the keyboard unable to send at all. - */ -/** - * The menu has to follow the caret, not just the text. Astryx recomputes the - * active trigger only on `input`, so an arrow key off the query used to leave - * the menu open over a trigger no longer under the cursor — and the next Enter - * spliced a token in at the stale offset instead of sending. - */ -/** - * Upstream contract: the trigger boundaries are Astryx's `findActiveTrigger` - * now, and it is NOT equivalent to the `detectMentionTrigger` it replaced — a - * space ends an `@` query, so a path with a space in it can no longer be - * searched. Pin the grammar we actually depend on so an Astryx upgrade that - * moves a boundary fails here rather than in front of a user. - */ -/** - * The `@` file trigger: menu → inline token → the path the backend receives. - * The token is a real chip in the draft now, so this also pins the one cascade - * invariant it depends on — the token span must shrink to its badge. A - * `[contenteditable]` selector that also matched the token's - * `contenteditable="false"` stretched it to the full line and pushed the - * surrounding text onto separate rows. - */ -test('the @ trigger: empty-menu sends, caret boundaries, trigger grammar, and the inline token round trip', async ({ - invocableSkillsWindow: page, -}) => { - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('@zzzznomatchzzzz'); - await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible(); - - // A leaked send is asynchronous, so `toHaveCount(0)` here would pass before - // it lands — and a second Enter sending the same text would then hide it. - // Withhold, retype into something distinguishable, and pin the total. - await composer.press('Enter'); - await composer.press('Enter'); - await expect(page.getByRole('log').getByText('Fake backend received: @zzzznomatchzzzz')).toBeVisible(); - await expect(page.getByLabel('你发送的消息')).toHaveCount(1); - // Settle before the next phase sends: an Enter during a streaming turn - // becomes steering instead of a new message. - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000 }); - - await composer.fill('看一下 @agent'); - await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible(); - - for (let index = 0; index < 6; index += 1) await composer.press('ArrowLeft'); - await expect.poll(() => composer.getAttribute('aria-expanded')).toBe('false'); - - await composer.press('Enter'); - await expect(page.getByRole('log').getByText('Fake backend received: 看一下 @agent')).toBeVisible(); - await expect(composer.locator('[data-astryx-token]')).toHaveCount(0); - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(2, { timeout: 20_000 }); - - const expanded = () => composer.getAttribute('aria-expanded'); - const files = page.getByRole('listbox', { name: '工作区文件' }); - - await composer.fill('看一下 @agent'); - await expect(files).toBeVisible(); - - // A space ends the query — narrowing an `@` search by a second word, which - // the retired popup allowed, is gone. - await composer.pressSequentially(' write'); - await expect.poll(expanded).toBe('false'); - - // A non-boundary `@` is not a trigger. - await composer.fill('mail user@host.com'); - await expect.poll(expanded).toBe('false'); - - // The nearest boundary wins. - await composer.fill('@a /b'); - await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible(); - await expect(files).toHaveCount(0); - - await composer.fill('看一下 @agent'); - - const listbox = page.getByRole('listbox', { name: '工作区文件' }); - await expect(listbox.getByRole('option').first()).toBeVisible(); - await composer.press('Enter'); - await composer.pressSequentially('里的说明'); - await composer.pressSequentially(';普通文本 @.maka/skills/agent-write/SKILL.md'); - await composer.press('Escape'); - - const token = composer.locator('[data-astryx-token]'); - await expect(token).toHaveAttribute( - 'data-astryx-token-value', - '@.maka/skills/agent-write/SKILL.md', - ); - await composer.press('Enter'); - // Third send of this journey: anchor on the newest bubble. - const bubble = page.getByLabel('你发送的消息').last(); - await expect(bubble).toBeVisible(); - const sentFileBadges = bubble.locator('.maka-chat-message-bubble-user .astryx-badge'); - await expect(sentFileBadges).toHaveCount(1); - await expect(sentFileBadges).toHaveText('SKILL.md'); - await expect(bubble).toContainText( - '看一下 SKILL.md 里的说明;普通文本 @.maka/skills/agent-write/SKILL.md', - ); - // The transcript replays the selected token's label, while the model still - // receives the exact serialized path with normalized spacing. - // Scope to the transcript log: after several turns the prompt rail also - // previews this reply text, and a page-wide getByText is ambiguous under - // Playwright strict mode. - await expect( - page.getByRole('log').getByText( - 'Fake backend received: 看一下 @.maka/skills/agent-write/SKILL.md 里的说明;普通文本 @.maka/skills/agent-write/SKILL.md', - ), - ).toBeVisible(); - - await page.reload(); - const reloadedBubble = page.getByLabel('你发送的消息').last(); - await expect(reloadedBubble).toBeVisible(); - await expect( - reloadedBubble.locator('.maka-chat-message-bubble-user .astryx-badge'), - ).toHaveCount(1); - await expect(reloadedBubble).toContainText( - '普通文本 @.maka/skills/agent-write/SKILL.md', - ); -}); diff --git a/apps/desktop/e2e/composer-skill-invocation.spec.ts b/apps/desktop/e2e/composer-skill-invocation.spec.ts index bfdb002ac6..d807d86ea2 100644 --- a/apps/desktop/e2e/composer-skill-invocation.spec.ts +++ b/apps/desktop/e2e/composer-skill-invocation.spec.ts @@ -1,136 +1,5 @@ -import type { Page } from '@playwright/test'; -import { expect, test, COMPOSER_INPUT, waitForInvocableSkills } from './fixtures'; +import { expect, test, COMPOSER_INPUT } from './fixtures'; -async function createStarterSkillAndReload(page: Page): Promise { - const result = await page.evaluate(() => window.maka.skills.createStarter()); - expect(result.ok).toBe(true); - await page.reload(); - await expect(page.locator(COMPOSER_INPUT)).toBeVisible(); - await waitForInvocableSkills(page, ['starter-skill']); -} - -/** The staged Skill's inline chip, addressed by the token it serializes to. */ -const STARTER_CHIP = '[data-astryx-token-value="/skill:starter-skill"]'; - -/** - * Pick 示例技能 from the `/` menu. `append` types the trigger after whatever - * is already in the draft; the default replaces the draft, which is what a - * chip-only send needs. - */ -async function selectStarterSkill( - page: Page, - options: { append?: boolean } = {}, -): Promise { - const composer = page.locator(COMPOSER_INPUT); - if (options.append) { - await composer.click(); - await composer.pressSequentially(' /'); - } else { - await composer.fill('/'); - } - const listbox = page.getByRole('listbox', { name: /技能/ }); - await expect(listbox).toBeVisible(); - await expect(listbox.getByRole('option', { name: /示例技能/ })).toBeVisible(); - await composer.press('Enter'); - await expect(page.locator(STARTER_CHIP)).toContainText('示例技能'); -} - -// One seeded window, three phases in load-bearing order: the gating phase -// reads the pre-session state, the collaboration phase must create the very -// first session, and the Deep Research phase runs last because it moves the -// active session. (Draft-chip restoration keeps its own window below: its -// contract is editor rebuilds on external value changes, and concurrent -// session activity in a shared window perturbs exactly that.) -test('slash suggestions: project gating, collaboration modes, and Deep Research filtering', async ({ - invocableSkillsWindow: page, -}) => { - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('/'); - const listbox = page.getByRole('listbox', { name: /技能/ }); - await expect(listbox).toBeVisible(); - await expect(listbox).toContainText('Project Only'); - await expect(listbox).toContainText('Workspace Only'); - await expect(listbox).toContainText('Agent Write'); - await expect(listbox).not.toContainText('Host Incompatible'); - - const planNames = await page.evaluate(async () => - (await window.maka.skills.listInvocable(undefined, { - collaborationMode: 'plan', - })).map((skill) => skill.name), - ); - expect(planNames).not.toContain('Agent Write'); - - await composer.fill('Open a session'); - await composer.press('Enter'); - await expect.poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length).toBe(1); - const [session] = await page.evaluate(() => window.maka.sessions.list()); - if (!session) throw new Error('the composer did not create a session'); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list()))[0]?.status) - .not.toBe('running'); - - const listNames = (sessionId: string) => - page.evaluate( - async (id) => (await window.maka.skills.listInvocable(id)).map((skill) => skill.name), - sessionId, - ); - - await expect.poll(() => listNames(session.id)).toContain('Agent Write'); - await composer.fill('/'); - await expect(listbox).toContainText('Agent Write'); - - await expect - .poll(() => - page.evaluate(async ({ sessionId }) => { - try { - await window.maka.sessions.setCollaborationMode(sessionId, 'plan'); - return true; - } catch (error) { - if (String(error).includes('linked Turn is active')) return false; - throw error; - } - }, { sessionId: session.id }), - ) - .toBe(true); - await expect.poll(() => listNames(session.id)).not.toContain('Agent Write'); - await expect(listbox).not.toContainText('Agent Write'); - - - await composer.fill('/'); - await expect(listbox).toBeVisible(); - await expect(listbox).not.toContainText('Deep Research Only'); - await composer.fill(''); - - // ⌘K is the palette's only entry now — the 更多操作 menu that used to hold - // a 打开命令面板 item is gone. ControlOrMeta covers CI's Linux and macOS. - await page.keyboard.press('ControlOrMeta+KeyK'); - await page.getByRole('dialog', { name: '命令面板' }).getByRole('option', { name: /新建深度研究/ }).click(); - await expect(page.getByLabel('深度研究,只读探索').filter({ visible: true })).toBeVisible(); - - await composer.fill('/'); - await expect(listbox).toContainText('Deep Research Only'); -}); - -/** - * The staged Skill has to still look staged after the draft comes back. - * - * `ChatComposerInput` rebuilds the editor from the string on every external - * value change, which drops the chip spans and leaves the `/skill:` text - * they serialize to (facebook/astryx #4655). That draft still sends the Skill, - * but the user is looking at an internal id where they left a chip, and this is - * the ordinary path: any session that ever staged a Skill hits it on the way - * back. - * - * Two Skills, one of them mid-draft, because the redraw walks the tokens back - * to front so that replacing a later one cannot move an earlier one's offsets. - * A single token at the end of the draft would never exercise that. - * - * Blurred on purpose: the swap runs from a sidebar click, so the redraw has to - * work without the editor holding focus, and the caret still has to land after - * the draft rather than at offset 0. The send at the end is the other half — - * `insertToken` anchors every chip with a U+00A0, and the wire text has to come - * out with ordinary spaces regardless. - */ test('staged Skills come back as chips after leaving and returning', async ({ invocableSkillsWindow: page, }) => { @@ -184,81 +53,3 @@ test('staged Skills come back as chips after leaving and returning', async ({ // invocation pins zero turns and zero sessions so it must run before any // send; the chip-only send then re-enables the Skill and owns the first // message; the + entry phases send nothing and run last. -test('starter skill: blocked invocation, chip-only send, and the + Skills entry', async ({ - window: page, -}) => { - await createStarterSkillAndReload(page); - await createStarterSkillAndReload(page); - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('run it'); - await selectStarterSkill(page, { append: true }); - const disabled = await page.evaluate(() => window.maka.skills.setEnabled('starter-skill', false)); - expect(disabled.ok).toBe(true); - - await composer.press('Enter'); - - await expect(page.getByText('Skill 调用失败,消息未发送')).toBeVisible(); - await expect(composer).toContainText('run it'); - await expect(page.locator(STARTER_CHIP)).toContainText('示例技能'); - await expect(page.locator('.maka-turn')).toHaveCount(0); - // #1433: the composer creates the session BEFORE it sends, so a rejected - // first send has to remove it again. Otherwise every blocked invocation - // leaves a nameless empty session in the sidebar. `quick-chat.ts` used to - // carry unit tests for this; when the composer became the only first-send - // path, nothing was asserting it any more. - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(0); - - // Re-arm for the next phases: the blocked phase disabled the Skill and left - // a draft behind. Wait for the invocable list to reflect the re-enable - // before the menus below depend on it. - const reEnabled = await page.evaluate(() => window.maka.skills.setEnabled('starter-skill', true)); - expect(reEnabled.ok).toBe(true); - await waitForInvocableSkills(page, ['starter-skill']); - await composer.fill(''); - - await selectStarterSkill(page); - - await composer.press('Enter'); - - const sentMessage = page.getByLabel('你发送的消息').first(); - await expect(sentMessage).toContainText('示例技能'); - await expect( - sentMessage.locator('.maka-chat-message-bubble-user .astryx-badge'), - ).toHaveText('示例技能'); - - // Settle the chip-only turn before the + phases interact with the menu. - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000 }); - - const listbox = page.getByRole('listbox', { name: /技能/ }); - const plus = page.locator('.maka-composer-plus-menu').getByRole('button'); - const skillsEntry = page.getByRole('menuitem', { name: '选择技能' }); - const openFromPlus = async () => { - // Astryx ignores clicks on a float for ~100ms after one light-dismisses, - // and picking from the `/` menu dismisses one. Nothing observable marks the - // end of that window, so retry the click until + answers rather than sleep - // past it — under the threshold the click reaches nothing at all. - await expect(async () => { - await plus.click(); - await expect(skillsEntry).toBeVisible({ timeout: 500 }); - }).toPass(); - await skillsEntry.click(); - }; - - // Empty draft: the trigger is at a line start, so no space is needed. - await openFromPlus(); - // Keyboard selection is pinned elsewhere; click the option so this phase - // does not race the menu's highlight settling after the Skill re-enable. - await listbox.getByRole('option', { name: /示例技能/ }).click(); - await expect(page.locator(STARTER_CHIP)).toContainText('示例技能'); - - await openFromPlus(); - await expect(listbox).toBeVisible(); - - // Escape leaves the typed trigger behind, exactly as it does when the user - // types `/` themselves — it is ordinary draft text, not a surface to dismiss. - await page.keyboard.press('Escape'); - await expect(listbox).toBeHidden(); - await expect(composer).toContainText('/'); -}); diff --git a/apps/desktop/e2e/external-session-import.spec.ts b/apps/desktop/e2e/external-session-import.spec.ts deleted file mode 100644 index b6f772c414..0000000000 --- a/apps/desktop/e2e/external-session-import.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { createServer } from 'node:http'; -import { copyFile, mkdir } from 'node:fs/promises'; -import path from 'node:path'; -import type { Page } from '@playwright/test'; -import { e2eHomeDir, expect, test } from './fixtures'; - -test('imports a Codex rollout through the Desktop Runtime Host', async ({ - externalSessionImportWindow: page, -}) => { - const modelCatalog = createServer((_request, response) => { - response.writeHead(200, { 'content-type': 'application/json' }); - response.end( - JSON.stringify({ - object: 'list', - data: [ - { - id: 'external-import-model', - object: 'model', - created: 0, - owned_by: 'e2e', - }, - ], - }), - ); - }); - await new Promise((resolve) => modelCatalog.listen(0, '127.0.0.1', resolve)); - const address = modelCatalog.address(); - if (address === null || typeof address === 'string') throw new Error('Model catalog did not bind'); - - try { - await page.evaluate(async (baseUrl) => { - await window.maka.connections.create({ - slug: 'external-import-e2e', - name: 'External import E2E', - providerType: 'openai-compatible', - baseUrl, - defaultModel: 'external-import-model', - apiKey: 'e2e-placeholder', - }); - await window.maka.connections.fetchModels('external-import-e2e'); - await window.maka.connections.update('external-import-e2e', { - defaultModel: 'external-import-model', - enabledModelIds: ['external-import-model'], - }); - await window.maka.connections.setDefault('external-import-e2e'); - }, `http://127.0.0.1:${address.port}/v1`); - - await importCodexRollout(page); - } finally { - await new Promise((resolve, reject) => { - modelCatalog.close((error) => (error ? reject(error) : resolve())); - }); - } -}); - -async function importCodexRollout(page: Page): Promise { - const rolloutDirectory = path.join(e2eHomeDir(), '.codex', 'sessions', '2026', '08', '08'); - await mkdir(rolloutDirectory, { recursive: true }); - await copyFile( - path.resolve('../../packages/storage/src/__tests__/fixtures/codex-rollout-v0.144.jsonl'), - path.join(rolloutDirectory, 'rollout-2026-08-08T00-00-00-codex-session-1.jsonl'), - ); - - const expandSidebar = page.getByRole('button', { name: '展开侧边栏' }); - if (await expandSidebar.isVisible()) await expandSidebar.click(); - await page.getByRole('button', { name: '导入会话', exact: true }).click(); - - const dialog = page.getByRole('dialog', { name: '导入外部会话' }); - await expect(dialog).toBeVisible(); - await expect(dialog.getByRole('button', { name: 'Codex', exact: true })).toBeVisible(); - - const sourceSession = dialog.getByRole('button').filter({ hasText: 'Fix the parser' }); - await expect(sourceSession).toHaveCount(1); - await sourceSession.click(); - await dialog.getByRole('button', { name: '导入会话', exact: true }).click(); - - await expect(dialog).toBeHidden(); - await expect( - page.locator('.maka-user-message').getByText('Fix the parser', { exact: true }), - ).toBeVisible(); - await expect( - page.locator('.maka-assistant-answer').getByText('I found the issue.', { exact: true }), - ).toBeVisible(); -} diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index a3c03ef9be..c99beaf60b 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -181,21 +181,6 @@ async function seedE2eGitReviewProject( ); } -/** - * The sandboxed HOME of the run currently under test. Set by withE2eWindow - * before Electron launches. - * - * Read it from inside a test BODY, never as a fixture: a fixture would have no - * declared dependency on the window fixture, so Playwright could resolve it - * before the window is set up and hand back a stale path. - */ -let currentHomeDir = ''; - -export function e2eHomeDir(): string { - if (!currentHomeDir) throw new Error('e2eHomeDir() is only valid inside a test that opened a window'); - return currentHomeDir; -} - /** * Own the full launch lifecycle so a failure anywhere — seeding, Electron * launch, firstWindow, or the readiness wait — still tears down the Electron @@ -227,14 +212,13 @@ async function withE2eWindow( gitReviewExtraFiles?: number; extraConnectionCount?: number; }, - use: (page: Page) => Promise, + use: (page: Page, context: { userDataDir: string }) => Promise, ): Promise { const userDataDir = await mkdtemp(path.join(tmpdir(), 'maka-e2e-')); // Lives inside the throwaway userData dir so the existing teardown removes // it too — there is no second path to leak. const homeDir = path.join(userDataDir, 'home'); await mkdir(homeDir, { recursive: true }); - currentHomeDir = homeDir; let app: ElectronApplication | undefined; const mainLogs: string[] = []; const rendererLogs: string[] = []; @@ -291,7 +275,7 @@ async function withE2eWindow( const rendererDetail = rendererLogs.length > 0 ? `\nRenderer console:\n${rendererLogs.join('\n')}` : ''; throw new Error(`${detail}${mainDetail}${rendererDetail}`, { cause: error }); } - await use(page); + await use(page, { userDataDir }); } finally { try { if (app) await closeElectronApplication(app, 5_000); @@ -303,146 +287,42 @@ async function withE2eWindow( export const test = base.extend<{ window: Page; - externalSessionImportWindow: Page; - firstRunWindow: Page; - modelPickerLongWindow: Page; - sandboxBoundaryWindow: Page; - readOnlyBoundaryWindow: Page; - activityCardWindow: Page; - sessionWorkbarWindow: Page; artifactPaneWindow: Page; - botSettingsWindow: Page; + gitReviewWindow: { page: Page; projectRoot: string }; invocableSkillsWindow: Page; - settingsProjectsWindow: Page; - oauthReloginWindow: Page; }>({ // Seeded: a pre-staged connection clears onboarding so the composer is ready. - // Used by chat / session / settings / attachment specs. window: async ({}, use) => { await withE2eWindow({ seed: true, readinessSelector: COMPOSER_INPUT, locale: 'zh' }, use); }, - // External Session import runs through the production Runtime Host owner. - externalSessionImportWindow: async ({}, use) => { - await withE2eWindow( - { - seed: true, - readinessSelector: '.maka-session-panel', - locale: 'zh', - }, - use, - ); - }, - // No connection: the real main process derives `needs_connection`, and the - // renderer replaces the empty chat with the first-task activation card. - firstRunWindow: async ({}, use) => { + artifactPaneWindow: async ({}, use) => { await withE2eWindow( { seed: false, - readinessSelector: '[data-maka-contract="onboarding-card"]', - e2eFixtureScenario: 'first-run', - locale: 'zh', - }, - use, - ); - }, - // Settings -> 偏好 -> 项目, with three seeded catalog entries (available, - // long-path, and folder-gone) so the list, the default control, and the - // unavailable row all render without touching a native directory picker. - settingsProjectsWindow: async ({}, use) => { - await withE2eWindow( - { - seed: true, - readinessSelector: '.settingsMainPane', - e2eFixtureScenario: 'settings-projects', + readinessSelector: '.maka-artifact-list', + e2eFixtureScenario: 'artifact-pane', locale: 'zh', }, use, ); }, - modelPickerLongWindow: async ({}, use) => { + gitReviewWindow: async ({}, use) => { await withE2eWindow( { seed: true, readinessSelector: COMPOSER_INPUT, locale: 'zh', - extraConnectionCount: 10, - }, - use, - ); - }, - // Sandbox-boundary takeover: boots a deterministic expansion request in the - // real desktop shell so the composer-slot placement and non-modal behavior - // are covered without a provider or test-only renderer state path. - sandboxBoundaryWindow: async ({}, use) => { - await withE2eWindow( - { seed: false, readinessSelector: '.maka-sandbox-boundary-prompt', e2eFixtureScenario: 'sandbox-boundary', locale: 'zh' }, - use, - ); - }, - // Read-only boundary (#1611): the Deep Research fixture session is seeded - // with `permissionMode: 'explore'`, so the metadata store derives a genesis - // managed read-only boundary for it. That makes this the only window where - // the composer's permission label is driven by a real read-only profile - // travelling main → IPC → renderer. - readOnlyBoundaryWindow: async ({}, use) => { - await withE2eWindow( - { seed: false, readinessSelector: COMPOSER_INPUT, e2eFixtureScenario: 'deep-research-progress', locale: 'zh' }, - use, - ); - }, - // A committed reasoning + multi-tool transcript. Geometry specs amplify the - // existing detail height in the browser; the production projection and - // disclosure components remain the fixture's rendering path. - activityCardWindow: async ({}, use) => { - await withE2eWindow( - { - seed: false, - readinessSelector: '.maka-deep-thinking', - e2eFixtureScenario: 'turn-narrative', - locale: 'zh', - showWindow: true, - }, - use, - ); - }, - // Session workbar: seeds a task tree and opens the unified auxiliary - // workspace so its shell controls and peer tabs run against real IPC data. - sessionWorkbarWindow: async ({}, use) => { - await withE2eWindow( - { - seed: false, - // The data contract, not the tag: the panel's surface is an Astryx Card - // (a div carrying role="complementary"), so a tag-anchored selector - // would pin an implementation detail the design system owns. - readinessSelector: '[data-maka-contract="session-workbar-right"]', - e2eFixtureScenario: 'task-ledger', - locale: 'zh', + gitReviewExtraFiles: 0, }, - use, - ); - }, - artifactPaneWindow: async ({}, use) => { - await withE2eWindow( - { - seed: false, - readinessSelector: '.maka-artifact-list', - e2eFixtureScenario: 'artifact-pane', - locale: 'zh', + async (page, context) => { + await use({ + page, + projectRoot: path.join(context.userDataDir, 'git-review-project'), + }); }, - use, - ); - }, - // Remote access: uses the e2e-fixture workspace so Settings opens on the - // channel catalog and main injects deterministic IM onboarding adapters. - // The renderer still talks through the real preload/IPC/session authority. - botSettingsWindow: async ({}, use) => { - await withE2eWindow( - { seed: false, readinessSelector: '[aria-label="设置内容"]', e2eFixtureScenario: 'settings-bots', locale: 'zh' }, - use, ); }, - // Project + Maka-workspace Skills with one deliberately host-incompatible - // entry. Proves `/` uses Runtime discovery/gating rather than management UI data. + // Project + workspace Skills for draft/chip journeys. invocableSkillsWindow: async ({}, use) => { await withE2eWindow({ seed: true, @@ -451,19 +331,6 @@ export const test = base.extend<{ invocableSkills: true, }, use); }, - oauthReloginWindow: async ({}, use) => { - await withE2eWindow( - { - seed: false, - // The connection detail is a page now, not a dialog; waiting on - // `dialog[open]` waited for markup this redesign deleted. - readinessSelector: '[data-maka-contract="connection-detail"]', - e2eFixtureScenario: 'oauth-relogin', - locale: 'zh', - }, - use, - ); - }, }); export { expect }; diff --git a/apps/desktop/e2e/floating-recipe.spec.ts b/apps/desktop/e2e/floating-recipe.spec.ts deleted file mode 100644 index f4e3288ddb..0000000000 --- a/apps/desktop/e2e/floating-recipe.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { test, expect } from './fixtures'; - -// Guard for the `@astryxdesign/core` floating-surface patch (patches/README). -// -// DESIGN.md §5: every portal surface is `--surface-overlay` fill + -// `--border-soft` ring + `--elevation-overlay` + `overflow: hidden` + container -// radius. Astryx ships the fill and the radius and hardcodes the other three -// wrong — it reaches for the LOW shadow (which is --elevation-raised), leaves -// the surface ringless, and leaves `overflow: visible` so content corners -// escape the surface's own radius. -// -// The painted surface is an anonymous wrapper OUTSIDE `[role=menu]`, carrying -// no themeProps label, so the patch adds one hook class and the recipe itself -// lives in product CSS (styles/astryx-mount.css). This asserts the RESULT, not -// the hook: if Astryx ever ships the recipe itself, this stays green with the -// patch deleted, which is exactly the deletion condition. -test('a portal surface wears the floating recipe, not a plate shadow', async ({ window: page }) => { - await page.getByRole('button', { name: /选择新对话模型/ }).click(); - // `[role="menu"]` specifically: the composer keeps a permanently-mounted, - // hidden `[role="listbox"]` for slash suggestions, which a looser selector - // matches first and which never paints a surface. - const menu = page.getByRole('menu'); - await expect(menu).toBeVisible(); - - const surface = await page.evaluate(() => { - const el = document.querySelector('[role="menu"]'); - if (!el) return null; - // Walk out to whatever actually paints — the assertion must not care which - // node that is, only that the surface the user sees follows the recipe. - let node: Element | null = el; - while (node && node !== document.documentElement) { - const cs = getComputedStyle(node); - if (cs.backgroundColor && !/rgba\(0, 0, 0, 0\)|transparent/.test(cs.backgroundColor)) { - return { - radius: cs.borderTopLeftRadius, - borderWidth: cs.borderTopWidth, - borderStyle: cs.borderTopStyle, - overflow: cs.overflow, - shadow: cs.boxShadow, - }; - } - node = node.parentElement; - } - return null; - }); - - expect(surface).not.toBeNull(); - // Ring present. Its colour is --border-soft; the load-bearing part is that a - // ring exists at all, since a ringless overlay leans entirely on the shadow. - expect(surface!.borderStyle).toBe('solid'); - expect(surface!.borderWidth).not.toBe('0px'); - // Container radius (DESIGN.md §6) with clipping, so children cannot square - // off the corners the surface just drew. - expect(surface!.radius).toBe('12px'); - expect(surface!.overflow).toBe('hidden'); - // Overlay elevation, not raised. The two scales differ in the second layer's - // blur — overlay is 12px where raised is 8px — which is the cheapest stable - // way to tell them apart without pinning the whole shadow string. - expect(surface!.shadow).toContain('12px'); - expect(surface!.shadow).not.toContain('4px 8px'); -}); diff --git a/apps/desktop/e2e/mcp.spec.ts b/apps/desktop/e2e/mcp.spec.ts deleted file mode 100644 index d5b68c6478..0000000000 --- a/apps/desktop/e2e/mcp.spec.ts +++ /dev/null @@ -1,133 +0,0 @@ -import path from 'node:path'; -import { test, expect } from './fixtures.js'; - -const fixtureServer = path.resolve( - process.cwd(), - '../../packages/mcp/dist/__fixtures__/stdio-server.js', -); - -test('MCP module completes stdio add, discovery, disable, JSON import, and delete', async ({ window: page }) => { - await page.getByRole('button', { name: '展开侧边栏' }).click(); - const sidebar = page.getByRole('navigation', { name: '对话列表' }); - const extensions = sidebar.getByRole('button', { name: '扩展', exact: true }); - await expect(sidebar.getByRole('button', { name: '技能', exact: true })).toHaveCount(0); - await expect(sidebar.getByRole('button', { name: 'MCP', exact: true })).toHaveCount(0); - await extensions.click(); - await expect(extensions).toHaveAttribute('aria-current', 'page'); - await expect(sidebar.getByRole('radiogroup', { name: '会话分组方式' })).toBeVisible(); - await expect(sidebar.locator('.maka-session-list')).toBeVisible(); - - const extensionSelector = page.locator('.maka-module-hub-selector'); - await expect(extensionSelector).toHaveAccessibleName('扩展内容:技能'); - await extensionSelector.getByRole('button', { name: 'MCP' }).click(); - const mcp = page.getByRole('main', { name: '扩展' }); - await expect(mcp.getByRole('heading', { name: '扩展' })).toBeVisible(); - await expect(mcp.getByRole('toolbar', { name: 'MCP 浏览操作' })).toBeVisible(); - await expect(extensionSelector).toHaveAccessibleName('扩展内容:MCP'); - await expect(mcp.getByText('把 Maka 连接到你的工作环境')).toBeVisible(); - await expect(mcp.locator('[data-maka-contract="module-actions"]').getByRole('button')).toHaveCount(2); - await expect(mcp.getByRole('button', { name: '刷新', exact: true })).toBeVisible(); - - // Each hub restores its last module when the user returns from another - // sidebar destination. - await sidebar.getByRole('button', { name: '定时任务', exact: true }).click(); - await extensions.click(); - await expect(page.getByRole('main', { name: '扩展' })).toBeVisible(); - await expect(extensionSelector).toHaveAccessibleName('扩展内容:MCP'); - - await mcp.getByRole('button', { name: '添加 MCP' }).click(); - const editor = page.getByRole('dialog', { name: '添加 MCP' }); - await expect(editor.getByLabel('服务器 ID')).toBeFocused(); - await expect(editor.locator('label').filter({ hasText: '服务器 ID' })).toBeVisible(); - await expect(editor.locator('label').filter({ hasText: '命令' })).toBeVisible(); - await expect(editor.locator('label').filter({ hasText: '参数' })).toBeVisible(); - await expect(editor.locator('label').filter({ hasText: '工作目录' })).toBeVisible(); - await expect(editor.locator('label').filter({ hasText: '环境变量' })).toBeVisible(); - const environmentBox = await editor.getByLabel('环境变量').boundingBox(); - const workingDirectoryBox = await editor.getByLabel('工作目录').boundingBox(); - expect(environmentBox).not.toBeNull(); - expect(workingDirectoryBox).not.toBeNull(); - expect(environmentBox!.y).toBeLessThan(workingDirectoryBox!.y); - await expect(editor.getByText('高级设置', { exact: true })).toHaveCount(0); - await editor.getByRole('button', { name: '保存并连接' }).click(); - await expect(editor.getByLabel('服务器 ID')).toHaveAttribute('aria-invalid', 'true'); - await expect(editor.getByLabel('命令')).toHaveAttribute('aria-invalid', 'true'); - await editor.getByLabel('服务器 ID').fill('e2e-fixture'); - await expect(editor.getByLabel('命令')).toHaveAttribute('aria-invalid', 'true'); - await editor.getByRole('radio', { name: '远程 URL' }).click(); - await expect(editor.locator('label').filter({ hasText: '传输协议' })).toBeVisible(); - await expect(editor.locator('label').filter({ hasText: 'HTTP 请求头' })).toBeVisible(); - await expect(editor.getByText('高级设置', { exact: true })).toHaveCount(0); - await editor.getByRole('radio', { name: '本地 stdio' }).click(); - await editor.getByLabel('命令').fill(process.execPath); - await editor.getByLabel('参数').fill(fixtureServer); - await editor.getByRole('button', { name: '保存并连接' }).click(); - - // Saving lands on 已安装; the row shows the server and its live tool count. - const fixtureRow = mcp.getByRole('button', { name: /e2e-fixture/ }); - await expect(fixtureRow).toBeVisible(); - await expect(mcp.getByText('把 Maka 连接到你的工作环境')).toHaveCount(0); - await expect(mcp.getByText(/^本地 stdio ·/)).toBeVisible(); - await expect(mcp.getByText(/4 个工具/)).toBeVisible(); - - const config = await page.evaluate(() => window.maka.mcp.getConfig()); - expect(config.mcpServers['e2e-fixture']).toMatchObject({ - enabled: true, - command: process.execPath, - args: [fixtureServer], - }); - - // Selecting the row opens the inspector: discovered tools, edit, enable - // switch and delete all live there now. - await fixtureRow.click(); - const inspector = mcp.getByRole('complementary', { name: '服务器详情' }); - await expect(inspector.getByText('echo', { exact: true })).toBeVisible(); - await expect(inspector.getByText('rich', { exact: true })).toBeVisible(); - - const edit = inspector.getByRole('button', { name: '编辑', exact: true }); - await edit.click(); - const editDialog = page.getByRole('dialog', { name: '编辑 e2e-fixture' }); - await expect(editDialog.getByLabel('服务器 ID')).toBeDisabled(); - await expect(editDialog.getByLabel('命令')).toBeFocused(); - await page.keyboard.press('Escape'); - await expect(editDialog).toBeHidden(); - await expect(edit).toBeFocused(); - - await inspector.getByRole('switch', { name: '启用' }).click(); - await expect.poll(async () => { - const next = await page.evaluate(() => window.maka.mcp.getConfig()); - return next.mcpServers['e2e-fixture']?.enabled; - }).toBe(false); - await expect(inspector.getByRole('switch', { name: '启用' })).not.toBeChecked(); - - // Import a second server BEFORE the delete: with one row left behind, the - // delete below can prove where focus goes — the contract the empty-list - // path cannot exercise. - await mcp.getByRole('button', { name: '添加 MCP' }).click(); - await page.getByRole('dialog', { name: '添加 MCP' }).getByRole('radio', { name: '粘贴 JSON' }).click(); - const jsonEditor = page.getByRole('dialog', { name: '通过 JSON 导入' }); - await jsonEditor.getByLabel('JSON 配置').fill(JSON.stringify({ - mcpServers: { - 'remote-disabled': { url: 'https://example.com/mcp', enabled: false }, - }, - })); - await jsonEditor.getByRole('button', { name: '导入并连接' }).click(); - await expect(mcp.getByText('remote-disabled', { exact: true })).toBeVisible(); - await expect.poll(async () => { - const next = await page.evaluate(() => window.maka.mcp.getConfig()); - return next.mcpServers['remote-disabled']; - }).toMatchObject({ url: 'https://example.com/mcp', enabled: false }); - - // The import's view switch dropped the selection (it belongs to the view - // it was made in), so reopen the inspector before deleting. - await mcp.getByRole('button', { name: /e2e-fixture/ }).click(); - await inspector.getByRole('button', { name: '删除', exact: true }).click(); - await page.getByRole('alertdialog').getByRole('button', { name: '删除', exact: true }).click(); - await expect.poll(async () => { - const next = await page.evaluate(() => window.maka.mcp.getConfig()); - return next.mcpServers['e2e-fixture']; - }).toBeUndefined(); - // Focus lands on the row that took the deleted one's place — not on body, - // which would drop a keyboard user at the top of the document. - await expect(mcp.getByRole('button', { name: /remote-disabled/ })).toBeFocused(); -}); diff --git a/apps/desktop/e2e/new-messages-indicator.spec.ts b/apps/desktop/e2e/new-messages-indicator.spec.ts deleted file mode 100644 index 1b8e03ad3f..0000000000 --- a/apps/desktop/e2e/new-messages-indicator.spec.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { test, expect, COMPOSER_INPUT } from './fixtures'; - -/** - * #2205 regression: the floating "New messages" scroll-to-bottom indicator - * must clear once the user is back at the bottom, and must never leak into - * another conversation. - * - * Astryx ChatLayout flags `hasNewMessages` whenever the last - * `.astryx-chat-message` node changes while the scroller is unlocked, and used - * to clear it only through the button's `dismiss()`. The fix (a) clears the - * flag on every re-lock (`scrollend` settled within the lock threshold, i.e. - * the user is at the bottom), and (b) resets the new-message baseline and - * scroll lock on conversation switches: Maka passes the active session id as - * `conversationKey` to ChatLayout, which re-locks the scroll and clears the - * previous conversation's flag and last-message baseline without remounting - * the layout (a remount would drop an in-progress composer draft). - * - * Button labels are locale-dependent: Astryx ships en ("New messages" / - * "Scroll to bottom"), but Maka's Astryx copy overrides (astryx-copy.ts) map - * them to zh ("跳到最新消息" / "滚动到底部") under the default zh fixture - * locale. The specs match both so they stay green under either locale — the - * assertion is about the affordance appearing/clearing, not about Astryx's - * copy. Message-echo assertions use `{ exact: true }`: the prompt anchor rail - * renders a preview of the sent text (longer than the echo), which would - * otherwise make the loose regex match two nodes. - * - * The `window` fixture starts an empty session whose composer send path is - * exercised by send-message.spec.ts; this spec first grows the transcript - * past the viewport with an over-long message so the scroll-to-bottom - * affordances actually come into play. - */ - -function scrollToTop(page: import('@playwright/test').Page) { - return page.evaluate(() => { - const el = document.querySelector('[data-chat-scroll-container="true"]'); - if (!el) throw new Error('chat scroll container not found'); - el.scrollTop = 0; - el.dispatchEvent(new Event('scroll')); - }); -} - -async function scrollToBottom(page: import('@playwright/test').Page) { - await page.evaluate(() => { - const el = document.querySelector('[data-chat-scroll-container="true"]'); - if (!el) throw new Error('chat scroll container not found'); - el.scrollTop = el.scrollHeight; - el.dispatchEvent(new Event('scroll')); - // scrollend is a UA-dispatched event; the Astryx scroller listens for it - // natively, and the fixture's programmatic jump does not guarantee the - // browser emits one, so dispatch it explicitly to settle the lock. - el.dispatchEvent(new Event('scrollend')); - }); - // Astryx's synthetic-scroll guard bails out of the scroll handler while - // scrollHeight is still changing, which a programmatic jump right after the - // reply settles can trip; a real user's scroll keeps firing until the - // layout stops. Wait for the layout fact itself — scrollHeight stable across - // two consecutive animation frames — then drive one more cycle so - // `isScrolledUp` follows the final position. - await page.waitForFunction(() => { - const el = document.querySelector('[data-chat-scroll-container="true"]'); - if (!el) return false; - const sampled = (window as { __scrollHeightSample?: number }).__scrollHeightSample; - (window as { __scrollHeightSample?: number }).__scrollHeightSample = el.scrollHeight; - return sampled === el.scrollHeight; - }); - await page.evaluate(() => { - const el = document.querySelector('[data-chat-scroll-container="true"]'); - el?.dispatchEvent(new Event('scroll')); - el?.dispatchEvent(new Event('scrollend')); - }); -} - -async function growTranscriptOverflow(page: import('@playwright/test').Page) { - // Short viewport: a handful of normal messages overflow the transcript. - await page.setViewportSize({ width: 900, height: 420 }); - const composer = page.locator(COMPOSER_INPUT); - await expect(composer).toHaveAttribute('aria-label', '消息输入框'); - // The fake backend echoes the full input, so keep messages short — a long - // one streams hundreds of 9-char chunks and stalls the turn for 20s+. - // Astryx only flags the scrolled-up state past buttonThreshold (100px), so - // "overflowed" must mean past that threshold, not merely scrollable — on a - // compact Linux font the 3-message baseline can land between 1px and 100px - // and the scroll button never appears (e2e flake on CI). - for (let i = 1; i <= 6; i++) { - await composer.fill(`make the transcript overflow ${i}`); - await composer.press('Enter'); - await expect(page.getByText(`Fake backend received: make the transcript overflow ${i}`, { exact: true })).toBeAttached({ timeout: 20_000 }); - // The settle signal is the turn footer switching to its settled action - // ("重新生成"): exactly i settled turns after the i-th reply. Counting (not - // .last()) also proves every earlier turn settled, and .maka-bubble-streaming - // is unusable here — it can read 0 before the stream even starts. - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(i, { timeout: 20_000 }); - const { sh, ch } = await page.evaluate(() => { - const el = document.querySelector('[data-chat-scroll-container="true"]'); - return el ? { sh: el.scrollHeight, ch: el.clientHeight } : { sh: 0, ch: 0 }; - }); - // 150px = Astryx buttonThreshold (100) + 50px headroom for font/layout drift. - if (sh - ch > 150) return; - } - throw new Error('transcript did not overflow past the 100px button threshold after 6 messages'); -} - -test('clears the "New messages" indicator at the bottom and never leaks it into a new conversation', async ({ window: page }) => { - // Locale-agnostic matchers: zh overrides ("跳到最新消息"/"滚动到底部") or - // Astryx en ("New messages"/"Scroll to bottom") — see the file header. - const newMessagesBtn = page.getByRole('button', { name: /New messages|跳到最新消息/ }); - const scrollToBottomBtn = page.getByRole('button', { name: /Scroll to bottom|滚动到底部/ }); - - // Grow the transcript past the viewport, then pin to the bottom (the - // fixture boots there): no indicator. - await growTranscriptOverflow(page); - await scrollToBottom(page); - await expect(newMessagesBtn).toHaveCount(0); - - // Scroll up: the scroller unlocks and the plain scroll-to-bottom button - // appears. - await scrollToTop(page); - await expect(scrollToBottomBtn).toBeVisible(); - - // A reply lands while the scroller is unlocked → the indicator flags - // "New messages". The reply renders at the bottom of the transcript, which - // is out of view after the scroll-up, so assert attachment, not visibility. - const composer = page.locator(COMPOSER_INPUT); - await expect(composer).toHaveAttribute('aria-label', '消息输入框'); - await composer.fill('trigger new-message indicator'); - await composer.press('Enter'); - await expect(page.getByText('Fake backend received: trigger new-message indicator', { exact: true })).toBeAttached(); - await expect(newMessagesBtn).toBeVisible(); - // Let this reply settle too: if it were still streaming, the final - // scrollend's distance-from-bottom read could land above the lock threshold - // mid-chunk and skip the re-lock this test asserts. - await expect(page.getByRole('button', { name: '重新生成' }).last()).toBeAttached({ timeout: 20_000 }); - - // Scroll all the way back down: scrollend settles within the lock threshold - // → auto-follow re-locks → the indicator must clear (#2205). - await scrollToBottom(page); - await expect(newMessagesBtn).toHaveCount(0); - - // Phase 2 (#2205's second half), reusing the transcript grown above instead - // of paying the six-message seed again: re-flag the conversation, then prove - // the indicator does not leak into a brand-new conversation. - // - // Every turn must be fully settled first — a send while one still streams - // becomes steering, and no fresh reply would arrive to flag. The transcript - // length is dynamic (growTranscriptOverflow stops at overflow), so settle - // means: as many settled-turn footers as sent messages. - await expect - .poll( - async () => { - const [settled, sent] = await Promise.all([ - page.getByRole('button', { name: '重新生成' }).count(), - page.getByLabel('你发送的消息').count(), - ]); - return sent > 0 && settled === sent; - }, - { timeout: 20_000 }, - ) - .toBe(true); - await scrollToTop(page); - await composer.fill('flag this conversation'); - await composer.press('Enter'); - await expect(page.getByText('Fake backend received: flag this conversation', { exact: true })).toBeAttached(); - await expect(newMessagesBtn).toBeVisible(); - - // Start a brand-new conversation from the sidebar. - const expand = page.getByRole('button', { name: '展开侧边栏' }); - if (await expand.count()) await expand.click(); - await page.getByRole('button', { name: '新任务' }).click(); - await expect(composer).toHaveAttribute('aria-label', '消息输入框'); - - // The fresh conversation must not inherit the indicator… - await expect(newMessagesBtn).toHaveCount(0); - - // …and its first message must not re-trigger it while pinned at the bottom. - await composer.fill('first message of the new conversation'); - await composer.press('Enter'); - await expect(page.getByText('Fake backend received: first message of the new conversation', { exact: true })).toBeAttached(); - await expect(newMessagesBtn).toHaveCount(0); -}); diff --git a/apps/desktop/e2e/oauth-refresh.spec.ts b/apps/desktop/e2e/oauth-refresh.spec.ts deleted file mode 100644 index 802f171ee2..0000000000 --- a/apps/desktop/e2e/oauth-refresh.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { expect, test } from './fixtures.js'; - -test('Codex OAuth completion refreshes the open connection detail without leaving it', async ({ - oauthReloginWindow: page, -}) => { - const detail = page.locator('[data-maka-contract="connection-detail"]'); - - await expect(detail).toBeVisible(); - await expect(detail.getByText('等待 OAuth 登录')).toBeVisible(); - - await detail.getByRole('button', { name: '登录', exact: true }).click(); - - // The fixture completes through the real preload/IPC/login-hook path. The - // detail stays on screen throughout: success must update it in place, with - // no bounce back to the list and no close/reopen cycle. - await expect(detail).toBeVisible(); - await expect(detail.getByText('OAuth 已登录', { exact: true })).toBeVisible(); - - // And the refresh reached the backing list, not just the page in front of - // it: the row is healthy on return. - await page.getByRole('button', { name: '返回模型连接', exact: true }).click(); - const connectionRow = page.locator('[data-connection-slug="codex-subscription"]'); - await expect(connectionRow).toBeVisible(); - await expect(connectionRow).not.toContainText('需要重新登录'); - await expect(connectionRow).not.toHaveAttribute('data-disabled', 'true'); -}); diff --git a/apps/desktop/e2e/onboarding.spec.ts b/apps/desktop/e2e/onboarding.spec.ts deleted file mode 100644 index b22b106b75..0000000000 --- a/apps/desktop/e2e/onboarding.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { COMPOSER_INPUT, test, expect } from './fixtures'; - -test('first run connects a provider and starts the first task without workspace defaults', async ({ firstRunWindow: page }) => { - const onboarding = page.locator('[data-maka-contract="onboarding-card"]'); - - await expect(onboarding.getByRole('heading', { name: '接入一个 AI,开始第一项任务。' })).toBeVisible(); - await expect(onboarding.locator('.maka-onboarding-provider-row')).toHaveCount(4); - - await onboarding.locator('.maka-onboarding-provider-row[data-provider="opencode-free"]').click(); - - await expect(page.locator('[data-maka-contract="provider-setup"]')).toBeVisible(); - await expect(page.getByLabel('设置内容')).toBeVisible(); - - await page.getByRole('button', { name: '保存供应商', exact: true }).click(); - await expect(page.locator('[data-maka-contract="connection-detail"]')).toBeVisible(); - - // The production migration is removing workspace defaults. Reproduce that - // boundary deterministically while preserving the provider's enabled model - // inventory: the old onboarding state machine gets stuck here even though a - // new session can name this connection and model explicitly. - await page.evaluate(async () => { - await window.maka.connections.update('opencode-free', { defaultModel: '' }); - }); - await expect - .poll(() => - page.evaluate(async () => { - const connection = (await window.maka.connections.list()).find( - (candidate) => candidate.slug === 'opencode-free', - ); - return { - defaultSlug: await window.maka.connections.getDefault(), - defaultModel: connection?.defaultModel, - enabledModelIds: connection?.enabledModelIds, - }; - }), - ) - .toEqual({ - defaultSlug: null, - defaultModel: '', - enabledModelIds: [ - 'nemotron-3-ultra-free', - 'mimo-v2.5-free', - 'deepseek-v4-flash-free', - ], - }); - await page.getByRole('button', { name: '返回应用', exact: true }).click(); - - await expect(onboarding).toHaveCount(0); - await expect(page.locator(COMPOSER_INPUT)).toBeVisible(); - await expect(page.getByRole('button', { name: /选择新对话模型/ })).toHaveAccessibleName( - /Nemotron 3 Ultra Free/, - ); - - await page.locator(COMPOSER_INPUT).fill('完成首次任务'); - await page.locator(COMPOSER_INPUT).press('Enter'); - await expect - .poll(() => - page.evaluate(async () => { - const sessions = await window.maka.sessions.list(); - return sessions.map((session) => ({ - connectionSlug: session.llmConnectionSlug, - model: session.model, - })); - }), - ) - .toContainEqual({ connectionSlug: 'opencode-free', model: 'nemotron-3-ultra-free' }); -}); diff --git a/apps/desktop/e2e/permission-mode-surface.spec.ts b/apps/desktop/e2e/permission-mode-surface.spec.ts deleted file mode 100644 index 604fa10040..0000000000 --- a/apps/desktop/e2e/permission-mode-surface.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { expect, test, COMPOSER_INPUT } from './fixtures'; - -/** #1611: the live boundary update must cross main/renderer and survive reload. */ -test('approving an expansion updates the permission label at once and after a reload', async ({ - sandboxBoundaryWindow: page, -}) => { - const prompt = page.locator('.maka-sandbox-boundary-prompt'); - const trigger = page - .locator('.maka-composer-left-controls .permissionModeIcon') - .getByRole('button'); - - // The session runs read-only and is asking to write outside the workspace. - await expect(prompt).toHaveCount(1); - - await prompt.getByRole('button', { name: '本会话允许' }).click(); - await expect(prompt).toHaveCount(0); - - // #1611: the grant only bumps the boundary's revision — re-read authority. - await expect(trigger).toHaveAccessibleName('权限模式:自动'); - - await expect - .poll(() => - page.evaluate(async () => { - const state = await window.maka.e2eFixture.getState(); - return Object.keys(state?.sandboxBoundaryBySession ?? {}).length; - }), - ) - .toBe(0); - - await page.reload(); - await expect(page.locator(COMPOSER_INPUT)).toBeVisible(); - await expect(page.locator('.maka-boundary-unreadable-notice')).toHaveCount(0); - await expect(trigger).toHaveAccessibleName('权限模式:自动'); - - await expect(prompt).toHaveCount(0); - await expect(page.locator(COMPOSER_INPUT)).toBeVisible(); -}); diff --git a/apps/desktop/e2e/providers.spec.ts b/apps/desktop/e2e/providers.spec.ts deleted file mode 100644 index aa60813875..0000000000 --- a/apps/desktop/e2e/providers.spec.ts +++ /dev/null @@ -1,348 +0,0 @@ -// Provider add-flow E2E — representative journeys only. -// -// This suite deliberately keeps two journeys, NOT one clone per -// provider. The add flow (open settings → catalog → category → search → open → -// assert form defaults → save → assert detail + brand-mark render contract) is -// identical across every catalog provider, so exercising it once proves the -// mechanism. The per-provider *facts* it used to re-assert (label, base URL, -// default model, catalog group, and that a real brand mark is registered) are -// covered by registry-driven contract tests that auto-cover new providers with -// zero manual updates: -// - packages/core/src/__tests__/provider-catalog-contract.test.ts -// (structural invariants over CATALOG_PROVIDER_TYPES) -// - apps/desktop/src/main/__tests__/icon-governance-contract.test.ts -// ("renders a registered brand mark for every catalog provider") -// -// Adding a provider: do NOT copy an add-flow test here. The contract tests -// above cover its facts. Add an E2E only for a genuinely new *behavior* (a new -// credential field, a derived endpoint, a gating rule), not a new data point. - -import type { Page } from '@playwright/test'; -import { test, expect } from './fixtures'; - -/** 设置 → 模型. */ -async function openModelsPage(page: Page) { - await page.getByRole('button', { name: '展开侧边栏' }).click(); - await page.getByRole('button', { name: '设置' }).click(); - await page.locator('[aria-label="设置分组"]').getByText('模型', { exact: true }).click(); -} - -/** - * Walk to the catalog level and narrow it to one provider. - * - * The catalog is a page one level below the list, and its category is a - * Selector rather than a row of tabs — so reaching a provider is three named - * moves instead of a tab click plus a search. - */ -async function openCatalog(page: Page, options: { category: string; search: string }) { - await page.getByRole('button', { name: '添加连接', exact: true }).click(); - const catalog = page.locator('[data-maka-contract="provider-catalog"]'); - await expect(catalog).toBeVisible(); - // The catalog level lands on its search field before anything else is - // touched: it is what a user arrives here to do, and the shared route-focus - // hook is what puts them there. - const search = catalog.getByPlaceholder('搜索服务商'); - const category = catalog.getByRole('combobox', { name: '分类', exact: true }); - await expect(search).toBeFocused(); - // These are independent filters, not one composite toolbar: ordinary Tab - // order must move between them in both directions. - await page.keyboard.press('Tab'); - await expect(category).toBeFocused(); - await page.keyboard.press('Shift+Tab'); - await expect(search).toBeFocused(); - await category.click(); - await page.getByRole('option', { name: options.category, exact: true }).click(); - await catalog.getByPlaceholder('搜索服务商').fill(options.search); - return catalog; -} - -/** The provider setup level — one provider's form, or its account login. */ -function providerSetup(page: Page) { - return page.locator('[data-maka-contract="provider-setup"]'); -} - -/** The connection detail level. */ -function connectionDetail(page: Page) { - return page.locator('[data-maka-contract="connection-detail"]'); -} - -/** No level on this page is a modal; the delete confirm is the only one left. */ -async function expectNoDialog(page: Page) { - await expect(page.getByRole('dialog')).toHaveCount(0); -} - -// Canonical API-key journey then the two-field relay detail rules share one -// window: the add-journey deletes what it created, which is the clean state -// the relay phase needs. Header geometry was removed with #2478 as presentation. -test('provider connections: the canonical API-key journey and two-field rows', async ({ window: page }) => { - const panel = page.locator('[data-maka-contract="providers-panel"]'); - // One window, five named steps. Splitting these into separate tests would buy - // per-behavior isolation at the price of four more Electron cold starts; the - // steps give a failing run the same "which behavior broke" answer in the - // trace without that cost. - const setup = providerSetup(page); - const keyInput = setup.getByRole('textbox', { name: /API Key/ }); - const detail = connectionDetail(page); - const connection = page.getByRole('button', { name: /模型连接:Cerebras/ }); - - await test.step('the catalog reaches Cerebras and renders its color brand asset untouched', async () => { - await openModelsPage(page); - await expect(page.getByLabel('设置内容')).toBeVisible(); - const catalog = await openCatalog(page, { category: 'API', search: 'Cerebras' }); - - // A color brand asset renders as an untouched : no currentColor mask, - // no CSS paint, no color filter — and stays invariant across the theme flip. - const catalogMark = catalog.locator('.providerCatalogRow[data-provider="cerebras"] .providerLogo img'); - await expect(catalogMark).toBeVisible(); - expect(await catalogMark.evaluate(colorAssetRenderContract)).toEqual(COLOR_ASSET_RENDER_CONTRACT); - await page.evaluate(() => document.documentElement.classList.add('dark')); - expect(await catalogMark.evaluate(colorAssetRenderContract)).toEqual(COLOR_ASSET_RENDER_CONTRACT); - }); - - await test.step('picking a provider navigates to its setup level', async () => { - await page.getByRole('button', { name: /添加模型供应商:Cerebras/ }).click(); - await expect(setup).toBeVisible(); - // One container throughout: the catalog is replaced, not stacked behind a - // modal, and the way back to it is the level's own back control. - await expectNoDialog(page); - await expect(page.locator('[data-maka-contract="provider-catalog"]')).toHaveCount(0); - await expect(page.getByRole('button', { name: '返回服务商列表', exact: true })).toBeVisible(); - await expect(keyInput).toBeFocused(); - await expect(keyInput).toHaveAttribute('type', 'password'); - await expect(page.getByText('完成必要配置后,连接会出现在模型页上方。')).toBeVisible(); - await expect(keyInput).toHaveAttribute('placeholder', '输入或粘贴 API Key'); - await expect(setup.getByLabel('连接标识', { exact: true })).toHaveCount(0); - await expect(setup.getByLabel('服务地址', { exact: true })).toHaveCount(0); - await expect(setup.getByLabel('默认模型', { exact: true })).toHaveCount(0); - - // A 300-character key scrolls inside the field instead of growing it. The - // before/after values are the oracle; the field's width is a design token, - // not this contract. - const inputBox = await keyInput.boundingBox(); - await keyInput.fill(`sk-${'a'.repeat(300)}`); - const longKeyLayout = await keyInput.evaluate((input) => ({ - clientWidth: input.clientWidth, - scrollWidth: input.scrollWidth, - clientHeight: input.clientHeight, - scrollHeight: input.scrollHeight, - })); - expect(longKeyLayout.scrollWidth).toBeGreaterThan(longKeyLayout.clientWidth); - expect(longKeyLayout.scrollHeight).toBe(longKeyLayout.clientHeight); - expect((await keyInput.boundingBox())?.height).toBe(inputBox?.height); - }); - - await test.step('saving creates the connection and lands on its detail level', async () => { - await keyInput.fill('e2e-cerebras-key'); - await page.getByRole('button', { name: '保存供应商', exact: true }).click(); - - // Creating a connection is the start of setting it up, so the save lands on - // the page that owns every next move — no hunting for the new row. - await expect(setup).toHaveCount(0); - await expect(detail).toBeVisible(); - // The level itself takes focus, not its back button, and it is a region - // named by its own heading so the landing is announced. - await expect(detail).toBeFocused(); - await expect(detail).toHaveAttribute('role', 'region'); - await expect(page.getByRole('region', { name: 'Cerebras' })).toBeVisible(); - const detailMark = detail.locator('.providerLogo[data-provider="cerebras"] img'); - await expect(detailMark).toBeVisible(); - expect(await detailMark.evaluate(colorAssetRenderContract)).toEqual(COLOR_ASSET_RENDER_CONTRACT); - }); - - await test.step('going back lands where the user came from', async () => { - await page.getByRole('button', { name: '返回模型连接', exact: true }).click(); - await expect(detail).toHaveCount(0); - // This detail was reached by saving a new provider, not by opening a row, - // so there is no row to go back to and the primary action takes the ring. - await expect(page.getByRole('button', { name: '添加连接', exact: true })).toBeFocused(); - - // Opened from a row, the way back is that row — the ring returns to where - // the user left, not to the top of the list. - await connection.click(); - await expect(detail).toBeVisible(); - await page.getByRole('button', { name: '返回模型连接', exact: true }).click(); - await expect(connection).toBeFocused(); - await connection.click(); - await expect(detail).toBeVisible(); - }); - - await test.step('the detail replaces a key and manages enabled and default models', async () => { - // A settled credential is a row, not a form: it reports its state and - // carries one control. The input only exists while the user is changing it. - const connectionSection = detail.getByRole('region', { name: '连接' }); - await expect(connectionSection.getByText('已设置', { exact: true })).toBeVisible(); - await expect(connectionSection.getByRole('textbox', { name: /模型密钥/ })).toHaveCount(0); - - await connectionSection.getByRole('button', { name: '更换', exact: true }).click(); - const detailKeyField = connectionSection.getByRole('textbox', { name: /模型密钥/ }); - await expect(detailKeyField).toBeVisible(); - const saveKey = connectionSection.getByRole('button', { name: '保存', exact: true }); - await expect(saveKey).toBeDisabled(); - await detailKeyField.fill('sk-e2e-replacement-key'); - await expect(saveKey).toBeEnabled(); - // Cancel restores the row, discarding the draft. - await connectionSection.getByRole('button', { name: '取消', exact: true }).click(); - await expect(connectionSection.getByRole('textbox', { name: /模型密钥/ })).toHaveCount(0); - - const modelSection = detail.getByRole('region', { name: '模型' }); - await expect(modelSection).toBeVisible(); - - // Enabled models are a MultiSelector, not a wall of checkboxes: Astryx - // scopes CheckboxList to 3–7 options and a provider lists far more. - // The MultiSelector trigger is a listbox-popup button; role=combobox belongs - // to the search input inside the popup. - const enabledModels = modelSection.getByRole('button', { name: '启用的模型', exact: true }); - await enabledModels.click(); - // No option is locked, and unchecking one is not silently undone: the - // detail page no longer owns a "default model" that has to stay enabled. - // Which model a new chat starts on lives on 设置 · 通用, which is the one - // control for that pair. - await expect(page.getByRole('option', { name: /GPT OSS 120B/ })).not.toHaveAttribute('aria-disabled', 'true'); - await expect(modelSection.getByText('默认模型', { exact: true })).toHaveCount(0); - await page.getByRole('option', { name: /Gemma/ }).first().click(); - await page.keyboard.press('Escape'); - await expect(enabledModels).toContainText(/Gemma/); - - // Unchecking the connection's own default model is not silently undone. - // The store used to merge the default back into every write, so this click - // computed an identical list, short-circuited, and the box re-checked - // itself. A default that is no longer enabled is simply no longer a - // default. - await enabledModels.click(); - await page.getByRole('option', { name: /GPT OSS 120B/ }).first().click(); - await page.keyboard.press('Escape'); - await expect - .poll(async () => page.evaluate(async () => { - const list = await window.maka.connections.list(); - const entry = list.find((candidate) => candidate.slug === 'cerebras'); - return { enabled: entry?.enabledModelIds ?? null, model: entry?.defaultModel ?? null }; - })) - .toEqual({ enabled: ['gemma-4-31b'], model: '' }); - }); - - await test.step('deletion stays reachable and reversible in a short viewport', async () => { - // Short-viewport invariant: the detail is a page, so the settings content - // area owns the scrolling and the trailing action stays reachable. The test - // asserts reachability, not which node scrolls. - const cdp = await page.context().newCDPSession(page); - await cdp.send('Emulation.setDeviceMetricsOverride', { - width: 1000, - height: 500, - deviceScaleFactor: 1, - mobile: false, - }); - - const deleteButton = detail.getByRole('button', { name: '删除', exact: true }); - await deleteButton.scrollIntoViewIfNeeded(); - await expect(deleteButton).toBeInViewport(); - await deleteButton.click(); - const confirm = page.getByRole('alertdialog'); - await expect(confirm).toBeVisible(); - await confirm.getByRole('button', { name: '取消', exact: true }).click(); - await expect(confirm).toBeHidden(); - - // Confirming deletion refreshes the backing list before the route changes, - // then returns to the list with focus on its primary action — the row the - // user came from no longer exists. - await deleteButton.click(); - await expect(confirm).toBeVisible(); - await confirm.getByRole('button', { name: '删除', exact: true }).click(); - await expect(confirm).toBeHidden(); - await expect(detail).toHaveCount(0); - await expect(page.getByRole('button', { name: '添加连接', exact: true })).toBeFocused(); - await expect(connection).toHaveCount(0); - await expectNoDialog(page); - await cdp.send('Emulation.clearDeviceMetricsOverride'); - }); - - // Distinct detail behavior: the only providers with BOTH settled rows are the - // ones whose address is genuinely the user's. One row is a form at a time, so - // these two are also the only place the cross-row rules can be observed — a - // single-row provider (Cerebras, Cloudflare) cannot show them at all. - await openCatalog(page, { category: '聚合服务', search: '自定义中转站' }); - await page.getByRole('button', { name: /添加模型供应商:自定义中转站(OpenAI Chat)/ }).click(); - - const relaySetup = providerSetup(page); - await relaySetup.getByRole('textbox', { name: /服务地址/ }).fill('https://relay.example.com/v1'); - await relaySetup.getByRole('textbox', { name: /API Key/ }).fill('e2e-relay-key'); - await relaySetup.getByRole('textbox', { name: /默认模型/ }).fill('relay-model'); - await page.getByRole('button', { name: '保存供应商', exact: true }).click(); - - const relayDetail = connectionDetail(page); - await expect(relayDetail).toBeVisible(); - const connectionSection = relayDetail.getByRole('region', { name: '连接' }); - // A relay publishes no endpoint, so the address is the user's to type and the - // row exists. `gemini-cli` used to reach this branch too — it is OAuth with an - // empty baseUrl, and keying the row off "OAuth with a fixed URL" let it past. - await expect(connectionSection.getByText('服务地址', { exact: true })).toBeVisible(); - await expect(connectionSection.getByText('https://relay.example.com/v1')).toBeVisible(); - - // Abandon a key draft by opening the OTHER row rather than cancelling it. - await connectionSection.getByRole('button', { name: '更换', exact: true }).click(); - await connectionSection.getByRole('textbox', { name: /模型密钥/ }).fill('sk-never-confirmed'); - await connectionSection.getByRole('button', { name: '编辑', exact: true }).click(); - - // The endpoint row opens on the saved address, and saving it writes only the - // address: the patch used to carry both fields whichever row asked for it, so - // the key the user never confirmed rode along with it. - const endpointInput = connectionSection.getByRole('textbox', { name: '服务地址', exact: true }); - await expect(endpointInput).toHaveValue('https://relay.example.com/v1'); - await endpointInput.fill('https://relay.example.com/v2'); - await connectionSection.getByRole('button', { name: '保存', exact: true }).click(); - await expect(connectionSection.getByText('https://relay.example.com/v2')).toBeVisible(); - // The credential is untouched by the endpoint's save. A both-fields patch - // would have sent the blanked key, which the IPC boundary reads as "delete - // this secret" — so the row would report 尚未设置 instead. - await expect(connectionSection.getByText('已设置', { exact: true })).toBeVisible(); - - // And the abandoned key is gone rather than waiting in state for the next - // time the user opens its row. - await connectionSection.getByRole('button', { name: '更换', exact: true }).click(); - await expect(connectionSection.getByRole('textbox', { name: /模型密钥/ })).toHaveValue(''); - - // The same rule in the other direction, and the one the store can answer: - // abandon an ENDPOINT draft, then save the key row. The address the user - // never confirmed must not ride along with it. - await connectionSection.getByRole('button', { name: '编辑', exact: true }).click(); - await endpointInput.fill('https://relay.example.com/never-confirmed'); - await connectionSection.getByRole('button', { name: '更换', exact: true }).click(); - await connectionSection.getByRole('textbox', { name: /模型密钥/ }).fill('sk-e2e-relay-replacement'); - await connectionSection.getByRole('button', { name: '保存', exact: true }).click(); - await expect - .poll(async () => page.evaluate(async () => { - const list = await window.maka.connections.list(); - return list.find((entry) => entry.providerType === 'openai-compatible')?.baseUrl ?? null; - })) - .toBe('https://relay.example.com/v2'); - - // Final phase: the count mirrors the collection. Empty the whole collection - // (the seeded connection and the relay added above) and the count is gone. - await page.evaluate(async () => { - for (const entry of await window.maka.connections.list()) { - await window.maka.connections.delete(entry.slug); - } - }); - await page.reload(); - await page.getByRole('button', { name: '设置' }).click(); - await page.locator('[aria-label="设置分组"]').getByText('模型', { exact: true }).click(); - await expect(panel.getByText(/^· \d+$/)).toHaveCount(0); -}); - -const COLOR_ASSET_RENDER_CONTRACT = { - usesAssetMask: false, - hasCssPaint: false, - hasColorFilter: false, -}; - -function colorAssetRenderContract(element: Element): { - usesAssetMask: boolean; - hasCssPaint: boolean; - hasColorFilter: boolean; -} { - const style = getComputedStyle(element); - return { - usesAssetMask: style.maskImage !== 'none', - hasCssPaint: style.backgroundColor !== 'rgba(0, 0, 0, 0)', - hasColorFilter: style.filter !== 'none' || style.opacity !== '1', - }; -} diff --git a/apps/desktop/e2e/quote-companion.spec.ts b/apps/desktop/e2e/quote-companion.spec.ts deleted file mode 100644 index cd3620d0ed..0000000000 --- a/apps/desktop/e2e/quote-companion.spec.ts +++ /dev/null @@ -1,799 +0,0 @@ -import type { Page } from '@playwright/test'; -import { test, expect, COMPOSER_INPUT } from './fixtures'; - -async function openSideConversationFromLauncher(page: Page) { - await page.getByRole('button', { name: '打开工作栏工具' }).click(); - await page.getByRole('menuitem', { name: /侧边对话/ }).click(); - await expect( - page - .locator( - '.maka-workbar-tab[data-active][data-workbar-tab-id^="side-chat:"]', - ) - .getByRole('tab'), - ).not.toHaveAttribute('aria-busy', 'true'); -} - -async function waitForSourceSessionToSettle(page: Page) { - await expect - .poll(async () => { - const [source] = await page.evaluate(() => window.maka.sessions.list()); - return source?.status; - }) - .not.toBe('running'); -} - -/** - * The selection affordance's timing contract, in one transcript: the settle - * delay, Escape dismissal, composer exemption, the immediate hide when a new - * selection starts, and scroll-following. Each phase grows the same - * conversation rather than paying a fresh launch and reseed. - */ -test('the quote layer: settle timing, Escape, immediate hide, and scroll following', async ({ - window: page, -}) => { - test.slow(); - await page.setViewportSize({ width: 1400, height: 900 }); - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('selection timing source'); - await composer.press('Enter'); - - const reply = page.getByText(/Fake backend received: selection timing source/); - await expect(reply).toBeVisible(); - await expect(composer).toBeEditable(); - - const quoteLayer = page.locator('.maka-quote-actions'); - const selectContents = (locator: typeof reply) => - locator.evaluate((element) => { - const range = document.createRange(); - range.selectNodeContents(element); - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); - }); - - // Real drag-select with real mouse events. A drag emits a burst of - // `selectionchange`, and the gesture below lasts well past the settle delay, - // so a layer that appeared mid-drag — the original complaint — shows up here. - let replyBox: { x: number; y: number; width: number; height: number } | null = null; - await expect - .poll(async () => { - replyBox = await reply.boundingBox(); - return replyBox; - }) - .not.toBeNull(); - if (!replyBox) throw new Error('settled selection reply has no visible bounds'); - const dragY = replyBox.y + replyBox.height / 2; - await page.mouse.move(replyBox.x + 20, dragY); - await page.mouse.down(); - for (const dx of [60, 120, 180, 240, 300]) { - await page.mouse.move(replyBox.x + 20 + dx, dragY); - await page.evaluate(() => - document.dispatchEvent(new Event('selectionchange')), - ); - await page.waitForTimeout(90); - await expect(quoteLayer).toBeHidden(); - } - await page.mouse.up(); - await expect(quoteLayer).toBeVisible(); - - // Back to no selection, so the measurement below times a fresh appearance - // rather than finding the layer this drag already raised. - await page.evaluate(() => window.getSelection()?.removeAllRanges()); - await expect(quoteLayer).toBeHidden(); - - // Timed inside the page: measuring across the driver would fold IPC latency - // into the delay and make the assertion depend on host load. - const appearedAfterMs = await reply.evaluate(async (element) => { - const range = document.createRange(); - range.selectNodeContents(element); - const selection = window.getSelection(); - selection?.removeAllRanges(); - const startedAt = performance.now(); - selection?.addRange(range); - await new Promise((resolve, reject) => { - const deadline = startedAt + 3000; - const poll = () => { - if (document.querySelector('.maka-quote-actions')) resolve(); - else if (performance.now() > deadline) reject(new Error('quote layer never appeared')); - else requestAnimationFrame(poll); - }; - poll(); - }); - return performance.now() - startedAt; - }); - // A selection that is still moving has not earned the layer yet: this delay - // is the whole fix for "it appears the instant I select something". - expect(appearedAfterMs).toBeGreaterThan(300); - await expect(quoteLayer).toBeVisible(); - - // Escape dismisses, and the dismissal holds: the layer used to come back on - // the next unrelated keystroke because any keyup re-captured the selection. - await page.keyboard.press('Escape'); - await expect(quoteLayer).toBeHidden(); - await page.keyboard.press('Shift'); - // Real-timer negative window, derived from the hook's SELECTION_SETTLE_MS - // (350ms): a wrongly re-captured selection would surface the layer once that - // settle window elapses, so outliving it (with margin) proves the dismissal - // held. - await page.waitForTimeout(500); - await expect(quoteLayer).toBeHidden(); - - // Selecting inside the composer must not surface a transcript affordance — - // and must not leave the previous selection's layer standing either. Same - // SELECTION_SETTLE_MS-derived negative window as above. - await composer.fill('drafted text to select'); - await selectContents(composer); - await page.waitForTimeout(500); - await expect(quoteLayer).toBeHidden(); - - // A new selection hides the layer immediately, not when the next one - // settles (asserted within a frame — see the phase comment in the history). - await composer.fill('hide first beta'); - await composer.press('Enter'); - await expect(page.getByText(/Fake backend received: hide first beta/).last()).toBeVisible(); - await waitForSourceSessionToSettle(page); - - const select = (pattern: RegExp) => - page - .getByText(pattern) - .last() - .evaluate((element) => { - const range = document.createRange(); - range.selectNodeContents(element); - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); - }); - - await select(/Fake backend received: selection timing source/); - await expect(page.locator('.maka-quote-actions')).toBeVisible(); - - const stillVisibleAfterEvent = await page - .getByText(/Fake backend received: hide first beta/) - .last() - .evaluate(async (element) => { - const range = document.createRange(); - range.selectNodeContents(element); - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); - // Programmatic Selection mutations schedule `selectionchange` as a later - // task. Dispatch the user-visible event now and inspect its synchronous - // result, before the 350 ms settle path is allowed to re-show the layer. - document.dispatchEvent(new Event('selectionchange')); - return !!document.querySelector('.maka-quote-actions'); - }); - expect(stillVisibleAfterEvent).toBe(false); - - // Scrolling moves the selection, so it must move the layer. - await page.setViewportSize({ width: 1400, height: 700 }); - // Long enough that the selected turn can be scrolled clear of the scroller - // (two turns already exist from the phases above). - for (let i = 0; i < 12; i += 1) { - await composer.fill(`scroll follow source ${i}`); - await composer.press('Enter'); - await expect( - page.getByText(new RegExp(`Fake backend received: scroll follow source ${i}`)).last(), - ).toBeVisible(); - await waitForSourceSessionToSettle(page); - } - - await page - .getByText(/Fake backend received: scroll follow source 9/) - .last() - .evaluate((element) => { - // Centred first: the affordance is owed only to a selection inside the - // scroller's visible band, and where turn 11 lands depends on the host's - // font metrics. Without this the test asserts on layout luck. - element.scrollIntoView({ block: 'center' }); - const range = document.createRange(); - range.selectNodeContents(element); - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); - }); - - await expect(quoteLayer).toBeVisible(); - - }); - -/** - * Side-conversation entry points and the numbered-tab lifecycle in one - * window: the command palette, the slash suggestion, the /side command, and - * the launcher tabs all fork from one settled source turn. The numbered-tab - * phase runs last because it checks 以后不再询问, which suppresses the close - * confirmation for the rest of its window. - */ -test('side conversation entry points keep work outside the main transcript', async ({ - window: page, -}) => { - await page.setViewportSize({ width: 1400, height: 900 }); - const mainComposer = page.locator('.mainColumn').locator(COMPOSER_INPUT); - await mainComposer.fill('side chat command source'); - await mainComposer.press('Enter'); - await expect( - page.getByText(/Fake backend received: side chat command source/), - ).toBeVisible(); - const [sourceSession] = await page.evaluate(() => window.maka.sessions.list()); - expect(sourceSession).toBeDefined(); - await waitForSourceSessionToSettle(page); - - await page.keyboard.press('ControlOrMeta+KeyK'); - await page - .getByRole('dialog', { name: '命令面板' }) - .getByRole('option', { name: /打开侧边对话/ }) - .click(); - - const tab = page.getByRole('tab', { name: '侧边对话' }); - const companion = page.locator('.maka-quote-companion'); - await expect - .poll(() => - companion - .locator(COMPOSER_INPUT) - .evaluate((element) => element === document.activeElement), - ) - .toBe(true); - await expect(tab).not.toHaveAttribute('aria-busy', 'true'); - await expect(page.getByRole('button', { name: '关闭侧边对话' })).toBeVisible(); - - // Close the empty side chat before the next entry point: no content, so no - // confirmation is owed. - await page.getByRole('button', { name: '关闭侧边对话', exact: true }).click(); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(1); - - // Slash suggestion entry. - await waitForSourceSessionToSettle(page); - - await mainComposer.fill('/si'); - const slashMenu = page.getByRole('listbox', { name: '命令和技能' }); - await expect(slashMenu).toBeVisible(); - await expect( - slashMenu.getByRole('option', { name: /侧边对话.*不打断主任务/ }), - ).toBeVisible(); - await mainComposer.press('Enter'); - - await expect(page.getByRole('tab', { name: '侧边对话' })).toHaveAttribute( - 'aria-selected', - 'true', - ); - await expect(mainComposer).toHaveText(''); - await expect(page.locator('.maka-quote-companion .maka-turn')).toHaveCount(0); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(2); - - // Close the empty side chat before the next entry point: no content, so no - // confirmation is owed. - await page.getByRole('button', { name: '关闭侧边对话', exact: true }).click(); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(1); - - // /side opens titled and sends outside the main transcript. - await waitForSourceSessionToSettle(page); - - await mainComposer.fill('/side explain the renderer boundary'); - await mainComposer.press('Enter'); - - const titledTab = page.getByRole('tab', { - name: 'explain the renderer boundary', - }); - await expect(titledTab).toHaveAttribute('aria-selected', 'true'); - const titledCompanion = page.locator('.maka-quote-companion'); - await expect( - titledCompanion.getByText(/Fake backend received: explain the renderer boundary/), - ).toBeVisible(); - await expect(mainComposer).toHaveText(''); - - const sessions = await page.evaluate(() => window.maka.sessions.list()); - const companionSession = sessions.find( - ({ id, parentSessionId, labels }) => - id !== sourceSession?.id && - parentSessionId === sourceSession?.id && - labels.includes('mode:side_conversation'), - ); - expect(companionSession?.permissionMode).toBe(sourceSession?.permissionMode); - - const mainMessages = await page.evaluate( - (sessionId) => window.maka.sessions.readMessages(sessionId), - sourceSession!.id, - ); - expect( - mainMessages.some( - (message) => - message.type === 'user' && - 'text' in message && - message.text.includes('/side'), - ), - ).toBe(false); - - // Close the titled fork (it has content, so the confirmation is owed). - await page.getByRole('button', { name: '关闭explain the renderer boundary', exact: true }).click(); - await page.getByRole('dialog').getByRole('button', { name: '关闭侧边对话' }).click(); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(1); -}); - -test('numbered side chat tabs keep independent drafts and close policy', async ({ - window: page, -}) => { - test.slow(); - await page.setViewportSize({ width: 1400, height: 900 }); - const mainComposer = page.locator('.mainColumn').locator(COMPOSER_INPUT); - await mainComposer.fill('numbered side chat source'); - await mainComposer.press('Enter'); - await expect( - page.getByText(/Fake backend received: numbered side chat source/), - ).toBeVisible(); - await waitForSourceSessionToSettle(page); - const [sourceSession] = await page.evaluate(() => window.maka.sessions.list()); - expect(sourceSession).toBeDefined(); - - // Numbered tabs, independent drafts, and the don't-ask-again close. - await openSideConversationFromLauncher(page); - const visiblePanel = page.locator('.maka-quote-workbar-panel:not([hidden])'); - await expect(visiblePanel).toBeVisible(); - await expect(visiblePanel.locator('.maka-composer-context-drawer .astryx-token')).toHaveCount(0); - await expect(page.getByRole('tab', { name: '侧边对话' })).toHaveAttribute( - 'aria-selected', - 'true', - ); - await expect - .poll(() => - visiblePanel - .locator(COMPOSER_INPUT) - .evaluate((element) => element === document.activeElement), - ) - .toBe(true); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(2); - await visiblePanel.locator(COMPOSER_INPUT).fill('first side draft'); - - // Each New Tab action creates a separate side chat with its own draft owner. - await openSideConversationFromLauncher(page); - await expect(page.getByRole('tab', { name: '侧边对话 2' })).toHaveAttribute( - 'aria-selected', - 'true', - ); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(3); - await visiblePanel.locator(COMPOSER_INPUT).fill('second side draft'); - - await page.getByRole('tab', { name: '侧边对话', exact: true }).click(); - await expect(visiblePanel.locator(COMPOSER_INPUT)).toHaveText('first side draft'); - await visiblePanel.locator(COMPOSER_INPUT).press('Enter'); - await expect( - visiblePanel.getByText(/Fake backend received: first side draft/), - ).toBeVisible(); - await expect(page.getByRole('tab', { name: 'first side draft' })).toBeVisible(); - - await expect - .poll(async () => - page.evaluate((sourceSessionId) => { - return window.maka.sessions.list().then((sessions) => ({ - count: sessions.length, - companions: sessions - .filter( - ({ id, parentSessionId, labels }) => - id !== sourceSessionId && - parentSessionId === sourceSessionId && - labels.includes('mode:side_conversation'), - ) - .map(({ permissionMode }) => permissionMode) - .sort(), - })); - }, sourceSession!.id), - ) - .toEqual({ - count: 3, - companions: [sourceSession!.permissionMode, sourceSession!.permissionMode].sort(), - }); - - await page.getByRole('button', { name: '关闭first side draft', exact: true }).click(); - await expect(page.getByRole('dialog', { name: '关闭侧边对话?' })).toBeVisible(); - await page.getByRole('button', { name: '取消' }).click(); - await expect(page.getByRole('tab', { name: 'first side draft' })).toBeVisible(); - - await page.getByRole('button', { name: '关闭first side draft', exact: true }).click(); - await page.getByRole('checkbox', { name: '以后不再询问' }).check(); - await page.getByRole('button', { name: '关闭侧边对话', exact: true }).last().click(); - await expect(page.getByRole('tab', { name: 'first side draft' })).toHaveCount(0); - await expect(page.getByRole('tab', { name: '侧边对话 2' })).toBeVisible(); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(2); - - // The second tab kept its independent unsent draft while the first fork ran - // and was discarded. - await expect(visiblePanel.locator(COMPOSER_INPUT)).toHaveText('second side draft'); - await visiblePanel.locator(COMPOSER_INPUT).press('Enter'); - await expect( - visiblePanel.getByText(/Fake backend received: second side draft/), - ).toBeVisible(); - await expect(page.getByRole('tab', { name: 'second side draft' })).toBeVisible(); - await page.getByRole('button', { name: '关闭second side draft' }).click(); - await expect(page.getByRole('dialog', { name: '关闭侧边对话?' })).toHaveCount(0); - await expect( - page.locator('[data-maka-contract="session-workbar-right"]'), - ).toBeHidden(); - await expect(page.getByRole('button', { name: '展开会话工作栏' })).toBeVisible(); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(1); -}); - -test('renderer reload recovers and cleans its orphaned side conversation fork', async ({ - window: page, -}) => { - await page.setViewportSize({ width: 1400, height: 900 }); - const mainComposer = page.locator('.mainColumn').locator(COMPOSER_INPUT); - await mainComposer.fill('side chat reload source'); - await mainComposer.press('Enter'); - await expect(page.getByText(/Fake backend received: side chat reload source/)).toBeVisible(); - await waitForSourceSessionToSettle(page); - - await openSideConversationFromLauncher(page); - const sideComposer = page - .locator('.maka-quote-workbar-panel:not([hidden])') - .locator(COMPOSER_INPUT); - await sideComposer.fill('orphan this temporary fork'); - await sideComposer.press('Enter'); - await expect( - page.getByText(/Fake backend received: orphan this temporary fork/), - ).toBeVisible(); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(2); - - await page.reload(); - - await expect(page.locator(COMPOSER_INPUT)).toBeVisible(); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(1); - await expect( - page.locator('.maka-workbar-tab[data-workbar-tab-id^="side-chat:"]'), - ).toHaveCount(0); -}); - -test('side chat stages quotes and attachments in one isolated fork', async ({ - window: page, -}) => { - await page.setViewportSize({ width: 1400, height: 900 }); - const mainComposer = page.locator('.mainColumn').locator(COMPOSER_INPUT); - await mainComposer.fill('quote companion source one'); - await mainComposer.press('Enter'); - - const firstSourceReply = page.getByText(/Fake backend received: quote companion source one/); - await expect(firstSourceReply).toBeVisible(); - await waitForSourceSessionToSettle(page); - await mainComposer.fill('quote companion source two'); - await mainComposer.press('Enter'); - const secondSourceReply = page.getByText(/Fake backend received: quote companion source two/); - await expect(secondSourceReply).toBeVisible(); - await waitForSourceSessionToSettle(page); - const [sourceSession] = await page.evaluate(() => window.maka.sessions.list()); - expect(sourceSession).toBeDefined(); - - // Create the same real DOM Range a drag selection would produce. Mutating - // the selection fires `selectionchange` on its own, which is the hook's only - // trigger; the click below absorbs the settle delay before the layer shows. - const stageReply = async (reply: typeof firstSourceReply, expectedQuoteCount: number) => { - await reply.evaluate((element) => { - const range = document.createRange(); - range.selectNodeContents(element); - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); - document.dispatchEvent(new Event('selectionchange')); - document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); - document.dispatchEvent( - new KeyboardEvent('keyup', { bubbles: true, key: 'Shift' }), - ); - }); - await expect(page.getByRole('button', { name: '在侧栏追问' })).toBeVisible(); - await page.getByRole('button', { name: '在侧栏追问' }).click(); - await expect( - page.locator('.maka-quote-companion .maka-composer-context-drawer .astryx-token'), - ).toHaveCount(expectedQuoteCount); - await expect( - page - .locator( - '.maka-workbar-tab[data-active][data-workbar-tab-id^="side-chat:"]', - ) - .getByRole('tab'), - ).not.toHaveAttribute('aria-busy', 'true'); - await expect - .poll(() => - page - .locator('.maka-quote-workbar-panel:not([hidden])') - .locator(COMPOSER_INPUT) - .evaluate((element) => element === document.activeElement), - ) - .toBe(true); - }; - await stageReply(firstSourceReply, 1); - await stageReply(secondSourceReply, 2); - - const panel = page.locator('.maka-quote-companion'); - await expect(panel).toBeVisible(); - await expect(panel.getByRole('button', { name: '关闭', exact: true })).toHaveCount(0); - await expect(page.locator('[data-maka-contract="composer-inner"]')).toHaveCount(2); - await expect( - panel.getByRole('button', { name: '添加上下文', exact: true }), - ).toBeVisible(); - await expect(panel.locator('.permissionModeIcon button').first()).toBeEnabled(); - - // Quiet composer stages quotes as drawer Tokens (Astryx Token + remove). - const quoteTokens = panel.locator('.maka-composer-context-drawer .astryx-token'); - const contextDrawerToggle = panel.locator( - '.maka-composer-drawer [role="button"][aria-expanded]', - ); - await expect(contextDrawerToggle).toHaveAttribute('aria-expanded', 'false'); - await expect(quoteTokens).toHaveCount(2); - await contextDrawerToggle.click(); - await expect(quoteTokens).toHaveCount(2); - await expect(quoteTokens.first()).toBeVisible(); - await expect(panel.locator('.maka-composer-model-status')).toHaveCount(0); - await quoteTokens.first().getByRole('button', { name: /^移除/ }).click(); - await expect(quoteTokens).toHaveCount(1); - - // The empty transcript stays visually identical to an ordinary chat. Quote - // context appears only in the shared Composer drawer, never duplicated as - // explanatory content in the message area. - await expect(panel.locator('.maka-turn')).toHaveCount(0); - - const companionComposer = panel.locator(COMPOSER_INPUT); - await panel.locator('form.maka-composer').evaluate((form) => { - const transfer = new DataTransfer(); - transfer.items.add( - new File(['side attachment'], 'side-notes.txt', { type: 'text/plain' }), - ); - form.dispatchEvent( - new DragEvent('drop', { - bubbles: true, - cancelable: true, - dataTransfer: transfer, - }), - ); - }); - const attachmentToken = panel.locator( - '.maka-composer-context-drawer .astryx-token', - { hasText: 'side-notes.txt' }, - ); - await expect(attachmentToken).toBeVisible(); - await companionComposer.fill('explain this quote'); - await companionComposer.press('Enter'); - const runningTab = page.locator( - '.maka-workbar-tab[data-running][data-workbar-tab-id^="side-chat:"]', - ); - await expect(runningTab).toBeVisible(); - await expect(runningTab.locator('.maka-workbar-tab-spinner')).toBeVisible(); - await expect(runningTab.locator('.maka-workbar-tab-close')).toBeVisible(); - await expect(panel.getByText(/Fake backend received: explain this quote/)).toBeVisible(); - await expect(panel.getByRole('button', { name: '重新生成' })).toBeVisible(); - await expect(panel.getByRole('button', { name: '复制' }).last()).toBeVisible(); - await expect(panel.getByRole('button', { name: '分支' })).toHaveCount(0); - await expect(runningTab).toHaveCount(0); - await expect( - page.locator( - '.maka-workbar-tab[data-workbar-tab-id^="side-chat:"] .maka-workbar-tab-icon:not(.maka-workbar-tab-spinner)', - ), - ).toBeVisible(); - await expect(quoteTokens).toHaveCount(0); - await expect(attachmentToken).toHaveCount(0); - - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(2); - const companionSession = ( - await page.evaluate(() => window.maka.sessions.list()) - ).find(({ id }) => id !== sourceSession?.id); - expect(companionSession?.parentSessionId).toBe(sourceSession?.id); - expect(companionSession?.permissionMode).toBe(sourceSession?.permissionMode); - await expect(page.getByRole('tab', { name: 'explain this quote' })).toBeVisible(); - await page.getByRole('button', { name: '关闭explain this quote', exact: true }).click(); - await expect(page.getByRole('dialog', { name: '关闭侧边对话?' })).toBeVisible(); - await page.getByRole('dialog').getByRole('button', { name: '关闭侧边对话' }).click(); - await expect(panel).toBeHidden(); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(1); -}); - -test('side chat persists steering and permission changes', async ({ - window: page, -}) => { - await page.setViewportSize({ width: 1400, height: 900 }); - const mainComposer = page.locator('.mainColumn').locator(COMPOSER_INPUT); - await mainComposer.fill('side chat control source'); - await mainComposer.press('Enter'); - await expect( - page.getByText(/Fake backend received: side chat control source/), - ).toBeVisible(); - await waitForSourceSessionToSettle(page); - const [sourceSession] = await page.evaluate(() => window.maka.sessions.list()); - expect(sourceSession).toBeDefined(); - - // Steering and permission inheritance on a fresh fork. - await openSideConversationFromLauncher(page); - const steerPanel = page.locator('.maka-quote-workbar-panel:not([hidden])'); - const sideComposer = steerPanel.locator(COMPOSER_INPUT); - await sideComposer.fill('__e2e_wait_for_steering__'); - await sideComposer.press('Enter'); - - const sideUserMessages = steerPanel.locator('.maka-user-message'); - await expect(sideUserMessages).toHaveCount(1); - await expect(sideUserMessages.first()).toContainText('__e2e_wait_for_steering__'); - await expect(steerPanel.getByRole('button', { name: '停止' })).toBeVisible(); - const steerButton = steerPanel.getByRole('button', { name: '插入消息' }); - await expect(steerButton).toBeVisible(); - await sideComposer.fill('only answer the side request'); - await expect(steerButton).toBeEnabled(); - await steerButton.click(); - await expect(sideComposer).toHaveText(''); - await expect(sideUserMessages).toHaveCount(2); - await expect(sideUserMessages.first()).toContainText('__e2e_wait_for_steering__'); - await expect(sideUserMessages.nth(1)).toContainText('only answer the side request'); - await expect( - steerPanel.getByText(/Acknowledged steering: only answer the side request/), - ).toBeVisible(); - await expect(sideUserMessages).toHaveCount(2); - await expect(sideUserMessages.first()).toContainText('__e2e_wait_for_steering__'); - await expect(sideUserMessages.nth(1)).toContainText('only answer the side request'); - - const steerCompanion = ( - await page.evaluate(() => window.maka.sessions.list()) - ).find(({ id }) => id !== sourceSession?.id); - expect(steerCompanion?.permissionMode).toBe(sourceSession?.permissionMode); - const permissionButton = steerPanel.locator('.permissionModeIcon button').first(); - await expect(permissionButton).toBeEnabled(); - await permissionButton.click(); - await page.getByRole('menuitemradio', { name: /完全权限/ }).click(); - await expect - .poll(async () => { - const sessions = await page.evaluate(() => window.maka.sessions.list()); - return sessions.find(({ id }) => id === steerCompanion?.id)?.permissionMode; - }) - .toBe('bypass'); - - // The fork holds content, so closing it requires confirmation. - await page - .locator('.maka-workbar-tab[data-active][data-workbar-tab-id^="side-chat:"] .maka-workbar-tab-close') - .click(); - await page.getByRole('dialog').getByRole('button', { name: '关闭侧边对话' }).click(); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(1); -}); - -test('side chat recovers from failures and preserves its draft', async ({ - window: page, -}) => { - test.slow(); - await page.setViewportSize({ width: 1400, height: 900 }); - const mainComposer = page.locator('.mainColumn').locator(COMPOSER_INPUT); - await mainComposer.fill('side chat recovery source'); - await mainComposer.press('Enter'); - await expect( - page.getByText(/Fake backend received: side chat recovery source/), - ).toBeVisible(); - await waitForSourceSessionToSettle(page); - - // Failure classification, collapse survival, and retry. - const rightPanel = page.locator('[data-maka-contract="session-workbar-right"]'); - await openSideConversationFromLauncher(page); - const failPanel = page.locator('.maka-quote-companion'); - const failComposer = failPanel.locator(COMPOSER_INPUT); - - await failComposer.fill('__e2e_error__:network'); - await failComposer.press('Enter'); - await expect(failPanel.getByRole('button', { name: '停止' })).toBeVisible(); - - await page.getByRole('button', { name: '收起会话工作栏' }).click(); - await expect(rightPanel).toBeHidden(); - await page.getByRole('button', { name: '展开会话工作栏' }).click(); - await expect(failPanel).toBeVisible(); - await expect(failPanel.locator('.maka-quote-companion-error')).toHaveText('网络错误'); - await expect(failPanel.getByRole('button', { name: '停止' })).toHaveCount(0); - - await failComposer.fill('retry after deterministic network failure'); - await failComposer.press('Enter'); - await expect(failPanel.locator('.maka-quote-companion-error')).toHaveCount(0); - await expect( - failPanel.getByText(/Fake backend received: retry after deterministic network failure/), - ).toBeVisible(); - - await failComposer.fill('__e2e_error__:auth'); - await failComposer.press('Enter'); - const activeSideTab = page.locator( - '.maka-workbar-tab[data-running][data-workbar-tab-id^="side-chat:"]', - ); - await expect(activeSideTab).toBeVisible(); - await activeSideTab.locator('.maka-workbar-tab-close').click(); - await expect(page.getByRole('dialog', { name: '关闭侧边对话?' })).toBeVisible(); - await page.getByRole('dialog').getByRole('button', { name: '关闭侧边对话' }).click(); - await expect(failPanel).toBeHidden(); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(1); - await expect(page.getByText('鉴权失败')).toHaveCount(0); - - // Draft survival across collapse and launcher navigation. - await waitForSourceSessionToSettle(page); - - await openSideConversationFromLauncher(page); - const draftPanel = page.locator('.maka-quote-companion'); - const draftComposer = draftPanel.locator(COMPOSER_INPUT); - await draftComposer.fill('draft survives panel navigation'); - - await page.getByRole('button', { name: '收起会话工作栏' }).click(); - await expect(rightPanel).toBeHidden(); - await expect(draftPanel).toBeHidden(); - await page.getByRole('button', { name: '展开会话工作栏' }).click(); - await expect(rightPanel).toBeVisible(); - await expect(draftPanel).toBeVisible(); - await expect(draftComposer).toHaveText('draft survives panel navigation'); - - await page.getByRole('button', { name: '打开工作栏工具' }).click(); - await expect(page.getByRole('menuitem', { name: /侧边对话/ })).toBeVisible(); - await expect(draftPanel).toBeHidden(); - await page.getByRole('tab', { name: '侧边对话', exact: true }).click(); - await expect(draftPanel).toBeVisible(); - await expect(draftComposer).toHaveText('draft survives panel navigation'); - - await page.getByRole('button', { name: '关闭侧边对话', exact: true }).click(); - await expect(draftPanel).toBeHidden(); - await expect(rightPanel).toBeHidden(); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(1); -}); - -// Standalone window: the batch close owes its confirmation dialog, and the -// numbered-tab journey above ends with 以后不再询问 checked, which would -// suppress exactly that dialog in a shared window. -test('batch tab close confirms once and cleans every non-empty side fork', async ({ - window: page, -}) => { - await page.setViewportSize({ width: 1400, height: 900 }); - const mainComposer = page.locator(COMPOSER_INPUT); - await mainComposer.fill('batch side chat source'); - await mainComposer.press('Enter'); - await expect(page.getByText(/Fake backend received: batch side chat source/)).toBeVisible(); - await waitForSourceSessionToSettle(page); - - await page.getByRole('button', { name: '打开工作栏工具' }).click(); - await page.getByRole('menuitem', { name: '任务' }).click(); - - await openSideConversationFromLauncher(page); - let activePanel = page.locator('.maka-quote-workbar-panel:not([hidden])'); - await activePanel.locator(COMPOSER_INPUT).fill('first batch side chat'); - await activePanel.locator(COMPOSER_INPUT).press('Enter'); - await expect(activePanel.getByText(/Fake backend received: first batch side chat/)).toBeVisible(); - - await openSideConversationFromLauncher(page); - activePanel = page.locator('.maka-quote-workbar-panel:not([hidden])'); - await activePanel.locator(COMPOSER_INPUT).fill('second batch side chat'); - await activePanel.locator(COMPOSER_INPUT).press('Enter'); - await expect(activePanel.getByText(/Fake backend received: second batch side chat/)).toBeVisible(); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(3); - - await page.getByRole('tab', { name: /^任务/ }).click({ button: 'right' }); - await page.getByRole('menuitem', { name: '关闭其他标签' }).click(); - await expect(page.getByRole('dialog', { name: '关闭 2 个侧边对话?' })).toBeVisible(); - await page.getByRole('dialog').getByRole('button', { name: '关闭侧边对话' }).click(); - - await expect(page.getByRole('tab', { name: /^任务/ })).toBeVisible(); - await expect(page.locator('[data-workbar-tab-id^="side-chat:"]')).toHaveCount(0); - await expect - .poll(async () => (await page.evaluate(() => window.maka.sessions.list())).length) - .toBe(1); -}); diff --git a/apps/desktop/e2e/sandbox-boundary-takeover.spec.ts b/apps/desktop/e2e/sandbox-boundary-takeover.spec.ts deleted file mode 100644 index 896247c62f..0000000000 --- a/apps/desktop/e2e/sandbox-boundary-takeover.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { test, expect } from './fixtures.js'; - -test('sandbox boundary request takes over the composer slot without hiding the workspace', async ({ - sandboxBoundaryWindow, -}) => { - const prompt = sandboxBoundaryWindow.locator('.maka-sandbox-boundary-prompt'); - const slot = sandboxBoundaryWindow.locator('.maka-composer-interaction-slot'); - const composer = sandboxBoundaryWindow.locator('.maka-composer'); - const draft = '保留这段尚未发送的草稿'; - - await expect(slot.locator('.maka-sandbox-boundary-prompt')).toHaveCount(1); - await expect(composer).toHaveCount(1); - await expect(composer).toBeHidden(); - // The composer is hidden behind the boundary prompt, so the draft is seeded - // through the input event ChatComposerInput listens on rather than by typing. - const editable = composer.locator('[aria-label="消息输入框"]'); - const draftText = () => editable.evaluate((element) => element.textContent); - await editable.evaluate((element, value) => { - element.textContent = value; - element.dispatchEvent(new Event('input', { bubbles: true })); - }, draft); - await expect(sandboxBoundaryWindow.locator('dialog[open]')).toHaveCount(0); - await expect(sandboxBoundaryWindow.locator('[role="dialog"]')).toHaveCount(0); - await expect(sandboxBoundaryWindow.locator('.app')).not.toHaveAttribute('inert', ''); - - await expect(prompt.getByRole('heading', { name: '允许访问工作区以外的内容?' })).toBeVisible(); - await expect(prompt.getByText('/outside/dist')).toBeVisible(); - await expect(prompt.getByText('写入 · 目录及子目录')).toBeVisible(); - await expect(prompt.getByRole('button', { name: '拒绝' })).toBeFocused(); - await expect(prompt.getByRole('button', { name: '本会话允许' })).toBeVisible(); - - const promptBox = await prompt.boundingBox(); - const panelBox = await sandboxBoundaryWindow.locator('.maka-panel-detail').boundingBox(); - expect(promptBox).not.toBeNull(); - expect(panelBox).not.toBeNull(); - expect(promptBox!.y + promptBox!.height).toBeGreaterThan(panelBox!.y + panelBox!.height * 0.7); - - await sandboxBoundaryWindow.getByRole('button', { name: '展开侧边栏' }).click(); - await expect(composer).toBeHidden(); - await expect.poll(draftText).toBe(draft); - - await prompt.getByRole('button', { name: '本会话允许' }).click(); - await expect(prompt).toHaveCount(0); - await expect(composer).toBeVisible(); - await expect.poll(draftText).toBe(draft); -}); diff --git a/apps/desktop/e2e/send-message.spec.ts b/apps/desktop/e2e/send-message.spec.ts index 59aa08f054..db97f3601e 100644 --- a/apps/desktop/e2e/send-message.spec.ts +++ b/apps/desktop/e2e/send-message.spec.ts @@ -1,5 +1,4 @@ import { test, expect, COMPOSER_INPUT } from './fixtures'; -import { FAKE_MERMAID_HOSTILE_PROMPT, FAKE_MERMAID_PROMPT } from '@maka/runtime'; /** * Enter commits a candidate in a CJK IME; nothing else may act on it. Both the @@ -52,152 +51,3 @@ test('Enter mid-IME commits the candidate, then an ordinary send streams a reply await expect(page.getByRole('log').getByText(/Fake backend received: hello e2e/)).toBeVisible(); }); - -// Mermaid rendering and sanitization share one transcript: the settled -// diagram journey first, then the hostile fence as a second turn in the same -// window (the sanitizer assertions anchor on the newest diagram). -test('renders a settled Mermaid fence and keeps hostile directives inert', async ({ window: page }) => { - await page.setViewportSize({ width: 1440, height: 900 }); - const composer = page.locator(COMPOSER_INPUT); - await composer.fill(FAKE_MERMAID_PROMPT); - await composer.press('Enter'); - - await expect(page.getByRole('button', { name: '重新生成' })).toBeVisible(); - await expect(page.locator('.maka-bubble-streaming')).toHaveCount(0); - const diagram = page.locator('[data-maka-contract="mermaid"]').last(); - await expect(diagram).toHaveAttribute('data-maka-mermaid-state', 'rendered'); - await expect(diagram).toHaveAttribute('data-maka-mermaid-layout', 'ready'); - await expect(diagram.locator('.maka-mermaid-svg > svg')).toBeVisible(); - await expect(diagram.locator('script, foreignObject, a')).toHaveCount(0); - await expect(diagram.locator('.cluster')).toHaveCount(3); - - const viewport = diagram.locator('.maka-mermaid-viewport'); - const fitted = await viewport.evaluate((element) => ({ - clientWidth: element.clientWidth, - scrollWidth: element.scrollWidth, - clientHeight: element.clientHeight, - scrollHeight: element.scrollHeight, - })); - expect(fitted.scrollWidth).toBeLessThanOrEqual(fitted.clientWidth + 1); - expect(fitted.scrollHeight).toBeLessThanOrEqual(fitted.clientHeight + 1); - await expect(viewport).toHaveCSS('touch-action', 'pan-y'); - - const viewSource = diagram.getByRole('button', { name: '查看 Mermaid 源码' }); - await viewSource.click(); - await expect(diagram.locator('.maka-mermaid-source')).toContainText('flowchart TB'); - await viewSource.click(); - - // Zoom moves the diagram's content, never its chrome. Read the toolbar - // offset and the viewport height inside one evaluate: the transcript is a - // bottom-pinned scroller that re-pins on every ResizeObserver update, so two - // separate boundingBox() round-trips sample the same element at two scroll - // positions and turn that drift into a phantom offset change (#2000). - const readChrome = () => diagram.evaluate((element) => { - const diagramTop = element.getBoundingClientRect().top; - const toolbarTop = element.querySelector('.maka-mermaid-toolbar')?.getBoundingClientRect().top; - const viewportHeight = element.querySelector('.maka-mermaid-viewport')?.getBoundingClientRect().height; - return { toolbarOffset: (toolbarTop ?? 0) - diagramTop, viewportHeight: viewportHeight ?? 0 }; - }); - const chromeBeforeZoom = await readChrome(); - const zoomIn = diagram.getByRole('button', { name: '放大图表' }); - await zoomIn.click(); - await expect(diagram).toHaveAttribute('data-maka-mermaid-zoom', '1.25'); - await zoomIn.click(); - await expect(diagram).toHaveAttribute('data-maka-mermaid-zoom', '1.50'); - // Poll for the steady state: the zoomed layout settles over a rAF, a - // ResizeObserver pass, and the scroller's re-pin, so a single instantaneous - // read asserts a frame the user never sees. - await expect.poll(async () => { - const chrome = await readChrome(); - return Math.max( - Math.abs(chrome.toolbarOffset - chromeBeforeZoom.toolbarOffset), - Math.abs(chrome.viewportHeight - chromeBeforeZoom.viewportHeight), - ); - }).toBeLessThanOrEqual(1); - await expect.poll(() => viewport.evaluate((element) => - element.scrollWidth > element.clientWidth || element.scrollHeight > element.clientHeight)).toBe(true); - const zoomedBounds = await diagram.evaluate((element) => { - const svg = element.querySelector('.maka-mermaid-svg > svg'); - const content = svg?.querySelector('g'); - const svgRect = svg?.getBoundingClientRect(); - const contentRect = content?.getBoundingClientRect(); - return { - svg: svgRect - ? { top: svgRect.top, bottom: svgRect.bottom, width: svgRect.width, height: svgRect.height } - : null, - content: contentRect - ? { top: contentRect.top, bottom: contentRect.bottom } - : null, - }; - }); - expect(zoomedBounds.svg).not.toBeNull(); - expect(zoomedBounds.content).not.toBeNull(); - expect(zoomedBounds.content?.top ?? 0).toBeGreaterThanOrEqual((zoomedBounds.svg?.top ?? 0) - 1); - expect(zoomedBounds.content?.bottom ?? 0).toBeLessThanOrEqual((zoomedBounds.svg?.bottom ?? 0) + 1); - await diagram.getByRole('button', { name: '适应视窗' }).click(); - await expect(diagram).toHaveAttribute('data-maka-mermaid-zoom', '1.00'); - const inlineSvg = diagram.locator('.maka-mermaid-svg > svg'); - await expect(inlineSvg).toBeVisible(); - await expect.poll(async () => { - const bounds = await inlineSvg.boundingBox(); - return bounds ? bounds.width * bounds.height : 0; - }).toBeGreaterThan(0); - const inlineSvgBounds = await inlineSvg.boundingBox(); - expect(inlineSvgBounds).not.toBeNull(); - - await diagram.getByRole('button', { name: '全屏查看图表' }).click(); - const modal = page.locator('dialog.maka-mermaid-dialog'); - await expect(modal).toHaveAttribute('open', ''); - await expect(modal).toHaveAttribute('aria-modal', 'true'); - const expandedDiagram = modal.locator('[data-maka-contract="mermaid"]'); - const pageSize = page.viewportSize(); - await expect.poll(async () => { - const bounds = await modal.boundingBox(); - return bounds && pageSize - ? Math.max( - Math.abs(bounds.x), - Math.abs(bounds.y), - Math.abs(bounds.width - pageSize.width), - Math.abs(bounds.height - pageSize.height), - ) - : Number.POSITIVE_INFINITY; - }).toBeLessThanOrEqual(1); - await expect.poll(async () => { - const bounds = await expandedDiagram.locator('.maka-mermaid-svg > svg').boundingBox(); - return bounds ? bounds.width * bounds.height : 0; - }).toBeGreaterThan((inlineSvgBounds?.width ?? 0) * (inlineSvgBounds?.height ?? 0) * 1.5); - await expect.poll(() => expandedDiagram.locator('.maka-mermaid-actions').evaluate((element) => - getComputedStyle(element).getPropertyValue('-webkit-app-region'))).toBe('no-drag'); - const exitFullscreen = expandedDiagram.getByRole('button', { name: '退出全屏图表' }); - await expect(exitFullscreen).toBeFocused(); - await page.keyboard.press('Tab'); - await expect.poll(() => modal.evaluate((element) => element.contains(document.activeElement))).toBe(true); - await exitFullscreen.click(); - await expect(modal).not.toHaveAttribute('open', ''); - const enterFullscreen = diagram.getByRole('button', { name: '全屏查看图表' }); - await expect(enterFullscreen).toBeFocused(); - - await enterFullscreen.click(); - await expect(expandedDiagram.getByRole('button', { name: '退出全屏图表' })).toBeFocused(); - await page.keyboard.press('Escape'); - await expect(diagram.getByRole('button', { name: '全屏查看图表' })).toBeFocused(); - - await page.setViewportSize({ width: 340, height: 900 }); - await expect(diagram.getByRole('button', { name: '全屏查看图表' })).toBeVisible(); - await expect(diagram.getByRole('button', { name: '放大图表' })).toBeHidden(); - - // Back to a regular width for the hostile fence, and settle the first turn - // before sending the second. - await page.setViewportSize({ width: 1440, height: 900 }); - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000 }); - - await composer.fill(FAKE_MERMAID_HOSTILE_PROMPT); - await composer.press('Enter'); - - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(2, { timeout: 20_000 }); - const hostileDiagram = page.locator('[data-maka-contract="mermaid"]').last(); - await expect(hostileDiagram).toHaveAttribute('data-maka-mermaid-state', 'rendered'); - await expect(hostileDiagram.locator('.maka-mermaid-svg > svg')).toBeVisible(); - await expect(hostileDiagram.locator('script, foreignObject, a')).toHaveCount(0); - await expect(hostileDiagram.locator('[onclick], [onerror], [onload], [href^="javascript:"]')).toHaveCount(0); -}); diff --git a/apps/desktop/e2e/session-workbar.spec.ts b/apps/desktop/e2e/session-workbar.spec.ts index 3e29804180..e669dd4b06 100644 --- a/apps/desktop/e2e/session-workbar.spec.ts +++ b/apps/desktop/e2e/session-workbar.spec.ts @@ -1,4 +1,46 @@ -import { test, expect } from './fixtures'; +import { COMPOSER_INPUT, test, expect } from './fixtures'; +import type { Page } from '@playwright/test'; +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +async function openGitChanges(page: Page) { + const composer = page.locator(COMPOSER_INPUT); + await composer.fill('create review session'); + await composer.press('Enter'); + await expect(page.getByText(/Fake backend received: create review session/)).toBeVisible(); + await page.getByRole('button', { name: '展开会话工作栏' }).click(); + await page.getByRole('button', { name: '打开工作栏标签' }).first().click(); + await page.getByRole('menuitem', { name: '变更' }).click(); + return page.getByRole('region', { name: 'Git 变更' }); +} + +test('Git changes show branch files as a collapsed single-open list', async ({ + gitReviewWindow, +}) => { + const panel = await openGitChanges(gitReviewWindow.page); + const feature = panel.getByRole('button', { name: /feature\.txt/ }); + const base = panel.getByRole('button', { name: /base\.txt/ }); + await expect(feature).toHaveAttribute('aria-expanded', 'false'); + await expect(base).toHaveAttribute('aria-expanded', 'false'); + + await feature.click(); + await expect(feature).toHaveAttribute('aria-expanded', 'true'); + await base.click(); + await expect(base).toHaveAttribute('aria-expanded', 'true'); + await expect(feature).toHaveAttribute('aria-expanded', 'false'); +}); + +test('Git changes re-read the workspace after the app regains focus', async ({ + gitReviewWindow, +}) => { + const panel = await openGitChanges(gitReviewWindow.page); + await expect(panel.getByText('新增 4 行')).toBeVisible(); + + await writeFile(join(gitReviewWindow.projectRoot, 'base.txt'), 'base\nunstaged\nexternal\n'); + await gitReviewWindow.page.evaluate(() => window.dispatchEvent(new Event('focus'))); + + await expect(panel.getByText('新增 5 行')).toBeVisible(); +}); test('generated files use list-to-preview navigation with a compact action menu', async ({ artifactPaneWindow: page }) => { const pane = page.getByRole('region', { name: '生成文件预览面板' }); @@ -32,211 +74,3 @@ test('generated files use list-to-preview navigation with a compact action menu' // Workbar product journey. Narrow max-height and "toggle unmounted without a // session" are pinned in chat-shell-layout-contract (CSS + chrome actions source). -test('session tools share one user-controlled workbar that stays mounted across repeated collapse', async ({ sessionWorkbarWindow: page }) => { - const workbar = page.getByRole('complementary', { name: '会话工作栏' }); - const rightWorkbar = page.locator( - '[data-maka-contract="session-workbar-right"]', - ); - const tabs = workbar.getByRole('tablist', { name: '会话工作栏标签' }); - - await expect(tabs.getByRole('tab', { name: /任务/ })).toHaveAttribute('aria-selected', 'true'); - await expect(tabs.getByRole('tab', { name: /浏览器/ })).toHaveCount(0); - const launcher = workbar.getByRole('button', { name: '打开工作栏标签' }); - await launcher.click(); - await expect(page.getByRole('menuitem', { name: '生成文件' })).toBeEnabled(); - await page.keyboard.press('Escape'); - await expect( - page - .getByLabel('活跃会话任务') - .getByText(/完成会话任务台账升级/), - ).toBeVisible(); - - // Sizing is config handed to Astryx Resizable (#1861), and each part fails - // silently: a vertical separator is what makes the drag read clientX, and - // ArrowLeft has to widen an end-of-row panel. The width assertion is the - // load-bearing one — the aria-* values mirror hook state, not the panel. - const resize = page.getByRole('separator', { name: '调整会话工作栏宽度' }); - await expect(resize).toHaveAttribute('aria-orientation', 'vertical'); - await expect(resize).toHaveAttribute('aria-valuemin', '320'); - await expect(resize).toHaveAttribute('aria-valuemax', '600'); - await resize.focus(); - await resize.press('ArrowLeft'); - // 480 default (session-workbar-layout.ts) + the hook's 10px keyboard step. - await expect(resize).toHaveAttribute('aria-valuenow', '490'); - await expect(workbar).toHaveCSS('width', '490px'); - - // Pointer drag, grabbed near the bottom of the divider: Astryx's default - // side-placed grab zone lifts itself half its height off the handle, so a - // low grab is what proves `pillPlacement="center"` is still holding the hit - // area open. The fractional delta proves the width stays a whole pixel in - // both the panel and the value screen readers announce. - const box = (await resize.boundingBox())!; - const y = box.y + box.height * 0.9; - await page.mouse.move(box.x, y); - await page.mouse.down(); - await page.mouse.move(box.x - 20.5, y, { steps: 2 }); - await page.mouse.up(); - await expect(workbar).toHaveCSS('width', '511px'); - await expect(resize).toHaveAttribute('aria-valuenow', '511'); - - // Cmd+Tab mid-drag and release outside the app: Astryx only ends a drag on - // pointerup/pointercancel, so without a blur guard the listeners survive and - // the panel keeps tracking a button-less pointer while `body` stays stuck at - // `user-select: none`. - await page.mouse.move(box.x - 20.5, y); - await page.mouse.down(); - await page.evaluate(() => window.dispatchEvent(new Event('blur'))); - await page.mouse.move(box.x - 200, y, { steps: 2 }); - await page.mouse.up(); - await expect(workbar).toHaveCSS('width', '511px'); - await expect(page.locator('body')).toHaveCSS('user-select', 'auto'); - - // Which key the width lands on is the load-bearing decision of #1861 and the - // one thing every assertion above stays green through: `useResizable`'s - // `autoSaveId` writes synchronously on each committed size (~90 writes per - // drag), so the width stays on Maka's debounced key instead. Switching to - // `autoSaveId` would orphan the user's stored width silently. - await expect - .poll(() => page.evaluate(() => localStorage.getItem('maka-session-workbar-width-v1'))) - .toBe('511'); - expect( - await page.evaluate(() => Object.keys(localStorage).filter((key) => key.startsWith('astryx-resizable:'))), - ).toEqual([]); - - // Keyboard-driven disclosure, observed through what the user can read: a - // collapsed section hides its rows, and Enter on the trigger reveals them. - const recent = page.getByRole('button', { name: /最近结束/ }); - const recentRow = page.getByText('验证 Goal 一次提醒门禁'); - await expect(recent).toHaveAttribute('aria-expanded', 'false'); - await expect(recentRow).toBeHidden(); - - await recent.focus(); - await recent.press('Enter'); - await expect(recent).toHaveAttribute('aria-expanded', 'true'); - await expect(recentRow).toBeVisible(); - - // One toggle, in one place, across its own state change. It used to hand off - // to a second button inside the workbar's tab row while the workbar was open, - // so the control a user clicks twice moved between those two clicks. The - // titlebar is also the only row that already reserves `env(titlebar-area-*)`, - // which is what keeps this button clear of the Windows caption strip. - const collapse = page.getByRole('button', { name: '收起会话工作栏' }); - const openBox = (await collapse.boundingBox())!; - await expect(collapse).toHaveAttribute('aria-expanded', 'true'); - await collapse.click(); - - // Right and bottom panel topology is persistent now: collapse hides the - // right grid plate without destroying its tabs, drafts, or embedded views. - await expect(rightWorkbar).toHaveCount(1); - await expect(rightWorkbar).toBeHidden(); - await expect(rightWorkbar).toHaveAttribute('data-collapsed', 'true'); - await expect(page.locator('.maka-workbar-resize-handle')).toHaveCount(0); - - const expand = page.getByRole('button', { name: '展开会话工作栏' }); - await expect(expand).toHaveAttribute('aria-expanded', 'false'); - expect(await expand.boundingBox()).toMatchObject({ x: openBox.x, y: openBox.y }); - await expand.click(); - await expect(rightWorkbar).toBeVisible(); - // The width the drag above landed on, restored rather than reset. - await expect(rightWorkbar).toHaveCSS('width', '511px'); - - // A second collapse/expand cycle: the right plate must stay mounted (count - // 1, hidden, data-collapsed) rather than remount, and the user-set width - // must survive repetition, not just the first restore. - await collapse.click(); - await expect(rightWorkbar).toHaveCount(1); - await expect(rightWorkbar).toBeHidden(); - await expect(rightWorkbar).toHaveAttribute('data-collapsed', 'true'); - await expand.click(); - await expect(rightWorkbar).toBeVisible(); - await expect(rightWorkbar).toHaveCSS('width', '511px'); - - await rightWorkbar.getByRole('button', { name: '打开工作栏标签' }).click(); - await page.getByRole('menuitem', { name: '生成文件' }).click(); - await expect(page.getByText('暂无生成文件')).toBeVisible(); - - // The record-file row is a fact about the workspace, not the session: it - // exists even when the trace is empty, and it reads the exact database path - // from `app:info`'s operationalStateDatabasePath (resolved in main, the - // same single source of truth the data-settings row shows) — no second - // channel, so nothing to register per boot mode. - // Then pin the 320px minimum: the directory truncates while the filename - // keeps its glyphs (the row's whole point at that width), the tooltip - // trigger is keyboard-reachable, and the copy actually lands the FULL path - // on the clipboard. - await rightWorkbar.getByRole('button', { name: '打开工作栏标签' }).click(); - await page.getByRole('menuitem', { name: '追踪' }).click(); - const inspectorTab = tabs.getByRole('tab', { name: /追踪/ }); - await expect(inspectorTab).toHaveAttribute('aria-selected', 'true'); - const recordFileRow = page.locator( - '[data-maka-contract="session-inspector-record-file"]', - ); - await expect(recordFileRow).toBeVisible(); - // The filename is its own box, never truncated by the row's ellipsis — the - // only part of the row whose glyphs matter. `toBeVisible` cannot tell a - // visible box from a clipped one (overflow-hidden content stays "visible"), - // so prove there is no overflow: the box must be as wide as its content. - const recordFileName = recordFileRow.locator('.maka-inspector-record-file-name'); - await expect(recordFileName).toHaveText(/runtime\.sqlite$/); - await expect - .poll(() => - recordFileName.evaluate((el) => el.scrollWidth <= el.clientWidth), - ) - .toBe(true); - // The path also renders once inside the Astryx Tooltip's popover (hidden - // until hover/focus). The tooltip trigger is the row's path box, a real Tab - // stop: keyboard focus opens the tooltip (focus-visible) and Escape closes - // it — mouse users are not the only ones who can read the full path. - const recordFilePath = recordFileRow.locator('.maka-inspector-record-file'); - await expect(page.getByRole('tooltip')).toHaveCount(0); - await inspectorTab.focus(); - for ( - let step = 0; - step < 5 && !(await recordFilePath.evaluate((el) => el === document.activeElement)); - step += 1 - ) { - await page.keyboard.press('Tab'); - } - await expect(recordFilePath).toBeFocused(); - await expect(page.getByRole('tooltip')).toBeVisible(); - await page.keyboard.press('Escape'); - await expect(page.getByRole('tooltip')).toHaveCount(0); - const copyButton = recordFileRow.getByRole('button', { name: '复制文件路径' }); - await expect(copyButton).toBeEnabled(); - // Drag the divider all the way right: the workbar is an end-of-row panel - // (the shell hands Astryx `isReversed`), so dragging right shrinks it, and - // the width model clamps at the same 320px minimum the keyboard arrow uses, - // landing the panel on the smallest surface the row is allowed to live in — - // the filename still fits, the copy button stays inside the panel, and the - // clipboard receives the full path, not the truncated display. - const resizeBox = (await resize.boundingBox())!; - const handleY = resizeBox.y + resizeBox.height / 2; - await page.mouse.move(resizeBox.x, handleY); - await page.mouse.down(); - await page.mouse.move(resizeBox.x + 400, handleY, { steps: 5 }); - await page.mouse.up(); - await expect(resize).toHaveAttribute('aria-valuenow', '320'); - await expect(rightWorkbar).toHaveCSS('width', '320px'); - await expect(recordFileRow).toBeVisible(); - await expect(copyButton).toBeVisible(); - await expect - .poll(() => - recordFileName.evaluate((el) => el.scrollWidth <= el.clientWidth), - ) - .toBe(true); - await copyButton.click(); - await expect(page.getByText('已复制文件路径')).toBeVisible(); - // The row carries the FULL authoritative path from `app:info`, while the - // visible directory is allowed to truncate. Clipboard read is intentionally - // denied by the app's permission policy; the success toast proves the write. - const fullPath = await recordFileRow.getAttribute('data-full-path'); - expect(fullPath).toMatch(/runtime\.sqlite$/); - - await page.locator('button[aria-label="展开侧边栏"]').dispatchEvent('click'); - await page - .getByRole('navigation', { name: '对话列表' }) - .getByRole('button', { name: '扩展', exact: true }) - .dispatchEvent('click'); - await expect(workbar).toBeHidden(); - await expect(page.getByRole('main', { name: '扩展' })).toBeVisible(); -}); diff --git a/apps/desktop/e2e/settings-projects.spec.ts b/apps/desktop/e2e/settings-projects.spec.ts deleted file mode 100644 index fcbb445097..0000000000 --- a/apps/desktop/e2e/settings-projects.spec.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { expect, test } from './fixtures.js'; - -/** - * Settings · 偏好 · 项目 — the list, and the default-project control. - * - * The seeded catalog deliberately mixes an available project with one whose - * folder is gone, because the unavailable row is where this page can lie - * most easily: a "设为默认" that looked live on a folder the app cannot open - * would set a default that breaks every new conversation. - */ -test('the projects page lists the catalog, moves the default, gates reveal, and renames in place', async ({ - settingsProjectsWindow: page, -}) => { - await page - .getByRole('navigation', { name: /设置分组|Settings sections/ }) - .getByRole('button', { name: /项目|Projects/, exact: true }) - .click(); - - const main = page.getByRole('main', { name: /设置内容|Settings content/ }); - // Exact match: each project's folder name also appears inside its own path, - // so a substring lookup matches the name and the mono line both. - await expect(main.getByText('maka-agent', { exact: true })).toBeVisible(); - await expect(main.getByText('astryx-design-system', { exact: true })).toBeVisible(); - - // The row whose folder was never created reports that, instead of showing a - // path it cannot open. - await expect(main.getByText('retired-prototype', { exact: true })).toBeVisible(); - await expect(main.getByText('目录不可用').first()).toBeVisible(); - - // No default configured yet: every row offers to become one. - await expect(main.getByText('默认', { exact: true })).toHaveCount(0); - - const setDefaultButtons = main.getByRole('button', { name: '设为默认' }); - const enabled: number[] = []; - for (let index = 0; index < (await setDefaultButtons.count()); index += 1) { - if (await setDefaultButtons.nth(index).isEnabled()) enabled.push(index); - } - // An unavailable project cannot be made the default, and the disabled - // control says why rather than leaving the user to guess. - expect(enabled.length).toBeGreaterThan(0); - const disabled = setDefaultButtons.nth( - [...Array(await setDefaultButtons.count()).keys()].find((i) => !enabled.includes(i)) ?? 0, - ); - await expect(disabled).toBeDisabled(); - // Astryx surfaces a Button tooltip through `aria-describedby`, not `title`, - // so read the element it points at — asserting on `title` passed vacuously - // against an empty string. - const describedText = await disabled.evaluate((el) => { - const id = el.getAttribute('aria-describedby'); - return id ? (document.getElementById(id)?.textContent ?? '') : ''; - }); - expect(describedText).toContain('目录不可用'); - - await setDefaultButtons.nth(enabled[0]).click(); - - // The chosen row swaps its button for the Badge, and it persists. - await expect(main.getByText('默认', { exact: true })).toHaveCount(1); - await expect - .poll(() => - page.evaluate(() => - window.maka.settings.get().then((value) => value.projects.defaultProjectId), - ), - ) - .not.toBe(undefined); - - // Exactly one default at a time: setting another moves it rather than - // adding a second. - const remaining = main.getByRole('button', { name: '设为默认' }); - const stillEnabled: number[] = []; - for (let index = 0; index < (await remaining.count()); index += 1) { - if (await remaining.nth(index).isEnabled()) stillEnabled.push(index); - } - if (stillEnabled.length > 0) { - await remaining.nth(stillEnabled[0]).click(); - await expect(main.getByText('默认', { exact: true })).toHaveCount(1); - } - - // Reveal gating, same catalog: the row whose folder is gone must not offer - // a Finder entry that can only fail. - await expect(main.getByText('retired-prototype', { exact: true })).toBeVisible(); - - // The seeded `retired-prototype` folder was never created, so opening it - // could only fail; the entry is disabled rather than offered-and-broken. - await main.getByRole('button', { name: '更多操作:retired-prototype' }).click(); - await expect(page.getByRole('menuitem', { name: '在访达中打开' })).toBeDisabled(); - await page.keyboard.press('Escape'); - - // Main resolves the path from the catalog by id, so an available project - // reports a real directory rather than a renderer-supplied one. - const result = await page.evaluate(() => - window.maka.projects.reveal('proj-fixture-gone'), - ); - expect(result.ok).toBe(false); - - // Rename last — it rewrites a seeded name the phases above address. - await expect(main.getByText('astryx-design-system', { exact: true })).toBeVisible(); - - // Address the row by name, not by index: the app self-registers its own - // workspace as a project, so the seeded order is not the rendered order — - // an index here renamed the wrong project. - await main.getByRole('button', { name: '更多操作:astryx-design-system' }).click(); - await page.getByRole('menuitem', { name: '重命名' }).click(); - - const field = main.getByRole('textbox'); - await expect(field).toBeFocused(); - await field.fill('astryx-renamed'); - await main.getByRole('button', { name: '保存' }).click(); - - await expect(main.getByText('astryx-renamed', { exact: true })).toBeVisible(); - await expect(main.getByText('astryx-design-system', { exact: true })).toHaveCount(0); - - // It is the catalog that changed, not just the row. - await expect - .poll(() => - page.evaluate(() => - window.maka.projects.list().then((all) => all.map((p) => p.name)), - ), - ) - .toContain('astryx-renamed'); -}); diff --git a/apps/desktop/e2e/settings.spec.ts b/apps/desktop/e2e/settings.spec.ts index 4d92835a3b..99f3f20c98 100644 --- a/apps/desktop/e2e/settings.spec.ts +++ b/apps/desktop/e2e/settings.spec.ts @@ -1,183 +1,63 @@ import type { Page } from '@playwright/test'; -import { test, expect } from './fixtures'; +import { test, expect, COMPOSER_INPUT } from './fixtures'; function settingsNavigation(page: Page) { return page.getByRole('navigation', { name: /^(设置分组|Settings sections)$/ }); } -/** - * Settings take effect: open settings, switch the theme to dark, and confirm - * the root picks up the `dark` class (theme.ts applies it via - * classList.toggle). This exercises the settings open → navigate → mutate → - * apply path without depending on pixel colors. - */ -// Subagent preset lifecycle in one window: edit, delete, and create phases -// each seed their preset set with settings closed, then re-enter settings — -// the shell reads the store on open, not live. -test('subagent presets: edit round trip, reversible delete, and create-then-enable', async ({ window: page }) => { - await page.evaluate(async () => { - const connections = await window.maka.connections.list(); - const connection = connections[0]; - if (!connection) throw new Error('E2E subagent settings requires a seeded connection'); - await window.maka.settings.update({ - subagents: { - presets: [{ - id: 'e2e-fast-reader', - name: 'E2E 快速阅读', - description: '快速阅读大型代码仓库。', - profile: 'local_read', - connectionSlug: connection.slug, - model: connection.enabledModelIds?.[0] ?? connection.defaultModel, - // Seeded DISABLED on purpose: the editor's own switch reads from the - // preset, so this run is what proves saving an unrelated field makes - // the round trip without quietly re-enabling a preset the user - // turned off. - enabled: false, - }], - }, - }); - }); +test('settings owns the window chrome while a session remains active', async ({ + window: page, +}) => { + const composer = page.locator(COMPOSER_INPUT); + await composer.fill('create a session for settings'); + await composer.press('Enter'); + const identity = page.locator('[data-maka-contract="titlebar-identity"]'); + await expect(identity).toBeVisible(); await page.getByRole('button', { name: '展开侧边栏' }).click(); await page.getByRole('button', { name: '设置' }).click(); - const navItem = settingsNavigation(page).getByRole('button', { name: '子 Agent', exact: true }); - await navItem.click(); + await expect(page.getByRole('main', { name: '设置内容' })).toBeVisible(); + + await expect(identity).toHaveCount(0); + await expect(page.getByRole('button', { name: '搜索对话' })).toHaveCount(0); + await expect(page.getByRole('toolbar', { name: '工作区辅助操作' })).toHaveCount(0); + await expect(page.locator('.maka-shell-astryx')).toHaveAttribute('inert', ''); + + const titlebar = page.locator('.maka-window-titlebar'); + await expect(titlebar).toHaveCount(1); + await expect(titlebar).toHaveAttribute('aria-hidden', 'true'); + await expect(titlebar).not.toHaveAttribute('inert'); + await expect + .poll(() => + titlebar.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + appRegion: getComputedStyle(element).getPropertyValue('-webkit-app-region'), + hasArea: rect.width > 0 && rect.height > 0, + }; + }), + ) + .toEqual({ appRegion: 'drag', hasArea: true }); +}); - const settings = page.getByRole('main', { name: '设置内容' }); - await expect(settings.getByRole('heading', { name: '子 Agent', exact: true })).toBeVisible(); - await expect(settings.getByText('E2E 快速阅读', { exact: true })).toBeVisible(); - // The row's switch states the disabled preset; a badge beside it would be the - // same fact twice. - const rowSwitch = settings.getByRole('switch', { name: '启用: E2E 快速阅读' }); - await expect(rowSwitch).not.toBeChecked(); - // Arriving is not navigating: focus stays on the settings nav item the user - // just clicked. Only a level change moves it. - await expect(navItem).toBeFocused(); - - // The editor is a route level, not a dialog: the list is replaced in place - // and the back affordance is the only way out. - await settings.getByRole('button', { name: '配置“E2E 快速阅读”' }).click(); - await expect(settings.getByRole('heading', { name: 'E2E 快速阅读', exact: true })).toBeVisible(); - await expect(settings.getByRole('button', { name: '添加子 Agent' })).toBeHidden(); - // A level change moves focus to the level itself; without it the chevron - // that had focus unmounts and a keyboard user restarts from document.body. - await expect(settings.locator('[data-maka-contract="subagent-detail"]')).toBeFocused(); - // The level owns the whole preset, so it carries the two things the list row - // deliberately does not: the settled id, and deletion. - await expect(settings.getByText('e2e-fast-reader', { exact: true })).toBeVisible(); - await expect(settings.getByRole('button', { name: '删除', exact: true })).toBeVisible(); - // Renaming is the one edit that could re-key the preset: the id derives from - // the name while creating, and an existing preset must never follow it. - await settings.getByRole('textbox', { name: '显示名称' }).fill('E2E 快速阅读 v2'); - await settings.getByRole('textbox', { name: '适用场景' }).fill('快速阅读代码,并总结关键调用链。'); - await settings.getByRole('button', { name: '保存', exact: true }).click(); - - await expect(settings.getByRole('button', { name: '添加子 Agent' })).toBeVisible(); - await expect(settings.getByText('快速阅读代码,并总结关键调用链。', { exact: true })).toBeVisible(); - // Returning to the list puts focus back on the row the user left from. - await expect(settings.locator('[data-subagent-preset="e2e-fast-reader"]')).toBeFocused(); - await expect.poll(async () => page.evaluate(async () => { - const current = await window.maka.settings.get(); - const preset = current.subagents.presets[0]; - return { id: preset?.id, name: preset?.name, description: preset?.description, enabled: preset?.enabled }; - })).toEqual({ - id: 'e2e-fast-reader', - name: 'E2E 快速阅读 v2', - description: '快速阅读代码,并总结关键调用链。', - enabled: false, - }); - - // The settings shell snapshots the store when it opens, so seeding a new - // preset set only shows after leaving and re-entering settings — the same - // order the standalone tests used (seed first, open second). - await page.getByRole('button', { name: '返回应用', exact: true }).click(); - await page.evaluate(async () => { - const connections = await window.maka.connections.list(); - const connection = connections[0]; - if (!connection) throw new Error('E2E subagent settings requires a seeded connection'); - await window.maka.settings.update({ - subagents: { - presets: [{ - id: 'e2e-doomed', - name: 'E2E 待删除', - description: '这个配置会在本次测试里被删除。', - profile: 'local_read', - connectionSlug: connection.slug, - model: connection.enabledModelIds?.[0] ?? connection.defaultModel, - enabled: true, - }], - }, - }); - }); +test('opening settings commits an active titlebar rename', async ({ window: page }) => { + const composer = page.locator(COMPOSER_INPUT); + await composer.fill('create a session for settings rename'); + await composer.press('Enter'); - await page.getByRole('button', { name: '设置' }).click(); - await settingsNavigation(page).getByRole('button', { name: '子 Agent', exact: true }).click(); - - await settings.getByRole('button', { name: '配置“E2E 待删除”' }).click(); - const deleteButton = settings.getByRole('button', { name: '删除', exact: true }); - - // Cancelling the confirm has to leave the preset alone — the destructive path - // is the one place where "it did nothing" cannot be checked by eye. - await deleteButton.click(); - const confirm = page.getByRole('alertdialog'); - await expect(confirm).toBeVisible(); - await confirm.getByRole('button', { name: '取消', exact: true }).click(); - await expect(confirm).toBeHidden(); - await expect(settings.getByText('e2e-doomed', { exact: true })).toBeVisible(); - - await deleteButton.click(); - await expect(confirm).toBeVisible(); - await confirm.getByRole('button', { name: '删除', exact: true }).click(); - await expect(confirm).toBeHidden(); - - // Deletion is the only way the row a user came from can be missing, so it is - // the only thing that exercises the focus fallback. - await expect(settings.getByText('E2E 待删除', { exact: true })).toBeHidden(); - await expect(settings.getByRole('button', { name: '添加子 Agent' })).toBeFocused(); - await expect.poll(async () => page.evaluate(async () => { - const current = await window.maka.settings.get(); - return current.subagents.presets.length; - })).toBe(0); - - // The settings shell snapshots the store when it opens, so seeding a new - // preset set only shows after leaving and re-entering settings — the same - // order the standalone tests used (seed first, open second). - await page.getByRole('button', { name: '返回应用', exact: true }).click(); - await page.evaluate(async () => { - await window.maka.settings.update({ subagents: { presets: [] } }); - }); + const identity = page.locator('[data-maka-contract="titlebar-identity"]'); + await expect(identity).toBeVisible(); + await page.getByRole('button', { name: '展开侧边栏' }).click(); + await identity.getByRole('button', { name: /重命名对话/ }).click(); + await page.getByRole('textbox', { name: '重命名对话' }).fill('renamed before settings'); - await page.getByRole('button', { name: '设置' }).click(); - await settingsNavigation(page).getByRole('button', { name: '子 Agent', exact: true }).click(); - - // The create branch is a structurally different tree from the edit branch — - // a typed id instead of a settled one, and no delete section — so it needs - // its own journey rather than riding on the edit one. - await settings.getByRole('button', { name: '添加子 Agent' }).click(); - await settings.getByRole('textbox', { name: '显示名称' }).fill('E2E Web Research'); - // The id derives from the name until the user takes it over. - await expect(settings.getByRole('textbox', { name: 'subagent_id' })).toHaveValue('e2e-web-research'); - // Taking the id over stops the derivation for good: a later name edit must - // not walk over what the user typed. - await settings.getByRole('textbox', { name: 'subagent_id' }).fill('web-research-owned'); - await settings.getByRole('textbox', { name: '显示名称' }).fill('E2E Web Research 2'); - await expect(settings.getByRole('textbox', { name: 'subagent_id' })).toHaveValue('web-research-owned'); - await settings.getByRole('textbox', { name: '适用场景' }).fill('查找外部资料。'); - await settings.getByRole('switch', { name: '启用', exact: true }).click(); - await settings.getByRole('button', { name: '创建', exact: true }).click(); - - await expect(settings.getByText('E2E Web Research 2', { exact: true })).toBeVisible(); - await expect.poll(async () => page.evaluate(async () => { - const current = await window.maka.settings.get(); - const preset = current.subagents.presets[0]; - return { id: preset?.id, enabled: preset?.enabled }; - })).toEqual({ id: 'web-research-owned', enabled: false }); - - await settings.getByRole('switch', { name: '启用: E2E Web Research 2' }).click(); - await expect.poll(async () => page.evaluate(async () => { - const current = await window.maka.settings.get(); - return current.subagents.presets[0]?.enabled; - })).toBe(true); + // Programmatic activation preserves input focus, matching the macOS + // application-menu command that opens Settings before Chromium can blur it. + await page.getByRole('button', { name: '设置' }).evaluate((button) => button.click()); + await expect(page.getByRole('main', { name: '设置内容' })).toBeVisible(); + await page.keyboard.press('Escape'); + + await expect(identity).toContainText('renamed before settings'); }); // Appearance and channel surface in one window. The channel seed runs before diff --git a/apps/desktop/e2e/skill-delete-scope.spec.ts b/apps/desktop/e2e/skill-delete-scope.spec.ts deleted file mode 100644 index f3757da24e..0000000000 --- a/apps/desktop/e2e/skill-delete-scope.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -// User-scope skill deletion E2E (#1517). -// -// The delete path is the one place the Skills panel removes files from the -// user's HOME rather than from app-managed workspace data, so the journey that -// matters is the whole round trip: the contextual action is offered for the -// scopes the backend can actually delete, the confirmation protects the file -// operation, and the row is gone on the refresh that follows. -// -// This spec only exists because `buildE2eEnv` now sandboxes HOME. Before that, -// running it would have deleted a real skill out of the developer's -// `~/.agents/skills`. The unit tests in `skills.test.ts` cover the containment -// guards against a temp filesystem; what they cannot cover is the renderer -// sending a scope-aware ref through IPC and the list agreeing afterwards. - -import { access } from 'node:fs/promises'; -import path from 'node:path'; -import { e2eHomeDir, test, expect } from './fixtures.js'; - -test('offers delete only for deletable scopes and removes a user-scope skill from disk', async ({ - invocableSkillsWindow: page, -}) => { - const skillDir = path.join(e2eHomeDir(), '.agents', 'skills', 'user-only'); - // The sandbox is real: the seeded skill is on disk before anything happens. - await access(skillDir); - - await page.getByRole('button', { name: '展开侧边栏' }).click(); - const sidebar = page.getByRole('navigation', { name: '对话列表' }); - await sidebar.getByRole('button', { name: '扩展', exact: true }).click(); - await page.getByRole('main', { name: '扩展' }).waitFor(); - - // The panel always renders all three views, and it picks its landing view from - // whatever the skill list happened to be at mount — so click 已安装 - // unconditionally and let Playwright wait for it. Sampling `isVisible()` - // first only added a branch that could skip the click on a slow mount and - // then hunt for the row on the wrong tab. - await page.getByRole('radio', { name: '已安装' }).click(); - - // Non-destructive scope check first, in the same installed list: project - // skills live in the user's repo and are left to git — the backend refuses - // them with `blocked_scope`, and the panel must not offer a button that - // cannot work. - const projectRow = page.getByRole('button', { name: /Project Only/ }); - await expect(projectRow).toBeVisible(); - await projectRow.click(); - const inspector = page.getByRole('complementary', { name: '技能详情' }); - await expect(inspector.getByRole('button', { name: '删除', exact: true })).toHaveCount(0); - - const row = page.getByRole('button', { name: /User Only/ }); - await expect(row).toBeVisible(); - await row.click(); - - await inspector.getByRole('button', { name: '删除', exact: true }).click(); - - // Opening the confirmation alone must not touch disk. - await access(skillDir); - - const confirm = page.getByRole('alertdialog', { name: '确认删除 User Only' }); - await confirm.getByRole('button', { name: '删除', exact: true }).click(); - - await expect(page.getByRole('button', { name: /User Only/ })).toHaveCount(0); - await expect.poll(() => access(skillDir).then(() => 'present', () => 'gone')).toBe('gone'); - - // Focus moves to the row that took the deleted one's place — not to body, - // which would drop a keyboard user at the top of the document. - await expect(page.locator('.maka-module-page-rows > li button:focus')).toHaveCount(1); -}); diff --git a/apps/desktop/e2e/skills.spec.ts b/apps/desktop/e2e/skills.spec.ts deleted file mode 100644 index 38d995f927..0000000000 --- a/apps/desktop/e2e/skills.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Skills page interaction hierarchy. -// -// These checks use the real Desktop bridge and seeded Skill inventory. They -// protect the visible contract rather than the component implementation: -// installed rows are selectable and otherwise inert, and every per-skill -// action lives in the end-panel inspector — the same surface as 定时任务. - -import type { Page } from '@playwright/test'; -import { test, expect } from './fixtures.js'; - -async function openInstalledSkills(page: Page) { - await page.getByRole('button', { name: '展开侧边栏' }).click(); - const sidebar = page.getByRole('navigation', { name: '对话列表' }); - await sidebar.getByRole('button', { name: '扩展', exact: true }).click(); - await page.getByRole('main', { name: '扩展' }).waitFor(); - await page.getByRole('radio', { name: '已安装' }).click(); -} - -test('keeps installed rows inert and routes every per-skill action through the inspector', async ({ - invocableSkillsWindow: page, -}) => { - await openInstalledSkills(page); - - // The row itself is the one click target — a button named by the skill. - const row = page.getByRole('button', { name: /Workspace Only/ }); - await expect(row).toBeVisible(); - // Nothing else rides the row: no switch, no overflow menu, no use button. - const item = page.getByRole('listitem').filter({ hasText: 'Workspace Only' }); - await expect(item.getByRole('switch')).toHaveCount(0); - await expect(item.getByRole('button', { name: /更多操作/ })).toHaveCount(0); - - await row.click(); - const inspector = page.getByRole('complementary', { name: '技能详情' }); - await expect(inspector.getByRole('switch', { name: '启用' })).toBeVisible(); - await expect(inspector.getByRole('button', { name: '使用', exact: true })).toBeVisible(); - await expect(inspector.getByRole('button', { name: '打开 SKILL.md' })).toBeVisible(); - await expect(inspector.getByRole('button', { name: '固定到技能上下文' })).toBeVisible(); - await expect(inspector.getByRole('button', { name: '删除', exact: true })).toBeVisible(); -}); diff --git a/apps/desktop/e2e/slash-command-menu.spec.ts b/apps/desktop/e2e/slash-command-menu.spec.ts new file mode 100644 index 0000000000..03302c526f --- /dev/null +++ b/apps/desktop/e2e/slash-command-menu.spec.ts @@ -0,0 +1,134 @@ +import { expect, test, COMPOSER_INPUT } from './fixtures'; + +test('shows only slash commands executable in the current session state', async ({ + invocableSkillsWindow: page, +}) => { + const composer = page.locator(COMPOSER_INPUT); + await composer.click(); + await composer.pressSequentially('/'); + + const freshMenu = page.getByRole('listbox', { name: '命令和技能' }); + const freshCommands = freshMenu.getByRole('group', { name: '命令' }); + await expect(freshCommands.getByRole('option')).toHaveCount(2); + await expect(freshCommands.getByRole('option', { name: /使用 Graph.*\/graph/ })).toBeVisible(); + await expect(freshCommands.getByRole('option', { name: /使用 Swarm.*\/swarm/ })).toBeVisible(); + + await composer.press('Escape'); + await composer.fill('seed session'); + await composer.press('Enter'); + await expect(page.getByText('Fake backend received: seed session')).toBeVisible(); + + await composer.click(); + await composer.pressSequentially('/'); + + const menu = page.getByRole('listbox', { name: '命令和技能' }); + const groups = menu.getByRole('group'); + await expect(groups).toHaveCount(2); + await expect(groups.nth(0)).toHaveAttribute('aria-label', '命令'); + await expect(groups.nth(1)).toHaveAttribute('aria-label', 'Skills'); + + await expect(groups.nth(0).getByRole('option')).toHaveCount(4); +}); + +test('compacts the active session', async ({ + invocableSkillsWindow: page, +}) => { + const composer = page.locator(COMPOSER_INPUT); + await composer.fill('seed session'); + await composer.press('Enter'); + await expect(page.getByText('Fake backend received: seed session')).toBeVisible(); + + const sessionId = await page.evaluate(async () => (await window.maka.sessions.list())[0]?.id); + expect(sessionId).toBeTruthy(); + await page.evaluate((activeSessionId) => { + const testWindow = window as typeof window & { + __makaObservedCompactCompletion?: boolean; + }; + testWindow.__makaObservedCompactCompletion = false; + window.maka.sessions.subscribeChanges((event) => { + if (event.reason === 'status-change' && event.sessionId === activeSessionId) { + testWindow.__makaObservedCompactCompletion = true; + } + }); + }, sessionId!); + + await composer.click(); + await composer.pressSequentially('/'); + + const menu = page.getByRole('listbox', { name: '命令和技能' }); + const compact = menu.getByRole('group', { name: '命令' }).getByRole('option', { + name: /压缩上下文.*\/compact/, + }); + await compact.click(); + + await expect.poll(() => composer.textContent()).toBe('/compact '); + await expect(menu).not.toBeVisible(); + await composer.press('Enter'); + + await expect + .poll(() => + page.evaluate( + () => + (window as typeof window & { __makaObservedCompactCompletion?: boolean }) + .__makaObservedCompactCompletion, + ), + ) + .toBe(true); + await expect.poll(() => composer.textContent()).toBe(''); + await expect(page.getByText('压缩失败')).toHaveCount(0); + + await composer.fill('after compact'); + await composer.press('Enter'); + await expect(page.getByText('Fake backend received: after compact')).toBeVisible(); + await expect(page.getByText('Fake backend received: /compact')).toHaveCount(0); +}); + +test('offers commands only for the first token and keeps explicit Skill queries separate', async ({ + invocableSkillsWindow: page, +}) => { + const composer = page.locator(COMPOSER_INPUT); + await composer.fill('seed session'); + await composer.press('Enter'); + await expect(page.getByText('Fake backend received: seed session')).toBeVisible(); + + await composer.fill('explain /'); + const inlineMenu = page.getByRole('listbox', { name: '命令和技能' }); + await expect(inlineMenu.getByRole('group', { name: '命令' })).toHaveCount(0); + await expect(inlineMenu.getByRole('group', { name: 'Skills' })).toBeVisible(); + + await composer.fill('/skill:compact'); + await expect(inlineMenu.getByRole('option', { name: /\/compact/ })).toHaveCount(0); + + await composer.fill('/side'); + await composer.press('Home'); + await composer.press('ArrowRight'); + await expect(inlineMenu.getByRole('group', { name: '命令' })).toHaveCount(0); +}); + +test('dispatches a staged slash command instead of steering it into a running turn', async ({ + invocableSkillsWindow: page, +}) => { + const composer = page.locator(COMPOSER_INPUT); + await composer.fill('keep streaming '.repeat(80)); + await composer.press('Enter'); + await expect(page.getByRole('button', { name: '停止' })).toBeVisible(); + + await composer.fill('/compact explain'); + await expect(page.getByRole('button', { name: '插入消息' })).toBeVisible(); + await composer.press('Enter'); + + await composer.fill('/'); + const menu = page.getByRole('listbox', { name: '命令和技能' }); + const commands = menu.getByRole('group', { name: '命令' }); + await expect(commands.getByRole('option', { name: /\/compact/ })).toHaveCount(0); + const side = commands.getByRole('option', { name: /打开侧聊.*\/side/ }); + await side.click(); + await expect.poll(() => composer.textContent()).toBe('/side '); + await expect(page.locator('.maka-quote-workbar-panel')).toHaveCount(0); + + await composer.fill('/side discuss separately'); + await composer.press('Enter'); + + await expect(page.locator('.maka-quote-workbar-panel')).toHaveCount(1); + await expect(page.getByText(/Acknowledged steering: \/compact explain/)).toBeVisible(); +}); diff --git a/apps/desktop/e2e/storage-root-conflict.spec.ts b/apps/desktop/e2e/storage-root-conflict.spec.ts deleted file mode 100644 index 72c27abd53..0000000000 --- a/apps/desktop/e2e/storage-root-conflict.spec.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { _electron as electron, expect, test } from '@playwright/test'; -import type { ElectronApplication } from '@playwright/test'; -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { STORAGE_ROOT_MARKER_FILE, resolveStorageRoot } from '@maka/storage/root-authority'; -import { buildFixtureEnv } from '../../../scripts/fixture-env.mjs'; -import { closeElectronApplication } from '../../../scripts/electron-lifecycle.mjs'; - -const DESKTOP_ROOT = process.cwd(); - -/** - * Startup signal the main process prints synchronously right before showing - * the repair modal (see `confirmDesktopStorageRootRepair` in - * `runtime-host-boot.ts`). It is - * an explicit contract between the app and this test: it can only be printed - * after `ready` — startup runs inside the `whenReady` callback — - * and only when the root-identity gate fired, so seeing it proves both - * invariants at once. - * - * CDP evaluation is deliberately not the success signal: macOS modal loops - * block CDP evaluation while Linux modal dialogs keep answering it, so no - * evaluate-based heuristic is portable. A deadlocked main process (the - * regression this test guards) never reaches the gate, so the signal never - * appears; a future removal of the gate skips the signal too. - */ -const REPAIR_GATE_SIGNAL = '[storage-root] root-identity conflict; parking at repair dialog'; - -async function appParkedAtRepairGate(app: ElectronApplication): Promise { - return new Promise((resolve) => { - let settled = false; - const timeout = setTimeout(() => { - if (!settled) { - settled = true; - resolve(false); - } - }, 30_000); - app.on('console', (message) => { - if (settled) return; - if (message.text().includes(REPAIR_GATE_SIGNAL)) { - settled = true; - clearTimeout(timeout); - resolve(true); - } - }); - }); -} - -/** - * A conflicting storage root must park at the repair dialog — never deadlock - * in module evaluation — and must not write any store/db files before the - * user answers. - * - * Regression for the Electron ESM startup deadlock: top-level - * `await app.whenReady()` inside the repair-confirm path never resolves - * because `ready` only fires after the main module finishes evaluating. - */ -test('parks at the storage-root repair dialog and writes nothing before the answer', async () => { - const userDataDir = await mkdtemp(join(tmpdir(), 'maka-root-conflict-')); - const homeDir = join(userDataDir, 'home'); - await mkdir(homeDir, { recursive: true }); - let app; - try { - // Seed a real interactive marker, then corrupt its device id so startup - // must stop at the repair dialog (mirrors the disk-identity drift that - // triggers root_identity_collision). - const workspaceRoot = join(userDataDir, 'workspaces', 'default'); - await resolveStorageRoot({ path: workspaceRoot, kind: 'interactive' }); - const markerPath = join(workspaceRoot, STORAGE_ROOT_MARKER_FILE); - const marker = JSON.parse(await readFile(markerPath, 'utf8')) as { - rootIdentity: { dev: string }; - }; - marker.rootIdentity.dev = (BigInt(marker.rootIdentity.dev) + 1n).toString(); - const conflictingMarker = `${JSON.stringify(marker)}\n`; - await writeFile(markerPath, conflictingMarker); - - // Launch without a fixture so the repair gate is live (fixture mode - // seeds its own workspace and bypasses the dialog). - app = await electron.launch({ - args: ['.'], - cwd: DESKTOP_ROOT, - env: buildFixtureEnv(userDataDir, homeDir, {}), - }); - - // The gate signal is printed only after ready and only when the conflict - // fired; a deadlocked main process (the regression) never prints it. - expect(await appParkedAtRepairGate(app)).toBe(true); - - // While the dialog is unanswered, no store/db files may be created in - // the workspace: the root-identity gate must precede all storage. - const workspaceEntries = await readdir(workspaceRoot); - expect(workspaceEntries).toEqual([STORAGE_ROOT_MARKER_FILE]); - expect(await readFile(markerPath, 'utf8')).toBe(conflictingMarker); - } finally { - if (app) await closeElectronApplication(app, 5_000); - await rm(userDataDir, { recursive: true, force: true }); - } -}); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e650213f7e..f19f26c510 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "@maka/desktop", "productName": "Maka", "description": "A local-first agent workspace built for real work.", - "version": "0.1.8", + "version": "0.1.9", "author": "The Maka Authors", "license": "Apache-2.0", "private": true, @@ -19,6 +19,7 @@ "build": "npm run build:resources && npm run build:main && npm run build:preload && npm run build:overlay && npm run build:renderer", "build:resources": "node scripts/copy-runtime-filesystem-worker.mjs", "build:test": "npm run build:main && npm run build:preload && npm run build:overlay", + "build:smoke": "npm run build:resources && npm run build:renderer", "clean:main": "node ../../scripts/clean-paths.mjs dist/main tsconfig.main.tsbuildinfo", "build:main": "tsc -p tsconfig.main.json", "build:preload": "esbuild src/preload/preload.ts --bundle --platform=node --format=cjs --outfile=dist/preload/preload.cjs --external:electron", diff --git a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt index f99ee985b9..5764e44b7f 100644 --- a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt +++ b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt @@ -9,7 +9,7 @@ Policy: every package must resolve to an ASF-compatible SPDX license. Compound expressions record the compatible selected license. Packages without a shipped license file require an exact name@version text override in the generator. -Package: @ai-sdk/anthropic@4.0.30 +Package: @ai-sdk/anthropic@4.0.36 Declared license: Apache-2.0 Selected license: Apache-2.0 Repository: https://github.com/vercel/ai#packages/anthropic @@ -31,7 +31,29 @@ limitations under the License. ================================================================================ -Package: @ai-sdk/cohere@4.0.21 +Package: @ai-sdk/code-mode@1.0.15 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: https://github.com/vercel/ai#packages/code-mode + +--- LICENSE --- +Copyright 2023 Vercel, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +================================================================================ + +Package: @ai-sdk/cohere@4.0.25 Declared license: Apache-2.0 Selected license: Apache-2.0 Repository: https://github.com/vercel/ai#packages/cohere @@ -53,7 +75,7 @@ limitations under the License. ================================================================================ -Package: @ai-sdk/gateway@4.0.41 +Package: @ai-sdk/gateway@4.0.46 Declared license: Apache-2.0 Selected license: Apache-2.0 Repository: https://github.com/vercel/ai#packages/gateway @@ -75,7 +97,7 @@ limitations under the License. ================================================================================ -Package: @ai-sdk/google@4.0.34 +Package: @ai-sdk/google@4.0.39 Declared license: Apache-2.0 Selected license: Apache-2.0 Repository: https://github.com/vercel/ai#packages/google @@ -97,7 +119,7 @@ limitations under the License. ================================================================================ -Package: @ai-sdk/openai@4.0.30 +Package: @ai-sdk/openai@4.0.36 Declared license: Apache-2.0 Selected license: Apache-2.0 Repository: https://github.com/vercel/ai#packages/openai @@ -119,7 +141,7 @@ limitations under the License. ================================================================================ -Package: @ai-sdk/openai-compatible@3.0.23 +Package: @ai-sdk/openai-compatible@3.0.27 Declared license: Apache-2.0 Selected license: Apache-2.0 Repository: https://github.com/vercel/ai#packages/openai-compatible @@ -141,7 +163,7 @@ limitations under the License. ================================================================================ -Package: @ai-sdk/provider@4.0.5 +Package: @ai-sdk/provider@4.0.7 Declared license: Apache-2.0 Selected license: Apache-2.0 Repository: https://github.com/vercel/ai#packages/provider @@ -163,7 +185,7 @@ limitations under the License. ================================================================================ -Package: @ai-sdk/provider-utils@5.0.21 +Package: @ai-sdk/provider-utils@5.0.25 Declared license: Apache-2.0 Selected license: Apache-2.0 Repository: https://github.com/vercel/ai#packages/provider-utils @@ -1335,6 +1357,36 @@ limitations under the License. ================================================================================ +Package: @openai/agents-core@0.14.3 +Declared license: MIT +Selected license: MIT +Repository: https://github.com/openai/openai-agents-js + +--- LICENSE --- +MIT License + +Copyright (c) 2025 OpenAI + +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. + +================================================================================ + Package: @protobufjs/aspromise@1.1.2 Declared license: BSD-3-Clause Selected license: BSD-3-Clause @@ -3317,36 +3369,6 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -Package: acorn@8.18.0 -Declared license: MIT -Selected license: MIT -Repository: git+https://github.com/acornjs/acorn.git - ---- LICENSE --- -MIT License - -Copyright (C) 2012-2022 by various contributors (see AUTHORS) - -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. - -================================================================================ - Package: agent-base@6.0.2 Declared license: MIT Selected license: MIT @@ -3408,7 +3430,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -Package: ai@7.0.52 +Package: ai@7.0.58 Declared license: Apache-2.0 Selected license: Apache-2.0 Repository: https://github.com/vercel/ai#packages/ai @@ -11449,6 +11471,216 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ================================================================================ +Package: openai@6.49.0 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: github:openai/openai-node + +--- LICENSE --- +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 OpenAI + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +================================================================================ + Package: p-finally@1.0.0 Declared license: MIT Selected license: MIT @@ -13499,70 +13731,6 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -Package: typescript@5.9.3 -Declared license: Apache-2.0 -Selected license: Apache-2.0 -Repository: https://github.com/microsoft/TypeScript.git - ---- LICENSE.txt --- -Apache License - -Version 2.0, January 2004 - -http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - -"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. - -"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. - -"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. - -"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. - -"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. - -"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. - -"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). - -"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. - -"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." - -"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: - -You must give any other recipients of the Work or Derivative Works a copy of this License; and - -You must cause any modified files to carry prominent notices stating that You changed the files; and - -You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and - -If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -================================================================================ - Package: uhyphen@0.2.0 Declared license: ISC Selected license: ISC @@ -14291,3 +14459,64 @@ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +================================================================================ + +Embedded component: quickjs-emscripten (embedded runtime) +Embedded by: @ai-sdk/code-mode@1.0.15 +Selected license: MIT +Repository: https://github.com/justjake/quickjs-emscripten + +--- VERSION-PINNED EMBEDDED LICENSE TEXT --- +MIT License + +quickjs-emscripten copyright (c) 2019-2024 Jake Teton-Landis + +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. + +================================================================================ + +Embedded component: QuickJS JavaScript engine (embedded runtime) +Embedded by: @ai-sdk/code-mode@1.0.15 +Selected license: MIT +Repository: https://github.com/bellard/quickjs + +--- VERSION-PINNED EMBEDDED LICENSE TEXT --- +MIT License + +Copyright (c) 2017-2021 Fabrice Bellard +Copyright (c) 2017-2021 Charlie Gordon + +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/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 082d371de8..983fc953fe 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -70,6 +70,7 @@ function createActionsDeps() { sessionId: undefined, navSection: 'sessions' as const, }), + checkTaskSubmissionReadiness: async () => true, clearPendingSessionAction: () => undefined, isNewChatSendSurfaceActive: () => true, isShellSurfaceOwnerActive: () => true, @@ -95,6 +96,41 @@ function createActionsDeps() { } describe('composer first-send cleanup', () => { + it('cancels when the composer owner changes during the readiness check', async () => { + const readiness = deferred(); + const activeIdRef = { current: 'session-a' as string | undefined }; + let sends = 0; + const restoreWindow = installWindow({ + sessions: { + send: async () => { + sends += 1; + return { ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] } }; + }, + }, + }); + + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef, + captureComposerImportOwner: () => ({ + sessionId: activeIdRef.current, + navSection: 'sessions', + }), + checkTaskSubmissionReadiness: () => readiness.promise, + isShellSurfaceOwnerActive: (owner) => owner.sessionId === activeIdRef.current, + }); + const sending = actions.send('hello'); + activeIdRef.current = 'session-b'; + readiness.resolve(true); + + assert.equal(await sending, false); + assert.equal(sends, 0, 'readiness for session A must never authorize a send to session B'); + } finally { + restoreWindow(); + } + }); + it('passes the effective offered model when creating the first session', async () => { let createInput: unknown; const restoreWindow = installWindow({ @@ -239,6 +275,14 @@ describe('composer first-send cleanup', () => { }); }); +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve }; +} + /** * #1433 round 5: the failure feedback for a send is addressed to the surface * that sent, and `showModelSetupToast` is not just a toast — it ends in diff --git a/apps/desktop/src/main/__tests__/artifact-visibility.test.ts b/apps/desktop/src/main/__tests__/artifact-visibility.test.ts index 00801c384c..ad912dbf95 100644 --- a/apps/desktop/src/main/__tests__/artifact-visibility.test.ts +++ b/apps/desktop/src/main/__tests__/artifact-visibility.test.ts @@ -32,10 +32,17 @@ describe('generated artifact visibility', () => { assert.deepEqual(filterUserVisibleArtifacts(hiddenSources.map(artifact)), []); }); - it('preserves user-facing generated files and legacy records without a source', () => { - const visibleSources: Array = [ + it('excludes generic tool traces from generated files', () => { + const hiddenSources: ArtifactSource[] = [ 'tool_result', 'tool_result_archive', + ]; + + assert.deepEqual(filterUserVisibleArtifacts(hiddenSources.map(artifact)), []); + }); + + it('preserves user-facing generated files and legacy records without a source', () => { + const visibleSources: Array = [ 'subagent_writeback', 'deep_research', 'export', diff --git a/apps/desktop/src/main/__tests__/contract-css-helpers.ts b/apps/desktop/src/main/__tests__/contract-css-helpers.ts deleted file mode 100644 index 5d55e313c4..0000000000 --- a/apps/desktop/src/main/__tests__/contract-css-helpers.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import { readAllRendererCss } from './css-test-helpers.js'; - -export const CONTRACT_REPO_ROOT = resolve(import.meta.dirname, '../../../../..'); -export const CONTRACT_RENDERER_ROOT = resolve(CONTRACT_REPO_ROOT, 'apps', 'desktop', 'src', 'renderer'); -export const CONTRACT_STYLES_ENTRY = resolve(CONTRACT_RENDERER_ROOT, 'styles.css'); - -export async function readRendererContractCss(): Promise { - try { - return await readAllRendererCss(); - } catch { - // Pre-split branches only have styles.css. - return readFile(CONTRACT_STYLES_ENTRY, 'utf8'); - } -} diff --git a/apps/desktop/src/main/__tests__/css-test-helpers.ts b/apps/desktop/src/main/__tests__/css-test-helpers.ts deleted file mode 100644 index 7f8b9d95d2..0000000000 --- a/apps/desktop/src/main/__tests__/css-test-helpers.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { dirname, resolve } from 'node:path'; - -export const REPO_ROOT = resolve(import.meta.dirname, '../../../../..'); -export const RENDERER_STYLES_ENTRY = resolve(REPO_ROOT, 'apps', 'desktop', 'src', 'renderer', 'styles.css'); - -const CSS_IMPORT_RE = /@import\s+"([^"]+\.css)"(?:\s+layer\([^)]+\))?\s*;/g; - -// Generated Astryx theme output (#1565 PR 3). The converge contracts govern -// Maka's hand-written CSS vocabulary; astryx-theme/maka.css is an -// `astryx theme build` artifact with Astryx's own token system, excluded here -// for the same reason node_modules sheets never entered the scan (bare -// imports are skipped below). -// First-party workspace stylesheets reached through a bare specifier. These -// are product CSS in the same cascade layer as styles/*, so they belong in the -// scan; the bare-import skip below exists to keep node_modules sheets out, and -// once read as "skip anything not starting with ." it silently exempted the -// one first-party sheet imported that way. Resolved from the package's own -// `exports` map rather than guessed, so a moved file fails loudly. -const WORKSPACE_CSS_EXPORTS: Record = { - '@maka/ui/styles.css': 'packages/ui/src/styles.css', -}; - -export async function expandCssImports(file: string, seen: Set): Promise { - const source = await readFile(file, 'utf8'); - let expanded = source; - - for (const match of source.matchAll(CSS_IMPORT_RE)) { - const importPath = match[1]; - const workspaceCss = WORKSPACE_CSS_EXPORTS[importPath]; - if (!workspaceCss && !importPath.startsWith('.')) continue; - if (importPath.includes('astryx-theme/')) continue; - - const resolvedPath = workspaceCss - ? resolve(REPO_ROOT, workspaceCss) - : resolve(dirname(file), importPath); - if (seen.has(resolvedPath)) continue; - - seen.add(resolvedPath); - expanded += `\n${await expandCssImports(resolvedPath, seen)}`; - } - - return expanded; -} - -export async function readAllRendererCss(): Promise { - // Fail closed: if import expansion breaks (missing file, bad @import path), - // surface the error so converge contracts catch it instead of silently - // degrading to only the styles.css entry and skipping styles/*. - return expandCssImports(RENDERER_STYLES_ENTRY, new Set([RENDERER_STYLES_ENTRY])); -} diff --git a/apps/desktop/src/main/__tests__/desktop-slash-command.test.ts b/apps/desktop/src/main/__tests__/desktop-slash-command.test.ts new file mode 100644 index 0000000000..62c69f3149 --- /dev/null +++ b/apps/desktop/src/main/__tests__/desktop-slash-command.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { parseDesktopSlashCommand } from '../../renderer/desktop-slash-command.js'; + +describe('Desktop slash command parsing', () => { + it('recognizes only input accepted by a Desktop command parser', () => { + assert.deepEqual(parseDesktopSlashCommand(' /compact '), { kind: 'compact' }); + assert.equal(parseDesktopSlashCommand('/compact explain'), null); + assert.deepEqual(parseDesktopSlashCommand('/side discuss separately'), { + kind: 'side', + command: { prompt: 'discuss separately' }, + }); + assert.deepEqual(parseDesktopSlashCommand('/graph status'), { + kind: 'graph', + command: { kind: 'status' }, + }); + assert.deepEqual(parseDesktopSlashCommand('/swarm investigate'), { + kind: 'swarm', + command: { kind: 'run_once', task: 'investigate' }, + }); + assert.equal(parseDesktopSlashCommand('explain /side'), null); + }); +}); diff --git a/apps/desktop/src/main/__tests__/git-review-main.test.ts b/apps/desktop/src/main/__tests__/git-review-main.test.ts index aa1f01b297..1550398ee8 100644 --- a/apps/desktop/src/main/__tests__/git-review-main.test.ts +++ b/apps/desktop/src/main/__tests__/git-review-main.test.ts @@ -104,6 +104,28 @@ describe('Git Review snapshot authority', () => { ); }); + it('compares a branch from its merge base when the base branch has advanced', async () => { + const root = await repository(); + await git(root, 'checkout', '-b', 'feature/review'); + await writeFile(join(root, 'feature.txt'), 'feature\n', 'utf8'); + await git(root, 'add', 'feature.txt'); + await git(root, 'commit', '-m', 'feature'); + + await git(root, 'checkout', 'main'); + await writeFile(join(root, 'main-only.txt'), 'main only\n', 'utf8'); + await git(root, 'add', 'main-only.txt'); + await git(root, 'commit', '-m', 'advance main'); + await git(root, 'checkout', 'feature/review'); + + const result = await readGitReview(root, 'branch'); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.deepEqual( + result.snapshot.files.map((file) => file.path), + ['feature.txt'], + ); + }); + it('stages and unstages only paths admitted by the current snapshot', async () => { const root = await repository(); await writeFile(join(root, 'base.txt'), 'base\nchanged\n', 'utf8'); diff --git a/apps/desktop/src/main/__tests__/ink-ladder-contract.test.ts b/apps/desktop/src/main/__tests__/ink-ladder-contract.test.ts deleted file mode 100644 index 2b630e16db..0000000000 --- a/apps/desktop/src/main/__tests__/ink-ladder-contract.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { strict as assert } from 'node:assert'; -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { describe, it } from 'node:test'; -import { REPO_ROOT } from './css-test-helpers.js'; - -/** - * The ink ladder's aliasing invariant (visual system 2.0, T1). - * - * `--foreground-dimmed` and `--foreground-secondary` were both written as - * "80% ink", but one mixed in srgb and the other in oklch, so the same words - * produced two different colours — 10.51:1 against 12.01:1, measured. That is - * the failure this pins: not a wrong value, but a SECOND definition of one - * tier that drifted because nothing said the two had to agree. - * - * Asserted on the source text rather than a rendered page because the point is - * that dimmed has no independent definition to drift from. A screenshot would - * only prove they happen to match today. - */ -const TOKENS_PATH = join(REPO_ROOT, 'apps', 'desktop', 'src', 'renderer', 'maka-tokens.css'); - -describe('ink ladder', () => { - it('never lets --foreground-dimmed hold its own mix', async () => { - const css = await readFile(TOKENS_PATH, 'utf8'); - const declarations = [...css.matchAll(/^\s*--foreground-dimmed:\s*([^;]+);/gm)].map( - (match) => match[1].trim(), - ); - - // T4 retired the alias: its five consumers now name --foreground-secondary - // directly, so zero declarations is the expected steady state and satisfies - // this invariant outright. The assertion below is what still has work to do - // — it fails the moment anyone reintroduces the token with a mix of its own, - // which is the drift being pinned, not the token's mere existence. - for (const value of declarations) { - assert.equal( - value, - 'var(--foreground-secondary)', - 'dimmed is the secondary tier under another name; giving it its own color-mix is how the two drifted apart before', - ); - } - }); - - it('derives every ink tier in one colour space', async () => { - const css = await readFile(TOKENS_PATH, 'utf8'); - // A tier mixed in srgb while its siblings mix in oklch is the exact defect - // above, and it is invisible in review because the percentages match. - const srgbInk = [...css.matchAll(/^\s*(--[a-z-]*foreground[a-z-]*):\s*color-mix\(in srgb[^;]*;/gm)]; - assert.deepEqual( - srgbInk.map((match) => match[1]), - [], - 'ink tiers derive in oklch; an srgb mix gives the same words a different colour', - ); - }); -}); diff --git a/apps/desktop/src/main/__tests__/new-session-project.test.ts b/apps/desktop/src/main/__tests__/new-session-project.test.ts index bc5fd2fcf5..88b0507f4c 100644 --- a/apps/desktop/src/main/__tests__/new-session-project.test.ts +++ b/apps/desktop/src/main/__tests__/new-session-project.test.ts @@ -175,7 +175,7 @@ test('new sessions resolve a merged project alias to the surviving project id', const original = await catalog.register(originalPath); await rm(originalPath, { recursive: true, force: true }); const duplicate = await catalog.register(cwd); - await catalog.relink(original.id, cwd, async () => {}); + await catalog.relinkWithSessions(original.id, cwd); const resolved = await resolveNewSessionProjectInput( makeInput(cwd, { projectId: duplicate.id }), diff --git a/apps/desktop/src/main/__tests__/project-management-service.test.ts b/apps/desktop/src/main/__tests__/project-management-service.test.ts index e0471308a0..4a5153074d 100644 --- a/apps/desktop/src/main/__tests__/project-management-service.test.ts +++ b/apps/desktop/src/main/__tests__/project-management-service.test.ts @@ -1,9 +1,9 @@ import assert from 'node:assert/strict'; -import { mkdir, mkdtemp, realpath, rename, rm } from 'node:fs/promises'; +import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; -import { createProjectCatalog, createSessionStore } from '@maka/storage'; +import { createProjectCatalog } from '@maka/storage'; import { createProjectManagementService } from '../project-management-service.js'; test('project management service owns selection and reversible lifecycle actions', async () => { @@ -20,12 +20,6 @@ test('project management service owns selection and reversible lifecycle actions }); const service = createProjectManagementService({ catalog, - sessions: { - listHeaders: async () => [], - updateHeader: async () => { - throw new Error('No sessions expected'); - }, - }, chooseDirectory: async () => nextDirectory, selection: { currentSelection: async () => ({ @@ -80,12 +74,6 @@ test('project management service rejects malformed IPC identities before catalog const base = await mkdtemp(join(tmpdir(), 'maka-project-service-input-')); const service = createProjectManagementService({ catalog: createProjectCatalog(join(base, 'storage')), - sessions: { - listHeaders: async () => [], - updateHeader: async () => { - throw new Error('No sessions expected'); - }, - }, chooseDirectory: async () => undefined, selection: { currentSelection: async () => ({ projectId: undefined, path: base }), @@ -112,12 +100,6 @@ test('project management service resolves a legacy path into one canonical selec const savedSelections: Array<{ projectId: string | null; projectPath: string }> = []; const service = createProjectManagementService({ catalog, - sessions: { - listHeaders: async () => [], - updateHeader: async () => { - throw new Error('No sessions expected'); - }, - }, chooseDirectory: async () => undefined, selection: { currentSelection: async () => ({ @@ -153,12 +135,6 @@ test('project management service persists an explicit no-project selection in ma const savedSelections: Array<{ projectId: string | null; projectPath: string }> = []; const service = createProjectManagementService({ catalog: createProjectCatalog(join(base, 'storage')), - sessions: { - listHeaders: async () => [], - updateHeader: async () => { - throw new Error('No sessions expected'); - }, - }, chooseDirectory: async () => undefined, selection: { currentSelection: async () => ({ @@ -208,12 +184,6 @@ test('archiving the current project resolves fallback or no-project inside main' }; const service = createProjectManagementService({ catalog, - sessions: { - listHeaders: async () => [], - updateHeader: async () => { - throw new Error('No sessions expected'); - }, - }, chooseDirectory: async () => undefined, selection: { currentSelection: async () => selection, @@ -251,128 +221,3 @@ test('archiving the current project resolves fallback or no-project inside main' await rm(base, { recursive: true, force: true }); } }); - -test('relinking merges a project that was accidentally added from its new path', async () => { - const base = await mkdtemp(join(tmpdir(), 'maka-project-service-merge-')); - const oldPath = join(base, 'old-location'); - const newPath = join(base, 'new-location'); - const secondPath = join(base, 'second-location'); - const storage = join(base, 'storage'); - await mkdir(oldPath); - let nextDirectory: string | undefined = oldPath; - let nextId = 0; - const catalog = createProjectCatalog(storage, { - now: () => 1_000, - createId: () => `project-${++nextId}`, - }); - const sessions = createSessionStore(storage); - let failUpdateNumber: number | undefined; - let updateCount = 0; - const service = createProjectManagementService({ - catalog, - sessions: { - listHeaders: () => sessions.listHeaders(), - updateHeader: async (sessionId, patch) => { - updateCount += 1; - if (updateCount === failUpdateNumber) { - throw new Error('injected session reassignment failure'); - } - return sessions.updateHeader(sessionId, patch); - }, - }, - chooseDirectory: async () => nextDirectory, - selection: { - currentSelection: async () => ({ - projectId: undefined, - path: nextDirectory ? await realpath(nextDirectory) : base, - }), - setSelection: () => {}, - }, - }); - - try { - const original = await service.add(); - assert.equal(original.ok, true); - if (!original.ok) throw new Error('Expected original project'); - await service.rename(original.project.id, 'Original name'); - await rename(oldPath, newPath); - - nextDirectory = newPath; - const duplicate = await service.add(); - assert.equal(duplicate.ok, true); - if (!duplicate.ok) throw new Error('Expected duplicate project'); - const oldSession = await sessions.create( - makeSessionInput(oldPath, original.project.id, 'Old history'), - ); - const newSession = await sessions.create( - makeSessionInput(newPath, duplicate.project.id, 'New history'), - ); - - failUpdateNumber = 2; - await assert.rejects( - () => service.relink(original.project.id), - /injected session reassignment failure/, - ); - assert.deepEqual( - (await catalog.list()).map((project) => project.id).sort(), - [original.project.id, duplicate.project.id].sort(), - ); - - failUpdateNumber = undefined; - updateCount = 0; - const merged = await service.relink(original.project.id); - - assert.equal(merged.ok, true); - if (!merged.ok) throw new Error('Expected merged project'); - assert.equal(merged.project.id, original.project.id); - assert.equal(merged.project.name, 'Original name'); - assert.equal(merged.project.preferredPath, await realpath(newPath)); - assert.deepEqual( - (await catalog.list()).map((project) => project.id), - [original.project.id], - ); - assert.equal( - (await sessions.readHeaderSnapshot(oldSession.id)).projectId, - original.project.id, - ); - assert.equal( - (await sessions.readHeaderSnapshot(oldSession.id)).cwd, - await realpath(newPath), - ); - assert.equal( - (await sessions.readHeaderSnapshot(newSession.id)).projectId, - original.project.id, - ); - - const lateAliasSession = await sessions.create( - makeSessionInput(newPath, duplicate.project.id, 'Late alias history'), - ); - await rename(newPath, secondPath); - nextDirectory = secondPath; - const relinkedAgain = await service.relink(original.project.id); - - assert.equal(relinkedAgain.ok, true); - const lateAliasHeader = await sessions.readHeaderSnapshot(lateAliasSession.id); - assert.equal(lateAliasHeader.projectId, original.project.id); - assert.equal( - lateAliasHeader.cwd, - await realpath(secondPath), - ); - } finally { - await sessions.close?.(); - await rm(base, { recursive: true, force: true }); - } -}); - -function makeSessionInput(cwd: string, projectId: string, name: string) { - return { - cwd, - projectId, - backend: 'fake' as const, - llmConnectionSlug: 'fake', - model: 'fake-model', - permissionMode: 'ask' as const, - name, - labels: [], - }; -} diff --git a/apps/desktop/src/main/__tests__/quote-companion-core.test.ts b/apps/desktop/src/main/__tests__/quote-companion-core.test.ts index 73ec29ddd2..9a1747eba8 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-core.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-core.test.ts @@ -17,6 +17,7 @@ import { applyCompanionInteractionEvent, cleanupCompanionCopy, createCompanionDismissalGuard, + dismissCompanionCopy, companionRunEventEffect, deriveCompanionComposerState, isCompanionTurnTerminal, @@ -58,6 +59,7 @@ interface FakeControl { function makeApi(control: FakeControl = {}) { const calls = { + lifecycle: [] as string[], removed: [] as string[], sent: [] as { id: string; @@ -95,6 +97,7 @@ function makeApi(control: FakeControl = {}) { return forked; }, cleanupSessionCopy: async (id) => { + calls.lifecycle.push(`cleanup:${id}`); calls.removed.push(id); if (control.cleanupThrows) throw new Error('cleanup failed'); }, @@ -102,6 +105,9 @@ function makeApi(control: FakeControl = {}) { calls.abandoned.push(id); if (control.abandonThrows) throw new Error('abandon acknowledgement lost'); }, + stop: async (id) => { + calls.lifecycle.push(`stop:${id}`); + }, send: async (id, cmd) => { calls.sent.push({ id, cmd }); if (control.sendThrows) throw new Error('send failed'); @@ -172,6 +178,19 @@ describe('companion dismissal guard', () => { assert.equal(replayedCleanup(), false); assert.equal(activeCleanup(), true); }); + + it('stops a live companion before dismissing its durable copy', async () => { + const { api, calls } = makeApi(); + + assert.equal( + await dismissCompanionCopy(api, 'source-session', 'panel-1', 'companion-session'), + true, + ); + assert.deepEqual(calls.lifecycle, [ + 'stop:companion-session', + 'cleanup:companion-session', + ]); + }); }); describe('deriveCompanionComposerState', () => { diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts index 28f1d7b7e8..3ffd2bdb7d 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts @@ -211,6 +211,35 @@ test('merges a configuration patch into each fresh CAS projection', async () => ); }); +test('retries a Session update through transient revision churn', async () => { + const responses: unknown[] = []; + for (let revision = 10; revision < 14; revision += 1) { + responses.push( + { kind: 'session', session: session('session-1', revision) }, + { + kind: 'revision_conflict', + expectedRevision: revision, + actualRevision: revision + 1, + }, + ); + } + responses.push( + { kind: 'session', session: session('session-1', 14) }, + { + kind: 'committed', + session: session('session-1', 15, { collaborationMode: 'plan' }), + }, + ); + const { client } = clientWithResponses(responses); + + const updated = await client.updateSessionConfiguration('session-1', { + collaborationMode: 'plan', + }); + + assert.equal(updated.revision, 15); + assert.equal(updated.collaborationMode, 'plan'); +}); + test('rebuilds a Runtime Policy mutation from each fresh CAS projection', async () => { const initial = createDefaultRuntimePolicy(); const concurrent = { diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts index 0570c61e5a..f32b76737b 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts @@ -315,6 +315,7 @@ test('drives the renderer Session execution facade through real UDS framing', as { client, observer, + observations: observer, attachmentApprovals: createAttachmentApprovalRegistry(), emitSessionsChanged() {}, stat: async () => ({ size: 0 }), diff --git a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts index 6f3bf10d9a..a05e6f9e54 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts @@ -7,6 +7,107 @@ import { registerRuntimeHostConnectionsIpc, } from '../runtime-host-connections-ipc-main.js'; +test('retries connection delete after a stale revision instead of failing permanently', async () => { + const handlers = new Map unknown>(); + let revision = 1; + let removals = 0; + registerRuntimeHostConnectionsIpc({ + ipcMain: { + handle: (channel, handler) => { + handlers.set(channel, handler as (...args: unknown[]) => unknown); + }, + }, + client: { + loadConnectionCatalog: async (): Promise => ({ + revision, + defaultTarget: null, + connections: [ + { + connectionId: 'connection-1', + revision, + slug: 'openrouter', + name: 'OpenRouter', + providerType: 'openai-compatible', + baseUrl: 'https://openrouter.ai/api/v1', + enabled: true, + enabledModelIds: ['model-1'], + models: [{ id: 'model-1' }], + }, + ], + }), + removeConnection: async (expected: { connectionId: string; revision: number }) => { + removals += 1; + if (expected.revision === 1) { + revision = 2; + return { kind: 'connection_stale' }; + } + assert.equal(expected.revision, 2); + return { kind: 'committed', catalogRevision: 3 }; + }, + } as never, + emitConnectionListChanged() {}, + }); + + await handlers.get('connections:delete')?.({}, 'openrouter'); + assert.equal(removals, 2); +}); + +test('treats a missing connection as a successful delete without calling remove', async () => { + const handlers = new Map unknown>(); + let removals = 0; + let listChanged = 0; + registerRuntimeHostConnectionsIpc({ + ipcMain: { + handle: (channel, handler) => { + handlers.set(channel, handler as (...args: unknown[]) => unknown); + }, + }, + client: { + loadConnectionCatalog: async (): Promise => ({ + revision: 1, + defaultTarget: null, + connections: [], + }), + removeConnection: async () => { + removals += 1; + return { kind: 'committed', catalogRevision: 2 }; + }, + } as never, + emitConnectionListChanged() { + listChanged += 1; + }, + }); + + await handlers.get('connections:delete')?.({}, 'already-gone'); + assert.equal(removals, 0); + assert.equal(listChanged, 1); +}); + +test('rejects invalid connection slug input instead of treating it as already deleted', async () => { + const handlers = new Map unknown>(); + registerRuntimeHostConnectionsIpc({ + ipcMain: { + handle: (channel, handler) => { + handlers.set(channel, handler as (...args: unknown[]) => unknown); + }, + }, + client: { + loadConnectionCatalog: async (): Promise => ({ + revision: 1, + defaultTarget: null, + connections: [], + }), + removeConnection: async () => ({ kind: 'committed', catalogRevision: 2 }), + } as never, + emitConnectionListChanged() {}, + }); + + await assert.rejects( + async () => handlers.get('connections:delete')?.({}, 42), + /Invalid connection slug|connection slug/i, + ); +}); + test('reports an existing but unconfigured credential as missing', async () => { const handlers = new Map unknown>(); registerRuntimeHostConnectionsIpc({ @@ -35,6 +136,51 @@ test('reports an existing but unconfigured credential as missing', async () => { ); }); +test('keeps saved custom header values out of the renderer and preserves them by name', async () => { + const handlers = new Map unknown>(); + let replacedHeaders: unknown; + registerRuntimeHostConnectionsIpc({ + ipcMain: { + handle: (channel, handler) => { + handlers.set(channel, handler as (...args: unknown[]) => unknown); + }, + }, + client: { + loadConnectionCatalog: async () => catalog(), + getConnectionRequestHeaders: async () => ({ + kind: 'found', + names: ['HTTP-Referer'], + }), + replaceConnectionRequestHeaders: async (_connectionId: string, headers: unknown) => { + replacedHeaders = headers; + return { kind: 'committed', names: ['HTTP-Referer', 'X-Title'] }; + }, + } as never, + emitConnectionListChanged() {}, + }); + + assert.deepEqual( + await handlers.get('connections:getRequestHeaders')?.({}, 'openrouter'), + { names: ['HTTP-Referer'] }, + ); + assert.equal( + JSON.stringify(await handlers.get('connections:getRequestHeaders')?.({}, 'openrouter')).includes('private.example'), + false, + ); + + assert.deepEqual( + await handlers.get('connections:setRequestHeaders')?.({}, 'openrouter', [ + { name: 'HTTP-Referer' }, + { name: 'X-Title', value: 'Maka' }, + ]), + { names: ['HTTP-Referer', 'X-Title'] }, + ); + assert.deepEqual(replacedHeaders, [ + { name: 'HTTP-Referer' }, + { name: 'X-Title', value: 'Maka' }, + ]); +}); + test('preserves the provider default inventory beside the recommended model', async () => { const handlers = new Map unknown>(); let createdModels: readonly string[] = []; diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index d42cb84bc7..edeca698f1 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts @@ -63,9 +63,10 @@ test('tears down the whole candidate when the Host connection closes', async () const candidate = await createDesktopRuntimeHostCandidate(host.connection, deps(ipc)); host.disconnect(); + await Promise.resolve(); + assert.equal(ipc.size, 0); await candidate.closed; - assert.equal(ipc.size, 0); assert.equal(host.closeCalls, 1); }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-owner.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-owner.test.ts index b16ae2df5b..f4f3a405fe 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-owner.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-owner.test.ts @@ -9,18 +9,25 @@ import type { import { startRuntimeHostDesktopOwner } from '../runtime-host-desktop-owner.js'; test('replaces a disconnected generation without falling back to embedded Runtime', { timeout: 10_000 }, async () => { - const first = candidateHarness(); + const first = candidateHarness({ delayDisconnect: true }); const second = candidateHarness(); const queue = [ready(first.candidate), ready(second.candidate)]; let starts = 0; let resolveSecondStart!: () => void; + let releaseSecond!: () => void; const secondStarted = new Promise((resolve) => { resolveSecondStart = resolve; }); + const secondReleased = new Promise((resolve) => { + releaseSecond = resolve; + }); const owner = await startRuntimeHostDesktopOwner({} as DesktopRuntimeHostCandidateStartInput, { startCandidate: async () => { starts += 1; - if (starts === 2) resolveSecondStart(); + if (starts === 2) { + resolveSecondStart(); + await secondReleased; + } const result = queue.shift(); assert.ok(result); return result; @@ -28,10 +35,18 @@ test('replaces a disconnected generation without falling back to embedded Runtim }); first.disconnect(); - await secondStarted; + const botMessage = owner.handleBotIncomingMessage({ text: 'hello' } as BotIncomingMessage); + const stop = owner.stopSession('session-1'); await new Promise((resolve) => setImmediate(resolve)); - await owner.handleBotIncomingMessage({ text: 'hello' } as BotIncomingMessage); - await owner.stopSession('session-1'); + assert.equal(starts, 1); + assert.equal(first.botMessages, 0); + assert.deepEqual(first.stoppedSessions, []); + first.finishDisconnect(); + await secondStarted; + assert.equal(second.botMessages, 0); + assert.deepEqual(second.stoppedSessions, []); + releaseSecond(); + await Promise.all([botMessage, stop]); assert.equal(first.botMessages, 0); assert.equal(second.botMessages, 1); @@ -40,33 +55,75 @@ test('replaces a disconnected generation without falling back to embedded Runtim assert.equal(second.closeCalls, 1); }); -test('reports exhausted reconnect attempts as fatal and never starts a fallback', { timeout: 10_000 }, async () => { +test('keeps reconnecting with bounded backoff until the Desktop adapter is restored', async () => { const first = candidateHarness(); + const replacement = candidateHarness(); let starts = 0; - let reportFatal!: (error: Error) => void; - const fatalReported = new Promise((resolve) => { - reportFatal = resolve; + const delays: number[] = []; + let resolveRestored!: () => void; + const restored = new Promise((resolve) => { + resolveRestored = resolve; }); const owner = await startRuntimeHostDesktopOwner({} as DesktopRuntimeHostCandidateStartInput, { startCandidate: async (): Promise => { starts += 1; - return starts === 1 - ? ready(first.candidate) - : { kind: 'failed', reason: 'host_unresponsive' }; + if (starts === 1) return ready(first.candidate); + if (starts < 4) return { kind: 'failed', reason: 'host_unresponsive' }; + resolveRestored(); + return ready(replacement.candidate); }, - onFatalError: (error) => { - reportFatal(error); + reconnectBackoff: { + minMs: 100, + maxMs: 150, + random: () => 0.5, + wait: async (delayMs) => { + delays.push(delayMs); + }, }, }); first.disconnect(); - const fatal = await fatalReported; + await restored; + await new Promise((resolve) => setImmediate(resolve)); assert.equal(starts, 4); - assert.match(fatal.message, /host_unresponsive/); + assert.deepEqual(delays, [100, 150]); + await owner.handleBotIncomingMessage({ text: 'restored' } as BotIncomingMessage); + assert.equal(replacement.botMessages, 1); + await owner.close(); +}); + +test('stops reconnecting when the replacement Host is incompatible', async () => { + const first = candidateHarness(); + let reportFatal!: (error: Error) => void; + const fatalReported = new Promise((resolve) => { + reportFatal = resolve; + }); + const owner = await startRuntimeHostDesktopOwner({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => + first.closeCalls === 0 + ? ready(first.candidate) + : { + kind: 'incompatible', + handshake: { + kind: 'incompatible', + hostEpoch: 'replacement-host', + protocolMin: 1, + protocolMax: 1, + compatibilityEpoch: 1, + state: 'ready', + replacement: 'wait_for_idle_exit', + }, + }, + onFatalError: reportFatal, + }); + + await first.candidate.close(); + const fatal = await fatalReported; + assert.match(fatal.message, /incompatible/); await owner.close(); }); -function candidateHarness() { +function candidateHarness(options: { delayDisconnect?: boolean } = {}) { let resolveClosed: (() => void) | undefined; const closed = new Promise((resolve) => { resolveClosed = resolve; @@ -74,9 +131,14 @@ function candidateHarness() { let closeCalls = 0; let botMessages = 0; const stoppedSessions: string[] = []; + let lifecycleState: 'ready' | 'unavailable' = 'ready'; const candidate = { closed, - client: {}, + client: { + get lifecycleState() { + return lifecycleState; + }, + }, botIncoming: { async handleBotIncomingMessage() { botMessages += 1; @@ -84,6 +146,7 @@ function candidateHarness() { }, async close() { closeCalls += 1; + lifecycleState = 'unavailable'; resolveClosed?.(); }, async stopSession(sessionId: string) { @@ -92,7 +155,11 @@ function candidateHarness() { } as unknown as DesktopRuntimeHostCandidate; return { candidate, - disconnect: () => resolveClosed?.(), + disconnect: () => { + lifecycleState = 'unavailable'; + if (!options.delayDisconnect) resolveClosed?.(); + }, + finishDisconnect: () => resolveClosed?.(), get closeCalls() { return closeCalls; }, diff --git a/apps/desktop/src/main/__tests__/runtime-host-github-copilot-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-github-copilot-ipc-main.test.ts index 79031f6866..c92f27a6a6 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-github-copilot-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-github-copilot-ipc-main.test.ts @@ -58,11 +58,12 @@ test('imports a local GitHub credential through the shared Host account path', a connectionId: current.connectionId, revision: current.revision, }); - const { relayModelProfiles, ...restChanges } = changes; + const { relayModelProfiles, requestBodyOverlay, ...restChanges } = changes; const updated: ConnectionCatalogEntry = { ...current, ...restChanges, ...(relayModelProfiles === null ? {} : { relayModelProfiles }), + ...(requestBodyOverlay === null ? {} : { requestBodyOverlay }), revision: current.revision + 1, }; catalog = { diff --git a/apps/desktop/src/main/__tests__/runtime-host-project-catalog.test.ts b/apps/desktop/src/main/__tests__/runtime-host-project-catalog.test.ts new file mode 100644 index 0000000000..1127f0fac7 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-project-catalog.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { RuntimeHostOperationError } from '@maka/runtime-host/client'; +import { isProjectPathMismatchError } from '@maka/storage'; +import { createRuntimeHostProjectCatalog } from '../runtime-host-project-catalog.js'; + +test('Host touch conflicts retain the Project path mismatch contract', async () => { + const catalog = createRuntimeHostProjectCatalog(() => + ({ + touchProject: async () => { + throw new RuntimeHostOperationError( + 'project.catalog.mutate', + 'operation_conflict', + 'Path does not belong to project project-1', + ); + }, + }) as never, + ); + + await assert.rejects( + () => catalog.touch('project-1', '/workspace/other'), + (error: unknown) => isProjectPathMismatchError(error), + ); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-project-session-catalog.test.ts b/apps/desktop/src/main/__tests__/runtime-host-project-session-catalog.test.ts deleted file mode 100644 index af0d014838..0000000000 --- a/apps/desktop/src/main/__tests__/runtime-host-project-session-catalog.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, test } from "node:test"; -import { createRuntimeHostProjectSessionCatalog } from "../runtime-host-project-session-catalog.js"; - -describe("Runtime Host project Session catalog", () => { - test("projects Host Session headers without taking local authority", async () => { - const catalog = createRuntimeHostProjectSessionCatalog({ - listSessions: async () => [ - { id: "session-1", cwd: "/workspace/one", projectId: "project-1" }, - ], - relocateSessionCwd: async () => assert.fail("unexpected relocation"), - updateSessionMetadata: async () => assert.fail("unexpected metadata update"), - } as never); - - assert.deepEqual(await catalog.listHeaders(), [ - { id: "session-1", cwd: "/workspace/one", projectId: "project-1" }, - ]); - }); - - test("relocates cwd and project association in one Host mutation", async () => { - const calls: unknown[][] = []; - const catalog = createRuntimeHostProjectSessionCatalog({ - listSessions: async () => [], - relocateSessionCwd: async (...args: unknown[]) => { - calls.push(args); - return {} as never; - }, - updateSessionMetadata: async () => assert.fail("must not split the mutation"), - } as never); - - await catalog.updateHeader("session-1", { - cwd: "/workspace/next", - projectId: "project-2", - }); - - assert.deepEqual(calls, [ - ["session-1", "/workspace/next", "project-2"], - ]); - }); - - test("updates a project association without relocating cwd", async () => { - const calls: unknown[][] = []; - const catalog = createRuntimeHostProjectSessionCatalog({ - listSessions: async () => [], - relocateSessionCwd: async () => assert.fail("unexpected relocation"), - updateSessionMetadata: async (...args: unknown[]) => { - calls.push(args); - return {} as never; - }, - } as never); - - await catalog.updateHeader("session-1", { projectId: "project-2" }); - - assert.deepEqual(calls, [ - ["session-1", { projectId: "project-2" }], - ]); - }); -}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-reconnecting-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-reconnecting-ipc-main.test.ts new file mode 100644 index 0000000000..1c81beafb9 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-reconnecting-ipc-main.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { IpcMain } from "electron"; +import { + RuntimeHostOperationError, + RuntimeHostRequestInterruptedError, +} from "@maka/runtime-host/client"; +import { RuntimeHostReconnectingIpcMain } from "../runtime-host-reconnecting-ipc-main.js"; + +test("holds an invocation across a Runtime Host candidate replacement", async () => { + const ipc = ipcHarness(); + const router = new RuntimeHostReconnectingIpcMain(ipc); + router.handleReconnectableRead("sessions:list", async () => "first"); + assert.equal(await ipc.invoke("sessions:list"), "first"); + + router.removeHandler("sessions:list"); + const waiting = ipc.invoke("sessions:list"); + let settled = false; + void waiting.finally(() => { + settled = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(settled, false); + + router.handleReconnectableRead("sessions:list", async () => "replacement"); + assert.equal(await waiting, "replacement"); + + router.removeHandler("sessions:list"); + const failed = deferred(); + router.handleReconnectableRead("sessions:list", async () => { + await failed.promise; + throw new RuntimeHostOperationError( + "session.catalog.query", + "host_draining", + "Runtime Host is draining", + ); + }); + const draining = ipc.invoke("sessions:list"); + router.removeHandler("sessions:list"); + router.handleReconnectableRead("sessions:list", async () => "after-drain"); + failed.resolve(); + assert.equal(await draining, "after-drain"); + + router.removeHandler("sessions:list"); + router.handleReconnectableRead("sessions:list", async () => { + throw new RuntimeHostRequestInterruptedError( + "session.catalog.query", + "query", + "dispatched", + "timeout", + ); + }); + await assert.rejects(() => ipc.invoke("sessions:list"), /was interrupted/); + + router.close(); + assert.equal(ipc.size, 0); +}); + +test("does not replay a command IPC handler after a draining rejection", async () => { + const ipc = ipcHarness(); + const router = new RuntimeHostReconnectingIpcMain(ipc); + let calls = 0; + router.handle("sessions:send", async () => { + calls += 1; + throw new RuntimeHostOperationError( + "turn.start", + "host_draining", + "Runtime Host is draining", + ); + }); + + await assert.rejects(() => ipc.invoke("sessions:send"), /draining/); + assert.equal(calls, 1); + router.close(); +}); + +type IpcHandler = Parameters[1]; + +function ipcHarness() { + const handlers = new Map(); + return { + handle(channel: string, handler: IpcHandler): void { + if (handlers.has(channel)) throw new Error(`duplicate handler: ${channel}`); + handlers.set(channel, handler); + }, + removeHandler(channel: string): void { + handlers.delete(channel); + }, + async invoke(channel: string): Promise { + const handler = handlers.get(channel); + assert.ok(handler); + return handler({} as never); + }, + get size(): number { + return handlers.size; + }, + }; +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve }; +} diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index 3de9764510..69ed445c46 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -742,15 +742,22 @@ function ipcHarness() { } function registerExecutionIpc( - deps: Omit & + deps: Omit< + RuntimeHostSessionExecutionIpcDeps, + 'sessionCopyCleanup' | 'onBackgroundError' | 'observations' + > & Partial< - Pick + Pick< + RuntimeHostSessionExecutionIpcDeps, + 'sessionCopyCleanup' | 'onBackgroundError' | 'observations' + > >, ipcMain: Pick, ): (sessionId: string) => Promise { return registerRuntimeHostSessionExecutionIpc( { ...deps, + observations: deps.observations ?? deps.observer, sessionCopyCleanup: deps.sessionCopyCleanup ?? unusedSessionCopyCleanup(), onBackgroundError: deps.onBackgroundError ?? (() => undefined), }, diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts index 75f1604f41..273de05838 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts @@ -6,7 +6,9 @@ import type { SessionContinuitySnapshot, SubscriptionFrame, } from "@maka/runtime-host/protocol"; +import { RuntimeHostSubscriptionError } from "@maka/runtime-host/client"; import type { DesktopRuntimeHostSession } from "../runtime-host-client.js"; +import { RuntimeHostSessionObservationRegistry } from "../runtime-host-session-observation-registry.js"; import { RuntimeHostSessionObserver, type RuntimeHostSessionObserverTarget, @@ -103,6 +105,126 @@ test("joins an active Turn without losing or replaying assistant text", async () assert.equal(closeCount, 1); }); +test("restores renderer observation after the Host connection is replaced", async () => { + const firstEvents = new AsyncFrameQueue(); + const secondEvents = new AsyncFrameQueue(); + const firstObserver = new RuntimeHostSessionObserver({ + client: { + openSession: async () => ({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: firstEvents, + async close() { + firstEvents.end(); + }, + }), + }, + emitSessionsChanged() {}, + }); + const secondObserver = new RuntimeHostSessionObserver({ + client: { + openSession: async () => ({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([ + { + type: "assistant", + id: "message-1", + turnId: "turn-1", + ts: 10, + text: "Hello", + modelId: "test-model", + }, + ]), + events: secondEvents, + async close() { + secondEvents.end(); + }, + }), + }, + emitSessionsChanged() {}, + now: () => 50, + }); + const observations = new RuntimeHostSessionObservationRegistry(); + const target = eventTarget(10); + + assert.deepEqual(await observations.attach(firstObserver), []); + await observations.observe("session-1", "observer-1", target); + observations.detach(firstObserver); + await firstObserver.close(); + assert.deepEqual(await observations.attach(secondObserver), ["session-1"]); + await waitFor(() => target.events.length === 1); + + secondEvents.push(deltaFrame(1, 5, " again")); + secondEvents.push({ + kind: "subscription.session_projection", + hostEpoch: "host-2", + subscriptionId: "subscription-2", + sequence: 2, + snapshot: continuitySnapshot({ + rootTurn: { + sessionId: "session-1", + turnId: "turn-1", + runId: "run-1", + status: "completed", + terminalEventId: "terminal-1", + }, + }), + }); + await waitFor(() => + target.events.some((event) => event.type === "complete"), + ); + + assert.deepEqual( + target.events.map((event) => [ + event.type, + "text" in event ? event.text : undefined, + ]), + [ + ["text_delta", "Hello"], + ["text_delta", " again"], + ["text_complete", "Hello again"], + ["complete", undefined], + ], + ); + + await observations.close(); + await secondObserver.close(); +}); + +test("does not publish a terminal error while an owner-managed connection is replaced", async () => { + let rejectFrame!: (error: Error) => void; + let closeCount = 0; + const events: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: () => + new Promise>((_resolve, reject) => { + rejectFrame = reject; + }), + }), + }; + const observer = new RuntimeHostSessionObserver({ + client: { + openSession: async () => ({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events, + async close() { + closeCount += 1; + }, + }), + }, + emitSessionsChanged() {}, + recoverConnectionClosed: true, + }); + const target = eventTarget(11); + await observer.observe("session-1", "observer-1", target); + + rejectFrame(new RuntimeHostSubscriptionError("connection_closed", "Host restarted")); + await waitFor(() => closeCount === 1); + assert.equal(target.events.some((event) => event.type === "error"), false); + await observer.close(); +}); + test("keeps a native Turn watched without a renderer and releases it at terminal", async () => { const events = new AsyncFrameQueue(); const finishedTurns: Array<[string, "completed" | "abandoned"]> = []; diff --git a/apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts index 750fc41390..52c3e907d7 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts @@ -36,6 +36,7 @@ test('keeps a resolved Skill mutation on one project root', async () => { rootReads += 1; return rootReads === 1 ? '/tmp/project-a' : '/tmp/project-b'; }, + getDefaultPermissionMode: async () => 'ask', openPath: async () => '', }); @@ -64,6 +65,7 @@ test('requests the Host-owned invocable projection for existing and new Sessions workspaceRoot: '/tmp/maka-runtime-host-skills-workspace', mainWindowController: {} as never, getCurrentProjectRoot: async () => '/tmp/project-a', + getDefaultPermissionMode: async () => 'bypass', openPath: async () => '', }); @@ -79,6 +81,7 @@ test('requests the Host-owned invocable projection for existing and new Sessions kind: 'new_session', context: { projectRoot: '/tmp/project-a' }, collaborationMode: 'plan', + permissionMode: 'bypass', }, ]); }); diff --git a/apps/desktop/src/main/__tests__/stale-sessions.test.ts b/apps/desktop/src/main/__tests__/stale-sessions.test.ts index ec94bb8c89..6e6e07a189 100644 --- a/apps/desktop/src/main/__tests__/stale-sessions.test.ts +++ b/apps/desktop/src/main/__tests__/stale-sessions.test.ts @@ -10,7 +10,6 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { deriveStaleSessionIds } from '../../renderer/stale-sessions.js'; -import { readRendererContractCss } from './contract-css-helpers.js'; function session(partial: { id: string; backend?: string; slug?: string }): { id: string; @@ -108,54 +107,9 @@ describe('deriveStaleSessionIds', () => { }); }); -describe('stale session CSS contract (@kenji review gate)', () => { - // @kenji's PR108g review: "active stale row 不要因为 active state 取消所有 - // warning 信号". Active state can restore opacity, but the pill must NOT - // disappear, and no rule may set `display: none` / `visibility: hidden` - // on the pill regardless of selector chain. This grep-style assertion - // catches that contract from a CSS regression — cheap second layer on - // top of the component-level invariant that `stale` prop is derived from - // `staleSessionIds.has(session.id)` (independent of active state). - - it('inactive stale row dims, active stale row restores opacity', async () => { - const css = await readRendererContractCss(); - // SideNav session rows: stale dims only this row's item (child combinators); - // current page on that item restores opacity. - assert.match( - css, - /\.maka-session-row\[data-stale="true"\]\s*>\s*div\s*>\s*\.astryx-side-nav-item\s*\{[\s\S]*?opacity:\s*var\(--opacity-muted\)/, - 'expected stale SideNav session rows to dim only the direct SideNavItem', - ); - assert.match( - css, - /\.maka-session-row\[data-stale="true"\]\s*>\s*div\s*>\s*\.astryx-side-nav-item\[aria-current="page"\]/, - 'expected the active stale leaf SideNav row to restore full opacity', - ); - assert.match( - css, - /\.maka-session-row\[data-stale="true"\]\s*>\s*div\s*>\s*\.astryx-side-nav-item:has\(\s*>\s*\[aria-current="page"\]\s*\)/, - 'expected the active stale collapsible SideNav row to restore full opacity', - ); - // A bare descendant selector would mute healthy subagents under a stale parent. - assert.doesNotMatch( - css, - /\.maka-session-row\[data-stale="true"\](?![^\n{]*\s*>\s*div\s*>)\s+\.astryx-side-nav-item/, - 'stale opacity must not use an unbounded descendant selector', - ); - }); - - it('stale pill is never hidden by any CSS rule (active state preserves warning signal)', async () => { - const css = await readRendererContractCss(); - // Any rule that targets `.maka-list-row-stale-pill` AND applies - // display: none / visibility: hidden / opacity: 0 is a regression on - // the @kenji gate. Scan the CSS body for those patterns. - const PILL_HIDE = /\.maka-list-row-stale-pill[^{]*\{[^}]*?(?:display:\s*none|visibility:\s*hidden|opacity:\s*0(?![\.\d]))/; - assert.doesNotMatch( - css, - PILL_HIDE, - 'no CSS rule may hide `.maka-list-row-stale-pill` (active stale row must still show pill)', - ); - }); +describe('stale session list wiring', () => { + // Pill visibility on active rows is a product invariant; assert it through + // the panel render, not by grepping Astryx SideNav CSS selectors. it('staleSessionIds wires data-stale and pill only on marked sessions', async () => { const { makeSessionSummary, renderSessionListPanel } = await import( diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index b0d51bd320..519694145a 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -80,9 +80,8 @@ describe('single live-turn handoff', () => { // The render-layer fold keeps answer text as the grouping boundary, but // adds no second Processing disclosure around the native reasoning and - // Astryx tool-call disclosures. + // tool-call disclosures. Order is product-facing; vendor class names are not. assert.equal((markup.match(/data-processing="block"/g) ?? []).length, 0); - assert.equal((markup.match(/astryx-chat-tool-calls/g) ?? []).length, 1); assert.ok(markup.indexOf('深度思考') >= 0); assert.ok(markup.indexOf('深度思考') < markup.indexOf('最终答案')); assert.ok(markup.indexOf('最终答案') < markup.indexOf('Bash')); diff --git a/apps/desktop/src/main/__tests__/task-readiness-notice.test.ts b/apps/desktop/src/main/__tests__/task-readiness-notice.test.ts new file mode 100644 index 0000000000..18338d2204 --- /dev/null +++ b/apps/desktop/src/main/__tests__/task-readiness-notice.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { TaskSubmissionReadinessSnapshot } from '@maka/core'; +import { + deriveTaskReadinessNotice, + isTaskSubmissionHardBlocked, + resolveTaskReadinessModelTarget, +} from '../../renderer/task-readiness-notice.js'; + +test('an unlocked stale session checks the send projection rebind target', () => { + assert.deepEqual( + resolveTaskReadinessModelTarget( + { llmConnectionSlug: 'stale', model: 'removed-model' }, + { kind: 'rebind', connectionSlug: 'healthy', model: 'ready-model' }, + undefined, + ), + { connectionSlug: 'healthy', model: 'ready-model' }, + ); +}); + +test('confirmed repair and unavailable states block, while loading uncertainty does not', () => { + assert.equal(isTaskSubmissionHardBlocked(snapshot('repair_required', 'model_target')), true); + assert.equal(isTaskSubmissionHardBlocked(snapshot('unavailable', 'runtime')), true); + assert.equal( + isTaskSubmissionHardBlocked(snapshot('repair_required', 'model_target'), { + ignoreModelTarget: true, + }), + false, + ); + assert.equal(isTaskSubmissionHardBlocked(snapshot('unknown', 'runtime')), false); + assert.equal(isTaskSubmissionHardBlocked(undefined), false); +}); + +test('runtime and workspace blockers produce actionable localized notices', () => { + const runtime = deriveTaskReadinessNotice(snapshot('unavailable', 'runtime'), 'en'); + assert.equal(runtime?.action, 'retry'); + assert.match(runtime?.title ?? '', /runtime/i); + + const workspace = deriveTaskReadinessNotice(snapshot('unavailable', 'workspace'), 'zh'); + assert.equal(workspace?.action, 'workspace_picker'); + assert.match(workspace?.title ?? '', /工作区/); +}); + +test('model blockers stay owned by existing connection recovery surfaces', () => { + assert.equal( + deriveTaskReadinessNotice(snapshot('repair_required', 'model_target'), 'zh'), + undefined, + ); +}); + +function snapshot( + state: TaskSubmissionReadinessSnapshot['state'], + id: 'runtime' | 'model_target' | 'workspace', +): TaskSubmissionReadinessSnapshot { + const dimension = { + id, + state, + authority: + id === 'runtime' + ? ('runtime_host' as const) + : id === 'workspace' + ? ('workspace_execution' as const) + : ('connection_readiness' as const), + checkedAt: 1, + ...(id === 'workspace' ? { repairTarget: { kind: 'workspace_picker' as const } } : {}), + }; + return { + checkedAt: 1, + state, + dimensions: [dimension], + blockers: state === 'ready' ? [] : [dimension], + }; +} diff --git a/apps/desktop/src/main/config-transfer-service.ts b/apps/desktop/src/main/config-transfer-service.ts index 0713b424b0..45fedc52fc 100644 --- a/apps/desktop/src/main/config-transfer-service.ts +++ b/apps/desktop/src/main/config-transfer-service.ts @@ -20,7 +20,7 @@ import { stripSettingsSecretsForExport } from './settings-ipc-helpers.js'; * wrappers that supply the real stores + file dialogs. * * The credential category (opt-in, plaintext) is gathered by walking the - * connection list and reading each slug's `api_key` / `oauth_token` — the + * connection list and reading each slug's connection credentials — the * credential store exposes no bulk-list, so enumeration is the only path. */ @@ -30,10 +30,15 @@ export interface ExportedCredential { value: string; } -const CONNECTION_CREDENTIAL_KINDS: readonly CredentialKind[] = ['api_key', 'oauth_token']; +const CONNECTION_CREDENTIAL_KINDS: readonly CredentialKind[] = [ + 'api_key', + 'oauth_token', + 'request_headers', +]; const VALID_CREDENTIAL_KINDS: ReadonlySet = new Set([ 'api_key', 'oauth_token', + 'request_headers', 'bot_token', 'app_secret', 'proxy_password', diff --git a/apps/desktop/src/main/connections-ipc-validation.ts b/apps/desktop/src/main/connections-ipc-validation.ts index 41666a47cf..b246942b48 100644 --- a/apps/desktop/src/main/connections-ipc-validation.ts +++ b/apps/desktop/src/main/connections-ipc-validation.ts @@ -1,5 +1,7 @@ import { normalizeConnectionBaseUrl, + normalizeOptionalRequestBodyOverlay, + normalizeRequestHeaders, type CreateConnectionInput, type UpdateConnectionInput, } from '@maka/core'; @@ -56,11 +58,19 @@ export function normalizeCreateConnectionInputForIpc(value: unknown): CreateConn input.relayModelProfiles === undefined ? undefined : normalizeRelayModelProfiles(input.relayModelProfiles); + const requestHeaders = + input.requestHeaders === undefined ? undefined : normalizeRequestHeaders(input.requestHeaders); + const requestBodyOverlay = + input.requestBodyOverlay === undefined + ? undefined + : normalizeOptionalRequestBodyOverlay(input.requestBodyOverlay); const normalized = { ...input, slug, ...(apiKey === undefined ? {} : { apiKey }), ...(relayModelProfiles === undefined ? {} : { relayModelProfiles }), + ...(requestHeaders === undefined ? {} : { requestHeaders }), + ...(requestBodyOverlay === undefined ? {} : { requestBodyOverlay }), } as CreateConnectionInput; return normalizeConnectionBaseUrlForIpc(normalized); } @@ -68,12 +78,16 @@ export function normalizeCreateConnectionInputForIpc(value: unknown): CreateConn export function normalizeConnectionPatchSecretsForIpc(value: unknown): UpdateConnectionInput { if (typeof value !== 'object' || value === null) throw new Error('Invalid Connection update'); const patch = value as UpdateConnectionInput; - if (!Object.prototype.hasOwnProperty.call(patch, 'apiKey') || patch.apiKey === undefined) { - return patch; - } - return { + const normalized = { ...patch, - apiKey: normalizeConnectionApiKeyForIpc(patch.apiKey, 'apiKey'), + ...(Object.prototype.hasOwnProperty.call(patch, 'apiKey') && patch.apiKey !== undefined + ? { apiKey: normalizeConnectionApiKeyForIpc(patch.apiKey, 'apiKey') } + : {}), + }; + if (patch.requestBodyOverlay === undefined || patch.requestBodyOverlay === null) return normalized; + return { + ...normalized, + requestBodyOverlay: normalizeOptionalRequestBodyOverlay(patch.requestBodyOverlay) ?? null, }; } diff --git a/apps/desktop/src/main/git-review-main.ts b/apps/desktop/src/main/git-review-main.ts index e9be6c137f..f7fe97620c 100644 --- a/apps/desktop/src/main/git-review-main.ts +++ b/apps/desktop/src/main/git-review-main.ts @@ -56,10 +56,16 @@ export async function readGitReview( ? requestedBaseBranch ?? (await resolveBaseBranch(repositoryRoot, currentBranch, runGit)) : null; + const branchComparison = + source === 'branch' && baseBranch + ? cleanLine( + await runGit(repositoryRoot, ['merge-base', baseBranch, 'HEAD']), + ) + : null; const tracked = await readTrackedChanges({ repositoryRoot, source, - baseBranch, + branchComparison, hasHead, runGit, }); @@ -197,7 +203,7 @@ export async function mutateGitReview(input: { async function readTrackedChanges(input: { repositoryRoot: string; source: GitReviewSource; - baseBranch: string | null; + branchComparison: string | null; hasHead: boolean; runGit: GitReviewCommandRunner; }): Promise<{ files: GitReviewFile[]; diffChars: number; truncated: boolean }> { @@ -206,8 +212,8 @@ async function readTrackedChanges(input: { ? [['--cached']] : input.source === 'unstaged' ? [[]] - : input.baseBranch - ? [[input.baseBranch]] + : input.branchComparison + ? [[input.branchComparison]] : [ input.hasHead ? ['--cached'] : ['--cached', '--root'], [], diff --git a/apps/desktop/src/main/ipc-reconnect-policy.ts b/apps/desktop/src/main/ipc-reconnect-policy.ts new file mode 100644 index 0000000000..7f0fd91369 --- /dev/null +++ b/apps/desktop/src/main/ipc-reconnect-policy.ts @@ -0,0 +1,19 @@ +import type { IpcMain } from "electron"; + +export type IpcHandler = Parameters[1]; + +export interface ReconnectableReadIpcMain extends Pick { + handleReconnectableRead?(channel: string, listener: IpcHandler): void; +} + +export function handleReconnectableRead( + ipcMain: ReconnectableReadIpcMain, + channel: string, + listener: IpcHandler, +): void { + if (ipcMain.handleReconnectableRead) { + ipcMain.handleReconnectableRead(channel, listener); + } else { + ipcMain.handle(channel, listener); + } +} diff --git a/apps/desktop/src/main/onboarding-ipc-main.ts b/apps/desktop/src/main/onboarding-ipc-main.ts index e5f80bda42..c0ddb6d005 100644 --- a/apps/desktop/src/main/onboarding-ipc-main.ts +++ b/apps/desktop/src/main/onboarding-ipc-main.ts @@ -1,9 +1,13 @@ import { ipcMain } from 'electron'; import type { createOnboardingService } from './onboarding-service.js'; +import { + handleReconnectableRead, + type ReconnectableReadIpcMain, +} from './ipc-reconnect-policy.js'; export interface OnboardingIpcDeps { onboardingService: ReturnType; - ipcMain?: Pick; + ipcMain?: ReconnectableReadIpcMain; } export function registerOnboardingIpc(deps: OnboardingIpcDeps): void { @@ -12,7 +16,9 @@ export function registerOnboardingIpc(deps: OnboardingIpcDeps): void { // these on app load and whenever `sessions:changed` / // `connections:changed` / settings change events fire. No push from // main. - target.handle('onboarding:getSnapshot', async () => deps.onboardingService.getSnapshot()); + handleReconnectableRead(target, 'onboarding:getSnapshot', async () => + deps.onboardingService.getSnapshot(), + ); target.handle('onboarding:setMilestone', async (_event, id: unknown, status: unknown) => { // Service throws INVALID_MILESTONE_ID / INVALID_MILESTONE_STATUS // for bad inputs; let the error propagate so the renderer sees diff --git a/apps/desktop/src/main/project-management-service.ts b/apps/desktop/src/main/project-management-service.ts index f870fdf97d..ee5b240972 100644 --- a/apps/desktop/src/main/project-management-service.ts +++ b/apps/desktop/src/main/project-management-service.ts @@ -1,4 +1,4 @@ -import type { ProjectCatalog, ProjectRecord } from '@maka/storage'; +import type { ProjectRecord } from '@maka/core'; import type { CurrentProjectSelection } from './project-root-controller.js'; type DirectoryActionResult = @@ -29,19 +29,18 @@ export interface ProjectManagementService { restore(projectId: unknown): Promise; } -export interface ProjectSessionCatalog { - listHeaders(): Promise< - Array<{ readonly id: string; readonly cwd: string; readonly projectId?: string | null }> - >; - updateHeader( - sessionId: string, - patch: { readonly cwd?: string; readonly projectId?: string | null }, - ): Promise; +export interface ProjectManagementCatalog { + list(): Promise; + register(path: string): Promise; + select(projectId: string): Promise<{ project: ProjectRecord; path: string }>; + relink(projectId: string, path: string): Promise; + rename(projectId: string, name: string): Promise; + archive(projectId: string): Promise; + restore(projectId: string): Promise; } export function createProjectManagementService(deps: { - catalog: ProjectCatalog; - sessions: ProjectSessionCatalog; + catalog: ProjectManagementCatalog; chooseDirectory(): Promise; selection: { currentSelection(): Promise; @@ -112,38 +111,17 @@ export function createProjectManagementService(deps: { const id = requireProjectId(projectId); const path = await deps.chooseDirectory(); if (!path) return { ok: false, reason: 'cancelled' }; - let selectedProjectWasRelinked = false; - const prepareSessions = async (context: { - projectId: string; - projectAliases: string[]; - destinationPath: string; - previousLocations: Array<{ path: string }>; - conflictingProjectId?: string; - conflictingProjectAliases?: string[]; - }) => { - const selectedPath = (await deps.selection.currentSelection()).path; - selectedProjectWasRelinked = context.previousLocations.some( - (location) => location.path === selectedPath, - ); - const survivingIds = new Set([context.projectId, ...context.projectAliases]); - const conflictingIds = new Set([ - ...(context.conflictingProjectId ? [context.conflictingProjectId] : []), - ...(context.conflictingProjectAliases ?? []), - ]); - for (const header of await deps.sessions.listHeaders()) { - if (header.projectId && survivingIds.has(header.projectId)) { - await deps.sessions.updateHeader(header.id, { - cwd: context.destinationPath, - ...(header.projectId !== context.projectId - ? { projectId: context.projectId } - : {}), - }); - } else if (header.projectId && conflictingIds.has(header.projectId)) { - await deps.sessions.updateHeader(header.id, { projectId: context.projectId }); - } - } - }; - const project = await deps.catalog.relink(id, path, prepareSessions); + const [selection, projects] = await Promise.all([ + deps.selection.currentSelection(), + deps.catalog.list(), + ]); + const previous = projects.find( + (project) => project.id === id || project.aliases?.includes(id), + ); + const selectedProjectWasRelinked = previous?.locations.some( + (location) => location.path === selection.path, + ); + const project = await deps.catalog.relink(id, path); if (selectedProjectWasRelinked && project.preferredPath) { deps.selection.setSelection(project.id, project.preferredPath); } diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index d3de6e124f..d89110eb43 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -19,11 +19,11 @@ import { buildMcpTools, type BotIncomingMessage, } from "@maka/runtime"; +import { loadOrCreateRuntimeHostClientInstanceId } from "@maka/runtime-host/client"; import { McpClientManager } from "@maka/mcp"; import { createSettingsStore, createMcpConfigStore, - createProjectCatalog, createSqlitePlanReminderStore, } from "@maka/storage"; import { registerAppIpc } from "./app-ipc-main.js"; @@ -71,6 +71,7 @@ import { registerPermissionOverlayIpc, } from "./permission-overlay/permission-overlay-main.js"; import { resolveProjectContextRoot } from "./project-context-root.js"; +import { resolveDefaultPermissionMode } from "./permission-mode-default.js"; import { createProjectManagementService } from "./project-management-service.js"; import type { ProjectManagementService } from "./project-management-service.js"; import { createProjectRootController } from "./project-root-controller.js"; @@ -99,7 +100,7 @@ import { registerRuntimeHostOAuthIpc } from "./runtime-host-oauth-ipc-main.js"; import { RuntimeHostOAuthPresentation } from "./runtime-host-oauth-presentation.js"; import { registerRuntimeHostPermissionsIpc } from "./runtime-host-permissions-ipc-main.js"; import { registerRuntimeHostSearchIpc } from "./runtime-host-search-ipc-main.js"; -import { createRuntimeHostProjectSessionCatalog } from "./runtime-host-project-session-catalog.js"; +import { createRuntimeHostProjectCatalog } from "./runtime-host-project-catalog.js"; import { toDesktopHostSessionSummary } from "./runtime-host-session-catalog-ipc-main.js"; import { loadRuntimeHostSettings, @@ -129,6 +130,9 @@ await resolveShellEnv(); const buildInfo = resolveBuildInfo(app.isPackaged, app.getAppPath()); const userDataDir = app.getPath("userData"); +const runtimeHostClientInstanceId = await loadOrCreateRuntimeHostClientInstanceId( + join(userDataDir, "runtime-host-client.json"), +); const e2eFixture = resolveDesktopE2eFixture(); const useBotOnboardingFixture = e2eFixture?.scenario === "settings-bots" || @@ -156,10 +160,6 @@ if (e2eFixture) { } } const settingsStore = createSettingsStore(workspaceRoot); -const projectCatalog = createProjectCatalog(workspaceRoot, { - onLegacyImportFailure: (error) => - console.error("[projects] projects.json could not be imported:", error), -}); const mcpConfigStore = createMcpConfigStore(workspaceRoot); const mcpManager = new McpClientManager({ clientName: "maka-desktop", @@ -237,13 +237,12 @@ const oauthPresentation = new RuntimeHostOAuthPresentation( ); let owner: RuntimeHostDesktopOwner | undefined; let runtimePolicyClient: DesktopRuntimeHostClient | undefined; +const projectCatalog = createRuntimeHostProjectCatalog(() => { + if (!runtimePolicyClient) throw new Error("Runtime Host client is unavailable"); + return runtimePolicyClient; +}); const projectManagement: ProjectManagementService = createProjectManagementService({ catalog: projectCatalog, - sessions: { - listHeaders: () => runtimeHostProjectSessionCatalog().listHeaders(), - updateHeader: (sessionId, patch) => - runtimeHostProjectSessionCatalog().updateHeader(sessionId, patch), - }, chooseDirectory: async () => { const result = await mainWindowController.showOpenDialog({ title: "Add project", @@ -253,17 +252,15 @@ const projectManagement: ProjectManagementService = createProjectManagementServi }, selection: projectRoot, }); -function runtimeHostProjectSessionCatalog() { - if (!runtimePolicyClient) throw new Error("Runtime Host client is unavailable"); - return createRuntimeHostProjectSessionCatalog(runtimePolicyClient); -} const mcpCapabilityPublisher = createCapabilityRevisionPublisher(() => mcpManager.toolSnapshotRevision(), ); let settingsBotsIpc: SettingsBotsIpcHandle | undefined; const botRegistry = new BotRegistry({ onIncomingMessage: (message: BotIncomingMessage) => { - void owner?.handleBotIncomingMessage(message); + void owner + ?.handleBotIncomingMessage(message) + .catch((error) => console.error("[runtime-host] bot message failed:", error)); }, onStatusChange: (status) => { mainWindowController.send("settings:bots:statusChanged", status); @@ -376,6 +373,7 @@ const sessionCopyOwnerProcessId = randomUUID(); owner = await startRuntimeHostDesktopOwner( { rootPath: workspaceRoot, + clientInstanceId: runtimeHostClientInstanceId, candidateEntrypoint: new URL( import.meta.resolve( isE2e @@ -521,6 +519,9 @@ function registerHostClientIpc( const unsubscribeSessionCatalogChanges = client.subscribeSessionCatalogChanges( ({ sessionId }) => emitSessionsChanged("updated", sessionId), ); + const unsubscribeProjectCatalogChanges = client.subscribeProjectCatalogChanges(() => { + mainWindowController.send("projects:changed"); + }); const capabilityBinding = mcpCapabilityPublisher.bind( controls.refreshClientCapabilities, ); @@ -639,6 +640,8 @@ function registerHostClientIpc( workspaceRoot, mainWindowController, getCurrentProjectRoot: () => projectRoot.current(), + getDefaultPermissionMode: () => + resolveDefaultPermissionMode(() => loadRuntimeHostSettings(settingsIpcDeps)), openPath: (path) => shell.openPath(path), }); registerRuntimeHostSearchIpc({ ipcMain: scopedIpc, client }); @@ -756,6 +759,7 @@ function registerHostClientIpc( return async () => { unsubscribeConfigurationChanges(); unsubscribeSessionCatalogChanges(); + unsubscribeProjectCatalogChanges(); candidateSettingsBotsIpc.dispose(); if (settingsBotsIpc === candidateSettingsBotsIpc) { settingsBotsIpc = undefined; diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 62dcd03331..e42da6e8d7 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -1,5 +1,6 @@ import { createHash, randomUUID } from "node:crypto"; import type { AttachmentRef, ShellRunUpdate } from "@maka/core/events"; +import type { ProjectRecord } from "@maka/core"; import type { PlanSessionState, PlanUserControlInput } from "@maka/core/plan"; import { decodeStoredMessageForRead, @@ -34,6 +35,7 @@ import { readRuntimeHostConnectionCatalog, readRuntimeHostInvocableSkills, readRuntimeHostResources, + readRuntimeHostProjects, readRuntimeHostSessions, readRuntimeHostSkillCatalog, } from "@maka/runtime-host/client"; @@ -64,6 +66,9 @@ import { type PlanQueryResult, type PricingMutation, type PricingQueryResult, + type ProjectCatalogMutateInput, + type ProjectCatalogMutateResult, + type ProjectCatalogProject, type QueueRetractInput, type QueueRetractResult, type SessionCatalogFilter, @@ -97,6 +102,7 @@ import { } from "@maka/runtime-host/protocol"; const MAX_OPTIMISTIC_ATTEMPTS = 3; +const MAX_SESSION_REVISION_ATTEMPTS = 8; const MAX_PRICING_SNAPSHOT_ATTEMPTS = 3; export type DesktopSessionConfigurationPatch = Partial; @@ -199,6 +205,11 @@ export class DesktopRuntimeHostClient { return this.connection.subscribeConfigurationChanges(listener); } + subscribeProjectCatalogChanges(listener: (revision: number) => void): () => void { + this.#assertOpen(); + return this.connection.subscribeProjectCatalogChanges(listener); + } + subscribeSessionCatalogChanges( listener: (frame: SessionCatalogChangedFrame) => void, ): () => void { @@ -298,6 +309,19 @@ export class DesktopRuntimeHostClient { return this.#request("credential.vault.delete", input); } + getConnectionRequestHeaders( + connectionId: string, + ): Promise> { + return this.#request("connection.request-headers.query", { connectionId }); + } + + replaceConnectionRequestHeaders( + connectionId: string, + headers: OperationInput<"connection.request-headers.replace">["headers"], + ): Promise> { + return this.#request("connection.request-headers.replace", { connectionId, headers }); + } + fetchConnectionModels( connectionId: string, ): Promise> { @@ -468,6 +492,78 @@ export class DesktopRuntimeHostClient { } } + async listProjects(): Promise { + this.#assertOpen(); + try { + return (await readRuntimeHostProjects(this.connection)).map(toProjectRecord); + } catch (error) { + if (!(error instanceof RuntimeHostCatalogReadError)) throw error; + throw new DesktopRuntimeHostClientError( + "catalog_unstable", + "Project catalog kept changing while Desktop read it", + ); + } + } + + async registerProject(path: string): Promise { + const result = await this.#mutateProject({ kind: "register", path }); + return this.#projectForMutation(result); + } + + async selectProject(projectId: string): Promise<{ project: ProjectRecord; path: string }> { + const result = await this.#mutateProject({ kind: "select", projectId }); + if (result.kind !== "selection") throw invalidProjection("Project selection"); + return { project: await this.#projectById(result.projectId), path: result.path }; + } + + async touchProject(projectId: string, path?: string): Promise { + return this.#projectForMutation( + await this.#mutateProject({ kind: "touch", projectId, path: path ?? null }), + ); + } + + async relinkProject(projectId: string, path: string): Promise { + return this.#projectForMutation( + await this.#mutateProject({ kind: "relink", projectId, path }), + ); + } + + async renameProject(projectId: string, name: string): Promise { + return this.#projectForMutation( + await this.#mutateProject({ kind: "rename", projectId, name }), + ); + } + + async archiveProject(projectId: string): Promise { + return this.#projectForMutation( + await this.#mutateProject({ kind: "archive", projectId }), + ); + } + + async restoreProject(projectId: string): Promise { + return this.#projectForMutation( + await this.#mutateProject({ kind: "restore", projectId }), + ); + } + + #mutateProject(input: ProjectCatalogMutateInput) { + this.#assertOpen(); + return this.#request("project.catalog.mutate", input); + } + + async #projectForMutation(result: ProjectCatalogMutateResult): Promise { + if (result.kind !== "project") throw invalidProjection("Project mutation"); + return this.#projectById(result.projectId); + } + + async #projectById(projectId: string): Promise { + const project = (await this.listProjects()).find( + (candidate) => candidate.id === projectId || candidate.aliases?.includes(projectId), + ); + if (!project) throw invalidProjection("Project mutation"); + return project; + } + async listArtifacts(sessionId: string): Promise { for (let attempt = 0; attempt < MAX_OPTIMISTIC_ATTEMPTS; attempt += 1) { const first = await this.#request("artifact.query", { @@ -730,7 +826,7 @@ export class DesktopRuntimeHostClient { } async removeSession(sessionId: string): Promise { - for (let attempt = 0; attempt < MAX_OPTIMISTIC_ATTEMPTS; attempt += 1) { + for (let attempt = 0; attempt < MAX_SESSION_REVISION_ATTEMPTS; attempt += 1) { const current = await this.#requireSession(sessionId); const result = await this.#request("session.remove", { sessionId, @@ -1379,7 +1475,7 @@ export class DesktopRuntimeHostClient { sessionId: string, update: (current: SessionCatalogProjection) => Promise, ): Promise { - for (let attempt = 0; attempt < MAX_OPTIMISTIC_ATTEMPTS; attempt += 1) { + for (let attempt = 0; attempt < MAX_SESSION_REVISION_ATTEMPTS; attempt += 1) { const current = await this.#requireSession(sessionId); const result = await update(current); if (result.kind === "committed") @@ -1442,6 +1538,18 @@ function requireSessionProjection( ); } +function toProjectRecord(project: ProjectCatalogProject): ProjectRecord { + return { + id: project.id, + ...(project.aliases.length === 0 ? {} : { aliases: [...project.aliases] }), + name: project.name, + locations: project.locations.map((location) => ({ ...location })), + ...(project.archivedAt === null ? {} : { archivedAt: project.archivedAt }), + available: project.available, + ...(project.preferredPath === null ? {} : { preferredPath: project.preferredPath }), + }; +} + function clientClosed(): DesktopRuntimeHostClientError { return new DesktopRuntimeHostClientError( "client_closed", diff --git a/apps/desktop/src/main/runtime-host-config-ipc-main.ts b/apps/desktop/src/main/runtime-host-config-ipc-main.ts index a75ead306b..5a65561a2f 100644 --- a/apps/desktop/src/main/runtime-host-config-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-config-ipc-main.ts @@ -122,10 +122,7 @@ async function gatherRuntimeHostConfig( const locators = selected.has('credentials') ? exportLocators(catalog?.connections ?? []) : []; - const exported = - locators.length > 0 - ? await deps.client.exportConfigurationCredentials({ locators }) - : { credentials: [] }; + const exported = await exportConfigurationCredentials(deps.client, locators); const secrets = new Map( exported.credentials.map((entry) => [locatorKey(entry.locator), entry.secret]), ); @@ -148,6 +145,23 @@ async function gatherRuntimeHostConfig( return buildConfigBundle({ appVersion: deps.appVersion, data }); } +async function exportConfigurationCredentials( + client: DesktopRuntimeHostClient, + locators: readonly CredentialLocator[], +) { + const credentials: Array<{ locator: CredentialLocator; secret: string }> = []; + for (const locator of locators) { + const exported = await client.exportConfigurationCredentials({ locator }); + if (exported.credential) { + credentials.push({ + locator: exported.credential.locator, + secret: Buffer.from(exported.credential.secretBase64, 'base64').toString('utf8'), + }); + } + } + return { credentials }; +} + function runtimeHostTransferDeps( deps: RuntimeHostConfigIpcDeps, ): ConfigTransferDeps { @@ -204,6 +218,7 @@ export async function saveConnection( // must CLEAR, not inherit — the update contract's "absent means // untouched" would otherwise resurrect the old profiles. relayModelProfiles: connection.relayModelProfiles ?? null, + requestBodyOverlay: connection.requestBodyOverlay ?? null, }, ); if (updated.kind !== 'committed') { @@ -219,6 +234,9 @@ export async function saveConnection( enabled: connection.enabled, enabledModelIds: [...(connection.enabledModelIds ?? [])], ...(importedProfiles === undefined ? {} : { relayModelProfiles: importedProfiles }), + ...(connection.requestBodyOverlay === undefined + ? {} + : { requestBodyOverlay: connection.requestBodyOverlay }), }); if (created.kind !== 'committed') { throw new Error(`Unable to create imported Connection: ${created.kind}`); @@ -240,7 +258,14 @@ async function saveConnectionCredential( const catalog = await client.loadConnectionCatalog(); const connection = catalog.connections.find((item) => item.slug === slug); if (!connection) throw new Error(`Imported Connection not found: ${slug}`); - const locator = connectionCredentialLocator(connection); + const locator = + kind === 'request_headers' + ? ({ + scope: 'connection', + connectionId: connection.connectionId, + kind: 'request_headers', + } as const) + : connectionCredentialLocator(connection); if (!locator || locator.kind !== kind) return; const current = await client.queryCredential(locator); const saved = await client.setCredential({ @@ -261,7 +286,12 @@ function exportLocators( return [ ...connections.flatMap((connection) => { const locator = connectionCredentialLocator(connection); - return locator ? [locator] : []; + const requestHeaders = { + scope: 'connection', + connectionId: connection.connectionId, + kind: 'request_headers', + } as const; + return locator ? [locator, requestHeaders] : [requestHeaders]; }), { scope: 'network_proxy', kind: 'password' }, { scope: 'web_search', provider: 'tavily', kind: 'api_key' }, @@ -275,9 +305,18 @@ function connectionCredentials( return connections.flatMap((connection) => { const locator = connectionCredentialLocator(connection); const secret = locator ? secrets.get(locatorKey(locator)) : undefined; - return locator && secret - ? [{ slug: connection.slug, kind: locator.kind, value: secret }] - : []; + const requestHeadersLocator = { + scope: 'connection', + connectionId: connection.connectionId, + kind: 'request_headers', + } as const; + const requestHeaders = secrets.get(locatorKey(requestHeadersLocator)); + return [ + ...(locator && secret ? [{ slug: connection.slug, kind: locator.kind, value: secret }] : []), + ...(requestHeaders + ? [{ slug: connection.slug, kind: 'request_headers' as const, value: requestHeaders }] + : []), + ]; }); } diff --git a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts index c8bcf40ebf..c9c30707f8 100644 --- a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts @@ -3,6 +3,7 @@ import type { ConnectionTestResult, CreateConnectionInput, LlmConnection, + SavedRequestHeaders, UpdateConnectionInput, } from '@maka/core'; import { @@ -17,6 +18,7 @@ import type { ConnectionCatalogSnapshot, CredentialLocator, } from '@maka/core/runtime-policy'; +import { normalizeRequestHeaderUpdates } from '@maka/core/runtime-policy'; import type { ConnectionTestRunResult } from '@maka/runtime-host/protocol'; import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; import { @@ -31,9 +33,11 @@ type HostConnectionsClient = Pick< | 'createConnection' | 'deleteCredential' | 'fetchConnectionModels' + | 'getConnectionRequestHeaders' | 'loadConnectionCatalog' | 'queryCredential' | 'removeConnection' + | 'replaceConnectionRequestHeaders' | 'setCredential' | 'setDefaultConnectionTarget' | 'testConnection' @@ -66,6 +70,25 @@ export function registerRuntimeHostConnectionsIpc( ?.configured === true ); }); + deps.ipcMain.handle('connections:getRequestHeaders', async (_event, slug: unknown) => { + const connection = requireConnection(await snapshot(), slug); + const result = await deps.client.getConnectionRequestHeaders(connection.connectionId); + if (result.kind !== 'found') throw new Error('Connection no longer exists'); + return { names: result.names } satisfies SavedRequestHeaders; + }); + deps.ipcMain.handle( + 'connections:setRequestHeaders', + async (_event, slug: unknown, rawUpdates: unknown) => { + const connection = requireConnection(await snapshot(), slug); + const result = await deps.client.replaceConnectionRequestHeaders( + connection.connectionId, + normalizeRequestHeaderUpdates(rawUpdates), + ); + if (result.kind === 'connection_not_found') throw new Error('Connection no longer exists'); + if (result.kind === 'committed') deps.emitConnectionListChanged(); + return { names: result.names } satisfies SavedRequestHeaders; + }, + ); deps.ipcMain.handle('connections:setDefault', async (_event, slug: unknown) => { const catalog = await snapshot(); const target = slug === null @@ -103,21 +126,37 @@ export function registerRuntimeHostConnectionsIpc( enabledModelIds: defaultEnabledModelIdsWhenOmitted(input.providerType), }), ...(relayModelProfiles === undefined ? {} : { relayModelProfiles }), + ...(input.requestBodyOverlay === undefined + ? {} + : { requestBodyOverlay: input.requestBodyOverlay }), }); if (created.kind !== 'committed') { throw new Error(`Unable to create Connection: ${created.kind}`); } - if (input.apiKey) { + try { const entry = requireConnection(await snapshot(), input.slug); - const credential = await deps.client.setCredential({ - locator: connectionCredential(entry), - expected: null, - secret: input.apiKey, - }); - if (credential.kind !== 'committed') { - await deps.client.removeConnection(created.connection).catch(() => undefined); - throw new Error(`Unable to save Connection credential: ${credential.kind}`); + if (input.apiKey) { + const credential = await deps.client.setCredential({ + locator: connectionCredential(entry), + expected: null, + secret: input.apiKey, + }); + if (credential.kind !== 'committed') { + throw new Error(`Unable to save Connection credential: ${credential.kind}`); + } + } + if (input.requestHeaders && Object.keys(input.requestHeaders).length > 0) { + const requestHeaders = await deps.client.replaceConnectionRequestHeaders( + entry.connectionId, + Object.entries(input.requestHeaders).map(([name, value]) => ({ name, value })), + ); + if (requestHeaders.kind !== 'committed') { + throw new Error(`Unable to save custom request headers: ${requestHeaders.kind}`); + } } + } catch (error) { + await deps.client.removeConnection(created.connection).catch(() => undefined); + throw error; } deps.emitConnectionListChanged(); return requireProjectedConnection(await snapshot(), input.slug); @@ -145,6 +184,9 @@ export function registerRuntimeHostConnectionsIpc( ...(patch.relayModelProfiles === undefined ? {} : { relayModelProfiles: normalizeRelayModelProfiles(patch.relayModelProfiles) ?? null }), + ...(patch.requestBodyOverlay === undefined + ? {} + : { requestBodyOverlay: patch.requestBodyOverlay }), }, ); if (updated.kind !== 'committed') { @@ -168,16 +210,38 @@ export function registerRuntimeHostConnectionsIpc( return requireProjectedConnection(await snapshot(), current.slug); }); deps.ipcMain.handle('connections:delete', async (_event, slug: unknown) => { - const catalog = await snapshot(); - const current = requireConnection(catalog, slug); - requireCommitted( - await deps.client.removeConnection({ + // OAuth/model-fetch can bump the connection revision under the UI. Retry + // on connection_stale with a fresh snapshot so delete does not fail with a + // opaque "service unavailable" after the user already confirmed. + const maxAttempts = 6; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const catalog = await snapshot(); + let current: ReturnType; + try { + current = requireConnection(catalog, slug); + } catch (error) { + // Only treat a missing slug as success. Invalid input must still fail. + if (error instanceof Error && error.message.startsWith('No such Connection:')) { + deps.emitConnectionListChanged(); + return; + } + throw error; + } + const result = await deps.client.removeConnection({ connectionId: current.connectionId, revision: current.revision, - }), - 'delete Connection', - ); - deps.emitConnectionListChanged(); + }); + // RemoveCatalogConnectionResult is only committed | connection_stale. + if (result.kind === 'committed') { + deps.emitConnectionListChanged(); + return; + } + if (attempt < maxAttempts - 1) { + continue; + } + // English so renderer locale mapping (provider-panel-shared) can choose zh/en. + throw new Error('Unable to delete Connection: connection_stale'); + } }); deps.ipcMain.handle('connections:fetchModels', async (_event, slug: unknown) => { const current = requireConnection(await snapshot(), slug); @@ -248,6 +312,9 @@ export function projectHostConnections(catalog: ConnectionCatalogSnapshot): LlmC ...(connection.relayModelProfiles === undefined ? {} : { relayModelProfiles: connection.relayModelProfiles }), + ...(connection.requestBodyOverlay === undefined + ? {} + : { requestBodyOverlay: connection.requestBodyOverlay }), ...(connection.modelSource === undefined ? {} : { modelSource: connection.modelSource }), ...(connection.modelsFetchedAt === undefined ? {} diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index f549dea0b3..9b097ceb00 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -7,6 +7,7 @@ import type { import type { BotRegistry } from "@maka/runtime"; import { connectOrSpawnRuntimeHost, + waitForRuntimeHostReady, type ConnectOrSpawnRuntimeHostInput, type ConnectOrSpawnRuntimeHostResult, type RuntimeHostConnection, @@ -39,9 +40,11 @@ import { registerRuntimeHostSessionExecutionIpc, type RuntimeHostSessionExecutionIpcDeps, } from "./runtime-host-session-execution-ipc-main.js"; +import { RuntimeHostSessionObservationRegistry } from "./runtime-host-session-observation-registry.js"; import { RuntimeHostSessionObserver } from "./runtime-host-session-observer.js"; +import type { IpcHandler, ReconnectableReadIpcMain } from "./ipc-reconnect-policy.js"; -type CandidateIpcMain = Pick; +type CandidateIpcMain = ReconnectableReadIpcMain & Pick; export interface DesktopRuntimeHostCandidateDeps { readonly ipcMain: CandidateIpcMain; @@ -99,6 +102,7 @@ export interface DesktopRuntimeHostCandidateStartInput extends DesktopRuntimeHos readonly connectTimeoutMs?: number; readonly handshakeTimeoutMs?: number; readonly candidateEntrypoint?: string | URL; + readonly signal?: AbortSignal; } export type DesktopRuntimeHostCandidateStartResult = @@ -127,6 +131,8 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { readonly #closeNativeCapabilities: () => Promise; readonly #closeSessionDomains: () => Promise; readonly #disposeClientIpc: (() => void | Promise) | undefined; + readonly #detachSessionObservations: () => void; + readonly #closeSessionObservations: () => Promise; readonly #hasRegisteredCapabilities: () => boolean; readonly #stopSession: (sessionId: string) => Promise; #closeTask: Promise | undefined; @@ -139,6 +145,8 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { closeNativeCapabilities: () => Promise; closeSessionDomains: () => Promise; disposeClientIpc: (() => void | Promise) | undefined; + detachSessionObservations: () => void; + closeSessionObservations: () => Promise; connectionClosed: Promise; hasRegisteredCapabilities: () => boolean; stopSession: (sessionId: string) => Promise; @@ -151,6 +159,8 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { this.#closeNativeCapabilities = input.closeNativeCapabilities; this.#closeSessionDomains = input.closeSessionDomains; this.#disposeClientIpc = input.disposeClientIpc; + this.#detachSessionObservations = input.detachSessionObservations; + this.#closeSessionObservations = input.closeSessionObservations; this.#hasRegisteredCapabilities = input.hasRegisteredCapabilities; this.#stopSession = input.stopSession; this.botIncoming = input.botIncoming; @@ -167,6 +177,8 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { } async #close(): Promise { + this.#ipc.close(); + this.#detachSessionObservations(); const domainResults = await Promise.allSettled([this.#closeSessionDomains()]); const results = await Promise.allSettled([ this.#botIncoming.close(), @@ -181,8 +193,8 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { } async #closeConnection(): Promise { - this.#ipc.close(); await this.#observer.close().catch(() => undefined); + await this.#closeSessionObservations().catch(() => undefined); if (this.#hasRegisteredCapabilities()) { await this.#client.unregisterClientCapabilities().catch(() => undefined); } @@ -192,6 +204,7 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { export async function startDesktopRuntimeHostCandidate( input: DesktopRuntimeHostCandidateStartInput, + observationRegistry?: RuntimeHostSessionObservationRegistry, ): Promise { const connection = await connectOrSpawnRuntimeHost(connectInput(input)); if (connection.kind !== "connected") return connection; @@ -199,12 +212,14 @@ export async function startDesktopRuntimeHostCandidate( await waitForRuntimeHostReady( connection.connection, input.electionDeadlineMs ?? 45_000, + input.signal, ); return { kind: "ready", candidate: await createDesktopRuntimeHostCandidate( connection.connection, input, + observationRegistry, ), }; } catch (error) { @@ -213,31 +228,17 @@ export async function startDesktopRuntimeHostCandidate( } } -async function waitForRuntimeHostReady( - connection: RuntimeHostConnection, - timeoutMs: number, -): Promise { - const deadline = Date.now() + timeoutMs; - while (true) { - const status = await connection.status(Math.max(1, deadline - Date.now())); - if (status.state === "ready") return; - if (status.state === "draining") - throw new Error("Runtime Host drained before becoming ready"); - const remaining = deadline - Date.now(); - if (remaining <= 0) - throw new Error("Runtime Host did not become ready before the deadline"); - await new Promise((resolve) => - setTimeout(resolve, Math.min(25, remaining)), - ); - } -} - export async function createDesktopRuntimeHostCandidate( connection: RuntimeHostConnection, deps: DesktopRuntimeHostCandidateDeps, + observationRegistry?: RuntimeHostSessionObservationRegistry, ): Promise { const client = new DesktopRuntimeHostClient(connection); const ipc = new ScopedIpcMain(deps.ipcMain); + const sessionObservations = + observationRegistry ?? + new RuntimeHostSessionObservationRegistry((error) => deps.onError?.(error)); + const ownsSessionObservations = observationRegistry === undefined; const providers = new Set(); const nativeSessionIds = new Set(); const releaseNativeResources = async ( @@ -292,6 +293,7 @@ export async function createDesktopRuntimeHostCandidate( let observer: RuntimeHostSessionObserver | undefined; let closeSessionDomains: (() => Promise) | undefined; let disposeClientIpc: (() => void | Promise) | undefined; + let observationsAttached = false; let capabilitiesRegistered = false; try { let domains: RuntimeHostSessionDomainsIpcHandle | undefined; @@ -306,6 +308,7 @@ export async function createDesktopRuntimeHostCandidate( outcome === "completed" ? deps.completeComputerUseTurn(sessionId) : deps.nativeCapabilities.releaseComputerUseSession(sessionId), + recoverConnectionClosed: observationRegistry !== undefined, ...(deps.now ? { now: deps.now } : {}), }); observer = sessionObserver; @@ -322,6 +325,11 @@ export async function createDesktopRuntimeHostCandidate( ipc, ); closeSessionDomains = domains.close; + const restoredSessionIds = await sessionObservations.attach(sessionObserver); + observationsAttached = true; + for (const sessionId of restoredSessionIds) { + deps.emitSessionsChanged("message-appended", sessionId); + } const watchComputerUseTurn = (sessionId: string, turnId: string): void => { void sessionObserver .watchTurn(sessionId, turnId) @@ -410,6 +418,7 @@ export async function createDesktopRuntimeHostCandidate( { client, observer: sessionObserver, + observations: sessionObservations, attachmentApprovals: deps.attachmentApprovals, emitSessionsChanged: deps.emitSessionsChanged, stat: deps.stat, @@ -441,15 +450,25 @@ export async function createDesktopRuntimeHostCandidate( closeNativeCapabilities, closeSessionDomains: domains.close, disposeClientIpc, + detachSessionObservations: () => + sessionObservations.detach(sessionObserver), + closeSessionObservations: () => + ownsSessionObservations + ? sessionObservations.close() + : Promise.resolve(), connectionClosed: connection.closed, hasRegisteredCapabilities: () => capabilitiesRegistered, stopSession, }); } catch (error) { ipc.close(); + if (observationsAttached && observer) sessionObservations.detach(observer); await Promise.resolve(disposeClientIpc?.()).catch(() => undefined); await closeSessionDomains?.().catch(() => undefined); await observer?.close().catch(() => undefined); + if (ownsSessionObservations) { + await sessionObservations.close().catch(() => undefined); + } await client.close().catch(() => undefined); await closeNativeCapabilities().catch(() => undefined); throw error; @@ -481,10 +500,11 @@ function connectInput( ...(input.candidateEntrypoint === undefined ? {} : { candidateEntrypoint: input.candidateEntrypoint }), + ...(input.signal === undefined ? {} : { signal: input.signal }), }; } -class ScopedIpcMain implements Pick { +class ScopedIpcMain implements ReconnectableReadIpcMain { readonly #ipcMain: CandidateIpcMain; readonly #channels = new Set(); #closed = false; @@ -494,6 +514,14 @@ class ScopedIpcMain implements Pick { } handle(channel: string, listener: Parameters[1]): void { + this.#handle(channel, listener, false); + } + + handleReconnectableRead(channel: string, listener: IpcHandler): void { + this.#handle(channel, listener, true); + } + + #handle(channel: string, listener: IpcHandler, reconnectableRead: boolean): void { if (this.#closed) throw new Error("Desktop Runtime Host candidate IPC is closed"); if (this.#channels.has(channel)) { @@ -501,7 +529,11 @@ class ScopedIpcMain implements Pick { `Desktop Runtime Host candidate registered duplicate IPC: ${channel}`, ); } - this.#ipcMain.handle(channel, listener); + if (reconnectableRead && this.#ipcMain.handleReconnectableRead) { + this.#ipcMain.handleReconnectableRead(channel, listener); + } else { + this.#ipcMain.handle(channel, listener); + } this.#channels.add(channel); } diff --git a/apps/desktop/src/main/runtime-host-desktop-owner.ts b/apps/desktop/src/main/runtime-host-desktop-owner.ts index d20b21e5c6..359e5e0601 100644 --- a/apps/desktop/src/main/runtime-host-desktop-owner.ts +++ b/apps/desktop/src/main/runtime-host-desktop-owner.ts @@ -1,13 +1,18 @@ import type { BotIncomingMessage } from '@maka/runtime'; +import { + RuntimeHostPermanentReconnectError, + startRuntimeHostReconnectLifecycle, + type RuntimeHostReconnectBackoff, + type RuntimeHostReconnectLifecycle, +} from '@maka/runtime-host/client'; import { startDesktopRuntimeHostCandidate, type DesktopRuntimeHostCandidate, type DesktopRuntimeHostCandidateStartInput, type DesktopRuntimeHostCandidateStartResult, } from './runtime-host-desktop-candidate.js'; - -const MAX_RECONNECT_ATTEMPTS = 3; -const RECONNECT_DELAY_MS = 250; +import { RuntimeHostReconnectingIpcMain } from './runtime-host-reconnecting-ipc-main.js'; +import { RuntimeHostSessionObservationRegistry } from './runtime-host-session-observation-registry.js'; export interface RuntimeHostDesktopOwner { handleBotIncomingMessage(message: BotIncomingMessage): Promise; @@ -20,114 +25,103 @@ export async function startRuntimeHostDesktopOwner( options: { startCandidate?: ( input: DesktopRuntimeHostCandidateStartInput, + observationRegistry: RuntimeHostSessionObservationRegistry, ) => Promise; onFatalError?: (error: Error) => void; + reconnectBackoff?: RuntimeHostReconnectBackoff; } = {}, ): Promise { const owner = new RuntimeHostDesktopOwnerImpl( input, options.startCandidate ?? startDesktopRuntimeHostCandidate, options.onFatalError ?? ((error) => console.error('[runtime-host] reconnect failed:', error)), + options.reconnectBackoff, ); await owner.start(); return owner; } class RuntimeHostDesktopOwnerImpl implements RuntimeHostDesktopOwner { - #candidate: DesktopRuntimeHostCandidate | undefined; - #generation = 0; - #closed = false; - #closeTask: Promise | undefined; + readonly #ipcMain: RuntimeHostReconnectingIpcMain; + readonly #sessionObservations: RuntimeHostSessionObservationRegistry; + #lifecycle: RuntimeHostReconnectLifecycle | undefined; constructor( private readonly input: DesktopRuntimeHostCandidateStartInput, private readonly startCandidate: ( input: DesktopRuntimeHostCandidateStartInput, + observationRegistry: RuntimeHostSessionObservationRegistry, ) => Promise, private readonly onFatalError: (error: Error) => void, - ) {} + private readonly reconnectBackoff: RuntimeHostReconnectBackoff | undefined, + ) { + this.#ipcMain = new RuntimeHostReconnectingIpcMain(this.input.ipcMain); + this.#sessionObservations = new RuntimeHostSessionObservationRegistry( + (error) => this.input.onError?.(error), + ); + } async start(): Promise { - const candidate = await this.connect(); - this.install(candidate); + try { + this.#lifecycle = await startRuntimeHostReconnectLifecycle({ + connect: (signal) => this.connect(signal), + onFatalError: this.onFatalError, + ...(this.reconnectBackoff ? { backoff: this.reconnectBackoff } : {}), + }); + } catch (error) { + await this.#sessionObservations.close(); + this.#ipcMain.close(); + throw error; + } } async handleBotIncomingMessage(message: BotIncomingMessage): Promise { - const candidate = this.#candidate; - if (!candidate || this.#closed) return; + const candidate = await this.#waitForReadyCandidate(); await candidate.botIncoming.handleBotIncomingMessage(message); } async stopSession(sessionId: string): Promise { - const candidate = this.#candidate; - if (!candidate || this.#closed) return; + const candidate = await this.#waitForReadyCandidate(); await candidate.stopSession(sessionId); } - close(): Promise { - this.#closeTask ??= this.closeOwner(); - return this.#closeTask; - } - - private async closeOwner(): Promise { - this.#closed = true; - this.#generation += 1; - const candidate = this.#candidate; - this.#candidate = undefined; - await candidate?.close(); - } - - private install(candidate: DesktopRuntimeHostCandidate): void { - if (this.#closed) { - void candidate.close(); - return; - } - this.#candidate = candidate; - const generation = ++this.#generation; - void candidate.closed.then( - () => this.reconnect(generation), - () => this.reconnect(generation), - ); - } - - private async reconnect(generation: number): Promise { - if (this.#closed || generation !== this.#generation) return; - this.#candidate = undefined; - for (let attempt = 0; attempt < MAX_RECONNECT_ATTEMPTS; attempt += 1) { - if (this.#closed || generation !== this.#generation) return; - if (attempt > 0) await delay(RECONNECT_DELAY_MS); + async close(): Promise { + try { + await this.#lifecycle?.close(); + } finally { try { - const candidate = await this.connect(); - if (this.#closed || generation !== this.#generation) { - await candidate.close(); - return; - } - this.install(candidate); - return; - } catch (error) { - if (attempt + 1 === MAX_RECONNECT_ATTEMPTS) { - this.onFatalError(asError(error)); - } + await this.#sessionObservations.close(); + } finally { + this.#ipcMain.close(); } } } - private async connect(): Promise { - const result = await this.startCandidate(this.input); + private async connect(signal: AbortSignal): Promise { + const result = await this.startCandidate( + { ...this.input, ipcMain: this.#ipcMain, signal }, + this.#sessionObservations, + ); if (result.kind === 'ready') return result.candidate; if (result.kind === 'incompatible') { - throw new Error( + throw new RuntimeHostPermanentReconnectError( `Runtime Host is incompatible (protocol ${result.handshake.protocolMin}-${result.handshake.protocolMax}; ${result.handshake.replacement})`, ); } throw new Error(`Runtime Host startup failed: ${result.reason}`); } -} -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} + #requireLifecycle(): RuntimeHostReconnectLifecycle { + if (!this.#lifecycle) throw new Error('Desktop Runtime Host owner has not started'); + return this.#lifecycle; + } -function asError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); + async #waitForReadyCandidate(): Promise { + const lifecycle = this.#requireLifecycle(); + let candidate = await lifecycle.waitForCurrent(); + while (candidate.client.lifecycleState !== 'ready') { + candidate = await lifecycle.waitForCurrent(candidate); + } + return candidate; + } } diff --git a/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts b/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts index 792a9b0adc..ce564ab420 100644 --- a/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts @@ -82,6 +82,9 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void } deps.ipcMain.handle(channel('get-auth-url'), async () => { if (!providerEnabled(provider)) return providerDisabled(); + // Drop any Desktop-tracked attempt for this provider so a re-click does + // not race a stale completeAuthorization waiter against a new start. + await cancelProviderAttempts(deps, activeAttempts, provider); const connection = await ensureRuntimeHostAccountConnection(deps.client, { providerType: provider, slug: INTERACTIVE_OAUTH_CONNECTION_SLUGS[provider], @@ -100,7 +103,13 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void } catch (error) { expectation.cancel(error); await deps.client.cancelOAuthLogin(attemptId).catch(() => undefined); - return actionFailure('Unable to start OAuth authorization'); + // Prefer the host's message when present so "already in progress" is not + // flattened into a generic 鉴权失败 for the toast classifier. + const detail = + error instanceof Error && error.message.trim().length > 0 + ? error.message + : 'Unable to start OAuth authorization'; + return actionFailure(detail); } }); deps.ipcMain.handle(channel('open-auth-url'), (_event, attemptId: unknown) => { diff --git a/apps/desktop/src/main/runtime-host-oauth-presentation.ts b/apps/desktop/src/main/runtime-host-oauth-presentation.ts index 1c220b565e..77b1988382 100644 --- a/apps/desktop/src/main/runtime-host-oauth-presentation.ts +++ b/apps/desktop/src/main/runtime-host-oauth-presentation.ts @@ -29,6 +29,10 @@ export class RuntimeHostOAuthPresentation implements OAuthPresentationBackend { resolvePresented = accept; rejectPresented = decline; }); + // If a later expect() supersedes this one before waitForPresentation + // attaches, Node would log UnhandledPromiseRejection. Keep a no-op + // handler; real waiters still observe the same rejection. + void presented.catch(() => undefined); const timer = setTimeout(() => { if (this.#pending?.attemptId !== attemptId) return; this.#pending = undefined; diff --git a/apps/desktop/src/main/runtime-host-project-catalog.ts b/apps/desktop/src/main/runtime-host-project-catalog.ts new file mode 100644 index 0000000000..b3a644c777 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-project-catalog.ts @@ -0,0 +1,45 @@ +import type { ProjectRecord } from "@maka/core"; +import { RuntimeHostOperationError } from "@maka/runtime-host/client"; +import { ProjectPathMismatchError } from "@maka/storage"; +import type { ProjectManagementCatalog } from "./project-management-service.js"; +import type { DesktopRuntimeHostClient } from "./runtime-host-client.js"; + +export interface DesktopProjectCatalog extends ProjectManagementCatalog { + touch(projectId: string, path?: string): Promise; +} + +type RuntimeHostProjectClient = Pick< + DesktopRuntimeHostClient, + | "archiveProject" + | "listProjects" + | "registerProject" + | "relinkProject" + | "renameProject" + | "restoreProject" + | "selectProject" + | "touchProject" +>; + +export function createRuntimeHostProjectCatalog( + resolveClient: () => RuntimeHostProjectClient, +): DesktopProjectCatalog { + return { + list: () => resolveClient().listProjects(), + register: (path) => resolveClient().registerProject(path), + select: (projectId) => resolveClient().selectProject(projectId), + async touch(projectId, path) { + try { + return await resolveClient().touchProject(projectId, path); + } catch (error) { + if (error instanceof RuntimeHostOperationError && error.code === "operation_conflict") { + throw new ProjectPathMismatchError(projectId, path ?? ""); + } + throw error; + } + }, + relink: (projectId, path) => resolveClient().relinkProject(projectId, path), + rename: (projectId, name) => resolveClient().renameProject(projectId, name), + archive: (projectId) => resolveClient().archiveProject(projectId), + restore: (projectId) => resolveClient().restoreProject(projectId), + }; +} diff --git a/apps/desktop/src/main/runtime-host-project-session-catalog.ts b/apps/desktop/src/main/runtime-host-project-session-catalog.ts deleted file mode 100644 index ee1108164f..0000000000 --- a/apps/desktop/src/main/runtime-host-project-session-catalog.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { ProjectSessionCatalog } from "./project-management-service.js"; -import type { DesktopRuntimeHostClient } from "./runtime-host-client.js"; - -type RuntimeHostProjectClient = Pick< - DesktopRuntimeHostClient, - "listSessions" | "relocateSessionCwd" | "updateSessionMetadata" ->; - -export function createRuntimeHostProjectSessionCatalog( - client: RuntimeHostProjectClient, -): ProjectSessionCatalog { - return { - async listHeaders() { - return (await client.listSessions()).map((session) => ({ - id: session.id, - cwd: session.cwd, - projectId: session.projectId, - })); - }, - - async updateHeader(sessionId, patch) { - if (patch.cwd !== undefined) { - return client.relocateSessionCwd(sessionId, patch.cwd, patch.projectId); - } - if (patch.projectId !== undefined) { - return client.updateSessionMetadata(sessionId, { - projectId: patch.projectId, - }); - } - return undefined; - }, - }; -} diff --git a/apps/desktop/src/main/runtime-host-reconnecting-ipc-main.ts b/apps/desktop/src/main/runtime-host-reconnecting-ipc-main.ts new file mode 100644 index 0000000000..02de0b8dec --- /dev/null +++ b/apps/desktop/src/main/runtime-host-reconnecting-ipc-main.ts @@ -0,0 +1,126 @@ +import type { IpcMain } from "electron"; +import { + RuntimeHostOperationError, + RuntimeHostRequestInterruptedError, +} from "@maka/runtime-host/client"; +import type { IpcHandler, ReconnectableReadIpcMain } from "./ipc-reconnect-policy.js"; + +interface HandlerWaiter { + readonly resolve: (handler: IpcHandler) => void; + readonly reject: (error: Error) => void; +} + +interface HandlerSlot { + readonly waiters: Set; + readonly reconnectableRead: boolean; + handler?: IpcHandler; +} + +/** + * Keeps Electron IPC admission stable while a Runtime Host candidate is replaced. + */ +export class RuntimeHostReconnectingIpcMain + implements ReconnectableReadIpcMain +{ + readonly #ipcMain: Pick; + readonly #slots = new Map(); + #closed = false; + + constructor(ipcMain: Pick) { + this.#ipcMain = ipcMain; + } + + handle(channel: string, listener: IpcHandler): void { + this.#handle(channel, listener, false); + } + + handleReconnectableRead(channel: string, listener: IpcHandler): void { + this.#handle(channel, listener, true); + } + + #handle(channel: string, listener: IpcHandler, reconnectableRead: boolean): void { + if (this.#closed) { + throw new Error("Desktop Runtime Host IPC router is closed"); + } + let slot = this.#slots.get(channel); + if (!slot) { + const created: HandlerSlot = { waiters: new Set(), reconnectableRead }; + this.#ipcMain.handle(channel, (event, ...args) => + this.#dispatch(created, event, args), + ); + this.#slots.set(channel, created); + slot = created; + } + if (slot.reconnectableRead !== reconnectableRead) { + throw new Error(`Desktop Runtime Host IPC policy changed: ${channel}`); + } + if (slot.handler) { + throw new Error(`Desktop Runtime Host IPC handler already exists: ${channel}`); + } + slot.handler = listener; + for (const waiter of slot.waiters) waiter.resolve(listener); + slot.waiters.clear(); + } + + removeHandler(channel: string): void { + const slot = this.#slots.get(channel); + if (slot) slot.handler = undefined; + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + const error = new Error("Desktop Runtime Host IPC router is closed"); + for (const [channel, slot] of this.#slots) { + for (const waiter of slot.waiters) waiter.reject(error); + slot.waiters.clear(); + slot.handler = undefined; + this.#ipcMain.removeHandler(channel); + } + this.#slots.clear(); + } + + async #dispatch( + slot: HandlerSlot, + event: Parameters[0], + args: readonly unknown[], + ): Promise { + let handler = slot.handler ?? (await this.#waitForHandler(slot)); + while (true) { + try { + return await handler(event, ...args); + } catch (error) { + if ( + !slot.reconnectableRead || + !isReconnectableReadFailure(error) + ) { + throw error; + } + handler = await this.#waitForHandler(slot, handler); + } + } + } + + #waitForHandler(slot: HandlerSlot, previous?: IpcHandler): Promise { + if (this.#closed) { + return Promise.reject( + new Error("Desktop Runtime Host IPC router is closed"), + ); + } + if (slot.handler && slot.handler !== previous) { + return Promise.resolve(slot.handler); + } + return new Promise((resolve, reject) => { + slot.waiters.add({ resolve, reject }); + }); + } +} + +function isReconnectableReadFailure(error: unknown): boolean { + return ( + (error instanceof RuntimeHostOperationError && + error.code === "host_draining") || + (error instanceof RuntimeHostRequestInterruptedError && + error.reason === "connection_lost") + ); +} diff --git a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts index 64926acc91..cd7031bca1 100644 --- a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts @@ -1,5 +1,4 @@ import { randomUUID } from 'node:crypto'; -import type { IpcMain } from 'electron'; import { isCollaborationMode, isOrchestrationMode, @@ -28,6 +27,10 @@ import { } from './session-family-action.js'; import { normalizeSessionModelSelection } from './session-model-input.js'; import type { SessionCopyCleanupAuthority } from './quote-companion-cleanup.js'; +import { + handleReconnectableRead, + type ReconnectableReadIpcMain, +} from './ipc-reconnect-policy.js'; type RuntimeHostSessionCatalogClient = Pick< DesktopRuntimeHostClient, @@ -60,7 +63,7 @@ export interface RuntimeHostSessionCatalogIpcDeps { export function registerRuntimeHostSessionCatalogIpc( deps: RuntimeHostSessionCatalogIpcDeps, - ipcMain: Pick, + ipcMain: ReconnectableReadIpcMain, ): void { const newId = deps.newId ?? randomUUID; const listSessions = async (filter?: SessionListFilter): Promise => { @@ -78,7 +81,9 @@ export function registerRuntimeHostSessionCatalogIpc( const actionIds = (sessionId: string, options: unknown) => resolveSessionActionIds(() => listSessions(), sessionId, options); - ipcMain.handle('sessions:list', (_event, filter?: SessionListFilter) => listSessions(filter)); + handleReconnectableRead(ipcMain, 'sessions:list', (_event, filter?: SessionListFilter) => + listSessions(filter), + ); ipcMain.handle('sessions:cleanupSessionCopy', async (_event, sessionId: string) => { await deps.sessionCopyCleanup.cleanup(sessionId); }); diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index fc44c06f34..2c18f9c8c0 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -26,6 +26,7 @@ import { } from "./permission-response-guard.js"; import type { DesktopRuntimeHostClient } from "./runtime-host-client.js"; import type { SessionCopyCleanupAuthority } from './quote-companion-cleanup.js'; +import type { RuntimeHostSessionObservationRegistry } from "./runtime-host-session-observation-registry.js"; import { RuntimeHostSessionObserver, type RuntimeHostSessionObserverTarget, @@ -55,6 +56,10 @@ type RuntimeHostSessionExecutionClient = Pick< export interface RuntimeHostSessionExecutionIpcDeps { client: RuntimeHostSessionExecutionClient; observer: RuntimeHostSessionObserver; + observations: Pick< + RuntimeHostSessionObservationRegistry, + "observe" | "unobserve" + >; attachmentApprovals: AttachmentApprovalRegistry; emitSessionsChanged: ( reason: SessionChangedReason, @@ -112,7 +117,7 @@ export function registerRuntimeHostSessionExecutionIpc( async (event, sessionId: unknown, observerId: unknown) => { const normalizedSessionId = requiredId(sessionId, "Session"); const normalizedObserverId = requiredId(observerId, "Session observer"); - await deps.observer.observe( + await deps.observations.observe( normalizedSessionId, normalizedObserverId, event.sender as RuntimeHostSessionObserverTarget, @@ -120,7 +125,9 @@ export function registerRuntimeHostSessionExecutionIpc( }, ); ipcMain.handle("sessions:unobserve", async (_event, observerId: unknown) => { - await deps.observer.unobserve(requiredId(observerId, "Session observer")); + await deps.observations.unobserve( + requiredId(observerId, "Session observer"), + ); }); ipcMain.handle("sessions:readMessages", async (_event, sessionId: string) => { const messages = await deps.observer.readMessages(sessionId); diff --git a/apps/desktop/src/main/runtime-host-session-observation-registry.ts b/apps/desktop/src/main/runtime-host-session-observation-registry.ts new file mode 100644 index 0000000000..858201a943 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-session-observation-registry.ts @@ -0,0 +1,139 @@ +import type { + RuntimeHostSessionObserver, + RuntimeHostSessionObserverTarget, +} from "./runtime-host-session-observer.js"; + +type SessionObservationSource = Pick< + RuntimeHostSessionObserver, + "observe" | "unobserve" +>; + +interface SessionObservationRegistration { + readonly sessionId: string; + readonly target: RuntimeHostSessionObserverTarget; + readonly destroyedListener: () => void; +} + +/** + * Keeps renderer observation intent alive while the Host connection is replaced. + */ +export class RuntimeHostSessionObservationRegistry { + readonly #registrations = new Map< + string, + SessionObservationRegistration + >(); + readonly #onError: (error: unknown) => void; + #source: SessionObservationSource | undefined; + #closed = false; + + constructor(onError: (error: unknown) => void = () => undefined) { + this.#onError = onError; + } + + async attach(source: SessionObservationSource): Promise { + this.#assertOpen(); + if (this.#source && this.#source !== source) { + throw new Error("Runtime Host Session observations are already attached"); + } + this.#source = source; + const restored = await Promise.all( + [...this.#registrations].map(async ([observerId, registration]) => { + try { + await source.observe( + registration.sessionId, + observerId, + registration.target, + ); + return registration.sessionId; + } catch (error) { + if (this.#source === source) this.#onError(error); + return undefined; + } + }), + ); + return [...new Set(restored.filter((sessionId): sessionId is string => !!sessionId))]; + } + + detach(source: SessionObservationSource): void { + if (this.#source === source) this.#source = undefined; + } + + async observe( + sessionId: string, + observerId: string, + target: RuntimeHostSessionObserverTarget, + ): Promise { + this.#assertOpen(); + const previous = this.#registrations.get(observerId); + if (previous) { + if (previous.sessionId !== sessionId || previous.target.id !== target.id) { + throw new Error("Runtime Host Session observer identity was reused"); + } + return; + } + + const destroyedListener = () => { + void this.#remove(observerId).catch(this.#onError); + }; + const registration = { sessionId, target, destroyedListener }; + this.#registrations.set(observerId, registration); + target.once("destroyed", destroyedListener); + + const source = this.#source; + if (!source) return; + try { + await source.observe(sessionId, observerId, target); + } catch (error) { + if ( + this.#source === source && + this.#registrations.get(observerId) === registration + ) { + this.#deleteRegistration(observerId, registration); + } + throw error; + } + } + + async unobserve(observerId: string): Promise { + await this.#remove(observerId); + } + + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + const source = this.#source; + this.#source = undefined; + const registrations = [...this.#registrations]; + this.#registrations.clear(); + for (const [, registration] of registrations) { + registration.target.off("destroyed", registration.destroyedListener); + } + if (source) { + await Promise.allSettled( + registrations.map(([observerId]) => source.unobserve(observerId)), + ); + } + } + + async #remove(observerId: string): Promise { + const registration = this.#registrations.get(observerId); + if (!registration) return; + this.#deleteRegistration(observerId, registration); + await this.#source?.unobserve(observerId); + } + + #deleteRegistration( + observerId: string, + registration: SessionObservationRegistration, + ): void { + if (this.#registrations.get(observerId) !== registration) return; + this.#registrations.delete(observerId); + registration.target.off("destroyed", registration.destroyedListener); + } + + #assertOpen(): void { + if (this.#closed) { + throw new Error("Runtime Host Session observation registry is closed"); + } + } +} diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts index bcbd740027..a6d8d0b5cf 100644 --- a/apps/desktop/src/main/runtime-host-session-observer.ts +++ b/apps/desktop/src/main/runtime-host-session-observer.ts @@ -24,6 +24,7 @@ import type { DesktopRuntimeHostClient, DesktopRuntimeHostSession, } from "./runtime-host-client.js"; +import { RuntimeHostSubscriptionError } from "@maka/runtime-host/client"; const MAX_PENDING_FRAMES = 512; @@ -50,6 +51,7 @@ export interface RuntimeHostSessionObserverDeps { sessionId: string, outcome: "completed" | "abandoned", ) => void | Promise; + recoverConnectionClosed?: boolean; now?: () => number; } @@ -103,6 +105,7 @@ export class RuntimeHostSessionObserver { sessionId: string, outcome: "completed" | "abandoned", ) => void | Promise; + readonly #recoverConnectionClosed: boolean; readonly #now: () => number; #closed = false; @@ -117,6 +120,7 @@ export class RuntimeHostSessionObserver { deps.emitAgentGraphChanged ?? (() => undefined); this.#onWatchedTurnFinished = deps.onWatchedTurnFinished ?? (() => undefined); + this.#recoverConnectionClosed = deps.recoverConnectionClosed ?? false; this.#now = deps.now ?? Date.now; } @@ -369,7 +373,16 @@ export class RuntimeHostSessionObserver { ); } } catch (error) { - if (!state.closing) this.#publishSubscriptionFailure(state, error); + if (state.closing) return; + if ( + this.#recoverConnectionClosed && + error instanceof RuntimeHostSubscriptionError && + error.reason === "connection_closed" + ) { + void this.#closeState(state); + return; + } + this.#publishSubscriptionFailure(state, error); } } diff --git a/apps/desktop/src/main/runtime-host-skills-ipc-main.ts b/apps/desktop/src/main/runtime-host-skills-ipc-main.ts index 0626a25fee..3ec3aa9b7f 100644 --- a/apps/desktop/src/main/runtime-host-skills-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-skills-ipc-main.ts @@ -1,4 +1,4 @@ -import type { ipcMain as electronIpcMain } from "electron"; +import type { ChatDefaultPermissionMode } from "@maka/core"; import { resolveSkillDiscoveryPaths, scanSkillsWithDiagnostics, @@ -29,17 +29,22 @@ import type { DesktopSkillCatalogSnapshot, } from "./runtime-host-client.js"; import { resolveSkillOpenPath } from "./skill-open-path.js"; +import { + handleReconnectableRead, + type ReconnectableReadIpcMain, +} from "./ipc-reconnect-policy.js"; const MAX_REVISION_ATTEMPTS = 3; type MainWindowController = ReturnType; interface RuntimeHostSkillsIpcDeps { - readonly ipcMain: Pick; + readonly ipcMain: ReconnectableReadIpcMain; readonly client: DesktopRuntimeHostClient; readonly workspaceRoot: string; readonly mainWindowController: MainWindowController; readonly getCurrentProjectRoot: () => Promise; + readonly getDefaultPermissionMode: () => Promise; readonly openPath: (path: string) => Promise; } @@ -73,7 +78,8 @@ export function registerRuntimeHostSkillsIpc( ); }); - deps.ipcMain.handle( + handleReconnectableRead( + deps.ipcMain, "skills:listInvocable", async (_event, sessionId?: unknown, newSessionContext?: unknown) => { const target = @@ -85,6 +91,7 @@ export function registerRuntimeHostSkillsIpc( collaborationMode: normalizeNewSessionCollaborationMode(newSessionContext) ?? "agent", + permissionMode: await deps.getDefaultPermissionMode(), }; return (await deps.client.listInvocableSkills(target)).map( (item): InvocableSkillEntry => ({ ...item }), diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index e0f9c0778f..fa7065e3a4 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -391,6 +391,7 @@ export interface MakaBridge { }; projects: { list(): Promise; + subscribeChanges(handler: () => void): () => void; add(): Promise< { ok: true; project: ProjectRecord; path: string } | { ok: false; reason: 'cancelled' } >; @@ -458,6 +459,11 @@ export interface MakaBridge { test(slug: string, opts?: { model?: string }): Promise; fetchModels(slug: string): Promise; hasSecret(slug: string): Promise; + getRequestHeaders(slug: string): Promise; + setRequestHeaders( + slug: string, + headers: readonly import('@maka/core').RequestHeaderUpdate[], + ): Promise; subscribeEvents(handler: (event: ConnectionEvent) => void): () => void; }; mcp: { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index f95d3908bf..c4503f4430 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -427,6 +427,11 @@ const makaBridge = { list(): Promise { return ipcRenderer.invoke('projects:list'); }, + subscribeChanges(handler: () => void): () => void { + const listener = () => handler(); + ipcRenderer.on('projects:changed', listener); + return () => ipcRenderer.off('projects:changed', listener); + }, add(): Promise< { ok: true; project: ProjectRecord; path: string } | { ok: false; reason: 'cancelled' } > { @@ -560,6 +565,15 @@ const makaBridge = { hasSecret(slug: string): Promise { return ipcRenderer.invoke('connections:hasSecret', slug); }, + getRequestHeaders(slug: string): Promise { + return ipcRenderer.invoke('connections:getRequestHeaders', slug); + }, + setRequestHeaders( + slug: string, + headers: readonly import('@maka/core').RequestHeaderUpdate[], + ): Promise { + return ipcRenderer.invoke('connections:setRequestHeaders', slug, headers); + }, subscribeEvents(handler: (event: ConnectionEvent) => void): () => void { const listener = (_event: Electron.IpcRendererEvent, payload: ConnectionEvent) => handler(payload); ipcRenderer.on('connections:event', listener); diff --git a/apps/desktop/src/renderer/agent-graph-panel.tsx b/apps/desktop/src/renderer/agent-graph-panel.tsx index 93b9b4b62a..7562d44ead 100644 --- a/apps/desktop/src/renderer/agent-graph-panel.tsx +++ b/apps/desktop/src/renderer/agent-graph-panel.tsx @@ -6,7 +6,10 @@ import type { } from '@maka/runtime'; import { IconButton } from '@maka/ui'; import { ICON_SIZE, ChevronDown } from '@maka/ui/icons'; +import { Banner } from '@astryxdesign/core/Banner'; import { Button } from '@astryxdesign/core/Button'; +import { EmptyState } from '@astryxdesign/core/EmptyState'; +import { Spinner } from '@astryxdesign/core/Spinner'; type GraphPanelCopy = { title: string; @@ -257,29 +260,37 @@ export function AgentGraphPanel(props: { {!collapsed ? (
{stopError ? ( -

- {copy.stopFailed} -

+ ) : null} - {loading && !snapshot ?

{copy.loading}

: null} + {loading && !snapshot ? ( + + ) : null} {error ? ( -

- {copy.loadFailed}{' '} -

); } diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 1f7a8bd3ad..0e8a923c61 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -13,15 +13,15 @@ import type { PlanReminder, QuoteRef, SessionSummary, + SlashCommandIdForSurface, UiLocale, UiLocalePreference, } from '@maka/core'; import { collapseSessionRevisions, isLinkedSubagentSession, - parseGraphCommand, - parseSwarmCommand, resolveUiLocale, + slashCommandsForSurface, } from '@maka/core'; import { hasSettledInitialOnboarding } from '@maka/core/onboarding-milestone'; import { @@ -29,6 +29,7 @@ import { DailyReviewPage, ChatSurfaceLayout, type ComposerHandle, + type ComposerSendMetadata, type ComposerSlashCommandOption, type MakaUriDest, MakaUriContext, @@ -52,10 +53,16 @@ import { getSharedUiCopy, reconcileInteractions, } from '@maka/ui'; -import { MessageCircleQuestion } from '@maka/ui/icons'; +import { GitBranch, MessageCircleQuestion, Minimize2, Network } from '@maka/ui/icons'; import { useKeyboardHelp } from './keyboard-help'; import { useCommandPalette } from './command-palette'; import { ChatMessageSurface } from './chat-message-surface'; +import { useTaskSubmissionReadiness } from './use-task-submission-readiness'; +import { + deriveTaskReadinessNotice, + isTaskSubmissionHardBlocked, + resolveTaskReadinessModelTarget, +} from './task-readiness-notice'; import { deriveWorkspaceReadinessRecovery } from './workspace-readiness-recovery'; import { LiveTurnReconciler } from './live-turn-reconciler'; import { useAppShellSessionUiReads } from './use-app-shell-session-ui-reads'; @@ -71,7 +78,6 @@ import { findPreferredSideChatWorkbarTab, terminalRefFromWorkbarTab, terminalSessionWorkbarTabId, - staticSessionWorkbarTabId, } from './session-workbar-tabs'; import { consumeCompanionInitialPrompt, @@ -80,10 +86,8 @@ import { removeStagedCompanionQuote, stageCompanionQuote, } from './quote-companion-panel-state'; -import { - parseSideChatCommand, - sideChatTitleFromPrompt, -} from './side-chat-command'; +import { sideChatTitleFromPrompt } from './side-chat-command'; +import { parseDesktopSlashCommand } from './desktop-slash-command'; import { applyCompanionForkVisibilityEvent, reconcileCompanionForkVisibility, @@ -181,7 +185,6 @@ import { useShellChatModel } from './use-shell-chat-model'; import { useShellLiveTurn } from './use-shell-live-turn'; import { useShellLayout } from './use-shell-layout'; import { useShellResume } from './use-shell-resume'; -import { filterUserVisibleArtifacts } from './artifact-visibility'; import { recoverOrphanedCompanionCopies } from './quote-companion-core'; import { useSideConversationWorkspace } from './use-side-conversation-workspace'; @@ -732,6 +735,9 @@ function AppShellContent({ // live in useShellChatModel (pure derivation of the snapshot + active session); // openSettingsSection is injected so the notice can wrap the derived click // target. + const activeSessionSendOutcome = activeSession + ? onboarding.snapshot?.sessionSendOutcomes[activeSession.id] + : undefined; const { chatModelChoices, activeConnection, @@ -752,9 +758,7 @@ function AppShellContent({ uiLocale, connections, snapshotChoices: onboarding.snapshot?.chatModelChoices, - sessionSendOutcome: activeSession - ? onboarding.snapshot?.sessionSendOutcomes[activeSession.id] - : undefined, + sessionSendOutcome: activeSessionSendOutcome, defaultConnection, activationCandidate: onboardingActivationCandidate, activeSession, @@ -1324,9 +1328,6 @@ function AppShellContent({ pinWorkbarTab, openWorkbarLauncher, } = useShellLayout(); - const workbarPanelsStateRef = useRef(workbarPanelsState); - workbarPanelsStateRef.current = workbarPanelsState; - const revealWorkbarLauncher = useCallback(() => { setWorkbarCollapsed(false); openWorkbarLauncher('right'); @@ -1369,75 +1370,43 @@ function AppShellContent({ (initialPrompt?: string) => openNewSideConversation('right', initialPrompt), [openNewSideConversation], ); - const openSideConversationRef = useRef(openSideConversation); - openSideConversationRef.current = openSideConversation; - const sideChatSlashCommands = useMemo< - readonly ComposerSlashCommandOption[] - >( - () => - activeId - ? [ - { - id: 'side', - name: workbarCopy.sideChat, - description: workbarCopy.launcher.sideChat, - keywords: ['side', 'btw', '侧聊', '追问'], - Icon: MessageCircleQuestion, - onSelect: () => openSideConversationRef.current(), - }, - ] - : [], - [activeId, workbarCopy], + const desktopSlashCommands = useMemo( + () => { + const streaming = turnActive || activeStreamingLive; + const availableCommands = slashCommandsForSurface('desktop').filter( + ({ id, session }) => + (session === 'none' || Boolean(activeId)) + && !(streaming && id === 'compact'), + ); + const presentation: Record< + SlashCommandIdForSurface<'desktop'>, + Omit + > = { + compact: { + ...shellCopy.slashCommands.compact, + keywords: ['compact', 'context', '压缩', '上下文'], + Icon: Minimize2, + }, + side: { + ...shellCopy.slashCommands.side, + keywords: ['side', 'btw', '侧聊', '追问'], + Icon: MessageCircleQuestion, + }, + swarm: { + ...shellCopy.slashCommands.swarm, + keywords: ['swarm', 'multi-agent', '多智能体'], + Icon: Network, + }, + graph: { + ...shellCopy.slashCommands.graph, + keywords: ['graph', 'agent graph', '智能体图'], + Icon: GitBranch, + }, + }; + return availableCommands.map(({ id }) => ({ id, ...presentation[id] })); + }, + [activeId, activeStreamingLive, shellCopy.slashCommands, turnActive], ); - useEffect(() => { - return window.maka.artifacts.subscribeChanges((event) => { - if ( - event.reason !== 'created' || - event.sessionId !== activeIdRef.current || - navSelectionRef.current.section !== 'sessions' - ) { - return; - } - const expectedSessionId = event.sessionId; - void window.maka.artifacts - .get(expectedSessionId, event.artifactId) - .then((artifact) => { - if ( - !artifact || - activeIdRef.current !== expectedSessionId || - navSelectionRef.current.section !== 'sessions' || - filterUserVisibleArtifacts([artifact]).length === 0 - ) { - return; - } - const filesTabId = staticSessionWorkbarTabId('files'); - const current = workbarPanelsStateRef.current; - const placement = current.right.tabs.some( - (tab) => tab.id === filesTabId, - ) - ? 'right' - : current.bottom.tabs.some((tab) => tab.id === filesTabId) - ? 'bottom' - : 'right'; - openDynamicWorkbarTab( - { - id: filesTabId, - kind: 'files', - preview: true, - }, - placement, - ); - if (placement === 'right') setWorkbarCollapsed(false); - else setBottomPanelOpen(true); - }) - .catch(() => {}); - }); - }, [ - openDynamicWorkbarTab, - setBottomPanelOpen, - setWorkbarCollapsed, - ]); - const openSideConversationWithQuote = useCallback( (quote: QuoteRef) => { if (!activeId) return; @@ -1770,6 +1739,20 @@ function AppShellContent({ }, onSelectNoProject: selectNoProject, }; + const taskReadinessRequest = { + ...resolveTaskReadinessModelTarget(activeSession, activeSessionSendOutcome, newChatModel), + cwd: activeSession?.cwd ?? projectInfo?.projectPath, + }; + const taskReadiness = useTaskSubmissionReadiness( + taskReadinessRequest, + onboarding.snapshot, + ); + const taskReadinessNotice = deriveTaskReadinessNotice(taskReadiness.snapshot, uiLocale); + const ignoreTaskReadinessModelTarget = + activeSession !== undefined && activeSessionSendOutcome?.kind !== 'blocked'; + const taskSubmissionHardBlocked = isTaskSubmissionHardBlocked(taskReadiness.snapshot, { + ignoreModelTarget: ignoreTaskReadinessModelTarget, + }); // The titlebar names the directory the ACTIVE session runs in, so it reads // the same projected project state the picker does — `projectInfo` already // resolves to the session's own cwd once a session owns it. @@ -1818,6 +1801,9 @@ function AppShellContent({ projectPath: projectInfo?.projectPath, newSessionModel: newChatModel, newSessionCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent', + // Refresh only; Desktop Main re-reads the authoritative default before + // constructing the Runtime Host preview target. + newSessionPermissionMode: defaultPermissionMode, }); const { applyE2eFixture } = useStableActions(createAppShellE2eFixtureActions, { @@ -1850,6 +1836,7 @@ function AppShellContent({ activeIdRef, addPendingSessionAction, captureComposerImportOwner, + checkTaskSubmissionReadiness: taskSubmissionReadyAtSend, clearPendingSessionAction, isNewChatSendSurfaceActive, isShellSurfaceOwnerActive, @@ -1913,17 +1900,22 @@ function AppShellContent({ upsertSessionSummary, }); + async function taskSubmissionReadyAtSend(): Promise { + const snapshot = await taskReadiness.checkNow(); + return !isTaskSubmissionHardBlocked(snapshot, { + ignoreModelTarget: ignoreTaskReadinessModelTarget, + }); + } + async function sendWithAttachments( text: string, - metadata?: { workspaceFileReferences?: readonly WorkspaceFileReferencePosition[] }, + metadata?: ComposerSendMetadata, ): Promise { const revision = revisionDraftRef.current; const revisionSend = Boolean( revision && activeIdRef.current === revision.draftSessionId, ); - const sideChatCommand = parseSideChatCommand(text); - const swarmCommand = parseSwarmCommand(text); - const graphCommand = parseGraphCommand(text); + const slashCommand = parseDesktopSlashCommand(text); if ( revisionSend && revision && @@ -1940,13 +1932,13 @@ function AppShellContent({ toastApi.info(actionCopy.revisionUnavailableTitle, actionCopy.revisionAttachmentsUnsupported); return false; } - if (text.trim() === '/compact' || sideChatCommand || swarmCommand || graphCommand) { + if (slashCommand) { toastApi.info(actionCopy.revisionUnavailableTitle, actionCopy.revisionCommandUnsupported); return false; } if (!(await prepareRevisionSend(text))) return false; } - if (text.trim() === '/compact') { + if (slashCommand?.kind === 'compact') { const sessionId = activeIdRef.current; if (!sessionId) return true; try { @@ -1965,7 +1957,7 @@ function AppShellContent({ return false; } } - if (sideChatCommand) { + if (slashCommand?.kind === 'side') { if (!activeIdRef.current) { toastApi.info( shellCopy.sideChatUnavailableTitle, @@ -1984,10 +1976,11 @@ function AppShellContent({ ); return false; } - openSideConversation(sideChatCommand.prompt || undefined); + openSideConversation(slashCommand.command.prompt || undefined); return true; } - if (swarmCommand) { + if (slashCommand?.kind === 'swarm') { + const swarmCommand = slashCommand.command; if (swarmCommand.kind === 'status') { const active = activeIdRef.current ? (activeSessionForView?.orchestrationMode ?? 'default') === 'swarm' @@ -2029,7 +2022,8 @@ function AppShellContent({ if (ok !== false && quotes) clearQuotes(); return ok; } - if (graphCommand) { + if (slashCommand?.kind === 'graph') { + const graphCommand = slashCommand.command; if (graphCommand.kind === 'status') { const active = activeIdRef.current ? (activeSessionForView?.orchestrationMode ?? 'default') === 'graph' @@ -2115,6 +2109,15 @@ function AppShellContent({ } } + function submitWhileStreaming( + text: string, + metadata?: ComposerSendMetadata, + ): Promise { + return parseDesktopSlashCommand(text) + ? sendWithAttachments(text, metadata) + : steerWithText(text); + } + const stop = createAppShellStopAction({ uiLocale, activeIdRef, @@ -2165,11 +2168,12 @@ function AppShellContent({ }, [activeId, activeStreamingMessageId, messages, settleAssistantStreaming]); const hasModalOpen = helpOpen || paletteOpen || searchModalOpen || externalImportOpen; + const shellObscured = hasModalOpen || settingsOpen; useEffect(() => { const handleWorkbarShortcut = (event: KeyboardEvent) => { if ( - hasModalOpen || + shellObscured || navSelectionRef.current.section !== 'sessions' || !activeId ) { @@ -2203,7 +2207,7 @@ function AppShellContent({ }; window.addEventListener('keydown', handleWorkbarShortcut, true); return () => window.removeEventListener('keydown', handleWorkbarShortcut, true); - }, [activeId, hasModalOpen, requestOpenWorkbarTab]); + }, [activeId, requestOpenWorkbarTab, shellObscured]); useAppShellNavRefSync({ navSelection, @@ -2516,51 +2520,57 @@ function AppShellContent({ one frame-level hit-test surface. */}
- sessionSideNavHandleRef.current?.getCollapseState()?.toggle()} - sidebarToggleHidden={settingsOpen} - onOpenSearchModal={() => setSearchModalOpen(true)} - /> - {/* Only a session has an identity to state. The other views name - themselves in the nav column they are selected from, and the - new-task surface still shows its project in the composer's - WorkspacePicker — which stops rendering at the exact moment this - takes over, when the first message creates the session. */} - {/* `activeSessionForView`, not `activeSession`: opening or creating a - session runs a few hundred ms on a placeholder record while the real - summary loads, and the name this replaced (the context layer's) was - showing through that window. Hung on the real record alone, 新任务 - was named nowhere for the length of it. */} - {navSelection.section === 'sessions' && activeSessionForView && ( - { - void sessionRowActionHandlers.renameSession(activeSessionForView.id, name); - }} - project={ - titlebarProjectName - ? { name: titlebarProjectName, onOpenFolder: () => void openProjectFolder() } - : undefined - } - parentSession={titlebarParentSession} - /> - )} - {!VIEWS_WITHOUT_WORKSPACE_ACTIONS.has(agentsView) && ( - + {/* Settings owns the full window chrome. Keep this empty header mounted + as the frameless window's drag authority, but remove every control + and identity belonging to the obscured session shell. */} + {!settingsOpen && ( + <> + sessionSideNavHandleRef.current?.getCollapseState()?.toggle()} + onOpenSearchModal={() => setSearchModalOpen(true)} + /> + {/* Only a session has an identity to state. The other views name + themselves in the nav column they are selected from, and the + new-task surface still shows its project in the composer's + WorkspacePicker — which stops rendering at the exact moment this + takes over, when the first message creates the session. */} + {/* `activeSessionForView`, not `activeSession`: opening or creating a + session runs a few hundred ms on a placeholder record while the real + summary loads, and the name this replaced (the context layer's) was + showing through that window. Hung on the real record alone, 新任务 + was named nowhere for the length of it. */} + {navSelection.section === 'sessions' && activeSessionForView && ( + { + void sessionRowActionHandlers.renameSession(activeSessionForView.id, name); + }} + project={ + titlebarProjectName + ? { name: titlebarProjectName, onOpenFolder: () => void openProjectFolder() } + : undefined + } + parentSession={titlebarParentSession} + /> + )} + {!VIEWS_WITHOUT_WORKSPACE_ACTIONS.has(agentsView) && ( + + )} + )}
) : null} - {!hasModalOpen && !settingsOpen && ( + {!shellObscured && ( (undefined); const pendingArtifactListRetryRef = useRef(false); const pendingArtifactActionRef = useRef(null); - const pendingCreatedArtifactIdRef = useRef(null); artifactPaneSessionIdRef.current = sessionId; @@ -104,7 +103,6 @@ export function ArtifactPane(props: { artifactListRequestSeqRef.current += 1; pendingArtifactListRetryRef.current = false; pendingArtifactActionRef.current = null; - pendingCreatedArtifactIdRef.current = null; }; }, []); @@ -156,9 +154,6 @@ export function ArtifactPane(props: { // (one session's worth) and the metadata is already in memory on main. const unsubscribe = window.maka.artifacts.subscribeChanges((event) => { if (event.sessionId === sessionId) { - if (event.reason === 'created') { - pendingCreatedArtifactIdRef.current = event.artifactId; - } void refresh(); } }); @@ -179,17 +174,6 @@ export function ArtifactPane(props: { // 已删除墓碑记录保持可选,用于展示明确失败态;只有选中 id 彻底消失时才回退到最新 live artifact。 useEffect(() => { - const pendingId = pendingCreatedArtifactIdRef.current; - if (pendingId && recordsSessionId === sessionId) { - if (activeRecords.some((record) => record.id === pendingId)) { - pendingCreatedArtifactIdRef.current = null; - if (selectedId !== pendingId) setSelectedId(pendingId); - return; - } - if (records.some((record) => record.id === pendingId)) { - pendingCreatedArtifactIdRef.current = null; - } - } if (activeRecords.length === 0) { if (selectedId !== null) setSelectedId(null); return; @@ -197,7 +181,7 @@ export function ArtifactPane(props: { if (!selectedId || !activeRecords.some((record) => record.id === selectedId)) { setSelectedId(preferredArtifactSelectionId(activeRecords)); } - }, [activeRecords, records, recordsSessionId, selectedId, sessionId]); + }, [activeRecords, selectedId]); const previewRecord = useMemo( () => view.kind === 'preview' diff --git a/apps/desktop/src/renderer/artifact-preview-registry-shell.tsx b/apps/desktop/src/renderer/artifact-preview-registry-shell.tsx index 9f688a6eb5..f81ef32164 100644 --- a/apps/desktop/src/renderer/artifact-preview-registry-shell.tsx +++ b/apps/desktop/src/renderer/artifact-preview-registry-shell.tsx @@ -27,6 +27,7 @@ import { useEffect, useState } from 'react'; import type { ArtifactDescriptor } from '@maka/core'; import { Button, useUiLocale } from '@maka/ui'; +import { Banner } from '@astryxdesign/core/Banner'; import { Spinner } from '@astryxdesign/core/Spinner'; import { type ArtifactPreviewInput, @@ -106,39 +107,42 @@ export function UnsupportedArtifactPreview(props: { const catalog = getArtifactCopy(locale); const copy = describeUnsupportedReason(props.reason, catalog); return ( -
-
{copy.title}
-

{copy.description}

-
-
-
{catalog.registry.name}
-
{props.input.name || catalog.registry.unnamed}
-
- {props.input.mimeType && ( -
-
{catalog.registry.type}
-
{props.input.mimeType}
-
- )} -
-
{catalog.registry.size}
-
{formatPreviewSize(props.input.sizeBytes, locale)}
-
-
- {/* @kenji review @msg 9cf1ca7a — only render the button when a - real handler is provided. We deliberately do NOT render a - disabled button here: a disabled button suggests the action - is available "eventually", which is misleading for surfaces - that have no path-open IPC. */} - {props.onShowInFolder && ( + +

{copy.description}

+
+
+
{catalog.registry.name}
+
{props.input.name || catalog.registry.unnamed}
+
+ {props.input.mimeType ? ( +
+
{catalog.registry.type}
+
{props.input.mimeType}
+
+ ) : null} +
+
{catalog.registry.size}
+
{formatPreviewSize(props.input.sizeBytes, locale)}
+
+
+ + )} + endContent={props.onShowInFolder ? (
+ ) : undefined} + /> ); } diff --git a/apps/desktop/src/renderer/artifact-preview.tsx b/apps/desktop/src/renderer/artifact-preview.tsx index 1a93221934..dc18044077 100644 --- a/apps/desktop/src/renderer/artifact-preview.tsx +++ b/apps/desktop/src/renderer/artifact-preview.tsx @@ -44,6 +44,7 @@ import { syntaxLanguageForPath, useUiLocale, } from '@maka/ui'; +import { Banner } from '@astryxdesign/core/Banner'; import { CodeBlock } from '@astryxdesign/core/CodeBlock'; import { Spinner } from '@astryxdesign/core/Spinner'; import { RegistryArtifactPreview } from './artifact-preview-registry-shell'; @@ -342,10 +343,14 @@ function FailureCard(props: { description: string; }) { return ( -
-
{props.title}
-

{props.description}

-
+ ); } diff --git a/apps/desktop/src/renderer/artifact-visibility.ts b/apps/desktop/src/renderer/artifact-visibility.ts index 03cc0fca58..3cc937cdbb 100644 --- a/apps/desktop/src/renderer/artifact-visibility.ts +++ b/apps/desktop/src/renderer/artifact-visibility.ts @@ -1,8 +1,8 @@ import type { ArtifactDescriptor, ArtifactSource } from '@maka/core'; const USER_VISIBLE_ARTIFACT_SOURCES = { - tool_result: true, - tool_result_archive: true, + tool_result: false, + tool_result_archive: false, synthesis_cache_block: false, history_compact_block: false, history_compact_source: false, diff --git a/apps/desktop/src/renderer/browser-panel.tsx b/apps/desktop/src/renderer/browser-panel.tsx index 8f5454a5a2..9cdf4fb791 100644 --- a/apps/desktop/src/renderer/browser-panel.tsx +++ b/apps/desktop/src/renderer/browser-panel.tsx @@ -24,6 +24,7 @@ import { useUiLocale, } from '@maka/ui'; import { EmptyState } from '@astryxdesign/core/EmptyState'; +import { Toolbar } from '@astryxdesign/core/Toolbar'; import { Tooltip } from '@astryxdesign/core/Tooltip'; import { getBrowserCopy, type BrowserCopy } from './locales/browser-copy'; @@ -145,73 +146,82 @@ export function BrowserPanel(props: { sessionId: string; hidden: boolean }) { return (
-
- - } - variant="ghost" - size="sm" - isDisabled={!state.canGoBack} - onClick={() => void window.maka.browser.back(sessionId)} - /> - - - } - variant="ghost" - size="sm" - isDisabled={!state.canGoForward} - onClick={() => void window.maka.browser.forward(sessionId)} - /> - - - : } - variant="ghost" - size="sm" - isDisabled={!state.hasPage && !state.loading} - onClick={() => - state.loading ? void window.maka.browser.stop(sessionId) : void window.maka.browser.reload(sessionId) - } - /> - -
- { - editingRef.current = true; - }} - onBlur={() => { - editingRef.current = false; - setAddress(state.url); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.currentTarget.blur(); - go(); - } - }} - /> -
- - } - variant="ghost" - size="sm" - onClick={() => void window.maka.browser.close(sessionId)} - /> - -
+ + + } + variant="ghost" + size="sm" + isDisabled={!state.canGoBack} + onClick={() => void window.maka.browser.back(sessionId)} + /> + + + } + variant="ghost" + size="sm" + isDisabled={!state.canGoForward} + onClick={() => void window.maka.browser.forward(sessionId)} + /> + + + : } + variant="ghost" + size="sm" + isDisabled={!state.hasPage && !state.loading} + onClick={() => + state.loading ? void window.maka.browser.stop(sessionId) : void window.maka.browser.reload(sessionId) + } + /> + +
+ { + editingRef.current = true; + }} + onBlur={() => { + editingRef.current = false; + setAddress(state.url); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.currentTarget.blur(); + go(); + } + }} + /> +
+
+ )} + endContent={( + + } + variant="ghost" + size="sm" + onClick={() => void window.maka.browser.close(sessionId)} + /> + + )} + />
{!state.hasPage && ( void; showOnboardingHero: boolean; onboardingState: OnboardingState | undefined; isOnboardingLoading: boolean; @@ -62,6 +65,8 @@ export function ChatMessageSurface({ activeSessionId, sessionHealthNotice, workspaceReadinessRecovery, + taskReadinessNotice, + onTaskReadinessAction, showOnboardingHero, onboardingState, isOnboardingLoading, @@ -151,6 +156,22 @@ export function ChatMessageSurface({ deepResearchRun={deepResearchRun} emptyOverride={emptyOverride} /> + {taskReadinessNotice && ( +
+ } /> +
+ )} {workspaceReadinessRecovery && (
- {copy.loadingWorkbar} +
diff --git a/apps/desktop/src/renderer/desktop-slash-command.ts b/apps/desktop/src/renderer/desktop-slash-command.ts new file mode 100644 index 0000000000..bae3d3621f --- /dev/null +++ b/apps/desktop/src/renderer/desktop-slash-command.ts @@ -0,0 +1,24 @@ +import { + parseGraphCommand, + parseSwarmCommand, + type ParsedGraphCommand, + type ParsedSwarmCommand, +} from '@maka/core'; +import { parseSideChatCommand, type SideChatCommand } from './side-chat-command.js'; + +export type DesktopSlashCommand = + | { kind: 'compact' } + | { kind: 'side'; command: SideChatCommand } + | { kind: 'graph'; command: ParsedGraphCommand } + | { kind: 'swarm'; command: ParsedSwarmCommand }; + +/** Classify input with the same parsers that own Desktop command execution. */ +export function parseDesktopSlashCommand(input: string): DesktopSlashCommand | null { + if (input.trim() === '/compact') return { kind: 'compact' }; + const side = parseSideChatCommand(input); + if (side) return { kind: 'side', command: side }; + const graph = parseGraphCommand(input); + if (graph) return { kind: 'graph', command: graph }; + const swarm = parseSwarmCommand(input); + return swarm ? { kind: 'swarm', command: swarm } : null; +} diff --git a/apps/desktop/src/renderer/external-session-import-dialog.tsx b/apps/desktop/src/renderer/external-session-import-dialog.tsx index a434a8686d..2a03b4f2fa 100644 --- a/apps/desktop/src/renderer/external-session-import-dialog.tsx +++ b/apps/desktop/src/renderer/external-session-import-dialog.tsx @@ -4,7 +4,9 @@ import { Button } from '@astryxdesign/core/Button'; import { CheckboxInput } from '@astryxdesign/core/CheckboxInput'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { EmptyState } from '@astryxdesign/core/EmptyState'; +import { Item } from '@astryxdesign/core/Item'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; +import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl'; import { HStack, VStack } from '@astryxdesign/core/Stack'; import { Text } from '@astryxdesign/core/Text'; import { @@ -226,24 +228,24 @@ export function ExternalSessionImportDialog(props: { {copy.description} - {adapterIds.length > 0 && ( + {adapterIds.length > 0 && adapterId !== null && (
- - {copy.sourceLabel} - -
+ setAdapterId(value)} + > {adapterIds.map((id) => ( - + value={id} + label={sourceLabel(id, copy.codex)} + /> ))} -
+
)} @@ -275,7 +277,6 @@ export function ExternalSessionImportDialog(props: { )} {noSource && ( - /* Section-local absence (DESIGN.md §10 tier 1): title only. */ )} @@ -314,45 +315,35 @@ export function ExternalSessionImportDialog(props: { )} {catalogEmpty && ( - /* Section-local absence (DESIGN.md §10 tier 1): title only. */ )} {catalog.sessions.length > 0 && (
{catalog.sessions.map((session) => { const timestamp = session.updatedAt ?? session.createdAt; + const meta = [ + session.cwd, + timestamp !== undefined ? dateFormatter.format(timestamp) : null, + session.archived ? copy.archived : null, + ].filter(Boolean).join(' · '); return ( - + /> ); })}
diff --git a/apps/desktop/src/renderer/keyboard-help.tsx b/apps/desktop/src/renderer/keyboard-help.tsx index a0e58a62b6..98b11d98b7 100644 --- a/apps/desktop/src/renderer/keyboard-help.tsx +++ b/apps/desktop/src/renderer/keyboard-help.tsx @@ -8,6 +8,7 @@ import { useState } from 'react'; import { ICON_SIZE, Keyboard } from '@maka/ui/icons'; import { useUiLocale } from '@maka/ui'; +import { Heading } from '@astryxdesign/core/Heading'; import { Kbd } from '@astryxdesign/core/Kbd'; import { useHotkeys } from '@astryxdesign/core/hooks'; import { @@ -92,7 +93,7 @@ export function KeyboardHelpModal(props: {
{copy.sections.map((section) => (
-

{section.heading}

+ {section.heading}
{section.rows.map((row) => (
diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index 92fe08a79b..b9760d7bd6 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -99,34 +99,21 @@ export interface DesktopConversationCopy { empty: string; /** The panel-empty (tier 2) sentence under `empty`. */ emptyHelp: string; - sourceLabel: string; - branchSource: string; - unstagedSource: string; - stagedSource: string; - lastTurnSource: string; notGitRepository: string; workspaceUnavailable: string; unbornRepository: string; gitFailed: string; invalidBaseBranch: string; truncated: string; - stageFile: string; - unstageFile: string; - mutationFailed: string; - snapshotChanged: string; - revertFile: string; - revertTitle: string; - revertDescription(path: string): string; - revertConfirm: string; - cancel: string; showMore(remaining: number): string; - summary(files: number, additions: number, deletions: number): string; - comparison(base: string, current: string): string; - workingTree(branch: string): string; + hiddenLines(count: number): string; + changedFiles(count: number): string; + addedLines(count: number): string; + deletedLines(count: number): string; + added(count: number): string; + deleted(count: number): string; loadFailed: string; retry: string; - refresh: string; - diffCount(count: number): string; }; terminalPanel: { ariaLabel: string; @@ -373,7 +360,7 @@ const COPY = { workbar: { ariaLabel: '会话工作栏', sectionsAriaLabel: '会话工作栏标签', - review: '审阅', + review: '变更', terminal: '终端', terminalNumbered: (index) => `终端 ${index}`, tasks: '任务', @@ -395,7 +382,7 @@ const COPY = { closeOthers: '关闭其他标签', closeToRight: '关闭右侧标签', launcher: { - review: '审阅当前会话产生的文件差异', + review: '查看当前 Git 工作区变化', terminal: '查看当前会话的终端运行和实时输出', tasks: '查看和维护当前会话的任务台账', browser: '打开内置浏览器并保留当前页面', @@ -405,39 +392,24 @@ const COPY = { }, }, reviewPanel: { - ariaLabel: '会话审阅', - empty: '当前会话还没有文件差异', - emptyHelp: '这个会话还没有可回顾的更改。', - sourceLabel: '审阅来源', - branchSource: '分支', - unstagedSource: '未暂存', - stagedSource: '已暂存', - lastTurnSource: '上一轮', + ariaLabel: 'Git 变更', + empty: '当前 Git 工作区没有变化', + emptyHelp: '提交、暂存或修改文件后,变化会显示在这里。', notGitRepository: '当前会话目录不是 Git 仓库', workspaceUnavailable: '当前会话目录已不可用', unbornRepository: 'Git 仓库还没有可比较的提交', gitFailed: '无法读取 Git 工作区变化', invalidBaseBranch: '选择的比较分支已不可用', truncated: '变化过多,仅显示前一部分文件', - stageFile: '暂存文件', - unstageFile: '取消暂存文件', - mutationFailed: '无法更新 Git 暂存区', - snapshotChanged: 'Git 变化已更新,请重试', - revertFile: '还原文件', - revertTitle: '还原未暂存变化?', - revertDescription: (path) => - `${path} 的未暂存变化会被移除;未跟踪文件会被删除。`, - revertConfirm: '确认还原', - cancel: '取消', showMore: (remaining) => `再显示 ${Math.min(20, remaining)} 个文件`, - summary: (files, additions, deletions) => - `${files} 个文件 · +${additions} · -${deletions}`, - comparison: (base, current) => `${current} 相对 ${base}`, - workingTree: (branch) => `${branch} 工作区`, - loadFailed: '无法读取会话差异', + hiddenLines: (count) => `另有 ${count} 行未显示`, + changedFiles: (count) => `${count} 个文件有变更`, + addedLines: (count) => `新增 ${count} 行`, + deletedLines: (count) => `删除 ${count} 行`, + added: (count) => `新增 ${count}`, + deleted: (count) => `删除 ${count}`, + loadFailed: '无法读取 Git 变化', retry: '重试', - refresh: '刷新审阅', - diffCount: (count) => `${count} 个差异`, }, terminalPanel: { ariaLabel: '会话终端', @@ -570,7 +542,7 @@ const COPY = { workbar: { ariaLabel: 'Conversation workbar', sectionsAriaLabel: 'Conversation workbar tabs', - review: 'Review', + review: 'Changes', terminal: 'Terminal', terminalNumbered: (index) => `Terminal ${index}`, tasks: 'Tasks', @@ -592,7 +564,7 @@ const COPY = { closeOthers: 'Close other tabs', closeToRight: 'Close tabs to the right', launcher: { - review: 'Review file diffs produced by this conversation', + review: 'View changes in the current Git workspace', terminal: 'Inspect terminal runs and live output for this conversation', tasks: 'View and maintain the task ledger for this conversation', browser: 'Open the embedded browser and keep the current page', @@ -602,40 +574,26 @@ const COPY = { }, }, reviewPanel: { - ariaLabel: 'Conversation review', - empty: 'No file diffs in this conversation yet', - emptyHelp: 'No changes to review for this session yet.', - sourceLabel: 'Review source', - branchSource: 'Branch', - unstagedSource: 'Unstaged', - stagedSource: 'Staged', - lastTurnSource: 'Last turn', + ariaLabel: 'Git changes', + empty: 'No changes in the current Git workspace', + emptyHelp: 'Committed, staged, and modified files appear here.', notGitRepository: 'This conversation directory is not a Git repository', workspaceUnavailable: 'This conversation directory is unavailable', unbornRepository: 'This Git repository has no commit to compare yet', gitFailed: 'Could not read Git workspace changes', invalidBaseBranch: 'The selected comparison branch is unavailable', truncated: 'Too many changes; showing the first files only', - stageFile: 'Stage file', - unstageFile: 'Unstage file', - mutationFailed: 'Could not update the Git index', - snapshotChanged: 'Git changes were refreshed; try again', - revertFile: 'Revert file', - revertTitle: 'Revert unstaged changes?', - revertDescription: (path) => - `Unstaged changes in ${path} will be removed; untracked files will be deleted.`, - revertConfirm: 'Revert', - cancel: 'Cancel', showMore: (remaining) => `Show ${Math.min(20, remaining)} more file${Math.min(20, remaining) === 1 ? '' : 's'}`, - summary: (files, additions, deletions) => - `${files} file${files === 1 ? '' : 's'} · +${additions} · -${deletions}`, - comparison: (base, current) => `${current} compared with ${base}`, - workingTree: (branch) => `${branch} working tree`, - loadFailed: 'Could not read conversation diffs', + hiddenLines: (count) => + `${count} more line${count === 1 ? '' : 's'} not shown`, + changedFiles: (count) => `${count} changed file${count === 1 ? '' : 's'}`, + addedLines: (count) => `${count} line${count === 1 ? '' : 's'} added`, + deletedLines: (count) => `${count} line${count === 1 ? '' : 's'} deleted`, + added: (count) => `${count} added`, + deleted: (count) => `${count} deleted`, + loadFailed: 'Could not read Git changes', retry: 'Retry', - refresh: 'Refresh review', - diffCount: (count) => `${count} diff${count === 1 ? '' : 's'}`, }, terminalPanel: { ariaLabel: 'Conversation terminal', diff --git a/apps/desktop/src/renderer/locales/settings-provider-copy.ts b/apps/desktop/src/renderer/locales/settings-provider-copy.ts index 4d15903ae7..73f67247ba 100644 --- a/apps/desktop/src/renderer/locales/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-provider-copy.ts @@ -46,6 +46,9 @@ const zhCopy = { deleteFailed: '删除模型连接失败', credentialReadFailed: '读取模型凭据状态失败', refreshFailed: '刷新模型连接失败', saveFailed: '保存模型连接失败', saveModelsFailed: '保存启用模型失败', modelKey: '模型密钥', pasteModelKey: '粘贴模型密钥', getModelKey: '获取模型密钥', saving: '保存中…', + advancedRequest: '高级请求设置', advancedRequestHelp: '为这个连接的 HTTP 请求添加请求头和额外 JSON 请求体。请求头值作为凭据保存在本机。', + requestHeaders: '自定义请求头', headerName: '请求头名称', headerValue: '请求头值', retainedHeaderValue: '保留已保存的值', addHeader: '添加请求头', removeHeader: '移除', noRequestHeaders: '未设置自定义请求头。', + extraRequestBody: '额外请求体(JSON)', extraRequestBodyHelp: '仅添加顶层字段;若与 Maka 生成的字段重名,请求会明确失败。', requestCustomizationInvalid: '请求设置无效', requestHeadersInvalidDetail: '请检查请求头名称和值。', requestBodyInvalidDetail: '请输入符合要求的 JSON 对象。', saveAdvancedRequest: '保存高级请求设置', configuredHeaders: (count: number) => `${count} 个请求头`, noAdvancedRequest: '未设置', oauthLoggedIn: 'OAuth 已登录', oauthLoading: 'OAuth 状态读取中', oauthUnknown: 'OAuth 状态未知', oauthWaiting: '等待 OAuth 登录', oauthLoggedInDetail: '该模型连接使用主进程保存的 OAuth access token;若请求提示需要重新登录,请到账号连接重新授权。', oauthLoadingDetail: '正在读取本机 OAuth 登录状态,读取完成前不会把未知状态显示成未登录。', @@ -112,7 +115,7 @@ const zhCopy = { }, }, panel: { - tabs: { recommended: '推荐', accounts: '账号', plans: '模型计划', api: 'API', aggregators: '聚合服务', local: '本地' }, + tabs: { all: '全部', recommended: '推荐', accounts: '账号', plans: '模型计划', api: 'API', aggregators: '聚合服务', local: '本地' }, loadFailed: '载入模型连接失败', loadingAria: '正在加载模型供应商', connections: '模型连接', retry: '点击重试。', empty: '还没有模型连接', emptyHelp: '从下方选择一种连接方式开始。', default: '默认', setDefault: '设为默认', setDefaultTitle: '让新对话默认使用这个连接', setDefaultPending: '设置中…', setDefaultFailed: '设为默认失败', addHelp: '选择账号登录、模型计划、API、聚合服务或本地运行时。', @@ -131,6 +134,9 @@ const zhCopy = { invalidSlug: '连接标识格式不正确', duplicateSlug: '连接标识已存在', cloudflareAccount: '请填写 Cloudflare Account ID', endpointRequired: '这个供应商需要填写服务地址', wiredLogin: '请到账号连接完成登录;登录成功后会自动创建模型连接。', unwiredLogin: '该账号登录暂未接入聊天发送;请先使用同一家厂商的模型密钥。', apiKeyPlaceholder: '输入或粘贴 API Key', cancel: '取消', wiredTitle: '使用账号连接登录', unwiredTitle: '账号登录暂未接入', + advancedRequest: '高级请求设置', expandAdvancedRequest: '展开高级请求设置', collapseAdvancedRequest: '收起高级请求设置', + requestHeaders: '自定义请求头', headerName: '请求头名称', headerValue: '请求头值', retainedHeaderValue: '保留已保存的值', addHeader: '添加请求头', removeHeader: '移除', noRequestHeaders: '未设置自定义请求头。', + extraRequestBody: '额外请求体(JSON)', extraRequestBodyHelp: '仅添加顶层字段;与 Maka 生成字段重名时会明确失败。请求头值将作为凭据保存在本机。', requestCustomizationInvalid: '请检查高级请求设置。', wiredDetail: '不要在这里手动添加;请回到模型连接页的账号连接完成登录,Maka 会自动创建并刷新模型连接。', unwiredDetail: '这类账号登录暂未接入聊天发送。当前请先使用同一家厂商的模型密钥。', slug: '连接标识', name: '显示名称', @@ -192,6 +198,9 @@ const enCopy: ProviderSettingsCopy = { deleteFailed: 'Failed to delete model connection', credentialReadFailed: 'Failed to read model credential status', refreshFailed: 'Failed to refresh model connection', saveFailed: 'Failed to save model connection', saveModelsFailed: 'Failed to save enabled models', modelKey: 'Model key', pasteModelKey: 'Paste model key', getModelKey: 'Get model key', saving: 'Saving…', + advancedRequest: 'Advanced request settings', advancedRequestHelp: 'Add HTTP headers and extra JSON request-body fields for this connection. Header values stay in the local credential vault.', + requestHeaders: 'Custom request headers', headerName: 'Header name', headerValue: 'Header value', retainedHeaderValue: 'Keep saved value', addHeader: 'Add header', removeHeader: 'Remove', noRequestHeaders: 'No custom request headers.', + extraRequestBody: 'Extra request body (JSON)', extraRequestBodyHelp: 'Adds top-level fields only. A collision with a Maka-generated field fails explicitly.', requestCustomizationInvalid: 'Invalid request settings', requestHeadersInvalidDetail: 'Check the request header names and values.', requestBodyInvalidDetail: 'Enter a valid JSON object that meets the requirements.', saveAdvancedRequest: 'Save advanced request settings', configuredHeaders: (count: number) => `${count} ${count === 1 ? 'header' : 'headers'}`, noAdvancedRequest: 'Not configured', oauthLoggedIn: 'OAuth signed in', oauthLoading: 'Reading OAuth status', oauthUnknown: 'OAuth status unavailable', oauthWaiting: 'Waiting for OAuth sign-in', oauthLoggedInDetail: 'This model connection uses an OAuth access token stored by the main process. If a request asks you to sign in again, reauthorize it under account connections.', oauthLoadingDetail: 'Reading the local OAuth status. An unknown state will not be shown as signed out.', @@ -256,7 +265,7 @@ const enCopy: ProviderSettingsCopy = { }, }, panel: { - tabs: { recommended: 'Recommended', accounts: 'Accounts', plans: 'Model plans', api: 'API', aggregators: 'Aggregators', local: 'Local' }, + tabs: { all: 'All', recommended: 'Recommended', accounts: 'Accounts', plans: 'Model plans', api: 'API', aggregators: 'Aggregators', local: 'Local' }, loadFailed: 'Failed to load model connections', loadingAria: 'Loading model providers', connections: 'Connections', retry: 'Select to retry.', empty: 'No model connections yet', emptyHelp: 'Choose a connection method below to begin.', default: 'Default', setDefault: 'Set as default', setDefaultTitle: 'New chats will use this connection', setDefaultPending: 'Setting…', setDefaultFailed: 'Could not set as default', addHelp: 'Choose account sign-in, a model plan, API, aggregator, or local runtime.', @@ -275,6 +284,9 @@ const enCopy: ProviderSettingsCopy = { invalidSlug: 'The connection identifier format is invalid', duplicateSlug: 'Connection identifier already exists', cloudflareAccount: 'Enter the Cloudflare Account ID', endpointRequired: 'This provider requires a service URL', wiredLogin: 'Complete sign-in under account connections. A model connection is created automatically afterward.', unwiredLogin: 'This account sign-in is not connected to chat yet. Use a model key from the same provider for now.', apiKeyPlaceholder: 'Enter or paste API key', cancel: 'Cancel', wiredTitle: 'Sign in with an account connection', unwiredTitle: 'Account sign-in is not connected', + advancedRequest: 'Advanced request settings', expandAdvancedRequest: 'Show advanced request settings', collapseAdvancedRequest: 'Hide advanced request settings', + requestHeaders: 'Custom request headers', headerName: 'Header name', headerValue: 'Header value', retainedHeaderValue: 'Keep saved value', addHeader: 'Add header', removeHeader: 'Remove', noRequestHeaders: 'No custom request headers.', + extraRequestBody: 'Extra request body (JSON)', extraRequestBodyHelp: 'Adds top-level fields only. A collision with a Maka-generated field fails explicitly. Header values stay in the local credential vault.', requestCustomizationInvalid: 'Check the advanced request settings.', wiredDetail: 'Do not add this provider manually here. Return to account connections and sign in; Maka creates and refreshes the model connection automatically.', unwiredDetail: 'This account sign-in is not connected to chat yet. Use a model key from the same provider for now.', slug: 'Connection identifier', name: 'Display name', diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index c64ea10952..6ff1ee1312 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -6,6 +6,7 @@ import { type PermissionMode, type ChatDefaultPermissionMode, type SettingsSection, + type SlashCommandIdForSurface, type ThinkingLevel, } from '@maka/core'; @@ -372,6 +373,10 @@ type ShellCopy = { newConversation: string; compactErrorTitle: string; compactErrorFallback: string; + slashCommands: Record, { + name: string; + description: string; + }>; sideChatUnavailableTitle: string; sideChatUnavailableDescription: string; sideChatContextPendingTitle: string; @@ -1048,6 +1053,12 @@ const SHELL_COPY_BY_LOCALE = { newConversation: '新建对话', compactErrorTitle: '压缩失败', compactErrorFallback: '对话暂时无法压缩,请稍后重试。', + slashCommands: { + compact: { name: '压缩上下文', description: '压缩旧历史并保留当前任务' }, + graph: { name: '使用 Graph', description: '查看、切换或单次运行 Graph' }, + side: { name: '打开侧聊', description: '在右侧开始一个具体话题' }, + swarm: { name: '使用 Swarm', description: '查看、切换或单次运行 Swarm' }, + }, sideChatUnavailableTitle: '暂时无法打开侧边对话', sideChatUnavailableDescription: '请先在主会话中发送一条消息,再使用 /side。', sideChatContextPendingTitle: '先处理待发送的上下文', @@ -1553,6 +1564,12 @@ const SHELL_COPY_BY_LOCALE = { newConversation: 'New conversation', compactErrorTitle: 'Compaction failed', compactErrorFallback: 'The conversation could not be compacted. Try again later.', + slashCommands: { + compact: { name: 'Compact context', description: 'Compact older history while preserving the current task' }, + graph: { name: 'Use Graph', description: 'Inspect, switch, or run Graph once' }, + side: { name: 'Open side chat', description: 'Start a specific topic in the side panel' }, + swarm: { name: 'Use Swarm', description: 'Inspect, switch, or run Swarm once' }, + }, sideChatUnavailableTitle: 'Side chat is not available yet', sideChatUnavailableDescription: 'Send a message in the main conversation before using /side.', diff --git a/apps/desktop/src/renderer/maka-tokens.css b/apps/desktop/src/renderer/maka-tokens.css index f6a5805168..cf3187b9d9 100644 --- a/apps/desktop/src/renderer/maka-tokens.css +++ b/apps/desktop/src/renderer/maka-tokens.css @@ -264,8 +264,6 @@ --destructive-wash-border: oklch(from var(--destructive) l c h / 0.24); --info-wash: oklch(from var(--info) l c h / 0.08); --info-wash-border: oklch(from var(--info) l c h / 0.24); - --warning-wash-strong: oklch(from var(--warning) l c h / 0.12); - --warning-wash-strong-border: oklch(from var(--warning) l c h / 0.40); --destructive-wash-strong: oklch(from var(--destructive) l c h / 0.12); --destructive-wash-strong-border: oklch(from var(--destructive) l c h / 0.40); diff --git a/apps/desktop/src/renderer/mcp-page.tsx b/apps/desktop/src/renderer/mcp-page.tsx index 2fc05c7845..3b444a1270 100644 --- a/apps/desktop/src/renderer/mcp-page.tsx +++ b/apps/desktop/src/renderer/mcp-page.tsx @@ -760,11 +760,10 @@ function McpServerInspector(props: { />
)} {planMode.error && reviewable && ( -

{planMode.error}

+ )}
@@ -256,23 +257,22 @@ export function PlanExecutionPanel(props: { return (
- - {expanded && (
    {execution.steps.map((step) => ( @@ -280,7 +280,6 @@ export function PlanExecutionPanel(props: { )}
- )} - {planMode.error &&

{planMode.error}

} + + {planMode.error && }
); } diff --git a/apps/desktop/src/renderer/quote-companion-core.ts b/apps/desktop/src/renderer/quote-companion-core.ts index 9171362de1..8992ad8f03 100644 --- a/apps/desktop/src/renderer/quote-companion-core.ts +++ b/apps/desktop/src/renderer/quote-companion-core.ts @@ -48,6 +48,7 @@ export interface CompanionSessionApi { ): Promise; cleanupSessionCopy(sessionId: string): Promise; abandonSessionCopy(sessionId: string): Promise; + stop(sessionId: string): Promise; send( sessionId: string, command: { @@ -170,6 +171,16 @@ export async function cleanupCompanionCopy( } } +export async function dismissCompanionCopy( + api: CompanionSessionApi, + sourceSessionId: string, + panelId: string, + companionSessionId: string, +): Promise { + await api.stop(companionSessionId).catch(() => undefined); + return cleanupCompanionCopy(api, sourceSessionId, panelId, companionSessionId); +} + /** * The shared Composer's `streaming` input means "a turn is interruptible", not * merely "a text delta has arrived". Keep the companion interruptible from the diff --git a/apps/desktop/src/renderer/quote-companion-panel.tsx b/apps/desktop/src/renderer/quote-companion-panel.tsx index 093ae7204a..1478840475 100644 --- a/apps/desktop/src/renderer/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/quote-companion-panel.tsx @@ -1,4 +1,6 @@ import { useCallback, useEffect, useRef, type ComponentProps } from 'react'; +import { Banner } from '@astryxdesign/core/Banner'; +import { Spinner } from '@astryxdesign/core/Spinner'; import { ChatView, ChatSurfaceLayout, @@ -10,7 +12,6 @@ import { type ChatModelChoice, type ComposerHandle, } from '@maka/ui'; -import { ICON_SIZE, Loader2 } from '@maka/ui/icons'; import type { SessionSummary } from '@maka/core'; import { useQuoteCompanion } from './use-quote-companion'; import { useAppShellComposerAttachments } from './use-app-shell-composer-attachments'; @@ -177,13 +178,12 @@ export function QuoteCompanionPanel(props: { data-preparing={companion.preparing || undefined} > - {companion.error &&
{companion.error}
} + {companion.error && ( + + )} {(companion.activeSandboxBoundary || companion.activeQuestion) && (
{companion.activeSandboxBoundary && ( @@ -226,7 +226,7 @@ export function QuoteCompanionPanel(props: { return accepted; }} onStop={() => void companion.stop()} - onSteer={(text: string) => companion.steer(text)} + onStreamingSubmit={(text: string) => companion.steer(text)} hidden={Boolean(activeInteraction)} streaming={companion.streaming} processing={companion.processing} @@ -278,18 +278,8 @@ export function QuoteCompanionPanel(props: { }} emptyOverride={ companion.preparing ? ( -
-
- ) : null} + } + /> + ) : null} + {gitSnapshot?.truncated ? ( + + ) : null} + {empty ? ( + /* Panel empty (DESIGN.md §10 tier 2): the whole panel is empty, so it + carries icon and description, not the compact form. */ + } + title={copy.empty} + description={copy.emptyHelp} + /> + ) : null} + {gitFiles.length > 0 ? ( +
+ + {visibleGitFiles.map((file) => { + const preview = boundedDiff(file.diff); + return ( + + + {file.path} + + + {file.additions > 0 ? ( + + {copy.added(file.additions)} + + ) : null} + {file.deletions > 0 ? ( + + {copy.deleted(file.deletions)} + + ) : null} + + + } + > + + {preview.hiddenLines > 0 ? ( + + {copy.hiddenLines(preview.hiddenLines)} + + ) : null} + + ); + })} + + {remainingGitFiles > 0 ? ( +
+
+ ) : null} +
+ ) : null} + ); } diff --git a/apps/desktop/src/renderer/session-workbar.tsx b/apps/desktop/src/renderer/session-workbar.tsx index 8503581970..1d92071a0e 100644 --- a/apps/desktop/src/renderer/session-workbar.tsx +++ b/apps/desktop/src/renderer/session-workbar.tsx @@ -45,9 +45,13 @@ import { X, } from '@maka/ui/icons'; import { Badge } from '@astryxdesign/core/Badge'; +import { Button } from '@astryxdesign/core/Button'; import { Card } from '@astryxdesign/core/Card'; import { ContextMenu } from '@astryxdesign/core/ContextMenu'; +import { Item } from '@astryxdesign/core/Item'; +import { Kbd } from '@astryxdesign/core/Kbd'; import { Section } from '@astryxdesign/core/Section'; +import { Spinner } from '@astryxdesign/core/Spinner'; import { Toolbar } from '@astryxdesign/core/Toolbar'; import { Tooltip } from '@astryxdesign/core/Tooltip'; import type { SessionSummary } from '@maka/core'; @@ -95,17 +99,8 @@ const SessionTerminalPanel = lazy(() => function WorkbarPanelLoading(props: { label: string }) { return ( -
-
diff --git a/apps/desktop/src/renderer/settings/custom-pet-settings-section.tsx b/apps/desktop/src/renderer/settings/custom-pet-settings-section.tsx index f3925ce2b8..2cb14d3b92 100644 --- a/apps/desktop/src/renderer/settings/custom-pet-settings-section.tsx +++ b/apps/desktop/src/renderer/settings/custom-pet-settings-section.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { EmptyState } from '@astryxdesign/core'; import { Badge, Button, useMountedRef, useToast, useUiLocale } from '@maka/ui'; import { SettingsRow, SettingsSection } from './settings-section'; import { settingsActionErrorMessage } from './settings-error-copy'; @@ -194,11 +195,11 @@ export function CustomPetSettingsSection() { /> {loading && library.pets.length === 0 ? ( - + ) : null} {!loading && library.pets.length === 0 ? ( - + ) : null} {library.pets.map((pet) => { diff --git a/apps/desktop/src/renderer/settings/daily-review-settings-page.tsx b/apps/desktop/src/renderer/settings/daily-review-settings-page.tsx index 9a2a242e0d..ac4e80950b 100644 --- a/apps/desktop/src/renderer/settings/daily-review-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/daily-review-settings-page.tsx @@ -6,6 +6,7 @@ import { buildCatalogDailyReviewModelOptions } from '../model-catalog-choices'; import { getDailyReviewSettingsCopy, type DailyReviewSettingsCopy } from '../locales/settings-daily-review-copy'; import { settingsActionErrorMessage } from './settings-error-copy'; import { SettingsPage, SettingsRow, SettingsSection } from './settings-section'; +import { SettingsSkeletonStack } from './settings-skeleton'; import { useActionGuard } from './use-action-guard'; const DAILY_REVIEW_DEFAULT_MODEL_VALUE = '__maka_daily_review_default_model__'; @@ -92,6 +93,14 @@ export function DailyReviewSettingsPage(props: { connections: readonly LlmConnec const scheduleDisabled = formDisabled; const selectedModelValue = config?.modelKey.trim() || DAILY_REVIEW_DEFAULT_MODEL_VALUE; + if (loading) { + return ( + + + + ); + } + return ( {!hasConfigIpc ? : null} diff --git a/apps/desktop/src/renderer/settings/memory-entry-list.tsx b/apps/desktop/src/renderer/settings/memory-entry-list.tsx index b3ae85b70f..104ca728c0 100644 --- a/apps/desktop/src/renderer/settings/memory-entry-list.tsx +++ b/apps/desktop/src/renderer/settings/memory-entry-list.tsx @@ -1,4 +1,5 @@ import type { LocalMemoryState } from '@maka/core'; +import { EmptyState } from '@astryxdesign/core'; import { Button, MoreMenu, RelativeTime } from '@maka/ui'; import { memoryOriginLabel } from './memory-settings-labels'; import type { MemorySettingsCopy } from '../locales/settings-memory-copy'; @@ -22,15 +23,12 @@ export function MemoryEntryList(props: { {props.copy.countEntries(props.entries.length)}
{props.entries.length === 0 ? ( -

{props.filtered ? props.copy.text.noMatchEntry : props.copy.text.noEntry}

+ ) : ( - /* PR-MEMORY-ENTRY-LIST-A11Y-0 (round 18/30): fourth - application of the same ARIA list fix. Was `
` with `
` rows — - semantic `
    ` / `
  • ` so screen readers get the - relationship from the elements themselves. The inner - `
    ` per entry stays — articles are valid - sectioning content inside list items. */
      {props.entries.map((entry) => { const copyPending = props.pendingCopyIds?.has(`entry:${entry.id}:copy`) ?? false; diff --git a/apps/desktop/src/renderer/settings/memory-settings-page.tsx b/apps/desktop/src/renderer/settings/memory-settings-page.tsx index 8e7df2cd74..aeb6d2c3d6 100644 --- a/apps/desktop/src/renderer/settings/memory-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/memory-settings-page.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import type { AppSettings, UpdateAppSettingsResult } from '@maka/core'; import { + Banner, Button, EmptyState, FormLayout, @@ -148,10 +149,12 @@ export function MemorySettingsPage(props: { > {memoryEntryPreviewBlockedReason && ( -
      - {copy.text.previewPaused} - {memoryEntryPreviewBlockedReason} -
      +
      )} {addFormOpen && ( @@ -210,10 +213,12 @@ export function MemorySettingsPage(props: { )} {memoryDraftHasSensitiveFields && ( -
      - {copy.text.sensitiveDraft} - {copy.text.sensitiveDraftHelp} -
      +
      )} {visibleMemoryEntries.entries.length > 0 ? ( diff --git a/apps/desktop/src/renderer/settings/permission-center-page.tsx b/apps/desktop/src/renderer/settings/permission-center-page.tsx index 62d0d3505b..0dcb598b7d 100644 --- a/apps/desktop/src/renderer/settings/permission-center-page.tsx +++ b/apps/desktop/src/renderer/settings/permission-center-page.tsx @@ -158,15 +158,17 @@ export function PermissionCenterPage() { if (error || !permissions || !capabilities) { return ( - setRefreshTick((tick) => tick + 1)} label={copy.readAgain} /> - )} - /> + + setRefreshTick((tick) => tick + 1)} label={copy.readAgain} /> + )} + /> + ); } diff --git a/apps/desktop/src/renderer/settings/projects-settings-page.tsx b/apps/desktop/src/renderer/settings/projects-settings-page.tsx index f825d9cc95..f034d66ca0 100644 --- a/apps/desktop/src/renderer/settings/projects-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/projects-settings-page.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { AppSettings, ProjectRecord, UpdateAppSettingsResult } from '@maka/core'; import { Badge, @@ -47,19 +47,23 @@ export function ProjectsSettingsPage(props: { const [homePath, setHomePath] = useState(undefined); const [renamingId, setRenamingId] = useState(null); const [draftName, setDraftName] = useState(''); + const reloadGeneration = useRef(0); const reload = useCallback(async () => { + const generation = ++reloadGeneration.current; const next = await window.maka.projects.list(); - if (mountedRef.current) setProjects(next); + if (mountedRef.current && generation === reloadGeneration.current) setProjects(next); }, [mountedRef]); useEffect(() => { void reload(); + const unsubscribe = window.maka.projects.subscribeChanges(() => void reload()); // Paths render unabbreviated until this lands, which is why // `collapseHomePath` treats an unknown home as a no-op rather than a bug. void window.maka.app.info().then((info) => { if (mountedRef.current) setHomePath(info.homePath); }); + return unsubscribe; }, [reload, mountedRef]); // Archived projects are removed-from-Maka, not deleted; they belong to the diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index 305de4635a..bd61ee1f8e 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -15,6 +15,7 @@ import { providerSupportsModelDiscovery, } from '@maka/core/llm-connections'; import { Banner, HStack, VStack } from '@astryxdesign/core'; +import { Collapsible } from '@astryxdesign/core/Collapsible'; import { Button, FormLayout, @@ -33,6 +34,12 @@ import { type ConnectionsBridge, } from './provider-panel-shared'; import { getProviderSettingsCopy } from '../locales/settings-provider-copy'; +import { + newRequestHeaders, + parseRequestBodyOverlay, + RequestCustomizationEditor, + type RequestHeaderDraft, +} from './request-customization-editor'; type ProviderFormField = | 'slug' @@ -40,6 +47,7 @@ type ProviderFormField = | 'accountId' | 'baseUrl' | 'defaultModel' + | 'advancedRequest' | 'form'; type ProviderFormError = { @@ -67,6 +75,9 @@ export function AddProviderForm(props: { const [cloudflareAccountId, setCloudflareAccountId] = useState(''); const [apiKey, setApiKey] = useState(''); const [defaultModel, setDefaultModel] = useState(recommendedDefaultModel); + const [requestHeaders, setRequestHeaders] = useState([]); + const [requestBodyText, setRequestBodyText] = useState(''); + const [advancedOpen, setAdvancedOpen] = useState(false); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const submitGuard = useActionGuard<'submit'>(); @@ -135,6 +146,15 @@ export function AddProviderForm(props: { message: isWiredOAuth ? copy.wiredLogin : copy.unwiredLogin, }); } + let normalizedRequestHeaders: Readonly>; + let requestBodyOverlay: ReturnType; + try { + normalizedRequestHeaders = newRequestHeaders(requestHeaders); + requestBodyOverlay = parseRequestBodyOverlay(requestBodyText); + } catch { + setAdvancedOpen(true); + return setError({ field: 'advancedRequest', message: copy.requestCustomizationInvalid }); + } submitGuard.begin('submit'); setBusy(true); try { @@ -155,6 +175,10 @@ export function AddProviderForm(props: { ? { enabledModelIds: [...OPENCODE_FREE_DEFAULT_ENABLED_MODELS] } : {}), ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), + ...(Object.keys(normalizedRequestHeaders).length > 0 + ? { requestHeaders: normalizedRequestHeaders } + : {}), + ...(requestBodyOverlay === undefined ? {} : { requestBodyOverlay }), }); if (!addProviderMountedRef.current) return; let modelDiscoveryError: unknown; @@ -185,6 +209,44 @@ export function AddProviderForm(props: { void submit(); } + const advancedRequestEditor = ( + + + { + setRequestHeaders(headers); + clearFieldError('advancedRequest'); + }} + bodyText={requestBodyText} + onBodyTextChange={(value) => { + setRequestBodyText(value); + clearFieldError('advancedRequest'); + }} + disabled={busy} + copy={{ + headers: copy.requestHeaders, + headerName: copy.headerName, + headerValue: copy.headerValue, + retainedValue: copy.retainedHeaderValue, + addHeader: copy.addHeader, + removeHeader: copy.removeHeader, + noHeaders: copy.noRequestHeaders, + body: copy.extraRequestBody, + bodyHelp: copy.extraRequestBodyHelp, + }} + /> + {error?.field === 'advancedRequest' && ( + + )} + + + ); + if (usesApiKeyDialog) { return ( @@ -206,6 +268,7 @@ export function AddProviderForm(props: { isDisabled={busy} hasAutoFocus /> + {advancedRequestEditor} {error?.field === 'form' && ( )} @@ -323,6 +386,7 @@ export function AddProviderForm(props: { } /> )} + {advancedRequestEditor} {error?.field === 'form' && ( diff --git a/apps/desktop/src/renderer/settings/provider-catalog-page.tsx b/apps/desktop/src/renderer/settings/provider-catalog-page.tsx index 63b3765240..853a2f5f47 100644 --- a/apps/desktop/src/renderer/settings/provider-catalog-page.tsx +++ b/apps/desktop/src/renderer/settings/provider-catalog-page.tsx @@ -23,9 +23,17 @@ import { OAuthLoginPanel, useOAuthCards, type OAuthCardId } from './provider-oau import { type ConnectionsBridge } from './provider-panel-shared'; import { getProviderSettingsCopy } from '../locales/settings-provider-copy'; -type CatalogCategory = ProviderCatalogGroup | 'recommended' | 'accounts'; +type CatalogCategory = ProviderCatalogGroup | 'all' | 'recommended' | 'accounts'; -const CATALOG_CATEGORIES: CatalogCategory[] = ['recommended', 'accounts', 'plans', 'api', 'aggregators', 'local']; +const CATALOG_CATEGORIES: CatalogCategory[] = [ + 'all', + 'recommended', + 'accounts', + 'plans', + 'api', + 'aggregators', + 'local', +]; /** * How the catalog is filtered. It lives in the panel, not in this component: @@ -37,7 +45,7 @@ export interface CatalogFilter { category: CatalogCategory; } -export const CATALOG_INITIAL_FILTER: CatalogFilter = { query: '', category: 'recommended' }; +export const CATALOG_INITIAL_FILTER: CatalogFilter = { query: '', category: 'all' }; /** * One provider being set up. `credentials` is a form, `account` is a browser @@ -68,7 +76,7 @@ export function ProviderCatalogPage(props: { const copy = providerCopy.panel; const catalogCopy = providerCopy.catalog; const { query, category } = props.filter; - const showsOAuth = category === 'recommended' || category === 'accounts'; + const showsOAuth = category === 'all' || category === 'recommended' || category === 'accounts'; const oauth = useOAuthCards({ query: showsOAuth ? query : undefined }); // Category is a Selector, not a second TabList: the page header already owns @@ -119,7 +127,7 @@ export function ProviderCatalogPage(props: { variant="ghost" size="sm" label={copy.clearSearch} - onClick={() => props.onFilterChange({ query: '', category: 'recommended' })} + onClick={() => props.onFilterChange({ ...props.filter, query: '' })} /> )} /> @@ -210,7 +218,13 @@ function providersForCategory(category: CatalogCategory, query: string, locale: return source.filter((type) => { if (!CATALOG_PROVIDER_TYPES.includes(type)) return false; if (PROVIDER_DEFAULTS[type].status !== 'ready') return false; - if (category !== 'recommended' && PROVIDER_DEFAULTS[type].catalogGroup !== category) return false; + if ( + category !== 'all' && + category !== 'recommended' && + PROVIDER_DEFAULTS[type].catalogGroup !== category + ) { + return false; + } if (!normalizedQuery) return true; const display = providerDisplay(type, locale); return [type, display.name, display.description, PROVIDER_DEFAULTS[type].label] diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx index bb7922049c..da6392a94a 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -45,6 +45,15 @@ import { type ConnectionDetailProps, type OAuthLoginService, } from './use-connection-detail'; +import { + formatRequestBodyOverlay, + parseRequestBodyOverlay, + requestHeaderUpdates, + RequestBodyEditor, + RequestHeadersEditor, + savedRequestHeaderDrafts, + type RequestHeaderDraft, +} from './request-customization-editor'; export function ConnectionDetail(props: ConnectionDetailProps) { const defaults = PROVIDER_DEFAULTS[props.connection.providerType]; @@ -163,12 +172,101 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { // Opening a row discards the other's draft: leaving an abandoned draft in // state meant it reappeared when the user came back to that row, and — until // `save` became per-field — rode along with the next save. - const [editingRow, setEditingRow] = useState<'key' | 'endpoint' | null>(null); - function openRow(row: 'key' | 'endpoint') { + const [editingRow, setEditingRow] = useState<'key' | 'endpoint' | 'headers' | 'body' | null>(null); + const [savedHeaderNames, setSavedHeaderNames] = useState([]); + const [headerDrafts, setHeaderDrafts] = useState([]); + const savedBodyText = formatRequestBodyOverlay(connection.requestBodyOverlay); + const [bodyDraft, setBodyDraft] = useState(savedBodyText); + const [requestCustomizationBusy, setRequestCustomizationBusy] = useState(false); + const toast = useToast(); + const mounted = useMountedRef(); + const allActionsBusy = detailActionBusy || requestCustomizationBusy; + const hasHeaderDraftChanges = + headerDrafts.length !== savedHeaderNames.length || + headerDrafts.some( + (header, index) => + !header.retained || + header.value.length > 0 || + header.name.toLowerCase() !== savedHeaderNames[index]?.toLowerCase(), + ); + + useEffect(() => { + let current = true; + setSavedHeaderNames([]); + setHeaderDrafts([]); + setBodyDraft(formatRequestBodyOverlay(connection.requestBodyOverlay)); + void props.bridge + .getRequestHeaders(connection.slug) + .then(({ names }) => { + if (!current) return; + setSavedHeaderNames(names); + setHeaderDrafts(savedRequestHeaderDrafts(names)); + }) + .catch((error) => { + if (!current) return; + toast.error(copy.requestCustomizationInvalid, providerPanelActionErrorMessage(error, locale)); + }); + return () => { + current = false; + }; + }, [connection.slug, props.bridge, toast]); + + function openRow(row: 'key' | 'endpoint' | 'headers' | 'body') { if (row === 'key') setBaseUrl(savedBaseUrl); - else setApiKey(''); + else if (row === 'endpoint') setApiKey(''); + else if (row === 'headers') setHeaderDrafts(savedRequestHeaderDrafts(savedHeaderNames)); + else setBodyDraft(savedBodyText); setEditingRow(row); } + + async function saveRequestHeaders(): Promise { + let updates; + try { + updates = requestHeaderUpdates(headerDrafts); + } catch { + toast.error(copy.requestCustomizationInvalid, copy.requestHeadersInvalidDetail); + return false; + } + setRequestCustomizationBusy(true); + try { + const saved = await props.bridge.setRequestHeaders(connection.slug, updates); + if (!mounted.current) return true; + setSavedHeaderNames(saved.names); + setHeaderDrafts(savedRequestHeaderDrafts(saved.names)); + await props.onChanged(); + return true; + } catch (error) { + if (mounted.current) { + toast.error(copy.saveFailed, providerPanelActionErrorMessage(error, locale)); + } + return false; + } finally { + if (mounted.current) setRequestCustomizationBusy(false); + } + } + + async function saveRequestBody(): Promise { + let overlay; + try { + overlay = parseRequestBodyOverlay(bodyDraft); + } catch { + toast.error(copy.requestCustomizationInvalid, copy.requestBodyInvalidDetail); + return false; + } + setRequestCustomizationBusy(true); + try { + await props.bridge.update(connection.slug, { requestBodyOverlay: overlay ?? null }); + await props.onChanged(); + return true; + } catch (error) { + if (mounted.current) { + toast.error(copy.saveFailed, providerPanelActionErrorMessage(error, locale)); + } + return false; + } finally { + if (mounted.current) setRequestCustomizationBusy(false); + } + } // Most of the 60 providers publish a fixed endpoint; editing it there can // only break the connection, and a proxy belongs in 设置 · 通用 · 网络, not in // a per-connection URL. So the row exists only where the address is genuinely @@ -258,7 +356,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { value={apiKeyStatusHint} actionLabel={hasSecret === true ? copy.change : copy.set} isEditing={editingRow === 'key'} - isDisabled={detailActionBusy} + isDisabled={allActionsBusy} canSave={hasApiKeyChange} saveLabel={copy.save} cancelLabel={copy.cancel} @@ -272,7 +370,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { placeholder={copy.pasteModelKey} label={copy.modelKeyAria(display.name)} isLabelHidden - isDisabled={detailActionBusy} + isDisabled={allActionsBusy} /> {defaults.signupUrl && ( @@ -322,11 +420,81 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { still needs one. Two rules with a gap between them read as an empty row, so only ever one. */} {!supportsApiKey && !showsEndpoint && } + + + 0 + ? copy.configuredHeaders(savedHeaderNames.length) + : copy.noAdvancedRequest} + actionLabel={copy.edit} + actionAriaLabel={`${copy.edit}: ${copy.requestHeaders}`} + isEditing={editingRow === 'headers'} + isDisabled={allActionsBusy} + canSave={hasHeaderDraftChanges} + saveLabel={copy.save} + cancelLabel={copy.cancel} + onEdit={() => openRow('headers')} + onCancel={() => { + setHeaderDrafts(savedRequestHeaderDrafts(savedHeaderNames)); + setEditingRow(null); + }} + onSave={async () => { + if (await saveRequestHeaders()) setEditingRow(null); + }} + > + + + + openRow('body')} + onCancel={() => { + setBodyDraft(savedBodyText); + setEditingRow(null); + }} + onSave={async () => { + if (await saveRequestBody()) setEditingRow(null); + }} + > + + + + + void updateEnabledModels(next)} /> {/* Each button reports its own work through clickAction (spinner + @@ -336,9 +504,9 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { because it takes an options object: handing it the click event would pass a MouseEvent as `opts`. */} -