Compare commits

...
524 Commits
Author SHA1 Message Date
SivanCola 3b677cf383 Merge pull request #6137 from SivanCola/fix/auto-compound-bash-approval
fix auto approval for compound bash / 修复复合 bash 自动审批
2026-07-07 12:02:12 +08:00
SivanCola 27962e7d95 Merge pull request #6136 from SivanCola/fix/ready-meta-reconcile
Reconcile startup readiness metadata / 对账启动 ready 元数据
2026-07-07 11:58:34 +08:00
merge-order-check bea5daa725 fix auto approval for compound bash 2026-07-07 11:54:17 +08:00
merge-order-check 6dd0b063e8 Reconcile startup readiness metadata 2026-07-07 11:43:56 +08:00
SivanCola 96f850438c Merge pull request #6134 from SivanCola/fix/retry-startup-lease-recovery
Recover startup session lease retries / 恢复启动会话锁重试
2026-07-07 11:27:50 +08:00
merge-order-check 2edf7fdae0 Recover tabs after startup session lease release 2026-07-07 11:00:44 +08:00
SivanCola f8bef2ac7d Merge pull request #6131 from SivanCola/fix/heartbeat-external-edit-adoption
Honor external edits to heartbeat-tasks.json / 心跳任务文件的外部编辑不再丢失
2026-07-07 07:13:08 +08:00
SivanCola c92b7b528c Merge pull request #6123 from SivanCola/fix/recovery-copy-ux-and-session-data-guard
Fix recovery-copy UX and guard agent writes to session data / 修复冲突副本体验并防护 agent 直写会话数据
2026-07-07 07:02:42 +08:00
merge-order-check da6edfcc6f Honor external edits to heartbeat-tasks.json
heartbeat-tasks.json is documented as human- and AI-editable (file
header + the heartbeat panel tip), but the engine loaded it once at
startup and then treated its in-memory copy as the source of truth:

- an externally added task was never scheduled until a manual Refresh
  or an app restart, and
- any run-state save (mergeRunUpdatesLocked writes the full list after
  a task fires) silently rolled the external edit back — the same
  "my write did not take" shape the session-data guard exists for,
  except here the product explicitly invites the edit.

Two changes make the contract real:

- mergeRunUpdatesLocked now rebases onto the on-disk list before its
  full-list save: the engine owns only the run-state fields (TopicID,
  LastRunAt, CreatedAt backfill), so external additions, edits, and
  deletions survive the save. A missing/unreadable file falls back to
  the in-memory list, preserving the historical behavior (and the
  existing concurrent-edit test).
- tick() probes the config mtime and adopts external edits before
  scheduling, so an agent-added task runs on the next tick. The engine
  records the mtime after its own writes to avoid re-reading them.
2026-07-07 04:06:26 +08:00
merge-order-check f2b3f40901 Deduplicate recovery conflict notices 2026-07-07 03:59:06 +08:00
merge-order-check 4b00b175e5 Keep heartbeat-tasks.json agent-editable
Review finding: heartbeat-tasks.json is a documented human/AI-editable
contract (desktop/heartbeat.go header; the heartbeat panel tip says
"AI agents can also edit heartbeat-tasks.json"), so the runtime-ledger
guard must not hard-refuse it — that broke the advertised flow of
asking the agent to create or update heartbeat tasks. Drop it from the
deny list and the bash hint needles, and pin the contract with a
positive regression so the guard can never swallow it again.
2026-07-07 03:43:49 +08:00
merge-order-check dfbc998926 Narrow the bash hint's lexical blind spots
Follow-up sweep on the remaining lexical gaps of the session-data
bash warning (write tools were never affected — relative paths get
absolutized before the hard deny):

- $HOME/${HOME} and %APPDATA%/$env:APPDATA spellings of the guarded
  trees now match, alongside the existing absolute and ~/ forms.
- The hint is workDir-aware: the desktop Global workspace lives at
  <state root>/global-workspace, so ../sessions reached the stores
  without an absolute path in the command text. Relative forms are
  matched from a state-root workDir, and a workDir already inside a
  guarded store warns on every command.
- workDir goes through realPath so a symlinked cwd (or /var vs
  /private/var on macOS) cannot dodge the workDir checks.

Remaining lexical limits (paths hidden in script files, runtime
variable concatenation) are inherent to text matching; the writer-tool
hard deny and the conflict-copy CAS remain the backstops.
2026-07-07 03:35:31 +08:00
merge-order-check 8dd8666194 Close case-fold and ledger gaps in the session-data guard
Same-shape sweep after the initial guard landed:

- Case-variant bypass (confirmed by repro): EvalSymlinks does not
  normalize case, and default macOS/Windows volumes are
  case-insensitive, so ~/.reasonix/SESSIONS/x.jsonl reached the real
  store while sliding past the deny check — the same failure shape as
  the Windows lease-key case split (#6023). Deny-side checks (the
  guard and forbid_read's confineRead) now fold case on those
  platforms; allow-side confine keeps exact matching, since folding an
  allow rule on a case-sensitive filesystem would wave a genuinely
  different directory through.
- State-root-direct runtime ledgers (desktop-tabs.json and friends,
  heartbeat-tasks.json, metrics/crash-pending.json) are rewritten
  wholesale by the app, so agent edits vanish the same way session
  edits do — the "index files" from the user report. They join the
  guard; config.toml/credentials/skills stay writable on request.
- reasonix review built its subagent bash from the init template,
  skipping the guard; it now carries the same session-data warning.
2026-07-07 03:35:31 +08:00
merge-order-check 82f39271ff Fix recovery-copy UX and guard agent writes to session data
Recovery-copy fixes (user-reported screenshots):
- agent:ready could reuse an in-flight hydrate and keep a stale
  ready=false tab meta, locking the composer until an unrelated
  settings reload re-emitted ready. The ready handler now refreshes
  the active tab metadata from ListTabs first (preserving cached
  history), so the send gate opens as soon as the backend is ready.
- Recovery notices no longer expose internal recovery-branch ids;
  the frontend maps both the new and the legacy English notice
  wording to localized, user-facing text so old transcripts render
  friendly too. The recovery banner no longer builds a tooltip from
  digest/parent-id internals, and the now-dead locale keys are gone.

Session-data write guard (root cause of the token-burn loop):
- An agent asked to "fix Reasonix" from inside Reasonix would write
  the app's own session JSONL/meta files via bash or file tools. The
  runtime is the only writer of those stores; external edits race its
  autosaves, fork endless conflict copies, and look to the agent like
  failed writes, so it retries in a loop.
- New SessionDataGuard rejects writer-tool targets under
  <state root>/sessions and <state root>/projects/*/sessions even when
  workspace write roots cover them (e.g. a home-directory workspace);
  explicit [sandbox] allow_write entries remain a sanctioned escape
  hatch. bash appends a warning when a command references those trees
  (never blocks: diagnostic reads are legitimate) so the retry loop is
  broken at the model level.
- Wired through ConfineWriters/ConfineBash/Workspace at the boot and
  ACP composition roots; zero-value guard keeps init-registered tools
  unconfined as before.
2026-07-07 03:35:31 +08:00
SivanCola 9cbe684a5a Merge pull request #6128 from SivanCola/fix/stepfun-base-url
Fix StepFun preset base URLs
2026-07-07 03:31:23 +08:00
SivanCola 3a5b4d9fef Merge pull request #6118 from SivanCola/ci/release-npm-freshness-check
ci(release): catch missing stable npm tags without failing in-flight publishes
2026-07-07 03:30:02 +08:00
SivanCola 1c17637267 Merge pull request #6126 from SivanCola/fix/sidecar-reconcile-and-doctor-out
Migrate diagnostic sidecars through overlong-name reconciliation / 超长名整理时迁移诊断 sidecar
2026-07-07 03:25:11 +08:00
merge-order-check ead3fec4fd Migrate legacy StepFun preset URLs 2026-07-07 03:22:57 +08:00
SivanCola 2dcc7fd5a8 Merge pull request #6125 from SivanCola/refactor/session-owned-rewrite-baseline
Move the persisted-rewrite baseline into agent.Session / 将 rewrite 持久化基线内聚进 Session
2026-07-07 03:15:45 +08:00
SivanCola d23d347897 ci(release): distinguish missing npm tag from an in-flight npm publish
Review catch: the freshness check raced normal releases. The runbook
pushes v*/npm-v*/desktop-v* together, the two workflows wait on the
release environment independently, and npm dist-tags propagate
asynchronously — so a single read of dist-tags.latest could fail the
CLI release while the npm publish was merely awaiting approval or
propagating, telling the operator to push a tag that was already
pushed.

Split the check into the two states the review named:
- npm-v<version> tag missing (git ls-remote) -> hard fail. This is the
  #5822 gap: nobody released to npm at all.
- tag pushed but latest lagging -> poll 6x10s (matching release-npm's
  own verify cadence), then WARN and pass: the publish may still be
  awaiting its environment approval, and release-npm.yml's verify step
  owns asserting the dist-tag lands.

RELEASING.md wording updated to match (fails on missing tag; pending
publish only warns).
2026-07-07 03:13:53 +08:00
merge-order-check 85833b9c07 Fix StepFun preset base URLs 2026-07-07 03:09:34 +08:00
merge-order-checkandSivanCola 279ec453fe Format session rewrite baseline field
Co-authored-by: SivanCola <[email protected]>
2026-07-07 03:09:05 +08:00
SivanCola 7f445eafaa Merge pull request #6124 from SivanCola/refactor/conflict-outcome-enum
Return an explicit outcome from snapshot conflict recovery / 会话冲突恢复显式返回结果类型
2026-07-07 02:57:19 +08:00
SivanCola 36c293cd80 Merge pull request #6120 from SivanCola/fix/session-conflict-diagnostics
Fix session conflict diagnostics and depth-cap baseline / 修复会话冲突诊断与深度上限基线
2026-07-07 02:52:47 +08:00
SivanCola 8b6fa8c452 docs: update npm channel docs for latest -> 1.x
The migration guide and release runbook still documented the retired
"latest stays pinned to 0.x, v2 lives under @next" policy — the exact
policy that caused #5822. Update both to the current reality:

- MIGRATING.md: bare `npm i -g reasonix` installs the current 1.x
  stable; @next is the rc channel; 0.x stays installable by pinning
  [email protected].2.
- RELEASING.md: channels table shows latest = current 1.x stable with
  next/canary as pre-release buffers; the stable-ship step notes the
  npm-v* tag moves `latest` automatically and must not be skipped
  (release.yml now fails when npm latest lags); drop the manual
  "promote to default install" step — promotion is automatic.
2026-07-07 02:42:00 +08:00
merge-order-check a7b5a0487e Keep diagnostic sidecars out of overlong-name reconciliation and migrate them
The overlong-name pass filtered by a bare .jsonl suffix, so an
.events.jsonl or .conflicts.jsonl whose name crossed the 224-byte bound
(owner stem between 209 and 224 bytes) was renamed into a fake session
with fabricated meta, while the real transcript's rename orphaned both
logs under the retired stem. Filter with store.IsSessionTranscriptName
and carry the event log, event index, and conflict log through
migrateSessionSidecars like the other owned sidecars.

Also accept --out=PATH (equals form) in reasonix doctor session.
2026-07-07 02:34:39 +08:00
merge-order-check d2d37c47ed Move the persisted-rewrite baseline into agent.Session
The controller kept savedRewriteVersion as external bookkeeping about
the executor's session object, so every SetSession had to be paired
with a manual baseline reset and snapshot() had to guess whether
recovery had swapped the object — the guess that broke depth-cap
force-saves (#6120).

Store persistedRewriteVersion on the Session itself: save paths advance
it with the rewriteVersion captured in the same lock window as the
message snapshot (a compaction landing mid-save stays unpersisted, and
a slower save cannot roll it backwards), clones carry it, and swapped
sessions take their baseline with them. snapshotRewriteDecision and
both mark helpers plus their seven call-site pairings collapse into
Session.NeedsRewriteSave.

Behavior fix folded in: installing a clone that still holds an
unpersisted rewrite no longer stamps it persisted-by-construction; the
next snapshot correctly runs as an owned rewrite.
2026-07-07 02:30:42 +08:00
merge-order-check 56667add01 Return an explicit outcome from recoverSnapshotConflict
The caller inferred what recovery did from path and session-pointer
comparisons; the depth-cap baseline bug hid in exactly that inference.
Declare the outcome (adopted / force-saved / forked / dropped) at the
site that performs it, and let replaceSessionAfterCancel log when its
cancel cleanup was dropped instead of silently discarding the result.
2026-07-07 02:19:38 +08:00
merge-order-check 74fa880ef3 Fix deprecated zip SetModTime and warn that doctor session bundles are unredacted 2026-07-07 01:58:43 +08:00
merge-order-check 7a75155823 Fix session conflict diagnostics and depth-cap baseline 2026-07-07 01:17:11 +08:00
SivanCola 92fa78cbc3 ci(release): fail a stable CLI release when npm latest lags behind
v1.17.5 shipped as binaries and Homebrew while the npm `latest`
dist-tag still pointed at 0.53.2 (#5822) — every `npm update -g` /
`pnpm update -g` user was silently downgraded to a months-old version.
The npm line is triggered by its own tag namespace (npm-vX.Y.Z), and
the stable npm tag was simply never pushed: 1.0.0 through 1.17.5 all
went to npm as -rc.1 prereleases under `next`. release-npm.yml's verify
step (added for #5822) only guards runs that happen; nothing caught the
run that DIDN'T.

Add a freshness gate to the stable CLI release: after the public smoke,
compare `npm view reasonix dist-tags.latest` against the version being
released (sort -V). A stale `latest` fails the release with the exact
remediation (push the npm-vX.Y.Z tag, or `npm dist-tag add` for an
already-published version). Prereleases skip — npm latest does not move
on prereleases. A `latest` NEWER than the tag passes, so re-releasing
an older CLI tag is not blocked.
2026-07-07 00:59:43 +08:00
SivanCola 4facd0d6bd Merge pull request #6114 from SivanCola/fix/new-session-resets-goal
fix(control): clear the active goal on session rotation
2026-07-07 00:20:11 +08:00
SivanCola bc1add0b86 fix(desktop): validate the restored tab goal against the session sidecar
Review catch: a typed /new or /clear rotates the session through the
controller slash path and never reaches App.NewSession/ClearSession, so
desktop-tabs.json can keep the pre-rotation goal. On the next restart,
restoreOrBuildTabs read entry.Goal unfiltered and the tab build seeded
it into the rotated session via SetGoal(buildGoal) — resurrecting the
goal the rotation had cleared. (The goal-state filter I pointed at in
the PR description only runs on the runtime-profile path, not startup
restore.)

Restore now validates entry.Goal through runningTabSessionGoal: the
rotation writes a stopped goal-state sidecar onto the fresh path, so a
restart reads stopped → no goal; a tab still pointing at the old
session reads running → goal preserved (resume semantics); a session
without a sidecar keeps the persisted goal (legacy).

TestRestoredTabGoalFollowsSessionGoalState pins the cross-layer
contract with a real controller rotation: rotated path → cleared, old
path → preserved, no sidecar → fallback.
2026-07-06 23:46:34 +08:00
SivanCola 9d00a3716d fix(control): clear the active goal on session rotation
NewSession and ClearSession reset the executor session, planner,
guardian, and checkpoints — but not the goal machine. A running goal's
text (plus its autoresearch runtime block) kept injecting into the
fresh session's first turns, silently steering a conversation the user
had explicitly reset.

Controller: both rotations now ClearGoal() after rebindCheckpoints has
moved the goal-state path to the fresh session, so the cleared
(stopped) state lands on the NEW path while the old session's sidecar —
persisted before the rotation — keeps its running goal for /resume.
This covers every frontend (typed /new, CLI, serve, bot, desktop).

Desktop: the tab's persisted goal copy follows the controller —
App.NewSession and ClearSession clear tab.goal (otherwise the next
rebuild or restart would re-seed the old goal via SetGoal(tab.goal)),
and clearActiveSessionRuntime no longer seeds the destroyed
conversation's goal into the replacement controller. The model/effort/
settings rebuild paths keep the goal: those are same-session rebuilds.

TestSessionRotationClearsActiveGoal pins all four properties: goal
empty and no <active-goal> injection after /new and /clear, the old
session's sidecar keeps the running goal, and the new session's sidecar
does not carry it. Verified it fails without the ClearGoal calls.
2026-07-06 23:18:11 +08:00
SivanCola 221a7645b6 Merge pull request #6106 from SivanCola/fix/windows-bash-sandbox-default-off
Default Windows bash sandbox to off / Windows Bash 沙箱默认关闭
2026-07-06 22:03:44 +08:00
SivanCola d2f0030732 Apply user config upgrades on CLI startup
Run ApplyUserConfigUpgradesOnStartup from migrateLegacyConfigForCLI so
Windows CLI-only users also get the one-time bash = "enforce" to off
migration instead of waiting for a desktop launch. The upgrade is
config_version-gated, so desktop and CLI never double-apply it, and
metadata commands (version/help) still leave the config untouched.
Also drop the unused bool return of the migration helper.
2026-07-06 21:58:39 +08:00
SivanCola 48812e90f1 Simplify bash sandbox delta render and guard GOOS test overrides
Drop the dead BashMode comparison in the project delta condition (an
empty raw value always resolves to the platform default on both sides)
and replace per-test runtimeGOOS save/restore boilerplate with a
setRuntimeGOOS helper whose t.Setenv call panics under t.Parallel,
keeping the package-global override race-free.
2026-07-06 21:58:30 +08:00
SivanCola 7ae4219dcc Merge pull request #6105 from SivanCola/fix/mcp-toolset-session-pinning
fix(plugin): pin cache-hit MCP placeholder tools for the whole session
2026-07-06 21:33:06 +08:00
SivanCola 4a4426634b Fix Windows bash sandbox upgrade lint 2026-07-06 21:31:42 +08:00
SivanCola 255189876b test(plugin): poll for the refreshed cache instead of the first loadable one
macOS CI caught a race in the new divergent-handshake test: the stale
cache the test writes is itself loadable, so waitForCachedSchema could
return it before the background saveLazyCachedSchema landed. Poll until
the cache actually contains the live tool set (bounded by the same 5s).
2026-07-06 21:23:30 +08:00
merge-order-check 10eb314bdb Default Windows bash sandbox to off 2026-07-06 21:20:24 +08:00
SivanCola 808abb9f3a fix(plugin): pin cache-hit MCP placeholder tools for the whole session
The provider request's tools array is part of the cached prompt prefix,
and cache_shape.go already tracked mid-session tool changes as a "tools"
prefix invalidation (a full-conversation cache miss at 10x pricing).
The lazy-placeholder mechanism registered byte-stable cached schemas at
boot — and then trySwap replaced them with the live tools as soon as
the background handshake finished. Whenever the live handshake differed
from the cache (description tweaks, schema upgrades, newly added
tools), the swap rewrote the tools array mid-session and cold-started
the conversation's provider cache.

Pin cache-hit placeholders instead: trySwap no longer touches the
registry for them. The lazyTools keep presenting the cached bytes the
model has seen since boot and forward Execute to the real tools through
the shared spawn state (which already worked — the swap was never
functionally necessary). Live drift still lands in the schema cache via
saveLazyCachedSchema, so the NEXT session presents the updated surface:
freshness deferred one session for byte-stable tool bytes within this
one — the same trade the environment-probe snapshot makes for the
system prompt.

The cache-miss connect stub still swaps (there was nothing real to
present); that is a one-time cost per server, and the background kick
usually completes before the first user message anyway. A snapshot with
zero cached tools now also takes the stub path — previously its live
tools joined the registry mid-session with no placeholders at all.

Covers all trySwap callers including the IsServerAlreadyConnected
shared-host recovery paths. Verified by reverting the guard: the new
divergent-handshake test fails with the old swap behavior.
2026-07-06 21:15:40 +08:00
SivanCola 4f54c51eef Merge pull request #6102 from SivanCola/feat/recovery-branch-gc
feat(desktop): garbage-collect redundant recovery branches to trash
2026-07-06 21:03:20 +08:00
SivanCola 89f52f1b6e fix(desktop): gate the first recovery-GC sweep on tab restore
Review catch: startup launches restoreOrBuildTabs on a goroutine and
started the GC immediately after, so the first sweep could run against
the pre-restore empty tab map. Two consequences: sessionOpenInAnyTab
would judge every saved tab's session as closed (a recovery tab about
to be restored could be swept to trash), and DeleteSession's tab-list
persistence would overwrite desktop-tabs.json with the empty snapshot,
wiping the user's saved tab layout.

Add a tabsRestored gate: armed in startup before the restore goroutine
launches, closed via defer when restoreOrBuildTabs finishes (both the
saved-tabs and first-launch paths, and the panic-recover path). The
GC's first sweep waits on the gate or ctx cancellation. markTabsRestored
is idempotent; tabsRestoredSignal reports already-restored when the gate
was never armed, so App instances built directly in tests keep working.

Regression test forces the exact race: gate closed → sweep provably
blocked and the branch untouched; restore lands the tab holding the
branch and opens the gate → the sweep runs and skips the now-open
branch.
2026-07-06 20:51:45 +08:00
SivanCola f50babf8a5 Merge pull request #6088 from esengine/codex/session-trash-friendly-errors
fix(desktop): surface actionable errors when session cleanup hits a blocked file
2026-07-06 20:36:15 +08:00
SivanCola 2f1302b449 feat(desktop): garbage-collect redundant recovery branches to trash
Conflict recovery forks a copy of the transcript on every save conflict
(#5993). The triggers are fixed, but every fork that ever happened still
sits in the session list until the user trashes it by hand — the
"会话列表被污染" half of that report was never addressed.

Add a conservative GC. agent.ReclaimableRecoveryBranches reclaims a
branch only when EVERY condition holds, because a recovery branch exists
precisely to prevent data loss:
  - meta says Recovered with a fork digest;
  - the transcript still matches that digest (never continued on — one
    follow-up turn disqualifies it permanently);
  - the parent (meta.ParentID) still exists and COVERS the branch content
    (equal digest, or branch is a compatible prefix — the same checks
    SaveRecoveryBranch uses to declare recovery unnecessary);
  - no runtime holds the branch's session lease;
  - it has been idle past a 24h grace period.

The desktop sweeps every session directory it lists from, once at startup
and every 6h, re-checking liveness (lease + open-in-tab) immediately
before disposal and routing removal through DeleteSession — so a swept
branch is moved to the recoverable session trash, never hard-deleted.

Verified the coverage guard by reverting it: a diverged fork (content
present nowhere else) then becomes reclaimable and the test fails.
2026-07-06 20:25:09 +08:00
SivanCola c0fecd8779 fix(desktop): satisfy session error lint 2026-07-06 20:23:40 +08:00
SivanCola 0645b0e8e3 Merge pull request #6096 from SivanCola/fix/cost-display-scope-cleanups
fix(ui): make every cost/cache readout match its labeled scope
2026-07-06 20:12:35 +08:00
SivanCola 72f0210bb4 fix(desktop): align ContextPanel session cache average with all-sources scope
Review catch on this PR: the panel's session cache average still
preferred usage.sessionCacheHitTokens — the wire session counters the
Go agent scopes to the executor only — over the all-sources telemetry,
while the same panel's session cost and per-source rows (and StatusBar
after this PR) already report all sources. Because the reducer keeps
s.usage across context refreshes, the executor-only number would stick
indefinitely.

Extract contextSessionCache: panel info first, ContextInfo second, wire
counters only as the pre-refresh bridge for a fresh session — and pick
hit/miss as a pair from a single source so the computed rate can never
mix scopes (the old per-field ?? chains could).
2026-07-06 19:37:42 +08:00
SivanCola 69984b1bca Merge pull request #6100 from esengine/codex/pr6095-extra-regression-tests
test(agent): cover snapshot repair edge cases
2026-07-06 19:33:11 +08:00
SivanCola 072cf44246 Merge pull request #6097 from SivanCola/fix/winsandbox-hidden-console-lock-holder
fix(sandbox): hide Windows sandbox consoles and fail lock waits fast
2026-07-06 19:30:47 +08:00
SivanCola 9a38adde4d Merge pull request #6094 from SivanCola/test/rebind-byte-stability
test: guard prompt-prefix byte stability at boot and desktop rebind level
2026-07-06 19:26:50 +08:00
SivanCola d6f196ac94 test(agent): cover snapshot repair edge cases 2026-07-06 19:26:36 +08:00
SivanCola 59df72bde9 fix(ui): make every cost/cache readout match its labeled scope
Four labeled-scope mismatches in cost/cache displays, bundled as the
tail of the cost-display cleanup line (#6075 fixed the big one):

- Desktop status bar 平均命中 silently switched scope: the live value
  used the wire session counters (executor agent only) while the
  fallback used tab telemetry (all request sources). Prefer the
  all-sources telemetry — matching the 会话费用 tooltip's "includes
  main model, subagents and auxiliary calls" — with the executor-only
  live counters only bridging a fresh session's first turn.
- CLI status line painted "turn hit 0.00%" for providers that report
  no prompt-cache fields, by falling back to PromptTokens as the
  denominator. The tag now hides when cache fields are absent; a
  genuine full miss (fields present, hit zero) still renders.
- serve web UI "总费用" accumulated since page load across session
  rotations and fell back to a single request's cost. The counters now
  reset on /new and /resume, the single-request fallback is gone, and
  the card is relabeled 会话费用/Session Cost.
- Desktop ContextPanel's session-cost fallback chain could render one
  request's usage.cost under the 会话费用 label; single-request
  branches removed (usage now contributes currency only).
2026-07-06 19:20:00 +08:00
SivanCola 72a0bf225e fix(sandbox): hide Windows sandbox consoles and fail lock waits fast
- Launch sandboxed user commands and internal icacls/taskkill with
  CREATE_NO_WINDOW + STARTF_USESHOWWINDOW/SW_HIDE so no black console
  flashes over the desktop (npm run dev / icacls windows in the report).
- Record each root lock's holder (pid + command preview) in a sidecar
  file; the queue notice and timeout error now name the blocking command
  so users know what to stop instead of only being told to raise
  WINDOWS_SANDBOX_LOCK_MS.
- Drop the interactive lock wait default to 1 minute so a queued
  foreground command fails fast into the existing escape-approval path;
  background bash jobs keep the patient 10-minute wait via Spec.LockWait
  (WINDOWS_SANDBOX_LOCK_MS still overrides both).
2026-07-06 19:19:55 +08:00
SivanCola 72382df0e9 Merge pull request #6095 from SivanCola/fix/snapshot-interrupted-tool-tail
fix(agent): stop load-time repairs from masquerading as snapshot conflicts
2026-07-06 19:17:28 +08:00
SivanCola 1192fad2f5 test(desktop): compare rebind request against a no-rebind baseline
Review feedback on the rebind guard: comparing after[:len(before)]
against the FIRST turn's request only proved the system prompt and
first user message stayed stable — the resumed prefix's assistant/tool
messages were never compared.

Strengthen to the full comparison: from one transcript saved after turn
one, run the same follow-up turn twice — once on the original
controller (no rebind, the cache-warm baseline) and once after the
desktop rebind path — and require the two requests to be byte-identical
END TO END. The rebind now loads an independent copy of the saved
transcript, since the baseline turn autosaves onto the original path.
The drifted-prompt canary gets the same baseline structure and
additionally pins the divergence to the leading system message.
2026-07-06 19:13:04 +08:00
SivanCola 1437d5a24a fix(control): self-heal fork-orphaned in-flight turn markers on open
The marker transplant stops new recovery forks from leaving in-flight
turn markers behind, but every fork made by an older runtime already
left one on its forked-from branch. Opening such a branch fired
recoverInterruptedTurn against a turn that completed on the recovery
child, stripping the transcript back to the turn boundary and
force-saving the truncation.

recoverInterruptedTurn now recognizes the leftover: a recovery branch
whose parent is this session and whose creation postdates the marker
means the marked turn moved there rather than dying with a runtime.
Clear the stale marker and keep the messages; a marker with no such
child still strips the partial tail as before.
2026-07-06 19:04:33 +08:00
SivanCola 6db192b8e7 test: guard prompt-prefix byte stability at boot and desktop rebind level
Final defense line for the provider prefix cache, closing the CI gaps
the desktop-cost triage identified: nothing verified that (a) prompt
composition is deterministic and (b) the desktop rebind path preserves
request bytes.

- TestBuildComposesByteStableSystemPrompt: two Builds over the same
  workspace/config must compose byte-identical system prompts; a
  failure prints a window around the first diverging byte so the
  drifting section is named. Probes are covered cross-process by the
  persisted-snapshot tests; this pins the rest of the composition
  (memory, skills index, output style, policies).
- TestRebindKeepsRequestPrefixBytes: after the desktop rebind shape
  (LoadSession + sessionWithFreshSystemPrompt + Resume on a fresh
  controller), the next request's leading messages must be
  byte-identical to the request sent before the rebind.
- TestRebindWithDriftedPromptBreaksRequestPrefix pins the failure mode
  the guard exists for (a drifted prompt breaks the prefix), so a
  future swap-policy change updates the guards consciously.
2026-07-06 18:59:27 +08:00
SivanCola 7932bc1e46 fix(control): move the in-flight turn marker onto the recovery branch
A snapshot-conflict recovery fork switches the controller onto the new
branch mid-turn, but the in-flight turn marker written at turn start
stayed on the original path — turn end then cleared the (absent) marker
on the recovery path, leaving the original's marker behind forever. The
next open of the original branch fired recoverInterruptedTurn against a
turn that in fact kept running on the recovery branch, stripping every
message after the turn boundary and force-saving the truncation.

Transplant the marker when the fork switches paths: mark the recovery
branch (so a crash before turn end still strips correctly there) and
clear the forked-from branch.
2026-07-06 18:56:39 +08:00
SivanCola a1fecd3aa0 fix(agent): keep dirty-resume saves off the append shortcut, raw-check recovery need
Two more sites judged the normalized load as if it were the disk truth:

- checkSnapshotWrite let a session resumed from a repaired transcript take
  the append-only shortcut with appendFrom measured against the normalized
  view. The event log replays the raw transcript, so the append event's
  index broke the replay chain and the whole appended turn silently
  vanished from disk on the next load (until a later save healed the log).
  A pending repair now falls through to the full rewrite, which persists
  the repair and the new turn together.

- SaveRecoveryBranch decided "recovery not needed" only against the
  normalized view, so a mid-history repair (e.g. an empty tool-call name
  backfilled on load) made a fully-covered snapshot look uncovered and
  forked a pointless recovery branch. Coverage now falls back to the raw
  transcript, mirroring checkSnapshotWrite.
2026-07-06 18:53:45 +08:00
SivanCola a84f2aeb03 Merge pull request #6093 from SivanCola/fix/env-probe-snapshot-byte-stability
fix(environment): persist probe snapshots so the prompt prefix stays byte-stable
2026-07-06 18:53:20 +08:00
SivanCola b96f152a07 Merge pull request #6092 from esengine/codex/desktop-localized-error-notices
Localize desktop error notices
2026-07-06 18:50:35 +08:00
SivanCola 09ce7df436 fix(agent): judge snapshot appends against the raw on-disk transcript
A mid-turn autosave can legitimately cut an assistant tool call from its
still-running result, leaving a dangling call at the tail of the saved
transcript. checkSnapshotWrite compared the next snapshot against the
LoadSession view of disk, where normalization has already fabricated a
placeholder answer for that call. The live session's real tool result
collides with the placeholder, so a pure append misread as diverged and
forked a "snapshot conflict" recovery branch 30 seconds after the first
mid-turn save of any tool-heavy turn.

Keep the pre-repair transcript on loaded sessions and fall back to it in
checkSnapshotWrite when the normalized prefix checks fail: append shape,
the owned-rewrite digest, and conflict-kind classification are now judged
against the bytes actually on disk. Appending the real results also lands
the transcript well-formed, so no repair is left pending in that path.
2026-07-06 18:42:42 +08:00
SivanCola 3eda5346a9 fix(environment): persist probe snapshots so the prompt prefix stays byte-stable
The environment section sits inside the provider-cached system-prompt
prefix, but probe results were point-in-time observations with only a
5-minute in-memory cache: a 2s timeout flips a slow tool between
"found" and "timeout", a GUI-launched desktop resolves a different PATH
than a login shell, a cold docker daemon reports an exit error. Every
flip rewrote the prefix; the desktop's fresh-system-prompt swap then
propagated the drifted prompt onto every resumed session — a
whole-conversation cache miss at 10x pricing plus a persisted rewrite.
This was the main mechanism behind "desktop costs more than CLI"
(#2945, #5850 comments).

- Persist one probe snapshot per fingerprint under the shared cache
  root (ProbeOptions.SnapshotDir, opt-in; boot passes config.CacheDir).
  A snapshot younger than 24h serves without re-probing, so rebuilds,
  app relaunches, and the CLI and desktop on the same machine render
  identical environment bytes. 24h aligns with cacheColdAfter: past a
  day idle the provider cache is dead anyway, so a refresh then is free.
- Flap merge on refresh: transient failures (timeout, nonzero exit)
  keep the previous successful observation; definitive results (found,
  not found, not trusted) always win, so uninstalled tools still drop
  out.
- Desktop resume logs when it swaps a system prompt whose bytes differ
  from the persisted one — with deterministic composition this should
  only fire on genuine config changes, so a field log without one
  points at a new nondeterminism source.

The snapshot dir is host state and stays out of the probe fingerprint.
2026-07-06 18:38:13 +08:00
SivanCola d580bb38d5 Localize provider extra body syntax errors 2026-07-06 18:35:05 +08:00
SivanCola 068f35dc77 Localize desktop error notices 2026-07-06 18:21:38 +08:00
SivanCola dce3b3a70e Merge pull request #6076 from SivanCola/chore/internalize-windows-sandbox
Internalize Windows sandbox backend / 内置 Windows 沙箱后端
2026-07-06 17:37:25 +08:00
SivanCola e38623e255 Merge pull request #6077 from SivanCola/fix/new-session-running-guard
fix(control): refuse NewSession while a turn is running
2026-07-06 17:28:04 +08:00
SivanCola 28d0ab8677 fix(sandbox): clean up Windows helper availability checks 2026-07-06 17:27:37 +08:00
SivanCola a0337caf9b fix(desktop): surface actionable errors when session cleanup hits a blocked file
Raw OS errors from session trash/restore/purge — Windows sharing
violations while antivirus or sync tools hold a transcript, access
denied, disk full — previously reached the UI toast verbatim. Map them
at the App entry points (TrashTopic, DeleteTopic, DeleteSession,
RestoreSession, PurgeTrashedSession) to sanitized, path-free messages
with retry guidance, following the errSessionBusyElsewhere precedent.
The original error is logged so diagnostics keep the path and errno;
unrecognized errors pass through unchanged.
2026-07-06 17:27:26 +08:00
SivanCola 203ab34a81 fix(control): gate summarizeAt through the rotation gate
SummarizeFrom/SummarizeUpTo still used the bare Running() check before
rewriting the live session — and their window is the widest of the
class: the summarizer performs a provider round-trip before
Session.Replace, leaving seconds for a turn to start and then have the
log replaced under it. Reachable from desktop summarize actions, the
TUI rewind menu, and serve /summarize.

Route summarizeAt through beginRotation()/endRotation() from the
boundary read through the post-rewrite snapshot, keeping the original
"cannot summarize while a turn is running" message on the running path.

Also strengthen TestSessionMutationsRefuseWhileRotating: every gated
method now asserts the errRotationInProgress sentinel instead of any
non-nil error, so each method's wiring is independently
regression-proof (a bare-Running() revert previously slipped past the
weaker checks by failing later for an unrelated reason). Verified the
summarize assertions fail with the gate removed.
2026-07-06 17:17:31 +08:00
SivanCola bb0dd15e1b Merge pull request #6085 from esengine/codex/session-trash-missing-path
fix(desktop): make session trash tolerate missing artifacts
2026-07-06 17:13:38 +08:00
SivanCola 387cc22517 fix(sandbox): route the Windows helper in the desktop app and fail closed without it
The Windows bash sandbox relaunches os.Executable() as a hidden helper
process, but the desktop binary never routed that subcommand: every
sandboxed command started a second GUI instance that forwarded to the
running app via the single-instance lock and exited 0 with no output, so
bash silently returned empty on Windows (#6046, #6051, #6067, #6070,
#6072). The 1.17.3 escape prompt never fired because it keys on the
helper's exit-code-126 failure marker, which a dispatch-less binary
never prints.

- desktop/main.go dispatches the helper subcommand before any
  Wails/single-instance setup, with tests pinning the route.
- Entry points now declare the route via
  sandbox.RegisterHelperDispatch(); on Windows, Available() stays false
  without it and Command/CommandArgs refuse to wrap, so a future
  dispatch-less embedder fails closed with the existing clear error and
  escape-approval path instead of swallowing output.
- Queueing behind another sandboxed command's per-root lock now emits a
  one-line notice (sliced mutex waits) instead of blocking silently for
  up to 10 minutes; recorded as a local modification in the winsandbox
  NOTICE.
- reasonix doctor reports the resolved shell (Git Bash vs PowerShell is
  the first thing to check for low-integrity launch failures) and warns
  when a project reasonix.toml pins [sandbox], which overrides
  Settings/user-config edits (#5961 confusion).
- GUIDE/GUIDE.zh-CN gain a Windows sandbox troubleshooting note.
2026-07-06 17:09:14 +08:00
SivanCola 1e408f5e33 fix(desktop): drop partial trash copy when source vanishes mid-copy
If the source disappears while the copy fallback is running, copyAndRemove
now removes the partially written destination before treating the artifact
as already moved, so the trash never keeps a truncated transcript that a
later restore would resurrect. Real copy failures still propagate and leave
the source untouched.
2026-07-06 17:01:30 +08:00
SivanCola 252ff75f17 fix(control): route all session mutations through the rotation gate
NewSession/ClearSession closed their turn↔swap TOCTOU window with the
rotation gate, but the same bare-Running()-then-mutate-later shape lived
in every other interactive session-mutating entry point:

- forkNamed: Running() check, then Snapshot(), then SetSession
- Branch:    read running, then Snapshot(), then SetSession
- SwitchBranch: read running, then Branches()/LoadSession, then SetSession
- Rewind:    Running() check, then Session().Replace + SnapshotRewrite
- Compact:   Running() check, then CompactNow rewrites the live session

All are dispatched from submit()'s synchronous slash-command path (or
Fork wrappers), so a user can trigger them while a turn streams — the
guard existed for exactly that reason, but a turn starting after the
check and before the mutation slipped through, replacing/rewriting the
session under the run loop.

Route all five through beginRotation()/endRotation(), same as
NewSession/ClearSession: the gate is held from before the snapshot/load
through the swap, and runGuarded/RunTurn already refuse to start while
c.rotating. Each keeps its original "while a turn is running" message on
the running path and reports errRotationInProgress for the newly-closed
window. None of these methods is called from inside a turn body, so the
gate is never re-entered (no deadlock).

TestSessionMutationsRefuseWhileRotating asserts all five (plus RunTurn)
refuse while a rotation holds the gate and the live session is untouched,
and that the gate cannot be claimed while a turn runs. Verified it fails
if the rotating check is removed.
2026-07-06 17:00:13 +08:00
SivanCola a6f96dc2e4 fix(control): close the NewSession/ClearSession rotation TOCTOU window
The running guard checked c.running once at entry, released c.mu, and
only swapped the executor session after Snapshot(). A turn could start
during that snapshot window (running was false at the check) via
runGuarded/RunTurn and then have its live session replaced by
SetSession — the exact race the guard meant to prevent, just moved later.

Replace the point-in-time check with a rotation gate: beginRotation
atomically refuses when a turn is running or another rotation holds the
gate, and stays held (c.rotating) from before Snapshot() through the
swap. runGuarded and RunTurn now also refuse to start while c.rotating,
so turn-start and session-rotation are mutually exclusive in both
directions and the run loop's session reference cannot change under it.

Regression test forces the interleaving deterministically by parking
NewSession inside Snapshot()'s recovery callback, then asserting a
concurrent RunTurn is refused, running stays false, and the session is
not mutated until the rotation completes. Verified it fails without the
c.rotating checks.
2026-07-06 16:45:54 +08:00
SivanCola c79a15de28 fix(desktop): make session trash tolerate missing artifacts 2026-07-06 16:22:29 +08:00
SivanCola 395aa3d799 fix(control): refuse NewSession while a turn is running
ClearSession and Compact both guard against rotating the session out
from under a running turn, but NewSession did not. Both the Submit
"/new" path (go c.NewSession()) and the bot gateway invoke it
asynchronously, so a mid-turn /new could swap the executor session
while the run loop reads it directly and race the turn-end snapshot
onto the fresh path.

Mirror ClearSession's running guard in NewSession. The bot gateway
cancels the in-flight turn before rotating, and that cancel is
asynchronous — give it a bounded window to unwind (same 5s/10ms shape
as the desktop's waitControllerStopped) and report the failure to the
chat instead of claiming a new session started.
2026-07-06 16:20:29 +08:00
SivanCola 52cc819466 Merge pull request #6082 from SivanCola/fix/recovery-chain-depth-cap
fix(agent): cap recovery chain depth and force-save at the cap
2026-07-06 16:18:50 +08:00
SivanCola 67bd9f19f1 Merge pull request #6080 from SivanCola/fix/snapshot-conflict-trigger-telemetry
feat(control): log snapshot-conflict triggers with the revision ledger
2026-07-06 16:10:49 +08:00
SivanCola bd33d5138c Merge pull request #6074 from SivanCola/fix/session-recovery-handoff-serialization
Serialize session recovery snapshots / 串行化会话恢复快照
2026-07-06 16:05:07 +08:00
SivanCola 790d06c4a3 Merge pull request #6079 from SivanCola/ci/cache-impact-desktop-watchlist
ci(cache-impact): watch desktop session prompt swap and environment probes
2026-07-06 16:02:25 +08:00
SivanCola 5ceab67371 fix(control): truncate the cancel flush under the snapshot handoff lock
replaceSessionAfterCancel took snapshotMu only around the SaveRewrite/
recovery flush, but the in-memory Replace ran before it. A mid-turn
autosave already holding the lock could then capture the truncated
transcript mid-save, read the longer partial autosave on disk as a
stale-prefix conflict, and adopt it back into the executor — silently
undoing the cancel cleanup before the flush could persist it, and
pointing the later SaveRewrite at the resurrected session.

Hold snapshotMu from the top of the cleanup so the truncation, todo
rebuild, path read, and flush are one atomic handoff.

The CancelFlush regression case now performs a real truncation (drop the
assistant reply, the shape stripTurnMessagesAfter produces) instead of a
same-content no-op, asserts the in-memory transcript is untouched while
the blocked handoff is still in flight, and verifies the truncation
lands on the recovery transcript afterwards. The blocked-phase assertion
fails against the previous code.
2026-07-06 15:57:44 +08:00
SivanCola 909bea3618 Merge pull request #6073 from SivanCola/fix/readiness-permission-loop
Stop readiness permission loops / 阻止 readiness 权限循环
2026-07-06 15:55:07 +08:00
SivanCola 82a9d866d6 fix(agent): cap recovery chain depth and force-save at the cap
A save-conflict loop used to fork recovery branches from recovery
branches without bound — #5993 reached 8 nested levels, each fork
polluting the session list. The conflict triggers behind that loop are
fixed, but nothing prevents a future regression from spawning chains
again: BranchMeta.Recovered was display-only and SaveRecoveryBranch
never looked at the parent's recovery state.

- Stamp BranchMeta.RecoveryDepth on every recovery fork (parent depth
  + 1; legacy recovery metas count as depth 1).
- SaveRecoveryBranch refuses to fork a parent already at
  SessionRecoveryMaxDepth (3) with ErrSessionRecoveryDepthExceeded.
- At the cap the controller force-saves the transcript onto the
  current branch instead: this runtime is the only writer of recovery
  branches it created, so the force write keeps the data, re-anchors
  the persistence baseline (no immediate re-conflict), and stops the
  file proliferation. A warn log plus a distinct notice make the
  condition visible.
2026-07-06 15:47:28 +08:00
SivanCola e4afe93738 fix(agent): harden readiness loop-guard pass and blocked-storm detection
- Replace the readiness loop-guard text scan with host state armed by the
  storm breaker itself: a guard on any result of a multi-call batch now
  unlocks the final blocker report, and tool output that merely quotes
  "[loop guard]" no longer does.
- Keep the pass across the bookkeeping the guard recommends (ask,
  todo_write, complete_step); revoke it once a successful write or command
  receipt proves receipts are obtainable again.
- Add a blocked-turn streak detector so rotating tools, reordering batches,
  or blockers whose text varies per attempt still trip the guard after
  three consecutive fully-blocked turns.
2026-07-06 15:44:50 +08:00
SivanCola 89b7555861 fix(control): serialize session swaps with the snapshot handoff
snapshotMu closed the snapshot-vs-snapshot recovery race, but snapshot
still reads sessionPath and the executor session as two separate steps,
and every session/path swap mutated that state without the lock. A swap
landing between the two reads pairs the old path with the new session:
best case it manufactures the same spurious conflict/recovery cascade,
worst case an append-shaped mismatch writes one transcript's messages
into another transcript's file (the loaded session has no persisted
baseline for the old path, so the revision CAS is disarmed).

Take snapshotMu across the swap sections of NewSession, ClearSession,
forkNamed, Branch, SwitchBranch, Resume, and SetSessionPath. ClearSession
holds it from artifact removal onward so a racing save cannot resurrect
the just-removed transcript. Resume releases it before
recoverInterruptedTurn/maybeColdResumePrune, which snapshot on their own
(the lock is not reentrant).

replaceSessionAfterCancel was a second save/recovery handoff outside the
lock: its post-cancel flush calls SaveRewrite and recoverSnapshotConflict
directly. Serialize it the same way and read the path under the lock so
an in-flight recovery retarget cannot leave it stale.

Regression test blocks the first recovery callback and races
SetSessionPath, Resume, and the cancel flush against the in-flight
handoff: none may complete until the handoff commits, and only one
recovery transcript may exist afterwards.
2026-07-06 15:38:51 +08:00
SivanCola 3b723f8f37 feat(control): log snapshot-conflict triggers with the revision ledger
Field reports of frequent "session changed on disk" notices (#6069)
are undiagnosable today: the stale-prefix adoption and the
recovery-not-needed adoption emit the same notice text, nothing logs
which trigger fired, and the revision ledger that drove the conflict
decision is discarded.

- Log every recoverSnapshotConflict outcome (stale-prefix adopt,
  recovery-not-needed adopt, failed adoption, recovery fork) with the
  conflict kind, save mode, message counts, and the
  BaseRevision/DiskRevision pair from SessionSnapshotConflictError.
- Differentiate the recovery-not-needed notice text ("local changes
  already covered") so user screenshots identify the trigger.
- The recovery-not-needed path whose disk adoption fails used to drop
  the snapshot silently; it now leaves a warning so "my last turns
  vanished" reports can be tied to it.
2026-07-06 15:37:45 +08:00
SivanCola 4ce57642ae ci(cache-impact): watch desktop session prompt swap and environment probes
The cache-impact gate watched only internal/ prompt surfaces, but two
cache-sensitive surfaces lived outside it:

- desktop/session_prompt.go rewrites the persisted leading system
  message with the freshly composed prompt on every tab resume/rebind;
  any byte difference there invalidates the whole conversation's prefix
  cache and persists the rewrite.
- internal/environment/* composes the environment section embedded in
  the cached system prefix; probe output changes shift the prefix bytes
  for every session.

Add both to the cache-sensitive list and to the system-prompt-sensitive
list so changes require Cache-impact/Cache-guard lines and an explicit
System-prompt-review note.
2026-07-06 15:32:21 +08:00
SivanCola 95f592a53d Merge pull request #6075 from SivanCola/fix/desktop-session-cost-scope
fix(desktop): key tab usage telemetry to its session so 会话费用 stops accumulating across sessions
2026-07-06 15:17:35 +08:00
SivanCola 5fe20ebda8 chore: internalize Windows sandbox backend 2026-07-06 15:11:47 +08:00
SivanCola 4da3f3e488 fix(desktop): key tab usage telemetry to its session
The per-tab usage telemetry that feeds the status bar 会话费用 had no
session identity, so its totals leaked across session rotations and were
then persisted into the wrong session's .telemetry.json sidecar:

- App.NewSession and the clear-while-running rebuild never reset it
  (only ClearSession did), so a "new session" kept the old running total.
- Rebinding a tab to a session without a telemetry sidecar (fresh
  session, CLI-created session) kept the previous session's totals and
  saved them into the new session's sidecar on the next event, so the
  contamination compounded into an all-sessions running total (#5850).
- A typed /new routes through Controller.Submit and rotates the session
  without the App layer ever noticing, so no App-side reset can cover it.
- A save-conflict recovery fork carried cost in memory but never wrote
  the fork's sidecar, undercounting to zero after a restart.

Fix: key the in-memory telemetry with the tab's sessionRuntimeKey and
re-sync at every telemetry writer (usage / turn start / turn done /
read-file recorders) and reader (ContextUsageForTab, ContextPanel). On a
key mismatch the new session's persisted sidecar is swapped in, or zero
when none exists. NewSession and the clear-while-running path reset the
telemetry to the rotated session; the build/rebind restore is now
unconditional and keys the telemetry; recovery forks re-key the carried
totals and persist the fork sidecar at retarget time; detach/attach tab
clones carry the key.
2026-07-06 15:08:55 +08:00
SivanCola c69b4d9540 fix(control): serialize session recovery snapshots 2026-07-06 15:05:54 +08:00
SivanCola 4d1998b0ca fix(agent): stop readiness permission loops 2026-07-06 14:59:40 +08:00
SivanCola 3d2ed10742 Merge pull request #6063 from SivanCola/fix/session-exact-append-stale-baseline
Fix session append recovery conflicts / 修复会话追加误入冲突副本
2026-07-06 14:22:12 +08:00
SivanCola f4591d7c1c fix(agent): let a same-content foreign stamp keep rewrite ownership
A mirror runtime healing the revision ledger over identical content
(same-content save recording a missed revision) advanced the revision
under its own writer id. The next owned rewrite on this side — a
cancelled turn's truncation or an auto-compaction — then read as a
stale-runtime conflict and forked a recovery branch, even though the
on-disk transcript was byte-for-byte what this session last persisted.

ownsPersistedState now consults the ledger's recorded content digest:
when the baseline digest, the on-disk transcript digest, and the
ledger digest all describe the same bytes, a foreign revision stamp is
bookkeeping over content this session owns, and rewriting it destroys
nothing of the stamper's. At worst the conflict relocates to the
stamper's next divergent save, where its own in-memory history forks a
recovery branch as usual — no content-loss path opens.

A stamp whose digest disagrees with the on-disk transcript still
revokes ownership: that is the aftermath of a save whose bytes landed
but whose record failed, the bytes cannot be attributed to a writer
(event writer ids are per-process, not per-session), and only the
conservative conflict path preserves them.
2026-07-06 14:05:08 +08:00
SivanCola 895972d792 fix(agent): anchor stale-baseline session saves to the persisted baseline
Replace the exact-append CAS exemption with a reachability guard:
locate the persisted baseline digest among the pending snapshot's
incremental prefix digests (re-anchored on the disk system message when
a resume swapped the prompt) and require the on-disk transcript to
still reach that depth.

This extends the stale-revision exemption to compatible-system appends
(resume swapped the leading system prompt, then a routine turn append
forked a spurious recovery branch), while closing the gap the
exact-append exemption opened: an append over a transcript another
runtime deliberately rewound below the baseline now conflicts again
instead of silently resurrecting the removed suffix.

ownsPersistedState no longer lets an absent revision ledger revoke
rewrite ownership: recorded revisions start at 1, so a disk revision of
0 means the meta sidecar was deleted or rebuilt by a listing-only
writer after this session's save — the digest+version match already
proves the transcript is ours. Cancel-turn truncations and compaction
rewrites therefore survive a swept sidecar. A foreign recorded revision
still revokes ownership, preserving the guard against stale controllers
force-rewinding a ledger another runtime has stamped.
2026-07-06 13:07:23 +08:00
SivanCola 190a52283d Merge pull request #6062 from esengine/codex/desktop-release-latest
fix(release): keep desktop as repository latest
2026-07-06 12:50:32 +08:00
SivanCola 7f9491966c fix(agent): allow safe session append from stale revision baseline 2026-07-06 12:25:04 +08:00
SivanCola 97c746625a fix(release): keep desktop as latest release 2026-07-06 12:23:43 +08:00
SivanCola 0f4e3f3bb8 Merge pull request #6056 from SivanCola/fix/desktop-meta-git-branch-async
Avoid blocking metadata hydration on git branch probes / 避免 git 分支探测阻塞元信息刷新
2026-07-06 11:52:46 +08:00
SivanCola b922cb2f64 Merge pull request #6057 from esengine/codex/hydrate-performance-breadcrumbs
test: Assert stale hydrate metadata is ignored
2026-07-06 11:46:25 +08:00
SivanCola 53138c7658 test(desktop): close probe release channel once to fix test race 2026-07-06 11:45:44 +08:00
SivanCola b1c357812d fix(desktop): clear branch refresh flag even if the probe unwinds 2026-07-06 11:36:03 +08:00
SivanCola 345212f7fb test: assert stale hydrate metadata is ignored 2026-07-06 11:28:48 +08:00
SivanCola d60c52e5c7 fix(desktop): refresh metadata git branch asynchronously 2026-07-06 11:25:59 +08:00
SivanCola d58238782c Merge pull request #6036 from SivanCola/fix/global-topic-index-repair
fix desktop Global topic index repair
2026-07-06 10:39:38 +08:00
SivanCola aabf833416 Merge pull request #6041 from SivanCola/fix/rebuild-recovery-path-continuity
Continue on recovery path when pre-rebuild snapshot conflicts / 重建前快照冲突时沿恢复分支继续写入
2026-07-06 10:33:24 +08:00
merge-order-check 2d1b53d7be skip tombstoned topics when reusing indexed blank topics 2026-07-06 10:07:04 +08:00
merge-order-check 4ed24c6bf4 fix(acp): resolve session ids to the recovery transcript after restart
sessionRecoveredHandler moved the live session's transcript and lease to
the recovery file, but persisted lookups still treated the id-keyed path
as canonical: after a process restart, session/load and session/resume
reopened the pre-recovery transcript (the user's recovered work silently
vanished from ACP's view), loadMeta read the stale sidecar, non-live
session/delete removed the original while the recovery file lingered as
an undeletable orphan, and session/list could surface the stale sidecar's
title and timestamps.

Leave an ActiveTranscript redirect on the id-keyed sidecar when a recovery
commits (always written against the id-keyed path, so resolution stays a
single hop even for recovery-of-recovery), and resolve ids through it:
resolveTranscriptPath validates the redirect stays inside the session dir,
exists, and claims the same session id before following, falling back to
the id-keyed path otherwise. session/load, session/resume, loadMeta, and
the non-live delete fallback all resolve through it; session/delete also
removes the id-keyed twin so neither file resurfaces as a ghost; and
session/list reduces the two sidecars claiming one id to the active
transcript's meta.

Regression tests cover restart-time load (resolved transcript holds the
recovered content), restart-time delete (both transcripts and sidecars
removed, list empty), and list showing exactly one entry backed by the
active transcript's metadata.
2026-07-06 09:51:06 +08:00
merge-order-check cf9afb9d9e fix(acp): move session transcript and lease with snapshot recovery
When a snapshot conflict retargeted the controller to a recovery branch,
the ACP session object kept sess.transcript and sess.lease on the original
file: session/prompt reported the stale transcriptPath, session/delete
destroyed the wrong file (orphaning the live recovery transcript), and the
controller kept writing the recovery file with no session lease guarding
it against other runtimes.

Wire control's OnSessionRecovered hook through SessionParams and the acp
factory into every controller the service builds. The handler acquires the
recovery lease before releasing the original (Rebind order), moves
sess.transcript, and saves ACP meta against the recovery file at commit
time; a lease failure aborts the recovery commit so the controller stays
on the original path. This covers both recovery triggers: the turn-end
autosave in persistAfterTurn and the pre-rebuild snapshot in
rebuildSession.

Regression tests assert transcript, lease, and controller agree on the
recovery path after both triggers, the original transcript's lease is
released, and follow-up saves do not derive a second recovery branch.
2026-07-06 09:43:41 +08:00
merge-order-check 7378f0ff1f fix(session): continue on recovery path when pre-rebuild snapshot conflicts
Controller rebuild paths (desktop model/effort/token-mode/settings switch,
serve switchModel, TUI /model and /effort, ACP rebuildSession) captured the
session path before snapshotting the old controller. A snapshot conflict can
retarget the controller to a recovery branch, so the stale pre-snapshot path
bound the just-recovered transcript back to the original file - every later
save re-conflicted and derived yet another recovery branch, looping recovery
notices on the affected session.

Capture the continue/resume/adopt path (and carried history) after Snapshot
instead, so a rebuild keeps writing the recovery branch the snapshot just
committed to. Desktop gains sessionPathAfterSnapshot to re-read the old
controller's path on all four rebuild paths; CLI/serve/ACP move the capture
below the snapshot call.

Regression tests cover each surface: the rebuilt controller must land on the
recovery path and a follow-up snapshot must not derive a second recovery
branch.
2026-07-06 09:43:41 +08:00
merge-order-check 1aebd4fcdf prune deleted topic titles on scan saves and hide tombstoned topics in tree 2026-07-06 09:39:47 +08:00
SivanCola 9f43d13f5e Merge pull request #6037 from SivanCola/fix/unique-trash-conflict
fix(desktop): preserve conflicting trash sessions
2026-07-06 09:14:22 +08:00
merge-order-check 22608aa2c7 repair only missing topic indexes and close delete tombstone race 2026-07-06 09:07:45 +08:00
merge-order-check b9beebd5bf fix(desktop): preserve conflicting trash sessions 2026-07-06 09:06:12 +08:00
SivanCola 082e188198 Merge pull request #5719 from GTC2080/GTC/fix-5717-tui-cjk-repaint
fix(cli): repaint wide-character input on Windows
2026-07-06 09:05:29 +08:00
SivanCola e155699904 Merge pull request #6039 from SivanCola/fix/npm-dist-tag-stable-promotion
fix(npm): promote stable publishes to the latest dist-tag
2026-07-06 08:50:03 +08:00
SivanCola 1f46e71c69 Merge pull request #6038 from SivanCola/fix/desktop-updater-ua-github-fallback
fix(desktop): identify updater traffic and add a GitHub manifest fallback
2026-07-06 08:49:48 +08:00
SivanCola 8d4cfad768 Merge pull request #6040 from SivanCola/ci/release-public-smoke
ci(release): smoke the public update paths after publishing
2026-07-06 08:49:32 +08:00
merge-order-check 7af507b2c3 fix(desktop): identify updater traffic and add a GitHub manifest fallback
Both manifest endpoints (dl.reasonix.io, crash.reasonix.io) share one
Cloudflare zone, so bot protection that 403s a user's egress IP takes out
the whole chain at once (#6005). Three changes:

- Send a descriptive User-Agent on manifest and artifact requests so the
  edge can allowlist updater traffic instead of scoring it as a generic
  Go client.
- Append the GitHub compatibility manifest as the stable channel's last
  resort. release.yml pins the desktop-manifest mirror to the repo-wide
  latest release (the compatibility asset), so the URL always serves the
  desktop manifest, from infrastructure outside that Cloudflare zone.
- Keep every endpoint's failure via errors.Join instead of surfacing only
  whichever endpoint died last, so reports like #6005 show the full chain.
2026-07-06 04:16:23 +08:00
merge-order-check 6bb5e61b7f ci(release): smoke the public update paths after publishing
The authenticated R2 smoke verifies the mirror landed but says nothing
about what end users' updaters actually reach — #5826/#5858 (manifest 404)
shipped broken for weeks because no release step exercised the public
edge, and #6005 (gateway 403) was only visible in user reports.

- release.yml: after attaching the desktop-manifest compatibility asset,
  fetch it back anonymously with a Go client UA via GitHub's public
  releases/latest URL — the exact path pre-v1.16 updaters poll. GitHub
  serves its own runners (no bot wall), so this asserts hard.
- release-desktop.yml: probe the release gateway the same way. 200 must
  serve the just-released version; 403 is the known bot-protection gap
  (#6005) and only warns until a WAF skip rule lands; 404/5xx/stale
  version fail the release.
2026-07-06 04:15:58 +08:00
merge-order-check adadf64fa1 fix(npm): promote stable publishes to the latest dist-tag
The "0.x stable owns latest" rule was a migration guard from when the 1.x
Go line was rc-only. Once 1.x went stable the guard inverted into a trap:
latest stayed pinned at 0.53.2, so pnpm update -g / npm update -g silently
downgraded every 1.x user back to the retired line, and the documented
manual promotion (npm dist-tag add) never happened (#5822).

Stable versions now always publish under latest; rc/beta stay on next and
canary stays on canary. A post-publish verify step polls the registry and
fails the release if the tag did not land — the guard #5822 lacked.
2026-07-06 04:13:43 +08:00
SivanCola d61dc916c8 Merge pull request #6034 from SivanCola/feature/session-lease-cli-acp-serve
Take session leases in CLI, ACP, and serve / CLI、ACP、serve 写绑定会话时接入会话租约
2026-07-06 03:56:24 +08:00
merge-order-check d1a3cc999c fix(ci): bound windows lease tests 2026-07-06 03:51:03 +08:00
merge-order-check 97bc4488b5 fix desktop global topic index repair 2026-07-06 03:45:04 +08:00
SivanCola 9c7c87e964 Merge pull request #6035 from SivanCola/fix/nested-tool-compass-icon
fix(desktop): refine nested tool count icon / 优化嵌套工具计数图标
2026-07-06 03:24:51 +08:00
merge-order-check 1e7a44102c fix(desktop): refine nested tool count icon 2026-07-06 03:19:02 +08:00
SivanCola 3211ad62a9 Merge pull request #6032 from SivanCola/fix/windows-sandbox-escape-prompt
Add Windows sandbox escape approvals
2026-07-06 03:14:22 +08:00
merge-order-check 4f3bfa9030 Add Windows sandbox escape approvals 2026-07-06 03:00:02 +08:00
merge-order-check 1aac234fc6 fix(serve): serialize session-path-changing handlers on one binding mutex
Review finding on this PR: /resume, /new, and /fork ran their
snapshot -> lease rebind -> controller bind sequences without any
cross-request serialization. net/http runs handlers concurrently and
one serve instance backs multiple browser tabs, so two interleaved
requests could leave the controller writing session B while the lease
keeper guards session C - B unprotected, C wrongly occupied - exactly
the split this PR exists to prevent. switchModel was serialized by its
own switchMu but nothing stopped the other handlers from rebinding the
same controller mid-switch.

Widen switchMu into bindMu and take it in every entry point that
changes the active session path (/resume, /new, /fork, switchModel).
The lock is acquired after body decoding and path validation, so a
slow client cannot hold the binding lock while uploading; lock order
stays bindMu -> s.mu with no reverse path.

Regressions: a deterministic interleave test parks the first resume
between its rebind and its Resume via a test hook (gated by an atomic
CAS, not sync.Once - Once.Do's internal mutex would accidentally
re-serialize the window) and asserts the second resume waits and both
land with controller and lease aligned; removing the guard makes it
fail. Plus two hammer tests (concurrent resume x resume and
resume x fork) asserting the same alignment invariant under -race.

Refs #6034 review.
2026-07-06 02:59:53 +08:00
merge-order-check caaead1cd9 feat(cli/acp/serve): take session leases when binding sessions for writing
Phase 2b of #6027: the desktop has held session leases since v1.16, but
CLI --resume/--continue, the chat TUI's session switches, serve, and ACP
never acquired one — leaving desktop+CLI on the same session as the last
real silent dual-writer entrance (CAS only detected it after the fact by
forking recovery copies).

- control.SessionLeaseKeeper: single-holder lease follower for the
  non-desktop frontends. Rebind acquires the new path before releasing
  the old one, no-ops on the already-held canonical path, releases on
  empty path, and leaves the previous lease untouched on failure.
  control.SessionInUseMessage renders the shared holder line (pid, host,
  since; never the writer id or the session path).
- CLI: run/chat resume targets fail fast before setup when held, with
  the holder line plus the two ways out (close the holder, or rerun
  with --copy). New --copy flag duplicates the resume target beside the
  original (event-log-aware Session.Save; parent/preview/turns/model
  meta; ' (copy)' title suffix) and continues writable on the copy.
  In-TUI /resume, /switch refuse held targets and keep the current
  session; /switch re-points the lease if the switch itself fails after
  the lease moved. /new, /clear, /branch, and rewind-fork follow the
  controller onto fresh paths. Leases release after the controller
  closes on exit.
- serve: POST /resume returns 409 with the holder line when held (state
  unchanged); /new, /fork, and model-switch path rotations follow their
  new paths. Read-only endpoints are unaffected.
- ACP: session/new, session/load, and session/resume guard their bind;
  held sessions surface a protocol error with the holder line.
  session/close, session/delete, and closeAll release; delete releases
  before removing files so no lock sidecar is left behind on Windows.
- controller: export ResolveBranchRef (wrapper over resolveBranch) so
  the TUI can learn a /switch target's path before moving its lease.
- Crash leftovers need no special casing: the OS lock is the sole
  arbiter, so a dead holder's lease is simply acquirable.

Not wired in this round (documented in #6027): the bot gateway's
attached-session path (needs desktop-style lease handover semantics)
and destructive-op guards for serve/ACP delete endpoints.

Refs #6027.

Cache-impact: none - CLI/serve/ACP session binding and help text only;
no provider-visible prompts, tool schemas, or request serialization.
2026-07-06 02:15:20 +08:00
SivanCola 075b961254 Merge pull request #6030 from SivanCola/fix/lease-experience-cleanup
Polish lease UX: self-heal metadata damage, sanitize errors, retire lock sidecars / 打磨租约体验:元数据损坏自愈、错误脱敏、锁侧车清理
2026-07-06 01:03:53 +08:00
SivanCola 8ea1c4905b Merge pull request #6023 from SivanCola/fix/desktop-lease-key-canonicalization
Fix Windows desktop session lease self-lock on model switch / 修复 Windows 桌面端切换模型会话租约自锁
2026-07-06 00:53:27 +08:00
merge-order-check cce4bfa2bb fix(desktop): release replaced-tab resources when session clear loses the swap
clearActiveSessionRuntime's swap-time identity check silently dropped
the freshly built controller when the tab had been closed or replaced
while the clear ran off-lock: newCtrl was never closed and the session
lease it had just bound stayed held by the abandoned tab struct.

Mirror rebuildSettingLocked's failure branch: close the fresh
controller, release the lease, still finish the destroyed runtime's
teardown, and report the lost swap instead of returning success.
2026-07-06 00:24:36 +08:00
SivanCola 8ab68bf0fc Merge pull request #6025 from SivanCola/fix/session-meta-cas-hardening
Harden session CAS meta ledger against torn reads and lost updates / 加固会话 CAS 元数据账本防撕裂读与丢失更新
2026-07-06 00:10:40 +08:00
SivanCola 6fae377e2f Merge pull request #6029 from SivanCola/fix/replacefile-no-torn-fallback
fix(fileutil): keep ReplaceFile atomic under transient rename failures / 瞬时重命名失败时保持 ReplaceFile 的原子发布
2026-07-06 00:10:10 +08:00
merge-order-check 5ac17b55f0 fix(desktop): supersede in-flight startup build on provider removal and session clear
Two more sync-swap paths shared the race fixed for settings rebuilds:

- removeBuiltInProviderAccessAndRetargetTabs and
  deleteProviderAndRetargetTabs cleared tab.Ctrl without bumping the
  build generation, so a startup build planned against the removed
  provider could finish later, pass its generation check, and reinstall
  a controller for it - racing the generation-0 retarget rebuild.
- clearActiveSessionRuntime installed the fresh controller without
  invalidating an in-flight build, whose completion would overwrite the
  swap and resurrect a runtime bound to the just-destroyed session.

Supersede the build inside the same critical sections that perform the
swap, and extract a shared assertion helper for the regression tests
covering all four protected paths.
2026-07-06 00:09:40 +08:00
SivanCola 4065a6218b Merge pull request #5937 from SivanCola/feature/windows-native-sandbox
Add native Windows sandbox helper / 添加原生 Windows 沙箱 helper
2026-07-06 00:03:12 +08:00
SivanCola c33c372a2f fix(desktop): self-heal lease metadata damage, sanitize surfaced errors, retire lock sidecars
Follow-up polish on the lease/CAS stack after the #6023/#6025 hotfixes,
closing the remaining ways a healthy single-window session could still
present as busy, leak holder details, or litter the session directory.

- Reclaim now arbitrates on the OS lock alone: a missing or unreadable
  lease.json (user cleanup, AV quarantine, crash tear) with a free lock is
  a leftover, not a holder. Previously an orphaned in-process entry whose
  info file disappeared could never be reclaimed - the fallback acquire
  re-hit the orphaned entry forever and every rebuild stayed busy. The
  desktop gate mirrors this: nil lease info allows the reclaim attempt,
  foreign holders are still refused.
- Raw lease errors (session path + host-pid-writer id) can no longer reach
  the UI: startup bind failures (StartupErr feeds the topbar banner and tab
  metas), ClearSession's bridge returns, and the recovery-lease callback
  (chat notices via snapshot errors) all render the user-facing busy
  message; raw details stay in slog. The busy message drops its dangling
  'before changing this setting' clause when no setting applies.
- SessionLease.Release retires the .lock/.lease.lock sidecars through the
  existing atomic take-then-remove helpers (non-blocking, no-op against any
  live holder or in-flight save), so ordinary use stops accumulating lock
  files that previously waited for the next boot reconcile (#6014).
- Autosave failures warn once per burst and at most once per 5 minutes per
  tab: retries are slog-only, the user notice fires when the burst gives
  up. Explicit action saves keep immediate feedback. Every failure is still
  logged.
- SaveRecoveryBranch tolerates event-index write failures like the other
  save paths: the recovery transcript and meta are already durable, so a
  failed listing accelerator no longer discards a successful recovery.
- Lease test suite now feeds user-shape (mixed-case) paths to the APIs
  under test instead of pre-canonicalized ones - the pattern that let the
  Windows case-fold mismatch (#5999) escape the suite - plus regressions
  for orphan-without-info, corrupt-info, foreign-info-refused, sidecar
  retirement on release, StartupErr sanitization, and the autosave warning
  debounce.

Refs #6014 #5999 #6027.
2026-07-05 23:59:24 +08:00
SivanCola 1054c188d3 Bump windows-sandbox for run-local residue markers 2026-07-05 23:54:13 +08:00
SivanCola bfe416b706 fix(desktop): supersede in-flight startup build on settings rebuild
rebuildSettingLocked installed its controller without bumping the tab's
build generation, so a startup build still in flight would later pass
its generation check, overwrite the just-installed controller, and leak
it - leaving the session on a runtime built before the new credentials
were saved. Provider-key saves hit this window most often: users
typically save a key right while the initial startup build is running.

Apply the same supersede-after-successful-swap the model/effort/token
switches use, and add a regression test that models an in-flight build
(live generation plus cancellable build context) and asserts the rebuild
supersedes and cancels it.
2026-07-05 23:51:25 +08:00
SivanCola 728d4af4ce fix(session): heal stale meta ledger on same-content saves
A save that lands its transcript bytes and then fails to record the
revision (fail-closed recordSessionContentRevision, or a crash between
the two writes) leaves the sidecar describing older content. The
same-content retry then took the up-to-date shortcut and never touched
the ledger again, so revision/content_digest stayed stale until the
next content-bearing save — contradicting the "later save can bump the
revision" recovery the fail-closed path promises. Idle sessions never
healed at all.

checkSnapshotWrite now surfaces the ledger digest it already reads, and
the up-to-date path records the deferred revision when that digest no
longer matches the on-disk transcript, reproducing the exact state the
interrupted save would have left. A missing or digest-less legacy
sidecar stays untouched: stamping those would bump revisions other
runtimes still hold as baselines.
2026-07-05 23:48:32 +08:00
SivanCola df181876f3 Merge pull request #6004 from HaoyueQin/feat/isolated-runtime-config-override
feat(config): support REASONIX_HOME for fully isolated runtimes / 支持 REASONIX_HOME 完全隔离运行时
2026-07-05 23:44:03 +08:00
SivanCola 7ea789d93b docs: sync isolated home Chinese path guide 2026-07-05 23:38:44 +08:00
SivanCola 34a337ac0f fix(fileutil): never fall back to a torn in-place copy on transient rename failures
ReplaceFile fell back to copyOnto after exhausting its rename retries,
regardless of why the rename failed. copyOnto truncates dest in place,
so a reader racing the copy observes an empty or half-written file —
silently breaking the atomic-publication contract every AtomicWriteFile
caller relies on (session leases, credentials, plugin state, mcp.json).
CI caught it once the window lined up: TestStateLoadDuringSaveNeverSees
TornFile failed on windows-latest with 'unexpected end of JSON input'
after a transient dest lock outlived the ~0.7s retry budget and the
copy truncated the state file under the reader.

Split the failure classes. Transient locks (antivirus, search indexer,
a reader without delete sharing) now retry longer — 12 attempts, ~1.5s
of backoff — and then surface the rename error; a loud failure beats a
silently torn file. The cross-device class (ERROR_NOT_SAME_DEVICE /
EXDEV, reported by encryption-software filter drivers even for a
same-dir rename, #2696) still takes the copy fallback because rename is
structurally impossible there — but now immediately, instead of first
sleeping through a retry ladder that cannot succeed, which also cuts
~0.7s of latency from every save on such hosts.

The rename seam (renameFile) exists because neither failure class can
be provoked portably on a real filesystem; the new tests pin dest
integrity plus the surfaced error for the transient class, and the
immediate no-retry copy for the cross-device class.
2026-07-05 23:32:53 +08:00
SivanCola 1cc80b8100 Merge remote-tracking branch 'origin/main-v2' into pr6004-isolated-runtime-config-override
# Conflicts:
#	docs/CONFIG_PATHS.md
2026-07-05 23:23:45 +08:00
SivanCola 4c6dc74500 fix: keep implicit legacy migration out of isolated homes 2026-07-05 23:22:36 +08:00
SivanCola 7e2d115df3 Merge pull request #6024 from SivanCola/feature/task-contract-credentials-path
feat: add task contracts and stabilize credential path
2026-07-05 23:08:18 +08:00
SivanCola a8e9421bf9 Bump windows-sandbox to survive marker PID reuse
Picks up b79e9a5: the crash-residue marker is keyed by PID alone, so a
helper process that drew a crashed predecessor's PID used to skip the
stale marker in its run-start sweep (pid == self), append its own lines
into the same file, and then delete the whole file on exit — orphaning
the predecessor's forbid_read deny or tool-directory grant beyond any
future sweep's reach. The library now tracks marker ownership
in-process: cleanup only deletes a marker this process wrote, and an
unowned file at the run's own PID is consumed at run start as a dead
predecessor's residue. The filename stays <pid>.txt so older binaries
sharing the marker directory keep parsing PIDs correctly during
upgrade windows.
2026-07-05 23:06:13 +08:00
SivanCola 88598c8fc3 docs: clarify state root paths 2026-07-05 22:58:26 +08:00
SivanCola e6d574587a fix(session): harden the CAS meta ledger against torn reads and lost updates
The transcript CAS keyed off the .jsonl.meta sidecar, but the ledger was
maintained unprotected. On Windows this manufactured conflicts in normal
single-window use: recovery branches forked on every app start, recovery
files accumulated, and 'adopted the newer transcript' notices looped.

- Retry transient sidecar reads (I/O and JSON-decode failures, 20/50/100ms)
  before giving up: ReplaceFile's non-atomic copyOnto fallback tears
  concurrent readers, and AV/indexer handles fail reads transiently.
- Stop swallowing unreadable-ledger errors into revision 0: a persistently
  unreadable ledger now fails the save (transcript bytes are already
  durable; the autosave retry heals it) instead of desyncing the baseline
  and misreading the next honest save as a foreign write.
- LoadSession with an unreadable sidecar still opens the session but marks
  the baseline revision-unknown: revision CAS is disarmed (digest/prefix
  checks still catch real divergence) and owned rewrites anchor on
  digest+version, so compaction cannot become a permanent conflict. A
  missing sidecar remains a known revision-0 baseline.
- Hold the per-path meta lock across the whole read-modify-write in
  RenameSession, topic-title propagation, legacy topic migration, and
  restoreSessionTopicIndex, so a concurrent save's revision bump can no
  longer be rolled back by a stale read-back.
- Downgrade event-index write failures to a warning at both save() sites:
  the index is a listing accelerator and must not strand the baseline
  behind a transcript+revision that already landed.
- Capture save() snapshots under the save locks so concurrent in-process
  savers can no longer land out of order and misread the newer transcript
  as a stale-prefix conflict.

Regression tests cover fail-closed saves on a corrupt ledger, unknown-
revision baselines (load, append, owned rewrite), missing-meta behavior,
index-failure tolerance, a concurrent writers hammer asserting revision
monotonicity, and deterministic under-lock capture.

Fixes #6014. Fixes #5998. Fixes #5996. Refs #6003.
2026-07-05 22:34:43 +08:00
SivanCola 98a1fb29a0 feat: add task contracts and stabilize credential path 2026-07-05 22:24:48 +08:00
SivanCola aa503bca85 fix(desktop): unify session runtime keys with lease canonicalization
The lease registry keys sessions through agent's canonical form (clean,
absolute, lowercased on Windows), while desktop sessionRuntimeKey kept the
raw path case. On Windows every tab path contains uppercase segments
(C:\Users\...), so the tab's own lease never matched its session key:
ensureSessionLease re-acquired against the process's own registry entry,
reclaim then hit the tab's live OS lock handle, and every model / effort /
token-mode / provider-key rebuild failed with the "already open in another
Reasonix window" error on a single window — including fresh installs.

- Export agent.CanonicalSessionPath and layer it into sessionRuntimeKey so
  desktop identity checks agree with the lease registry byte-for-byte.
- Normalize the canonicalTabSessionPath fallback (project-scope sessions)
  with clean+abs so shape variants cannot split one file into two keys.
- Keep case-folded keys out of SessionPath: detach/attach paths now carry
  the display path separately from the runtime key.
- Release only the build's own lease on startup-build failure, and skip
  claiming a lease the build merely reused (a concurrent switch owns it).
- Supersede in-flight startup builds when a model/effort/token switch
  installs its controller, so a slow blank-session build can no longer
  overwrite the new runtime or strip its lease.
- Regression tests: lease/runtime key agreement, idempotence, Windows case
  folding, project-scope fallback normalization, fast-path lease reuse.

Fixes #5999. Fixes #6006. Fixes #5989. Fixes #5990. Refs #5996.
2026-07-05 22:17:38 +08:00
SivanCola 98166cbc60 Merge pull request #6001 from HUQIANTAO/fix/disable-summ-from-on-last-turn
fix(desktop): disable "Summarize after this" on the last message
2026-07-05 22:11:00 +08:00
SivanCola bd045b0dfd Bump windows-sandbox to harden the residue sweep
Picks up the follow-up (3058fd1) that re-checks the system-root
invariant at sweep time: the crash-residue marker lives in the user's
%TEMP% and is untrusted, so a legacy or planted line naming System32 /
Program Files could previously drive the next run's sweep to strip the
broad built-in package SIDs from a system directory's factory ACLs. The
sweep now refuses system paths independently of the recorder-side
filter.
2026-07-05 22:09:21 +08:00
SivanCola 0bf3c44a11 Fix last summary turn detection
Use the same authoritative question turn mapping for the last-turn check that TurnActions uses for rewind actions. This keeps "Summarize after this" disabled on the last visible turn even when restored or paged history uses non-contiguous checkpoint turns.

Co-authored-by: SivanCola <[email protected]>
2026-07-05 19:56:04 +08:00
HaoyueQin faaa43d459 test(boot): update legacy session migration test for REASONIX_HOME isolation
When REASONIX_HOME is set, legacy session migration is skipped to
prevent production data leaking into the isolated environment. Update
TestBuildMigratesLegacyXDGAndProjectSessions to verify this behavior.
2026-07-05 17:12:05 +08:00
HaoyueQin a238ace545 feat: support REASONIX_HOME for fully isolated runtime environments
When REASONIX_HOME is set, the runtime is now fully self-contained: all
configuration, state, cache, skills, commands, hooks, trust, credentials,
migration, and desktop tab/project data live under that directory tree.
Legacy fallback paths and auto-migration are skipped so a source-built
binary can run side-by-side with a stable release without interfering.

Changes:
- config/paths.go: add IsolatedHomeDir(), gate legacyOSSupportDir /
  legacyXDGConfigPaths, make userCacheDir() fall back to
  REASONIX_HOME/cache when isolated
- config/migrate.go: skip MigrateLegacyIfNeeded when isolated
- config/mcpjson.go: skip legacyConfigPath when isolated
- config/ccswitch.go: skip LoadCCSwitchMCP when isolated
- config/credentials.go: skip ~/.env credential source when isolated
- skill/skill.go: skip OS-home convention dir scan when isolated
- hook/hook.go: skip legacy reasonix home fallback when isolated
- migration/migration.go: skip auto migration entry points when isolated
- outputstyle/outputstyle.go: skip home dir scan when isolated
- desktop/tabs.go: desktopConfigDir / globalWorkspaceRoot now use
  config.ReasonixHomeDir() instead of raw os.UserConfigDir()
- desktop/updater.go: defaultUpdateCacheBaseDir uses config.CacheDir()
- CONTRIBUTING.md: document isolated development workflow
- docs/CONFIG_PATHS.md: document full isolation behavior

Closes #5988
2026-07-05 17:00:29 +08:00
HUQIANTAO ab25819640 fix(desktop): disable "Summarize after this" on the last message
The "Summarize after this" (summ-from) button was enabled on the last
user message even though there is nothing after it to summarize. This
mirrors the existing check that disables "Summarize before this" on
the first message (turn <= 0).

- Add isLastTurn prop to TurnActions component
- Check isLastTurn in actionDisabledReason for summ-from scope
- Pass isLastTurn from Transcript hot zone and WarmTurnItems
- Add i18n key rewind.disabledNoLater (en/zh/zh-TW)

Closes #5744
2026-07-05 16:49:01 +08:00
YHH 5c18d06e1e Merge pull request #5997 from esengine/fix/desktop-release-not-latest
fix(ci): keep the repository-wide latest badge on the CLI release
2026-07-05 16:18:18 +08:00
esengine 36c9a44728 fix(ci): keep the repository-wide latest badge on the CLI release
release-desktop.yml's header reserves GitHub's repository-wide "latest"
for the CLI line, but gh release create was not passing --latest=false,
so whichever line published second won the badge — desktop-v1.17.0
stole it this cycle (fixed by hand with gh release edit). Pin desktop
releases to --latest=false so the badge stays on the v* line.
2026-07-05 01:07:14 -07:00
YHH b9612efc5f Merge pull request #5995 from esengine/fix/release-smoke-via-r2-api
fix(ci): smoke release pointers via authenticated R2 S3 API
2026-07-05 15:56:04 +08:00
esengine 9d1925ee6e fix(ci): smoke release pointers via authenticated R2 S3 API
dl.reasonix.io (Cloudflare) persistently serves 403 to GitHub Actions
egress IPs, so every stable release run failed at the public-edge curl
steps even though the publish itself succeeded, and the failure skipped
the two real follow-up steps (attach compatibility latest.json to the
CLI release, refresh the site version).

Read the mirrored manifests and head the platform objects over the
authenticated S3 endpoint instead:

- release-desktop.yml smoke: aws s3 cp / s3api head-object against the
  bucket, mapping each public URL back to its object key. Verifies the
  mirror landed; the public edge is unreachable from CI by Cloudflare
  policy and is exercised by real user traffic.
- release.yml compatibility asset: fetch latest/latest.json via S3
  using the same R2 secrets, skipping cleanly when they are absent
  (matching the desktop workflow's HAS_R2 guard).
2026-07-05 00:49:39 -07:00
SivanCola 39bfc0b045 Bump windows-sandbox to never mutate system executable dirs
Picks up two upstream fixes: exe-dir contention coverage with the
close of the deny-residue window (2a86cf8, 0e09612), and the follow-up
that stops grantAppContainerExecutable from snapshotting, granting, or
recording crash-residue on system directories (1a3e77e) — previously a
crashed elevated run could leave a residue marker that made the next
run strip built-in package SIDs from System32 / Program Files ACLs.
2026-07-05 14:48:13 +08:00
SivanCola 2ecc3bbee0 Bump windows-sandbox for per-root lock thread pinning
Pick up the follow-up that pins the per-root lock to one OS thread, so a Go
goroutine migrating threads cannot release a Windows mutex from a non-owning
thread or re-enter it within the process. Cross-process serialization (each
sandboxed command is its own helper process) was already correct; this
tightens the within-process semantics the library's unit tests cover.
2026-07-05 14:44:28 +08:00
SivanCola ece9c41f8f Bump windows-sandbox with ACL lifecycle hardening
Update the native Windows sandbox backend to the release that fixes the
mutation-based-isolation failure modes: recursive icacls timeout on large
workspaces, residual Low integrity labels after cleanup, concurrent runs
corrupting each other's ACL boundary, forbid_read deny residue after a crash,
forbid_read entries pointing at files being silently unprotected, and
PATH-resolved icacls/taskkill.

Correct the docs to match the actual Windows boundary: writable low-integrity
commands can still write the narrow set of locations Windows leaves writable to
any low-integrity process (e.g. AppData\LocalLow) in addition to the configured
roots, concurrent commands on a shared workspace are serialized, forbid_read
now covers files, and crash residue is swept by the next run.
2026-07-05 14:44:28 +08:00
SivanCola 1071bc41e7 Fix Windows sandbox wait env var 2026-07-05 14:44:28 +08:00
SivanCola 52f1b7535e Test Windows sandbox helper integration 2026-07-05 14:44:28 +08:00
SivanCola f541bd353a Bump Windows sandbox test coverage dependency 2026-07-05 14:44:28 +08:00
SivanCola a7582e3d20 Add native Windows sandbox helper 2026-07-05 14:44:28 +08:00
SivanCola f4ef59f2ff Merge pull request #5976 from SivanCola/fix/session-recovery-event-log-cleanup
Fix session recovery persistence cleanup / 修复会话恢复持久化清理
2026-07-05 14:24:14 +08:00
SivanCola 7bb7290c2f Hold session locks across trash/delete and fix lint
Review follow-ups on the event-log hardening:

- Close the probe-then-delete lease window: desktop trash, reconcile, and
  permanent delete now take a SessionRemovalGuard that holds the session's
  save lock and lease lock across the whole operation (built on #5977's
  delete-while-holding-flock primitive), so another process can no longer
  acquire the lease between a busy probe and the sidecar removal and then
  lose its freshly locked lease file. Live owners surface as a sanitized
  busy error; the lock files are deleted atomically with the release.
- Run overlong-named sessions (pre-bounded recovery cascade names) in
  checkpoint-only mode: their event log/index sidecars would exceed the
  filesystem name limit, so the probe treats them as non-native instead of
  failing saves with ENAMETOOLONG.
- Fix staticcheck QF1003: tagged switch for the save-reason mapping.
2026-07-05 14:03:03 +08:00
SivanCola 07794a0533 Merge pull request #5971 from SivanCola/fix/effort-switch-notices
Surface effort switch blockers / 显示推理力度切换阻塞原因
2026-07-05 13:59:45 +08:00
SivanCola bc18992293 Merge pull request #5968 from SivanCola/fix/session-lease-race
Fix desktop session lease race and remaining runtime data races / 修复桌面端会话租约竞态及其余运行时数据竞态
2026-07-05 13:57:00 +08:00
SivanCola b942018011 Merge remote-tracking branch 'origin/main-v2' into fix/session-recovery-event-log-cleanup 2026-07-05 13:53:39 +08:00
SivanCola 080e0d1182 Merge pull request #5983 from SivanCola/fix/bash-sandbox-unavailable-guidance
Explain unavailable bash sandbox recovery / 说明 bash 沙箱不可用时的恢复方式
2026-07-05 13:50:15 +08:00
SivanCola f9b2c3abe8 bump build generation before snapshotting ctrl in session rebind
Review follow-up: rebindTabToLoadedSessionPath observed tab.Ctrl (and ran
loadTabSessionProfile's disk I/O) before invalidating the in-flight async
build. runtimeRebuildMu does not cover startTabControllerBuild's goroutine,
so inside that window the async build still passed its swap-time generation
check: it could install its controller right after rebind observed
ctrl == nil, the loaded build then silently overwrote it, and the stale
runtime plus its shared-host reference leaked (its lease was already covered
by the adopt-on-ensure path).

The validation section now runs in one a.mu critical section that checks tab
membership, compares session keys (same-key resume stays a no-op and leaves
an in-flight build alone), bumps buildGeneration + cancels the build context,
and only then snapshots tab.Ctrl — making the snapshot authoritative: after
the bump the async build can only fall into its superseded branches, which
release exactly what it acquired via abandonSupersededBuild. The duplicate
bump at the ctrl!=nil branch tail is gone, and the build now re-checks
ownership right after its lease bind so a superseded build cannot write its
stale session path back onto the rebound tab.

New regression stalls a real async build inside its lease bind, rebinds the
tab to another session mid-flight, then releases the build to attempt its
swap: the rebound controller must survive, and the stale build's lease and
shared-host reference must both be released (exactly one host ref remains).

Verified: cd desktop && go test -race . passes; golangci-lint v2.12.2
(CI version) 0 issues; gofmt/go vet clean.
2026-07-05 13:49:55 +08:00
SivanCola 2c9f2c7788 Merge pull request #5977 from SivanCola/fix/session-recovery-cascade
Fix cascading session recovery / 修复会话恢复级联
2026-07-05 13:47:52 +08:00
SivanCola 89c3958040 fix: explain unavailable bash sandbox recovery 2026-07-05 13:43:18 +08:00
SivanCola f0221714c9 Harden session event log persistence and recovery cleanup
- Replay the event log tolerantly: torn tails are truncated by the next
  save instead of bricking LoadSession; foreign/legacy .events.jsonl files
  squatting the native log path are read-ignored and never written; logs
  from a newer schema fail loudly instead of being silently truncated.
- Bound log growth: past a size threshold the log compacts to a single
  replace event, and every rewrite/compaction refreshes the .jsonl
  checkpoint so direct readers and older binaries stay bounded-stale.
- Keep force saves (subagents, guardian, migrations, forks) single-file:
  they never bootstrap an event log and fold an existing one instead.
- Sweep the event log/index from every deletion surface through
  store.SessionSidecarFiles — /clear, serve delete, ACP delete, subagent
  delete, desktop trash — so deleted conversations cannot leak or be
  resurrected; guardian sidecars are excluded from transcript discovery.
- Guard desktop trash/delete with an external-lease busy check so removing
  lease files cannot break cross-process mutual exclusion.
- Migrate frozen-checkpoint readers to event-aware APIs: prompt history,
  serve session list, auto topic titles, duplicate-trash comparison,
  history search recency, and doctor byte totals.
- Count recovery-copy activity in topic summaries, hide recovery-only
  topics only when unpinned with no live runtime session, and mark
  conflict copies in the session tree and History drawer.
2026-07-05 13:34:23 +08:00
SivanCola 5311e0c9eb fix: open lock handle with DELETE access and commit overlong renames atomically
The Windows delete-disposition path only works when the handle carries
DELETE access, but os.OpenFile requests just GENERIC_READ|GENERIC_WRITE, so
RemoveAndUnlock always fell through to the post-close os.Remove fallback —
reopening the exact cleanup-vs-saver window the unlink-under-lock ordering
was meant to close (invisible to single-threaded CI). Open the lock file
with CreateFile(GENERIC_READ|GENERIC_WRITE|DELETE) and full share mode so
the disposition call runs on the locked handle; a Windows-only test pins
that the fallback counter never advances. A plain open of a lock file now
reads as held (sharing violation), matching the flock-held contract.

renameOverlongSession committed the transcript rename and then folded any
sidecar-migration error into a single return that dropped newID, so the
caller skipped re-parenting even though the old name was already gone —
orphaning child branches with no way to recover the mapping on a later run.
Split the rename (retryable: on failure nothing moved) from post-rename
migration (best-effort: newID is returned with joined warnings) so children
always re-parent once the transcript has moved.
2026-07-05 13:34:15 +08:00
SivanCola 6159661f92 fix superseded build cleanup releasing the replacement build's resources
Review follow-up on the generation-guard commit: the superseded branches in
buildTabControllerWithContext cleaned up by taking SharedHostKey (and, in the
final branch, the session lease) from the tab itself. Before generation
guards those branches only ran for removed tabs, where the tab's fields were
the stale build's own; now a rebind supersedes builds on a *live* tab whose
replacement build may already have published its own host key and lease, so
the stale build was releasing the new runtime's host reference (leaking the
host on same-root rebinds, or closing a host still in use on root changes)
and could strip the new session's lease.

Superseded cleanup now goes through abandonSupersededBuild, which releases
only what the stale build itself acquired: its controller, its own rootKey
reference, and — via the new releaseSessionLeaseForKey — only a lease still
bound to its own session path (removal keeps working because the keys match;
a rebind always changes the key). Owned-failure branches keep the
take-from-tab behavior, which is correct there.

Also drop tabWorkspaceName and tabRemovedForBuild, left unused by the
snapshot/generation refactor and flagged by golangci-lint (unused) in CI.

Verified: cd desktop && go test -race . passes; golangci-lint v2.12.2 (CI
version) reports 0 issues in both modules; new regressions cover
releaseSessionLeaseForKey key-matching and the stale-build-vs-replacement
ownership scenario from the review.
2026-07-05 13:13:53 +08:00
SivanCola 963b43c625 fix: delete held lock files via handle disposition on Windows
Windows refuses a path-based os.Remove of a file this process still holds
open, so the unlink-under-lock cleanup introduced for lock/lease sidecars
failed with a sharing violation on every removal (windows-latest CI).

Replace the closure-based try-lock with a sessionLockFile handle whose
RemoveAndUnlock deletes the file atomically with the release: Unix unlinks
while the flock is still held, Windows marks the delete disposition on the
held handle so the name dies with it. Lease info sidecars keep the plain
remove-under-lease-lock path — nothing ever holds them open. The overlong
rename pass retires the old .lock through the same primitive.
2026-07-05 13:00:54 +08:00
SivanCola e5494594c2 fix: close rewrite-persistence races and migrate overlong session names
Capture the rewrite version when the snapshot decision is made and record
that captured version monotonically after a successful write, instead of
re-reading it afterwards: a compaction landing mid-save could be marked
persisted without ever reaching disk, turning the next autosave into a
spurious conflict and a recovery branch. A stale no-rewrite decision now
retries once as an owned rewrite, and disk-adoption resets the baseline to
the adopted session instead of inheriting the replaced session's counter.

Unlink stale lock/lease sidecars while still holding their flocks so a
concurrent saver cannot acquire an inode that is being deleted under it.

Rename transcripts whose basenames outgrew the sidecar name limit (the
leftover shape of the unbounded recovery cascade, #5923): conversation
bytes move verbatim to a bounded name, branch meta migrates with its ID
rewritten, children are re-parented, and disposable lock/lease files are
retired. Sessions past the limit could never be saved again because their
lock sidecars were uncreatable.
2026-07-05 12:43:15 +08:00
SivanCola 56ce1d0a18 fix desktop runtime data races and serialize rebind swaps
Extends the #5955 session-lease fix (#5968) across the remaining shared-state
races found in a full desktop concurrency audit:

- sessionLease: route the five remaining raw accesses (detach clone, detach
  nil write, applyRuntimeTab transfer, rebuild reclaim, cross-tab probe)
  through new sessionLeaseMu helpers (take/adopt/key), so no path can leak or
  resurrect a lease and re-trigger the false 'another Reasonix window' error.
- tabEventSink: tabID/app now live under the sink mutex like ctx; Emit
  snapshots the binding, and detach/reattach/clear rebind via setBinding
  (same reasoning as the #5352 clearContext fix).
- detachRuntimeForReplacement: clone, lease/sink handover, membership check
  and re-publication now happen in one a.mu critical section, so a runtime
  unlinked by DeleteSession/TrashTopic cannot resurrect (#4384 class) and the
  disabledMCP map is no longer read lock-free while the event goroutine
  writes ActivityStatus.
- rebindTabToLoadedSessionPath and clearActiveSessionRuntime take
  runtimeRebuildMu like every other build+swap path; Controller.close is
  idempotent (sync.Once) so racing teardowns cannot re-fire SessionEnd hooks
  or double-close.
- bound methods (MetaForTab, Submit-family readiness/read-only checks,
  ReplayPendingPrompts, shutdown, MCP toggles, AutoResearch, ReloadCommands)
  read per-tab fields via locked snapshots instead of check-then-use.
- build/rebuild goroutines (buildTabControllerWithContext, SetModelForTab,
  SetEffortForTab, SetTokenModeForTab, rebuildSettingLocked,
  clearActiveSessionRuntime) capture a tabRuntimeSnapshot under a.mu before
  the off-lock boot, and SharedHostKey is published/taken under a.mu only.
- teardown/autosave paths (CloseTab, keepOnlyVisibleTab, closeTabRuntime,
  detachSessionRuntime, maybeAutoTitleTopic, topic assignment) snapshot
  fields under the lock before I/O.
- App-level fixes: bot install poll copies the session under a.mu, tray menu
  items are published under a.mu, bot runtime lazy init removed.

go test -race ./... passes in desktop and internal/control, including new
regression tests for concurrent lease helpers, detach membership/transfer,
sink rebind during Emit, MetaForTab vs build swap, and idempotent Close.
2026-07-05 12:34:47 +08:00
SivanCola 0f7f43e744 fix: prevent cascading session recovery 2026-07-05 11:50:05 +08:00
SivanCola 564afe1981 Fix session recovery persistence cleanup 2026-07-05 11:37:24 +08:00
SivanCola 666a947bce fix(desktop): surface effort switch blockers 2026-07-05 11:25:03 +08:00
SivanCola 06bbd210ee fix desktop session lease race 2026-07-05 11:01:22 +08:00
YHH 118e7f9c3b fix(registry): reject whole-repo source when publishing a skill (#5962)
A skill published with a bare GitHub repo-root source (owner/repo, or a branch
root with no sub-path) silently mass-installs every SKILL.md in the repo: the
installsource planner misses plugin detection for repos that only ship a
.claude-plugin manifest and falls through to a full-repo skill scan.

Validate at publish time — a kind:skill submission whose source resolves to a
whole repo is now rejected with a 422 and guidance to point source at a single
skill (a /tree/<branch>/<path> subtree or a raw SKILL.md URL). MCP kinds and
non-GitHub sources are unaffected.
2026-07-05 10:33:22 +08:00
SivanCola e509626a00 Merge pull request #5952 from SivanCola/fix/defer-settings-rebuild-on-lease
Fix deferred settings rebuild after persistence / 修复持久化后热刷新延迟失败
2026-07-05 10:24:43 +08:00
YHH f5fb4c5b75 fix(crash-report): stabilize community review table and add one-click copy (#5959)
The community moderation table used the browser's auto table-layout, so the
long summary in the first column starved the meta columns: version/installs/
stars and the submitted date wrapped character-by-character. Switch that table
to table-layout:fixed with explicit column widths (new .reg-table + colgroup)
so no column is squeezed below its content.

Add a per-row "Copy for review" button that copies the whole submission (kind,
source, repo_url, homepage, tags, summary, README) to the clipboard in one
click, reusing the existing [data-copy] handler and .copy-btn feedback. A
reviewer can hand a submission off without hand-copying fields one by one. The
manifest snapshot lives on package_versions (frontmatter only), so source and
repo_url stay the pointers to the actual reviewable content.
2026-07-05 02:14:37 +00:00
SivanCola ebdfe03167 Merge origin/main-v2 into fix/defer-settings-rebuild-on-lease
Resolve overlap with #5948 (orphaned session lease reclaim):
- Keep runtimeRebuildMu serialization on SetEffortForTab and
  SetTokenModeForTab together with main-v2's detached-runtime attach
  preamble.
- Adopt ensureTabSessionLeaseForRebuild (lease reclaim + user-facing
  error) in rebuildSettingLocked so all four rebuild paths share the
  same lease semantics; drop the now-duplicate userFacingSessionLeaseError
  wrappers at helper call sites.
- Clear the deferred-rebuild pending entry after successful effort and
  token-mode swaps, matching model switch and settings rebuilds.
2026-07-05 10:09:44 +08:00
SivanCola e4272b885b Auto-retry deferred settings rebuild and serialize controller swaps
- Add a background retry loop that re-runs the deferred rebuild once the
  session lease held by another Reasonix process is released; the warning
  and the retry bind to the tab whose refresh failed instead of whichever
  tab is focused when the notice fires.
- Serialize rebuildSetting, SetModelForTab, SetEffortForTab, and
  SetTokenModeForTab behind runtimeRebuildMu: two concurrent build+swap
  sequences on the same tab both pass the tab-identity check, so the
  loser's controller would replace the winner's and leak it without Close.
- Keep the old controller alive in SetEffortForTab until the replacement
  holds the session lease; the old code closed it up front, leaving a dead
  tab when the lease was held. Wrap effort/token-mode lease failures in
  the user-facing error instead of leaking session paths and writer IDs.
- Route the remaining bare rebuild call sites (auto-plan classifier,
  provider access removal, provider delete, reload settings, refresh
  skills) through the deferred-rebuild warning so a held lease no longer
  fails operations whose config change already persisted.
2026-07-05 09:59:42 +08:00
YHH 155f08893c docs: remove defunct oosmetrics badges from READMEs (#5953)
The oosmetrics achievement service has shut down (api.oosmetrics.com and
the site no longer resolve), so the three badges rendered as broken images.
Drop the block from both the English and Simplified Chinese READMEs.
2026-07-05 01:25:31 +00:00
SivanCola 4cf882e80d Fix deferred settings rebuild after persistence 2026-07-05 08:52:31 +08:00
SivanCola 761627c655 Merge pull request #5948 from SivanCola/fix/session-lease-reclaim
fix(desktop): recover orphaned session leases on model/effort/token switches / 修复单窗口切换模型/思考力度/省 token 误报"会话已被占用"
2026-07-05 08:28:07 +08:00
SivanCola 32fe51e1cb chore(desktop): drop dead prevPath recompute in pre-workspace attach
The first attachExistingSessionRuntime branch in SetEffortForTab and
SetTokenModeForTab recomputed prevPath, but the value is never read
before the unconditional recompute that follows
ensureTabControllerWorkspace, so golangci-lint (ineffassign) failed the
desktop CI job. Drop the dead recompute and call attach as a statement,
mirroring SetModelForTab; behavior is unchanged.
2026-07-05 08:13:20 +08:00
SivanCola e8ebd833b1 fix(desktop): recover orphaned session leases on switches and guard duplicate trash deletes
Model, effort, and token-mode switches failed with "session is already
open in another Reasonix window" in a single-window session whenever an
in-process lease entry was orphaned by an interrupted runtime (for
example after a canceled turn in a recovered session), and the effort
and token-mode paths surfaced the raw lease error including the session
path and writer id.

- Route all three rebuild flows through ensureTabSessionLeaseForRebuild,
  which reclaims a same-process orphaned lease only when no other tab or
  detached runtime uses the session, and always maps lease conflicts to
  the user-facing busy error.
- Attach an existing detached runtime before rebuilding in the effort
  and token-mode paths, mirroring the model switch.
- Close the old controller only after the replacement is fully
  installed, and keep the tab's lease on build failures, so a failed
  switch leaves the current session usable for chat.
- Treat a live session that is byte-identical to its existing trash copy
  as removable on delete, but refuse while another runtime holds the
  session lease so a foreign window's next save cannot be silently
  dropped; empty-stub removal gets the same guard.
2026-07-05 05:30:07 +08:00
SivanCola a43bf21e4d fix(agent): reclaim session leases without evicting live owners
TryReclaimCurrentProcessSessionLease deleted the in-process owner entry
before re-acquiring, so a racing reclaim -- or one aimed at a lease that
was actually alive -- evicted the live holder's fast-fail sentinel. The
OS file lock kept mutual exclusion intact, but the process-local layer
degraded permanently once hit.

Rework the owner registry around identity:

- Store a unique per-lease id in sessionLeaseOwners and remove entries
  with CompareAndDelete, so acquire failure paths and stale releases can
  only ever remove their own entry.
- Take the OS lease lock first in reclaim; an active holder keeps the
  lock held for its whole lifetime, so reclaiming from it now fails with
  ErrSessionLeaseHeld without touching its entry. Only a truly orphaned
  entry (lock free, lease info naming this process) is taken over.
- Fall back to a plain acquire when the lease info vanished because the
  holder finished releasing, and report unreadable lease info as a held
  lease instead of leaking a raw filesystem error to callers that show
  it to users.
- Add SessionLeaseHeldByOtherRuntime so destructive flows can tell a
  live foreign holder from a crashed-process leftover, without touching
  the lock file in the uncontended case.
2026-07-05 05:30:07 +08:00
SivanCola efa942eb91 Merge pull request #5946 from SivanCola/fix/assistant-copy-complete-turn
Fix assistant turn copy range / 修复助手轮次复制范围
2026-07-05 03:41:25 +08:00
SivanCola d1637e2415 fix(desktop): copy all assistant text in a turn 2026-07-05 03:09:10 +08:00
SivanCola f6a2074130 Merge pull request #5944 from SivanCola/fix/mcp-tools-list-race
Fix MCP tools/list startup race / 修复 MCP 工具列表启动竞态
2026-07-05 02:37:06 +08:00
SivanCola 451c9f95cc Merge pull request #5942 from SivanCola/fix/windows-update-handoff
Harden Windows desktop update handoff / 强化 Windows 桌面端更新交接
2026-07-05 02:26:54 +08:00
SivanCola f3223bcfbd fix: keep Windows update helper name platform scoped 2026-07-05 02:21:40 +08:00
SivanCola 90e7b1d531 fix mcp tools list startup race 2026-07-05 02:20:31 +08:00
SivanCola 1d3320e876 fix: harden Windows desktop update handoff 2026-07-05 02:11:26 +08:00
SivanCola 2d2841fdbd Merge pull request #5939 from SivanCola/fix/npm-cli-binary-version
Fix npm CLI binary version injection / 修复 npm CLI 二进制版本注入
2026-07-05 01:50:38 +08:00
SivanCola a2eebf8f41 Merge pull request #5920 from SivanCola/fix/desktop-runtime-locks
Fix desktop runtime lock inversions / 修复桌面运行时锁顺序问题
2026-07-05 01:46:37 +08:00
SivanCola 2c6afe20d4 Fix npm CLI binary version injection 2026-07-05 01:44:42 +08:00
SivanCola 3426dea1f7 fix(desktop): emit tree events after keepOnlyVisibleTab drops the lock
Match RemoveWorkspace: sessionRemovalMu now covers snapshotting, pruning,
and closing the removed runtimes via a closure, and emitProjectTreeChanged
fires after it is released — a tree-changed listener re-entering a removal
path can no longer deadlock on the held lock.
2026-07-05 01:39:32 +08:00
SivanCola c541413fde fix(config): keep read paths off disk and lock the last bare writer
Two leftovers from the edit-lock rollout: the desktop config-load helpers
persisted one-time legacy migrations (provider-access normalize, legacy
bot-config move) from plain read paths — Settings panel, tray locale,
single-surface probe, bot runtime load — as an unlocked read-modify-write
racing the locked bot-mapping writer exactly at first launch; and the TUI
/reasoning-language slash command still saved the user config bare.

Split the migrations into in-memory-only variants used by the read-only
View helpers (zero SaveTo; the migrated form reaches disk via the first
locked write path), document loadDesktopUserConfigForEdit as
lock-required, move the read-only callers onto View — with a credential-
loading variant where the consumer resolves env-based secrets (bot
runtime, MCP connect, provider-fallback planning) — and wrap the slash
command's load-modify-save in the edit lock.
2026-07-05 01:39:16 +08:00
SivanCola 09db5dc7ff fix(acp): close the promptable window while a config switch is queued
A queued pendingConfig could be jumped: rebuildSession's defer released
maintenanceDone before applyPendingSessionConfig ran, and begin() only
checked running/deleted/maintenance — so a prompt arriving in between
started a turn on the outgoing config. begin now also refuses while a
pendingConfig is queued; the queue is claimed in the same critical
section that raises maintenanceDone so no promptable instant exists in
between, and a failed apply drops the parked config only when nothing in
flight is left to retry it (keeping the session promptable instead of
wedging begin forever). Regression tests pin both the direct invariant
and the exact end-of-maintenance window.
2026-07-05 00:50:26 +08:00
SivanCola f87c736a96 fix(config): take the user-config edit lock on every remaining writer
The edit lock landed on the bot mapping, pairing, and desktop
applyConfig* paths, but its own comment promised more: every other
in-process load-modify-save of the user config could still overwrite a
locked writer's fields with a stale copy. Cover the stragglers — desktop
settings/MCP writers, serve's effort switch, the controller skill toggle
and /memory-v5 slash path, and the CLI TUI + reasonix config writers.
Lock sections hold only load-mutate-save; controller rebuilds, snapshots,
and fan-out stay outside. The provider-removal paths re-load under the
lock after their off-lock snapshot planning instead of saving the stale
planning copy, so the lock never spans controller work (no ABBA). Add a
regression test racing a bot-mapping writer against a settings writer,
asserting every round survives.
2026-07-05 00:50:10 +08:00
SivanCola ab3c108b30 fix(desktop): release sessionRemovalMu before fallback build and tree events
RemoveWorkspace held sessionRemovalMu to the end of the function, spanning
startTabControllerBuild, project-file updates, and the synchronous
emitProjectTreeChanged hook. The lock only needs to cover snapshotting,
binding removal, and closing the removed runtimes (a detached runtime must
finish its in-flight autosave before DeleteSession can see the files);
scope it with a closure — mirroring DeleteSession/TrashTopic — so a future
tree-changed listener that re-enters a removal path cannot deadlock.
2026-07-05 00:33:42 +08:00
SivanCola c7c127c310 fix(fileutil): retry rename before the non-atomic copy fallback
ReplaceFile fell back to copyOnto on the first rename failure. copyOnto
truncates dest in place, so a reader racing a transient rename failure
(Windows sharing violation from AV / indexer / a concurrent reader) could
observe an empty or half-written file — windows-latest caught exactly that
('unexpected end of JSON input') in the pluginpkg torn-file test. Exhaust
the rename retries first and keep the in-place copy strictly as the last
resort for filter-driver hosts where a same-dir rename never succeeds.
2026-07-05 00:33:42 +08:00
SivanCola 885c7886ef fix(fileutil): chmod tmp before close to survive Windows sharing races
AtomicWriteFile chmod'd the temp file by path after closing it, leaving a
window where the Windows AV / search indexer could grab or move it and make
the chmod fail with 'file not found' — observed flaking the concurrent
pluginpkg state test on windows-latest. Chmod the still-open handle instead.
The reader-side test now retries the transient sharing violation the publish
rename can trigger on Windows (a real torn file still fails via SyntaxError).
2026-07-05 00:25:12 +08:00
SivanCola 377c3c3f90 test: silence SA2001 on intentional lock-availability probes
The lock-probe regression tests acquire and immediately release the owner
mutex from a helper goroutine to prove the code under test is not holding
it across a callback — the empty critical section is the point. Annotate
the four probes the same way the boot connector probe already is.
2026-07-05 00:01:30 +08:00
SivanCola 96ff4b6503 Merge remote-tracking branch 'origin/main-v2' into fix/desktop-runtime-locks
# Conflicts:
#	internal/agent/session.go
2026-07-04 23:49:27 +08:00
SivanCola a987c902bf Merge pull request #5922 from SivanCola/fix/session-rewrite-recovery
Fix session rewrite recovery loops / 修复会话重写恢复分支循环
2026-07-04 23:41:16 +08:00
SivanCola 2ed83f545d Merge remote-tracking branch 'origin/main-v2' into fix/desktop-runtime-locks
# Conflicts:
#	desktop/settings_app.go
2026-07-04 23:38:36 +08:00
SivanCola 7fdb4fbc8a fix(desktop): stop leaking runtimes on model switch and close hosts off-lock
Model/effort/token-mode/settings rebuilds published the new controller onto the
tab without re-checking it still lived in a.tabs: a concurrent CloseTab left the
fresh controller (and its session lease) orphaned — the user-visible 'lease
held, session won't open' bug. Revalidate under a.mu and close+release on a
mismatch; route the stray tab.Ctrl reads through controllerForTab. Move
plugin.Host.Close (which reaps MCP subprocesses) out of a.mu. Serialize
clearActiveSessionRuntime/RestoreSession with sessionRemovalMu. Snapshot tab
fields under a.mu in the meta/recovery writers. Tear down the bot gateway off
r.mu via a dedicated lifecycle lock.
2026-07-04 23:29:19 +08:00
SivanCola d75036fd3b fix(serve,acp): keep controller swaps off the frontend locks
serve.switchModel held the write lock across Snapshot, boot.Build, and the
old controller's Close (up to 15s of jobs teardown + SessionEnd hook), wedging
every HTTP handler on s.ctl()'s RLock. Mirror the acp rebuild fix: switchMu
serializes the switch, the swap revalidates s.ctrl under a short lock, teardown
runs after unlock. In acp, route the remaining bare sess.ctrl reads through a
locked accessor and make closeAll wait for in-flight maintenance.
2026-07-04 23:29:09 +08:00
SivanCola 807b98a077 fix(bot): guard config maps, copy cancels off-lock, connect MCP without the lock
cfg.Channels/ConnectionChannels/ToolApprovalMode were written under gw.mu but
read unlocked in sessionOptionsForMessage and the project/session index scans:
concurrent map read+write is a fatal panic. Snapshot them under gw.mu. Copy
state.cancel inside the lock before invoking it. Make pairing.json RMW atomic
under a package lock. In boot, connect_tool_source spawned the MCP subprocess
under the connector lock, blocking parallel connects; run the connect callback
off-lock.
2026-07-04 23:28:58 +08:00
SivanCola bd7d0fcd67 fix(plugin): budget stdio waits and stop probing shells under the lock
withStderr reaped the child with an unbudgeted cmd.Wait while holding callMu:
a surviving grandchild that keeps the stderr pipe open wedged every future
call on the transport (close() already budgets exactly this wait). Share its
5s budget via waitWithBudget. cachedShellPATH ran up to ~6s of login-shell
probes under its lock and re-probed empty results on every spawn; probe
off-lock with an in-flight channel and cache completed empty results.
pluginpkg state RMW gets a process lock and atomic writes.
2026-07-04 23:28:50 +08:00
SivanCola 1e4f38271c fix(control): close session data races on rewind/compact/turn and sidecar meta
Rewind truncated the live message slice in place, racing History/Save readers
on other goroutines; use Session.Snapshot/Replace and a lock-guarded Len().
Compact lacked the running-turn guard Rewind/Branch have, letting a manual
compact rewrite the log under the run loop. Run's turn++ was unlocked. The
branch-meta sidecar RMW helpers (UpdateSessionMeta, in-flight markers,
model/touch) now hold the per-path save lock so concurrent writers can't drop
each other's fields. Document the promptMu/sink.Emit contract.
2026-07-04 23:28:38 +08:00
SivanCola 223f34678f fix(config): serialize in-process user config read-modify-write
LoadForEdit+SaveTo is not atomic: concurrent editors (bot auto-session
persistence vs settings saves) each load, mutate, and save their own copy,
silently dropping the other writer's fields. Add a process-wide edit lock
and move botruntime's rememberPersistMu onto it so every editor shares one
serialization point.
2026-07-04 23:28:28 +08:00
SivanCola 393f8508ca Merge origin/main-v2 into fix/session-rewrite-recovery
Resolves the desktop/settings_app.go conflict against the already-
merged #5931 (39270df55, 'Fix provider-key rebuild lease handling'),
which independently refactored rebuild()/rebuildSetting() around the
same resumeWithFreshSystemPrompt(ctrl, carried, path) call this PR
introduces.

Keeps origin/main-v2's structure for rebuildSetting(): it defers
oldCtrl.Close() until after the new controller's resume succeeds
(lease-safety fix), which this branch's older pre-refactor hunk did
not have anywhere else to fall back to. The resumeWithFreshSystemPrompt
call itself is equivalent on both sides — this PR's fix to that
function's implementation (desktop/session_prompt.go) is untouched by
the merge and carries forward unchanged.
2026-07-04 23:26:06 +08:00
SivanCola 565216c28c Skip revision bump on no-op session snapshot
checkSnapshotWrite now reports when the on-disk transcript already
matches the pending write byte-for-byte. save() skips the rewrite and
revision bump in that case instead of unconditionally recording a new
revision.

Without this, a routine no-op autosave (e.g. an idle tab's shutdown
snapshot) advanced the on-disk revision under any other runtime that
had cloned this file's persistence baseline via CloneWithMessages(If
Compatible). That runtime's next legitimate append then read as a
stale-runtime conflict and forked a spurious recovery branch — the
same symptom this PR fixes, reintroduced through a different trigger.

A load-time repair (LoadSession normalizing dangling tool calls, empty
names, truncated args) still forces a real write even when digests
match, so the repair actually reaches disk instead of being skipped as
a no-op forever.
2026-07-04 23:18:12 +08:00
SivanCola 82e7ba1068 Merge pull request #5931 from SivanCola/feature/longcat-provider
Add LongCat provider presets / 新增 LongCat 供应商预设
2026-07-04 22:48:09 +08:00
SivanCola 24946f31c7 Merge pull request #5777 from SauronSkywalker/fix/desktop-zoom-slider-resize-coords
# fix(desktop): fix zoom slider capsule seam and correct frameless resize coordinates
2026-07-04 22:47:54 +08:00
Reasonix ContributorandSivanCola dfa0928859 fix(desktop): fix zoom slider capsule seam and correct frameless resize coordinates
Co-authored-by: SivanCola <[email protected]>
2026-07-04 22:42:08 +08:00
SivanCola 11fc7d1b33 Add LongCat provider presets 2026-07-04 22:39:34 +08:00
SivanCola 0a1839adc9 test(desktop): add faster desktop test lane 2026-07-04 22:31:10 +08:00
SivanCola c3c3cac1f9 Merge pull request #5926 from SivanCola/fix/desktop-provider-rebuild-lease
Fix provider-key rebuild lease handling / 修复 provider key 重建租约处理
2026-07-04 22:18:44 +08:00
SivanCola 9c06b33ffa Merge pull request #5925 from SivanCola/fix/desktop-hydration-lag-meta
Reduce desktop hydration metadata lag / 降低桌面端 hydrate 元数据卡顿
2026-07-04 22:07:34 +08:00
SivanCola 39270df554 Fix provider-key rebuild lease handling 2026-07-04 21:58:22 +08:00
SivanCola fd9f8027f4 Guard session baseline cloning 2026-07-04 21:52:20 +08:00
SivanCola d5949f79bb Fix desktop hydration lag metadata refresh 2026-07-04 21:52:15 +08:00
SivanCola 250b9f6f91 Merge pull request #5924 from SivanCola/fix/display-zoom-recovery
Fix display zoom recovery controls / 修复显示缩放恢复控件
2026-07-04 21:48:14 +08:00
SivanCola c454608baf fix(runtime): address lock review follow-ups 2026-07-04 21:43:29 +08:00
SivanCola b95698d03c fix(desktop): make display zoom recoverable 2026-07-04 21:33:33 +08:00
SivanCola 65118e2d9b Merge pull request #5921 from SivanCola/fix/desktop-topic-order-metadata
Stabilize desktop topic ordering metadata / 修复桌面会话排序元数据
2026-07-04 21:15:45 +08:00
SivanCola 62cbf89624 Fix session rewrite recovery loops 2026-07-04 21:13:46 +08:00
SivanCola 0bffc871da fix: stabilize desktop topic ordering metadata 2026-07-04 21:00:28 +08:00
SivanCola 65be8e9d0c fix(runtime): avoid snapshot lock inversions 2026-07-04 20:48:30 +08:00
SivanCola c0867f60a5 Merge pull request #5912 from SivanCola/fix/desktop-model-switch-lease
Fix desktop model switch lease recovery / 修复桌面端切模型会话租约恢复
2026-07-04 18:23:50 +08:00
SivanCola 020a1f1757 fix(desktop): satisfy model switch lease lint 2026-07-04 18:17:15 +08:00
SivanCola 02d8bf531f Merge pull request #5911 from SivanCola/fix/mac-main-thread-hang-watchdog
Detect macOS main-thread hangs / 检测 macOS 主线程卡死
2026-07-04 18:15:10 +08:00
SivanCola 76dc8a48bc fix(desktop): preserve model switch session leases 2026-07-04 18:07:09 +08:00
SivanCola 01c4e63e58 Reduce macOS hang watchdog false positives 2026-07-04 18:02:37 +08:00
SivanCola 6abb384aac Detect macOS main-thread hangs 2026-07-04 17:40:00 +08:00
SivanCola afeb51806f Merge pull request #5908 from SivanCola/feature/bot-channel-settings-ui
Simplify bot channel settings / 简化机器人渠道设置
2026-07-04 16:46:33 +08:00
SivanCola af1d9151ec Merge pull request #5906 from HaoyueQin/feat/image-preview-5832
feat(frontend): add image preview on click in composer and messages / 上传图片后增加图片预览功能
2026-07-04 16:45:47 +08:00
SivanCola f6f90dc279 Preserve pasted images in browser mock
Store pasted image data URLs in the browser dev mock so composer thumbnails and image previews render the same bytes that were pasted. Keep a valid fallback preview for mock attachment paths.

Co-authored-by: SivanCola <[email protected]>
2026-07-04 16:40:31 +08:00
SivanCola 92539bbd1b fix(bot): approve pairing into connection access 2026-07-04 16:39:18 +08:00
SivanCola 311a613c0c Add image preview interaction coverage
Ensure edit-mode image thumbnails open the shared image viewer and limit pointer/hover styling to thumbnails with an image click handler. Extend the desktop image capability test to cover composer, sent-message, and edit-mode previews.

Co-authored-by: SivanCola <[email protected]>
2026-07-04 16:32:21 +08:00
SivanCola e76d093c57 feat(bot): simplify per-channel settings
- add per-bot access and runtime overrides for QQ and IM connections
- replace the bot overview entry with a direct four-channel manager
- keep primary bot controls between connection summary and access settings

Co-authored-by: SivanCola <[email protected]>
2026-07-04 16:13:39 +08:00
Reasonix User 7a95eed7c2 feat(frontend): add image preview on click in composer and messages
Add ImageViewer component that shows a full-size image preview in a
portal overlay (following MermaidDiagram fullscreen pattern). Both the
composer thumbnail chips and sent-message image attachments are now
clickable — opens a lightbox-style overlay with the full image.

- New ImageViewer component with createPortal to .chat-pane
- Message.tsx: image attachments get onClick + keyboard accessibility
- ComposerContextCard: new onImageClick prop for thumbnails
- Composer.tsx: integrated ImageViewer with tooltip hint
- CSS: hover scale(1.08) on thumbnails, ImageViewer overlay styles
- i18n: imageViewer.clickToPreview/closePreview/title in en/zh/zh-TW

Closes #5832
2026-07-04 14:51:14 +08:00
SivanCola 96795f99d1 Merge pull request #5900 from SivanCola/feature/session-title-auto-refresh
Auto-refresh session titles without overriding manual names / 自动更新会话标题且尊重手动命名
2026-07-04 11:51:35 +08:00
SivanCola e6b77215fd Merge pull request #5895 from SivanCola/feature/deep-provider-presets
Add curated provider presets / 增加深度适配的供应商预设
2026-07-04 11:35:30 +08:00
SivanCola 403bfcab50 Fix provider preset refresh auth and modified state 2026-07-04 11:25:19 +08:00
SivanCola df5ee95898 feat(desktop): auto-refresh session titles 2026-07-04 11:16:58 +08:00
SivanCola 14396181b8 Merge pull request #5896 from SivanCola/fix/file-ref-cache-refresh
Refresh file reference caches / 刷新文件引用缓存
2026-07-04 08:49:01 +08:00
SivanCola 0076b8816a fix(frontend): refresh workspace file caches 2026-07-04 07:22:16 +08:00
SivanCola 99928cebeb Add preset conflict actions 2026-07-04 07:11:39 +08:00
SivanCola de1fb49951 Track provider preset install state 2026-07-04 06:53:37 +08:00
SivanCola 5c8e238eef Guard provider presets from overwriting existing config 2026-07-04 06:31:51 +08:00
SivanCola 68a1a47322 Merge pull request #5894 from SivanCola/feature/bot-gateway-upgrades
Upgrade bot gateway operations / 升级 Bot 网关运维能力
2026-07-04 06:14:48 +08:00
SivanCola 0f5a7f143c fix(bot): admit role-listed users
Co-authored-by: SivanCola <[email protected]>
2026-07-04 06:00:11 +08:00
SivanCola 4a6dcda402 Compact provider preset cards 2026-07-04 05:49:47 +08:00
SivanCola 6e6c32627b fix(bot): stabilize gateway CI checks
Co-authored-by: SivanCola <[email protected]>
2026-07-04 05:42:15 +08:00
SivanCola d30e6f1c37 Polish provider preset card layout 2026-07-04 05:29:47 +08:00
SivanCola 7c64489c69 docs(bot): update gateway usage guide 2026-07-04 05:18:18 +08:00
SivanCola 73f622326f Add curated provider preset catalog 2026-07-04 05:10:17 +08:00
95cf355b25 feat(bot): consolidate related gateway improvements
Adapt related bot gateway and adapter work from PRs #5105, #5093, #5046, and #5888.

Co-authored-by: cecil-su <[email protected]>

Co-authored-by: Juko-div <[email protected]>

Co-authored-by: SEPURI-SAI-KRISHNA <[email protected]>

Co-authored-by: zlwu <[email protected]>
2026-07-04 05:08:06 +08:00
SivanCola 54d5fe65b4 fix(bot): resolve gateway review findings 2026-07-04 04:55:27 +08:00
SivanCola 9237eb82b6 feat(bot): add project session navigation commands 2026-07-04 04:25:01 +08:00
SivanCola 62a95e31b0 Merge pull request #5890 from zlwu/fix/feishu-reaction-cleanup
fix(feishu): remove pending reaction emoji after turn completes
2026-07-04 03:58:08 +08:00
SivanCola a1bbcd7194 feat(bot): upgrade gateway operations 2026-07-04 03:52:27 +08:00
SivanCola b6f0a8ebbd Fix queued bot reaction cleanup
Store queued reaction cleanup callbacks on the gateway instead of controller state so reactions added before controller creation still get removed.

Carry queued cleanup callbacks into the merged follow-up turn so the pending reaction is removed when that turn finishes.

Co-authored-by: SivanCola <[email protected]>
2026-07-04 03:48:07 +08:00
SivanCola 23ce2a7af7 Merge pull request #5893 from SivanCola/fix/subagent-bot-workspace-routing
Fix workspace routing for subagents and bot sessions / 修复子代理与 Bot 会话工作区路由
2026-07-04 03:45:41 +08:00
SivanCola c880b7ad73 Fix workspace routing for subagents and bot sessions 2026-07-04 03:36:15 +08:00
SivanCola 499f7f2e44 Merge pull request #5887 from HaoyueQin/fix/desktop-paste-block-display
fix(desktop): show pasted text content in message bubbles / 修复桌面端粘贴文本在消息气泡中仅显示标签
2026-07-04 02:40:50 +08:00
SivanCola 53086911fd Merge pull request #5892 from SivanCola/fix/active-goal-transient-strip
Strip active-goal from user previews / 从用户预览中剥离 active-goal
2026-07-04 02:39:56 +08:00
SivanCola 850c4b5db5 Merge remote-tracking branch 'origin/main-v2' into fix/active-goal-transient-strip
# Conflicts:
#	internal/agent/preview.go
#	internal/agent/preview_test.go
2026-07-04 02:34:44 +08:00
zlwu c57062b788 fix(feishu): remove pending reaction emoji after turn completes
AddPendingReaction now returns a cleanup function that the gateway
defers in runTurn. When the turn finishes (or errors), the OnIt
emoji is automatically removed via MessageReaction.Delete API.

Minimal interface change: return type becomes (func(), error) instead
of error. QQ/WeChat are unaffected — they don't implement this
interface.
2026-07-04 02:34:41 +08:00
SivanCola 26e5b6ad01 Simplify pasted block controls
Use a single expand/collapse control for pasted message blocks and remove the separate preview button to avoid overlapping interaction semantics.

Co-authored-by: SivanCola <[email protected]>
2026-07-04 02:31:22 +08:00
SivanCola 010b761286 Merge pull request #5891 from SivanCola/feature/capability-orchestrator-routing
Capability routing hints for skills and MCP / 为 Skills 和 MCP 添加能力路由提示
2026-07-04 02:30:57 +08:00
SivanCola 519f94232f test(desktop): guard active-goal history display 2026-07-04 02:29:51 +08:00
SivanCola d0e0d4e4f3 Merge pull request #5889 from SivanCola/fix/reasoning-language-auto-anchor
Anchor Chinese reasoning language from raw prompts / 从原始提问锚定中文思考语言
2026-07-04 02:25:40 +08:00
SivanCola d451290d46 Make pasted block expansion reversible
Keep pasted message cards mounted after expansion so users can collapse the full text back to the compact card state.

Co-authored-by: SivanCola <[email protected]>
2026-07-04 02:22:10 +08:00
SivanCola 29c859b7b0 Fix capability routing CI gates 2026-07-04 02:21:54 +08:00
SivanColaandmyipanta 91035ddd8e fix(agent): strip active-goal from user previews
Adapted from #4513 by @myipanta.

Co-authored-by: myipanta <[email protected]>
2026-07-04 02:21:49 +08:00
SivanColaandliaoyl830 4eb0b6e1c3 fix(agent): anchor Chinese reasoning language from raw prompts
Co-authored-by: liaoyl830 <[email protected]>
2026-07-04 02:19:12 +08:00
SivanCola 3cff8c9ffc Fix pasted block locale parsing
Ensure message pasted block parsing recognizes Traditional Chinese labels and add a focused regression test for Simplified Chinese, Traditional Chinese, and English labels.

Co-authored-by: SivanCola <[email protected]>
2026-07-04 02:11:53 +08:00
SivanCola 0003bef4e7 Add capability routing hints 2026-07-04 02:10:51 +08:00
SivanCola b82a97dd1d Merge pull request #5886 from SivanCola/feature/third-party-parser-hardening
Harden parser paths with maintained libraries / 用成熟库加固解析路径
2026-07-04 01:20:01 +08:00
HaoyueQin 6968f1dca3 fix(desktop): show pasted text blocks with preview/expand in message bubbles
Pasted long text in the desktop input is folded into a collapsible
label (e.g. '[已粘贴文本 #2 · 31 行]').  When the message is sent
the bubble previously showed only that label, hiding the actual
content from the user even though the model received it.

This change adds per-block preview/expand controls inside the
user-message bubble, modelled after the composer's existing paste
block UI:
- A bordered card with the label on the left and Eye (preview) +
  Expand buttons on the right
- Preview toggles a <pre> showing the raw pasted content
- Expand irreversibly replaces the label with the actual text
  inline at its original position in the message

Closes #5863
2026-07-04 01:19:55 +08:00
SivanCola ecd14ab332 fix: remove unused parser helpers 2026-07-04 01:11:34 +08:00
SivanColaandJuly-X 174ea10844 refactor: harden parsers with maintained libraries
Replace several hand-rolled parsers and text processors with maintained libraries while preserving Reasonix-specific policy boundaries and fallbacks.

Add focused regression coverage for shell argv policy, HTML extraction, frontmatter decoding, dotenv parsing, glob matching, diff output, grapheme clipping, and optional tree-sitter code indexing.

Co-authored-by: July-X <[email protected]>
2026-07-04 01:11:34 +08:00
SivanCola c87628398f Merge pull request #5884 from SivanCola/chore/math-rendering-attribution
Record math rendering attribution / 补记数学渲染贡献归属
2026-07-04 01:05:10 +08:00
e02e62c310 chore: record math rendering attribution
Refs #5662, #5649, #4543.

This empty follow-up commit records commit-level GitHub attribution for contributors whose source PR work was kept or adapted by the #5662 math rendering consolidation.

Co-authored-by: drafish <[email protected]>
Co-authored-by: lightfront <[email protected]>
Co-authored-by: light-front-theory <[email protected]>
2026-07-04 00:59:48 +08:00
SivanCola fee799a7ed Merge pull request #5172 from liaoyl830/fix-5167-tool-schema-properties
fix(provider): include empty properties in tool schemas (#5167)
2026-07-04 00:40:33 +08:00
SivanCola 1e7379c29d Merge main-v2 into schema properties PR
Update the OpenAI empty-parameters regression expectation after #5883 so it matches the stricter canonical schema shape.

Co-authored-by: SivanCola <[email protected]>
2026-07-04 00:32:56 +08:00
SivanCola 0b579f2110 Merge pull request #5883 from SivanCola/fix/provider-explicit-temperature
Preserve explicit zero temperature and empty tool schemas / 保留显式零温度并补齐空工具 schema
2026-07-04 00:22:42 +08:00
SivanCola 1ea049d9c2 fix(openai): default empty tool parameters 2026-07-04 00:08:11 +08:00
SivanColaandSEPURI-SAI-KRISHNA 8e5d9e815d fix(provider): preserve explicit zero temperature
Adapted from #5048 by @SEPURI-SAI-KRISHNA. Keep the explicit-zero temperature serialization fix while preserving caller-controlled DeepSeek temperature behavior.

Co-authored-by: SEPURI-SAI-KRISHNA <[email protected]>
2026-07-03 23:51:28 +08:00
SivanCola cba9390d2b Merge pull request #5882 from SivanCola/fix/bash-repeat-guard-ast
Parse Bash redirects in repeat guard / 用 Bash AST 识别 repeat guard 重定向
2026-07-03 23:29:09 +08:00
SivanCola 34ca109ec4 Merge pull request #5881 from SivanCola/fix/acp-host-owned-permissions
Fix ACP permission ownership / 修复 ACP 权限归属
2026-07-03 23:20:42 +08:00
SivanCola 4c7386f921 fix(agent): parse bash redirects in repeat guard 2026-07-03 23:16:19 +08:00
SivanCola 224c6e61d3 Merge pull request #5840 from SauronSkywalker/fix/window-controls-ui-and-context-panel-realtime
fix(windows): window desktop controls white box & ContextPanel real-time data桌面客户端窗口控制按钮的视觉 bug与概览面板数据更新修复
2026-07-03 23:11:45 +08:00
SivanCola b44e9af7e5 fix(acp): leave persistent permissions to hosts 2026-07-03 23:09:55 +08:00
SivanCola e7a8a1320d Merge pull request #5876 from SivanCola/fix/bash-ast-shell-parsing
Parse Bash syntax with AST / 用 AST 解析 Bash 语法
2026-07-03 23:02:31 +08:00
SivanCola 7f51ad178b Refresh context panel on usage token changes
Co-authored-by: SivanCola <[email protected]>
2026-07-03 22:57:13 +08:00
SivanColaandJuly-X 8664212c45 fix(bash): parse shell syntax with Bash AST
Replace hand-rolled Bash scanners with mvdan.cc/sh AST parsing for background keepalive detection, permission matching, plan-mode read-only checks, safe redirect normalization, and verification command matching.

Add heredoc regressions for issue #5624 so command execution and cancellation both release turns promptly.

Co-authored-by: July-X <[email protected]>
2026-07-03 22:41:16 +08:00
SivanCola 4ec73b2441 Merge pull request #5875 from SivanCola/fix/sandbox-settings-diagnostics
Clarify sandbox write roots settings / 明确沙箱可写根设置
2026-07-03 22:30:23 +08:00
SivanCola acd567721c Merge pull request #5874 from SivanCola/fix/sandbox-parallel-safety-integration
Fail closed bash sandbox and make parallel tasks read-only / Bash 沙箱失败即拒绝并限制并行子任务为只读
2026-07-03 22:21:11 +08:00
SivanCola a72c392b68 fix: align bash sandbox unavailable messaging 2026-07-03 22:13:12 +08:00
SivanCola 2e15cc9d59 test: make sandbox write root expectations portable 2026-07-03 22:12:46 +08:00
SivanCola eaba743bc5 fix: clarify sandbox write roots settings 2026-07-03 21:54:59 +08:00
SivanCola 157809c4d7 fix(agent): harden bash sandbox and parallel tasks 2026-07-03 21:49:49 +08:00
SivanCola 1447b62908 Merge pull request #5871 from SivanCola/fix/planmode-safe-null-redirects
Allow plan mode safe null redirects / 放宽计划模式安全空重定向
2026-07-03 21:17:45 +08:00
SivanCola d3b5111ca8 fix(planmode): allow safe null redirects 2026-07-03 21:02:26 +08:00
SivanCola a306a07f82 Merge pull request #5867 from SivanCola/fix/plan-mode-read-only-commands
Allow plan-mode read-only command prefixes / 支持计划模式只读命令前缀
2026-07-03 20:53:08 +08:00
SivanCola 15101b87f4 Merge pull request #5868 from SivanCola/fix/bash-devnull-redirect-permission
Match safe bash redirects in permission rules / 支持安全 Bash 重定向权限匹配
2026-07-03 20:49:28 +08:00
SivanCola 6f989b09c5 fix(permission): normalize cross-shell null redirects 2026-07-03 20:39:36 +08:00
SivanCola 110c5ece63 Merge pull request #5845 from myipanta/fix/workspace-prompt-5792
fix: add workspace root to system prompt for project context awareness
2026-07-03 19:17:56 +08:00
SivanCola 7837083eba Merge remote-tracking branch 'origin/main-v2' into fix/plan-mode-read-only-commands
# Conflicts:
#	internal/boot/boot.go
#	internal/control/controller.go
2026-07-03 19:13:23 +08:00
SivanCola f14ce69798 fix(permission): match bash safe redirects 2026-07-03 19:11:49 +08:00
SivanColaandSivanCola bfda54539b test: cover workspace prompt fork and rewind
Co-authored-by: SivanCola <[email protected]>
2026-07-03 19:10:41 +08:00
SivanCola ee20852d1b Add plan-mode bash read-only trust approval 2026-07-03 19:09:45 +08:00
SivanCola a7a256adc1 Merge pull request #5703 from morningtzh/feat/permission-decompose-compound-bash
feat(permission): match compound bash commands segment-by-segment
2026-07-03 18:56:55 +08:00
SivanColaandSivanCola 0466fef9d6 Make workspace prompt escaping test portable
Co-authored-by: SivanCola <[email protected]>
2026-07-03 18:52:48 +08:00
SivanCola dc68694cdb Merge pull request #5865 from esengine/fix/desktop-release-manifest-gateway
fix: route desktop updater via release gateway
2026-07-03 18:51:11 +08:00
SivanColaandSivanCola a1029786ad Escape workspace root in prompt line
Co-authored-by: SivanCola <[email protected]>
2026-07-03 18:46:00 +08:00
SivanCola 50a658e434 Merge pull request #5857 from SivanCola/fix/windows-shell-cancel-hang
Fix Windows shell cancellation for Git Bash descendants / 修复 Windows Shell 取消残留进程
2026-07-03 18:45:38 +08:00
SivanColaandSivanCola 9a4f56e4cf fix(permission): preserve compound bash exact rules
Keep whole-command bash allow/ask/deny rules authoritative before segment-level decomposition, so existing exact compound approvals and denials keep their previous precedence.

Co-authored-by: SivanCola <[email protected]>
2026-07-03 18:44:39 +08:00
SivanColaandmyipanta 06634777c1 fix: route desktop updater via release gateway
Co-authored-by: myipanta <[email protected]>
2026-07-03 18:41:09 +08:00
SivanCola 7bd9c13f01 Merge pull request #5856 from esengine/fix/session-conflict-recovery
fix(session): recover stale controller transcripts
2026-07-03 18:37:38 +08:00
SivanCola 107456d605 Cover shell routing during rapid tab switches 2026-07-03 18:36:58 +08:00
SivanCola f19c00f141 fix(desktop): surface recovery lease failures 2026-07-03 18:29:12 +08:00
SivanCola 2ba719ebab fix(planmode): allow declared read-only bash prefixes 2026-07-03 18:26:40 +08:00
SivanCola 7cb64f55e0 Preserve direct shell session isolation 2026-07-03 18:19:10 +08:00
SivanCola 201423138e Merge pull request #5864 from SivanCola/codex/fix-5753-fresh-human-memory-approval
Restrict remember/forget and plan approvals to fresh human-only flow
2026-07-03 18:17:07 +08:00
SivanCola 787fe8a0bf fix(session): require lease before recovery switch 2026-07-03 18:16:21 +08:00
SivanCola 9d0890f92a Merge pull request #5844 from myipanta/fix/memory-yolo-test-5753
test: add YOLO regression test for remember/forget forced approval (#…
2026-07-03 18:12:32 +08:00
SivanCola 79efac68a3 Unify tracked shell cancellation paths 2026-07-03 18:10:43 +08:00
SivanCola 608527502a Restrict remember/forget and plan approvals to fresh human-only flow 2026-07-03 17:45:09 +08:00
SivanCola 61baf002e9 test: cover project blank session workspace binding 2026-07-03 17:37:18 +08:00
SivanCola 0f89cec5ea Merge pull request #5861 from SivanCola/fix/serve-http-auth-cookie
Fix serve HTTP auth cookies / 修复 serve HTTP 登录 Cookie
2026-07-03 17:33:40 +08:00
SivanCola 6482375b72 fix(session): preserve malformed metadata errors 2026-07-03 17:26:19 +08:00
SivanCola 5f2eac7053 fix(serve): keep HTTP auth cookies usable 2026-07-03 17:25:48 +08:00
SivanCola f61118b8fe Stabilize Windows Git Bash cancel regression 2026-07-03 17:23:56 +08:00
SivanCola 8ecdc894a8 fix(session): add transcript CAS and runtime leases 2026-07-03 17:22:14 +08:00
jiangrenhang faab493b02 fix: add workspace root to system prompt for project context awareness
When switching projects in the desktop app, the AI previously had no explicit provider-visible cue for the active workspace when project memory was absent or similar across roots. Add a Current workspace line to the cache-stable boot prompt after the language policy so each controller carries its resolved workspace root.

The existing no-memory prompt invariant now strips the boot-time workspace line, and a new regression test verifies two different WorkspaceRoot values produce different system prompt workspace lines.

Cache-impact: low; adds one stable per-session workspace line to the provider-visible system prompt. Different project roots get distinct prompt prefixes, which is expected for project-scoped sessions.

Cache-guard: go test ./internal/boot

System-prompt-review: required; adds "Current workspace: <root>" after LanguagePolicy and before token-economy/environment sections.

Refs #5792
2026-07-03 17:20:05 +08:00
SivanCola d7bd736a85 Use real Python for Windows Git Bash cancel test 2026-07-03 17:19:26 +08:00
SivanCola 4e3a1aea57 Fix Windows Git Bash regression test path 2026-07-03 17:13:42 +08:00
SivanCola afa8b710f4 Merge pull request #5773 from GTC2080/GTC/fix-5769-stop-button
fix(desktop): keep stop state when switching to running tabs
2026-07-03 17:08:36 +08:00
SivanCola 02702c8378 Fix Windows shell cancellation for tracked process trees 2026-07-03 17:07:43 +08:00
SivanColaandSivanCola 863d07be20 Merge main-v2 into PR 5773
Preserve both project-switch send fallback and running-tab rapid switch behavior.

Co-authored-by: SivanCola <[email protected]>
2026-07-03 17:02:25 +08:00
SivanCola 2b105dd575 Merge pull request #5854 from GTC2080/GTC/fix-yolo-project-switch
fix: reconcile opened topic runtime
2026-07-03 16:57:11 +08:00
SivanCola 7902c9321d fix(session): recover stale controller transcripts 2026-07-03 16:50:52 +08:00
SivanColaandSivanCola 7e2a14ef91 fix: preserve last-click tab activation
Co-authored-by: SivanCola <[email protected]>
2026-07-03 16:47:17 +08:00
SivanCola 7135b32e14 Merge pull request #5720 from GTC2080/GTC/fix-5716-creation-tool-collapse
fix(desktop): hide collapsed creation tool bodies
2026-07-03 16:46:37 +08:00
SivanColaandSivanCola acc954a506 fix: reconcile runtime on active tab fallback
Co-authored-by: SivanCola <[email protected]>
2026-07-03 16:29:51 +08:00
SivanCola 4e440421af Merge pull request #5852 from GTC2080/GTC/fix-block-comment-prompt
fix: allow block comment prompts
2026-07-03 16:17:23 +08:00
SivanCola a9928d3ef5 Merge pull request #5847 from SivanCola/feature/desktop-system-notifications
Add desktop system notifications for turns / 为桌面端任务完成增加系统通知
2026-07-03 16:11:13 +08:00
SivanCola 74b9dd77b6 Merge pull request #5851 from SivanCola/fix/session-snapshot-conflict
Guard session snapshots against stale overwrites / 防止旧快照覆盖会话
2026-07-03 16:05:59 +08:00
SivanColaandSivanCola 1e8fc36577 fix: share slash comment prompt dispatch
Co-authored-by: SivanCola <[email protected]>
2026-07-03 16:05:15 +08:00
SivanColaandBarsit ec814165dd chore: credit related notification contribution
Credits the Windows Toast direction from PR #5365 in the repository history.

Co-authored-by: Barsit <[email protected]>
2026-07-03 16:04:06 +08:00
SivanCola 4b4b976fcd fix(desktop): avoid duplicate Windows notification fallback 2026-07-03 15:52:33 +08:00
SivanCola aad432d693 fix(session): protect explicit rewrites from stale controllers 2026-07-03 15:49:46 +08:00
SivanCola a6807da7f8 Merge pull request #5059 from maoxin1234/feat/zhipu-thinking
feat(provider): add Zhipu GLM thinking-mode support
2026-07-03 15:49:31 +08:00
GTC2080 bf24e28377 fix: reconcile opened topic runtime 2026-07-03 17:47:18 +10:00
SivanCola 31d3b7c0e7 docs(desktop): clarify provider compatibility settings 2026-07-03 15:40:01 +08:00
GTC2080 63fbad01d3 fix: allow block comment prompts 2026-07-03 17:39:39 +10:00
SivanCola d2e91a8a11 fix(session): guard snapshots against stale overwrites 2026-07-03 15:39:25 +08:00
SivanCola aeb6431f73 chore: avoid touching cache-sensitive config 2026-07-03 14:59:53 +08:00
SivanCola 98ee87101b feat(desktop): add system notifications for turns 2026-07-03 14:58:09 +08:00
SivanCola 27b86732c2 Merge remote-tracking branch 'origin/main-v2' into HEAD 2026-07-03 14:42:31 +08:00
SivanCola 912bbd86ae Merge pull request #5833 from SivanCola/codex/claude-plugin-compat
Support Claude-style plugin hooks and CLAUDE.md compatibility
2026-07-03 14:37:51 +08:00
jiangrenhang 939975881e test: add YOLO regression test for remember/forget forced approval (#5753)
The protection for remember/forget tools already exists on main-v2
(requiresFreshApprovalTool + policy.Ask rules in newInteractiveGate),
but only Auto mode had a dedicated test. This adds an equivalent test
for YOLO/full-access mode to prevent regression.

Cache-impact: none — test-only change.

Cache-guard: go test ./internal/control/ -run "TestToolApprovalModeYoloForcesMemoryAskRules"

Refs: #5753
2026-07-03 13:46:11 +08:00
Reasonix Dev 746d06800a fix: window controls white box & ContextPanel real-time data
- Window controls white box hidden when workspace panel closed
- Window controls dim (opacity 0.35) when command palette open
- Creation mode window controls height matches dock tools (40px)
- Topicbar buttons no longer pushed off-screen when workspace opens
- Creation dock tabs use flex layout to prevent overlap
- ContextPanel prompt/completion/reasoning tokens now real-time
- Cache hit/miss rate now real-time (usage precedence over info)
- Throttled background refresh (1s) during streaming for telemetry data
2026-07-03 13:11:33 +08:00
MorningTZH bd31c9e10e test(permission): cover heredoc, leading operator, process substitution
Documents the tokenizer's scope explicitly and hardens against three shapes
that would otherwise decompose incorrectly:

- heredocs (`cat <<EOF … EOF`): body isn't shell syntax but the tokenizer
  can't tell where it ends without tracking the delimiter. Bail on any
  unquoted `<<` and fall back to exact match.
- leading control operators (`&& ls`, `; ls`, `| grep`, `|| echo`):
  malformed shell; refuse to decompose.
- process substitution (`<(cmd)` / `>(cmd)`): tracked as opaque via the
  same paren-depth counter used for `$(cmd)`, so operators inside don't
  split the outer command.

Also expands the package doc to enumerate known out-of-scope shapes and to
note that the hand-rolled tokenizer exists to avoid a full `mvdan.cc/sh`
dependency; the contract is `[]string` or `nil` for exact-match fallback,
so swapping in a real parser later is a local change.
2026-07-03 13:00:25 +08:00
MorningTZH f55083fa62 fix(permission): use tagged switch for parenDepth branch (QF1003)
golangci-lint (staticcheck QF1003) flagged the if/else if on the same
variable as a tagged-switch candidate. Same behavior, just the idiomatic
form the project's lint config expects.
2026-07-03 12:53:22 +08:00
MorningTZH 23b429d3d5 feat(permission): match compound bash commands segment-by-segment
Compound bash commands (`&&` / `||` / `|` / `;`) currently fall back to
whole-string matching in the rule table, so permanent approvals for atomic
commands never carry over to a compound of those same commands. #3882 reported
this and was closed as by-design because synthesizing a prefix from a compound
(`cd /path && …` → `Bash(cd /path:*)`) would silently authorize whatever
follows the operator.

This change takes a different route with the same safety posture: instead of
synthesizing new rules, decompose a compound at match time and check each
simple-command segment against the existing rule table independently. The
storage path is unchanged — `BashCommandPrefix` still returns nil for compound
subjects, so "always allow" still stores the exact literal on the persist
side. What changes is only the matching algorithm.

- Add `DecomposeBashCommand` (`internal/permission/bash_decompose.go`): a
  hand-rolled tokenizer that splits on `;`, `|`, `||`, `&`, `&&`, and
  newlines while treating single/double quotes, `$(…)`, backticks, and
  backslash-escapes as opaque. `2>&1` / `>&2` are recognized as fd
  duplication (not splitters). Unbalanced quotes/parens return nil so the
  caller falls back to exact-string matching.
- Route `Policy.DecideSubject` bash path through `decideBashSegments` when
  decomposition yields ≥2 segments. Precedence stays deny > ask > allow >
  fallback: any segment hitting deny denies the whole call, any missing rule
  turns the whole call into Ask, whole-call Allow requires every segment to
  be covered by an allow rule or classified read-only by `shellsafe`.
- Effect: a user who has approved `Bash(git add:*)` + `Bash(git commit:*)` +
  `Bash(git push:*)` (via the existing atomic prefix save path or manual
  config) now passes `git add . && git commit -m … && git push` silently.

Out of scope for this pass:

- Redirect stripping (`2>/dev/null`, `> file`) inside individual segments —
  `bashPrefixMatches` still rejects subjects with any shell syntax, so a
  segment carrying a redirect misses even a matching prefix rule. Follow-up.
- Per-segment approval UI in the TUI. This change is match-side only.

Cache-impact: none — permission is called before any LLM request; nothing
about system prompt, memory prefix, tool schema, or provider serialization
changes.

Cache-guard: `go test ./internal/permission/... -count=1` (15 tokenizer
cases + 6 policy cases, all pass). `go build ./cmd/reasonix` and `go vet ./...`
clean.

Refs: #5702, #3882, #3815
2026-07-03 12:53:22 +08:00
YHH a87adced3c Merge pull request #5838 from esengine/site-registry-origin
Point skills page at the live registry API (crash worker)
2026-07-03 12:29:26 +08:00
SivanCola 573f58a1c2 Merge remote-tracking branch 'origin/main-v2' into HEAD
# Conflicts:
#	desktop/frontend/src/components/SettingsPanel.tsx
#	desktop/frontend/src/locales/en.ts
#	desktop/frontend/src/locales/zh-TW.ts
#	desktop/frontend/src/locales/zh.ts
#	desktop/settings_app_test.go
2026-07-03 12:26:39 +08:00
yhh b7d1e95c50 fix(site): point skills registry API at the live worker
registry.reasonix.io was never provisioned; the folded registry API now
serves from the crash worker. Default the skills page at crash.reasonix.io
so discovery/publish work; PUBLIC_REGISTRY_ORIGIN still overrides.
2026-07-02 21:24:18 -07:00
SivanCola a8fcd8c1c8 Merge pull request #5808 from ying-hua/fix/provider-extra-body
fix(provider): support provider extra_body
2026-07-03 12:22:10 +08:00
SivanColaandSivanCola 3307be74d9 feat(desktop): expose provider thinking override
Co-authored-by: SivanCola <[email protected]>
2026-07-03 12:21:25 +08:00
SivanCola 639736f5fa fix: return failure for plugin install JSON errors 2026-07-03 12:19:57 +08:00
YHH 2c7ad4973e Merge pull request #5834 from esengine/admin-unify
Unify community registry into the admin backend + speed up the dashboard
2026-07-03 12:11:28 +08:00
SivanColaandSivanCola eefcb09038 Add provider extra body settings UI
Co-authored-by: SivanCola <[email protected]>
2026-07-03 12:10:16 +08:00
SivanColaandSivanCola 02a1741a3d fix(provider): honor DeepSeek thinking disable config
Co-authored-by: SivanCola <[email protected]>
2026-07-03 12:07:11 +08:00
yhh 0f2ce6529f chore(crash-dashboard): set reasonix-registry database_id 2026-07-02 21:05:31 -07:00
SivanCola 9bc6344fa1 feat: add session plugin usage command 2026-07-03 11:58:21 +08:00
SivanColaandSivanCola dbced6638a Merge main-v2 into provider extra_body
Resolve docs/GUIDE.md by keeping both the extra_body configuration docs and the latest Desktop hooks guide section.

Co-authored-by: SivanCola <[email protected]>
2026-07-03 11:53:58 +08:00
SivanCola 253faa132f feat: surface plugin package usage details 2026-07-03 11:39:17 +08:00
yhh 606d782379 feat(crash-dashboard): fold skill/MCP registry into the admin backend
Give the owner one admin console instead of a standalone, undeployed
registry worker. The registry source moves under the crash worker as a
second data plane (REGISTRY_DB); its public + publish API is served by
delegating to the folded Hono app, and a server-rendered Community tab
moderates user-published skills/MCP (approve/reject/hide/verify) gated on
the unified dashboard admin role, writing the shared audit log.

Requires a reasonix-registry D1 (schema in registry-schema.sql) before
the auto-deploy runs; the id in wrangler.toml is a placeholder.
2026-07-02 20:13:14 -07:00
SivanCola ad3d06343d feat: expand Claude-style plugin compatibility 2026-07-03 11:01:47 +08:00
yhh ac5f37f40b perf(crash-dashboard): cache identity + per-module queries + indexes
Dashboard loads blocked on a fresh id.reasonix.io /me round-trip and ran
all nine aggregation queries regardless of the module shown. Cache the
resolved identity per token hash (60s), fetch only the active module's
data (metric_users COUNT(DISTINCT) now hits only /stats/preferences), and
index the groups/pings/metrics columns the filters and facets scan.
2026-07-02 19:59:07 -07:00
SivanCola 9b5c870bd3 Merge pull request #5791 from SivanCola/feature/session-hook-subagent-depth
Support hook bootstrap, nested subagents, and plugin packages / 支持 Hook 启动上下文、二级子代理与插件包
2026-07-03 10:43:38 +08:00
SivanCola 2b29e2d92f docs: expand plugin CLI usage 2026-07-02 19:08:30 +08:00
SivanCola bafd9db24d docs: describe desktop plugin settings 2026-07-02 19:04:33 +08:00
SivanCola 8ff0b64884 fix(desktop): refine local plugin install layout 2026-07-02 19:00:48 +08:00
SivanCola 8de3dba0ff fix(desktop): swap plugin install actions and options 2026-07-02 18:56:52 +08:00
SivanCola d2ec3a1d68 fix(desktop): compact plugin install name field 2026-07-02 18:54:15 +08:00
SivanCola 9b70f121c0 fix(desktop): left align plugin replace option 2026-07-02 18:52:16 +08:00
SivanCola a71a850213 fix(desktop): extend plugin local path field 2026-07-02 18:50:14 +08:00
SivanCola 95d06df682 fix(desktop): align plugin install mode layouts 2026-07-02 18:48:25 +08:00
SivanCola addd1125d9 fix(desktop): unify plugin install form layout 2026-07-02 18:43:56 +08:00
SivanCola 08cf2b4585 fix(desktop): unify plugin install action label 2026-07-02 18:34:12 +08:00
ying-hua 052708f6f4 Support provider extra_body 2026-07-02 18:33:27 +08:00
SivanCola 554e881a69 fix(desktop): clarify plugin link mode 2026-07-02 18:29:22 +08:00
SivanCola 4e6db19690 fix(desktop): clarify plugin replacement option 2026-07-02 18:25:55 +08:00
SivanCola 044936cd5b refactor(desktop): simplify plugin install settings 2026-07-02 18:19:23 +08:00
SivanCola 48f9b9418a feat(desktop): add plugin folder picker 2026-07-02 18:10:09 +08:00
SivanCola 8ac66a4b18 fix(history): satisfy around clamp lint 2026-07-02 17:51:19 +08:00
SivanCola b99c08a52c feat(desktop): add plugin settings page 2026-07-02 17:48:39 +08:00
SivanCola 61e943beb3 fix(history): clamp around window without overflow 2026-07-02 17:48:34 +08:00
SivanCola 9e9d9e6e44 Merge pull request #5801 from esengine/docs/website-docs-refresh
Refresh website docs page / 刷新官网文档页
2026-07-02 17:38:20 +08:00
SivanCola f7c6048fa4 Refresh website documentation page 2026-07-02 17:32:57 +08:00
SivanCola 42ee48f214 feat(plugin): add unified plugin packages 2026-07-02 17:23:56 +08:00
SivanCola 6acdf3edb3 test(control): wait for edited prompt autosave 2026-07-02 16:45:42 +08:00
SivanCola 5d63d8e29a fix(agent): satisfy hook context lint 2026-07-02 16:41:51 +08:00
SivanCola 26ef771c7a feat(agent): support hook context and nested subagents 2026-07-02 15:58:51 +08:00
SivanCola bf53799e8a Merge pull request #5786 from SivanCola/feature/mouse-capture-toggle
Auto-copy mouse selection + mouse-capture toggle / 拖拽选中自动复制 + 鼠标接管开关
2026-07-02 15:37:35 +08:00
SivanCola ee9d976622 feat(cli): auto-copy mouse selection and add mouse-capture toggle
Releasing a left-drag selection in the TUI transcript now copies it to
the clipboard automatically (native terminal convention), with a
transient status-line "copied to clipboard" notice shared by the
mouse, right-click, and Ctrl+C copy paths.

Add "/mouse" to release mouse capture back to the terminal on demand,
restoring its native click-drag selection and right-click context
menu at the cost of in-app drag-select, the scrollbar, and
wheel-scroll while it's off. REASONIX_DISABLE_MOUSE=1 starts every
session with it off.
2026-07-02 15:02:17 +08:00
SivanCola d5ba463964 Merge pull request #5774 from SivanCola/fix/windows-classic-drag-rail
Fix Windows classic titlebar dragging / 修复 Windows 经典标题栏拖动
2026-07-02 12:00:44 +08:00
SivanCola 4762cd33d6 fix(desktop): add Windows classic drag rail 2026-07-02 11:53:20 +08:00
GTC2080 9838a27280 fix: keep stop state when switching to running tabs 2026-07-02 13:49:44 +10:00
SivanCola 484bca854f Merge pull request #5757 from SivanCola/feature/custom-provider-setup
Simplify custom provider setup / 简化自定义供应商接入配置
2026-07-02 10:05:33 +08:00
SivanCola 9ff0b20589 feat(desktop): simplify custom provider setup 2026-07-02 09:45:56 +08:00
SivanCola a2eaf80601 Merge pull request #5755 from ttmouse/feat/heartbeat-bot-push
feat: Heartbeat 定时任务输出实时推送到 Bot 渠道(飞书/QQ)
2026-07-02 09:44:31 +08:00
SivanCola cf2a8b061b Localize heartbeat bot push labels
Co-authored-by: SivanCola <[email protected]>
2026-07-02 03:06:32 +08:00
SivanCola fcd6180976 Fix heartbeat bot forwarding isolation
Co-authored-by: SivanCola <[email protected]>
2026-07-02 02:18:24 +08:00
SivanCola 6cbffaa78b Merge pull request #5754 from SivanCola/fix/anchor-edit-safety
Guard anchor edits after same-turn writes / 防止同回合锚点编辑失效
2026-07-02 01:58:50 +08:00
SivanCola 9ee254b2ec Fix anchor edit guard follow-ups 2026-07-02 01:44:07 +08:00
ttmouse cae565c158 feat: Heartbeat → Bot 渠道事件推送(双 Sink 分流) 2026-07-02 01:27:02 +08:00
SivanCola 3255803636 Guard anchor edits after same-turn writes 2026-07-02 01:05:53 +08:00
SivanCola c8ac697010 Merge pull request #5751 from SivanCola/fix/desktop-trash-ancillary-lag
Reduce desktop trash fallback hydration lag / 降低桌面端删除话题后的恢复卡顿
2026-07-02 00:39:24 +08:00
SivanCola 8d8bccd334 Merge pull request #5752 from SivanCola/fix/windows-classic-titlebar-tools
Fix Windows classic titlebar tool positioning / 修复 Windows 经典标题栏工具定位
2026-07-02 00:38:20 +08:00
SivanCola 3b0b9c0546 fix(desktop): keep checkpoint file arrays non-null 2026-07-02 00:32:52 +08:00
SivanCola 3608146829 Fix Windows classic titlebar tool positioning 2026-07-02 00:13:27 +08:00
SivanCola 3b3c9dba57 fix(desktop): reduce trash fallback hydration lag 2026-07-02 00:03:20 +08:00
SivanCola e1c2f46e8a Merge pull request #5743 from SivanCola/fix/desktop-code-preview-scroll
Fix workspace code preview scrolling / 修复工作区代码预览滚动
2026-07-01 23:27:44 +08:00
SivanCola 798c7ce85a fix(desktop): restore code preview scrolling 2026-07-01 23:06:04 +08:00
SivanCola 2ade3cd50d Merge pull request #5737 from esengine/fix/windows-frameless-titlebar
Fix Windows frameless titlebar controls / 修复 Windows 无边框标题栏控制
2026-07-01 23:01:58 +08:00
SivanCola da348d552e Merge pull request #5738 from SivanCola/fix/desktop-session-save-failures
Fix silent desktop session save failures / 修复桌面会话保存失败静默丢失
2026-07-01 22:46:02 +08:00
SivanCola 96431bc31f fix(desktop): surface session snapshot failures 2026-07-01 22:31:45 +08:00
SivanCola 2b56585f8f fix(desktop): add Windows frameless titlebar controls 2026-07-01 22:25:25 +08:00
SivanCola 4ea38a7a22 Merge pull request #5733 from SivanCola/fix/bash-cancel-wait-stall
Fix stalled bash cancel waits / 修复 bash 取消后等待卡死
2026-07-01 22:06:01 +08:00
SivanCola 5b8a9c0cd5 fix(bash): preserve wait error after cancel 2026-07-01 21:49:53 +08:00
SivanCola 1f4d811ac3 Merge pull request #5729 from SivanCola/feature/desktop-unread-topic-dot
Show unread topic attention dots / 显示未读会话提醒点
2026-07-01 21:39:05 +08:00
YHH 7cab46ac3a Merge pull request #5731 from esengine/feat/account-aware-skills
feat(skills): account-aware registry page with admin review
2026-07-01 21:38:13 +08:00
SivanCola 0a38be6852 fix(bash): release cancelled tracked waits 2026-07-01 21:38:00 +08:00
SivanCola 0ae11ebdef fix(desktop): omit hidden unread topic actions 2026-07-01 21:33:18 +08:00
yhh fdb99115d8 feat(skills): make the registry page account-aware with admin review
Reuse the site account client (scripts/auth.js) to show sign-in state in the nav, gate publishing behind a signed-in session, and reveal a moderator review queue (approve/reject) to admins. Adds currentAccount()/accountLogout() helpers to auth.js for account-aware pages.
2026-07-01 06:31:40 -07:00
SivanCola 2bcac618d7 feat(desktop): show unread topic attention dots 2026-07-01 21:13:10 +08:00
YHH 25184026bc Merge pull request #5728 from esengine/feat/registry-moderation
feat(registry): moderate submissions before they go live
2026-07-01 20:54:20 +08:00
yhh 3a1d04ae23 feat(registry): moderate submissions before they go live
Publishing now requires a verified email; non-admin submissions land as 'pending' and stay out of the listing, detail, and activity feed until an admin approves them. Adds /v1/admin review endpoints (approve / reject / hide / verify) gated on the admin role. Versions of an already-approved package stay live; admins publish straight to active.
2026-07-01 05:47:41 -07:00
SivanCola e73221c8b7 Merge pull request #5726 from esengine/feature/composer-guidance-queue
Add composer guidance queue controls / 新增 Composer 引导队列控制
2026-07-01 20:44:54 +08:00
YHH bd9923d644 Merge pull request #5725 from esengine/feat/skills-mcp-registry
feat: skill/MCP registry — worker + web discovery page
2026-07-01 20:28:37 +08:00
yhh 6892b5a035 feat(site): add skills registry page
Browse, search, sort, and publish community skills and MCP servers at /skills, with a live activity feed. Client-fetches the registry (PUBLIC_REGISTRY_ORIGIN, defaults to registry.reasonix.io) and links it from the home nav.
2026-07-01 05:21:13 -07:00
SivanCola 885ad5627f feat(desktop): add composer guidance queue controls 2026-07-01 20:21:08 +08:00
yhh e702eb082e feat(registry): add skill/MCP registry worker
Discovery layer over the existing install_source rail. Hono + D1: list/search published skills and MCP servers, record installs, and serve a live activity feed. Identity is delegated to id.reasonix.io; the worker stores only metadata and a source pointer, and never fetches or executes package content.
2026-07-01 05:21:07 -07:00
GTC2080 ef1d287fc0 fix(desktop): hide collapsed creation tool bodies 2026-07-01 20:51:18 +10:00
GTC2080 c146456d8a fix(cli): repaint wide-character input on Windows 2026-07-01 20:15:05 +10:00
SivanCola ebd73457da Merge pull request #5711 from SivanCola/fix/compact-prompt-approvals
Compact approval prompts
2026-07-01 17:55:11 +08:00
YHH 3f037fa745 feat(site): bilingual community forum (EN / 中文) (#5715)
The /community pages shipped English-only; the rest of the site is
bilingual. Add the same EN/中文 toggle: a lang switch in the community
nav, .l-en/.l-zh spans for all static chrome, and the shared reasonix-lang
choice so the language carries over from the main site. The client
renders dynamic labels (categories, times, counts, badges) as toggled
spans too, and localizes the anti-spam/gate errors by code — so the whole
forum reads in either language.
2026-07-01 17:53:13 +08:00
SivanCola f83b84b896 fix(desktop): show approval details by default 2026-07-01 17:47:51 +08:00
SivanCola be59e66976 Merge pull request #5714 from SivanCola/feature/context-panel-usage-visuals
Refine context panel usage overview / 优化上下文面板用量概览
2026-07-01 17:42:36 +08:00
SivanCola d924d998a0 feat: refine context panel usage overview 2026-07-01 17:32:31 +08:00
YHH 8b04b164dc fix(forum): let staff bypass the trust-based post gates (#5713)
Categories with a high min_trust_to_post (e.g. Announcements at 4) were
unreachable: admins/moderators seed at trust 2, so the min_trust and
new-member link checks blocked them. Staff now bypass the trust gates
(and the rate limiter) while still needing a verified email.
2026-07-01 17:32:09 +08:00
SivanCola 118fe34378 Merge pull request #5704 from SivanCola/fix/session-profile-restore
fix(desktop): Restore session profile on switch / 修复切会话恢复权限与思考模式
2026-07-01 17:25:18 +08:00
SivanCola 170771fc3a fix(desktop): compact approval prompts 2026-07-01 17:19:20 +08:00
YHH 06fc964bc9 feat(site): community forum frontend at /community (#5708)
The human-facing forum, built on the approved design and unified into the
main site: Astro pages at reasonix.io/community (discussions home, thread,
new topic, guidelines). They're client-rendered against the
forum.reasonix.io API and gated on the shared id.reasonix.io session (the
rxid cookie is sent cross-subdomain), so there's no second login and no
separate app to deploy — it ships with the site via pages.yml.

Anti-spam surfaces here too: the composer only appears when signed in, and
the API's gate errors (verify email, links-restricted, rate limits) are
shown inline. Reuses the site's design tokens with self-hosted fonts.
API base is build-configurable via PUBLIC_FORUM_API (defaults to
https://forum.reasonix.io).
2026-07-01 17:16:45 +08:00
YHH 6643299111 feat(forum): community forum API with shared identity + anti-spam (#5706)
* feat(forum): community forum API with shared identity + anti-spam

First slice of the Reasonix Community forum: a new reasonix-forum worker
(forum.reasonix.io) backed by its own D1. Identity is the shared
id.reasonix.io session — it reads the .reasonix.io rxid cookie and
resolves it via /me (Bearer), the same pattern the crash dashboard uses —
so there's no second login.

Anti-abuse is built in at the identity/trust gate rather than per-post
content scanning, keeping it cheap: posting requires a verified email;
new (trust 0) members can't post links; per-trust daily caps + a per-IP
limiter throttle flooding; and N distinct flags auto-hide a post pending
moderation. Endpoints: categories/topics/posts read, create topic, reply,
and flag.

Bootstrap (out of band): wrangler d1 create reasonix-forum, apply
schema.sql + seed.sql, fill database_id. Frontend, deploy CI, and the
moderation queue land in follow-ups.

* ci(forum): provision D1 + auto-deploy forum.reasonix.io on merge

Fill the reasonix-forum database_id (D1 created) and add the deploy
workflow so merging to main-v2 ships forum.reasonix.io, matching the
accounts/crash worker pattern.
2026-07-01 17:15:03 +08:00
SivanCola 72ce3d8d93 fix(desktop): persist closed session profile 2026-07-01 17:07:07 +08:00
SivanCola 0aad3bd186 fix(desktop): restore session profile on switch 2026-07-01 16:44:08 +08:00
YHH 1858974dc7 feat(crash): unify dashboard auth onto id.reasonix.io identity (#5701)
The crash dashboard ran its own account system — a separate D1 users/
sessions table with its own password login — which fragmented identity
from id.reasonix.io. Make it consume the shared identity instead: it
resolves the .reasonix.io rxid cookie via id.reasonix.io/me (Bearer) and
keeps only a per-dashboard role in a new `access` table. Existing roles
migrate by email (migrate-access.sql); ADMIN_EMAILS force-promotes the
owner so a missed migration can't lock them out. Sign-in redirects to the
shared login; sign-out revokes the shared session.

Also: accounts /auth/logout accepts a Bearer token (so crash can revoke
server-side), and the site login honours a next to any *.reasonix.io.
2026-07-01 16:26:24 +08:00
SivanCola ac2f6695c5 Merge pull request #5700 from SivanCola/docs/attribute-todo-panel-contributors
Attribute TodoPanel contributor credit / 补充 TodoPanel 贡献署名
2026-07-01 16:19:41 +08:00
69d0185ff6 docs: leave attribution in PR history
Co-authored-by: dengbushi <[email protected]>
Co-authored-by: HHHEEEWWW <[email protected]>
2026-07-01 16:13:34 +08:00
4d8caaa944 docs: attribute todo panel contributors
Co-authored-by: dengbushi <[email protected]>
Co-authored-by: HHHEEEWWW <[email protected]>
2026-07-01 16:10:02 +08:00
SivanCola 5e40ed682b Merge pull request #5698 from SivanCola/fix/todo-panel-batch-collapse
fix(desktop): scope todo panel completion state / 修复待办面板跨会话完成态
2026-07-01 15:56:29 +08:00
SivanCola 1284d1c6c1 fix(desktop): scope todo panel state by session 2026-07-01 15:46:04 +08:00
YHH 3093f03585 feat(site): account UI — sign-in, register, profile, device approval (#5682)
The accounts API (id.reasonix.io) shipped without a web frontend, so no
one could register or sign in. Add Astro pages that consume the JSON API:
login (handles the post-verify ?verified banner), register, forgot/reset,
a gated account page (profile edit, password change, sign out, delete),
and a device-approval page — the human half of the CLI/desktop device
flow (enter the terminal's code, review, approve/reject). Reuses the
site's existing design tokens; all copy is bilingual like the rest of the
site. The API base is build-configurable via PUBLIC_ACCOUNTS_API and
defaults to https://id.reasonix.io.
2026-07-01 15:42:22 +08:00
YHH f39f06896f feat(accounts): device-authorization sign-in for CLI/desktop (#5681)
The worker only did cookie-based web login, so the CLI/desktop had no way
to authenticate without a browser redirect. Add an RFC 8628-style device
flow: /device/start issues a device_code (polled) plus a short user_code
the human approves at APP_ORIGIN/device while signed in; /device/poll
returns the minted cli session on the winning atomic claim. Only the
device code's peppered hash is stored, and the session token is created at
claim time, so it never lands in the DB. Session middleware now resolves a
Bearer token in addition to the cookie, so the one sessions table serves
both web and cli kinds.
2026-07-01 15:42:11 +08:00
YHH cb1a0bfd18 ci(accounts): sync RESEND_API_KEY from repo secret on deploy (#5695)
The worker reads env.RESEND_API_KEY at runtime from Cloudflare, but the
key was only stored as a GitHub Actions secret — where wrangler deploy
never pushes it — so mail silently fell back to the console stub. Add a
deploy step that pipes the RESEND_API_KEY repo secret into
wrangler secret put, giving the mail key a single source of truth and
removing the need for local wrangler auth. Guarded so a missing secret
just skips (stays in stub mode) instead of setting an empty key.
2026-07-01 15:26:57 +08:00
YHH c757d24483 ci(accounts): auto-deploy id.reasonix.io on merge to main-v2 (#5679)
* ci(accounts): auto-deploy id.reasonix.io on merge to main-v2

The accounts worker shipped without a deploy pipeline — only the crash
worker had one, so id.reasonix.io could never go live from CI. Mirror
deploy-crash-worker with the repo's standard pnpm setup (action-setup@v6
+ setup-node cache), path-filtered to workers/accounts/**. Migrations
and secrets stay manual and out of band, same as the crash worker.

* fix(accounts): send from the verified send.reasonix.io domain

MAIL_FROM was [email protected], but only send.reasonix.io is a
verified Resend domain (the apex isn't). Resend rejects sends from an
unverified from-domain, so every verification/reset email would 403.
Send from the verified subdomain instead.
2026-07-01 15:13:54 +08:00
SivanCola c38b0801cc Merge pull request #5691 from SivanCola/fix/transient-blank-session-delete
Fix delete fallback blank sessions / 修复删除后的空白会话残留
2026-07-01 15:05:16 +08:00
SivanCola 8d42210d7c fix(desktop): discard unused transient blank sessions 2026-07-01 14:55:19 +08:00
SivanCola 191772931f fix(desktop): keep delete fallback blanks out of project tree 2026-07-01 14:27:47 +08:00
SivanCola 56a53237f4 Merge pull request #5675 from SivanCola/fix/legacy-sidebar-project-recovery
Recover legacy sidebar projects on upgrade / 升级时恢复旧版侧栏项目
2026-07-01 11:49:53 +08:00
SivanCola c045230307 fix(desktop): serialize project index updates 2026-07-01 11:41:06 +08:00
SivanCola 946255ce89 fix(desktop): recover legacy sidebar projects on upgrade 2026-07-01 11:27:46 +08:00
SivanCola 397605ab13 Merge pull request #5674 from SivanCola/fix/mcp-remove-clears-failure
Clear stale MCP failure after removal / 删除 MCP 后清理残留失败状态
2026-07-01 11:13:59 +08:00
SivanColaandcyq1017 a753893797 fix(desktop): clear failed MCP status after removal
Co-authored-by: cyq1017 <[email protected]>
2026-07-01 11:08:30 +08:00
SivanCola 23bf5dd464 Merge pull request #5657 from CVEngineer66/feature/autoresearch-runtime
Add host-managed AutoResearch runtime
2026-07-01 04:53:00 +08:00
SivanCola c600b12693 Merge pull request #5643 from SauronSkywalker/dpi-zoom-restart
feat(desktop): add custom DPI zoom via WebView2 ZoomFactor
2026-07-01 04:51:57 +08:00
SivanCola d2cf3addc7 Merge pull request #5662 from SivanCola/fix/math-rendering-pr-integration
Consolidate math rendering fixes / 整合数学渲染修复
2026-07-01 04:51:17 +08:00
SivanCola f1951c5d1f Harden AutoResearch task store paths
Co-authored-by: SivanCola <[email protected]>
2026-07-01 01:01:35 +08:00
SivanCola 82a9d6829e Consolidate math rendering fixes 2026-07-01 00:54:52 +08:00
SivanCola 7981c96d70 fix(desktop): keep zoom preference authoritative
Co-authored-by: SivanCola <[email protected]>
2026-07-01 00:50:51 +08:00
SivanCola ac1b2ad45d Merge pull request #5661 from SivanCola/fix/hide-windows-probe-consoles
Hide Windows startup probe consoles / 隐藏 Windows 启动探测终端弹窗
2026-07-01 00:49:18 +08:00
SivanCola f00bb1ea85 Fix AutoResearch readiness evidence loop 2026-07-01 00:22:09 +08:00
SivanCola 68b7ea6c07 fix(desktop): hide zoom setting outside Windows
Co-authored-by: SivanCola <[email protected]>
2026-07-01 00:12:19 +08:00
SivanCola d239e346f6 fix(environment): hide startup probe consoles on Windows 2026-07-01 00:09:17 +08:00
wufengfan 5527e8e805 Keep AutoResearch out of default tool surface 2026-06-30 23:51:13 +08:00
wufengfan cf7ee9e400 Remove AutoResearch frontend UI surfaces 2026-06-30 23:35:21 +08:00
wufengfan c580472167 Reuse AutoResearch task for repeated goal updates 2026-06-30 23:07:03 +08:00
wufengfan 462fd5e281 Refresh status metadata for AutoResearch notices 2026-06-30 23:01:39 +08:00
wufengfan 8ddad32b4b Allow AutoResearch status bar item in config 2026-06-30 22:53:31 +08:00
wufengfan 2bc0d2f010 Add host-managed AutoResearch runtime 2026-06-30 22:40:43 +08:00
SauronSkywalker af13bc76be feat(desktop): add custom DPI zoom via WebView2 ZoomFactor
- Add ZoomFactor support in windows.Options (main.go)
- Add zoom_factor.go for JSON persistence of zoom setting
- Add dpiScale.ts for frontend zoom state management
- Add slider UI in Settings → Appearance with capsule thumb
- Add SetDesktopZoomFactor / GetDesktopZoomFactor / RestartApplication bindings
- Add locale keys for zoom setting (en/zh/zh-TW)

Setting changes require app restart to take effect (WebView2 engine-level).
2026-06-30 17:17:27 +08:00
wufengfan 4d767f3db4 Add AutoResearch runtime design spec 2026-06-29 22:21:11 +08:00
liaoyl830 5b81febdab fix(provider): include empty properties in tool schemas (#5167) 2026-06-23 21:58:36 +08:00
maoxin1234 c58805afea docs: add reasoning-controls-by-provider reference
Document which OpenAI-compatible backend uses which reasoning protocol
(thinking.type vs reasoning_effort), which params each ignores, and how to
drive thinking on a non-auto-detected backend via the `thinking` field.
Includes a troubleshooting section for telling provider behaviour apart
from a Reasonix bug. Suggested by @eghrhegpe (#5063).
2026-06-23 09:46:05 +08:00
maoxin1234 44876d2607 feat(provider): generic thinking toggle + DeepSeek disable
Broaden the thinking-mode support beyond Zhipu auto-detection (credit
@eghrhegpe, #5063):

- Add a vendor-agnostic `thinking` config field (enabled|disabled) read
  from cfg.Extra, so OpenAI-compatible providers we don't auto-detect
  (e.g. opencode.ai) can drive thinking.type explicitly. An explicit
  field also overrides the Zhipu effort default.
- Let DeepSeek turn thinking off via /effort disabled (off now maps to
  disabled instead of erroring), routed through thinking.type=disabled
  with no reasoning_effort.

Covered by buildRequest/parse tests plus updated DeepSeek effort tests.
2026-06-22 22:28:46 +08:00
maoxin1234 8ecdf392ad feat(provider): add Zhipu GLM thinking-mode support
Zhipu's OpenAI-compatible endpoint (open.bigmodel.cn / api.z.ai) gates
chain-of-thought through `thinking.type` (enabled|disabled) and silently
ignores `reasoning_effort`, so GLM users could not turn thinking off and
paid for reasoning tokens they didn't want.

Detect Zhipu hosts via IsZhipu() and route reasoning control to the
thinking knob, mirroring the existing MiniMax handling: emit thinking.type
and omit reasoning_effort. /effort exposes enabled|disabled and maps stale
depth levels onto them.
2026-06-22 21:27:00 +08:00
619 changed files with 83776 additions and 7218 deletions

No files matched your search

+14
View File
@@ -48,6 +48,7 @@ jobs:
run: go build ./...
- name: test
if: runner.os != 'Windows'
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
# regression there silently tanks the cache hit rate the project is
@@ -55,6 +56,19 @@ jobs:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test ./...
- name: test
if: runner.os == 'Windows'
timeout-minutes: 10
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
# regression there silently tanks the cache hit rate the project is
# built around.
REASONIX_RELEASE_CACHE_GUARD: "1"
# Bound sandbox helper children in Windows tests so a failed OS-level
# launch cannot pin the Actions step after Go's package timeout fires.
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -p 4 -timeout=3m ./...
race:
runs-on: ubuntu-latest
steps:
@@ -0,0 +1,49 @@
name: Deploy accounts worker
on:
push:
branches: [main-v2]
paths:
- 'workers/accounts/**'
- '.github/workflows/deploy-accounts-worker.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-accounts-worker
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/accounts/pnpm-lock.yaml
- name: Deploy
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
pnpm install --frozen-lockfile
npx wrangler deploy
- name: Sync RESEND_API_KEY to the worker
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
run: |
if [ -n "$RESEND_API_KEY" ]; then
printf '%s' "$RESEND_API_KEY" | npx wrangler secret put RESEND_API_KEY
else
echo "RESEND_API_KEY not set in repo secrets; skipping (worker stays in stub email mode)."
fi
+38
View File
@@ -0,0 +1,38 @@
name: Deploy forum worker
on:
push:
branches: [main-v2]
paths:
- 'workers/forum/**'
- '.github/workflows/deploy-forum-worker.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-forum-worker
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/forum/pnpm-lock.yaml
- name: Deploy
working-directory: workers/forum
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
pnpm install --frozen-lockfile
npx wrangler deploy
+116 -6
View File
@@ -8,7 +8,8 @@ name: Release desktop
# Wails cannot cross-compile a CGO/WebKit binary, so build/ fans out to one native
# runner per platform. Artifacts are minisign-signed (MINISIGN_* secrets), a
# latest.json manifest is generated, and everything is published to a GitHub
# release and mirrored to R2 (the updater reads R2 first, GitHub as fallback).
# release and mirrored to R2 (the updater reads R2 first, then the crash worker
# release gateway; stable desktop releases own GitHub's repository-wide "latest").
#
# The same build/sign/manifest pipeline serves two channels. A `desktop-v*` tag
# (or manual stable dispatch) publishes a GitHub release and moves R2's latest/
@@ -260,16 +261,22 @@ jobs:
working-directory: desktop
run: go run ./cmd/sign manifest ../dist "${{ steps.ver.outputs.version }}" "${{ steps.ver.outputs.tag }}"
# Canary is R2-only and never appears on the GitHub releases page; the mirror
# job picks up the signed dist via the canary-dist artifact below. Stable
# publishes a GitHub release as usual.
# Canary never appears on the GitHub releases page; the mirror job picks up
# the signed dist via the canary-dist artifact below. Stable publishes a
# GitHub release as usual.
- name: Publish GitHub release
if: steps.ver.outputs.channel != 'canary'
env:
GH_TOKEN: ${{ github.token }}
run: |
# Keep the repository homepage focused on the installable desktop app;
# the CLI release line is configured not to claim repository-wide latest.
args=(--title "Reasonix Desktop ${{ steps.ver.outputs.version }}" --generate-notes)
[ "${{ steps.ver.outputs.prerelease }}" = "true" ] && args+=(--prerelease)
if [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then
args+=(--prerelease --latest=false)
else
args+=(--latest)
fi
gh release create "${{ steps.ver.outputs.tag }}" dist/* "${args[@]}"
- name: Upload canary dist for mirror
@@ -285,7 +292,7 @@ jobs:
needs: publish
runs-on: ubuntu-latest
permissions:
contents: read # gh release download
contents: write # gh release download + compatibility manifest upload
actions: write # dispatch pages.yml to re-bake the site version
# Skip cleanly when R2 isn't configured; GitHub release still works as fallback.
if: ${{ github.repository_owner == 'esengine' }}
@@ -366,6 +373,109 @@ jobs:
aws s3 cp assets/ "s3://${R2_BUCKET}/latest/" --recursive --endpoint-url "$ENDPOINT"
fi
# dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot
# protection), so smoke the mirrored objects over the authenticated S3 API
# instead of the public edge. This verifies the mirror landed; the public
# edge itself is not reachable from CI and is covered by end users' traffic.
- name: Smoke desktop release pointers
if: env.HAS_R2 == 'true'
env:
TAG: ${{ steps.ver.outputs.tag }}
VERSION: ${{ steps.ver.outputs.version }}
CHANNEL: ${{ steps.ver.outputs.channel }}
PRERELEASE: ${{ steps.ver.outputs.prerelease }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
f=assets/latest.json
jq -e --arg version "$VERSION" --arg prefix "https://dl.reasonix.io/${TAG}/" '
.version == $version and
.download_page == "https://reasonix.io/#start" and
([.platforms[] | (.url, .sig)] |
all(type == "string" and startswith($prefix) and (contains("/releases/latest/") | not)))
' "$f" >/dev/null
aws s3 cp "s3://${R2_BUCKET}/${TAG}/latest.json" /tmp/reasonix-desktop-tag-latest.json --endpoint-url "$ENDPOINT"
jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-tag-latest.json >/dev/null
if [ "$CHANNEL" = "canary" ]; then
pointer="canary"
elif [ "$PRERELEASE" = "true" ]; then
pointer=""
else
pointer="latest"
fi
if [ -n "$pointer" ]; then
aws s3 cp "s3://${R2_BUCKET}/${pointer}/latest.json" /tmp/reasonix-desktop-pointer-latest.json --endpoint-url "$ENDPOINT"
jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-pointer-latest.json >/dev/null
fi
jq -r '.platforms[] | .url, .sig' "$f" | while IFS= read -r asset; do
key="${asset#https://dl.reasonix.io/}"
aws s3api head-object --bucket "$R2_BUCKET" --key "$key" --endpoint-url "$ENDPOINT" >/dev/null
done
# Best-effort probe of the release gateway — the updater's second
# manifest source — over the same public edge and Go client UA end users
# hit. A 403 here is the known Cloudflare bot-protection gap (#6005:
# datacenter/proxy egress gets blocked before the worker runs) and must
# not fail the release until a WAF skip rule for /v1/desktop/releases/*
# lands; it is surfaced as a warning so the run shows whether the edge
# is open. Anything else unexpected (404, 5xx, wrong version) means the
# gateway route or pointer regressed and fails hard.
- name: Probe public release gateway
if: env.HAS_R2 == 'true'
env:
VERSION: ${{ steps.ver.outputs.version }}
CHANNEL: ${{ steps.ver.outputs.channel }}
PRERELEASE: ${{ steps.ver.outputs.prerelease }}
run: |
set -euo pipefail
if [ "$PRERELEASE" = "true" ]; then
echo "prerelease — no pointer moved; skipping gateway probe"
exit 0
fi
chan="stable"
[ "$CHANNEL" = "canary" ] && chan="canary"
url="https://crash.reasonix.io/v1/desktop/releases/${chan}/latest.json"
# curl already prints 000 for a transport failure; || true keeps -e
# from killing the step so the case below can route it.
code="$(curl -sS -A "Go-http-client/2.0" -o /tmp/gateway-latest.json -w '%{http_code}' "$url" || true)"
case "$code" in
200)
if jq -e --arg version "$VERSION" '.version == $version' /tmp/gateway-latest.json >/dev/null; then
echo "gateway serves $VERSION on $chan"
else
echo "::error::gateway responded 200 but serves $(jq -r '.version // "<none>"' /tmp/gateway-latest.json), want $VERSION — stale or wrong pointer"
exit 1
fi
;;
403)
echo "::warning::gateway returned 403 to CI egress — known bot-protection gap (#6005), not failing the release"
;;
000|"")
echo "::warning::gateway unreachable from CI (transport error), not failing the release"
;;
*)
echo "::error::gateway returned $code for $url — route or pointer regression"
exit 1
;;
esac
- name: Attach desktop manifest to matching CLI release
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' && steps.ver.outputs.prerelease != 'true'
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
if gh release view "$VERSION" >/dev/null 2>&1; then
gh release upload "$VERSION" assets/latest.json --clobber
else
echo "CLI release $VERSION does not exist yet; release.yml will attach the compatibility latest.json when it publishes."
fi
# Stable release moved R2 latest/ — rebuild the site so its build-time baked
# version + JSON-LD follow (site.js's runtime .rxv refresh can't touch first paint / SEO).
- name: Refresh site to the new version
+30 -4
View File
@@ -6,16 +6,17 @@ name: Release npm
# own tag lets a prerelease ship to npm's `next` channel without touching the
# stable Homebrew cask (and lets a stable cask ship without forcing npm to leave
# its rc). Bump with:
# git tag npm-vX.Y.Z # stable -> publishes under `next`
# git tag npm-vX.Y.Z # stable -> publishes under `latest`
# git tag npm-vX.Y.Z-rc.N # prerelease -> publishes under `next`
# git push origin <tag>
#
# A manual workflow_dispatch can publish either an opt-in canary from the current
# ref or an approved stable/next version when tag creation is restricted. Canary
# publishes to the `canary` dist-tag (npm i reasonix@canary) and never moves
# `next`/`latest`. build.mjs decides the dist-tag: 0.x stable is `latest`; a
# `-canary.` build is `canary`; the 1.x line and rc prereleases are `next`. Promote
# a 1.x stable to the default install with `npm dist-tag add [email protected] latest`.
# `next`/`latest`. build.mjs decides the dist-tag: a `-canary.` build is `canary`,
# any other prerelease is `next`, and a stable version is `latest` (#5822 — the
# old "promote latest by hand" rule left it pinned at 0.53.2 and downgraded
# `npm update -g` users). The verify step below asserts the tag actually landed.
on:
push:
tags: ['npm-v*']
@@ -86,3 +87,28 @@ jobs:
- run: node npm/build.mjs "${{ steps.ver.outputs.arg }}" --publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# Assert the dist-tag landed where build.mjs aimed it. This is the guard
# that #5822 lacked: `latest` sat on 0.53.2 for months while stables went
# to `next`, and nothing noticed until users got downgraded. The registry
# applies tags asynchronously after publish, hence the poll.
- name: Verify dist-tags
run: |
set -euo pipefail
version="${{ steps.ver.outputs.arg }}"
version="${version#npm-}"; version="${version#v}"
case "$version" in
*-canary.*) tag=canary ;;
*-*) tag=next ;;
*) tag=latest ;;
esac
for attempt in 1 2 3 4 5 6; do
got="$(npm view reasonix dist-tags."$tag" 2>/dev/null || true)"
if [ "$got" = "$version" ]; then
echo "dist-tag $tag -> $got"
exit 0
fi
echo "dist-tag $tag -> ${got:-<unset>}, want $version (attempt $attempt)"
sleep 10
done
echo "::error::npm dist-tag '$tag' never landed on $version"
exit 1
+124 -1
View File
@@ -30,7 +30,9 @@ jobs:
name: archives + checksums + homebrew tap
needs: cache-guard
runs-on: ubuntu-latest
# CLI stable release (archives + GitHub release + Homebrew cask). Gated on the
# CLI stable release (archives + GitHub release + Homebrew cask). It does not
# claim GitHub's repository-wide Latest badge; that belongs to desktop so the
# repository homepage points users at the installable app. Gated on the
# `release` environment so only esengine can approve it going public.
environment: release
steps:
@@ -48,3 +50,124 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
- name: Attach desktop manifest compatibility asset
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
case "$TAG" in
*-*)
echo "prerelease $TAG — GitHub latest does not move here; skipping desktop manifest compatibility asset"
exit 0
;;
esac
if [ "$HAS_R2" != "true" ]; then
echo "R2 secrets not configured; skipping desktop manifest compatibility asset"
exit 0
fi
# dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot
# protection), so read the manifest over the authenticated S3 API instead
# of the public edge.
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
aws configure set region auto
aws s3 cp "s3://${R2_BUCKET}/latest/latest.json" latest.raw.json \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
jq '.download_page = "https://reasonix.io/#start"' latest.raw.json > latest.json
jq -e '
([.platforms[] | (.url, .sig)] |
all(type == "string" and startswith("https://dl.reasonix.io/") and (contains("/releases/latest/") | not)))
' latest.json >/dev/null
gh release upload "$TAG" latest.json --clobber
# The compatibility asset exists for pre-v1.16 desktop updaters that
# still poll GitHub's repository-wide latest URL. Desktop releases now
# own that Latest badge, but this check still exercises the public fallback
# path exactly the way those clients fetch it: anonymously, over the public
# edge, with a Go client UA. Unlike dl.reasonix.io (whose bot protection
# 403s Actions egress — see the R2 note above), GitHub serves its own
# runners, so this can hard-fail. #5826/#5858 shipped a broken update check
# for weeks precisely because nothing exercised the public path. Retries
# cover the release CDN propagating the freshly uploaded asset.
- name: Smoke public compatibility manifest
env:
TAG: ${{ github.ref_name }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
run: |
set -euo pipefail
case "$TAG" in
*-*)
echo "prerelease $TAG — no compatibility asset uploaded; skipping"
exit 0
;;
esac
if [ "$HAS_R2" != "true" ]; then
echo "R2 secrets not configured; no compatibility asset uploaded; skipping"
exit 0
fi
url="https://github.com/${{ github.repository }}/releases/latest/download/latest.json"
for attempt in 1 2 3 4 5 6; do
if curl -fsSL -A "Go-http-client/2.0" -o /tmp/compat-latest.json "$url"; then
jq -e '(.version | type == "string") and (.platforms | type == "object")' /tmp/compat-latest.json >/dev/null
echo "public compatibility manifest OK (desktop version $(jq -r .version /tmp/compat-latest.json))"
exit 0
fi
echo "attempt $attempt failed; retrying in 10s"
sleep 10
done
echo "::error::public compatibility manifest unreachable at $url"
exit 1
# A stable CLI release must never leave the npm line behind: v1.17.5
# shipped as binaries/Homebrew while npm `latest` still pointed at 0.53.2
# (#5822) — every `npm update -g` user was silently downgraded to a
# months-old version, and nothing noticed because the npm line
# (release-npm.yml, `npm-vX.Y.Z` tags) is triggered independently and the
# stable npm tag was simply never pushed. release-npm.yml's own verify
# step only guards runs that happen; this guard catches the run that
# DIDN'T.
#
# Two distinct states, two responses (the runs race: both workflows wait
# on the release environment independently, and npm dist-tags propagate
# asynchronously, so "tag pushed but latest not moved yet" is a NORMAL
# mid-release state, not a failure):
# - npm-v<version> tag missing -> hard fail. This is the #5822 gap:
# nobody pushed the npm release at all.
# - tag pushed, latest lagging -> poll briefly, then WARN and pass.
# The npm run may still be awaiting its own environment approval;
# release-npm.yml's verify step owns asserting the dist-tag lands.
- name: Check npm latest dist-tag freshness
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
case "$TAG" in
*-*)
echo "prerelease $TAG — npm latest does not move on prereleases; skipping"
exit 0
;;
esac
version="${TAG#v}"
if ! git ls-remote --exit-code origin "refs/tags/npm-v$version" >/dev/null; then
echo "::error::the npm-v$version tag was never pushed — the npm channel is being left behind and 'npm update -g' users will be downgraded to the old 'latest'. Push it: git tag npm-v$version ${TAG} && git push origin npm-v$version (or 'npm dist-tag add reasonix@$version latest' for an already-published version)."
exit 1
fi
for attempt in 1 2 3 4 5 6; do
got="$(npm view reasonix dist-tags.latest 2>/dev/null || true)"
if [ -n "$got" ]; then
newest="$(printf '%s\n%s\n' "$got" "$version" | sort -V | tail -1)"
if [ "$newest" = "$got" ]; then
echo "npm latest -> $got (>= $version) OK"
exit 0
fi
fi
echo "npm latest -> ${got:-<unreadable>}, want >= $version (attempt $attempt)"
sleep 10
done
echo "::warning::npm-v$version is pushed but npm 'latest' is still ${got:-<unreadable>} — the npm publish run is likely awaiting release-environment approval or still propagating. Approve/monitor the release-npm run; its verify step asserts the dist-tag lands."
exit 0
+1
View File
@@ -58,4 +58,5 @@ homebrew_casks:
release:
prerelease: auto
make_latest: false
name_template: "Reasonix CLI {{ .Tag }}"
+31
View File
@@ -63,6 +63,37 @@ make hooks # install git hooks (pre-push: go vet)
make cross # cross-compile for all 6 targets
```
### Isolated development environment
A source-built binary shares no on-disk state with a stable release when launched
with `REASONIX_HOME` set. This gives each build its own self-contained directory
tree — config, credentials, sessions, cache, skills, commands, hooks, and
desktop tab state — so the two builds never interfere:
**CLI**
```bash
REASONIX_HOME=/tmp/reasonix-dev go run ./cmd/reasonix
# or after building:
# REASONIX_HOME=/tmp/reasonix-dev ./bin/reasonix
```
**Desktop**
```bash
cd desktop && wails build
REASONIX_HOME=/tmp/reasonix-dev-isolated build/bin/reasonix-desktop
```
On Windows, use `$env:REASONIX_HOME` in PowerShell or `set REASONIX_HOME=` in
Command Prompt; the binary extension is `.exe`.
The directory is empty on first launch; the app behaves exactly like a fresh
install. Every subsequent write — config saves, credential storage, session
logs — stays under `REASONIX_HOME`. Legacy migration, OS-home convention
directory scanning, and all other fallback paths are skipped so no production
data leaks in or out.
### Cache-first review gate
Reasonix treats high prompt-cache hit rate as product behavior. Changes that
+10 -1
View File
@@ -2,7 +2,7 @@ VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
LDFLAGS := -s -w -X main.version=$(VERSION)
GOEXE := $(shell go env GOEXE)
.PHONY: build vet fmt test hooks cross clean
.PHONY: build vet fmt test desktop-test desktop-test-short desktop-test-times hooks cross clean
build:
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
@@ -17,6 +17,15 @@ fmt:
test:
go test ./...
desktop-test:
cd desktop && go test .
desktop-test-short:
cd desktop && go test -short .
desktop-test-times:
cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
hooks:
@git config core.hooksPath .githooks
@echo "installed: core.hooksPath -> .githooks (pre-push runs go vet)"
+2 -6
View File
@@ -33,12 +33,6 @@
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
</p>
<p align="center">
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/9e931d80-2050-4b10-902e-44970cc133ad.svg" alt="oosmetrics — Top 2 in Agents by velocity"/></a>
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/556d94b3-61b7-486b-baf2-888b9327deab.svg" alt="oosmetrics — Top 3 in LLMs by velocity"/></a>
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/0f457d4c-efca-4d15-ad2b-139691ff342c.svg" alt="oosmetrics — Top 3 in CLI by velocity"/></a>
</p>
<br/>
<h3 align="center">A DeepSeek-native AI coding agent for your terminal.</h3>
@@ -137,6 +131,8 @@ commands, `@` references, and two-model setup are all in the
from the desktop app, then use approvals, YOLO, and commands from IM.
- **[Spec](./docs/SPEC.md)** — engineering contract: architecture, registries,
data types, and roadmap.
- **[Task contracts & pause policy](./docs/TASK_CONTRACT.md)** — structure
complex requests with context, output boundaries, constraints, and when to ask.
- **[Tool contract](./docs/TOOL_CONTRACT.md)** — provider-visible built-in tool
names, read-only flags, and schema snapshot guard.
- **[Migrating from 0.x](./docs/MIGRATING.md)** — moving from the legacy
+1 -6
View File
@@ -33,12 +33,6 @@
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
</p>
<p align="center">
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/9e931d80-2050-4b10-902e-44970cc133ad.svg" alt="oosmetrics — Top 2 in Agents by velocity"/></a>
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/556d94b3-61b7-486b-baf2-888b9327deab.svg" alt="oosmetrics — Top 3 in LLMs by velocity"/></a>
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/0f457d4c-efca-4d15-ad2b-139691ff342c.svg" alt="oosmetrics — Top 3 in CLI by velocity"/></a>
</p>
<br/>
<h3 align="center">面向终端的 DeepSeek 原生 AI coding agent。</h3>
@@ -126,6 +120,7 @@ provider key 的运行时 fallback,但仍会作为当前 workspace 范围内
- **[机器人使用指南](./docs/BOT_GUIDE.zh-CN.md)** —— 桌面端连接飞书、Lark、微信
Bot,以及 IM 里的审批、YOLO 和命令交互。
- **[规格](./docs/SPEC.md)** —— 工程契约:架构、registry、数据类型与路线图。
- **[任务合约与暂停策略](./docs/TASK_CONTRACT.zh-CN.md)** —— 用背景、输出边界、约束和暂停条件组织复杂请求。
- **[工具合约](./docs/TOOL_CONTRACT.zh-CN.md)** —— provider 可见的内置工具名、
read-only 标记和 schema 快照保护。
- **[从 0.x 迁移](./docs/MIGRATING.md)** —— 从 legacy TypeScript 版本迁到 1.0 Go 重写版。
+10 -40
View File
@@ -10,6 +10,8 @@ import (
"strings"
"time"
"unicode/utf8"
"reasonix/internal/shellparse"
)
type diffOpts struct {
@@ -546,57 +548,25 @@ func failingTestNames(out string) []string {
}
func runTests(repo, testCmd string, pkgs []string) (bool, string) {
fields := splitShellFields(testCmd)
test, err := shellparse.ParseStaticCommand(testCmd, shellparse.StaticCommandPolicy{AllowEnvAssignments: true, AllowStderrToStdout: true})
if err != nil {
return false, "invalid test command: " + err.Error()
}
fields := test.Argv
if len(fields) == 0 {
fields = []string{"go", "test"}
}
args := append(fields[1:], pkgs...)
cmd := exec.Command(fields[0], args...)
cmd.Dir = repo
if len(test.Env) > 0 {
cmd.Env = append(os.Environ(), test.Env...)
}
cmd.WaitDelay = 5 * time.Minute // bound the wait if `go test` hangs
out, err := cmd.CombinedOutput()
return err == nil, string(out)
}
// splitShellFields splits on whitespace, honoring single/double-quoted spans and stripping the quotes.
func splitShellFields(s string) []string {
var out []string
var cur strings.Builder
inSingle, inDouble := false, false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case inSingle:
if c == '\'' {
inSingle = false
} else {
cur.WriteByte(c)
}
case inDouble:
if c == '"' {
inDouble = false
} else {
cur.WriteByte(c)
}
case c == '\'':
inSingle = true
case c == '"':
inDouble = true
case c == ' ' || c == '\t' || c == '\n':
if cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
}
default:
cur.WriteByte(c)
}
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}
// changedGoFiles lists .go files changed by base...HEAD, excluding *_test.go
// when includeTests is false (we want the source under test).
func changedGoFiles(repo, base string, includeTests bool) []string {
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestRunTestsAllowsStaticEnvAssignment(t *testing.T) {
repo := t.TempDir()
writeE2EFile(t, filepath.Join(repo, "go.mod"), "module example.com/e2ebenchtest\n\ngo 1.23\n")
writeE2EFile(t, filepath.Join(repo, "env_test.go"), `package e2ebenchtest
import (
"os"
"testing"
)
func TestEnv(t *testing.T) {
if got := os.Getenv("REASONIX_E2E_ENV"); got != "ok" {
t.Fatalf("REASONIX_E2E_ENV = %q", got)
}
}
`)
ok, out := runTests(repo, "GOWORK=off REASONIX_E2E_ENV=ok go test", []string{"./..."})
if !ok {
t.Fatalf("runTests failed:\n%s", out)
}
}
func TestRunTestsRejectsDynamicEnvAssignment(t *testing.T) {
ok, out := runTests(t.TempDir(), "REASONIX_E2E_ENV=$(echo ok) go test", []string{"./..."})
if ok {
t.Fatal("runTests accepted dynamic env assignment")
}
if !strings.Contains(out, "invalid test command: shell expansion") {
t.Fatalf("output = %q, want shell expansion rejection", out)
}
}
func writeE2EFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
+28 -4
View File
@@ -62,6 +62,24 @@ In a plain browser the native bindings are absent, so `bridge.ts` falls back to
exact same event contract — so layout, streaming, markdown, tool cards, and the
diff seam can all be built without rebuilding Go.
## Test
The desktop package is a nested Go module, so parent `go test ./...` does not run
it. Use the full lane before merging desktop changes, and the short lane for fast
local feedback:
```sh
make desktop-test # cd desktop && go test .
make desktop-test-short # skips slow desktop integration/e2e checks
```
To find the next bottleneck, rank individual test cases from the JSON stream:
```sh
make desktop-test-times
# or: cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
```
### Frontend UI review checklist
For anchored menus, dropdowns, tooltips, and other portaled UI, review both the
@@ -101,7 +119,10 @@ Desktop releases ride their own tag namespace, `desktop-v<semver>` (plain `v*`
tags are the CLI release). Pushing one triggers `.github/workflows/release-desktop.yml`,
which builds on a native runner per platform (Wails can't cross-compile a
CGO/WebKit binary), packages each artifact, signs it with minisign, generates a
`latest.json` manifest, publishes a GitHub release, and mirrors everything to R2.
`latest.json` manifest, publishes a GitHub release, marks the desktop release as
GitHub's repository-wide `Latest`, mirrors everything to R2, and attaches the
current desktop manifest to the matching CLI release for old clients that still
ask GitHub's repository-wide `latest` release for it.
The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
`libwebkit2gtk-4.1-0` at runtime — present by default on Ubuntu 22.04+, Fedora 40+.
@@ -109,9 +130,12 @@ The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
git tag desktop-v1.1.0 && git push origin desktop-v1.1.0
```
The app checks `latest.json` on startup (R2 first, GitHub as fallback) and shows
an update banner when a newer version is published; **Settings → Software update**
has a manual check. Self-update behavior by platform:
The app checks `latest.json` on startup (R2 first, then the
`crash.reasonix.io` desktop release gateway) and shows an update banner when a
newer version is published; **Settings → Software update** has a manual check.
The gateway resolves only the desktop `desktop-v*` release line and never uses
GitHub's repository-wide `/releases/latest` shortcut, so updater behavior does
not depend on homepage badge semantics. Self-update behavior by platform:
- **Linux / Windows** — download, verify the minisign signature, then update in
place: Linux replaces the binary and relaunches; Windows runs the per-user NSIS
+1627 -393
View File
File diff suppressed because it is too large. Load diff
+345 -4
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
@@ -49,7 +50,12 @@ func waitForFile(t *testing.T, path, want string) {
func waitForAutosaveIdle(t *testing.T, tab *WorkspaceTab) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
waitForAutosaveIdleWithin(t, tab, 2*time.Second)
}
func waitForAutosaveIdleWithin(t *testing.T, tab *WorkspaceTab, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
tab.saveMu.Lock()
idle := !tab.saving && !tab.saveAgain
@@ -87,13 +93,12 @@ func appWithTab(t *testing.T, path string) (*App, *WorkspaceTab) {
// nil sink ctx (no webview) must not disable persistence.
func TestTurnDonePersistsSession(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
_ = path
a, tab := appWithTab(t, path)
_, tab := appWithTab(t, path)
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, path, "remember this turn")
_ = a
waitForAutosaveIdle(t, tab)
}
// TestNonTurnDoneDoesNotPersist confirms only TurnDone triggers a save, so the
@@ -132,6 +137,314 @@ func TestScheduleSnapshotCoalesces(t *testing.T) {
waitForAutosaveIdle(t, tab)
}
func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, tab := appWithTab(t, path)
_ = a
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForAutosaveIdleWithin(t, tab, 5*time.Second)
tab.saveMu.Lock()
failures := tab.saveFailures
tab.saveMu.Unlock()
if failures == 0 {
t.Fatal("autosave failure should be recorded and retried")
}
if info, err := os.Stat(path); err != nil || !info.IsDir() {
t.Fatalf("blocked session path should still be the directory, info=%v err=%v", info, err)
}
if err := os.Remove(path); err != nil {
t.Fatalf("remove blocked dir: %v", err)
}
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, path, "remember this turn")
waitForAutosaveIdle(t, tab)
tab.saveMu.Lock()
failures = tab.saveFailures
tab.saveMu.Unlock()
if failures != 0 {
t.Fatalf("autosave failures after recovery = %d, want 0", failures)
}
}
func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
originalPath := filepath.Join(dir, "session.jsonl")
originalTopic := "topic_original"
if err := setTopicTitle("", originalTopic, "Original"); err != nil {
t.Fatalf("set original topic title: %v", err)
}
current := agent.NewSession("sys")
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
if err := current.Save(originalPath); err != nil {
t.Fatalf("Save current: %v", err)
}
if err := agent.SaveBranchMeta(originalPath, agent.BranchMeta{
Scope: "global",
TopicID: originalTopic,
TopicTitle: "Original",
Preview: "first",
Turns: 2,
SchemaVersion: agent.BranchMetaCountsVersion,
}); err != nil {
t.Fatalf("SaveBranchMeta original: %v", err)
}
staleSess := agent.NewSession("sys")
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
app := &App{
tabs: map[string]*WorkspaceTab{},
detachedSessions: map[string]*WorkspaceTab{},
activeTabID: "recovery_tab",
}
tab := &WorkspaceTab{
ID: "recovery_tab",
Scope: "global",
WorkspaceRoot: root,
TopicID: originalTopic,
TopicTitle: "Original",
SessionPath: originalPath,
Ready: true,
model: "test-model",
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
tab.Ctrl = control.New(control.Options{
Executor: staleExec,
SessionDir: dir,
SessionPath: originalPath,
Label: "test",
Sink: tab.sink,
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
OnSessionRecovered: app.handleTabSessionRecovered(tab),
})
app.tabs[tab.ID] = tab
if err := tab.Ctrl.Snapshot(); err != nil {
t.Fatalf("Snapshot: %v", err)
}
recoveryPath := tab.Ctrl.SessionPath()
if recoveryPath == "" || recoveryPath == originalPath {
t.Fatalf("recovery path = %q, want distinct path", recoveryPath)
}
if tab.SessionPath != recoveryPath {
t.Fatalf("tab session path = %q, want recovery path %q", tab.SessionPath, recoveryPath)
}
if tab.TopicID != originalTopic {
t.Fatalf("tab topic ID = %q, want original topic %q", tab.TopicID, originalTopic)
}
meta, ok, err := agent.LoadBranchMeta(recoveryPath)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
}
if !meta.Recovered || meta.TopicID != tab.TopicID || meta.TopicTitle != tab.TopicTitle {
t.Fatalf("recovery meta = %+v, tab topic=%q/%q", meta, tab.TopicID, tab.TopicTitle)
}
tabMeta := app.tabMeta(tab, true)
if !tabMeta.Recovered || tabMeta.RecoveryDigest != meta.RecoveryDigest || tabMeta.RecoveryParentID != string(meta.ParentID) {
t.Fatalf("tab recovery meta = %+v, want digest %q parent %q", tabMeta, meta.RecoveryDigest, meta.ParentID)
}
nodes := app.ListProjectTree()
foundOriginal := false
var walk func([]ProjectNode)
walk = func(list []ProjectNode) {
for _, node := range list {
if node.Recovered {
t.Fatalf("project tree should hide recovery metadata, got node %+v", node)
}
if node.TopicID == originalTopic {
foundOriginal = true
}
walk(node.Children)
}
}
walk(nodes)
if !foundOriginal {
t.Fatalf("project tree did not include original topic %q: %#v", originalTopic, nodes)
}
}
func TestDesktopSnapshotConflictRecoveryRequiresRecoveryLease(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
originalPath := filepath.Join(dir, "session.jsonl")
current := agent.NewSession("sys")
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
if err := current.Save(originalPath); err != nil {
t.Fatalf("Save current: %v", err)
}
staleSess := agent.NewSession("sys")
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
recovery, err := staleSess.SaveRecoveryBranch(agent.RecoveryBranchOptions{
OriginalPath: originalPath,
BranchMeta: agent.BranchMeta{
Name: agent.RecoveryBranchDefaultName,
Scope: "global",
TopicID: "topic_recovery",
TopicTitle: "Recovery",
},
})
if err != nil {
t.Fatalf("SaveRecoveryBranch: %v", err)
}
lease, err := agent.TryAcquireSessionLease(recovery.Path)
if err != nil {
t.Fatalf("TryAcquireSessionLease recovery: %v", err)
}
defer lease.Release()
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
runtimeEvents := make(chan runtimeEventEnvelope, 4)
app := &App{
ctx: context.Background(),
tabs: map[string]*WorkspaceTab{},
detachedSessions: map[string]*WorkspaceTab{},
activeTabID: "recovery_tab",
}
app.runtimeEvents.emit = func(ctx context.Context, name string, payload ...interface{}) {
runtimeEvents <- runtimeEventEnvelope{
ctx: ctx,
name: name,
payload: append([]interface{}(nil), payload...),
}
}
tab := &WorkspaceTab{
ID: "recovery_tab",
Scope: "global",
WorkspaceRoot: root,
TopicID: "topic_original",
TopicTitle: "Original",
SessionPath: originalPath,
Ready: true,
model: "test-model",
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
tab.Ctrl = control.New(control.Options{
Executor: staleExec,
SessionDir: dir,
SessionPath: originalPath,
Label: "test",
Sink: tab.sink,
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
OnSessionRecovered: app.handleTabSessionRecovered(tab),
})
app.tabs[tab.ID] = tab
err = tab.Ctrl.Snapshot()
if !errors.Is(err, agent.ErrSessionLeaseHeld) {
t.Fatalf("Snapshot err = %v, want ErrSessionLeaseHeld", err)
}
if got := tab.Ctrl.SessionPath(); got != originalPath {
t.Fatalf("controller session path = %q, want original %q", got, originalPath)
}
if tab.SessionPath != originalPath {
t.Fatalf("tab session path = %q, want original %q", tab.SessionPath, originalPath)
}
if tab.TopicID != "topic_original" {
t.Fatalf("tab topic ID = %q, want original topic", tab.TopicID)
}
deadline := time.After(time.Second)
for {
select {
case emitted := <-runtimeEvents:
if emitted.name != "session:recovery-failed" {
continue
}
if len(emitted.payload) != 1 {
t.Fatalf("session:recovery-failed payload count = %d, want 1", len(emitted.payload))
}
failed, ok := emitted.payload[0].(sessionRecoveryFailedEvent)
if !ok {
t.Fatalf("session:recovery-failed payload type = %T, want sessionRecoveryFailedEvent", emitted.payload[0])
}
if failed.Reason != "lease_held" {
t.Fatalf("session:recovery-failed reason = %q, want lease_held", failed.Reason)
}
return
case <-deadline:
t.Fatal("session:recovery-failed event was not emitted")
}
}
}
func TestSetActiveTabBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, _ := appWithTab(t, path)
a.tabs["target_tab"] = &WorkspaceTab{
ID: "target_tab",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
a.tabOrder = []string{"test_tab", "target_tab"}
err := a.SetActiveTab("target_tab")
if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") {
t.Fatalf("SetActiveTab error = %v, want persistence failure", err)
}
if a.activeTabID != "test_tab" {
t.Fatalf("active tab = %q, want original tab after failed save", a.activeTabID)
}
}
func TestRebindSessionBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, tab := appWithTab(t, path)
target := filepath.Join(t.TempDir(), "target.jsonl")
sess := agent.NewSession("system")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "target prompt"})
if err := sess.Save(target); err != nil {
t.Fatalf("save target: %v", err)
}
loaded, err := agent.LoadSession(target)
if err != nil {
t.Fatalf("load target: %v", err)
}
err = a.rebindTabToLoadedSessionPath(tab, target, loaded)
if err == nil || !strings.Contains(err.Error(), "save current session before switching sessions") {
t.Fatalf("rebind error = %v, want persistence failure", err)
}
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
t.Fatalf("tab controller/path changed after failed save: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
}
}
// TestCloseTabNoResurrectionFromAutosave is the regression test for #4384.
// It proves that after CloseTab returns, the per-turn autosave goroutine can no
// longer write the session file — even when it is in flight at the moment the
@@ -187,6 +500,34 @@ func TestCloseTabNoResurrectionFromAutosave(t *testing.T) {
}
}
func TestCloseTabBlocksWhenSessionCannotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, tab := appWithTab(t, path)
survivor := &WorkspaceTab{
ID: "survivor_tab",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
a.tabs[survivor.ID] = survivor
a.tabOrder = []string{tab.ID, survivor.ID}
err := a.CloseTab(tab.ID)
if err == nil || !strings.Contains(err.Error(), "save current session before closing tab") {
t.Fatalf("CloseTab error = %v, want persistence failure", err)
}
if _, ok := a.tabs[tab.ID]; !ok {
t.Fatal("tab was removed even though its session could not be saved")
}
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
t.Fatalf("tab controller/path changed after failed close: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
}
}
// TestCloseTabSurvivorKeepsAutosave ensures the survivor tab is untouched: the
// closing/drain logic is per-tab and must not leak to other tabs.
func TestCloseTabSurvivorKeepsAutosave(t *testing.T) {
+306
View File
@@ -3,6 +3,8 @@ package main
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
@@ -166,6 +168,60 @@ func TestEnsureBlankTabReusesIndexedTopicWithEmptyStub(t *testing.T) {
}
}
func TestEnsureBlankTabStoresCreatedAt(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
before := time.Now().UnixMilli()
meta, err := app.EnsureBlankTab("global", "")
after := time.Now().UnixMilli()
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
createdAt := loadTopicCreatedAt("", meta.TopicID)
if createdAt < before || createdAt > after {
t.Fatalf("createdAt = %d, want between %d and %d", createdAt, before, after)
}
nodes := app.ListProjectTree()
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 {
t.Fatalf("project tree = %#v, want Global with one topic", nodes)
}
if got := nodes[0].Children[0].CreatedAt; got != createdAt {
t.Fatalf("project tree createdAt = %d, want %d", got, createdAt)
}
}
func TestEnsureBlankTabRepairsMissingCreatedAtForReusedTopic(t *testing.T) {
isolateDesktopUserDirs(t)
const topicID = "topic_20260704-104018_deadbeef"
if err := setTopicTitleWithSource("", topicID, defaultTopicTitle, topicTitleSourceAuto); err != nil {
t.Fatalf("set topic title: %v", err)
}
if err := prependTopicInProjectsFile("", topicID, false); err != nil {
t.Fatalf("prepend topic: %v", err)
}
if got := loadTopicCreatedAt("", topicID); got != 0 {
t.Fatalf("createdAt before reuse = %d, want 0", got)
}
app := NewApp()
meta, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if meta.TopicID != topicID {
t.Fatalf("EnsureBlankTab topic = %q, want reused topic %q", meta.TopicID, topicID)
}
expected := time.Date(2026, 7, 4, 10, 40, 18, 0, time.UTC).UnixMilli()
if got := loadTopicCreatedAt("", topicID); got != expected {
t.Fatalf("repaired createdAt = %d, want %d", got, expected)
}
}
// EnsureBlankTab reuses an already-open project-scoped blank tab.
func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
@@ -189,6 +245,221 @@ func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
}
}
func TestEnsureBlankTabStartsProjectRuntimeWithCurrentWorkspacePrompt(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := robustTempDir(t)
projectB := robustTempDir(t)
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
app := NewApp()
first, err := app.EnsureBlankTab("project", projectA)
if err != nil {
t.Fatalf("EnsureBlankTab(project A): %v", err)
}
tabA := waitForTabReady(t, app, first.ID)
if got := normalizeProjectRoot(tabA.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectA) {
t.Fatalf("project A controller workspace root = %q, want %q", got, normalizeProjectRoot(projectA))
}
second, err := app.EnsureBlankTab("project", projectB)
if err != nil {
t.Fatalf("EnsureBlankTab(project B): %v", err)
}
if second.ID == first.ID {
t.Fatalf("EnsureBlankTab reused project A tab %q for project B", second.ID)
}
tabB := waitForTabReady(t, app, second.ID)
if got := normalizeProjectRoot(tabB.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
t.Fatalf("project B tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
t.Fatalf("project B controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
if !sameDesktopPath(tabB.Ctrl.SessionDir(), desktopSessionDir(projectB)) {
t.Fatalf("project B controller session dir = %q, want %q", tabB.Ctrl.SessionDir(), desktopSessionDir(projectB))
}
if !sameDesktopPath(filepath.Dir(tabB.Ctrl.SessionPath()), desktopSessionDir(projectB)) {
t.Fatalf("project B controller session path = %q, want under %q", tabB.Ctrl.SessionPath(), desktopSessionDir(projectB))
}
sys := systemPromptFrom(tabB.Ctrl.History())
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
t.Fatalf("project B system prompt missing current workspace %q:\n%s", projectB, sys)
}
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
t.Fatalf("project B system prompt retained project A workspace %q:\n%s", projectA, sys)
}
}
func TestBlankTabSessionPathRejectsOtherProjectWorkspace(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := robustTempDir(t)
projectB := robustTempDir(t)
pathA, err := createEmptySessionFile(desktopSessionDir(projectA), "test-model")
if err != nil {
t.Fatalf("create project A empty session: %v", err)
}
tab := &WorkspaceTab{
ID: "blank-project-b",
Scope: "project",
WorkspaceRoot: projectB,
SessionPath: pathA,
}
if blankTabSessionPathHasNoContent(tab) {
t.Fatalf("blank tab treated session %q from project A as reusable for project B %q", pathA, projectB)
}
}
func TestForkKeepsProjectWorkspacePrompt(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := robustTempDir(t)
projectB := robustTempDir(t)
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
app := NewApp()
first, err := app.EnsureBlankTab("project", projectA)
if err != nil {
t.Fatalf("EnsureBlankTab(project A): %v", err)
}
waitForTabReady(t, app, first.ID)
second, err := app.EnsureBlankTab("project", projectB)
if err != nil {
t.Fatalf("EnsureBlankTab(project B): %v", err)
}
tabB := waitForTabReady(t, app, second.ID)
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
forked, err := app.Fork(turn)
if err != nil {
t.Fatalf("Fork: %v", err)
}
if forked.ID == "" || forked.ID == second.ID {
t.Fatalf("forked tab ID = %q, want a fresh tab distinct from %q", forked.ID, second.ID)
}
forkTab := waitForTabReady(t, app, forked.ID)
if got := normalizeProjectRoot(forkTab.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
t.Fatalf("fork tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
if got := normalizeProjectRoot(forkTab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
t.Fatalf("fork controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
sys := systemPromptFrom(forkTab.Ctrl.History())
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
t.Fatalf("fork system prompt missing project B workspace %q:\n%s", projectB, sys)
}
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
t.Fatalf("fork system prompt retained project A workspace %q:\n%s", projectA, sys)
}
}
func TestRewindKeepsProjectWorkspacePrompt(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := robustTempDir(t)
projectB := robustTempDir(t)
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
app := NewApp()
first, err := app.EnsureBlankTab("project", projectA)
if err != nil {
t.Fatalf("EnsureBlankTab(project A): %v", err)
}
waitForTabReady(t, app, first.ID)
second, err := app.EnsureBlankTab("project", projectB)
if err != nil {
t.Fatalf("EnsureBlankTab(project B): %v", err)
}
tabB := waitForTabReady(t, app, second.ID)
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
if err := app.Rewind(turn, "conversation"); err != nil {
t.Fatalf("Rewind: %v", err)
}
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
t.Fatalf("rewound controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
sys := systemPromptFrom(tabB.Ctrl.History())
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
t.Fatalf("rewound system prompt missing project B workspace %q:\n%s", projectB, sys)
}
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
t.Fatalf("rewound system prompt retained project A workspace %q:\n%s", projectA, sys)
}
}
func installStubControllerWithCurrentPrompt(t *testing.T, app *App, tab *WorkspaceTab) *control.Controller {
t.Helper()
if tab == nil || tab.Ctrl == nil {
t.Fatal("tab controller is required")
}
sys := systemPromptFrom(tab.Ctrl.History())
if strings.TrimSpace(sys) == "" {
t.Fatal("tab controller did not expose a system prompt")
}
sessionDir := tab.Ctrl.SessionDir()
sessionPath := tab.Ctrl.SessionPath()
workspaceRoot := tab.Ctrl.WorkspaceRoot()
label := tab.Ctrl.Label()
tab.Ctrl.Close()
sess := agent.NewSession(sys)
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
ctrl := control.New(control.Options{
Runner: ag,
Executor: ag,
SessionDir: sessionDir,
SessionPath: sessionPath,
WorkspaceRoot: workspaceRoot,
Label: label,
SystemPrompt: sys,
Sink: event.Discard,
})
tab.Ctrl = ctrl
app.bindControllerDisplayRecorder(ctrl)
return ctrl
}
func submitStubTurnAndWaitForCheckpoint(t *testing.T, ctrl control.SessionAPI, input string) int {
t.Helper()
ctrl.SubmitUserTurn(input, input)
waitNotRunning(t, ctrl)
deadline := time.Now().Add(time.Second)
for {
checkpoints := ctrl.Checkpoints()
if len(checkpoints) > 0 {
return checkpoints[len(checkpoints)-1].Turn
}
if time.Now().After(deadline) {
t.Fatal("controller did not record a checkpoint")
}
time.Sleep(10 * time.Millisecond)
}
}
func TestEnsureBlankTabResetsReusableAutoTopicTitle(t *testing.T) {
isolateDesktopUserDirs(t)
@@ -371,6 +642,41 @@ func TestEnsureBlankTabDoesNotReuseProjectTopicWithSession(t *testing.T) {
}
}
// EnsureBlankTab must not reuse a tombstoned topic: the reused ID would flow
// into ensureTopicIndexed, whose intentional prepend clears the delete
// tombstone and resurrects the topic the user removed.
func TestEnsureBlankTabDoesNotReuseTombstonedTopic(t *testing.T) {
isolateDesktopUserDirs(t)
// Race product on disk: deleted topic whose default title lingered in the
// global title map (title-only, absent from GlobalTopics, no sessions).
tombstonedID := "topic_tombstone_blank"
if err := setTopicTitle("", tombstonedID, defaultTopicTitle); err != nil {
t.Fatalf("set lingering title: %v", err)
}
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
return true, nil
}); err != nil {
t.Fatalf("seed tombstone: %v", err)
}
meta, err := NewApp().EnsureBlankTab("global", "")
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if meta.TopicID == tombstonedID {
t.Fatalf("EnsureBlankTab reused tombstoned topic %q", meta.TopicID)
}
f := loadProjectsFile()
if !containsDesktopString(f.DeletedTopics, tombstonedID) {
t.Fatalf("deletedTopics = %#v, tombstone must survive blank-tab creation", f.DeletedTopics)
}
if containsDesktopString(f.GlobalTopics, tombstonedID) {
t.Fatalf("globalTopics = %#v, tombstoned topic must not be re-indexed", f.GlobalTopics)
}
}
// NewSession skips the snapshot when the current tab has no real conversation content.
func TestNewSessionNoopsWhenCurrentTabIsBlank(t *testing.T) {
+2430 -15
View File
File diff suppressed because it is too large. Load diff
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"errors"
"testing"
"time"
)
func TestReportTabSnapshotErrorDebouncesAutosave(t *testing.T) {
app := NewApp()
tab := &WorkspaceTab{ID: "tab_warn", sink: &tabEventSink{tabID: "tab_warn", app: app}}
failure := errors.New("disk unhappy")
app.reportTabSnapshotError(tab, "autosave", failure)
tab.saveMu.Lock()
first := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if first.IsZero() {
t.Fatal("first autosave warning did not record its timestamp")
}
// Within the window: suppressed, timestamp untouched.
app.reportTabSnapshotError(tab, "autosave", failure)
tab.saveMu.Lock()
second := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if !second.Equal(first) {
t.Fatalf("suppressed warning moved the debounce timestamp: %v -> %v", first, second)
}
// Window expired: warns again.
tab.saveMu.Lock()
tab.lastAutosaveWarnAt = time.Now().Add(-autosaveWarnInterval - time.Second)
tab.saveMu.Unlock()
app.reportTabSnapshotError(tab, "autosave", failure)
tab.saveMu.Lock()
third := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if !third.After(first) {
t.Fatal("expired window did not re-arm the autosave warning")
}
// Explicit user actions are never debounced (state untouched).
app.reportTabSnapshotError(tab, "changing model", failure)
tab.saveMu.Lock()
fourth := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if !fourth.Equal(third) {
t.Fatal("action-save warning must not consume the autosave debounce window")
}
}
+31 -3
View File
@@ -15,6 +15,7 @@ import (
"reasonix/internal/bot"
"reasonix/internal/bot/feishu"
"reasonix/internal/bot/weixin"
"reasonix/internal/botruntime"
"reasonix/internal/config"
)
@@ -48,6 +49,7 @@ type BotConnectionView struct {
Model string `json:"model"`
ToolApprovalMode string `json:"toolApprovalMode"`
WorkspaceRoot string `json:"workspaceRoot"`
Access BotAccessView `json:"access"`
Credential BotConnectionCredentialView `json:"credential"`
SessionMappings []BotConnectionSessionMappingView `json:"sessionMappings"`
LastError string `json:"lastError"`
@@ -136,12 +138,19 @@ func (a *App) StartBotConnectionInstall(provider, domain string) (BotInstallStar
func (a *App) PollBotConnectionInstall(installID string) (BotInstallPollResult, error) {
installID = strings.TrimSpace(installID)
// Copy the session under a.mu: overlapping polls of the same install can
// race the locked PollDomain upgrade below with unlocked field reads.
a.mu.RLock()
session := a.botInstalls[installID]
sessionPtr := a.botInstalls[installID]
var sessionCopy botInstallSession
if sessionPtr != nil {
sessionCopy = *sessionPtr
}
a.mu.RUnlock()
if session == nil {
if sessionPtr == nil {
return BotInstallPollResult{Error: "install session not found"}, nil
}
session := &sessionCopy
if time.Now().After(session.ExpireAt) {
a.deleteBotInstall(installID)
return BotInstallPollResult{Status: "expired", Error: "install session expired"}, nil
@@ -164,6 +173,7 @@ func (a *App) PollBotConnectionInstall(installID string) (BotInstallPollResult,
Label: "微信",
Enabled: true,
Status: "connected",
Access: botInstallAccess(result.UserID),
Credential: config.BotConnectionCredential{AccountID: result.AccountID, TokenEnv: "WEIXIN_BOT_TOKEN"},
}, func(c *config.Config) {
c.Bot.Enabled = true
@@ -518,6 +528,7 @@ func (a *App) pollFeishuConnectionInstall(installID string, session *botInstallS
Label: label,
Enabled: true,
Status: "connected",
Access: botInstallAccess(userID),
Credential: config.BotConnectionCredential{AppID: appID, AppSecretEnv: secretEnv},
}, func(c *config.Config) {
c.Bot.Enabled = true
@@ -545,6 +556,9 @@ func (a *App) upsertBotConnection(conn config.BotConnectionConfig, updateLegacy
if conn.Status == "" {
conn.Status = "connected"
}
if normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode) == "" {
conn.ToolApprovalMode = "ask"
}
if conn.ID == "" {
conn.ID = connectionID(conn.Provider, conn.Domain)
}
@@ -556,6 +570,9 @@ func (a *App) upsertBotConnection(conn config.BotConnectionConfig, updateLegacy
for i, existing := range c.Bot.Connections {
if existing.ID == conn.ID {
conn.CreatedAt = firstNonEmptyBot(existing.CreatedAt, conn.CreatedAt)
if !botruntime.BotAccessActive(conn.Access) && botruntime.BotAccessActive(existing.Access) {
conn.Access = existing.Access
}
c.Bot.Connections[i] = conn
replaced = true
break
@@ -697,6 +714,7 @@ func botConnectionView(conn config.BotConnectionConfig) BotConnectionView {
return BotConnectionView{
ID: conn.ID, Provider: conn.Provider, Domain: conn.Domain, Label: conn.Label, Enabled: conn.Enabled, Status: conn.Status,
Model: conn.Model, ToolApprovalMode: normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode), WorkspaceRoot: conn.WorkspaceRoot,
Access: botAccessViewFromConfig(conn.Access),
Credential: BotConnectionCredentialView{
AppID: conn.Credential.AppID, AppSecretEnv: conn.Credential.AppSecretEnv, AccountID: conn.Credential.AccountID, TokenEnv: conn.Credential.TokenEnv,
SecretSet: botCredentialSecretSet(conn),
@@ -763,8 +781,9 @@ func botConnectionConfig(view BotConnectionView) config.BotConnectionConfig {
Enabled: view.Enabled,
Status: strings.TrimSpace(view.Status),
Model: strings.TrimSpace(view.Model),
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(view.ToolApprovalMode),
ToolApprovalMode: firstNonEmptyBot(normalizeBotConnectionToolApprovalMode(view.ToolApprovalMode), "ask"),
WorkspaceRoot: strings.TrimSpace(view.WorkspaceRoot),
Access: botAccessConfigFromView(view.Access),
Credential: config.BotConnectionCredential{
AppID: strings.TrimSpace(view.Credential.AppID),
AppSecretEnv: strings.TrimSpace(view.Credential.AppSecretEnv),
@@ -873,6 +892,15 @@ func connectionID(provider, domain string) string {
return strings.Trim(strings.ToLower(provider+"-"+domain), "-")
}
func botInstallAccess(userID string) config.BotAccessConfig {
userID = strings.TrimSpace(userID)
access := config.BotAccessConfig{Enabled: true, PairingEnabled: true}
if userID != "" {
access.Users = []string{userID}
}
return access
}
func randomInstallID() string {
var b [12]byte
if _, err := rand.Read(b[:]); err != nil {
+179
View File
@@ -0,0 +1,179 @@
package main
import (
"context"
"log"
"strings"
"sync"
"time"
"reasonix/internal/bot"
"reasonix/internal/event"
)
const botForwardSendTimeout = 30 * time.Second
const botForwardQueueSize = 64
// ── Forward target ──────────────────────────────────────────────────────────
// botForwardTarget identifies one remote chat to send forwarded events to.
type botForwardTarget struct {
ConnID string
Domain string
ChatID string
ChatType bot.ChatType
}
// ── Event forwarder ─────────────────────────────────────────────────────────
// botEventForwarder implements event.Sink and forwards relevant events to
// connected bot channels through the desktopBotRuntime. It is attached to a
// tabEventSink when a heartbeat task should push AI output to IM channels.
//
// It accumulates Text events and sends them as complete messages on TurnDone
// (and occasionally during generation when the buffer grows large enough), so
// the remote side sees progressive streaming output rather than one big blob.
type botEventForwarder struct {
runtime *desktopBotRuntime
targets []botForwardTarget
mu sync.Mutex
buf strings.Builder
queueMu sync.Mutex
queue chan string
closed bool
closeOnce sync.Once
}
// newBotEventForwarder creates a forwarder that sends to all given targets.
// runtime may be nil — Emit calls are then no-ops.
func newBotEventForwarder(runtime *desktopBotRuntime, targets []botForwardTarget) *botEventForwarder {
f := &botEventForwarder{
runtime: runtime,
targets: targets,
queue: make(chan string, botForwardQueueSize),
}
go f.run()
return f
}
// Emit implements event.Sink. It forwards text and lifecycle events to the
// connected bot channels; reasoning, tool dispatch, and other internal events
// are dropped to avoid noisy IM output.
func (f *botEventForwarder) Emit(e event.Event) {
if f.runtime == nil || len(f.targets) == 0 {
return
}
switch e.Kind {
case event.TurnStarted:
f.mu.Lock()
f.buf.Reset()
f.mu.Unlock()
case event.Text:
f.mu.Lock()
f.buf.WriteString(e.Text)
size := f.buf.Len()
f.mu.Unlock()
// Flush opportunistically when the buffer crosses a threshold, so long
// streams (e.g. "tell me three jokes") produce multiple messages.
if size >= 400 {
f.flush()
}
case event.TurnDone:
f.flush()
f.Close()
case event.ApprovalRequest:
// The heartbeat turn belongs to the desktop tab controller, not the bot
// gateway session, so remote /approve replies cannot satisfy this ID.
text := "⚠️ 需要在 Reasonix 桌面端批准操作: " + e.Approval.Tool + " — " + e.Approval.Subject
text += "\n请回到桌面窗口处理。"
f.sendToAll(text)
case event.AskRequest:
var qb strings.Builder
qb.WriteString("❓ 需要在 Reasonix 桌面端回答问题:\n")
for i, q := range e.Ask.Questions {
if i > 0 {
qb.WriteString("\n")
}
qb.WriteString(q.Prompt)
}
qb.WriteString("\n请回到桌面窗口处理。")
f.sendToAll(qb.String())
case event.Notice:
if e.Level == event.LevelWarn {
f.sendToAll("⚠️ " + e.Text)
}
case event.CompactionStarted:
f.sendToAll("🔄 正在压缩上下文...")
}
}
// flush sends the accumulated buffer as one message per target channel.
func (f *botEventForwarder) flush() {
f.mu.Lock()
text := strings.TrimSpace(f.buf.String())
if text == "" {
f.mu.Unlock()
return
}
f.buf.Reset()
f.mu.Unlock()
f.sendToAll(text)
}
// sendToAll dispatches text to every target channel. Errors are logged and
// non-fatal; a failed target does not block other targets.
func (f *botEventForwarder) sendToAll(text string) {
text = strings.TrimSpace(text)
if f.runtime == nil || len(f.targets) == 0 || text == "" {
return
}
f.queueMu.Lock()
defer f.queueMu.Unlock()
if f.closed {
return
}
select {
case f.queue <- text:
default:
log.Printf("[bot-forward] send queue full; dropping message for %d target(s)", len(f.targets))
}
}
func (f *botEventForwarder) run() {
for text := range f.queue {
f.sendToAllNow(text)
}
}
func (f *botEventForwarder) sendToAllNow(text string) {
for _, tgt := range f.targets {
ctx, cancel := context.WithTimeout(context.Background(), botForwardSendTimeout)
_, err := f.runtime.SendToAdapter(ctx, tgt.ConnID, tgt.Domain, bot.OutboundMessage{
ChatID: tgt.ChatID,
ChatType: tgt.ChatType,
Text: text,
})
cancel()
if err != nil {
log.Printf("[bot-forward] send to %s/%s failed: %v", tgt.ConnID, tgt.ChatType, err)
}
}
}
func (f *botEventForwarder) Close() {
f.closeOnce.Do(func() {
f.flush()
f.queueMu.Lock()
f.closed = true
close(f.queue)
f.queueMu.Unlock()
})
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"context"
"io"
"log/slog"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/bot"
"reasonix/internal/event"
)
type desktopForwardTestAdapter struct {
platform bot.Platform
name string
messages chan bot.InboundMessage
entered chan struct{}
release chan struct{}
sent chan bot.OutboundMessage
once sync.Once
}
func newDesktopForwardTestAdapter() *desktopForwardTestAdapter {
return &desktopForwardTestAdapter{
platform: bot.PlatformFeishu,
name: "forward-test",
messages: make(chan bot.InboundMessage),
entered: make(chan struct{}),
release: make(chan struct{}),
sent: make(chan bot.OutboundMessage, 8),
}
}
func (a *desktopForwardTestAdapter) Platform() bot.Platform { return a.platform }
func (a *desktopForwardTestAdapter) Name() string { return a.name }
func (a *desktopForwardTestAdapter) Start(context.Context) error {
return nil
}
func (a *desktopForwardTestAdapter) Stop() error { return nil }
func (a *desktopForwardTestAdapter) SendTyping(context.Context, string) error {
return nil
}
func (a *desktopForwardTestAdapter) Messages() <-chan bot.InboundMessage {
return a.messages
}
func (a *desktopForwardTestAdapter) Send(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
a.once.Do(func() { close(a.entered) })
select {
case <-a.release:
case <-ctx.Done():
return bot.SendResult{}, ctx.Err()
}
a.sent <- msg
return bot.SendResult{MessageID: "sent"}, nil
}
func newDesktopForwardTestRuntime(adapter bot.Adapter) *desktopBotRuntime {
gw := bot.NewGatewayWithAdapterBindings(bot.GatewayConfig{}, []bot.AdapterBinding{{
ID: "feishu-lark",
Domain: "lark",
Platform: bot.PlatformFeishu,
Adapter: adapter,
}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
return &desktopBotRuntime{gw: gw}
}
func TestBotEventForwarderDoesNotBlockEventEmissionOnSlowSend(t *testing.T) {
adapter := newDesktopForwardTestAdapter()
forwarder := newBotEventForwarder(newDesktopForwardTestRuntime(adapter), []botForwardTarget{{
ConnID: "feishu-lark",
Domain: "lark",
ChatID: "oc-group-1",
ChatType: bot.ChatGroup,
}})
done := make(chan struct{})
go func() {
forwarder.Emit(event.Event{Kind: event.Text, Text: strings.Repeat("x", 400)})
close(done)
}()
select {
case <-done:
case <-time.After(500 * time.Millisecond):
t.Fatal("Emit blocked behind slow bot send")
}
select {
case <-adapter.entered:
case <-time.After(500 * time.Millisecond):
t.Fatal("adapter send did not start")
}
forwarder.Close()
close(adapter.release)
select {
case <-adapter.sent:
case <-time.After(500 * time.Millisecond):
t.Fatal("queued bot message was not sent after adapter release")
}
}
func TestBotEventForwarderApprovalNoticeDoesNotExposeReplyID(t *testing.T) {
adapter := newDesktopForwardTestAdapter()
close(adapter.release)
forwarder := newBotEventForwarder(newDesktopForwardTestRuntime(adapter), []botForwardTarget{{
ConnID: "feishu-lark",
Domain: "lark",
ChatID: "oc-group-1",
ChatType: bot.ChatGroup,
}})
forwarder.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
ID: "approval-1",
Tool: "shell",
Subject: "run command",
}})
forwarder.Close()
select {
case msg := <-adapter.sent:
if strings.Contains(msg.Text, "approval-1") || strings.Contains(msg.Text, "/approve") {
t.Fatalf("approval notice exposed unusable reply routing: %q", msg.Text)
}
if !strings.Contains(msg.Text, "桌面") {
t.Fatalf("approval notice = %q, want desktop guidance", msg.Text)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("approval notice was not sent")
}
}
+168 -27
View File
@@ -23,16 +23,45 @@ type BotRuntimeStatusView struct {
}
type desktopBotRuntime struct {
mu sync.Mutex
cancel context.CancelFunc
gw *bot.BotGateway
status BotRuntimeStatusView
// lifecycleMu serializes start/stop transitions so two apply/stop calls
// can't race a gateway into existence. The slow work (gw.Stop teardown,
// gw.Start dials) runs while holding it but NOT r.mu, so status/send reads
// never block on a restart.
lifecycleMu sync.Mutex
mu sync.Mutex
cancel context.CancelFunc
gw *bot.BotGateway
status BotRuntimeStatusView
}
func newDesktopBotRuntime() *desktopBotRuntime {
return &desktopBotRuntime{status: BotRuntimeStatusView{Status: "stopped", Message: "bot runtime is not started"}}
}
func desktopBotChannelsWithLegacyQQ(qq config.QQBotConfig, channels map[bot.Platform]bot.ChannelConfig, connectionChannels map[string]bot.ChannelConfig) (map[bot.Platform]bot.ChannelConfig, map[string]bot.ChannelConfig) {
channel := bot.ChannelConfig{
Model: strings.TrimSpace(qq.Model),
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(qq.ToolApprovalMode),
WorkspaceRoot: strings.TrimSpace(qq.WorkspaceRoot),
}
if channel.Model == "" && channel.ToolApprovalMode == "" && channel.WorkspaceRoot == "" {
return channels, connectionChannels
}
if channels == nil {
channels = make(map[bot.Platform]bot.ChannelConfig)
}
if _, ok := channels[bot.PlatformQQ]; !ok {
channels[bot.PlatformQQ] = channel
}
if connectionChannels == nil {
connectionChannels = make(map[string]bot.ChannelConfig)
}
if _, ok := connectionChannels[string(bot.PlatformQQ)]; !ok {
connectionChannels[string(bot.PlatformQQ)] = channel
}
return channels, connectionChannels
}
func (a *App) refreshBotRuntimeAsync() {
if a.ctx == nil {
return
@@ -41,8 +70,11 @@ func (a *App) refreshBotRuntimeAsync() {
}
func (a *App) refreshBotRuntime() {
// NewApp always pre-fills botRuntime; a nil here means a test-constructed
// App with no bot runtime, which must not lazily create one from a
// background goroutine (that would race a concurrent refresh).
if a.botRuntime == nil {
a.botRuntime = newDesktopBotRuntime()
return
}
cfg, err := a.loadDesktopBotConfig()
if err != nil {
@@ -53,7 +85,11 @@ func (a *App) refreshBotRuntime() {
}
func (a *App) loadDesktopBotConfig() (*config.Config, error) {
cfg, _, err := a.loadDesktopUserConfigForEdit()
// Read-only load feeding the bot runtime and connection diagnostics. It
// must load credentials: the runtime resolves app secrets and control
// tokens from the process env (AppSecretEnv, Control.TokenEnv), which the
// credential-free view load would leave unset on a fresh process.
cfg, _, err := a.loadDesktopUserConfigForViewWithCredentials()
if err != nil {
return nil, err
}
@@ -81,24 +117,44 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
parent = context.Background()
}
plan := desktopBotRuntimePlan(cfg)
r.mu.Lock()
defer r.mu.Unlock()
r.stopLocked()
r.lifecycleMu.Lock()
defer r.lifecycleMu.Unlock()
r.stopCurrent()
if !plan.Start {
r.status = BotRuntimeStatusView{Status: plan.Status, Message: plan.Message}
r.setStatus(BotRuntimeStatusView{Status: plan.Status, Message: plan.Message})
return nil
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
ctx, cancel := context.WithCancel(parent)
modelName := botruntime.ModelName(cfg, "")
channels := botruntime.ChannelConfigs(cfg.Bot.Connections, true, true)
connectionChannels := botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, true, true)
channels, connectionChannels = desktopBotChannelsWithLegacyQQ(cfg.Bot.QQ, channels, connectionChannels)
gwCfg := bot.GatewayConfig{
Model: modelName,
ToolApprovalMode: cfg.Bot.ToolApprovalMode,
MaxSteps: cfg.Bot.MaxSteps,
QueueMode: cfg.Bot.QueueMode,
QueueCap: cfg.Bot.QueueCap,
QueueDrop: cfg.Bot.QueueDrop,
PairingEnabled: cfg.Bot.Pairing.Enabled,
PairingTTL: time.Duration(cfg.Bot.Pairing.RequestTTLMinutes) * time.Minute,
PairingMaxPending: cfg.Bot.Pairing.MaxPendingPerPlatform,
IgnoreSelfMessages: cfg.Bot.IgnoreSelfMessages,
SelfUserIDs: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.SelfUserIDs.QQ,
bot.PlatformFeishu: cfg.Bot.SelfUserIDs.Feishu,
bot.PlatformWeixin: cfg.Bot.SelfUserIDs.Weixin,
},
ControlEnabled: cfg.Bot.Control.Enabled,
ControlAddr: cfg.Bot.Control.Addr,
ControlToken: os.Getenv(strings.TrimSpace(cfg.Bot.Control.TokenEnv)),
WorkspaceRoot: workspaceRoot,
Channels: botruntime.ChannelConfigs(cfg.Bot.Connections, true, true),
ConnectionChannels: botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, true, true),
Channels: channels,
ConnectionChannels: connectionChannels,
Routes: botruntime.RouteConfigs(cfg.Bot.Routes, true, true),
ConnectionAccess: botruntime.ConnectionAccessConfigs(cfg),
Enabled: plan.Enabled,
Allowlist: bot.AllowlistConfig{
Enabled: cfg.Bot.Allowlist.Enabled,
@@ -108,6 +164,16 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuUsers,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinUsers,
},
Approvers: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.Allowlist.QQApprovers,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuApprovers,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinApprovers,
},
Admins: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.Allowlist.QQAdmins,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuAdmins,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinAdmins,
},
Groups: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.Allowlist.QQGroups,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuGroups,
@@ -122,14 +188,14 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
bindings := botruntime.AdapterBindings(cfg, plan.Enabled, nil, logger)
if len(bindings) == 0 {
cancel()
r.status = BotRuntimeStatusView{Status: "stopped", Message: "no bot adapters configured"}
r.setStatus(BotRuntimeStatusView{Status: "stopped", Message: "no bot adapters configured"})
return nil
}
gw := bot.NewGatewayWithAdapterBindings(gwCfg, bindings, logger)
if err := gw.Start(ctx); err != nil {
cancel()
gw.Stop()
r.status = BotRuntimeStatusView{Status: "error", Message: err.Error(), Connections: gw.AdapterCount()}
r.setStatus(BotRuntimeStatusView{Status: "error", Message: err.Error(), Connections: gw.AdapterCount()})
return err
}
runningConnections := gw.AdapterCount()
@@ -140,6 +206,7 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
status = "degraded"
message = fmt.Sprintf("%d bot connection(s) running; %d failed to start: %s", runningConnections, len(startErrors), summarizeBotRuntimeErrors(startErrors))
}
r.mu.Lock()
r.cancel = cancel
r.gw = gw
r.status = BotRuntimeStatusView{
@@ -149,6 +216,7 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
Connections: runningConnections,
StartedAt: time.Now().UTC().Format(time.RFC3339),
}
r.mu.Unlock()
return nil
}
@@ -206,8 +274,8 @@ func desktopBotRuntimePlan(cfg *config.Config) botRuntimePlan {
if !cfg.Bot.Enabled {
return botRuntimePlan{Status: "stopped", Message: "bot is disabled"}
}
if !cfg.Bot.Allowlist.AllowAll && (!cfg.Bot.Allowlist.Enabled || botruntime.AllowlistUserCount(cfg.Bot.Allowlist) == 0) {
return botRuntimePlan{Status: "blocked", Message: "bot requires an allowlist or allow_all=true"}
if !botruntime.BotConfigHasAccessControl(cfg.Bot) {
return botRuntimePlan{Status: "blocked", Message: "bot requires an allowlist, pairing, per-bot access, or allow_all=true"}
}
enabled, unknown := botruntime.EnabledPlatforms(cfg, nil)
if len(unknown) > 0 {
@@ -220,23 +288,36 @@ func desktopBotRuntimePlan(cfg *config.Config) botRuntimePlan {
}
func (r *desktopBotRuntime) stop(status, message string) {
r.mu.Lock()
defer r.mu.Unlock()
r.stopLocked()
r.status = BotRuntimeStatusView{Status: status, Message: message}
r.lifecycleMu.Lock()
defer r.lifecycleMu.Unlock()
r.stopCurrent()
r.setStatus(BotRuntimeStatusView{Status: status, Message: message})
}
func (r *desktopBotRuntime) stopLocked() {
if r.cancel != nil {
r.cancel()
r.cancel = nil
// stopCurrent detaches the running gateway under r.mu, then tears it down
// off-lock: gw.Stop() closes every session controller (up to the jobs teardown
// grace each) and must not stall status/send readers. Callers hold lifecycleMu.
func (r *desktopBotRuntime) stopCurrent() {
r.mu.Lock()
cancel := r.cancel
gw := r.gw
r.cancel = nil
r.gw = nil
r.mu.Unlock()
if cancel != nil {
cancel()
}
if r.gw != nil {
r.gw.Stop()
r.gw = nil
if gw != nil {
gw.Stop()
}
}
func (r *desktopBotRuntime) setStatus(status BotRuntimeStatusView) {
r.mu.Lock()
r.status = status
r.mu.Unlock()
}
func (r *desktopBotRuntime) snapshot() BotRuntimeStatusView {
r.mu.Lock()
defer r.mu.Unlock()
@@ -258,3 +339,63 @@ func (r *desktopBotRuntime) updateConnectionToolApprovalMode(connID, mode string
r.gw.UpdateConnectionToolApprovalMode(connID, mode)
return true
}
// SendToAdapter sends a message through the running gateway's adapter
// identified by connID. Returns an error if the gateway is not running
// or no matching adapter is found.
func (r *desktopBotRuntime) SendToAdapter(ctx context.Context, connID, domain string, msg bot.OutboundMessage) (bot.SendResult, error) {
r.mu.Lock()
gw := r.gw
r.mu.Unlock()
if gw == nil {
return bot.SendResult{}, nil // gateway not running — silent no-op
}
return gw.SendToAdapter(ctx, connID, domain, msg)
}
// Running returns true if the bot gateway is currently active.
func (r *desktopBotRuntime) Running() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.gw != nil
}
// ForwardTargets returns the list of bot forward targets derived from the
// current config's bot connections and their session mappings. Each mapping
// produces one target (connID + chatID + chatType) for event forwarding.
func (r *desktopBotRuntime) ForwardTargets(cfg *config.Config) []botForwardTarget {
if cfg == nil {
return nil
}
var targets []botForwardTarget
seen := make(map[botForwardTarget]bool)
for _, conn := range cfg.Bot.Connections {
if !conn.Enabled {
continue
}
connID := botruntime.ConnectionRuntimeID(conn)
domain := strings.TrimSpace(conn.Domain)
for _, sm := range conn.SessionMappings {
remoteID := strings.TrimSpace(sm.RemoteID)
if remoteID == "" {
continue
}
chatType := bot.ChatDM
if sm.ChatType != "" {
chatType = bot.ChatType(sm.ChatType)
}
target := botForwardTarget{
ConnID: connID,
Domain: domain,
ChatID: remoteID,
ChatType: chatType,
}
if seen[target] {
continue
}
seen[target] = true
targets = append(targets, target)
}
}
return targets
}
+96 -5
View File
@@ -39,6 +39,7 @@ func TestDesktopBotRuntimePlanBlocksWithoutAllowlist(t *testing.T) {
cfg := config.Default()
cfg.Bot.Enabled = true
cfg.Bot.Allowlist.Enabled = true
cfg.Bot.Pairing.Enabled = false
cfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
}
@@ -49,6 +50,44 @@ func TestDesktopBotRuntimePlanBlocksWithoutAllowlist(t *testing.T) {
}
}
func TestDesktopBotRuntimePlanStartsWithPairing(t *testing.T) {
cfg := config.Default()
cfg.Bot.Enabled = true
cfg.Bot.Allowlist.Enabled = true
cfg.Bot.Pairing.Enabled = true
cfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
}
plan := desktopBotRuntimePlan(cfg)
if !plan.Start {
t.Fatalf("plan = %+v, want start with pairing enabled", plan)
}
}
func TestDesktopBotChannelsWithLegacyQQConfig(t *testing.T) {
channels, connectionChannels := desktopBotChannelsWithLegacyQQ(config.QQBotConfig{
Model: "qq-model",
ToolApprovalMode: "auto",
WorkspaceRoot: "/tmp/qq-project",
}, nil, nil)
channel, ok := channels[bot.PlatformQQ]
if !ok {
t.Fatalf("platform QQ channel missing: %+v", channels)
}
if channel.Model != "qq-model" || channel.ToolApprovalMode != "auto" || channel.WorkspaceRoot != "/tmp/qq-project" {
t.Fatalf("platform channel = %+v, want QQ-specific runtime fields", channel)
}
connectionChannel, ok := connectionChannels["qq"]
if !ok {
t.Fatalf("connection QQ channel missing: %+v", connectionChannels)
}
if connectionChannel.Model != "qq-model" || connectionChannel.ToolApprovalMode != "auto" || connectionChannel.WorkspaceRoot != "/tmp/qq-project" {
t.Fatalf("connection channel = %+v, want QQ-specific runtime fields", connectionChannel)
}
}
func TestDesktopBotRuntimePlanStopsWhenBotDisabled(t *testing.T) {
cfg := config.Default()
cfg.Bot.Enabled = false
@@ -63,6 +102,37 @@ func TestDesktopBotRuntimePlanStopsWhenBotDisabled(t *testing.T) {
}
}
func TestDesktopBotRuntimeForwardTargetsDeduplicatesMappedChats(t *testing.T) {
cfg := config.Default()
cfg.Bot.Connections = []config.BotConnectionConfig{{
ID: "feishu-lark",
Provider: "feishu",
Domain: "lark",
Enabled: true,
SessionMappings: []config.BotConnectionSessionMapping{
{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-1"},
{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-2"},
{RemoteID: "oc-dm-1", ChatType: string(bot.ChatDM), UserID: "ou-user-1"},
},
}}
targets := newDesktopBotRuntime().ForwardTargets(cfg)
if len(targets) != 2 {
t.Fatalf("targets = %+v, want one group target plus one dm target", targets)
}
seen := map[string]bool{}
for _, target := range targets {
key := target.ConnID + "|" + target.Domain + "|" + target.ChatID + "|" + string(target.ChatType)
if seen[key] {
t.Fatalf("duplicate target %q in %+v", key, targets)
}
seen[key] = true
}
if !seen["feishu-lark|lark|oc-group-1|group"] || !seen["feishu-lark|lark|oc-dm-1|dm"] {
t.Fatalf("targets = %+v, want group and dm targets", targets)
}
}
func TestDesktopBotRuntimeConfigUsesUserBotSettings(t *testing.T) {
isolateDesktopUserDirs(t)
@@ -232,7 +302,8 @@ status = "connected"
t.Fatalf("chdir project: %v", err)
}
got, err := NewApp().loadDesktopBotConfig()
app := NewApp()
got, err := app.loadDesktopBotConfig()
if err != nil {
t.Fatalf("load desktop bot config: %v", err)
}
@@ -240,6 +311,17 @@ status = "connected"
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
}
// The bot-runtime load is a pure read: the merge above stays in memory and
// the user config file is not rewritten.
preWrite := config.LoadForEdit(config.UserConfigPath())
if preWrite.Bot.Enabled || len(preWrite.Bot.Connections) != 0 {
t.Fatalf("read path persisted bot config = %+v, want disk untouched until a locked write", preWrite.Bot)
}
// The first locked write path performs the on-disk migration.
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
t.Fatalf("applyConfigOnly: %v", err)
}
persisted := config.LoadForEdit(config.UserConfigPath())
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
@@ -281,7 +363,8 @@ status = "connected"
t.Fatalf("chdir project: %v", err)
}
got, err := NewApp().loadDesktopBotConfig()
app := NewApp()
got, err := app.loadDesktopBotConfig()
if err != nil {
t.Fatalf("load desktop bot config: %v", err)
}
@@ -289,13 +372,21 @@ status = "connected"
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
}
// The bot-runtime load is a pure read: it serves the legacy config from
// memory and must not create the user config file.
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
t.Fatalf("read path must not create the user config, stat err = %v", err)
}
// The first locked write path creates the user config with the migrated
// bot settings (adopting the legacy config, ConfigVersion-bumped).
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
t.Fatalf("applyConfigOnly: %v", err)
}
persisted := config.LoadForEdit(config.UserConfigPath())
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
}
if persisted.DesktopTheme() == "dark" {
t.Fatal("legacy project desktop theme should not be persisted during bot-only migration")
}
}
func TestDesktopSettingsBotMigrationPersistsOnlyBotBeforeFirstEdit(t *testing.T) {
+2 -2
View File
@@ -42,9 +42,9 @@ func TestBoundArrayPayloadsAreNonNilBeforeStartup(t *testing.T) {
if got := app.HooksSettings("global"); got.Hooks == nil || got.Events == nil {
t.Fatalf("HooksSettings(global) arrays = hooks:%v events:%v, want non-nil", got.Hooks, got.Events)
}
if got := app.Settings(); got.Providers == nil || got.OfficialProviders == nil || got.ProviderKinds == nil ||
if got := app.Settings(); got.Providers == nil || got.OfficialProviders == nil || got.ProviderPresets == nil || got.ProviderKinds == nil ||
got.Permissions.Allow == nil || got.Permissions.Ask == nil || got.Permissions.Deny == nil ||
got.Sandbox.AllowWrite == nil ||
got.Sandbox.AllowWrite == nil || got.Sandbox.EffectiveWriteRoots == nil ||
got.Bot.Allowlist.QQUsers == nil || got.Bot.Allowlist.FeishuUsers == nil || got.Bot.Allowlist.WeixinUsers == nil ||
got.Bot.Allowlist.QQGroups == nil || got.Bot.Allowlist.FeishuGroups == nil || got.Bot.Allowlist.WeixinGroups == nil {
t.Fatalf("Settings() contains nil array fields: %+v", got)
@@ -36,6 +36,7 @@ Unicode true
####
!include "wails_tools.nsh"
!include "FileFunc.nsh"
!include "LogicLib.nsh"
# The version information for this two must consist of 4 parts
VIProductVersion "${INFO_PRODUCTVERSION}.0"
@@ -76,6 +77,8 @@ ManifestDPIAware true
Name "${INFO_PRODUCTNAME}"
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
!define REASONIX_DEFAULT_INSTALLDIR "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}"
!define REASONIX_UPDATE_HELPER "reasonix-update-helper.exe"
!define REASONIX_UNLOCK_RETRIES 60
InstallDirRegKey HKCU "${UNINST_KEY}" "InstallLocation" # Reuse the previous install path on update; .onInit falls back to the default on first install.
InstallDir "${REASONIX_DEFAULT_INSTALLDIR}" # Per-user install location (no admin rights required).
ShowInstDetails show # This will always show the installation details.
@@ -130,14 +133,54 @@ fallback:
done:
FunctionEnd
Function reasonix.waitForExecutableUnlock
IfFileExists "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 done
StrCpy $0 0
retry:
ClearErrors
FileOpen $1 "$INSTDIR\${PRODUCT_EXECUTABLE}" a
IfErrors locked
FileClose $1
Goto done
locked:
IntOp $0 $0 + 1
IntCmp $0 ${REASONIX_UNLOCK_RETRIES} failed 0 0
Sleep 1000
Goto retry
failed:
IfSilent silent interactive
interactive:
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "Reasonix is still running. Close Reasonix, then click Retry to continue the installation." IDRETRY retry IDCANCEL abort
Goto retry
silent:
SetErrorLevel 1618
abort:
Abort "Reasonix is still running. Close Reasonix and run the installer again."
done:
FunctionEnd
Section
!insertmacro wails.setShellContext
!insertmacro wails.webview2runtime
Call reasonix.waitForExecutableUnlock
SetOutPath $INSTDIR
!insertmacro wails.files
!if /FileExists "${REASONIX_UPDATE_HELPER}"
File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
!else
!warning "${REASONIX_UPDATE_HELPER} was not found; Windows auto-update will fall back to installer-side waiting only."
!endif
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
@@ -155,6 +198,7 @@ Section "uninstall"
; Precision uninstall: delete main application files
Delete "$INSTDIR\${PRODUCT_EXECUTABLE}"
Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
+74
View File
@@ -25,6 +25,28 @@ func seedCheckpoint(t *testing.T, ckptDir string, c checkpoint.Checkpoint) {
}
}
func assertCheckpointFilesEncodeAsArray(t *testing.T, metas []CheckpointMeta) {
t.Helper()
raw, err := json.Marshal(metas)
if err != nil {
t.Fatal(err)
}
var payload []struct {
Files json.RawMessage `json:"files"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatal(err)
}
for i, item := range payload {
if string(item.Files) == "null" {
t.Fatalf("checkpoint %d files encoded as null; frontend expects []", i)
}
if len(item.Files) == 0 || item.Files[0] != '[' {
t.Fatalf("checkpoint %d files encoded as %s, want JSON array", i, item.Files)
}
}
}
// TestCheckpointsCanCodePropagatesToEarlierTurns covers #3438: RestoreCode(turn)
// reverts files touched in that turn or any later one, so a turn with no file
// changes of its own can still rewind code when a later turn changed files. The
@@ -77,4 +99,56 @@ func TestCheckpointsCanCodePropagatesToEarlierTurns(t *testing.T) {
if len(metas[0].Files) != 1 || metas[0].Files[0] != "a.txt" {
t.Fatalf("turn 0 cumulative files = %#v, want [a.txt]", metas[0].Files)
}
if metas[0].FileCount != 1 || metas[0].FilesTruncated {
t.Fatalf("turn 0 file summary = count %d truncated %v, want count 1 truncated false", metas[0].FileCount, metas[0].FilesTruncated)
}
if len(metas[2].Files) != 0 {
t.Fatalf("turn 2 cumulative files = %#v, want empty", metas[2].Files)
}
assertCheckpointFilesEncodeAsArray(t, metas)
}
func TestCheckpointsForTabLimitsCumulativeFilePreview(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "s.jsonl")
ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
t.Fatal(err)
}
content := "old"
files := make([]checkpoint.FileSnap, 0, checkpointFilePreviewLimit+5)
for i := 0; i < checkpointFilePreviewLimit+5; i++ {
files = append(files, checkpoint.FileSnap{Path: "file-" + strconv.Itoa(1000+i) + ".txt", Content: &content})
}
now := time.Now()
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 0, Time: now, Prompt: "before edits", MsgIndex: 0})
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 1, Time: now, Prompt: "edit many files", MsgIndex: 2, Files: files})
ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
ctrl.SetSessionPath(sessionPath)
app := &App{}
app.setTestCtrl(ctrl, "test")
metas := app.CheckpointsForTab("test")
if len(metas) != 2 {
t.Fatalf("checkpoints = %d, want 2", len(metas))
}
if metas[0].FileCount != checkpointFilePreviewLimit+5 {
t.Fatalf("turn 0 cumulative file count = %d, want %d", metas[0].FileCount, checkpointFilePreviewLimit+5)
}
if len(metas[0].Files) != checkpointFilePreviewLimit {
t.Fatalf("turn 0 preview files = %d, want %d", len(metas[0].Files), checkpointFilePreviewLimit)
}
if !metas[0].FilesTruncated {
t.Fatal("turn 0 should mark file preview as truncated")
}
if metas[0].TurnFileCount != 0 {
t.Fatalf("turn 0 file count = %d, want 0 for this turn", metas[0].TurnFileCount)
}
if metas[1].TurnFileCount != checkpointFilePreviewLimit+5 {
t.Fatalf("turn 1 file count = %d, want %d", metas[1].TurnFileCount, checkpointFilePreviewLimit+5)
}
assertCheckpointFilesEncodeAsArray(t, metas)
}
+3 -3
View File
@@ -166,12 +166,12 @@ func signFiles(files []string) error {
// release tag used in download URLs (e.g. "desktop-v1.1.0").
func genManifest(dir, version, tag string) error {
repo := os.Getenv("GITHUB_REPOSITORY")
if repo == "" {
repo = "esengine/reasonix"
if repo == "" || repo == "esengine/reasonix" {
repo = "esengine/DeepSeek-Reasonix"
}
m := update.Manifest{
Version: version,
DownloadPage: fmt.Sprintf("https://github.com/%s/releases/latest", repo),
DownloadPage: "https://reasonix.io/#start",
Platforms: map[string]update.Asset{},
}
entries, err := os.ReadDir(dir)
+4 -1
View File
@@ -85,6 +85,9 @@ func TestGenManifest(t *testing.T) {
if m.Version != "v1.2.0" {
t.Fatalf("version = %q, want v1.2.0", m.Version)
}
if m.DownloadPage != "https://reasonix.io/#start" {
t.Fatalf("download_page = %q, want official install page", m.DownloadPage)
}
if len(m.Platforms) != 5 {
t.Fatalf("want 5 platforms, got %d: %v", len(m.Platforms), m.Platforms)
}
@@ -92,7 +95,7 @@ func TestGenManifest(t *testing.T) {
if !ok {
t.Fatal("windows-amd64 missing")
}
wantURL := "https://github.com/esengine/reasonix/releases/download/desktop-v1.2.0/Reasonix-windows-amd64-installer.exe"
wantURL := "https://github.com/esengine/DeepSeek-Reasonix/releases/download/desktop-v1.2.0/Reasonix-windows-amd64-installer.exe"
if win.URL != wantURL {
t.Fatalf("windows url = %q, want %q", win.URL, wantURL)
}
+11
View File
@@ -0,0 +1,11 @@
package main
import "fmt"
func installerCommandLine(installer, dir string) string {
line := fmt.Sprintf(`"%s" /S`, installer)
if dir != "" {
line += " /D=" + dir
}
return line
}
+17
View File
@@ -0,0 +1,17 @@
package main
import (
"strings"
"testing"
)
func TestInstallerCommandLineIsSilentAndLeavesDFlagLast(t *testing.T) {
got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`)
want := `"C:\Temp\Reasonix Installer.exe" /S /D=D:\Tools\Reasonix App`
if got != want {
t.Fatalf("installerCommandLine = %q, want %q", got, want)
}
if !strings.HasSuffix(got, `/D=D:\Tools\Reasonix App`) {
t.Fatalf("/D= must be the final unquoted NSIS token, got %q", got)
}
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !windows
package main
import (
"fmt"
"os"
)
func main() {
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Windows desktop builds")
os.Exit(2)
}
+108
View File
@@ -0,0 +1,108 @@
//go:build windows
package main
import (
"flag"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"syscall"
"time"
"golang.org/x/sys/windows"
)
const parentExitTimeout = 2 * time.Minute
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
var parentPID uint
var installer, installDir, relaunch string
fs := flag.NewFlagSet("reasonix-update-helper", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
fs.UintVar(&parentPID, "parent-pid", 0, "Reasonix process id to wait for before installing")
fs.StringVar(&installer, "installer", "", "verified NSIS installer path")
fs.StringVar(&installDir, "install-dir", "", "Reasonix installation directory")
fs.StringVar(&relaunch, "relaunch", "", "Reasonix executable to start after the installer succeeds")
if err := fs.Parse(args); err != nil {
return 2
}
logger := newLogger()
if installer == "" {
logger.Print("missing --installer")
return 2
}
if parentPID != 0 {
if err := waitForProcessExit(uint32(parentPID), parentExitTimeout); err != nil {
logger.Printf("wait for parent process %d: %v", parentPID, err)
return 1
}
}
if err := runInstaller(installer, installDir); err != nil {
logger.Printf("run installer: %v", err)
return 1
}
if relaunch != "" {
if err := startRelaunch(relaunch, installDir); err != nil {
logger.Printf("relaunch: %v", err)
return 1
}
}
return 0
}
func newLogger() *log.Logger {
dir, err := os.UserCacheDir()
if err == nil {
dir = filepath.Join(dir, "Reasonix", "updates")
if err := os.MkdirAll(dir, 0o700); err == nil {
if f, err := os.OpenFile(filepath.Join(dir, "update-helper.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
return log.New(f, "", log.LstdFlags)
}
}
}
return log.New(os.Stderr, "", log.LstdFlags)
}
func waitForProcessExit(pid uint32, timeout time.Duration) error {
h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, pid)
if err != nil {
if err == windows.ERROR_INVALID_PARAMETER {
return nil
}
return err
}
defer windows.CloseHandle(h)
waitMS := uint32(timeout / time.Millisecond)
result, err := windows.WaitForSingleObject(h, waitMS)
if err != nil {
return err
}
switch result {
case windows.WAIT_OBJECT_0:
return nil
case uint32(windows.WAIT_TIMEOUT):
return fmt.Errorf("timed out after %s", timeout)
default:
return fmt.Errorf("unexpected wait result %d", result)
}
}
func runInstaller(installer, installDir string) error {
cmd := exec.Command(installer)
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(installer, installDir), HideWindow: true}
return cmd.Run()
}
func startRelaunch(relaunch, installDir string) error {
cmd := exec.Command(relaunch)
cmd.Dir = installDir
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
return cmd.Start()
}
+20 -3
View File
@@ -46,15 +46,32 @@ func writePendingCrash(site string, r any, stack []byte) {
report.Stack = sanitizeCrashText(stackText, maxCrashStackBytes)
report.TopFrame = topFrameFromStack(report.Stack)
report.Message = msg
_ = writePendingReport(report, true)
}
func writePendingReport(report crashReport, overwrite bool) bool {
body, err := json.Marshal(report)
if err != nil {
return
return false
}
path := pendingCrashPath()
if os.MkdirAll(filepath.Dir(path), 0o755) != nil {
return
return false
}
_ = os.WriteFile(path, body, 0o644)
if overwrite {
return os.WriteFile(path, body, 0o644) == nil
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return false
}
defer f.Close()
n, err := f.Write(body)
if err != nil || n != len(body) {
_ = os.Remove(path)
return false
}
return true
}
func (a *App) goSafe(site string, fn func()) {
+61
View File
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
)
@@ -67,6 +68,66 @@ func TestWritePendingCrashCaps(t *testing.T) {
}
}
func TestWritePendingReportCanAvoidOverwritingExistingCrash(t *testing.T) {
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
writePendingCrash("panic", "boom", []byte("stack"))
before, ok := readPending(t)
if !ok {
t.Fatal("expected initial pending crash")
}
hang := baseCrashReport("performance")
hang.Source = "native.watchdog"
hang.Label = "mac.main_thread.hang"
hang.Message = "hang"
if writePendingReport(hang, false) {
t.Fatal("writePendingReport overwrite=false should not replace existing report")
}
after, ok := readPending(t)
if !ok {
t.Fatal("expected pending crash after skipped write")
}
if after.Label != before.Label || after.Message != before.Message {
t.Fatalf("pending crash was overwritten: before=%+v after=%+v", before, after)
}
}
func TestWritePendingReportNonOverwriteAllowsOneConcurrentWriter(t *testing.T) {
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
const writers = 32
start := make(chan struct{})
var ready sync.WaitGroup
var done sync.WaitGroup
var successes atomic.Int32
for i := 0; i < writers; i++ {
ready.Add(1)
done.Add(1)
go func() {
defer done.Done()
report := baseCrashReport("performance")
report.Source = "native.watchdog"
report.Label = "mac.main_thread.hang"
report.Message = strings.Repeat("hang", 1024)
ready.Done()
<-start
if writePendingReport(report, false) {
successes.Add(1)
}
}()
}
ready.Wait()
close(start)
done.Wait()
if got := successes.Load(); got != 1 {
t.Fatalf("successful non-overwrite writers = %d, want 1", got)
}
if _, ok := readPending(t); !ok {
t.Fatal("expected one pending report")
}
}
func TestWritePendingCrashScrubsSensitiveText(t *testing.T) {
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
apiKey := "sk-proj-" + "abcdefghijklmnopqrstuvwxyz1234567890"
+384
View File
@@ -0,0 +1,384 @@
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"strings"
"sync"
"time"
"reasonix/internal/agent"
"reasonix/internal/control"
)
// deferredRebuildRetryInterval is how often the retry loop probes a held
// session lease. Package-level so tests can shorten it.
var deferredRebuildRetryInterval = 2 * time.Second
const deferredStartupBuildLabel = "__startup__"
// deferredRebuildState tracks tabs whose settings were saved to disk but whose
// runtime could not refresh, plus tabs whose initial startup failed, because
// the session lease was held by another Reasonix process. A single background
// loop probes the lease and replays the rebuild once the other side releases
// it. The loop only runs after enableDeferredRebuildRetry (the wails startup
// hook); tests that never call it get the pending bookkeeping without a
// background goroutine.
type deferredRebuildState struct {
mu sync.Mutex
pending map[string]string // tab ID -> setting label for notices
enabled bool
running bool
stopped bool
stop chan struct{}
}
// enableDeferredRebuildRetry arms the retry loop; called from startup.
func (a *App) enableDeferredRebuildRetry() {
d := &a.deferredRebuild
d.mu.Lock()
defer d.mu.Unlock()
d.enabled = true
a.startDeferredRebuildLoopLocked()
}
// startDeferredRebuildLoopLocked starts the loop when it is armed, idle, and
// has work. Callers must hold d.mu.
func (a *App) startDeferredRebuildLoopLocked() {
d := &a.deferredRebuild
if !d.enabled || d.running || d.stopped || len(d.pending) == 0 {
return
}
d.running = true
if d.stop == nil {
d.stop = make(chan struct{})
}
go a.deferredRebuildLoop(d.stop)
}
// scheduleDeferredRebuild records that tabID needs a runtime refresh for
// setting and starts the retry loop if it is not running yet. Repeated calls
// for the same tab collapse into one retry carrying the latest label.
func (a *App) scheduleDeferredRebuild(tabID, setting string) {
tabID = strings.TrimSpace(tabID)
if tabID == "" {
return
}
d := &a.deferredRebuild
d.mu.Lock()
defer d.mu.Unlock()
if d.stopped {
return
}
if d.pending == nil {
d.pending = map[string]string{}
}
d.pending[tabID] = setting
a.startDeferredRebuildLoopLocked()
}
func (a *App) scheduleDeferredStartupBuild(tabID string) {
a.scheduleDeferredRebuild(tabID, deferredStartupBuildLabel)
}
func isDeferredStartupBuild(setting string) bool {
return setting == deferredStartupBuildLabel
}
func (a *App) clearDeferredRebuild(tabID string) {
d := &a.deferredRebuild
d.mu.Lock()
delete(d.pending, tabID)
d.mu.Unlock()
}
func (a *App) deferredRebuildPending(tabID string) bool {
d := &a.deferredRebuild
d.mu.Lock()
defer d.mu.Unlock()
_, ok := d.pending[tabID]
return ok
}
// stopDeferredRebuildRetry permanently stops the retry loop; used on shutdown
// and by tests.
func (a *App) stopDeferredRebuildRetry() {
d := &a.deferredRebuild
d.mu.Lock()
defer d.mu.Unlock()
if d.stopped {
return
}
d.stopped = true
if d.stop != nil {
close(d.stop)
}
}
func (a *App) deferredRebuildLoop(stop <-chan struct{}) {
ticker := time.NewTicker(deferredRebuildRetryInterval)
defer ticker.Stop()
for {
select {
case <-stop:
d := &a.deferredRebuild
d.mu.Lock()
d.running = false
d.mu.Unlock()
return
case <-ticker.C:
}
if a.deferredRebuildTickDone() {
return
}
}
}
// deferredRebuildTickDone runs one retry pass and reports true when the loop
// should exit because nothing is pending anymore.
func (a *App) deferredRebuildTickDone() bool {
return a.deferredRebuildTick(true)
}
func (a *App) deferredRebuildTick(markIdle bool) bool {
d := &a.deferredRebuild
d.mu.Lock()
if d.stopped || len(d.pending) == 0 {
if markIdle {
d.running = false
}
d.mu.Unlock()
return true
}
pending := make(map[string]string, len(d.pending))
for id, setting := range d.pending {
pending[id] = setting
}
d.mu.Unlock()
for tabID, setting := range pending {
a.retryDeferredRebuild(tabID, setting)
}
return false
}
func (a *App) kickDeferredRebuildRetry() {
if a.ctx == nil {
return
}
a.goSafe("deferredRebuildKick", func() {
_ = a.deferredRebuildTick(false)
})
}
func (a *App) retryDeferredRebuild(tabID, setting string) {
if a.ctx == nil {
return
}
tab := a.tabByID(tabID)
if tab == nil || tab.ID != tabID {
// The tab is gone; nothing left to refresh.
a.clearDeferredRebuild(tabID)
return
}
if isDeferredStartupBuild(setting) {
a.retryDeferredStartupBuild(tabID, tab)
return
}
// Hold the rebuild mutex across probe + rebuild: the probe briefly acquires
// the session lease, and a concurrent manual rebuild's ensureSessionLease
// would see that probe as "held by another runtime" and spuriously defer.
a.runtimeRebuildMu.Lock()
defer a.runtimeRebuildMu.Unlock()
// rebuildSettingLocked refreshes the active tab only. Wait until the user
// is back on this tab so we refresh the runtime the pending setting was
// meant for, not whichever tab happens to be focused.
if a.activeTab() != tab {
return
}
ctrl := a.controllerForTab(tab)
if ctrl == nil {
// Mid-(re)build on another path (provider retarget, workspace repair);
// racing a second build+swap against it is what this loop must avoid.
return
}
if controllerHasActiveRuntimeWork(ctrl) {
return
}
if !a.deferredRebuildLeaseLooksFree(tab) {
return
}
err := a.rebuildSettingLocked(setting)
if err == nil {
// rebuildSettingLocked already cleared the pending entry for the tab it
// refreshed; just announce it.
a.noticeForTab(tabID, fmt.Sprintf("%s applied: session refreshed after the lease was released", setting))
return
}
if errors.Is(err, agent.ErrSessionLeaseHeld) {
return // grabbed back before we could rebuild; keep waiting
}
var busy *rebuildBusyError
if errors.As(err, &busy) {
return // a turn started meanwhile; retry once it finishes
}
// Anything else will not resolve by waiting; give up loudly instead of
// retrying forever.
a.clearDeferredRebuild(tabID)
slog.Warn("desktop: deferred settings rebuild failed", "setting", setting, "tab", tabID, "err", err)
a.warnForTab(tabID, fmt.Sprintf("%s was saved but the session could not refresh: %s", setting, err.Error()))
}
func (a *App) retryDeferredStartupBuild(tabID string, tab *WorkspaceTab) {
a.runtimeRebuildMu.Lock()
defer a.runtimeRebuildMu.Unlock()
if !a.tabHasRetryableStartupLeaseError(tab) {
a.clearDeferredRebuild(tabID)
return
}
if !a.deferredRebuildLeaseLooksFree(tab) {
return
}
err := a.rebuildStartupTabLocked(tab)
if err == nil {
a.clearDeferredRebuild(tabID)
return
}
if errors.Is(err, agent.ErrSessionLeaseHeld) {
return
}
a.clearDeferredRebuild(tabID)
slog.Warn("desktop: deferred session startup failed", "tab", tabID, "err", err)
}
func (a *App) tabHasRetryableStartupLeaseError(tab *WorkspaceTab) bool {
if tab == nil {
return false
}
a.mu.RLock()
defer a.mu.RUnlock()
return a.tabs[tab.ID] == tab && !tab.removed && tab.Ctrl == nil && tab.StartupErrLeaseHeld
}
func (a *App) rebuildStartupTabLocked(tab *WorkspaceTab) error {
buildCtx, cancel := context.WithCancel(a.bootContext())
a.mu.Lock()
if tab == nil || a.tabs[tab.ID] != tab || tab.removed {
a.mu.Unlock()
cancel()
return nil
}
if tab.Ctrl != nil {
a.mu.Unlock()
cancel()
return nil
}
if !tab.StartupErrLeaseHeld {
a.mu.Unlock()
cancel()
return nil
}
tab.buildGeneration++
generation := tab.buildGeneration
if tab.buildCancel != nil {
tab.buildCancel()
}
tab.buildCancel = cancel
tab.Ready = false
clearTabStartupError(tab)
tab.ActivityStatus = ""
if tab.sink == nil {
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
}
a.saveTabsLocked()
a.mu.Unlock()
a.buildTabControllerWithContext(tab, loadedTabSession{}, buildCtx, generation, cancel)
a.mu.RLock()
stillCurrent := false
var ctrl control.SessionAPI
startupErr := ""
leaseHeld := false
if tab != nil {
stillCurrent = a.tabs[tab.ID] == tab && !tab.removed
ctrl = tab.Ctrl
startupErr = tab.StartupErr
leaseHeld = tab.StartupErrLeaseHeld
}
a.mu.RUnlock()
if !stillCurrent || ctrl != nil {
return nil
}
if leaseHeld {
return agent.ErrSessionLeaseHeld
}
if strings.TrimSpace(startupErr) != "" {
return fmt.Errorf("session startup: %s", startupErr)
}
return fmt.Errorf("session startup: controller was not built")
}
func (a *App) tryRecoverStartupLeaseHeldTab(tab *WorkspaceTab) bool {
if a.ctx == nil || !a.tabHasRetryableStartupLeaseError(tab) {
return false
}
a.runtimeRebuildMu.Lock()
defer a.runtimeRebuildMu.Unlock()
if !a.tabHasRetryableStartupLeaseError(tab) {
return a.controllerForTab(tab) != nil
}
if !a.deferredRebuildLeaseLooksFree(tab) {
return false
}
err := a.rebuildStartupTabLocked(tab)
if err == nil {
a.clearDeferredRebuild(tab.ID)
return a.controllerForTab(tab) != nil
}
if errors.Is(err, agent.ErrSessionLeaseHeld) {
a.scheduleDeferredStartupBuild(tab.ID)
} else {
a.clearDeferredRebuild(tab.ID)
}
return false
}
// deferredRebuildLeaseLooksFree cheaply probes whether the tab's session lease
// could be acquired right now, without touching tab.sessionLease (only the
// serialized rebuild paths may mutate that). The probe path can lag the
// reconciled path the rebuild will use; a stale answer either re-defers on the
// next tick or lets the rebuild fail back into the pending set, so a mismatch
// only delays the retry.
func (a *App) deferredRebuildLeaseLooksFree(tab *WorkspaceTab) bool {
a.mu.RLock()
ctrl := tab.Ctrl
path := strings.TrimSpace(tab.SessionPath)
a.mu.RUnlock()
if ctrl != nil {
if p := strings.TrimSpace(ctrl.SessionPath()); p != "" {
path = p
}
}
if path == "" {
return true // nothing to probe; let the rebuild decide
}
lease, err := agent.TryAcquireSessionLease(sessionRuntimeKey(path))
if err != nil {
var leaseErr *agent.SessionLeaseError
if errors.As(err, &leaseErr) && leaseErr.Info != nil && leaseErr.Info.PID == os.Getpid() {
if host, _ := os.Hostname(); leaseErr.Info.Hostname == host {
// This process (usually this very tab) holds the probed path;
// the blocking lease is some other path. Attempt the rebuild
// and let its own lease checks decide.
return true
}
}
return !errors.Is(err, agent.ErrSessionLeaseHeld)
}
lease.Release()
return true
}
+2 -2
View File
@@ -10,9 +10,9 @@
"check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css",
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
"typecheck": "tsc --noEmit",
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
},
"dependencies": {
"@gsap/react": "^2.1.2",
@@ -16,7 +16,16 @@ const transpiled = ts.transpileModule(source, {
}).outputText;
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled).toString("base64")}`;
const { shouldShowTodoPanel, todoDismissalKey } = await import(moduleUrl);
const {
dismissedTodoKeyForScope,
scopedTodoBatchKey,
scopedTodoDismissalKey,
shouldOpenTodoPanelByDefault,
shouldShowTodoPanel,
todoBatchKey,
todoDismissalKey,
todoPanelScope,
} = await import(moduleUrl);
const completedTodos = [
{ content: "Inspect the report", status: "completed" },
@@ -42,6 +51,66 @@ assert.equal(
false,
"a user dismissal still hides that exact todo list",
);
const completedKey = todoDismissalKey(completedTodos);
const dismissedBySession = new Set([scopedTodoDismissalKey("session:a", completedKey)]);
assert.equal(
shouldShowTodoPanel(completedKey, dismissedTodoKeyForScope("session:a", dismissedBySession, completedKey), completedTodos),
false,
"a completed todo dismissal hides the list in the session where it was closed",
);
assert.equal(
shouldShowTodoPanel(completedKey, dismissedTodoKeyForScope("session:b", dismissedBySession, completedKey), completedTodos),
true,
"a completed todo dismissal in one session does not hide another session's list",
);
dismissedBySession.add(scopedTodoDismissalKey("session:b", completedKey));
assert.equal(
shouldShowTodoPanel(completedKey, dismissedTodoKeyForScope("session:a", dismissedBySession, completedKey), completedTodos),
false,
"closing another session's todo does not forget the first session dismissal",
);
assert.equal(
todoPanelScope({
activeTabId: "tab-a",
activeTab: {
id: "tab-a",
scope: "project",
workspaceRoot: "/repo",
topicId: "topic-a",
sessionPath: "/sessions/a.jsonl",
},
}),
"session:/sessions/a.jsonl",
"a restored history session uses its session path as the todo panel scope",
);
assert.equal(
todoPanelScope({
activeTabId: "tab-b",
activeTab: {
id: "tab-a",
scope: "project",
workspaceRoot: "/repo",
topicId: "topic-a",
sessionPath: "/sessions/a.jsonl",
},
eventChannel: "desktop-events",
}),
"tab:tab-b",
"a stale active-tab fallback must not scope dismissal to the previous session",
);
assert.equal(
todoPanelScope({
activeTabId: "tab-c",
activeTab: {
id: "tab-c",
scope: "project",
workspaceRoot: "/repo",
topicId: "topic-a",
},
}),
"tab:tab-c",
"an unsaved topic session uses its tab id so sibling sessions do not share todo state",
);
assert.equal(shouldShowTodoPanel(null, null, completedTodos), false, "no canonical todo item means no panel");
assert.equal(shouldShowTodoPanel("todo-empty", null, []), false, "empty todo lists do not render a panel");
@@ -61,6 +130,36 @@ assert.notEqual(
todoDismissalKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }]),
"real progress produces a fresh dismissal key",
);
assert.equal(
todoBatchKey(activeTodos),
todoBatchKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }]),
"status progress stays in the same todo batch",
);
assert.equal(
scopedTodoBatchKey("session:a", todoBatchKey(activeTodos)),
scopedTodoBatchKey("session:a", todoBatchKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }])),
"status progress keeps the same scoped open-state key",
);
assert.notEqual(
scopedTodoBatchKey("session:a", todoBatchKey(activeTodos)),
scopedTodoBatchKey("session:b", todoBatchKey(activeTodos)),
"the same task list in a different session gets isolated open state",
);
assert.notEqual(
todoBatchKey(activeTodos),
todoBatchKey([{ content: "Run a different task", status: "in_progress" }]),
"new task content creates a new todo batch",
);
assert.equal(
shouldOpenTodoPanelByDefault(activeTodos),
true,
"new incomplete todo batches open by default",
);
assert.equal(
shouldOpenTodoPanelByDefault(completedTodos),
false,
"completed todo batches collapse by default",
);
const iterations = 200_000;
const started = performance.now();
+236 -18
View File
@@ -1,5 +1,5 @@
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
import type { CSSProperties, KeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
import { ShellExpandProvider, useShellExpand } from "./lib/shellExpand";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
@@ -10,8 +10,11 @@ import {
Activity,
CircleHelp,
Command,
Copy as RestoreIcon,
Download,
Minus,
Search,
Square,
SquarePen,
PanelLeft,
PanelRight,
@@ -29,12 +32,14 @@ import {
Brain,
Cpu,
Palette,
X,
} from "lucide-react";
import { useToast } from "./lib/toast";
import { useWailsResizeFix } from "./lib/useWailsResizeFix";
import { asArray } from "./lib/array";
import { clearLegacyLangPref, normalizeLangPref, readLegacyLangPref, useI18n, useT, type Translator } from "./lib/i18n";
import { useController, type Item, type LiveStream } from "./lib/useController";
import { app, onEvent, onProjectTreeChanged } from "./lib/bridge";
import { app, onEvent, onProjectTreeChanged, onSessionRecovered, onSessionRecoveryFailed } from "./lib/bridge";
import { generativeMusic, isGenerativeMusicEnabled } from "./lib/generative-music";
import { playSuccessChime } from "./lib/sound";
import { Transcript } from "./components/Transcript";
@@ -59,7 +64,15 @@ import { HeartbeatPanel } from "./custom/features/heartbeat/HeartbeatPanel";
import "./custom/features/heartbeat/heartbeat.css";
import { CopyButton } from "./components/CopyButton";
import { parseTodos } from "./lib/tools";
import { shouldShowTodoPanel, todoDismissalKey } from "./lib/todoVisibility";
import {
dismissedTodoKeyForScope,
scopedTodoBatchKey,
scopedTodoDismissalKey,
shouldShowTodoPanel,
todoBatchKey,
todoDismissalKey,
todoPanelScope,
} from "./lib/todoVisibility";
import {
type BotConnectionView,
type BotRuntimeStatusView,
@@ -182,9 +195,70 @@ function normalizeDesktopLayoutStyle(style: string | undefined): DesktopLayoutSt
return "classic";
}
const SHOW_CONTEXT_DOCK = true;
const DISMISSED_TODO_STORAGE_KEY = "todoPanel:dismissedKeys";
const MAX_DISMISSED_TODO_KEYS = 160;
type HistoryScopeFilter = { scope: "global" | "project"; workspaceRoot: string };
type WorkspaceInsertTarget = "composer" | "planRevision";
type DesktopPlatform = "darwin" | "windows" | "linux";
function WindowsWindowControls() {
const [maximised, setMaximised] = useState(false);
const syncMaximised = useCallback(() => {
void app.IsMainWindowMaximised()
.then(setMaximised)
.catch(() => setMaximised(false));
}, []);
useEffect(() => {
syncMaximised();
window.addEventListener("resize", syncMaximised);
window.addEventListener("focus", syncMaximised);
return () => {
window.removeEventListener("resize", syncMaximised);
window.removeEventListener("focus", syncMaximised);
};
}, [syncMaximised]);
const toggleMaximise = useCallback(() => {
void app.ToggleMaximiseMainWindow()
.then(() => window.setTimeout(syncMaximised, 80))
.catch(() => undefined);
}, [syncMaximised]);
return (
<div className="windows-window-controls" aria-label="Window controls">
<button
className="windows-window-control windows-window-control--minimize"
type="button"
aria-label="Minimize window"
title="Minimize"
onClick={() => void app.MinimiseMainWindow()}
>
<Minus size={13} strokeWidth={1.9} />
</button>
<button
className="windows-window-control windows-window-control--maximize"
type="button"
aria-label="Maximize or restore window"
aria-pressed={maximised}
title={maximised ? "Restore" : "Maximize"}
onClick={toggleMaximise}
>
{maximised ? <RestoreIcon size={12} strokeWidth={1.75} /> : <Square size={11} strokeWidth={1.8} />}
</button>
<button
className="windows-window-control windows-window-control--close"
type="button"
aria-label="Close window"
title="Close"
onClick={() => void app.CloseMainWindow()}
>
<X size={13} strokeWidth={1.9} />
</button>
</div>
);
}
type HistoryViewState =
| { kind: "history"; source: "scope"; filter: HistoryScopeFilter; sessions: SessionMeta[] }
| { kind: "history"; source: "all"; sessions: SessionMeta[] }
@@ -231,6 +305,29 @@ type SidebarImConnectionDetailProps = {
onManageAllowlist: () => void;
};
function loadDismissedTodoKeys(): Set<string> {
try {
const saved = window.localStorage.getItem(DISMISSED_TODO_STORAGE_KEY);
if (!saved) return new Set();
const parsed = JSON.parse(saved) as unknown;
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((value): value is string => typeof value === "string" && value.length > 0));
} catch {
return new Set();
}
}
function saveDismissedTodoKeys(keys: ReadonlySet<string>): void {
try {
window.localStorage.setItem(
DISMISSED_TODO_STORAGE_KEY,
JSON.stringify(Array.from(keys).slice(-MAX_DISMISSED_TODO_KEYS)),
);
} catch {
/* ignore quota errors */
}
}
function isSidebarImConnection(connection: BotConnectionView): boolean {
return connection.provider === "feishu" || connection.provider === "weixin";
}
@@ -652,6 +749,21 @@ function browserPlatformOverride(): DesktopPlatform | null {
return null;
}
const GUIDANCE_QUEUE_MOCK_ITEMS = [
"先确认发送后输入框为什么残留刚发的消息,再决定修哪里。",
"保持真实 steer 协议不变,只调整前端乐观队列和按钮状态。",
"最后补后端 submit 悬挂时的回归测试,确保输入框会立刻释放。",
] as const;
function browserMockScenarioParam(): string {
if (typeof window === "undefined" || window.runtime) return "";
return new URLSearchParams(window.location.search).get("mock")?.trim().toLowerCase() ?? "";
}
function isGuidanceMockScenario(value: string): boolean {
return value === "guidance" || value === "guide" || value === "steer";
}
function detectBrowserPlatform(): DesktopPlatform {
const override = browserPlatformOverride();
if (override) return override;
@@ -904,6 +1016,8 @@ export default function App() {
const unsub = onEvent((e) => {
if (e.kind === "turn_done") {
setDockRefreshKey((v) => v + 1);
}
if (e.kind === "turn_done") {
if (!e.err) playSuccessChime();
}
});
@@ -917,6 +1031,9 @@ export default function App() {
const rightDockMode = useLayoutStore((s) => s.rightDockMode);
const setRightDockMode = useLayoutStore((s) => s.setRightDockMode);
const [dockRefreshKey, setDockRefreshKey] = useState(0);
const [fileRefRefreshKey, setFileRefRefreshKey] = useState(0);
const refreshComposerFileRefs = useCallback(() => setFileRefRefreshKey((value) => value + 1), []);
const composerFileRefRefreshKey = `${dockRefreshKey}:${fileRefRefreshKey}`;
const [projectRevision, setProjectRevision] = useState(0);
const [activeTopicTurns, setActiveTopicTurns] = useState<number | undefined>(undefined);
const [composerInsertRequest, setComposerInsertRequest] = useState<ComposerInsertRequest | null>(null);
@@ -925,6 +1042,7 @@ export default function App() {
const transientOverlayDismissSignal = useOverlayStore((s) => s.transientOverlayDismissSignal);
const setTransientOverlayDismissSignal = useOverlayStore((s) => s.setTransientOverlayDismissSignal);
const [desktopPlatform, setDesktopPlatform] = useState<DesktopPlatform>(detectBrowserPlatform);
useWailsResizeFix(desktopPlatform === "windows");
const [statusBarStyle, setStatusBarStyle] = useState<"icon" | "text">("text");
const [statusBarItems, setStatusBarItems] = useState<StatusBarItemId[]>(() => [...DEFAULT_STATUS_BAR_ITEMS]);
const [renamingTopicId, setRenamingTopicId] = useState<string | null>(null);
@@ -1393,7 +1511,11 @@ export default function App() {
// a stale local dismissal cannot hide work that still blocks final readiness;
// completed lists collapse automatically and can then be dismissed. The
// dismissal key is still based on stable todo content/state so history reloads
// do not resurrect the same finished list under a different event id.
// do not resurrect the same finished list under a different event id. The
// batch key ignores status changes so progress within the same task list does
// not look like a brand-new task batch. Dismissal and open state are scoped to
// the active session/topic/tab so different projects and sessions do not hide
// or reopen each other's todo panels.
const todoEntry = useMemo(() => {
for (let i = state.items.length - 1; i >= 0; i--) {
const it = state.items[i];
@@ -1405,9 +1527,30 @@ export default function App() {
}, [state.items]);
const todoItem = todoEntry?.item ?? null;
const todos = useMemo(() => (todoItem ? parseTodos(todoItem.args) : []), [todoItem]);
const [dismissedTodo, setDismissedTodo] = useState<string | null>(null);
const [dismissedTodoKeys, setDismissedTodoKeys] = useState<Set<string>>(loadDismissedTodoKeys);
const todoKey = useMemo(() => todoDismissalKey(todos), [todos]);
const todoBatch = useMemo(() => todoBatchKey(todos), [todos]);
const todoScope = useMemo(
() => todoPanelScope({ activeTab, activeTabId, eventChannel: state.meta?.eventChannel }),
[activeTab, activeTabId, state.meta?.eventChannel],
);
const dismissedTodo = useMemo(
() => dismissedTodoKeyForScope(todoScope, dismissedTodoKeys, todoKey),
[dismissedTodoKeys, todoKey, todoScope],
);
const scopedTodoKey = useMemo(() => scopedTodoDismissalKey(todoScope, todoKey), [todoKey, todoScope]);
const scopedTodoBatch = useMemo(() => scopedTodoBatchKey(todoScope, todoBatch), [todoBatch, todoScope]);
const showTodos = shouldShowTodoPanel(todoKey, dismissedTodo, todos);
const dismissTodos = useCallback(() => {
if (!scopedTodoKey) return;
setDismissedTodoKeys((current) => {
if (current.has(scopedTodoKey)) return current;
const next = new Set(current);
next.add(scopedTodoKey);
saveDismissedTodoKeys(next);
return next;
});
}, [scopedTodoKey]);
const sessionTitle = topicTitle(activeTab);
const sessionHasContent = state.items.length > 0 || Boolean(state.live?.text || state.live?.reasoning);
@@ -2124,10 +2267,20 @@ export default function App() {
return transcriptItems.slice(0, rewindState.boundaryIdx).filter((it) => it.kind !== "compaction");
}, [transcriptItems, rewindState]);
const latestGuidanceConsumed = useMemo(() => {
for (let i = state.items.length - 1; i >= 0; i--) {
const item = state.items[i];
if (item.kind === "notice" && item.text.startsWith("↪ ")) {
return { key: item.id, text: item.text.slice(2) };
}
}
return null;
}, [state.items]);
// send wrapper: commits any pending optimistic rewind before sending.
const commitThenSend = useCallback(async (displayText: string, submitText?: string) => {
if (activeTab?.readOnly) throw new Error("channel session is read-only");
if (!controllerReady) throw new Error("workspace is still starting");
if (activeTab?.readOnly) throw new Error(t("composer.readOnlyChannel"));
if (!controllerReady) throw new Error(t("composer.workspaceStarting"));
const rs = rewindStateRef.current;
if (rs) {
rewindStateRef.current = null;
@@ -2143,7 +2296,7 @@ export default function App() {
// Rewind failed: the Go conversation is intact. Do not send; the
// controller emits a notice with the reason.
setRewindState(null);
throw new Error("rewind failed");
throw new Error(t("rewind.failed"));
}
setRewindSignal((v) => v + 1);
if (rs.scope === "both") {
@@ -2153,7 +2306,7 @@ export default function App() {
}
}
await send(displayText, submitText);
}, [activeTab?.readOnly, controllerReady, send, rewind]);
}, [activeTab?.readOnly, controllerReady, send, rewind, t]);
const handleTranscriptPrompt = useCallback((text: string) => {
if (!controllerReady) return;
@@ -2395,7 +2548,7 @@ export default function App() {
} else {
throw new Error(scope === "global" && !session.topicId
? t("history.failedOpenSession")
: (session.topicId ? "Missing workspaceRoot" : t("history.failedOpenSession")));
: (session.topicId ? t("history.missingWorkspaceRoot") : t("history.failedOpenSession")));
}
if (!latest()) return;
seedActiveTabMeta(targetTab);
@@ -2440,6 +2593,37 @@ export default function App() {
enqueueNavigation({ kind: "blank", scope, workspaceRoot: scope === "project" ? workspaceRoot : "" }),
[enqueueNavigation]);
useEffect(() => {
return onSessionRecovered((event) => {
const scope = event.scope === "project" ? "project" : "global";
const workspaceRoot = scope === "project" ? event.workspaceRoot || "" : "";
setProjectRevision((value) => value + 1);
void refreshTabMetas();
showToast(t("recovery.toast", { title: event.topicTitle || t("recovery.branch") }), "warn", event.topicId
? {
actionLabel: t("recovery.open"),
durationMs: 9000,
onAction: () => {
void enqueueNavigation({
kind: "topic",
scope,
workspaceRoot,
topicId: event.topicId || "",
sessionPath: event.recoveryPath || "",
});
},
}
: { durationMs: 9000 });
});
}, [enqueueNavigation, refreshTabMetas, showToast, t]);
useEffect(() => {
return onSessionRecoveryFailed((event) => {
const key = event.reason === "lease_held" ? "recovery.failedLease" : "recovery.failedUnavailable";
showToast(t(key), "error", { durationMs: 9000 });
});
}, [showToast, t]);
const handleNewTab = useCallback(async () => {
closeTransientOverlays();
setSidebarImDetailConnectionId("");
@@ -2621,7 +2805,7 @@ export default function App() {
setProjectRevision((value) => value + 1);
const tabs = await refreshTabMetas();
if (activeTabId && !tabs.some((tab) => tab.id === activeTabId)) {
await syncActiveTab(true);
await syncActiveTab(false);
}
}, [activeTabId, refreshTabMetas, syncActiveTab]);
@@ -2678,6 +2862,8 @@ export default function App() {
: t("sidebar.collapse");
const sidebarNavTooltipDisabled = !sidebarCollapsed;
const browserPreviewChrome = typeof window !== "undefined" && !window.runtime;
const browserMockScenario = browserPreviewChrome ? browserMockScenarioParam() : "";
const guidanceQueueMockItems = isGuidanceMockScenario(browserMockScenario) ? GUIDANCE_QUEUE_MOCK_ITEMS : undefined;
const workspacePanelResetWidth = rightDockDetailActive
? RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH
: defaultRightDockTreeWidth();
@@ -2699,6 +2885,15 @@ export default function App() {
const topicbarCanRename = !sidebarImDetailConnection && Boolean(activeTab?.topicId);
const topicbarTitleEditSize = Math.min(56, Math.max(4, topicTitleDraft.length || topicbarTitle.length || 1));
const sidebarWorkbench = desktopLayoutStyle === "workbench";
const windowsFramelessChrome = desktopPlatform === "windows";
const handleWindowsTitlebarDoubleClick = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
if (!windowsFramelessChrome) return;
const target = event.target as HTMLElement | null;
if (!target?.closest(".app-chrome, .topicbar, .workbench-dock__tools")) return;
if (target.closest("button, input, textarea, select, a, [role='button'], [role='tab'], .windows-window-controls")) return;
event.preventDefault();
void app.ToggleMaximiseMainWindow();
}, [windowsFramelessChrome]);
// Creation keeps the classic sidebar/chat structure while gating chrome tweaks
// behind its own style flag so classic/workbench remain unchanged.
const appChromeHidden = sidebarWorkbench || sidebarCreation;
@@ -2713,11 +2908,13 @@ export default function App() {
<ShellExpandProvider>
<ShellHotkeys />
<TextSizeHotkeys />
<div
ref={appRef}
className={[
<div
ref={appRef}
onDoubleClickCapture={handleWindowsTitlebarDoubleClick}
className={[
"app",
`app--${desktopPlatform}`,
windowsFramelessChrome ? "app--windows-frameless" : "",
browserPreviewChrome ? "app--browser-preview" : "",
sidebarWorkbench ? "app--workbench" : "",
sidebarCreation ? "app--creation" : "",
@@ -3238,6 +3435,13 @@ export default function App() {
<div className="banner banner--error">{t("topbar.startupError", { msg: state.meta.startupErr })}</div>
)}
{activeTab?.recovered && !sidebarImDetailConnection && (
<div className="banner banner--recovery">
<span className="banner__badge">{t("recovery.branch")}</span>
<span>{t("recovery.banner")}</span>
</div>
)}
<UpdateBanner enabled={startupUpdateChecksEnabled === true} />
<main className="main">
@@ -3278,7 +3482,14 @@ export default function App() {
{!sidebarImDetailConnection && (
<footer className="footer" ref={footerRef}>
{showTodos && <TodoPanel todos={todos} onDismiss={() => setDismissedTodo(todoKey)} />}
{showTodos && (
<TodoPanel
key={scopedTodoBatch}
stateKey={scopedTodoBatch}
todos={todos}
onDismiss={dismissTodos}
/>
)}
{rewindState && (
<UndoRewindBanner
meta={{
@@ -3370,6 +3581,10 @@ export default function App() {
retry={state.retry}
transientDismissSignal={transientOverlayDismissSignal}
sessionKey={composerSessionKey}
fileRefRefreshKey={composerFileRefRefreshKey}
guidanceConsumedKey={latestGuidanceConsumed?.key}
guidanceConsumedText={latestGuidanceConsumed?.text}
guidanceQueuePreviewItems={guidanceQueueMockItems}
/>
<StatusBar
context={state.context}
@@ -3385,9 +3600,9 @@ export default function App() {
modelLabel={state.meta?.label}
labelStyle={statusBarStyle}
items={statusBarItems}
workspacePath={state.meta?.workspacePath || state.meta?.workspaceRoot || state.meta?.cwd}
workspaceName={state.meta?.workspaceName}
gitBranch={state.meta?.gitBranch}
workspacePath={state.meta?.workspacePath || state.meta?.workspaceRoot || state.meta?.cwd}
workspaceName={state.meta?.workspaceName}
gitBranch={state.meta?.gitBranch}
/>
</footer>
)}
@@ -3485,6 +3700,7 @@ export default function App() {
onPreviewModeChange={handleWorkspacePreviewModeChange}
onAddToChat={addWorkspaceTextToComposer}
onRequestPanelWidth={ensureWorkspacePanelWidth}
onFileTreeRefresh={refreshComposerFileRefs}
refreshKey={dockRefreshKey}
initialViewMode={rightDockMode === "changed" ? "changed" : "files"}
showViewTabs={false}
@@ -3519,6 +3735,7 @@ export default function App() {
initialTab={settingsTarget}
initialFocus={settingsFocus ?? undefined}
agentRunning={state.running}
desktopPlatform={desktopPlatform}
onClose={() => {
setSettingsFocus(null);
setSettingsTarget(null);
@@ -3563,6 +3780,7 @@ export default function App() {
<HeartbeatPanel open={heartbeatOpen} onClose={() => setHeartbeatOpen(false)} onOpenTopic={(scope, workspaceRoot, topicId) => {
void handleOpenTopic(scope, workspaceRoot, topicId);
}} />
{windowsFramelessChrome && <WindowsWindowControls />}
</div>
</ShellExpandProvider>
);
@@ -69,6 +69,12 @@ ok(
"AppChrome markup includes classic tab strip containers",
);
ok(
/const titlebarDragRail = darwinChrome \|\| platform === "windows";/.test(appChromeSource) &&
/\{titlebarDragRail && <span className="app-chrome__drag-rail"/.test(appChromeSource),
"AppChrome exposes the classic drag rail on macOS and Windows",
);
ok(
/const WORKSPACE_PANEL_DEFAULT_OPEN = false;/.test(layoutStoreSource) &&
/workspacePanelOpen:\s*WORKSPACE_PANEL_DEFAULT_OPEN/.test(layoutStoreSource),
@@ -252,6 +258,37 @@ for (const selector of [
);
}
for (const selector of [
".app--windows-frameless .app-chrome--native-tabs",
":root[data-theme-style] .app--windows-frameless .app-chrome--native-tabs",
]) {
const paddingRight = finalDeclaration(selector, "padding-right") ?? "";
ok(
finalDeclaration(selector, "--windows-frameless-titlebar-tools-offset") === "var(--windows-window-controls-safe)" &&
paddingRight.includes("--windows-frameless-titlebar-tools-offset") &&
paddingRight.includes("--chrome-panel-control-size") &&
!paddingRight.includes("--chrome-right-toggle-offset"),
`${selector} keeps titlebar tools fixed beside the Windows controls`,
);
}
for (const selector of [
".app--windows-frameless .app-chrome--native-tabs .app-chrome__panel-toggle--right",
":root[data-theme-style] .app--windows-frameless .app-chrome--native-tabs .app-chrome__panel-toggle--right",
]) {
ok(
finalDeclaration(selector, "right") === "calc(var(--windows-frameless-titlebar-tools-offset) + 8px)",
`${selector} stays fixed outside the Windows window controls`,
);
}
ok(
finalDeclaration(".app--windows-frameless:not(.app--workbench):not(.app--creation) .app-chrome--native-tabs .app-chrome__drag-rail", "--wails-draggable") === "drag" &&
finalDeclaration(".app--windows-frameless:not(.app--workbench):not(.app--creation) .app-chrome--native-tabs .app-chrome__drag-rail", "right")?.includes("--windows-window-controls-safe") &&
finalDeclaration(".app--windows .app-chrome--native-tabs .tabbar", "--wails-draggable") === "no-drag",
"Windows classic chrome keeps a draggable rail while tabs remain clickable",
);
for (const selector of [
".layout--workbench-chrome-hidden",
":root[data-theme-style] .layout--workbench-chrome-hidden",
@@ -4,6 +4,7 @@ import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import gsap from "gsap";
import { ApprovalModal } from "../components/ApprovalModal";
import { LocaleProvider } from "../lib/i18n";
import type { AppBindings } from "../lib/bridge";
@@ -12,6 +13,17 @@ import type { WireApproval } from "../lib/types";
let passed = 0;
let failed = 0;
type GsapToOptions = { onComplete?: () => void };
const gsapForTests = (typeof gsap.to === "function" ? gsap : (gsap as unknown as { default?: typeof gsap }).default) as unknown as {
to?: (target: unknown, vars: GsapToOptions) => unknown;
};
if (typeof gsapForTests.to === "function") {
gsapForTests.to = (_target: unknown, vars: GsapToOptions) => {
vars.onComplete?.();
return {};
};
}
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
@@ -42,7 +54,7 @@ async function waitFor(label: string, predicate: () => boolean, timeoutMs = 1000
ok(false, label);
}
function installDom() {
function installDom(language = "en-US") {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
@@ -50,6 +62,7 @@ function installDom() {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: language });
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
@@ -117,7 +130,7 @@ async function renderApproval(props: Partial<Parameters<typeof ApprovalModal>[0]
console.log("\napproval modal file references");
{
const dom = installDom();
const dom = installDom("en-US");
mockApp({
ListDir: async () => [{ name: "src", isDir: true }, { name: "README.md", isDir: false }],
SearchFileRefs: async () => [],
@@ -166,6 +179,86 @@ console.log("\napproval modal file references");
dom.window.close();
}
{
const dom = installDom("zh-CN");
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const { root } = await renderApproval({
approval: {
id: "sandbox-escape-approval-zh",
tool: "sandbox_escape",
subject: "run unconfined once: go test ./...",
reason: "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
},
});
const text = document.body.textContent ?? "";
ok(text.includes("仅本次不进沙箱运行:go test ./..."), "sandbox escape approval localizes subject in Chinese UI");
ok(text.includes("Windows 沙箱启动这条命令时失败"), "sandbox escape approval localizes English backend reason in Chinese UI");
ok(text.includes("允许一次"), "sandbox escape Chinese approval shows allow once");
ok(text.includes("本会话使用真实环境"), "sandbox escape Chinese approval shows session grant");
ok(text.includes("拒绝"), "sandbox escape Chinese approval shows deny");
ok(!text.includes("总是允许"), "sandbox escape Chinese approval hides persistent grant");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom("zh-CN");
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const { root } = await renderApproval({
approval: {
id: "memory-approval-zh",
tool: "remember",
subject: "Save/update memory \"prefers-vitest\" [user]: Preferred test framework | body: Use Vitest for frontend tests.",
},
});
const text = document.body.textContent ?? "";
ok(text.includes("保存记忆"), "remember approval localizes tool label in Chinese UI");
ok(text.includes("保存/更新记忆 \"prefers-vitest\" [user]"), "remember approval localizes subject prefix in Chinese UI");
ok(text.includes("正文: Use Vitest for frontend tests."), "remember approval localizes body label in Chinese UI");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom("zh-CN");
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const { root } = await renderApproval({
approval: {
id: "plan-mode-read-only-command-zh",
tool: "plan_mode_read_only_command",
subject: "Trust \"gh issue view\" as a read-only command prefix while planning\nCommand: gh issue view 5867 --json title",
reason: "This bash command is not in Reasonix's built-in read-only set. Confirm only if this exact prefix is read-only for planning and research. Auto/YOLO approval cannot answer this trust prompt.",
},
});
const text = document.body.textContent ?? "";
ok(text.includes("计划模式只读命令"), "plan-mode read-only command approval localizes tool label in Chinese UI");
ok(text.includes("在计划模式中信任 \"gh issue view\" 为只读命令前缀"), "plan-mode read-only command approval localizes subject in Chinese UI");
ok(text.includes("不在 Reasonix 内置只读集合中"), "plan-mode read-only command approval localizes reason in Chinese UI");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
mockApp({
@@ -196,5 +289,180 @@ console.log("\napproval modal file references");
dom.window.close();
}
{
const dom = installDom();
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const { root } = await renderApproval({
approval: {
id: "tool-approval",
tool: "bash",
subject: "npm run build\n\nRun the build command to verify frontend artifacts.",
},
});
const subject = document.querySelector(".approval-subject");
ok(subject != null, "tool approval shows its full subject by default");
eq(
subject?.textContent,
"npm run build\n\nRun the build command to verify frontend artifacts.",
"default-open tool approval keeps the complete subject visible",
);
ok(document.body.textContent?.includes("Hide") === true, "tool approval details start expanded");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const answers: Array<[boolean, boolean, boolean]> = [];
const { root } = await renderApproval({
approval: {
id: "memory-approval",
tool: "remember",
subject: "Save/update memory \"prefers-vitest\": Preferred test framework",
},
onAnswer: (allow, session, persist) => answers.push([allow, session, persist]),
});
const text = document.body.textContent ?? "";
ok(text.includes("Allow once"), "fresh-human approval shows allow once");
ok(text.includes("Deny"), "fresh-human approval shows deny");
ok(!text.includes("Allow for session"), "fresh-human approval hides session grant");
ok(!text.includes("Always allow"), "fresh-human approval hides persistent grant");
eq(
Array.from(document.querySelectorAll(".prompt-shelf__actions button")).map((button) => button.textContent).join("|"),
"1Allow once|2Deny",
"fresh-human approval keeps conventional allow/deny shortcut keys",
);
const allowButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("Allow once")) as HTMLButtonElement | undefined;
if (!allowButton) throw new Error("allow once button did not render");
await act(async () => {
allowButton.click();
await flushTimers();
});
eq(JSON.stringify(answers), JSON.stringify([[true, false, false]]), "fresh-human approval allows only once");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const answers: Array<[boolean, boolean, boolean]> = [];
const { root } = await renderApproval({
approval: {
id: "memory-approval-deny",
tool: "remember",
subject: "Save/update memory \"prefers-vitest\": Preferred test framework",
},
onAnswer: (allow, session, persist) => answers.push([allow, session, persist]),
});
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "2", bubbles: true, cancelable: true }));
await flushTimers();
});
eq(JSON.stringify(answers), JSON.stringify([[false, false, false]]), "fresh-human numeric 2 denies");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const answers: Array<{ allow: boolean; session: boolean; persist: boolean }> = [];
const { root } = await renderApproval({
approval: {
id: "sandbox-escape-approval",
tool: "sandbox_escape",
subject: "run unconfined once: go test ./...",
reason: "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
},
onAnswer: (allow, session, persist) => answers.push({ allow, session, persist }),
});
const text = document.body.textContent ?? "";
ok(text.includes("bash sandbox escape"), "sandbox escape approval uses a clear tool label");
ok(text.includes("Allow once"), "sandbox escape approval shows allow once");
ok(text.includes("Use real environment for this session"), "sandbox escape approval shows session grant");
ok(text.includes("Deny"), "sandbox escape approval shows deny");
ok(!text.includes("Always allow"), "sandbox escape approval hides persistent grant");
eq(
Array.from(document.querySelectorAll(".prompt-shelf__actions button")).map((button) => button.textContent).join("|"),
"1Allow once|2Use real environment for this session|3Deny",
"sandbox escape approval keeps conventional allow once/session/deny shortcut keys",
);
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }));
await flushTimers();
});
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
await flushTimers();
});
eq(JSON.stringify(answers), JSON.stringify([{ allow: true, session: true, persist: false }]), "sandbox escape Enter on selected session action grants session");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const answers: Array<{ allow: boolean; session: boolean; persist: boolean }> = [];
const { root } = await renderApproval({
approval: {
id: "sandbox-escape-deny-approval",
tool: "sandbox_escape",
subject: "run unconfined once: go test ./...",
reason: "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
},
onAnswer: (allow, session, persist) => answers.push({ allow, session, persist }),
});
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "3", bubbles: true, cancelable: true }));
await flushTimers();
});
eq(JSON.stringify(answers), JSON.stringify([{ allow: false, session: false, persist: false }]), "sandbox escape numeric 3 denies");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -1,6 +1,6 @@
// Run: tsx src/__tests__/bridge-drag-rejection.test.ts
import { isWailsNonFileDragError, isWailsNonFileDragErrorEvent } from "../lib/bridge";
import { isTransientWailsIPCError, isWailsNonFileDragError, isWailsNonFileDragErrorEvent } from "../lib/bridge";
let passed = 0;
let failed = 0;
@@ -58,6 +58,21 @@ eq(
false,
"keeps ErrorEvent invalid argument visible without a recent native file drag",
);
eq(
isTransientWailsIPCError(new DOMException("Failed to execute 'send' on 'WebSocket': Still in CONNECTING state.", "InvalidStateError")),
true,
"suppresses Wails IPC calls made before the websocket is open",
);
eq(
isTransientWailsIPCError(new TypeError("Cannot read properties of null (reading 'send')")),
true,
"suppresses Wails IPC calls made after the websocket is torn down",
);
eq(
isTransientWailsIPCError(new Error("backend returned an application error")),
false,
"keeps ordinary bridge failures visible",
);
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -43,7 +43,7 @@ ok(
"App loads secondary drawers on demand",
);
ok(
!/import\s+\{[^}]*\b(?:MCPServersSettingsPage|SkillsSettingsPage)\b[^}]*\}\s+from\s+["']\.\/CapabilitiesPanel["']/.test(settingsSource) &&
!/import\s+\{[^}]*\b(?:MCPServersSettingsPage|SkillsSettingsPage|PluginsSettingsPage)\b[^}]*\}\s+from\s+["']\.\/CapabilitiesPanel["']/.test(settingsSource) &&
!/import\s+\{[^}]*\bMemorySettingsPage\b[^}]*\}\s+from\s+["']\.\/MemoryPanel["']/.test(settingsSource),
"SettingsPanel keeps secondary settings pages out of the first settings chunk",
);
@@ -2,11 +2,11 @@ import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { MCPServersSettingsPage } from "../components/CapabilitiesPanel";
import { MCPServersSettingsPage, PluginsSettingsPage } from "../components/CapabilitiesPanel";
import type { AppBindings } from "../lib/bridge";
import { LocaleProvider } from "../lib/i18n";
import { mcpServerLifecycleActions, mcpServerRetryableFromAvailableList } from "../lib/mcpServerLifecycle";
import type { Meta, ServerView, TabMeta } from "../lib/types";
import type { Meta, PluginInstallOptions, PluginView, ServerView, TabMeta } from "../lib/types";
function ok(value: unknown, message: string) {
if (!value) throw new Error(message);
@@ -80,6 +80,7 @@ function installDom() {
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
globalThis.HTMLInputElement = dom.window.HTMLInputElement;
globalThis.Event = dom.window.Event;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
@@ -106,6 +107,16 @@ function findButton(label: string): HTMLButtonElement | undefined {
return Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === label) as HTMLButtonElement | undefined;
}
function setInputValue(input: HTMLInputElement, value: string) {
const win = input.ownerDocument.defaultView;
const setter = Object.getOwnPropertyDescriptor((win?.HTMLInputElement ?? HTMLInputElement).prototype, "value")?.set;
setter?.call(input, value);
const eventCtor = win?.Event ?? Event;
const inputEventCtor = win?.InputEvent ?? eventCtor;
input.dispatchEvent(new inputEventCtor("input", { bubbles: true, data: value, inputType: "insertText" } as InputEventInit));
input.dispatchEvent(new eventCtor("change", { bubbles: true }));
}
console.log("capabilities panel MCP actions");
{
@@ -233,3 +244,221 @@ console.log("capabilities panel MCP actions");
});
dom.window.close();
}
console.log("capabilities panel plugin actions");
{
const dom = installDom();
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
const meta: Meta = { label: "test", ready: true, eventChannel: "plugin-channel", cwd: "/tmp/reasonix-test", workspaceRoot: "/tmp/reasonix-test" };
const tabs: TabMeta[] = [{
id: "tab-plugin",
scope: "project",
workspaceRoot: "/tmp/reasonix-test",
workspaceName: "reasonix-test",
topicId: "topic-plugin",
topicTitle: "Plugins",
label: "Plugins",
ready: true,
running: false,
mode: "normal",
toolApprovalMode: "auto",
active: true,
cwd: "/tmp/reasonix-test",
}];
let planCalls = 0;
let installCalls = 0;
let toggleCalls = 0;
let updateCalls = 0;
let doctorCalls = 0;
let removeCalls = 0;
let pickFolderCalls = 0;
const plannedSources: string[] = [];
const installedSources: string[] = [];
let plugins: PluginView[] = [{
name: "superpowers",
version: "0.1.0",
description: "Shared agent skills and hooks.",
source: "git:github.com/obra/superpowers",
root: "~/.reasonix/plugins/superpowers",
manifestKind: "reasonix",
enabled: true,
skills: 2,
hooks: 1,
mcpServers: 0,
}];
window.go = {
main: {
App: {
Meta: async () => meta,
ListTabs: async () => tabs,
Plugins: async () => plugins.map((plugin) => ({ ...plugin, warnings: [...(plugin.warnings ?? [])] })),
PlanPluginInstall: async (source: string, options: PluginInstallOptions) => {
planCalls += 1;
plannedSources.push(source);
ok(options.dryRun === true, "plugin preview asks for dry-run planning");
return JSON.stringify({
ok: true,
status: "planned",
name: "superpowers",
actions: [{ kind: "plugin", action: "install_plugin_package", name: "superpowers", source, status: "planned" }],
});
},
InstallPlugin: async (source: string, _options: PluginInstallOptions) => {
installCalls += 1;
installedSources.push(source);
const next: PluginView = {
name: "superpowers",
version: "0.1.1",
description: "Shared agent skills and hooks.",
source,
root: "~/.reasonix/plugins/superpowers",
manifestKind: "reasonix",
enabled: true,
skills: 3,
hooks: 1,
mcpServers: 1,
skillDetails: [{ name: "plan", description: "Plan work before implementation.", invocation: "/plan", runAs: "inline" }],
hookDetails: [{ event: "SessionStart", contextFile: "CLAUDE.md", description: "Load startup context." }],
mcpServerDetails: [{ name: "context", transport: "stdio", command: "node server.js" }],
};
plugins = plugins.filter((plugin) => plugin.name !== next.name).concat(next);
return JSON.stringify({ ok: true, status: "done", actions: [{ action: "install_plugin_package", name: next.name, status: "done" }] });
},
SetPluginEnabled: async (name: string, enabled: boolean) => {
toggleCalls += 1;
plugins = plugins.map((plugin) => plugin.name === name ? { ...plugin, enabled } : plugin);
},
UpdatePlugin: async (name: string) => {
updateCalls += 1;
plugins = plugins.map((plugin) => plugin.name === name ? { ...plugin, version: "0.1.2" } : plugin);
return JSON.stringify({ ok: true, status: "done", name });
},
PluginDoctor: async (name: string) => {
doctorCalls += 1;
return { ...(plugins.find((plugin) => plugin.name === name) ?? plugins[0]), warnings: ["manifest exports no MCP auth metadata"] };
},
RemovePlugin: async (name: string) => {
removeCalls += 1;
plugins = plugins.filter((plugin) => plugin.name !== name);
},
PickPluginFolder: async () => {
pickFolderCalls += 1;
return "/tmp/superpowers-plugin";
},
} as Partial<AppBindings> as AppBindings,
},
};
await act(async () => {
root.render(React.createElement(LocaleProvider, null, React.createElement(PluginsSettingsPage)));
await flush();
});
await waitFor("superpowers plugin row", () => Boolean(document.querySelector(".cap-row__name")?.textContent?.includes("superpowers")));
ok(Boolean(document.querySelector(".cap-plugin-form-grid .cap-plugin-fields--local")), "local plugin install mode uses the shared form grid");
const localOptionTexts = Array.from(document.querySelectorAll(".cap-plugin-installer__options > .cap-plugin-option-block"))
.map((option) => option.textContent ?? "");
ok(localOptionTexts[0]?.includes("Overwrite same-name plugin"), "local install mode shows overwrite before link mode");
ok(localOptionTexts[1]?.includes("Developer mode: link source folder"), "local install mode shows link mode after overwrite");
const chooseFolder = findButton("Choose plugin folder");
if (!chooseFolder) throw new Error("missing plugin folder picker button");
await act(async () => {
chooseFolder.click();
await flush();
});
await waitFor("picked plugin folder source", () => document.body.textContent?.includes("/tmp/superpowers-plugin") ?? false);
ok(pickFolderCalls === 1, "clicking Choose folder invokes the plugin folder picker once");
const gitMode = findButton("Git repository");
if (!gitMode) throw new Error("missing Git repository install mode");
await act(async () => {
gitMode.click();
await flush();
});
ok(Boolean(document.querySelector(".cap-plugin-form-grid .cap-plugin-fields--git")), "Git plugin install mode uses the shared form grid");
const sourceInput = document.querySelector<HTMLInputElement>('input[aria-label="Git repository URL"]');
if (!sourceInput) throw new Error("missing plugin git source input");
await act(async () => {
setInputValue(sourceInput, "git:github.com/obra/superpowers");
await flush();
});
await waitFor("plugin preview enabled", () => findButton("Preview")?.disabled === false);
const preview = findButton("Preview");
if (!preview) throw new Error("missing plugin preview button");
await act(async () => {
preview.click();
await flush();
});
await waitFor("plugin install plan", () => document.body.textContent?.includes("install_plugin_package") ?? false);
ok(planCalls === 1, "clicking Preview invokes plugin install planning once");
ok(plannedSources[0] === "git:github.com/obra/superpowers", "plugin preview receives the entered Git source");
const install = findButton("Install plugin");
if (!install) throw new Error("missing plugin install button");
await act(async () => {
install.click();
await flush();
});
await waitFor("plugin install result", () => installCalls === 1 && plugins[0]?.version === "0.1.1");
ok(installedSources[0] === "git:github.com/obra/superpowers", "plugin install receives the entered Git source");
const disclosure = document.querySelector<HTMLButtonElement>(".cap-plugin-entry .cap-disclosure");
if (!disclosure) throw new Error("missing plugin disclosure");
await act(async () => {
disclosure.click();
await flush();
});
await waitFor("plugin update action", () => Boolean(findButton("Update")));
ok(document.body.textContent?.includes("How to use") ?? false, "expanded plugin details explain how to use the plugin");
ok(document.body.textContent?.includes("/plan") ?? false, "expanded plugin details list exported skill invocations");
ok(document.body.textContent?.includes("SessionStart") ?? false, "expanded plugin details list exported hooks");
ok(document.body.textContent?.includes("context") ?? false, "expanded plugin details list exported MCP servers");
const update = findButton("Update");
if (!update) throw new Error("missing plugin update button");
await act(async () => {
update.click();
await flush();
});
await waitFor("plugin update call", () => updateCalls === 1 && plugins[0]?.version === "0.1.2");
const doctor = findButton("Doctor");
if (!doctor) throw new Error("missing plugin doctor button");
await act(async () => {
doctor.click();
await flush();
});
await waitFor("plugin diagnostic warning", () => document.body.textContent?.includes("manifest exports no MCP auth metadata") ?? false);
ok(doctorCalls === 1, "clicking Doctor invokes plugin diagnostics once");
const toggle = document.querySelector<HTMLInputElement>(".cap-plugin-entry .cap-switch input");
if (!toggle) throw new Error("missing plugin enable toggle");
await act(async () => {
toggle.click();
await flush();
});
await waitFor("plugin disabled", () => toggleCalls === 1 && plugins[0]?.enabled === false);
const remove = findButton("Remove plugin");
if (!remove) throw new Error("missing plugin remove button");
await act(async () => {
remove.click();
await flush();
});
const confirmRemove = findButton("Confirm remove");
if (!confirmRemove) throw new Error("missing plugin confirm remove button");
await act(async () => {
confirmRemove.click();
await flush();
});
await waitFor("plugin removed", () => removeCalls === 1 && plugins.length === 0);
await act(async () => {
root.unmount();
});
dom.window.close();
}
@@ -8,7 +8,7 @@ import { Composer, composerPickFileEntry } from "../components/Composer";
import { LocaleProvider } from "../lib/i18n";
import { ToastProvider } from "../lib/toast";
import type { AppBindings } from "../lib/bridge";
import type { CollaborationMode, ToolApprovalMode, TokenMode } from "../lib/types";
import type { CollaborationMode, DirEntry, ToolApprovalMode, TokenMode } from "../lib/types";
let passed = 0;
let failed = 0;
@@ -200,6 +200,16 @@ async function waitFor(label: string, predicate: () => boolean) {
throw new Error(`timed out waiting for ${label}`);
}
type RenderedComposer = Awaited<ReturnType<typeof renderComposer>>;
function fileEntry(name: string): DirEntry {
return { name, isDir: false };
}
async function replaceComposerDraft(rerender: RenderedComposer["rerender"], id: number, text: string) {
await rerender({ insertRequest: { id, text, mode: "replace" } });
}
console.log("\ncomposer goal toggle");
{
@@ -568,5 +578,327 @@ console.log("\ncomposer goal toggle");
dom.window.close();
}
{
const dom = installDom();
const { root, rerender } = await renderComposer({
running: true,
guidanceQueuePreviewItems: ["confirm the send lifecycle", "keep steer protocol unchanged", "add a hanging submit regression"],
});
let guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
eq(guidanceItems.length, 2, "running guidance preview shows a compact queue preview");
ok(guidanceItems[0]?.textContent?.includes("confirm the send lifecycle") === true, "guidance preview shows the first seeded item");
ok(guidanceItems[1]?.textContent?.includes("keep steer protocol unchanged") === true, "guidance preview shows the second seeded item");
eq(document.querySelectorAll(".composer-guidance-item__guide").length, 2, "guidance preview exposes a guide action for each visible item");
let guidanceMore = document.querySelector(".composer-guidance-more") as HTMLButtonElement | null;
ok(guidanceMore?.textContent?.includes("1 more queued") === true, "guidance preview summarizes overflow items");
eq(guidanceMore?.getAttribute("aria-expanded"), "false", "guidance overflow starts collapsed");
if (!guidanceMore) throw new Error("guidance overflow button did not render");
await act(async () => {
guidanceMore.click();
await flushTimers();
});
guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
eq(guidanceItems.length, 3, "guidance overflow expands the remaining queued items");
ok(guidanceItems[2]?.textContent?.includes("add a hanging submit regression") === true, "expanded guidance preview shows the hidden item");
guidanceMore = document.querySelector(".composer-guidance-more") as HTMLButtonElement | null;
ok(guidanceMore?.textContent?.includes("Collapse") === true, "expanded guidance overflow can be collapsed");
eq(guidanceMore?.getAttribute("aria-expanded"), "true", "guidance overflow reports expanded state");
if (!guidanceMore) throw new Error("guidance collapse button did not render");
await act(async () => {
guidanceMore.click();
await flushTimers();
});
guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
eq(guidanceItems.length, 2, "guidance overflow collapses back to the compact preview");
await rerender({ guidanceQueuePreviewItems: ["only the latest preview seed"] });
guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
eq(guidanceItems.length, 1, "guidance preview refreshes when the seed changes");
ok(guidanceItems[0]?.textContent?.includes("only the latest preview seed") === true, "guidance preview renders the refreshed seed");
await rerender({ running: false });
ok(document.querySelector(".composer-guidance-item") === null, "guidance preview clears when the mock turn stops");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const { root, calls, rerender } = await renderComposer({
running: true,
onSend: (displayText, submitText) => {
calls.send.push(displayText);
calls.submit.push(submitText);
},
});
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("composer textarea did not render");
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("running composer send button did not render");
eq(textarea.placeholder, "Add guidance to the queue...", "running composer explains queued guidance input");
ok(sendButton.classList.contains("composer__btn--steer"), "running composer marks send button as steer");
ok(sendButton.disabled === true, "running steer button stays disabled without input");
await rerender({ insertRequest: { id: 4, text: "keep the files small", mode: "replace" } });
ok(sendButton.disabled === false, "running steer button enables after text input");
await act(async () => {
sendButton.click();
await flushTimers();
});
eq(calls.send.join(","), "", "running composer queues guidance without sending it immediately");
eq(textarea.value, "", "queued running guidance clears the composer draft");
const guidanceItem = document.querySelector(".composer-guidance-item") as HTMLElement | null;
if (!guidanceItem) throw new Error("running guidance chip did not render");
ok(guidanceItem.textContent?.includes("keep the files small") === true, "running guidance chip shows queued text");
ok(document.querySelector(".composer-guidance-head")?.textContent?.includes("Queued guidance 1") === true, "running guidance shelf shows queued count");
const guideButton = guidanceItem.querySelector(".composer-guidance-item__guide") as HTMLButtonElement | null;
if (!guideButton) throw new Error("running guidance guide button did not render");
await act(async () => {
guideButton.click();
await flushTimers();
});
eq(calls.send.join(","), "keep the files small", "queued guidance sends through onSend when guided");
ok(document.querySelector(".composer-guidance-item") === null, "queued guidance clears after being guided");
await rerender({ insertRequest: { id: 5, text: "prefer the smaller diff", mode: "replace" } });
await act(async () => {
sendButton.click();
await flushTimers();
});
const dismissibleGuidanceItem = document.querySelector(".composer-guidance-item") as HTMLElement | null;
if (!dismissibleGuidanceItem) throw new Error("dismissible guidance chip did not render");
const dismissButton = dismissibleGuidanceItem.querySelector(".composer-guidance-item__action") as HTMLButtonElement | null;
if (!dismissButton) throw new Error("running guidance dismiss button did not render");
await act(async () => {
dismissButton.click();
await flushTimers();
});
ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip can be dismissed");
await rerender({ insertRequest: { id: 6, text: "prefer the smaller diff", mode: "replace" } });
await act(async () => {
sendButton.click();
await flushTimers();
});
ok(document.querySelector(".composer-guidance-item") !== null, "running guidance chip renders again after another queued item");
await rerender({ guidanceConsumedKey: "s1", guidanceConsumedText: "prefer the smaller diff" });
ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip clears when steer is consumed");
await rerender({ insertRequest: { id: 7, text: "then stop showing the chip", mode: "replace" } });
await act(async () => {
sendButton.click();
await flushTimers();
});
ok(document.querySelector(".composer-guidance-item") !== null, "running guidance chip renders before turn stop");
await rerender({ running: false });
ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip clears when the turn stops");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const { root, calls, rerender } = await renderComposer({
running: true,
submitDisabled: true,
onSend: (displayText) => {
calls.send.push(displayText);
},
});
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("running composer send button did not render");
await rerender({ insertRequest: { id: 7, text: "steer while activating", mode: "replace" } });
ok(sendButton.disabled === false, "running guidance queue ignores controller submitDisabled");
await act(async () => {
sendButton.click();
await flushTimers();
});
eq(calls.send.join(","), "", "running guidance queues while controllerReady is false");
const guideButton = document.querySelector(".composer-guidance-item__guide") as HTMLButtonElement | null;
if (!guideButton) throw new Error("running guidance guide button did not render");
await act(async () => {
guideButton.click();
await flushTimers();
});
eq(calls.send.join(","), "steer while activating", "queued guidance can be guided while controllerReady is false");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
let listDirCalls = 0;
mockApp({
ListDir: async () => {
listDirCalls += 1;
return listDirCalls === 1 ? [fileEntry("cached-dir.txt")] : [fileEntry("fresh-dir.txt")];
},
SearchFileRefs: async () => [],
});
const { root, rerender } = await renderComposer();
await replaceComposerDraft(rerender, 101, "@");
await waitFor("initial @ directory load", () => listDirCalls === 1);
await replaceComposerDraft(rerender, 102, "");
await replaceComposerDraft(rerender, 103, "@");
await waitFor("@ directory revalidation call", () => listDirCalls === 2);
eq(listDirCalls, 2, "@ directory cache hit still revalidates ListDir");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
let listDirCalls = 0;
mockApp({
ListDir: async () => {
listDirCalls += 1;
return listDirCalls === 1 ? [fileEntry("manual-refresh-stale.txt")] : [fileEntry("manual-refresh-fresh.txt")];
},
SearchFileRefs: async () => [],
});
const { root, rerender } = await renderComposer({ fileRefRefreshKey: "0" });
await replaceComposerDraft(rerender, 201, "@");
await waitFor("initial @ directory load before refresh key", () => listDirCalls === 1);
await rerender({ fileRefRefreshKey: "1" });
await waitFor("@ directory reload after refresh key", () => listDirCalls === 2);
eq(listDirCalls, 2, "fileRefRefreshKey refreshes @ directory cache while the menu is open");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const realDateNow = Date.now;
let now = 1000;
let searchCalls = 0;
Date.now = () => now;
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => {
searchCalls += 1;
return searchCalls === 1 ? [fileEntry("alpha-old.ts")] : [fileEntry("alpha-new.ts")];
},
});
const { root, rerender } = await renderComposer();
try {
await replaceComposerDraft(rerender, 301, "@alpha");
await waitFor("initial @ search request", () => searchCalls === 1);
eq(searchCalls, 1, "@ search fetches the first query");
await replaceComposerDraft(rerender, 302, "");
now = 2000;
await replaceComposerDraft(rerender, 303, "@alpha");
await act(async () => {
await flushTimers();
});
eq(searchCalls, 1, "@ search cache is reused inside the TTL");
await replaceComposerDraft(rerender, 304, "");
now = 7001;
await replaceComposerDraft(rerender, 305, "@alpha");
await waitFor("expired @ search cache refresh", () => searchCalls === 2);
eq(searchCalls, 2, "@ search cache revalidates after the TTL");
} finally {
Date.now = realDateNow;
}
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
let staleListDirResolve: ((entries: DirEntry[]) => void) | undefined;
let thirdListDirResolve: ((entries: DirEntry[]) => void) | undefined;
let listDirCalls = 0;
mockApp({
ListDir: async () => {
listDirCalls += 1;
if (listDirCalls === 1) {
return new Promise<DirEntry[]>((resolve) => {
staleListDirResolve = resolve;
});
}
if (listDirCalls === 2) return [fileEntry("cache-live.txt")];
return new Promise<DirEntry[]>((resolve) => {
thirdListDirResolve = resolve;
});
},
SearchFileRefs: async () => [],
});
const { root, rerender } = await renderComposer({ fileRefRefreshKey: "0" });
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("composer textarea did not render");
await replaceComposerDraft(rerender, 401, "@cache");
await waitFor("initial stale @ directory request", () => listDirCalls === 1);
await rerender({ fileRefRefreshKey: "1" });
await waitFor("fresh @ directory request after refresh key", () => listDirCalls === 2);
await act(async () => {
await flushTimers();
});
staleListDirResolve?.([fileEntry("cache-stale.txt")]);
await act(async () => {
await flushTimers();
});
await replaceComposerDraft(rerender, 402, "");
await replaceComposerDraft(rerender, 403, "@cache");
await waitFor("second fresh @ directory request", () => listDirCalls === 3);
await act(async () => {
textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
await flushTimers();
});
eq(textarea.value, "@cache-live.txt ", "stale @ directory request cannot repopulate cache after refresh");
thirdListDirResolve?.([fileEntry("cache-later.txt")]);
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -5,6 +5,7 @@ import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { Composer } from "../components/Composer";
import { UserMessage } from "../components/Message";
import { LocaleProvider } from "../lib/i18n";
import { ToastProvider } from "../lib/toast";
import type { CollaborationMode, TokenMode, ToolApprovalMode } from "../lib/types";
@@ -137,7 +138,9 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
root.render(
<LocaleProvider>
<ToastProvider>
<Composer {...currentProps} />
<div className="chat-pane">
<Composer {...currentProps} />
</div>
</ToastProvider>
</LocaleProvider>,
);
@@ -183,6 +186,29 @@ function imagePasteEvent(file: File): Event {
return event;
}
function imageViewerOpen(): boolean {
return Boolean(document.querySelector(".image-viewer-backdrop .image-viewer__image"));
}
function renderUserMessage(text: string, props: Partial<Parameters<typeof UserMessage>[0]> = {}) {
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
const paint = async () => {
await act(async () => {
root.render(
<LocaleProvider>
<div className="chat-pane">
<UserMessage text={text} {...props} />
</div>
</LocaleProvider>,
);
await flushTimers();
});
};
return { root, paint };
}
console.log("\ncomposer image capability");
{
@@ -255,5 +281,107 @@ console.log("\ncomposer image capability");
dom.window.close();
}
{
const dom = installDom();
installBridgeApp({
SavePastedImage: async () => ".reasonix/attachments/mock.png",
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
});
const { root } = await renderComposer({ imageInputEnabled: true });
const file = new File(["img"], "photo.png", { type: "image/png", lastModified: 1 });
await act(async () => {
textarea().dispatchEvent(imagePasteEvent(file));
await flushTimers();
await flushTimers();
});
await waitFor(() => Boolean(document.querySelector(".composer-context__thumb img")));
const thumb = document.querySelector(".composer-context__thumb") as HTMLElement | null;
if (!thumb) throw new Error("missing composer image thumbnail");
await act(async () => {
thumb.click();
await flushTimers();
});
await waitFor(imageViewerOpen);
ok(imageViewerOpen(), "composer image thumbnail opens the image viewer");
await act(async () => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
await flushTimers();
});
await waitFor(() => !document.querySelector(".image-viewer-backdrop"));
ok(!document.querySelector(".image-viewer-backdrop"), "composer image viewer closes on Escape");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
installBridgeApp({
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
});
const { root, paint } = renderUserMessage("check @[photo.png](.reasonix/attachments/mock.png)");
await paint();
await waitFor(() => Boolean(document.querySelector(".msg-attachment--image img")));
const thumb = document.querySelector(".msg-attachment--image") as HTMLElement | null;
if (!thumb) throw new Error("missing message image thumbnail");
await act(async () => {
thumb.click();
await flushTimers();
});
await waitFor(() => Boolean(document.querySelector(".chat-pane > .image-viewer-backdrop")));
ok(Boolean(document.querySelector(".chat-pane > .image-viewer-backdrop")), "sent message image preview portals into the chat pane");
const close = document.querySelector(".image-viewer__close") as HTMLButtonElement | null;
if (!close) throw new Error("missing image viewer close button");
await act(async () => {
close.click();
await flushTimers();
});
await waitFor(() => !document.querySelector(".image-viewer-backdrop"));
ok(!document.querySelector(".image-viewer-backdrop"), "sent message image viewer closes from the close button");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
installBridgeApp({
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
});
const { root, paint } = renderUserMessage("check @[photo.png](.reasonix/attachments/mock.png)", {
turn: 1,
onEdit: () => true,
});
await paint();
await waitFor(() => Boolean(document.querySelector(".msg-attachment--image img")));
const edit = document.querySelector("button.msg-meta__btn:not(.msg-meta__copy)") as HTMLButtonElement | null;
if (!edit) throw new Error("missing message edit button");
await act(async () => {
edit.click();
await flushTimers();
});
await waitFor(() => Boolean(document.querySelector(".msg-edit .composer-context__thumb img")));
const thumb = document.querySelector(".msg-edit .composer-context__thumb") as HTMLElement | null;
if (!thumb) throw new Error("missing edit image thumbnail");
await act(async () => {
thumb.click();
await flushTimers();
});
await waitFor(imageViewerOpen);
ok(imageViewerOpen(), "edit message image thumbnail opens the image viewer");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -1,6 +1,6 @@
// Run: tsx src/__tests__/context-panel-breakdown.test.ts
import { contextBreakdown, contextCostDisplay, contextSourceRows, formatCacheHitRate, formatMetricTokens } from "../components/ContextPanel";
import { cacheHitTone, contextBreakdown, contextCostDisplay, contextSessionCache, contextSourceRows, contextUsageRefreshKey, contextWindowStatus, formatCacheHitRate, formatMetricTokens } from "../components/ContextPanel";
import { currencySymbol, formatMoney, formatMoneyLocalized } from "../lib/money";
let passed = 0;
@@ -49,7 +49,7 @@ eq(
42_124,
"legend values sum to used context tokens",
);
eq(Math.round(mock.otherPct), 33, "donut endpoint follows used/window percent");
eq(Math.round(mock.otherPct), 33, "usage endpoint follows used/window percent");
const issue5283 = contextBreakdown(6888, 1_000_000, 6840, 48, 48);
eq(
@@ -90,9 +90,16 @@ eq(
reasoningPct: 0,
otherPct: 0,
},
"unknown context window keeps donut segments empty",
"unknown context window keeps usage segments empty",
);
console.log("\ncontext window status");
eq(contextWindowStatus(33, 80), { tone: "good", key: "context.windowStatusHealthy" }, "low usage stays healthy");
eq(contextWindowStatus(72, 80), { tone: "notice", key: "context.windowStatusWatch" }, "usage near compact threshold warns early");
eq(contextWindowStatus(80, 80), { tone: "warn", key: "context.windowStatusPastCompact" }, "compact threshold reached takes warning tone");
eq(contextWindowStatus(91, 80), { tone: "warn", key: "context.windowStatusNearLimit" }, "near hard limit overrides compact status");
console.log("\ncontext panel cost");
const infoCost = contextCostDisplay({
@@ -102,6 +109,59 @@ const infoCost = contextCostDisplay({
usage: { cost: 0, costUsd: 0, currency: "¥" },
});
eq(infoCost, { amount: 0.1759, currency: "$" }, "panel cost keeps the panel currency instead of state default");
const singleRequestOnly = contextCostDisplay({
info: { sessionCost: 0, sessionCurrency: "", sessionCostUsd: 0 },
sessionCost: 0,
sessionCurrency: "¥",
usage: { cost: 0.42, costUsd: 0.42, currency: "¥" },
});
eq(
singleRequestOnly,
{ amount: 0, currency: "¥" },
"a single request's cost never renders under the session-cost label",
);
const localAccumulated = contextCostDisplay({
info: { sessionCost: 0, sessionCurrency: "", sessionCostUsd: 0 },
sessionCost: 1.5,
sessionCurrency: "¥",
usage: { cost: 0.42, costUsd: 0.42, currency: "$" },
});
eq(localAccumulated, { amount: 1.5, currency: "¥" }, "locally accumulated session cost still renders");
console.log("\ncontext panel session cache scope");
eq(
contextSessionCache(
{ sessionCacheHitTokens: 900, sessionCacheMissTokens: 100 },
{ cacheHitTokens: 800, cacheMissTokens: 200 },
{ sessionCacheHitTokens: 700, sessionCacheMissTokens: 300 },
),
{ hit: 900, miss: 100 },
"all-sources panel telemetry beats executor-only wire counters",
);
eq(
contextSessionCache(
{ sessionCacheHitTokens: 0, sessionCacheMissTokens: 0 },
{ cacheHitTokens: 800, cacheMissTokens: 200 },
{ sessionCacheHitTokens: 700, sessionCacheMissTokens: 300 },
),
{ hit: 800, miss: 200 },
"ContextInfo telemetry is the second all-sources choice",
);
eq(
contextSessionCache(
{ sessionCacheHitTokens: 0, sessionCacheMissTokens: 0 },
{ cacheHitTokens: 0, cacheMissTokens: 0 },
{ sessionCacheHitTokens: 700, sessionCacheMissTokens: 300 },
),
{ hit: 700, miss: 300 },
"executor-only wire counters only bridge the pre-refresh gap",
);
eq(
contextSessionCache(null, undefined, undefined),
{ hit: 0, miss: 0 },
"no data renders as empty, not NaN",
);
eq(formatMoney(infoCost.amount, infoCost.currency, "dash"), "$0.1759", "USD panel cost renders with dollar sign");
eq(currencySymbol("楼"), "¥", "unexpected currency text does not leak into money values");
eq(currencySymbol("aud"), "AUD ", "unknown ISO currency codes stay readable");
@@ -118,6 +178,32 @@ console.log("\ncontext panel cache rate");
eq(formatCacheHitRate(99_950, 50), "99.95%", "cache hit rate preserves two decimal places");
eq(formatCacheHitRate(0, 10_000), "0.00%", "cache hit rate shows zero when usage data exists");
eq(formatCacheHitRate(0, 0), "-", "cache hit rate stays empty before usage data exists");
eq(cacheHitTone(8700, 1300), "good", "healthy cache hit rate uses positive tone");
eq(cacheHitTone(6000, 4000), "notice", "mid cache hit rate uses notice tone");
eq(cacheHitTone(5999, 4001), "warn", "low cache hit rate uses warning tone");
eq(cacheHitTone(0, 0), undefined, "missing cache data stays uncolored");
console.log("\ncontext panel usage refresh key");
eq(contextUsageRefreshKey(undefined), "", "missing usage does not request a streaming refresh");
ok(
contextUsageRefreshKey({
totalTokens: 10,
promptTokens: 10,
completionTokens: 0,
reasoningTokens: 0,
sessionCacheHitTokens: 0,
sessionCacheMissTokens: 0,
}) !== contextUsageRefreshKey({
totalTokens: 11,
promptTokens: 10,
completionTokens: 1,
reasoningTokens: 0,
sessionCacheHitTokens: 0,
sessionCacheMissTokens: 0,
}),
"general token changes refresh even when cache counters stay unchanged",
);
console.log("\ncontext panel source rows");
@@ -133,6 +133,17 @@ check("$\\frac{a}{b}$", () => isLikelyInlineMath("\\frac{a}{b}") === true);
check("$f(x)$", () => isLikelyInlineMath("f(x)") === true);
check("$x+1$", () => isLikelyInlineMath("x+1") === true);
console.log("\nisLikelyInlineMath — classifier gaps from PR #4543");
check("$\\tfrac12$", () => isLikelyInlineMath("\\tfrac12") === true);
check("$\\sqrt2$", () => isLikelyInlineMath("\\sqrt2") === true);
check("$SO(3,1)$", () => isLikelyInlineMath("SO(3,1)") === true);
check("$SU(2)$", () => isLikelyInlineMath("SU(2)") === true);
check("$GL(n)$", () => isLikelyInlineMath("GL(n)") === true);
check("$K = -iJ$", () => isLikelyInlineMath("K = -iJ") === true);
check("$p = +\\alpha$", () => isLikelyInlineMath("p = +\\alpha") === true);
check("$+$", () => isLikelyInlineMath("+") === true);
check("$=$", () => isLikelyInlineMath("=") === true);
console.log("\nisLikelyInlineMath — currency/link (NOT math)");
check("$5", () => isLikelyInlineMath("5") === false);
check("$10", () => isLikelyInlineMath("10") === false);
@@ -153,6 +164,15 @@ check("uppercase $I$ → math (math name in non-English prose)", () => isLikelyI
check("uppercase $A$ → math", () => isLikelyInlineMath("A") === true);
check("uppercase $V$ → math", () => isLikelyInlineMath("V") === true);
console.log("\nisLikelyInlineMath — primed letters and bracketed labels");
check("$S'$ → math (primed letter)", () => isLikelyInlineMath("S'") === true);
check("$y''$ → math (double prime)", () => isLikelyInlineMath("y''") === true);
check("$f'(x)$ → math (primed function)", () => isLikelyInlineMath("f'(x)") === true);
check("$\\psi'$ → math (Greek with prime)", () => isLikelyInlineMath("\\psi'") === true);
check("$[56]$ → math (irrep label)", () => isLikelyInlineMath("[56]") === true);
check("$[56,0^+]$ → math", () => isLikelyInlineMath("[56,0^+]") === true);
check("$[\\mathbf{56}]$ → math", () => isLikelyInlineMath("[\\mathbf{56}]") === true);
console.log("\nisLikelyInlineMath — minimal LaTeX patterns (regression)");
// LLMs frequently emit minimal LaTeX in math contexts that the older
// classifier rejected as currency / word tokens. These tests pin down the
@@ -170,6 +190,8 @@ check("comma-separated $A, B$ → math (ordered pair)", () => isLikelyInlineMath
check("comma-separated $1, 2, 3$ → math (sequence)", () => isLikelyInlineMath("1, 2, 3") === true);
check("comma-separated $\\alpha, \\beta$ → math (Greek pair)", () => isLikelyInlineMath("\\alpha, \\beta") === true);
check("parens-wrapped $(A, B)$ inner → math", () => isLikelyInlineMath("(A, B)") === true);
check("cycle notation $(12)$ → math", () => isLikelyInlineMath("(12)") === true);
check("cycle notation $(12)(34)$ → math", () => isLikelyInlineMath("(12)(34)") === true);
check("$S$ (set name) → math", () => isLikelyInlineMath("S") === true);
check("$S$ with surrounding prose (regression)", () => {
return normalizeMath("$S$ 非空\n$S$ 有上界") === "$S$ 非空\n$S$ 有上界";
@@ -217,7 +239,7 @@ check("\\|x\\| renders as double bars", () => {
console.log("\nnormalizeMath — LLM delimiter conversion");
eq(normalizeMath("\\(x^2\\)"), "$x^2$", "\\(…\\) → $…$");
eq(normalizeMath("\\[E=mc^2\\]"), "$$E=mc^2$$", "\\[…\\] → $$…$$");
eq(normalizeMath("\\[E=mc^2\\]"), "$$\nE=mc^2\n$$", "\\[…\\] → $$…$$");
eq(normalizeMath("\\\\[4pt]"), "\\\\[4pt]", "\\\\[ line-break spacing protected");
console.log("\nnormalizeMath — \\slashed conversion (regression)");
@@ -235,19 +257,17 @@ console.log("\nnormalizeMath — inline $$ glued to prose (regression)");
// line before any $$ preceded by a letter/closing bracket/etc.
check("inline $$ after prose", () => {
const out = normalizeMath("decomposes as$$\n\\mathbf{6}.$$");
return /^decomposes as\n\n\$\$/.test(out) && out.includes("\\mathbf{6}");
return /^decomposes as\n\$\$/.test(out) && out.includes("\\mathbf{6}");
});
check("inline $$ after closing bracket", () => {
const out = normalizeMath("(octet)$$ \\mathbf{56}.$$");
return out.startsWith("(octet)\n\n$$");
return out.startsWith("(octet)\n$$");
});
check("inline $$ after closing brace (\\end{...}$$)", () => {
// A model that writes `\end{array}$$` or `\frac{a}{b}$$` on one line
// has the same micromark-fence problem as the comma case. The
// closing brace is the most common end-of-content marker in LaTeX
// math, so the repair-regex character class includes it.
// A display equation ending with }$$ must be extracted as a unit.
// The closing $$ must not be split off, or the equation body is emptied.
const out = normalizeMath("$$\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}$$");
return out.includes("\\end{pmatrix},\n\n$$") || out.includes("\\end{pmatrix}\n\n$$");
return out.includes("\\end{pmatrix},\n$$") || out.includes("\\end{pmatrix}\n$$");
});
check("inline $$ after comma on same line as content", () => {
// User-reported (2026-06-12, soft-pion chat): the model wrote the
@@ -261,25 +281,31 @@ check("inline $$ after comma on same line as content", () => {
// as math, which then fails to render with "Can't use function '$'
// in math mode" on the stray $ inside the equation body.
const out = normalizeMath("…D(q^2),$$\nwith $P=…$");
return out.includes("D(q^2),\n\n$$");
return out.includes("D(q^2),\n$$");
});
check("well-formed $$ already on own line is normalised consistently", () => {
// Whether the model writes `decomposes as$$\n\mathbf{6}.$$` or
// `decomposes as\n\n$$\n\mathbf{6}.$$`, both must produce the same
// remark-math-parseable form: opening $$ on its own line, body, blank
// line, closing $$ on its own line.
// `decomposes as\n\n$$\n\mathbf{6}.$$`, both must produce valid
// remark-math-parseable form: opening $$ on its own line, body, closing
// $$ on its own line.
const inline = normalizeMath("decomposes as$$\n\\mathbf{6}.$$");
const block = normalizeMath("decomposes as\n\n$$\n\\mathbf{6}.$$");
const expected = "decomposes as\n\n$$\n\\mathbf{6}.\n\n$$";
return inline === expected && block === expected;
const valid = (s: string) => /\n\$\$\n/.test(s) && /\n\$\$/.test(s) && s.includes("\\mathbf{6}");
return valid(inline) && valid(block);
});
check("\\[…\\] → $$…$$ still works (no spurious blank line)", () => {
return normalizeMath("\\[E=mc^2\\]") === "$$E=mc^2$$";
return normalizeMath("\\[E=mc^2\\]") === "$$\nE=mc^2\n$$";
});
check("digit before $$ is NOT a prose boundary (preserves c^2$$)", () => {
const out = normalizeMath("c^2$$ x $$");
return out === "c^2$$ x $$";
});
eq(normalizeMath("intro$$x+1"), "intro\n$$\nx+1", "orphan opening $$ is not duplicated");
eq(
normalizeMath("first$$a$$ middle $$b$$ end"),
"first\n$$\na\n$$\n middle \n$$\nb\n$$\n end",
"multiple display blocks on one line are all normalised",
);
console.log("\nnormalizeMath — non-math dollar filtering");
eq(normalizeMath("costs $1$ today"), "costs &#36;1&#36; today", "$1$ not math");
@@ -409,6 +435,10 @@ const e2e: Array<[string, string]> = [
["$\\frac{1}{\\sqrt{2}}\\|uud\\rangle$", "ket in fraction with \\|"],
["$\\|x\\|$", "norm \\|x\\| → double bar (regression)"],
["$\\langle\\psi\\|$", "bra closer \\| → single bar (regression)"],
["$S'$", "primed letter S'"],
["$f'(x)$", "primed function call"],
["$[56]$", "bracketed irrep label"],
["$[56,0^+]$", "bracketed irrep label with charge"],
];
for (const [src, label] of e2e) {
check(`${label}: ${src}`, () => katexOf(normalizeMath(src), false));
@@ -458,6 +488,19 @@ check("real inline math $x^2$ still renders as KaTeX", () => {
const html = renderHtml("the value $x^2$ here");
return html.includes("katex");
});
check("blockquote display math does not swallow following inline math", () => {
const html = renderHtml("> theorem\n> $$E=mc^2$$\n> after $x$");
return html.includes("katex-display")
&& !html.includes("katex-error")
&& html.includes(">x</mi>");
});
check("multi-line blockquote display math strips quote markers from the formula", () => {
const html = renderHtml("> theorem\n> $$\n> E=mc^2\n> $$\n> after $x$");
return html.includes("katex-display")
&& !html.includes("katex-error")
&& !html.includes("&gt; E")
&& html.includes(">x</mi>");
});
// ── Young diagram / tableau macros ─────────────────────────────────────────────
// `\yng` (ytableau) and `\young` (youngtab) are common in physics —
@@ -486,7 +529,7 @@ check("\\yng inside \\(...\\) does not get double-wrapped", () => {
});
check("\\yng inside \\[...\\] stays display math without triple dollars", () => {
const out = normalizeMath("\\[\\yng(2,1)\\]");
return out.startsWith("$$\\begin{array}{l}")
return out.startsWith("$$\n\\begin{array}{l}")
&& out.endsWith("$$")
&& !out.includes("$$$");
});
@@ -505,7 +548,7 @@ check("digit-starting inline math with \\young does not get nested wrappers", ()
});
check("display math ending in digit closes before following bare \\yng", () => {
const out = normalizeMath("$$x^2$$ \\yng(1)");
return out === "$$x^2$$ $\\begin{array}{l}\\square\\end{array}$";
return out === "$$\nx^2\n$$\n $\\begin{array}{l}\\square\\end{array}$";
});
check("bare \\yng after inline math is separated from adjacent dollars", () => {
const out = normalizeMath("$x$\\yng(1)");
@@ -0,0 +1,39 @@
// Run: tsx src/__tests__/message-pasted-blocks.test.ts
import { parsePastedBlocks } from "../components/Message";
let passed = 0;
let failed = 0;
function eq(a: unknown, b: unknown, label: string) {
if (JSON.stringify(a) === JSON.stringify(b)) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
failed += 1;
}
}
function wrapped(label: string, content: string): string {
return `${label}\n\n--- Begin ${label} ---\n${content}\n--- End ${label} ---`;
}
console.log("\nmessage pasted blocks");
for (const [label, name] of [
["[已粘贴文本 #2 · 31 行]", "Simplified Chinese"],
["[已貼上文字 #2 · 31 行]", "Traditional Chinese"],
["[Pasted text #2 · 31 lines]", "English"],
] as const) {
eq(
parsePastedBlocks(`before\n${label}\nafter`, wrapped(label, "line 1\nline 2")),
[{ label, content: "line 1\nline 2\n" }],
`${name} pasted text labels are parsed from submit text`,
);
}
eq(parsePastedBlocks("[unknown paste #1]", "--- Begin [unknown paste #1] ---\nnope\n--- End [unknown paste #1] ---"), [], "unknown labels are ignored");
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -74,7 +74,7 @@ function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta {
};
}
function meta(): Meta {
function meta(overrides: Partial<Meta> = {}): Meta {
return {
label: "model",
ready: true,
@@ -91,6 +91,7 @@ function meta(): Meta {
tokenMode: "full",
goal: "",
goalStatus: "stopped",
...overrides,
};
}
@@ -211,10 +212,14 @@ await act(async () => {
});
const guardedStartupTabs = deferred<TabMeta[]>();
let ensureBlankSurfaceCalls = 0;
const staleProjectA = "/repo/project-a";
const targetProjectB = "/repo/project-b";
const ensureBlankSurfaceCalls: Array<{ scope: string; workspaceRoot: string }> = [];
window.go.main.App = {
ListTabs: async () => guardedStartupTabs.promise,
MetaForTab: async () => meta(),
MetaForTab: async (tabID: string) => tabID === "tab-new"
? meta({ cwd: targetProjectB, workspaceRoot: targetProjectB, workspaceName: "project-b", workspacePath: targetProjectB })
: meta({ cwd: staleProjectA, workspaceRoot: staleProjectA, workspaceName: "project-a", workspacePath: staleProjectA }),
ContextUsageForTab: async () => context,
EffortForTab: async () => effort,
BalanceForTab: async () => balance,
@@ -224,9 +229,17 @@ window.go.main.App = {
HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
HistoryCheckpointTurnsForTab: async () => [],
ReplayPendingPrompts: async () => {},
EnsureBlankSurface: async () => {
ensureBlankSurfaceCalls += 1;
return tabMeta({ id: "tab-new", topicId: "topic-new", topicTitle: "New session" });
EnsureBlankSurface: async (scope: string, workspaceRoot: string) => {
ensureBlankSurfaceCalls.push({ scope, workspaceRoot });
return tabMeta({
id: "tab-new",
topicId: "topic-new",
topicTitle: "New session",
workspaceRoot: targetProjectB,
workspaceName: "project-b",
workspacePath: targetProjectB,
cwd: targetProjectB,
});
},
} as Partial<AppBindings> as AppBindings;
@@ -239,20 +252,31 @@ await act(async () => {
});
await act(async () => {
await controller?.ensureBlankSurface("project", "/repo");
await controller?.ensureBlankSurface("project", targetProjectB);
await flushPromises();
});
eq(ensureBlankSurfaceCalls, 1, "EnsureBlankSurface is called once");
eq(ensureBlankSurfaceCalls.length, 1, "EnsureBlankSurface is called once");
eq(ensureBlankSurfaceCalls[0]?.workspaceRoot, targetProjectB, "EnsureBlankSurface keeps the requested project root");
eq(controller?.activeTabId, "tab-new", "blank surface becomes active before startup sync resolves");
eq(controller?.state.meta?.workspaceRoot, targetProjectB, "blank surface exposes the new project root");
await act(async () => {
guardedStartupTabs.resolve([tabMeta({ id: "tab-old", topicId: "topic-old", topicTitle: "Old session" })]);
guardedStartupTabs.resolve([tabMeta({
id: "tab-old",
topicId: "topic-old",
topicTitle: "Old session",
workspaceRoot: staleProjectA,
workspaceName: "project-a",
workspacePath: staleProjectA,
cwd: staleProjectA,
})]);
await guardedStartupTabs.promise;
await flushPromises();
});
eq(controller?.activeTabId, "tab-new", "guarded startup sync cannot restore an older active tab");
eq(controller?.state.meta?.workspaceRoot, targetProjectB, "guarded startup sync cannot restore the old project root");
await act(async () => {
guardRoot.unmount();
@@ -6,6 +6,10 @@ import {
activeSessionAncestorKeys,
projectTreeTopicOpenRequest,
projectTreeShouldSuppressOpenForRename,
projectTreeReadActivityKey,
projectTreeTopicHasUnreadActivity,
projectTreeShouldRenderTopicActions,
projectTreeTopicMetaLine,
} from "../components/ProjectTree";
import type { ProjectNode } from "../lib/types";
@@ -24,6 +28,12 @@ function eq(a: unknown, b: unknown, label: string) {
console.log("\nproject tree runtime sessions");
const testT = (key: string, vars?: Record<string, string | number>) => {
if (key === "history.turnOne") return `${vars?.n ?? 1} turn`;
if (key === "history.turnOther") return `${vars?.n ?? 0} turns`;
return key;
};
const tree: ProjectNode[] = [
{
key: "global_folder",
@@ -104,6 +114,81 @@ eq(
"regular project topic still opens by topic",
);
eq(
projectTreeTopicMetaLine({
key: "global_topic_missing_time",
kind: "global_topic",
label: "Old empty topic",
topicId: "missing-time",
}, testT),
"projectTree.previously",
"topic with no turns and no timestamps renders previous-time fallback meta",
);
eq(
projectTreeTopicMetaLine({
key: "global_topic_recent",
kind: "global_topic",
label: "Recent blank topic",
topicId: "recent",
createdAt: Date.now(),
}, testT),
"projectTree.justNow",
"topic with a real recent timestamp still renders just-now meta",
);
const completedTopic: ProjectNode = {
key: "topic_complete",
kind: "topic",
label: "Completed",
root: "/repo",
topicId: "topic-complete",
lastActivityAt: 2000,
};
const completedTopicKey = projectTreeReadActivityKey(completedTopic) ?? "";
eq(
projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 1000 }, "project", "/repo", "other-topic"),
true,
"completed inactive topic with newer activity shows unread attention",
);
eq(
projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 2000 }, "project", "/repo", "other-topic"),
false,
"completed topic stops showing unread attention once opened at its latest activity",
);
eq(
projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 1000 }, "project", "/repo", "topic-complete"),
false,
"active topic does not show unread attention",
);
eq(
projectTreeTopicHasUnreadActivity({ ...completedTopic, status: "streaming", running: true }, { [completedTopicKey]: 1000 }, "project", "/repo", "other-topic"),
false,
"running topic keeps runtime status instead of completed-unread attention",
);
eq(
projectTreeShouldRenderTopicActions(false, true, false),
true,
"read workbench topic renders hover actions",
);
eq(
projectTreeShouldRenderTopicActions(false, true, true),
false,
"unread workbench topic omits hover actions from the keyboard tab order",
);
eq(
projectTreeShouldRenderTopicActions(true, true, false),
false,
"runtime session rows do not render topic hover actions",
);
eq(
projectTreeShouldSuppressOpenForRename(
{ rowKey: "topic-a", canRename: true },
@@ -47,14 +47,14 @@ eq(
);
eq(
mergedFetchedProviderModels(["mimo-v2.5-pro"], ["mimo-v2-flash", "mimo-v2-omni", "mimo-v2.5-pro"], { preserveCurated: true }),
mergedFetchedProviderModels(["mimo-v2.5-pro"], ["mimo-v2.5", "mimo-v2.5-pro"], { preserveCurated: true }),
["mimo-v2.5-pro"],
"manual access refresh preserves selected MiMo model instead of importing provider catalog",
);
eq(
providerModelCandidates(["mimo-v2.5-pro"], ["mimo-v2-flash", "mimo-v2-omni", "mimo-v2.5-pro"]),
["mimo-v2.5-pro", "mimo-v2-flash", "mimo-v2-omni"],
providerModelCandidates(["mimo-v2.5-pro"], ["mimo-v2.5", "mimo-v2.5-pro"]),
["mimo-v2.5-pro", "mimo-v2.5"],
"manual access refresh can show provider catalog as unsaved candidates",
);
@@ -0,0 +1,210 @@
// Run: tsx src/__tests__/ready-event-meta-sync.test.tsx
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { AppBindings } from "../lib/bridge";
import { useController } from "../lib/useController";
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) {
ok(true, label);
} else {
ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 30; attempt += 1) {
await act(async () => {
await flushPromises();
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
function tabMeta(ready: boolean): TabMeta {
return {
id: "tab-ready",
scope: "project",
workspaceRoot: "/repo",
workspaceName: "repo",
workspacePath: "/repo",
gitBranch: "main",
topicId: "topic-ready",
topicTitle: "Ready race",
sessionPath: "/repo/sessions/ready.jsonl",
label: "model",
ready,
running: false,
mode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
active: true,
cwd: "/repo",
};
}
function meta(ready: boolean): Meta {
return {
label: "model",
ready,
eventChannel: "agent:event",
cwd: "/repo",
workspaceRoot: "/repo",
workspaceName: "repo",
workspacePath: "/repo",
gitBranch: "main",
autoApproveTools: false,
bypass: false,
collaborationMode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
goal: "",
goalStatus: "stopped",
};
}
console.log("\nready event meta sync");
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
const balance: BalanceInfo = { available: false, display: "" };
const jobs: JobView[] = [];
const checkpoints: CheckpointMeta[] = [];
const readyHandlers: Array<(tabId?: string) => void> = [];
const historyGate = deferred<HistoryMessage[]>();
let backendReady = false;
let listTabsCalls = 0;
let historyCalls = 0;
let metaCalls = 0;
window.runtime = {
EventsOn: (name: string, cb: (...data: unknown[]) => void) => {
if (name === "agent:ready") readyHandlers.push(cb as (tabId?: string) => void);
return () => {};
},
BrowserOpenURL: () => {},
};
window.go = {
main: {
App: {
ListTabs: async () => {
listTabsCalls += 1;
return [tabMeta(backendReady)];
},
MetaForTab: async () => {
metaCalls += 1;
return meta(backendReady);
},
ContextUsageForTab: async () => context,
EffortForTab: async () => effort,
BalanceForTab: async () => balance,
JobsForTab: async () => jobs,
CheckpointsForTab: async () => checkpoints,
HistoryForTab: async () => historyGate.promise,
HistoryPageForTab: async () => {
historyCalls += 1;
const messages = await historyGate.promise;
return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false };
},
HistoryCheckpointTurnsForTab: async () => [],
ReplayPendingPrompts: async () => {},
} as Partial<AppBindings> as AppBindings,
},
};
type Controller = ReturnType<typeof useController>;
let controller: Controller | undefined;
function Probe() {
controller = useController();
return null;
}
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(<Probe />);
await flushPromises();
});
await waitFor("initial not-ready metadata", () => controller?.activeTabId === "tab-ready" && controller.state.meta?.ready === false);
eq(historyCalls, 1, "startup begins one history hydration");
eq(metaCalls, 0, "startup history remains in flight before ancillary meta");
backendReady = true;
await act(async () => {
for (const handler of readyHandlers) handler("tab-ready");
await flushPromises();
});
await waitFor("ready metadata refreshed before history settles", () => controller?.state.meta?.ready === true);
eq(listTabsCalls >= 2, true, "ready event refreshes active tab metadata from ListTabs");
eq(historyCalls, 1, "ready event joins the in-flight hydrate instead of reloading history");
eq(metaCalls, 0, "ready event does not wait for ancillary MetaForTab before unlocking send");
await act(async () => {
historyGate.resolve([{ role: "user", content: "hello" }]);
await historyGate.promise;
await flushPromises();
});
await waitFor("history finishes", () => controller?.state.hydrating === false);
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello") ?? false, "history still hydrates after the ready metadata sync");
await act(async () => {
root.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,220 @@
// Run: tsx src/__tests__/ready-meta-reconcile.test.tsx
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { AppBindings } from "../lib/bridge";
import { useController } from "../lib/useController";
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) {
ok(true, label);
} else {
ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
function flushPromises(ms = 0): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 100; attempt += 1) {
await act(async () => {
await flushPromises(25);
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
function tabMeta(id: string, ready: boolean, active: boolean): TabMeta {
return {
id,
scope: "project",
workspaceRoot: "/repo",
workspaceName: "repo",
workspacePath: "/repo",
gitBranch: "main",
topicId: `topic-${id}`,
topicTitle: id,
sessionPath: `/repo/sessions/${id}.jsonl`,
label: "model",
ready,
running: false,
mode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
active,
cwd: "/repo",
};
}
function meta(tabId: string, ready: boolean): Meta {
return {
label: "model",
ready,
eventChannel: "agent:event",
cwd: "/repo",
workspaceRoot: "/repo",
workspaceName: "repo",
workspacePath: "/repo",
sessionPath: `/repo/sessions/${tabId}.jsonl`,
gitBranch: "main",
autoApproveTools: false,
bypass: false,
collaborationMode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
goal: "",
goalStatus: "stopped",
};
}
console.log("\nready meta reconcile");
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
const balance: BalanceInfo = { available: false, display: "" };
const jobs: JobView[] = [];
const checkpoints: CheckpointMeta[] = [];
const historyGate = deferred<HistoryMessage[]>();
let backendReady = false;
let listTabsCalls = 0;
let historyCalls = 0;
let metaCalls = 0;
let approvalModeCalls = 0;
const metaTabIds: string[] = [];
window.runtime = {
EventsOn: () => () => {},
BrowserOpenURL: () => {},
};
window.go = {
main: {
App: {
ListTabs: async () => {
listTabsCalls += 1;
return [
tabMeta("tab-ready", backendReady, true),
tabMeta("tab-inactive", true, false),
];
},
MetaForTab: async (tabId: string) => {
metaCalls += 1;
metaTabIds.push(tabId);
return meta(tabId, tabId === "tab-ready" ? backendReady : true);
},
ContextUsageForTab: async () => context,
EffortForTab: async () => effort,
BalanceForTab: async () => balance,
JobsForTab: async () => jobs,
CheckpointsForTab: async () => checkpoints,
HistoryForTab: async () => historyGate.promise,
HistoryPageForTab: async (tabId: string) => {
historyCalls += 1;
const messages = await historyGate.promise;
return {
messages,
startTurn: 0,
endTurn: messages.filter((message) => message.role === "user").length,
totalTurns: messages.filter((message) => message.role === "user").length,
hasOlder: false,
};
},
HistoryCheckpointTurnsForTab: async () => [],
ReplayPendingPrompts: async () => {},
SetToolApprovalModeForTab: async () => {
approvalModeCalls += 1;
},
} as Partial<AppBindings> as AppBindings,
},
};
type Controller = ReturnType<typeof useController>;
let controller: Controller | undefined;
function Probe() {
controller = useController();
return null;
}
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(<Probe />);
await flushPromises();
});
await waitFor("initial not-ready metadata", () => controller?.activeTabId === "tab-ready" && controller.state.meta?.ready === false);
eq(historyCalls, 1, "startup begins one active-tab history hydration");
eq(listTabsCalls, 1, "startup fetches the active tab once");
backendReady = true;
await waitFor("ready metadata is reconciled without a ready event", () => controller?.state.meta?.ready === true);
ok(metaCalls >= 1, "active tab metadata is polled after a missed ready event");
ok(metaTabIds.length > 0 && metaTabIds.every((tabId) => tabId === "tab-ready"), "ready polling is limited to the active tab");
eq(listTabsCalls, 1, "ready polling does not re-list or activate tabs");
eq(historyCalls, 1, "ready polling does not start another history hydration");
eq(approvalModeCalls, 0, "ready polling does not rely on approval-mode changes");
await act(async () => {
historyGate.resolve([{ role: "user", content: "hello" }]);
await historyGate.promise;
await flushPromises();
});
await waitFor("history finishes", () => controller?.state.hydrating === false);
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello") ?? false, "history still hydrates after ready reconciliation");
await act(async () => {
root.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,35 @@
// Run: tsx src/__tests__/recovery-banner-privacy.test.ts
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
let passed = 0;
let failed = 0;
function ok(cond: boolean, label: string) {
if (cond) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
const here = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
console.log("\nrecovery banner privacy");
const recoveryBannerMatch = /<div className="banner banner--recovery"[\s\S]*?<\/div>/.exec(appSource);
const recoveryBanner = recoveryBannerMatch?.[0] ?? "";
ok(Boolean(recoveryBannerMatch), "App renders a recovery banner");
ok(!recoveryBanner.includes("title="), "recovery banner does not expose internals through a tooltip");
ok(!recoveryBanner.includes("recoveryDigest"), "recovery banner does not expose recovery digest");
ok(!recoveryBanner.includes("recoveryParentId"), "recovery banner does not expose parent session id");
ok(!appSource.includes("recoveryBannerTitle"), "App no longer builds a detailed recovery tooltip");
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -6,6 +6,9 @@ import { act } from "react";
import { createRoot } from "react-dom/client";
import {
SettingsPanel,
formatProviderExtraBody,
parseProviderExtraBody,
providerExtraBodyParseError,
providerBaseURLFromChatURL,
providerChatURLPreview,
providerEditorEffectiveKind,
@@ -58,18 +61,52 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
autoPlan: "off",
providers: [],
officialProviders: [],
providerPresets: [],
permissions: { mode: "ask", allow: [], ask: [], deny: [] },
sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], shell: "auto" },
sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], effectiveWorkspaceRoot: "/work", effectiveWriteRoots: ["/work"], shell: "auto" },
network: { proxyMode: "auto", proxyUrl: "", noProxy: "", proxy: { type: "socks5", server: "", port: 0, username: "", password: "" } },
agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" },
agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" },
bot: {
enabled: false,
model: "",
toolApprovalMode: "",
maxSteps: 0,
debounceMs: 0,
allowlist: { enabled: false, allowAll: false, qqUsers: [], feishuUsers: [], weixinUsers: [], qqGroups: [], feishuGroups: [], weixinGroups: [] },
qq: { enabled: false, appId: "", appSecretEnv: "", secretSet: false, sandbox: false },
toolApprovalMode: "",
maxSteps: 0,
debounceMs: 0,
queueMode: "steer",
queueCap: 20,
queueDrop: "summarize",
ignoreSelfMessages: true,
selfUserIds: { qq: [], feishu: [], weixin: [] },
control: { enabled: false, addr: "127.0.0.1:37913", tokenEnv: "REASONIX_BOT_CONTROL_TOKEN" },
pairing: { enabled: true, requestTtlMinutes: 60, maxPendingPerPlatform: 3 },
routes: [],
allowlist: {
enabled: false,
allowAll: false,
qqUsers: [],
feishuUsers: [],
weixinUsers: [],
qqApprovers: [],
feishuApprovers: [],
weixinApprovers: [],
qqAdmins: [],
feishuAdmins: [],
weixinAdmins: [],
qqGroups: [],
feishuGroups: [],
weixinGroups: [],
},
qq: {
enabled: false,
appId: "",
appSecretEnv: "",
secretSet: false,
sandbox: false,
model: "",
toolApprovalMode: "ask",
workspaceRoot: "",
access: { enabled: true, allowAll: false, pairingEnabled: true, users: [], groups: [], approvers: [], admins: [] },
},
feishu: { enabled: false, domain: "feishu", appId: "", appSecretEnv: "", secretSet: false, verificationToken: "", mode: "webhook", webhookPort: 0, requireMention: false },
weixin: { enabled: false, accountId: "", tokenEnv: "", tokenSet: false, apiBase: "" },
connections: [],
@@ -96,11 +133,40 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
console.log("\nsettings refresh snapshot");
eq(providerEditorEffectiveKind(true, "anthropic", ["anthropic", "openai"]), "openai", "new custom providers ignore sorted providerKinds and default to OpenAI");
eq(providerEditorEffectiveKind(true, "anthropic", ["anthropic", "openai"]), "anthropic", "new custom providers keep the selected Anthropic-compatible kind");
eq(providerEditorEffectiveKind(false, "anthropic", ["anthropic", "openai"]), "anthropic", "existing providers preserve their stored kind");
eq(providerChatURLPreview("https://proxy.example.com/v1", "", false), "https://proxy.example.com/v1/chat/completions", "base URL mode previews chat completions URL");
eq(providerChatURLPreview("", "https://proxy.example.com/custom/chat", true), "https://proxy.example.com/custom/chat", "full URL mode previews configured URL");
eq(providerBaseURLFromChatURL("https://proxy.example.com/v1/chat/completions"), "https://proxy.example.com/v1", "chat URL derives base URL for model discovery");
eq(formatProviderExtraBody({ top_p: 0.7, enable_thinking: true }), "{\n \"enable_thinking\": true,\n \"top_p\": 0.7\n}", "extra body editor formats stable JSON");
eq(JSON.stringify(parseProviderExtraBody('{ "enable_thinking": true, "top_p": 0.7 }')), "{\"enable_thinking\":true,\"top_p\":0.7}", "extra body editor parses JSON object");
let extraBodyRejected = false;
try {
parseProviderExtraBody("[true]");
} catch {
extraBodyRejected = true;
}
ok(extraBodyRejected, "extra body editor rejects non-object JSON");
const extraBodyTestT = ((key: string, vars?: Record<string, string | number>) => {
if (key === "settings.providerExtraBodyError") return "localized extra body fallback";
if (key === "settings.providerExtraBodyNull") return `${vars?.path} localized null`;
return key;
}) as any;
eq(
providerExtraBodyParseError(new SyntaxError("Unexpected token } in JSON"), extraBodyTestT),
"localized extra body fallback",
"extra body editor localizes JSON syntax errors",
);
try {
parseProviderExtraBody('{ "nested": { "value": null } }', extraBodyTestT);
ok(false, "extra body editor rejects localized null validation errors");
} catch (e) {
eq(
providerExtraBodyParseError(e, extraBodyTestT),
"extra_body.nested.value localized null",
"extra body editor keeps localized structured validation errors",
);
}
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
@@ -120,6 +186,7 @@ globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
window.scrollTo = () => {};
localStorage.clear();
const settingsSnapshots = [baseSettings("standard"), baseSettings("compact")];
let settingsCalls = 0;
@@ -146,6 +213,7 @@ await act(async () => {
<LocaleProvider>
<SettingsPanel
initialTab="general"
desktopPlatform="linux"
onClose={() => {}}
onChanged={(settings?: SettingsView) => {
onChangedSettings = settings;
@@ -193,6 +261,7 @@ await act(async () => {
<LocaleProvider>
<SettingsPanel
initialTab="general"
desktopPlatform="linux"
onClose={() => {}}
onChanged={() => {}}
/>
@@ -220,6 +289,137 @@ ok(document.body.textContent?.includes("Settings could not be loaded.") === fals
await act(async () => {
retryRoot.unmount();
});
const zoomRootEl = document.createElement("div");
document.body.appendChild(zoomRootEl);
const zoomRoot = createRoot(zoomRootEl);
let persistedZoom = 0.5;
const savedZoomFactors: number[] = [];
window.go = {
main: {
App: {
Settings: async () => baseSettings("standard"),
GetDesktopZoomFactor: async () => persistedZoom,
SetDesktopZoomFactor: async (factor: number) => {
persistedZoom = factor;
savedZoomFactors.push(factor);
},
} as Partial<AppBindings> as AppBindings,
},
};
localStorage.setItem("reasonix-zoom-restart", "1");
await act(async () => {
zoomRoot.render(
<LocaleProvider>
<SettingsPanel
initialTab="appearance"
desktopPlatform="windows"
onClose={() => {}}
onChanged={() => {}}
/>
</LocaleProvider>,
);
await flushPromises();
});
await waitFor("persisted display zoom sync", () => document.querySelector(".zoom-slider__value")?.textContent?.trim() === "50%");
const resetZoomButton = document.querySelector("button[aria-label='Reset display zoom to 100%']") as HTMLButtonElement | null;
if (!resetZoomButton) throw new Error("display zoom reset button did not render");
await act(async () => {
resetZoomButton.click();
await flushPromises();
});
await waitFor("display zoom reset", () => document.querySelector(".zoom-slider__value")?.textContent?.trim() === "100%");
eq(savedZoomFactors.at(-1), 1, "display zoom reset writes the default zoom factor");
eq(localStorage.getItem("reasonix-zoom-restart"), "1", "display zoom reset updates the local restart zoom cache");
await act(async () => {
zoomRoot.unmount();
});
// Bots tab: direct four-channel bot manager.
const botsRootEl = document.createElement("div");
document.body.appendChild(botsRootEl);
const botsRoot = createRoot(botsRootEl);
const botsSettings = baseSettings("standard");
botsSettings.bot.connections = [
{
id: "conn-feishu-1",
provider: "feishu",
domain: "feishu",
label: "kun",
enabled: true,
status: "connected",
model: "",
toolApprovalMode: "",
workspaceRoot: "",
credential: { appId: "cli_mock", appSecretEnv: "FEISHU_BOT_APP_SECRET", accountId: "", tokenEnv: "", secretSet: true },
sessionMappings: [],
lastError: "",
createdAt: "",
updatedAt: "",
access: { enabled: true, allowAll: false, pairingEnabled: true, users: ["ou_mock_user_001"], groups: [], approvers: [], admins: [] },
},
];
window.go = {
main: {
App: {
Settings: async () => botsSettings,
} as Partial<AppBindings> as AppBindings,
},
};
await act(async () => {
botsRoot.render(
<LocaleProvider>
<SettingsPanel initialTab="bots" desktopPlatform="linux" onClose={() => {}} onChanged={() => {}} />
</LocaleProvider>,
);
await flushPromises();
});
await waitFor("bot channel manager", () => Boolean(document.querySelector(".bot-channel-manager")));
ok(!document.querySelector(".bot-overview-grid"), "bots tab does not render the removed entry overview");
ok(!document.getElementById("bot-mobile-remote"), "bots tab no longer renders the mobile remote entry card");
ok(!document.querySelector(".bot-channel-entry"), "bots tab no longer renders the Bot Channel entry panel");
ok(!document.getElementById("bot-step-access"), "bots tab omits the old global access step card");
ok(!document.getElementById("bot-step-behavior"), "bots tab omits global default behavior card");
eq(document.querySelectorAll(".bot-step-chip").length, 0, "hero no longer shows the old two-step chips");
eq(document.querySelectorAll(".bot-channel-tabs [role=\"tab\"]").length, 4, "bot manager uses four fixed channel tabs on the left");
ok(document.querySelector(".bot-channel-setup-card")?.textContent?.includes("Configure QQ") === true, "unconfigured QQ tab shows key setup on the right");
ok(document.body.textContent?.includes("Back to entry") === false, "bot manager does not show a return-to-entry action");
const feishuTab = Array.from(document.querySelectorAll(".bot-channel-tabs [role=\"tab\"]")).find((button) => button.textContent?.includes("Feishu")) as HTMLButtonElement | undefined;
if (!feishuTab) throw new Error("Feishu channel tab did not render");
await act(async () => {
feishuTab.click();
await flushPromises();
});
await waitFor("selected Feishu detail", () => Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-card")));
ok(Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-card")), "configured channel renders selected bot detail on the right");
ok(Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-section--access")), "selected bot detail owns its access control");
ok(document.body.textContent?.includes("Access control") === true, "selected bot detail labels per-bot access control");
const selectedBotDetailText = document.querySelector(".bot-channel-manager__detail .bot-detail-card")?.textContent ?? "";
const connectionSummaryIndex = selectedBotDetailText.indexOf("Connection summary");
const enableBotIndex = selectedBotDetailText.indexOf("Enable bot");
const toolApprovalIndex = selectedBotDetailText.indexOf("Tool approval");
const modelIndex = selectedBotDetailText.indexOf("Model");
const accessControlIndex = selectedBotDetailText.indexOf("Access control");
ok(
connectionSummaryIndex >= 0 && enableBotIndex > connectionSummaryIndex && toolApprovalIndex > enableBotIndex && modelIndex > toolApprovalIndex && accessControlIndex > modelIndex,
"selected bot detail places enable, approval, and model controls between summary and access control",
);
ok(document.body.textContent?.includes("ou_mock_user_001") === true, "selected bot detail shows its trusted user");
ok(document.body.textContent?.includes("Legacy global allowlist") === true, "advanced area keeps the legacy global allowlist");
ok(document.querySelector(".bot-simple-advanced")?.textContent?.includes("local control API") === false, "advanced area no longer owns mobile/control API setup");
await act(async () => {
botsRoot.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
@@ -84,5 +84,10 @@ console.log("\nstatus bar workspace");
ok(!html.includes("后台作业") && !html.includes("Background jobs"), "status bar omits non-configurable job indicators");
}
{
const defaultItems = DEFAULT_STATUS_BAR_ITEMS as readonly string[];
ok(!defaultItems.includes("autoresearch"), "autoresearch is not a configurable status bar UI item");
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -130,23 +130,40 @@ const tabA = tabMeta("tab-a", { active: true });
const tabB = tabMeta("tab-b");
const tabC = tabMeta("tab-c");
const tabD = tabMeta("tab-d");
const tabE = tabMeta("tab-e");
const tabF = tabMeta("tab-f");
const tabG = tabMeta("tab-g");
const tabH = tabMeta("tab-h");
let backendActiveId = "tab-a";
const historyB = deferred<HistoryMessage[]>();
const historyD = deferred<HistoryMessage[]>();
let metaH = deferred<Meta>();
let historyH = deferred<HistoryMessage[]>();
const contextDGate = deferred<ContextInfo>();
const setActiveBGate = deferred<void>();
const setActiveEGate = deferred<void>();
const setActiveFGate = deferred<void>();
const submitTabCGate = deferred<void>();
const historyCalls: string[] = [];
const cancelCalls: string[] = [];
let contextDCalls = 0;
let metaHCalls = 0;
let holdNextContextForD = false;
let holdNextMetaForH = false;
let holdNextHistoryForH = false;
let setActiveCalls = 0;
let newSessionCalls = 0;
let failSetActiveFor = "";
const runningTabs = new Set<string>();
const tabsById = new Map([tabA, tabB, tabC, tabD].map((tab) => [tab.id, tab]));
const tabsById = new Map([tabA, tabB, tabC, tabD, tabE, tabF, tabG, tabH].map((tab) => [tab.id, tab]));
const eventHandlers: Array<(e: WireEvent) => void> = [];
const readyHandlers: Array<(tabId?: string) => void> = [];
function currentTabs(): TabMeta[] {
return [tabA, tabB, tabC, tabD].map((tab) => ({ ...tab, active: tab.id === backendActiveId, running: runningTabs.has(tab.id) }));
return Array.from(tabsById.values()).map((tab) => {
const running = runningTabs.has(tab.id);
return { ...tab, active: tab.id === backendActiveId, running, cancellable: running };
});
}
window.runtime = {
@@ -161,7 +178,14 @@ window.go = {
main: {
App: {
ListTabs: async () => currentTabs(),
MetaForTab: async (tabID: string) => metaFor(tabsById.get(tabID) ?? tabA),
MetaForTab: async (tabID: string) => {
if (tabID === "tab-h" && holdNextMetaForH) {
metaHCalls += 1;
holdNextMetaForH = false;
return metaH.promise;
}
return metaFor(tabsById.get(tabID) ?? tabA);
},
ContextUsageForTab: async (tabID: string) => {
if (tabID === "tab-d" && holdNextContextForD) {
contextDCalls += 1;
@@ -179,6 +203,13 @@ window.go = {
historyCalls.push(tabID);
if (tabID === "tab-b") return historyB.promise;
if (tabID === "tab-d") return historyD.promise;
if (tabID === "tab-e") return [userMessage("fork E")];
if (tabID === "tab-g") return [userMessage("history G")];
if (tabID === "tab-h" && holdNextHistoryForH) {
holdNextHistoryForH = false;
return historyH.promise;
}
if (tabID === "tab-h") return [userMessage("history H")];
return [userMessage("cached A")];
},
HistoryPageForTab: async (tabID: string) => {
@@ -186,21 +217,41 @@ window.go = {
return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false };
},
HistoryCheckpointTurnsForTab: async () => [],
OpenProjectTab: async () => {
backendActiveId = "tab-d";
return { ...(tabsById.get("tab-d") ?? tabD), active: true };
OpenProjectTab: async (workspaceRoot: string, topicId: string) => {
const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === workspaceRoot && tab.topicId === topicId) ?? tabD;
backendActiveId = target.id;
return { ...target, active: true };
},
ActivateTopic: async (_scope: string, workspaceRoot: string, topicId: string) => {
const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === workspaceRoot && tab.topicId === topicId) ?? tabG;
backendActiveId = target.id;
return { ...target, active: true };
},
NewSession: async () => {
newSessionCalls += 1;
},
Fork: async () => {
tabsById.set("tab-e", tabE);
backendActiveId = "tab-e";
runningTabs.add("tab-e");
return { ...tabE, active: true, running: true };
},
ReplayPendingPrompts: async () => {},
SetActiveTab: async (tabID: string) => {
setActiveCalls += 1;
if (tabID === "tab-b") await setActiveBGate.promise;
if (tabID === "tab-e") await setActiveEGate.promise;
if (tabID === "tab-f") await setActiveFGate.promise;
if (tabID === failSetActiveFor) throw new Error("persist failed");
backendActiveId = tabID;
},
CancelTab: async (tabID: string) => {
cancelCalls.push(tabID);
runningTabs.delete(tabID);
},
SubmitToTab: async (tabID: string) => {
runningTabs.add(tabID);
if (tabID === "tab-c") await submitTabCGate.promise;
},
SubmitDisplayToTab: async (tabID: string) => {
runningTabs.add(tabID);
@@ -275,6 +326,93 @@ eq(controller?.activeTabId, "tab-a", "late history for another tab does not chan
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "cached A") ?? false, "late history for another tab does not overwrite the active transcript");
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "late B") ?? false), "late history stays scoped to its tab state");
const historyCallsBeforeFallbackSync = historyCalls.length;
await act(async () => {
for (const handler of eventHandlers) handler({ kind: "approval_request", tabId: "tab-b", approval: { id: "stale-fallback-approval", tool: "bash", subject: "stale fallback approval" } });
await flushPromises();
});
backendActiveId = "tab-b";
await act(async () => {
await controller?.syncActiveTab(false);
await flushPromises();
});
eq(controller?.activeTabId, "tab-b", "backend fallback sync activates the backend-selected cached tab");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "late B") ?? false, "backend fallback sync keeps the cached transcript");
eq(historyCalls.length, historyCallsBeforeFallbackSync, "backend fallback sync preserves cached history instead of reloading it");
eq(controller?.state.approval?.id, undefined, "backend fallback sync reconciles stale approval state");
eq(controller?.state.running, false, "backend fallback sync reconciles stale running state");
await act(async () => {
await controller?.switchTab("tab-a", tabA);
await flushPromises();
});
await waitFor("tab-a restored after fallback sync", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
runningTabs.add("tab-e");
let switchToE: Promise<TabMeta[] | undefined> | undefined;
await act(async () => {
switchToE = controller?.switchTab("tab-e", { ...tabE, running: true, cancellable: true });
await flushPromises();
});
eq(controller?.activeTabId, "tab-e", "switching to a backend-running tab updates the active tab immediately");
eq(controller?.state.running, true, "backend-running tab restores the stop state before backend activation settles");
eq(controller?.state.cancellable, true, "backend-running tab remains cancellable before backend activation settles");
await act(async () => {
controller?.cancel();
await flushPromises();
});
eq(cancelCalls.join(","), "tab-e", "cancel targets the backend-running tab while activation is pending");
await act(async () => {
setActiveEGate.resolve();
await switchToE;
await flushPromises();
});
eq(controller?.state.running, false, "cancelled backend-running tab reconciles to idle after activation");
await act(async () => {
await controller?.switchTab("tab-a", tabA);
await flushPromises();
});
await waitFor("tab-a restored after backend-running switch", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
let switchToF: Promise<TabMeta[] | undefined> | undefined;
await act(async () => {
switchToF = controller?.switchTab("tab-f", tabF);
await flushPromises();
});
eq(controller?.activeTabId, "tab-f", "first rapid switch activates the slow target optimistically");
let switchToG: Promise<TabMeta[] | undefined> | undefined;
await act(async () => {
switchToG = controller?.switchTab("tab-g", tabG);
await switchToG;
await flushPromises();
});
eq(controller?.activeTabId, "tab-g", "second rapid switch wins immediately");
eq(backendActiveId, "tab-g", "second rapid switch activates the backend");
await act(async () => {
setActiveFGate.resolve();
await switchToF;
await flushPromises();
});
eq(controller?.activeTabId, "tab-g", "late completion from the first rapid switch does not replace the visible tab");
eq(backendActiveId, "tab-g", "late completion from the first rapid switch reasserts the last-clicked backend tab");
ok(!historyCalls.includes("tab-f"), "late completion from the first rapid switch does not hydrate the stale target");
await act(async () => {
await controller?.switchTab("tab-a", tabA);
await flushPromises();
});
await waitFor("tab-a restored after rapid switch", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
failSetActiveFor = "tab-b";
const historyCallsBeforeFailedSwitch = historyCalls.length;
await act(async () => {
await controller?.switchTab("tab-b", tabB);
await flushPromises();
});
eq(controller?.activeTabId, "tab-a", "failed backend tab switch reverts to the previous active tab");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "cached A") ?? false, "failed backend tab switch keeps the previous transcript visible");
eq(historyCalls.length, historyCallsBeforeFailedSwitch, "failed backend tab switch does not hydrate the rejected target");
failSetActiveFor = "";
await act(async () => {
for (const handler of eventHandlers) handler({ kind: "phase", text: "Planner is thinking", tabId: "tab-a" });
for (const handler of eventHandlers) handler({ kind: "message", text: "Planner kept", reasoning: "Planner notes", tabId: "tab-a" });
@@ -293,10 +431,15 @@ eq(historyCalls.length, historyCallsBeforeReady, "agent ready with cached transc
ok(controller?.state.items.some((item) => item.kind === "phase" && item.text === "Planner is thinking") ?? false, "agent ready keeps cached planner phase");
ok(controller?.state.items.some((item) => item.kind === "assistant" && item.text === "Planner kept" && item.reasoning === "Planner notes") ?? false, "agent ready keeps cached planner answer");
let tabCSendResolved = false;
await act(async () => {
controller?.sendToTab("tab-c", "streaming C");
const sendPromise = controller?.sendToTab("tab-c", "streaming C");
sendPromise?.then(() => {
tabCSendResolved = true;
});
await flushPromises();
});
eq(tabCSendResolved, true, "sendToTab resolves after optimistic dispatch before backend submit completes");
await act(async () => {
await controller?.switchTab("tab-c", tabC);
await flushPromises();
@@ -304,6 +447,11 @@ await act(async () => {
eq(controller?.activeTabId, "tab-c", "switching to a cached running tab still updates the active tab");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "streaming C") ?? false, "cached running tab keeps its optimistic transcript");
ok(!historyCalls.includes("tab-c"), "cached running tab skips history hydration");
await act(async () => {
submitTabCGate.resolve();
await submitTabCGate.promise;
await flushPromises();
});
holdNextContextForD = true;
await act(async () => {
@@ -342,6 +490,11 @@ ok(controller?.state.items.some((item) => item.kind === "user" && item.text ===
eq(controller?.state.hydratePlaceholderItems?.length ?? 0, 0, "topic history clears the hydration placeholder");
const historyCallsBeforeReopenD = historyCalls.length;
await act(async () => {
for (const handler of eventHandlers) handler({ kind: "approval_request", tabId: "tab-d", approval: { id: "stale-approval", tool: "bash", subject: "stale approval" } });
await controller?.switchTab("tab-a", tabA);
await flushPromises();
});
await act(async () => {
await controller?.openProjectTab(tabD.workspaceRoot, tabD.topicId || "");
await flushPromises();
@@ -349,6 +502,17 @@ await act(async () => {
eq(controller?.activeTabId, "tab-d", "reopening an already hydrated topic keeps it active");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history D") ?? false, "reopened cached topic keeps its transcript");
eq(historyCalls.length, historyCallsBeforeReopenD, "reopening an already hydrated topic skips history hydration");
eq(controller?.state.approval?.id, undefined, "reopening a topic reconciles stale approval state");
eq(controller?.state.running, false, "reopening a topic reconciles stale running state");
await act(async () => {
await controller?.rewind(0, "fork");
await flushPromises();
});
eq(controller?.activeTabId, "tab-e", "fork activates the forked tab");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "fork E") ?? false, "fork loads the forked transcript");
eq(controller?.state.running, true, "fork reconciles backend running state after reset hydration");
runningTabs.delete("tab-e");
const contextCallsBeforeInactiveD = contextDCalls;
await act(async () => {
@@ -370,6 +534,72 @@ await act(async () => {
});
eq(historyCalls.length, historyCallsBeforeReboundD + 1, "rebound topic reloads history when session path changes");
metaH = deferred<Meta>();
holdNextMetaForH = true;
const historyCallsBeforeSlowMeta = historyCalls.length;
await act(async () => {
await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
await flushPromises();
});
await waitFor("slow meta tab hydrates history", () =>
controller?.activeTabId === "tab-h" &&
controller.state.hydrating === false &&
(controller.state.items.some((item) => item.kind === "user" && item.text === "history H") ?? false) &&
metaHCalls === 1
);
eq(historyCalls.length, historyCallsBeforeSlowMeta + 1, "slow MetaForTab does not delay the history request");
eq(controller?.state.meta?.label, "model-tab-h", "slow MetaForTab leaves optimistic metadata visible while history hydrates");
await act(async () => {
metaH.resolve({ ...metaFor(tabH), label: "fresh-model-tab-h" });
await metaH.promise;
await flushPromises();
});
await waitFor("slow meta refresh applies", () => controller?.state.meta?.label === "fresh-model-tab-h");
metaH = deferred<Meta>();
const metaHCallsBeforeStale = metaHCalls;
holdNextMetaForH = true;
await act(async () => {
await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
await flushPromises();
});
await waitFor("slow meta pending before single-surface navigation", () => metaHCalls === metaHCallsBeforeStale + 1);
await act(async () => {
await controller?.activateTopic("project", tabG.workspaceRoot, tabG.topicId || "");
await flushPromises();
});
await waitFor("single-surface activation replaces visible tab", () =>
controller?.activeTabId === "tab-g" &&
(controller.state.items.some((item) => item.kind === "user" && item.text === "history G") ?? false)
);
await act(async () => {
metaH.resolve({ ...metaFor(tabH), label: "stale-model-tab-h" });
await metaH.promise;
await flushPromises();
});
eq(controller?.activeTabId, "tab-g", "late meta from a replaced tab does not switch the visible tab");
ok(controller?.state.meta?.label !== "stale-model-tab-h", "late meta from a replaced tab does not overwrite visible metadata");
historyH = deferred<HistoryMessage[]>();
holdNextHistoryForH = true;
await act(async () => {
await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
await flushPromises();
});
await waitFor("reopened tab-h treats stale late meta as discarded", () =>
controller?.activeTabId === "tab-h" &&
controller.state.hydrating === true &&
(controller.state.hydratePlaceholderItems?.some((item) => item.kind === "user" && item.text === "history G") ?? false)
);
await act(async () => {
historyH.resolve([userMessage("history H after stale meta")]);
await historyH.promise;
await flushPromises();
});
await waitFor("reopened tab-h finishes after stale meta discard", () =>
controller?.state.hydrating === false &&
(controller.state.items.some((item) => item.kind === "user" && item.text === "history H after stale meta") ?? false)
);
await act(async () => {
root.unmount();
});
@@ -3,7 +3,7 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { buildStepGroups, buildTurnGroups, createWarmLayerState, questionTurnsById, warmColdPageForTurn, warmLayerForSession, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination } from "../lib/transcriptGrouping";
import { buildStepGroups, buildTurnGroups, createWarmLayerState, lastQuestionTurn, questionTurnsById, warmColdPageForTurn, warmLayerForSession, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination } from "../lib/transcriptGrouping";
import type { Item } from "../lib/useController";
let passed = 0;
@@ -105,6 +105,22 @@ console.log("\ntranscript grouping contract");
eq(backendTurns.get("u0"), 0, "uses backend checkpoint turn zero when present");
eq(backendTurns.get("u2"), 3, "uses non-contiguous backend checkpoint turn");
ok(!backendTurns.has("u1"), "does not mix visible ordinal fallback into authoritative checkpoint sessions");
eq(lastQuestionTurn([
{ id: "u0", text: "first", turn: 0 },
{ id: "u1", text: "second", turn: 1 },
], visibleTurns), 1, "last question turn follows visible ordinal fallback");
eq(lastQuestionTurn([
{ id: "u0", text: "first", turn: 0, checkpointTurn: 0 },
{ id: "u1", text: "live without server stamp yet", turn: 1 },
{ id: "u2", text: "after hidden synthetic", turn: 2, checkpointTurn: 3 },
], backendTurns), 3, "last question turn follows non-contiguous backend turn");
const pagedTurns = questionTurnsById([
{ id: "u-recent", text: "recent prompt", turn: 0, checkpointTurn: 1060 },
]);
eq(lastQuestionTurn([
{ id: "u-recent", text: "recent prompt", turn: 0, checkpointTurn: 1060 },
], pagedTurns), 1060, "last question turn supports paged history windows");
}
{
@@ -0,0 +1,51 @@
// Run: tsx src/__tests__/turn-action-copy.test.ts
import { appendTurnActionCopyText } from "../lib/turnActionCopy";
let passed = 0;
let failed = 0;
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n`);
failed += 1;
}
}
console.log("\nturn action copy");
eq(
appendTurnActionCopyText("", "final answer"),
"final answer",
"keeps single assistant output unchanged",
);
eq(
appendTurnActionCopyText("first assistant block", "second assistant block"),
"first assistant block\n\nsecond assistant block",
"accumulates multiple assistant outputs in one turn",
);
eq(
appendTurnActionCopyText("first assistant block\n", "second assistant block"),
"first assistant block\nsecond assistant block",
"does not add an extra blank line when the previous block already ends with newline",
);
eq(
appendTurnActionCopyText("first assistant block", "\nsecond assistant block"),
"first assistant block\nsecond assistant block",
"does not add an extra blank line when the next block starts with newline",
);
eq(
appendTurnActionCopyText("final answer", " \n\t"),
"final answer",
"ignores whitespace-only assistant fragments",
);
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -113,6 +113,10 @@ eq(
"var(--statusbar-dock-height)",
"status bar min-height follows the scaled dock token",
);
eq(finalDeclaration(".provider-template-grid", "grid-auto-rows"), "92px", "provider preset cards use compact equal-height grid rows");
eq(finalDeclaration(".provider-template-card", "height"), "100%", "provider preset cards stretch to the grid row height");
eq(finalDeclaration(".provider-template-card strong", "-webkit-line-clamp"), "1", "provider preset card titles clamp to one line");
eq(finalDeclaration(".provider-template-card span", "-webkit-line-clamp"), "2", "provider preset card descriptions clamp to two lines");
eq(finalDeclaration(".statusbar", "white-space"), "nowrap", "status bar keeps metrics on one row");
eq(finalDeclaration(".statusbar", "overflow"), "hidden", "status bar clips instead of overflowing");
@@ -160,6 +164,16 @@ ok(
);
eq(finalDeclaration(".composer-modebar", "overflow"), "hidden", "chat mode switcher contains enlarged labels");
eq(
finalDeclaration(".app--creation .tool:not(.tool--open) > .tool__body", "height"),
"0 !important",
"collapsed creation tool bodies keep mounted content clipped",
);
eq(
finalDeclaration(".app--creation .tool:not(.tool--open) > .tool__body", "visibility"),
"hidden",
"collapsed creation tool bodies do not paint hidden tool text",
);
ok(
/@container\s*\(max-width:\s*760px\)[\s\S]*?\.composer-meta__control--model\s*\{[\s\S]*?flex\s*:\s*0 1 auto[\s\S]*?width\s*:\s*fit-content[\s\S]*?max-width\s*:\s*min\(240px,\s*42vw\)[\s\S]*?\.composer-meta--has-intent-chip\s+\.composer-meta__control--model\s*\{[\s\S]*?flex\s*:\s*0 1 auto[\s\S]*?width\s*:\s*fit-content[\s\S]*?max-width\s*:\s*min\(220px,\s*38vw\)[\s\S]*?\.composer-meta__control--effort\s*\{[\s\S]*?display\s*:\s*none[\s\S]*?\.composer-meta__control--more\s*\{[\s\S]*?display\s*:\s*inline-flex/.test(styles),
"composer compact controls activate at the capped theme width",
@@ -102,6 +102,7 @@ globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.windo
const eventHandlers: Array<(e: WireEvent) => void> = [];
let backendRunning = false;
let cancelCalls = 0;
let effortCalls = 0;
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
@@ -119,6 +120,10 @@ window.go = {
MetaForTab: async () => meta(),
ContextUsageForTab: async () => context,
EffortForTab: async () => effort,
SetEffortForTab: async () => {
effortCalls += 1;
throw new Error("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing effort");
},
BalanceForTab: async () => ({ available: false, display: "" }),
JobsForTab: async () => [],
CheckpointsForTab: async () => [],
@@ -178,6 +183,15 @@ eq(controller?.state.running, false, "cancel reconciliation clears the running s
eq(cancelCalls, 1, "CancelTab is called once");
eq(controller?.state.cancelRequested, false, "cancel reconciliation clears cancelRequested");
await act(async () => {
await controller?.setEffort("max");
await flushPromises();
});
const effortNotice = controller?.state.items.find((item) => item.kind === "notice" && item.text.includes("cannot change yet"));
eq(effortCalls, 1, "SetEffortForTab is called once");
ok(Boolean(effortNotice), "busy effort switch surfaces a non-failure warning notice");
await act(async () => {
root.unmount();
});
@@ -1,7 +1,7 @@
// Run: tsx src/__tests__/use-controller-meta.test.ts
import { foregroundRunningFromRuntimeMeta, initialState, metaFromTab, reducer, sameMeta, shouldReconcileStaleTurn } from "../lib/useController";
import type { Meta, TabMeta, WireUsage } from "../lib/types";
import { effortSwitchNoticeText, foregroundRunningFromRuntimeMeta, historyMessagesToItems, initialState, localizedBackendNoticeText, metaFromTab, modelSwitchNoticeText, reducer, sameMeta, shouldReconcileStaleTurn, tokenModeSwitchNoticeText } from "../lib/useController";
import type { HistoryMessage, Meta, TabMeta, WireUsage } from "../lib/types";
type LooseTabMeta = Omit<TabMeta, "toolApprovalMode"> & { toolApprovalMode?: TabMeta["toolApprovalMode"] | "" };
@@ -82,6 +82,161 @@ function usage(source: string): WireUsage {
console.log("\nuse controller meta");
{
eq(
modelSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing model"),
"The model cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
"model busy guard is localized",
);
eq(
modelSwitchNoticeText("this session is already open in another Reasonix window or still running in the background; close the other window or open a copy before changing model"),
"This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing models.",
"model lease conflict explains the safe path",
);
eq(
modelSwitchNoticeText("workspace is still starting"),
"This session is still starting. Try changing models again in a moment.",
"model startup race asks the user to retry later",
);
eq(
modelSwitchNoticeText('tab "tab-a" changed while switching model; retry'),
"The current session changed while switching models. Try once more.",
"model tab race asks the user to retry",
);
eq(
modelSwitchNoticeText('unknown model "missing"'),
'Unknown model "missing".',
"model unknown error is localized",
);
eq(
modelSwitchNoticeText('model "other/other-model" is not available because provider "other" is not added'),
'Model "other/other-model" is unavailable because provider "other" is not added.',
"model provider access error is localized",
);
}
{
eq(
effortSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing effort"),
"Reasoning effort cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
"effort busy guard is worded as temporary",
);
eq(
effortSwitchNoticeText("this session is already open in another Reasonix window or still running in the background; close the other window or open a copy before changing effort"),
"This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing effort.",
"effort lease conflict explains the safe path",
);
eq(
effortSwitchNoticeText("workspace is still starting"),
"This session is still starting. Try changing reasoning effort again in a moment.",
"effort startup race asks the user to retry later",
);
eq(
effortSwitchNoticeText('tab "tab-a" changed while switching effort; retry'),
"The current session changed while switching reasoning effort. Try once more.",
"effort tab race asks the user to retry",
);
eq(
effortSwitchNoticeText("unknown model \"missing\""),
"Reasoning effort switch failed: unknown model \"missing\"",
"effort true failure keeps the underlying error",
);
}
{
eq(
tokenModeSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing token mode"),
"Token saver cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
"token mode busy guard is localized",
);
eq(
tokenModeSwitchNoticeText('tab "tab-a" changed while switching token mode; retry'),
"The current session changed while switching token saver. Try once more.",
"token mode tab race asks the user to retry",
);
}
{
eq(
localizedBackendNoticeText("Session autosave failed: disk full"),
"Session autosave failed: disk full",
"backend autosave notice is localized through the active dictionary",
);
eq(
localizedBackendNoticeText("Session save failed before changing model: disk full"),
"Session save failed before changing models: disk full",
"backend save-before-action notice localizes the action",
);
eq(
localizedBackendNoticeText('model "old/model" is no longer available; switched to new/model'),
'Model "old/model" is no longer available; switched to new/model.',
"backend model fallback notice is localized",
);
eq(
localizedBackendNoticeText("session changed on disk; unsaved local transcript was saved as recovery branch 20260706-152144.863947300-longcat-openai-LongCat-2.0-119b7259f151-recovery-693ce51bcbcbaa9"),
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
"legacy recovery branch notice hides internal branch id",
);
eq(
localizedBackendNoticeText("session changed on disk; unsaved local transcript was saved as a conflict copy"),
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
"recovery copy notice is user-facing",
);
eq(
localizedBackendNoticeText("session conflicts kept recurring; kept the transcript on the current recovery branch"),
"Repeated save conflicts were detected, so the current conflict copy was saved in place.",
"legacy repeated recovery conflict notice is user-facing",
);
eq(
localizedBackendNoticeText("repeated save conflicts were detected; saved the current conflict copy in place"),
"Repeated save conflicts were detected, so the current conflict copy was saved in place.",
"repeated recovery conflict notice is user-facing",
);
eq(
localizedBackendNoticeText("session changed on disk; adopted the newer transcript"),
"The session changed on disk, so Reasonix adopted the newer transcript.",
"adopted transcript notice is user-facing",
);
eq(
localizedBackendNoticeText("session changed on disk; adopted the newer transcript (local changes already covered)"),
"The session changed on disk, so Reasonix adopted the newer transcript; the local changes were already covered.",
"covered adopted transcript notice is user-facing",
);
}
{
let s = reducer(initialState, {
type: "event",
e: { kind: "notice", level: "warn", text: "session conflicts kept recurring; kept the transcript on the current recovery branch" },
});
s = reducer(s, {
type: "event",
e: { kind: "notice", level: "warn", text: "repeated save conflicts were detected; saved the current conflict copy in place" },
});
const recoveryNotices = s.items.filter((item) => item.kind === "notice" && item.text.includes("current conflict copy"));
eq(recoveryNotices.length, 1, "repeated recovery conflict notices are collapsed in the live transcript");
eq(s.seq, 1, "collapsed recovery notice does not consume a sequence id");
s = reducer(s, { type: "event", e: { kind: "notice", level: "warn", text: "runtime notice" } });
s = reducer(s, { type: "event", e: { kind: "notice", level: "warn", text: "runtime notice" } });
const ordinaryNotices = s.items.filter((item) => item.kind === "notice" && item.text === "runtime notice");
eq(ordinaryNotices.length, 2, "ordinary repeated notices remain visible");
}
{
const history: HistoryMessage[] = [
{ role: "notice", level: "warn", content: "session conflicts kept recurring; kept the transcript on the current recovery branch" },
{ role: "notice", level: "warn", content: "repeated save conflicts were detected; saved the current conflict copy in place" },
{ role: "user", content: "continue" },
];
const hydrated = historyMessagesToItems(history, "h");
const recoveryNotices = hydrated.items.filter((item) => item.kind === "notice" && item.text.includes("current conflict copy"));
const users = hydrated.items.filter((item) => item.kind === "user");
eq(recoveryNotices.length, 1, "repeated recovery conflict notices are collapsed when hydrating history");
eq(users[0]?.kind === "user" && users[0].id, "h1", "collapsed history notice keeps later item ids compact");
eq(hydrated.seq, 2, "collapsed history notice does not inflate the hydrated sequence");
}
{
eq(sameMeta(meta(), meta()), true, "identical meta is unchanged");
eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "plan" })), false, "collaboration mode changes invalidate meta equality");
@@ -0,0 +1,163 @@
// Run: tsx src/__tests__/use-controller-send-fallback.test.tsx
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { AppBindings } from "../lib/bridge";
import { useController } from "../lib/useController";
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`);
}
function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta {
return {
id: "tab-send",
scope: "project",
workspaceRoot: "/repo/send",
workspaceName: "send",
workspacePath: "/repo/send",
gitBranch: "main",
topicId: "topic-send",
topicTitle: "Send",
label: "model-send",
ready: true,
running: false,
mode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
active: true,
cwd: "/repo/send",
...overrides,
};
}
function metaFor(tab: TabMeta): Meta {
return {
label: tab.label,
ready: tab.ready,
startupErr: tab.startupErr,
eventChannel: "agent:event",
cwd: tab.cwd || tab.workspaceRoot,
workspaceRoot: tab.workspaceRoot,
workspaceName: tab.workspaceName,
workspacePath: tab.workspacePath,
gitBranch: tab.gitBranch,
autoApproveTools: false,
bypass: false,
collaborationMode: tab.collaborationMode ?? "normal",
toolApprovalMode: tab.toolApprovalMode ?? "ask",
tokenMode: tab.tokenMode ?? "full",
goal: "",
goalStatus: "stopped",
};
}
console.log("\nuse controller send fallback");
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
const backendTab = tabMeta({ backgroundJobs: 2 });
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
const balance: BalanceInfo = { available: false, display: "" };
const jobs: JobView[] = [];
const checkpoints: CheckpointMeta[] = [];
let tabsAvailable = false;
let submitCalls = 0;
window.runtime = {
EventsOn: () => () => {},
BrowserOpenURL: () => {},
};
window.go = {
main: {
App: {
ListTabs: async () => (tabsAvailable ? [backendTab] : []),
MetaForTab: async () => metaFor(backendTab),
ContextUsageForTab: async () => context,
EffortForTab: async () => effort,
BalanceForTab: async () => balance,
JobsForTab: async () => jobs,
CheckpointsForTab: async () => checkpoints,
HistoryForTab: async (): Promise<HistoryMessage[]> => [],
HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
HistoryCheckpointTurnsForTab: async () => [],
ReplayPendingPrompts: async () => {},
SubmitToTab: async (tabId: string) => {
submitCalls += tabId === "tab-send" ? 1 : 0;
},
} as Partial<AppBindings> as AppBindings,
},
};
type Controller = ReturnType<typeof useController>;
let controller: Controller | undefined;
function Probe() {
controller = useController();
return null;
}
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(<Probe />);
await flushPromises();
});
eq(controller?.activeTabId, undefined, "startup has no active tab when backend has no tabs");
tabsAvailable = true;
await act(async () => {
await controller?.send("hello from fallback");
await flushPromises();
});
eq(controller?.activeTabId, "tab-send", "send fallback activates the backend-selected tab");
eq(controller?.state.backgroundJobs, 2, "send fallback reconciles backend runtime metadata");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello from fallback") ?? false, "send fallback keeps the optimistic user turn");
eq(submitCalls, 1, "send fallback submits to the activated tab");
await act(async () => {
root.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,124 @@
// Run: tsx src/__tests__/wails-resize-fix.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { useWailsResizeFix } from "../lib/useWailsResizeFix";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) {
ok(true, label);
} else {
ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
function installDom(): JSDOM {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.MouseEvent = dom.window.MouseEvent;
Object.defineProperty(window, "innerWidth", { configurable: true, value: 100 });
Object.defineProperty(window, "innerHeight", { configurable: true, value: 80 });
return dom;
}
function installWailsFlags(flags?: Partial<NonNullable<Window["wails"]>["flags"]>) {
window.wails = {
Callback: () => undefined,
EventsNotify: () => undefined,
flags: {
enableResize: true,
resizeEdge: undefined,
borderThickness: 6,
defaultCursor: null,
cssDragProperty: "--wails-draggable",
cssDragValue: "drag",
cssDropProperty: "--wails-drop-target",
cssDropValue: "drop",
shouldDrag: false,
deferDragToMouseMove: false,
disableScrollbarDrag: false,
disableDefaultContextMenu: false,
enableWailsDragAndDrop: false,
...flags,
},
};
}
function Harness({ enabled }: { enabled: boolean }) {
useWailsResizeFix(enabled);
return null;
}
async function renderHarness(root: Root, enabled: boolean) {
await act(async () => {
root.render(<Harness enabled={enabled} />);
});
}
async function unmount(root: Root) {
await act(async () => {
root.unmount();
});
}
console.log("\nwails resize fix");
{
const dom = installDom();
installWailsFlags({ enableResize: true, resizeEdge: "n-resize" });
const root = createRoot(document.getElementById("root")!);
await renderHarness(root, false);
window.dispatchEvent(new MouseEvent("mousemove", { clientX: 97, clientY: 40 }));
eq(window.wails?.flags.enableResize, true, "disabled hook leaves Wails resize enabled");
eq(window.wails?.flags.resizeEdge, "n-resize", "disabled hook leaves resize edge unchanged");
await unmount(root);
dom.window.close();
}
{
const dom = installDom();
document.documentElement.style.cursor = "grab";
installWailsFlags({ enableResize: true, resizeEdge: "n-resize" });
const root = createRoot(document.getElementById("root")!);
await renderHarness(root, true);
eq(window.wails?.flags.enableResize, false, "enabled hook disables Wails built-in mousemove resize");
window.dispatchEvent(new MouseEvent("mousemove", { clientX: 97, clientY: 40 }));
eq(window.wails?.flags.resizeEdge, "e-resize", "enabled hook detects right edge using innerWidth CSS pixels");
eq(document.documentElement.style.cursor, "e-resize", "enabled hook mirrors detected edge cursor");
await unmount(root);
eq(window.wails?.flags.enableResize, true, "cleanup restores previous enableResize");
eq(window.wails?.flags.resizeEdge, "n-resize", "cleanup restores previous resizeEdge");
eq(document.documentElement.style.cursor, "grab", "cleanup restores previous cursor");
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -7,7 +7,7 @@ import { createRoot } from "react-dom/client";
import { WorkspacePanel } from "../components/WorkspacePanel";
import { LocaleProvider } from "../lib/i18n";
import type { AppBindings } from "../lib/bridge";
import type { WorkspaceChangesView } from "../lib/types";
import type { DirEntry, WorkspaceChangesView } from "../lib/types";
let passed = 0;
let failed = 0;
@@ -101,6 +101,47 @@ async function renderWorkspace(changes: WorkspaceChangesView) {
return { dom, root };
}
async function renderFilesWorkspace(methods: Partial<AppBindings>, props: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) {
const dom = installDom();
window.go = {
main: {
App: {
ListDir: async () => [],
SearchFileRefs: async () => [],
WorkspaceGitHistory: async () => [],
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
...methods,
} as Partial<AppBindings> as AppBindings,
},
};
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
let currentProps: Parameters<typeof WorkspacePanel>[0] = {
open: true,
tabId: "tab-a",
cwd: "/repo",
maximized: false,
initialViewMode: "files",
onClose: () => {},
onToggleMaximized: () => {},
...props,
};
const rerender = async (nextProps: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) => {
currentProps = { ...currentProps, ...nextProps };
await act(async () => {
root.render(
<LocaleProvider>
<WorkspacePanel {...currentProps} />
</LocaleProvider>,
);
await flushPromises();
});
};
await rerender();
return { dom, root, rerender };
}
console.log("\nworkspace changes git errors");
{
@@ -151,5 +192,28 @@ console.log("\nworkspace changes git errors");
dom.window.close();
}
{
const calls: string[] = [];
const listDir = async (dir: string): Promise<DirEntry[]> => {
calls.push(dir);
return [];
};
const { dom, root, rerender } = await renderFilesWorkspace(
{ ListDir: listDir },
{ fileListRequest: { id: 1, paths: ["src/app.ts"] } },
);
await waitFor("initial referenced file dirs", () => calls.filter((dir) => dir === "src/").length === 1);
await rerender({ fileListRequest: { id: 2, paths: ["src/app.ts"] } });
await waitFor("referenced file dirs revalidated", () => calls.filter((dir) => dir === "src/").length === 2);
ok(calls.filter((dir) => dir === "src/").length === 2, "workspace file tree revalidates cached directories for repeated file-list requests");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -70,6 +70,9 @@ eq(
);
eq(finalDeclaration(".workspace-preview__body--code .workspace-note", "flex"), "0 0 auto", "code truncation note keeps its own row");
eq(finalDeclaration(".workspace-preview__body--code .code-block", "display"), "flex", "code block fills the preview viewport");
eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "display"), "flex", "code wrapper keeps the preview viewport height");
eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "flex"), "1 1 auto", "code wrapper participates in the preview flex height");
eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "min-height"), "0", "code wrapper can shrink around the scrollable code viewport");
eq(finalDeclaration(".workspace-preview__body--code .code", "overflow"), "auto", "code viewport owns horizontal and vertical scrolling");
eq(finalDeclaration(".workspace-preview__body--code .code", "min-height"), "0", "code viewport can shrink inside the preview pane");
eq(finalDeclaration(".workspace-preview__body--code .code", "margin"), "0", "code viewport scrollbar sits at the visible pane bottom");
+3 -17
View File
@@ -1,4 +1,4 @@
import { Minus, PanelLeft, PanelRight, Search, Square, X } from "lucide-react";
import { PanelLeft, PanelRight, Search } from "lucide-react";
import { TabBar } from "./TabBar";
import type { TabMeta } from "../lib/types";
import { useT } from "../lib/i18n";
@@ -58,14 +58,13 @@ export function AppChrome({
}: AppChromeProps) {
const t = useT();
const darwinChrome = platform === "darwin";
const showWindowsPreviewControls = browserPreviewChrome && platform === "windows";
const titlebarDragRail = darwinChrome || platform === "windows";
const chromeClassName = [
"app-chrome",
"app-chrome--tabs",
darwinChrome ? "app-chrome--darwin-tabs" : "app-chrome--native-tabs",
workbenchChrome ? "app-chrome--workbench" : "",
!darwinChrome ? "app-chrome--identityless" : "",
showWindowsPreviewControls ? "app-chrome--preview-window-controls" : "",
`app-chrome--platform-${platform}`,
].filter(Boolean).join(" ");
const tabBar = (
@@ -92,7 +91,7 @@ export function AppChrome({
<span />
</div>
)}
{darwinChrome && <span className="app-chrome__drag-rail" aria-hidden="true" />}
{titlebarDragRail && <span className="app-chrome__drag-rail" aria-hidden="true" />}
<button
className={[
"app-chrome__panel-toggle",
@@ -194,19 +193,6 @@ export function AppChrome({
<PanelRight size={16} />
</button>
)}
{showWindowsPreviewControls && (
<div className="app-chrome__window-controls app-chrome__window-controls--windows" aria-hidden="true">
<span className="app-chrome__window-control app-chrome__window-control--minimize">
<Minus size={12} strokeWidth={1.9} />
</span>
<span className="app-chrome__window-control app-chrome__window-control--maximize">
<Square size={10} strokeWidth={1.8} />
</span>
<span className="app-chrome__window-control app-chrome__window-control--close">
<X size={12} strokeWidth={1.9} />
</span>
</div>
)}
</header>
);
}
+152 -19
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import gsap from "gsap";
import { useT } from "../lib/i18n";
import { useT, type Translator } from "../lib/i18n";
import type { ComposerInsertRequest, DirEntry, WireApproval } from "../lib/types";
import { PromptAction, PromptBadge, PromptHeaderAction, PromptShelf } from "./PromptShelf";
import { playAttentionChime } from "../lib/sound";
@@ -27,6 +27,92 @@ function animateShelfExit(
options.onComplete();
}
function requiresFreshHumanApproval(tool: string): boolean {
return tool === "remember" || tool === "forget" || tool === "exit_plan_mode" || tool === "sandbox_escape";
}
function approvalToolLabel(tool: string, t: Translator): string {
switch (tool) {
case "bash":
return t("approval.toolLabelBash");
case "edit_file":
return t("approval.toolLabelEditFile");
case "write_file":
return t("approval.toolLabelWriteFile");
case "multi_edit":
return t("approval.toolLabelMultiEdit");
case "move_file":
return t("approval.toolLabelMoveFile");
case "web_fetch":
return t("approval.toolLabelWebFetch");
case "run_skill":
return t("approval.toolLabelRunSkill");
case "remember":
return t("approval.toolLabelRemember");
case "forget":
return t("approval.toolLabelForget");
case "sandbox_escape":
return t("approval.toolLabelSandboxEscape");
case "plan_mode_read_only_command":
return t("approval.toolLabelPlanModeReadOnly");
default:
return tool;
}
}
const sandboxEscapeEnglishSubjectFallback = "run shell command unconfined once";
const sandboxEscapeEnglishSubjectPrefix = "run unconfined once: ";
const planModeMcpEnglishSubject = /^MCP (.+) as read-only for planning and research$/;
const planModeBashEnglishSubject = /^Trust (.+) as a read-only command prefix while planning\r?\nCommand: ([\s\S]+)$/;
function localizeApprovalSubject(tool: string, subject: string, t: Translator): string {
const trimmed = subject.trim();
if (tool === "sandbox_escape") {
if (!trimmed || trimmed === sandboxEscapeEnglishSubjectFallback) return t("approval.sandboxEscapeSubjectFallback");
if (trimmed.startsWith(sandboxEscapeEnglishSubjectPrefix)) {
return `${t("approval.sandboxEscapeSubjectPrefix")}${trimmed.slice(sandboxEscapeEnglishSubjectPrefix.length)}`;
}
return trimmed;
}
if (tool === "remember") {
return trimmed
.replace(/^Save\/update memory/, t("approval.memorySaveUpdate"))
.replace(/\bbody: /g, `${t("approval.memoryBodyLabel")}: `);
}
if (tool === "forget" && trimmed.startsWith("Archive memory ")) {
return `${t("approval.memoryArchivePrefix")}${trimmed.slice("Archive memory ".length)}`;
}
const mcpTrust = trimmed.match(planModeMcpEnglishSubject);
if (mcpTrust) {
return t("approval.planModeMcpTrustSubject", { target: mcpTrust[1] ?? "" });
}
const bashTrust = trimmed.match(planModeBashEnglishSubject);
if (bashTrust) {
return t("approval.planModeBashTrustSubject", { prefix: bashTrust[1] ?? "", command: bashTrust[2] ?? "" });
}
return trimmed;
}
function localizeApprovalReason(tool: string, reason: string | undefined, t: Translator): string {
const trimmed = reason?.trim() ?? "";
if (tool !== "sandbox_escape") return trimmed;
if (trimmed.includes("could not wrap this command")) return t("approval.sandboxEscapeWrapReason");
if (trimmed.includes("failed while starting this command") || trimmed.includes("Run this command unconfined once?")) {
return t("approval.sandboxEscapeRuntimeReason");
}
return trimmed || t("approval.sandboxEscapeRuntimeReason");
}
function localizePlanModeApprovalReason(tool: string, reason: string, t: Translator): string {
if (tool === "plan_mode_read_only_command" && reason.includes("built-in read-only set")) {
return t("approval.planModeBashTrustReason");
}
if (reason.includes("external read-only hints need your confirmation")) {
return t("approval.planModeMcpTrustReason");
}
return reason;
}
export function ApprovalModal({
approval,
onAnswer,
@@ -48,8 +134,18 @@ export function ApprovalModal({
}) {
const t = useT();
const isPlanApproval = approval.tool === "exit_plan_mode";
const toolLabel = approvalToolLabel(approval.tool, t);
const isFreshHumanApproval = requiresFreshHumanApproval(approval.tool);
const hasFreshSessionGrant = approval.tool === "sandbox_escape";
const subject = localizeApprovalSubject(approval.tool, approval.subject, t);
const reason = localizePlanModeApprovalReason(approval.tool, localizeApprovalReason(approval.tool, approval.reason, t), t);
const subjectSummary = subject.split(/\r?\n/).find((line) => line.trim())?.trim() ?? "";
const toolMeta = reason || subjectSummary || approval.tool;
const hasToolDetails = Boolean(reason || subject);
const showToolDetailsByDefault = !isPlanApproval && hasToolDetails;
const [revisionOpen, setRevisionOpen] = useState(false);
const [revisionText, setRevisionText] = useState("");
const [detailsOpen, setDetailsOpen] = useState(() => showToolDetailsByDefault);
const [selectedIndex, setSelectedIndex] = useState(() => (isPlanApproval ? 1 : 0));
const cardRef = useRef<HTMLDivElement | null>(null);
const shelfRef = useRef<HTMLDivElement | null>(null);
@@ -59,7 +155,6 @@ export function ApprovalModal({
// the new one slides in. GSAP fromTo on the shelf wrapper avoids the
// jarring pop when the API cycles through 4+ pending approvals.
const closingRef = useRef(false);
const subject = approval.subject.trim();
const fileMenu = useFileReferenceMenu(revisionText, cwd);
const answerWithExit = (fn: () => void) => {
@@ -88,21 +183,32 @@ export function ApprovalModal({
const chooseToolAction = (key: string) => {
if (key === "1") answerWithExit(() => onAnswer(true, false, false));
else if (key === "2") answerWithExit(() => onAnswer(true, true, false));
else if (key === "3") answerWithExit(() => onAnswer(true, true, true));
else if (key === "4") answerWithExit(() => onAnswer(false, false, false));
else if (hasFreshSessionGrant && key === "2") answerWithExit(() => onAnswer(true, true, false));
else if (hasFreshSessionGrant && key === "3") answerWithExit(() => onAnswer(false, false, false));
else if (isFreshHumanApproval && key === "2") answerWithExit(() => onAnswer(false, false, false));
else if (isFreshHumanApproval && key === "4") answerWithExit(() => onAnswer(false, false, false));
else if (!isFreshHumanApproval && key === "2") answerWithExit(() => onAnswer(true, true, false));
else if (!isFreshHumanApproval && key === "3") answerWithExit(() => onAnswer(true, true, true));
else if (!isFreshHumanApproval && key === "4") answerWithExit(() => onAnswer(false, false, false));
else if (key === "Escape") answerWithExit(onStop);
};
const selectedToolActionKey = (index: number) => {
if (!isFreshHumanApproval) return String(index + 1);
if (hasFreshSessionGrant) return index === 0 ? "1" : index === 1 ? "2" : "3";
return index === 0 ? "1" : "2";
};
useEffect(() => {
cardRef.current?.focus();
setRevisionOpen(false);
setRevisionText("");
setDetailsOpen(showToolDetailsByDefault);
setSelectedIndex(isPlanApproval ? 1 : 0);
playAttentionChime();
}, [approval.id, isPlanApproval]);
}, [approval.id, isPlanApproval, showToolDetailsByDefault]);
const actionCount = isPlanApproval ? 3 : 4;
const actionCount = isPlanApproval ? 3 : isFreshHumanApproval ? (hasFreshSessionGrant ? 3 : 2) : 4;
const selectedIndexRef = useRef(selectedIndex);
selectedIndexRef.current = selectedIndex;
@@ -123,7 +229,7 @@ export function ApprovalModal({
event.preventDefault();
const key = String(selectedIndexRef.current + 1);
if (isPlanApproval) choosePlanAction(key);
else chooseToolAction(key);
else chooseToolAction(selectedToolActionKey(selectedIndexRef.current));
} else if (event.key === "1" || event.key === "2" || event.key === "3" || event.key === "4" || event.key === "Escape") {
event.preventDefault();
if (isPlanApproval) choosePlanAction(event.key);
@@ -132,7 +238,7 @@ export function ApprovalModal({
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [isPlanApproval, onAnswer, onExitPlan, onStop, actionCount]);
}, [isPlanApproval, isFreshHumanApproval, hasFreshSessionGrant, onAnswer, onExitPlan, onStop, actionCount]);
useEffect(() => {
if (revisionOpen) {
@@ -219,6 +325,7 @@ export function ApprovalModal({
return (
<div ref={shelfRef}>
<PromptShelf
className="prompt-shelf--compact prompt-shelf--plan-approval"
barRef={cardRef}
titleId="plan-approval-title"
title={t("approval.planReady")}
@@ -280,27 +387,53 @@ export function ApprovalModal({
return (
<div ref={shelfRef}>
<PromptShelf
className="prompt-shelf--compact prompt-shelf--tool-approval"
barRef={cardRef}
titleId="tool-approval-title"
title={t("approval.toolPending")}
badges={<PromptBadge>{approval.tool}</PromptBadge>}
badges={<PromptBadge>{toolLabel}</PromptBadge>}
meta={toolMeta}
headerActions={
<PromptHeaderAction onClick={() => answerWithExit(onStop)} ariaLabel={t("composer.stopShort")}>
Esc
</PromptHeaderAction>
<>
{hasToolDetails && (
<PromptHeaderAction onClick={() => setDetailsOpen((open) => !open)}>
{t(detailsOpen ? "approval.hideDetails" : "approval.details")}
</PromptHeaderAction>
)}
<PromptHeaderAction onClick={() => answerWithExit(onStop)} ariaLabel={t("composer.stopShort")}>
Esc
</PromptHeaderAction>
</>
}
actions={
<>
<PromptAction keyLabel="1" label={t("approval.allowOnce")} onClick={() => answerWithExit(() => onAnswer(true, false, false))} selected={selectedIndex === 0} />
<PromptAction keyLabel="2" label={t("approval.allowRuleSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
<PromptAction keyLabel="3" label={t("approval.allowRulePersistent")} onClick={() => answerWithExit(() => onAnswer(true, true, true))} selected={selectedIndex === 2} />
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 3} />
{isFreshHumanApproval ? (
hasFreshSessionGrant ? (
<>
<PromptAction keyLabel="2" label={t("approval.allowSandboxEscapeSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
<PromptAction keyLabel="3" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 2} />
</>
) : (
<PromptAction keyLabel="2" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 1} />
)
) : (
<>
<PromptAction keyLabel="2" label={t("approval.allowRuleSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
<PromptAction keyLabel="3" label={t("approval.allowRulePersistent")} onClick={() => answerWithExit(() => onAnswer(true, true, true))} selected={selectedIndex === 2} />
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 3} />
</>
)}
</>
}
>
{approval.reason && <div className="approval-reason">{approval.reason}</div>}
{subject && (
<pre className="approval-subject">{subject}</pre>
{detailsOpen && (
<div className="approval-details">
{reason && <div className="approval-reason">{reason}</div>}
{subject && (
<pre className="approval-subject">{subject}</pre>
)}
</div>
)}
</PromptShelf>
</div>
+20 -1
View File
@@ -22,8 +22,10 @@ export function AskCard({
// Per-question state: selected option labels, and an optional typed answer.
const [sel, setSel] = useState<Record<string, string[]>>({});
const [custom, setCustom] = useState<Record<string, string>>({});
const [customOpen, setCustomOpen] = useState(false);
const [active, setActive] = useState(0);
const shelfRef = useRef<HTMLDivElement | null>(null);
const customInputRef = useRef<HTMLInputElement | null>(null);
const advanceTimer = useRef<number | null>(null);
const questions = ask.questions;
@@ -36,6 +38,7 @@ export function AskCard({
shelfRef.current?.focus();
setSel({});
setCustom({});
setCustomOpen(false);
setActive(0);
if (advanceTimer.current != null) window.clearTimeout(advanceTimer.current);
playAttentionChime();
@@ -66,7 +69,15 @@ export function AskCard({
(sel[question.id]?.length ?? 0) > 0 || (custom[question.id]?.trim() ?? "") !== "";
const currentAnswered = q ? answered(q) : false;
const showSubmitAction = q ? q.multi || Boolean(custom[q.id]?.trim()) : false;
const showSubmitAction = q ? q.multi || customOpen || Boolean(custom[q.id]?.trim()) : false;
useEffect(() => {
setCustomOpen(false);
}, [active]);
useEffect(() => {
if (customOpen) customInputRef.current?.focus();
}, [customOpen]);
const finishOrAdvance = (nextSel = sel, nextCustom = custom) => {
if (advanceTimer.current != null) {
@@ -89,6 +100,7 @@ export function AskCard({
setCustom(nextCustom);
setSel(nextSel);
setCustomOpen(false);
if (!question.multi) {
if (advanceTimer.current != null) window.clearTimeout(advanceTimer.current);
@@ -148,6 +160,7 @@ export function AskCard({
return (
<PromptShelf
className="prompt-shelf--compact prompt-shelf--ask"
barRef={shelfRef}
titleId="ask-shelf-title"
title={t("ask.title")}
@@ -165,6 +178,9 @@ export function AskCard({
headerActions={
<>
<PromptHeaderAction onClick={onDismiss}>{t("ask.justChat")}</PromptHeaderAction>
{!customOpen && (
<PromptHeaderAction onClick={() => setCustomOpen(true)}>{t("ask.customAnswer")}</PromptHeaderAction>
)}
<PromptHeaderAction onClick={onStop} ariaLabel={t("composer.stopShort")}>Esc</PromptHeaderAction>
</>
}
@@ -213,8 +229,10 @@ export function AskCard({
)
}
>
{customOpen && (
<div className="ask-shelf__custom-row">
<input
ref={customInputRef}
className="ask-shelf__custom"
placeholder={t("ask.customPlaceholder")}
value={custom[q.id] ?? ""}
@@ -225,6 +243,7 @@ export function AskCard({
}}
/>
</div>
)}
</PromptShelf>
);
}
@@ -4,7 +4,7 @@ import { asArray } from "../lib/array";
import { app, openExternal } from "../lib/bridge";
import { useT } from "../lib/i18n";
import { mcpServerLifecycleActions, mcpServerRetryableFromAvailableList } from "../lib/mcpServerLifecycle";
import type { CapabilitiesView, MCPServerInput, ServerView, SkillRootSkillView, SkillRootView, SkillsSettingsView, SkillView, TabMeta } from "../lib/types";
import type { CapabilitiesView, MCPServerInput, PluginHookView, PluginInstallOptions, PluginMCPServerView, PluginSkillView, PluginView, ServerView, SkillRootSkillView, SkillRootView, SkillsSettingsView, SkillView, TabMeta } from "../lib/types";
import { InlineConfirmButton } from "./InlineConfirmButton";
import { ResizableDrawer } from "./ResizableDrawer";
import { Tooltip } from "./Tooltip";
@@ -20,6 +20,7 @@ type SettingsSnapshot<T> = { key: string; value: T };
let mcpSettingsSnapshot: SettingsSnapshot<ServerView[]> | null = null;
let skillsSettingsSnapshot: SettingsSnapshot<SkillsSettingsView> | null = null;
let pluginsSettingsSnapshot: SettingsSnapshot<PluginView[]> | null = null;
function settingsSnapshotKey(meta: Awaited<ReturnType<typeof app.Meta>> | null | undefined, tabs: TabMeta[] | null | undefined): string {
const active = tabs?.find((tab) => tab.active);
@@ -50,7 +51,7 @@ export function CapabilitiesPanel({
const [expandedServerTools, setExpandedServerTools] = useState<Set<string>>(() => new Set());
const reload = useCallback(async () => {
setView(normalizeCapabilitiesView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [] }))));
setView(normalizeCapabilitiesView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [], plugins: [] }))));
}, []);
useEffect(() => {
void reload();
@@ -314,6 +315,7 @@ export function CapabilitiesPanel({
function normalizeCapabilitiesView(view: CapabilitiesView | null | undefined): CapabilitiesView {
return {
servers: normalizeServerViews(view?.servers),
plugins: asArray(view?.plugins),
...normalizeSkillsSettingsView(view),
};
}
@@ -1590,6 +1592,619 @@ function AddServerForm({
);
}
type PluginInstallPlanAction = {
action?: string;
kind?: string;
name?: string;
source?: string;
status?: string;
message?: string;
error?: string;
};
type PluginInstallPlanView = {
raw: string;
ok?: boolean;
status?: string;
name?: string;
actions: PluginInstallPlanAction[];
warnings: string[];
error?: string;
};
type PluginInstallMode = "local" | "git";
// PluginsSettingsPage is the desktop plugin package manager embedded inside
// Settings. It mirrors the MCP/Skills density: install planning on top, package
// rows below, and diagnostics/details only when a row is expanded.
export function PluginsSettingsPage() {
const t = useT();
const [snapshotKey, setSnapshotKey] = useState("");
const [plugins, setPlugins] = useState<PluginView[] | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [installMode, setInstallMode] = useState<PluginInstallMode>("local");
const [localSource, setLocalSource] = useState("");
const [gitSource, setGitSource] = useState("");
const [name, setName] = useState("");
const [link, setLink] = useState(false);
const [replace, setReplace] = useState(false);
const [plan, setPlan] = useState<PluginInstallPlanView | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const [diagnostics, setDiagnostics] = useState<Record<string, PluginView>>({});
const reload = useCallback(async () => {
const [meta, tabs] = await Promise.all([
app.Meta().catch(() => null),
app.ListTabs().catch(() => []),
]);
const key = settingsSnapshotKey(meta, tabs);
setSnapshotKey(key);
const cached = key ? pluginsSettingsSnapshot : null;
if (cached?.key === key) {
setPlugins(cached.value);
} else {
setPlugins(null);
}
const next = normalizePluginViews(await app.Plugins().catch(() => []));
pluginsSettingsSnapshot = { key, value: next };
setPlugins(next);
}, []);
useEffect(() => { void reload(); }, [reload]);
const run = async (fn: () => Promise<unknown>, reloadAfter = true) => {
setBusy(true);
setErr(null);
setNotice(null);
try {
const result = await fn();
if (typeof result === "string" && result.trim()) {
const parsed = parsePluginInstallPlan(result);
setNotice(pluginPlanNotice(parsed, t));
}
if (reloadAfter) await reload();
return true;
} catch (e) {
setErr(String((e as Error)?.message ?? e));
if (reloadAfter) await reload();
return false;
} finally {
setBusy(false);
}
};
const sourceValue = (installMode === "local" ? localSource : gitSource).trim();
const installOptions = (): PluginInstallOptions => ({
dryRun: false,
link: installMode === "local" ? link : false,
replace,
name: installMode === "git" ? name.trim() || undefined : undefined,
});
const actionBusy = busy || !snapshotKey || !plugins;
const canPlan = sourceValue.length > 0 && !actionBusy;
const summary = plugins ? pluginListSummary(plugins, t) : "";
const togglePlugin = useCallback((pluginName: string) => {
setExpanded((prev) => { const next = new Set(prev); if (next.has(pluginName)) next.delete(pluginName); else next.add(pluginName); return next; });
}, []);
const setMode = (mode: PluginInstallMode) => {
setInstallMode(mode);
setPlan(null);
};
const previewInstall = () => {
if (!sourceValue) return;
void run(async () => {
const raw = await app.PlanPluginInstall(sourceValue, { ...installOptions(), dryRun: true });
setPlan(parsePluginInstallPlan(raw));
}, false);
};
const install = () => {
if (!sourceValue) return;
void run(async () => {
const raw = await app.InstallPlugin(sourceValue, installOptions());
setPlan(parsePluginInstallPlan(raw));
return raw;
});
};
const runDoctor = (pluginName: string) => {
void run(async () => {
const view = normalizePluginView(await app.PluginDoctor(pluginName));
setDiagnostics((prev) => ({ ...prev, [pluginName]: view }));
setExpanded((prev) => {
const next = new Set(prev);
next.add(pluginName);
return next;
});
}, false);
};
const updateLocalSource = (value: string) => {
setLocalSource(value);
setPlan(null);
};
const updateGitSource = (value: string) => {
setGitSource(value);
setPlan(null);
};
const pickPluginFolder = () => {
void run(async () => {
const path = await app.PickPluginFolder();
if (path) {
setInstallMode("local");
updateLocalSource(path);
}
}, false);
};
return (
<section className="mem-section">
{err && <div className="banner banner--error">{err}</div>}
{notice && !err && <div className="banner banner--success">{notice}</div>}
<div className="cap-plugin-installer">
<div className="cap-plugin-installer__head">
<div className="cap-plugin-installer__copy">
<div className="cap-plugin-installer__title">{t("caps.pluginInstallTitle")}</div>
<div className="cap-plugin-installer__hint">{t("caps.pluginInstallHint")}</div>
</div>
<div className="cap-tabs cap-plugin-installer__mode" role="group" aria-label={t("caps.pluginInstallMethod")}>
<button
className={`cap-tab${installMode === "local" ? " cap-tab--active" : ""}`}
type="button"
aria-pressed={installMode === "local"}
onClick={() => setMode("local")}
>
{t("caps.pluginInstallLocal")}
</button>
<button
className={`cap-tab${installMode === "git" ? " cap-tab--active" : ""}`}
type="button"
aria-pressed={installMode === "git"}
onClick={() => setMode("git")}
>
{t("caps.pluginInstallGit")}
</button>
</div>
</div>
<div className="cap-plugin-form-grid">
{installMode === "local" ? (
<div className="cap-plugin-fields cap-plugin-fields--local">
<div className="cap-plugin-folder-field">
<button className="btn btn--small" disabled={actionBusy} type="button" onClick={pickPluginFolder}>
{t("caps.pluginChooseLocalFolder")}
</button>
<div
className={`cap-plugin-path${localSource ? "" : " cap-plugin-path--empty"}`}
aria-label={t("caps.pluginLocalFolder")}
>
{localSource || t("caps.pluginNoLocalFolder")}
</div>
</div>
</div>
) : (
<div className="cap-plugin-fields cap-plugin-fields--git">
<input
className="mem-input"
aria-label={t("caps.pluginGitSource")}
placeholder={t("caps.pluginSourcePlaceholder")}
value={gitSource}
onInput={(e) => updateGitSource(e.currentTarget.value)}
onChange={(e) => updateGitSource(e.target.value)}
/>
<div className="cap-plugin-field">
<input
className="mem-input"
aria-label={t("caps.pluginInstallName")}
placeholder={t("caps.pluginInstallNamePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
</div>
)}
<div className="cap-plugin-installer__options">
<div className="cap-plugin-option-block">
<label className="cap-plugin-option">
<input type="checkbox" checked={replace} disabled={actionBusy} onChange={(e) => setReplace(e.target.checked)} />
<span>{t("caps.pluginReplace")}</span>
</label>
<div className="cap-plugin-option-hint">{t("caps.pluginReplaceHint")}</div>
</div>
{installMode === "local" && (
<div className="cap-plugin-option-block">
<label className="cap-plugin-option">
<input type="checkbox" checked={link} disabled={actionBusy} onChange={(e) => setLink(e.target.checked)} />
<span>{t("caps.pluginLink")}</span>
</label>
<div className="cap-plugin-option-hint">{t("caps.pluginLinkHint")}</div>
</div>
)}
</div>
<div className="cap-plugin-installer__actions">
<button className="btn btn--small" type="button" disabled={!canPlan} onClick={previewInstall}>
{t("caps.pluginPreview")}
</button>
<button className="btn btn--primary btn--small" type="button" disabled={!canPlan} onClick={install}>
{t("caps.pluginInstall")}
</button>
</div>
</div>
</div>
{plan && <PluginPlanPreview plan={plan} />}
<div className="cap-server-section cap-plugin-section">
<div className="cap-server-section__head">
<div className="cap-server-section__copy">
<div className="cap-server-section__title">{t("caps.installedPlugins")}</div>
{plugins && plugins.length > 0 && <div className="drawer__summary">{summary}</div>}
</div>
<button className="btn btn--small" disabled={actionBusy} type="button" onClick={() => void reload()}>
{t("caps.pluginRefresh")}
</button>
</div>
{!plugins ? (
<div className="mem-empty">{t("caps.loading")}</div>
) : plugins.length === 0 ? (
<div className="mem-empty mem-empty--cta">
<strong>{t("caps.noPluginsTitle")}</strong>
<span>{t("caps.noPluginsHint")}</span>
</div>
) : (
<div className="cap-server-group">
{plugins.map((plugin) => (
<PluginRow
key={plugin.name}
plugin={plugin}
diagnostic={diagnostics[plugin.name]}
busy={actionBusy}
expanded={expanded.has(plugin.name)}
onToggleDetails={() => togglePlugin(plugin.name)}
onToggleEnabled={(enabled) => void run(() => app.SetPluginEnabled(plugin.name, enabled))}
onUpdate={() => void run(() => app.UpdatePlugin(plugin.name))}
onDoctor={() => runDoctor(plugin.name)}
onRemove={() => void run(() => app.RemovePlugin(plugin.name))}
/>
))}
</div>
)}
</div>
</section>
);
}
function PluginPlanPreview({ plan }: { plan: PluginInstallPlanView }) {
const t = useT();
return (
<div className={`cap-plugin-plan${plan.error ? " cap-plugin-plan--error" : ""}`}>
<div className="cap-plugin-plan__head">
<div className="cap-plugin-plan__title">{plan.error ? t("caps.pluginPlanError") : t("caps.pluginPlanReady")}</div>
{plan.status && <span className="cap-source-badge">{plan.status}</span>}
</div>
{plan.name && <div className="cap-plugin-plan__meta">{plan.name}</div>}
{plan.error && <div className="cap-plugin-plan__warning">{plan.error}</div>}
{plan.warnings.map((warning, idx) => (
<div className="cap-plugin-plan__warning" key={`${warning}-${idx}`}>{warning}</div>
))}
{plan.actions.length > 0 ? (
<div className="cap-plugin-actions">
{plan.actions.map((action, idx) => (
<div className="cap-plugin-action" key={`${action.action || action.kind || "action"}-${idx}`}>
<span className="cap-plugin-action__name">{pluginPlanActionLabel(action, t)}</span>
{action.status && <span className="cap-source-badge">{action.status}</span>}
{action.source && <span className="cap-plugin-action__source">{action.source}</span>}
{action.message && <span className="cap-plugin-action__source">{action.message}</span>}
{action.error && <span className="cap-plugin-plan__warning">{action.error}</span>}
</div>
))}
</div>
) : (
<pre className="cap-plugin-plan__raw">{plan.raw}</pre>
)}
</div>
);
}
function PluginRow({
plugin,
diagnostic,
busy,
expanded,
onToggleDetails,
onToggleEnabled,
onUpdate,
onDoctor,
onRemove,
}: {
plugin: PluginView;
diagnostic?: PluginView;
busy: boolean;
expanded: boolean;
onToggleDetails: () => void;
onToggleEnabled: (enabled: boolean) => void;
onUpdate: () => void;
onDoctor: () => void;
onRemove: () => void;
}) {
const t = useT();
const status = plugin.error ? "failed" : plugin.enabled ? "connected" : "disabled";
const warnings = pluginWarnings(plugin, diagnostic);
const sub = plugin.error || pluginCapabilitiesSummary(plugin, t);
return (
<div className={`cap-server-entry cap-plugin-entry${plugin.enabled ? "" : " cap-server-entry--disabled"}`}>
<Tooltip label={plugin.error} disabled={!plugin.error} fill block>
<div className={`cap-row${plugin.enabled ? "" : " cap-row--disabled"}`}>
<Tooltip label={expanded ? t("caps.collapseDetails") : t("caps.expandDetails")}>
<button
className="cap-disclosure"
aria-expanded={expanded}
type="button"
onClick={onToggleDetails}
>
{expanded ? "⌄" : "›"}
</button>
</Tooltip>
<span className={`cap-dot cap-dot--${status}`} />
<div className="cap-row__text">
<div className="cap-row__head">
<span className="cap-row__name">{plugin.name}</span>
{plugin.manifestKind && <span className="cap-row__transport">{plugin.manifestKind}</span>}
{plugin.version && <span className="cap-source-badge">{plugin.version}</span>}
{warnings.length > 0 && <span className="cap-row__update cap-row__update--error">{t("caps.pluginWarnings", { count: warnings.length })}</span>}
</div>
<div className="cap-row__sub">{sub}</div>
</div>
<div className="cap-row__actions">
<Tooltip label={plugin.enabled ? t("caps.pluginDisable") : t("caps.pluginEnable")}>
<label className="cap-switch">
<input
type="checkbox"
checked={plugin.enabled}
disabled={busy}
onChange={(e) => onToggleEnabled(e.target.checked)}
/>
<span className="cap-switch__track" />
</label>
</Tooltip>
</div>
</div>
</Tooltip>
{expanded && (
<div className="cap-server-details">
<div className="cap-detail-grid">
<div className="cap-detail">
<span className="cap-detail__label">{t("caps.status")}</span>
<span className="cap-detail__value">{plugin.enabled ? t("caps.pluginEnabled") : t("caps.pluginDisabled")}</span>
</div>
{plugin.version && (
<div className="cap-detail">
<span className="cap-detail__label">{t("caps.pluginVersion")}</span>
<span className="cap-detail__value">{plugin.version}</span>
</div>
)}
{plugin.source && (
<div className="cap-detail cap-detail--wide">
<span className="cap-detail__label">{t("caps.pluginSource")}</span>
<span className="cap-detail__code">{plugin.source}</span>
</div>
)}
{plugin.root && (
<div className="cap-detail cap-detail--wide">
<span className="cap-detail__label">{t("caps.pluginRoot")}</span>
<span className="cap-detail__code">{plugin.root}</span>
</div>
)}
</div>
{plugin.description && <div className="cap-plugin-description">{plugin.description}</div>}
<PluginUsageDetails plugin={plugin} />
{diagnostic?.error && <div className="cap-source__warning">{diagnostic.error}</div>}
{warnings.map((warning, idx) => (
<div className="cap-source__warning" key={`${plugin.name}-warning-${idx}`}>{warning}</div>
))}
<div className="cap-detail-actions">
<button className="btn btn--small" disabled={busy} type="button" onClick={onUpdate}>
{t("caps.pluginUpdate")}
</button>
<button className="btn btn--small" disabled={busy} type="button" onClick={onDoctor}>
{t("caps.pluginDoctor")}
</button>
<InlineConfirmButton
label={t("caps.pluginRemove")}
confirmLabel={t("caps.pluginConfirmRemove")}
cancelLabel={t("common.cancel")}
disabled={busy}
danger
onConfirm={onRemove}
/>
</div>
</div>
)}
</div>
);
}
function PluginUsageDetails({ plugin }: { plugin: PluginView }) {
const t = useT();
const skills = asArray(plugin.skillDetails);
const hooks = asArray(plugin.hookDetails);
const mcps = asArray(plugin.mcpServerDetails);
const hasDetails = skills.length > 0 || hooks.length > 0 || mcps.length > 0;
return (
<div className="cap-plugin-usage">
<div className="cap-plugin-usage__title">{t("caps.pluginUsageTitle")}</div>
<div className="cap-plugin-usage__hint">
{plugin.enabled ? t("caps.pluginUsageEnabledHint") : t("caps.pluginUsageDisabledHint")}
</div>
{hasDetails ? (
<div className="cap-plugin-capabilities">
{skills.length > 0 && <PluginSkillList skills={skills} />}
{hooks.length > 0 && <PluginHookList hooks={hooks} />}
{mcps.length > 0 && <PluginMCPList servers={mcps} />}
</div>
) : (
<div className="cap-plugin-usage__empty">{t("caps.pluginNoCapabilityDetails")}</div>
)}
</div>
);
}
function PluginSkillList({ skills }: { skills: PluginSkillView[] }) {
const t = useT();
return (
<div className="cap-plugin-capability">
<div className="cap-plugin-capability__head">{t("caps.pluginSkillsTitle")}</div>
<div className="cap-plugin-capability__hint">{t("caps.pluginSkillsHint")}</div>
<div className="cap-plugin-capability__list">
{skills.map((skill) => (
<div className="cap-plugin-capability__item" key={`${skill.name}-${skill.path || skill.invocation || ""}`}>
<div className="cap-plugin-capability__line">
<span className="cap-plugin-capability__name">{skill.invocation || `/${skill.name}`}</span>
{skill.runAs && <span className="cap-source-badge">{skill.runAs}</span>}
</div>
<div className="cap-plugin-capability__desc">{skill.description || t("caps.pluginNoDescription")}</div>
</div>
))}
</div>
</div>
);
}
function PluginHookList({ hooks }: { hooks: PluginHookView[] }) {
const t = useT();
return (
<div className="cap-plugin-capability">
<div className="cap-plugin-capability__head">{t("caps.pluginHooksTitle")}</div>
<div className="cap-plugin-capability__hint">{t("caps.pluginHooksHint")}</div>
<div className="cap-plugin-capability__list">
{hooks.map((hook, idx) => {
const target = hook.command || hook.contextFile || t("caps.pluginHookNoTarget");
return (
<div className="cap-plugin-capability__item" key={`${hook.event}-${hook.match || "*"}-${target}-${idx}`}>
<div className="cap-plugin-capability__line">
<span className="cap-plugin-capability__name">{hook.event}</span>
<span className="cap-source-badge">{hook.match || "*"}</span>
</div>
<div className="cap-plugin-capability__desc">{hook.description || target}</div>
</div>
);
})}
</div>
</div>
);
}
function PluginMCPList({ servers }: { servers: PluginMCPServerView[] }) {
const t = useT();
return (
<div className="cap-plugin-capability">
<div className="cap-plugin-capability__head">{t("caps.pluginMCPTitle")}</div>
<div className="cap-plugin-capability__hint">{t("caps.pluginMCPHint")}</div>
<div className="cap-plugin-capability__list">
{servers.map((server) => (
<div className="cap-plugin-capability__item" key={server.name}>
<div className="cap-plugin-capability__line">
<span className="cap-plugin-capability__name">{server.name}</span>
{server.transport && <span className="cap-source-badge">{server.transport}</span>}
</div>
<div className="cap-plugin-capability__desc">{server.command || server.url || t("caps.pluginMCPNoTarget")}</div>
</div>
))}
</div>
</div>
);
}
function normalizePluginViews(plugins: PluginView[] | null | undefined): PluginView[] {
return sortPluginsForDisplay(asArray(plugins).map(normalizePluginView));
}
function normalizePluginView(plugin: PluginView): PluginView {
return {
...plugin,
name: plugin.name || "plugin",
root: plugin.root || "",
enabled: Boolean(plugin.enabled),
skills: Number.isFinite(plugin.skills) ? plugin.skills : 0,
hooks: Number.isFinite(plugin.hooks) ? plugin.hooks : 0,
mcpServers: Number.isFinite(plugin.mcpServers) ? plugin.mcpServers : 0,
skillDetails: asArray(plugin.skillDetails),
hookDetails: asArray(plugin.hookDetails),
mcpServerDetails: asArray(plugin.mcpServerDetails),
warnings: asArray(plugin.warnings),
};
}
function sortPluginsForDisplay(plugins: PluginView[]): PluginView[] {
return [...plugins].sort((a, b) => {
const priority = pluginDisplayPriority(a) - pluginDisplayPriority(b);
if (priority !== 0) return priority;
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
});
}
function pluginDisplayPriority(plugin: PluginView): number {
if (plugin.error) return 0;
if (plugin.enabled) return 1;
return 2;
}
function pluginListSummary(plugins: PluginView[], t: ReturnType<typeof useT>): string {
const enabled = plugins.filter((plugin) => plugin.enabled && !plugin.error).length;
const issues = plugins.filter((plugin) => Boolean(plugin.error) || asArray(plugin.warnings).length > 0).length;
return t("caps.pluginsSummary", { enabled, total: plugins.length, issues });
}
function pluginCapabilitiesSummary(plugin: PluginView, t: ReturnType<typeof useT>): string {
if (plugin.skills === 0 && plugin.hooks === 0 && plugin.mcpServers === 0) return t("caps.pluginNoCapabilities");
return t("caps.pluginCounts", { skills: plugin.skills, hooks: plugin.hooks, mcps: plugin.mcpServers });
}
function pluginWarnings(plugin: PluginView, diagnostic?: PluginView): string[] {
const warnings = [...asArray(plugin.warnings), ...asArray(diagnostic?.warnings)];
return Array.from(new Set(warnings.filter((warning) => warning.trim().length > 0)));
}
function parsePluginInstallPlan(raw: string): PluginInstallPlanView {
try {
const value = JSON.parse(raw) as Record<string, unknown>;
const actions = (Array.isArray(value.actions) ? value.actions : []).flatMap((action) => {
if (!action || typeof action !== "object") return [];
const item = action as Record<string, unknown>;
return [{
action: stringValue(item.action),
kind: stringValue(item.kind),
name: stringValue(item.name),
source: stringValue(item.source),
status: stringValue(item.status),
message: stringValue(item.message),
error: stringValue(item.error),
}];
});
return {
raw,
ok: typeof value.ok === "boolean" ? value.ok : undefined,
status: stringValue(value.status),
name: stringValue(value.name),
actions,
warnings: (Array.isArray(value.warnings) ? value.warnings : []).flatMap((warning) => typeof warning === "string" ? [warning] : []),
error: stringValue(value.error),
};
} catch {
return { raw, actions: [], warnings: [] };
}
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function pluginPlanActionLabel(action: PluginInstallPlanAction, t: ReturnType<typeof useT>): string {
const verb = action.action || action.kind || t("caps.pluginAction");
return [verb, action.name].filter(Boolean).join(" · ");
}
function pluginPlanNotice(plan: PluginInstallPlanView, t: ReturnType<typeof useT>): string {
if (plan.error) return plan.error;
if (plan.status === "done" || plan.status === "applied" || plan.status === "complete") return t("caps.pluginPlanInstalled");
return plan.status ? t("caps.pluginPlanStatus", { status: plan.status }) : t("caps.pluginPlanComplete");
}
// MCPServersSettingsPage is a self-contained MCP servers management page
// embedded inside the settings centre.
export function MCPServersSettingsPage() {
+249 -40
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
import { ArrowUp, Check, ChevronsUpDown, Eye, FileText, Folder, Gauge, List, MessageSquare, Search, Shield, ShieldAlert, ShieldCheck, SlidersHorizontal, Square, Target, Trash2, X } from "lucide-react";
import { ArrowUp, Check, ChevronDown, ChevronUp, ChevronsUpDown, CornerDownRight, Eye, FileText, Folder, Gauge, List, MessageSquare, Search, Shield, ShieldAlert, ShieldCheck, SlidersHorizontal, Square, Target, Trash2, X } from "lucide-react";
import { asArray } from "../lib/array";
import { filterAtMatches } from "../lib/atMatches";
import { DedupIndex, sha256 } from "../lib/attachDedup";
@@ -26,6 +26,7 @@ import { EffortSwitcher } from "./EffortSwitcher";
import { ModelSwitcher } from "./ModelSwitcher";
import { Tooltip } from "./Tooltip";
import { ComposerContextCard } from "./ComposerContextCard";
import { ImageViewer } from "./ImageViewer";
import { VirtualMenu } from "./VirtualMenu";
import { dirEntryMenuLabel, dirEntrySubmitPath } from "./FileReferenceMenu";
@@ -57,12 +58,24 @@ const PROMPT_HISTORY_PREFETCH_REMAINING = 3;
// it; the real gap is a few ms, so keep it short or a deliberate quick second
// Enter (submit) gets eaten too.
const IME_CONFIRM_GRACE_MS = 100;
const FILE_REF_SEARCH_CACHE_TTL_MS = 5000;
type PastedBlock = {
label: string;
text: string;
};
type PendingGuidance = {
id: number;
text: string;
submitText: string;
};
type FileRefSearchCacheEntry = {
entries: DirEntry[];
cachedAt: number;
};
type ComposerDraft = {
text: string;
attachments: Attachment[];
@@ -163,6 +176,13 @@ function emptyComposerDraft(): ComposerDraft {
};
}
function guidanceTextMatches(queued: string, consumed: string): boolean {
const left = queued.trim();
const right = consumed.trim();
if (!left || !right) return false;
return left === right || left.includes(right) || right.includes(left);
}
function cloneComposerDraft(draft: ComposerDraft): ComposerDraft {
return {
text: draft.text,
@@ -431,6 +451,10 @@ export function Composer({
retry,
transientDismissSignal,
sessionKey,
fileRefRefreshKey,
guidanceConsumedKey,
guidanceConsumedText,
guidanceQueuePreviewItems,
}: {
running: boolean;
collaborationMode: CollaborationMode;
@@ -470,6 +494,10 @@ export function Composer({
retry?: { attempt: number; max: number };
transientDismissSignal?: number;
sessionKey?: string;
fileRefRefreshKey?: number | string;
guidanceConsumedKey?: string;
guidanceConsumedText?: string;
guidanceQueuePreviewItems?: readonly string[];
}) {
const { t, locale } = useI18n();
const { showToast } = useToast();
@@ -477,6 +505,15 @@ export function Composer({
const now = useTick(running);
const [text, setText] = useState("");
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [imageViewer, setImageViewer] = useState<{ open: boolean; url: string; name: string }>({ open: false, url: "", name: "" });
const openComposerImageViewer = useCallback((url: string, name: string) => {
setImageViewer({ open: true, url, name });
}, []);
const closeComposerImageViewer = useCallback(() => {
setImageViewer((prev) => (prev.open ? { ...prev, open: false } : prev));
}, []);
const [workspaceRefs, setWorkspaceRefs] = useState<WorkspaceReference[]>([]);
const [pastedBlocks, setPastedBlocks] = useState<PastedBlock[]>([]);
const [openPastedLabels, setOpenPastedLabels] = useState<string[]>([]);
@@ -498,6 +535,10 @@ export function Composer({
const [pastChats, setPastChats] = useState<SessionMeta[]>([]);
const [pastChatQuery, setPastChatQuery] = useState("");
const [sessionRefs, setSessionRefs] = useState<SessionReference[]>([]);
const [pendingGuidance, setPendingGuidance] = useState<PendingGuidance[]>([]);
const [guidanceExpanded, setGuidanceExpanded] = useState(false);
const [guidanceSendingId, setGuidanceSendingId] = useState<number | null>(null);
const nextGuidanceId = useRef(1);
const [loadingPastChats, setLoadingPastChats] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [composerPrompt, setComposerPrompt] = useState<string | null>(null);
@@ -524,6 +565,8 @@ export function Composer({
const lastSelectionRef = useRef({ start: 0, end: 0 });
const consumedInsertIdRef = useRef(0);
const lastTransientDismissSignal = useRef(transientDismissSignal);
const lastGuidanceConsumedKey = useRef(guidanceConsumedKey);
const selfDispatchedGuidanceRef = useRef<string[]>([]);
const submittingRef = useRef(false);
const nativeClipboardPasteTimerRef = useRef<number | null>(null);
// Snapshot of the current cwd so async callbacks (openPastChats) can detect
@@ -533,6 +576,7 @@ export function Composer({
const attachmentDedupRef = useRef(new DedupIndex());
const attachmentDedupKeysRef = useRef<Record<string, AttachmentDedupKey>>({});
const draftKey = sessionKey || tabId || DEFAULT_COMPOSER_DRAFT_KEY;
const guidanceQueuePreviewKey = (guidanceQueuePreviewItems ?? []).map((item) => item.trim()).filter(Boolean).join("\n");
const draftsBySessionRef = useRef<Record<string, ComposerDraft>>({});
const activeDraftKeyRef = useRef(draftKey);
const textRef = useRef(text);
@@ -609,14 +653,37 @@ export function Composer({
useEffect(() => () => clearNativeClipboardPasteTimer(), []);
useEffect(() => {
if (wasRunning.current && !running && text.trim() === "") {
pastedBlocksRef.current = [];
setPastedBlocks([]);
setOpenPastedLabels([]);
if (wasRunning.current && !running) {
setPendingGuidance([]);
setGuidanceExpanded(false);
if (text.trim() === "") {
pastedBlocksRef.current = [];
setPastedBlocks([]);
setOpenPastedLabels([]);
}
}
wasRunning.current = running;
}, [running, text]);
useEffect(() => {
setPendingGuidance([]);
setGuidanceExpanded(false);
}, [draftKey]);
useEffect(() => {
if (!running || !guidanceQueuePreviewKey) return;
setGuidanceExpanded(false);
setPendingGuidance(
guidanceQueuePreviewKey
.split("\n")
.map((text) => ({ id: nextGuidanceId.current++, text, submitText: text })),
);
}, [guidanceQueuePreviewKey, running]);
useEffect(() => {
if (guidanceExpanded && pendingGuidance.length <= 2) setGuidanceExpanded(false);
}, [guidanceExpanded, pendingGuidance.length]);
// --- slash commands (whole-input "/token") ---
const [commands, setCommands] = useState<CommandInfo[]>([]);
useEffect(() => {
@@ -694,16 +761,9 @@ export function Composer({
const [entries, setEntries] = useState<DirEntry[]>([]);
const [searchEntries, setSearchEntries] = useState<DirEntry[]>([]);
const dirCache = useRef<Record<string, DirEntry[]>>({});
const searchCache = useRef<Record<string, DirEntry[]>>({});
const searchCache = useRef<Record<string, FileRefSearchCacheEntry>>({});
// When the workspace/project changes (cwd prop), invalidate all @ mention
// state so the picker reloads candidates for the new project. Without this,
// dirCache/searchCache retain entries from the old project and the picker
// shows stale results (issue #3601).
const prevCwdRef = useRef(cwd);
useEffect(() => {
if (prevCwdRef.current === cwd) return; // skip mount — state already initial
prevCwdRef.current = cwd;
const clearFileRefState = useCallback(() => {
dirCache.current = {};
searchCache.current = {};
setEntries([]);
@@ -714,29 +774,50 @@ export function Composer({
setLoadingPastChats(false);
setActive(0);
setDismissed(false);
}, [cwd]);
}, []);
// When the workspace/project changes (cwd prop), invalidate all @ mention
// state so the picker reloads candidates for the new project. Without this,
// dirCache/searchCache retain entries from the old project and the picker
// shows stale results (issue #3601).
const prevCwdRef = useRef(cwd);
useEffect(() => {
if (prevCwdRef.current === cwd) return; // skip mount — state already initial
prevCwdRef.current = cwd;
clearFileRefState();
}, [clearFileRefState, cwd]);
const prevFileRefRefreshKeyRef = useRef(fileRefRefreshKey);
useEffect(() => {
if (prevFileRefRefreshKeyRef.current === fileRefRefreshKey) return;
prevFileRefRefreshKeyRef.current = fileRefRefreshKey;
clearFileRefState();
}, [clearFileRefState, fileRefRefreshKey]);
useEffect(() => {
if (atRaw === null) return;
const cached = dirCache.current[atDir];
if (cached) {
setEntries(cached);
return;
} else {
setEntries([]);
}
let live = true;
app
.ListDir(atDir)
.then((es) => {
const list = asArray(es);
if (!live) return;
dirCache.current[atDir] = list;
if (live) setEntries(list);
setEntries(list);
})
.catch(() => {});
return () => {
live = false;
};
// re-fetch when the menu opens or the directory level changes
}, [atRaw === null, atDir, cwd]);
// Re-fetch when the menu opens, the directory level changes, or the
// workspace tree refreshes; cached data is only a fast first paint.
}, [atRaw === null, atDir, cwd, fileRefRefreshKey]);
useEffect(() => {
if (atRaw === null || atDir !== "" || atFrag === "") {
setSearchEntries([]);
@@ -744,23 +825,25 @@ export function Composer({
}
const cached = searchCache.current[atFrag];
if (cached) {
setSearchEntries(cached);
return;
setSearchEntries(cached.entries);
if (Date.now() - cached.cachedAt < FILE_REF_SEARCH_CACHE_TTL_MS) return;
} else {
setSearchEntries([]);
}
setSearchEntries([]);
let live = true;
app
.SearchFileRefs(atFrag)
.then((es) => {
const list = es ?? [];
searchCache.current[atFrag] = list;
if (live) setSearchEntries(list);
const list = asArray(es);
if (!live) return;
searchCache.current[atFrag] = { entries: list, cachedAt: Date.now() };
setSearchEntries(list);
})
.catch(() => {});
return () => {
live = false;
};
}, [atRaw === null, atDir, atFrag, cwd]);
}, [atRaw === null, atDir, atFrag, cwd, fileRefRefreshKey]);
const atMatches = useMemo(
() => {
if (atRaw === null) return [];
@@ -817,6 +900,28 @@ export function Composer({
setDismissed(true);
}, [transientDismissSignal]);
const takeSelfDispatchedGuidance = useCallback((text: string): boolean => {
const idx = selfDispatchedGuidanceRef.current.findIndex((queued) => guidanceTextMatches(queued, text));
if (idx < 0) return false;
selfDispatchedGuidanceRef.current.splice(idx, 1);
return true;
}, []);
useEffect(() => {
if (!guidanceConsumedKey || guidanceConsumedKey === lastGuidanceConsumedKey.current) return;
lastGuidanceConsumedKey.current = guidanceConsumedKey;
const consumed = (guidanceConsumedText ?? "").trim();
if (consumed && takeSelfDispatchedGuidance(consumed)) return;
setPendingGuidance((items) => {
if (items.length === 0) return items;
const idx = consumed
? items.findIndex((item) => guidanceTextMatches(item.submitText, consumed) || guidanceTextMatches(item.text, consumed))
: -1;
const removeAt = idx >= 0 ? idx : 0;
return items.filter((_, index) => index !== removeAt);
});
}, [guidanceConsumedKey, guidanceConsumedText, takeSelfDispatchedGuidance]);
// When the @ trigger disappears (user deleted the @), close the past:chats
// sub-menu and reset related state. Without this, showPastChats can outlive
// the @ token and leave the session list visible with no way to dismiss it.
@@ -1111,7 +1216,7 @@ export function Composer({
}, [showToast, t]);
const submit = async () => {
if (disabled || submitDisabled || readOnly || submittingRef.current) return;
if (disabled || (!running && submitDisabled) || readOnly || submittingRef.current) return;
const submitDraftKey = activeDraftKeyRef.current;
const currentText = textRef.current;
const trimmedText = currentText.trim();
@@ -1150,6 +1255,16 @@ export function Composer({
const sessionContext = currentSessionRefs.length === 0 ? "" : await buildSessionContext(currentSessionRefs);
const baseSubmitText = [expandPastedBlocks(trimmedText), refs].filter(Boolean).join(trimmedText && refs ? " " : "");
const submitText = sessionContext ? `${sessionContext}${baseSubmitText}` : baseSubmitText;
if (running) {
const guidanceText = displayText.trim();
const guidanceSubmitText = submitText.trim();
if (guidanceText) {
const id = nextGuidanceId.current++;
setPendingGuidance((items) => [...items, { id, text: guidanceText, submitText: guidanceSubmitText || guidanceText }]);
}
clearSubmittedDraft(submitDraftKey);
return;
}
await onSend(displayText, submitText);
clearSubmittedDraft(submitDraftKey);
} catch (error) {
@@ -1160,6 +1275,27 @@ export function Composer({
}
};
const sendQueuedGuidance = async (item: PendingGuidance) => {
if (disabled || readOnly || guidanceSendingId !== null) return;
const displayText = item.text.trim();
const submitText = item.submitText.trim() || displayText;
if (!displayText || !submitText) return;
selfDispatchedGuidanceRef.current.push(submitText);
setGuidanceSendingId(item.id);
try {
await onSend(displayText, submitText);
setPendingGuidance((items) => items.filter((queued) => queued.id !== item.id));
window.setTimeout(() => {
takeSelfDispatchedGuidance(submitText);
}, 5000);
} catch (error) {
takeSelfDispatchedGuidance(submitText);
showToast(error instanceof Error ? error.message : String(error), "warn");
} finally {
setGuidanceSendingId((current) => (current === item.id ? null : current));
}
};
const readFileAsDataURL = (file: File) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
@@ -1929,6 +2065,21 @@ export function Composer({
return `${word}… ${fmtElapsed(elapsedMs)}${tok}`;
})()
: null;
const submitEmpty = !text.trim() && attachments.length === 0 && workspaceRefs.length === 0;
const submitBlocked = submitting || pendingPaste > 0 || (submitEmpty && !(goalModeOn && !activeGoal)) || disabled || (!running && submitDisabled) || readOnly;
const submitTooltip = running ? t("composer.queueGuidance") : t("composer.send");
const composerPlaceholder = readOnly
? t("composer.readOnlyChannel")
: disabled
? t("common.loading")
: running
? t("composer.steerPlaceholder")
: goalModeOn && !activeGoal
? t("composer.goalInputPlaceholder")
: t("composer.placeholder");
const hiddenGuidanceCount = Math.max(0, pendingGuidance.length - 2);
const visibleGuidance = guidanceExpanded ? pendingGuidance : pendingGuidance.slice(0, 2);
const showGuidanceExpander = pendingGuidance.length > 2;
const composerMetaClass = [
"composer-meta",
hasEffort ? "composer-meta--has-effort" : "composer-meta--no-effort",
@@ -2185,6 +2336,58 @@ export function Composer({
</div>
</div>
)}
{pendingGuidance.length > 0 && (
<div className="composer-guidance-shelf" aria-label={t("composer.guidanceQueue")}>
<div className="composer-guidance-head">
<span className="composer-guidance-head__label">
<CornerDownRight size={14} />
<span>{t("composer.guidanceCount", { n: pendingGuidance.length })}</span>
</span>
</div>
<div className="composer-guidance-list">
{visibleGuidance.map((item) => (
<div className="composer-guidance-item" key={item.id}>
<CornerDownRight size={14} className="composer-guidance-item__icon" />
<span className="composer-guidance-item__text">{item.text}</span>
<Tooltip label={t("composer.guidanceSend")}>
<button
className="composer-guidance-item__guide"
type="button"
aria-label={t("composer.guidanceSend")}
disabled={!running || disabled || readOnly || guidanceSendingId !== null}
onClick={() => void sendQueuedGuidance(item)}
>
<CornerDownRight size={13} />
<span>{t("composer.guidanceMode")}</span>
</button>
</Tooltip>
<Tooltip label={t("composer.guidanceDismiss")}>
<button
className="composer-guidance-item__action"
type="button"
aria-label={t("composer.guidanceDismiss")}
disabled={guidanceSendingId === item.id}
onClick={() => setPendingGuidance((items) => items.filter((queued) => queued.id !== item.id))}
>
<Trash2 size={14} />
</button>
</Tooltip>
</div>
))}
{showGuidanceExpander && (
<button
className="composer-guidance-more"
type="button"
aria-expanded={guidanceExpanded}
onClick={() => setGuidanceExpanded((value) => !value)}
>
{guidanceExpanded ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
<span>{guidanceExpanded ? t("composer.guidanceCollapse") : t("composer.guidanceRemaining", { n: hiddenGuidanceCount })}</span>
</button>
)}
</div>
</div>
)}
{(attachments.length > 0 || workspaceRefs.length > 0 || sessionRefs.length > 0) && (
<div className="composer-context" aria-label={t("composer.contextItems")}>
{sortComposerAttachments(attachments).map((a) => {
@@ -2193,10 +2396,11 @@ export function Composer({
<ComposerContextCard
key={a.path}
variant="attachment"
tooltipLabel={a.path}
tooltipLabel={a.previewUrl ? `${t("imageViewer.clickToPreview")} — ${a.path}` : a.path}
removeLabel={t("composer.removeImage")}
onRemove={() => removeAttachment(a.path)}
previewUrl={a.previewUrl}
onImageClick={a.previewUrl ? () => openComposerImageViewer(a.previewUrl!, attachmentName(a)) : undefined}
imageOnly={imageOnly}
name={attachmentName(a)}
meta={attachmentExt(attachmentName(a)) || t("msg.fileAttachment")}
@@ -2240,6 +2444,12 @@ export function Composer({
))}
</div>
)}
<ImageViewer
open={imageViewer.open}
imageUrl={imageViewer.url}
imageName={imageViewer.name}
onClose={closeComposerImageViewer}
/>
{activePastedBlocks.length > 0 && (
<div className="composer__pasted">
{activePastedBlocks.map((block) => {
@@ -2324,7 +2534,7 @@ export function Composer({
lastCompositionEndAt.current = Date.now();
}}
style={textareaStyle}
placeholder={readOnly ? t("composer.readOnlyChannel") : disabled ? t("common.loading") : goalModeOn && !activeGoal ? t("composer.goalInputPlaceholder") : t("composer.placeholder")}
placeholder={composerPlaceholder}
rows={1}
disabled={disabled || readOnly}
/>
@@ -2333,17 +2543,16 @@ export function Composer({
{composerPrompt}
</span>
)}
{!running && (
<Tooltip label={t("composer.send")}>
<button
className="composer__btn composer__btn--send"
onClick={submit}
disabled={submitting || pendingPaste > 0 || ((!text.trim() && attachments.length === 0 && workspaceRefs.length === 0) && !(goalModeOn && !activeGoal)) || disabled || submitDisabled || readOnly}
>
<ArrowUp size={16} />
</button>
</Tooltip>
)}
<Tooltip label={submitTooltip}>
<button
className={`composer__btn composer__btn--send${running ? " composer__btn--steer" : ""}`}
onClick={submit}
disabled={submitBlocked}
aria-label={submitTooltip}
>
<ArrowUp size={16} />
</button>
</Tooltip>
</div>
<div className={composerMetaClass}>
<div className="composer-meta__params">
@@ -12,6 +12,7 @@ export function ComposerContextCard({
removeDisabled = false,
removeIconSize,
previewUrl,
onImageClick,
imageOnly = false,
folder = false,
name,
@@ -26,6 +27,8 @@ export function ComposerContextCard({
removeDisabled?: boolean;
removeIconSize?: number;
previewUrl?: string;
/** called when the image thumbnail is clicked (only for image attachments with previewUrl) */
onImageClick?: () => void;
imageOnly?: boolean;
folder?: boolean;
name?: string;
@@ -46,7 +49,13 @@ export function ComposerContextCard({
<Tooltip label={tooltipLabel}>
<span className="composer-context__label">
{previewUrl ? (
<span className="composer-context__thumb">
<span
className={`composer-context__thumb${onImageClick ? " composer-context__thumb--interactive" : ""}`}
onClick={onImageClick}
role={onImageClick ? "button" : undefined}
tabIndex={onImageClick ? 0 : undefined}
onKeyDown={onImageClick ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onImageClick(); } } : undefined}
>
<img src={previewUrl} alt="" draggable={false} />
</span>
) : variant === "attachment" ? (
+286 -224
View File
@@ -1,6 +1,6 @@
// ContextPanel shows the active tab's context gauge and token usage.
// All visible text is routed through the i18n dictionary.
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { asArray } from "../lib/array";
import { app } from "../lib/bridge";
import { useI18n, type Locale, type Translator } from "../lib/i18n";
@@ -23,9 +23,9 @@ interface ContextPanelProps {
refreshKey?: number;
}
function fmtTokens(n: number): string {
if (n >= 1000) return `${Math.round(n / 1000)}k`;
return String(n);
function fmtFullTokens(n: number): string {
if (n <= 0) return "0";
return String(Math.round(n));
}
function fmtDuration(ms: number, t: Translator): string {
@@ -62,11 +62,6 @@ export function formatMetricTokens(tokens: number | undefined, locale: Locale |
return { display: exact, exact };
}
function fmtTurns(turns: number | undefined, t: Translator): string {
if (typeof turns !== "number" || turns < 0) return "-";
return t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns });
}
function fmtUsageCacheRate(usage?: WireUsage): string {
if (!usage) return "-";
const denom = usage.cacheHitTokens + usage.cacheMissTokens;
@@ -80,10 +75,44 @@ export function formatCacheHitRate(hitTokens: number, missTokens: number): strin
return `${((hitTokens / denom) * 100).toFixed(2)}%`;
}
interface HealthResult {
type MetricTone = "accent" | "good" | "notice" | "warn";
type UsageAnalysisView = "source" | "type";
type ContextUsageRefreshFields = Pick<
WireUsage,
"totalTokens" | "promptTokens" | "completionTokens" | "reasoningTokens" | "sessionCacheHitTokens" | "sessionCacheMissTokens"
>;
export function contextUsageRefreshKey(usage?: ContextUsageRefreshFields): string {
if (!usage) return "";
return [
usage.totalTokens ?? 0,
usage.promptTokens ?? 0,
usage.completionTokens ?? 0,
usage.reasoningTokens ?? 0,
usage.sessionCacheHitTokens ?? 0,
usage.sessionCacheMissTokens ?? 0,
].join(":");
}
export function cacheHitTone(hitTokens: number, missTokens: number): MetricTone | undefined {
const denom = hitTokens + missTokens;
if (denom <= 0) return undefined;
const pct = (hitTokens / denom) * 100;
if (pct >= 80) return "good";
if (pct >= 60) return "notice";
return "warn";
}
function formatSharePercent(value: number, total: number): string {
if (total <= 0 || value <= 0) return "-";
const pct = (value / total) * 100;
if (pct > 0 && pct < 1) return "<1%";
return `${Math.round(pct)}%`;
}
interface ContextWindowStatus {
tone: "good" | "notice" | "warn";
shortKey: DictKey;
vars: Record<string, string | number>;
key: DictKey;
}
export function contextCostDisplay({
@@ -97,24 +126,40 @@ export function contextCostDisplay({
sessionCurrency?: string;
usage?: Pick<WireUsage, "cost" | "costUsd" | "currency">;
}): { amount: number; currency?: string } {
// Session-scoped sources only: this value renders under the 会话费用 label,
// and falling back to a single request's usage.cost silently displayed one
// turn's spend as the whole session's. usage now contributes currency only.
if (info?.sessionCost && info.sessionCost > 0) {
return { amount: info.sessionCost, currency: info.sessionCurrency || sessionCurrency || usage?.currency };
}
if (sessionCost && sessionCost > 0) {
return { amount: sessionCost, currency: sessionCurrency || info?.sessionCurrency || usage?.currency };
}
if (usage?.cost && usage.cost > 0) {
return { amount: usage.cost, currency: usage.currency || sessionCurrency || info?.sessionCurrency };
}
if (info?.sessionCostUsd && info.sessionCostUsd > 0) {
return { amount: info.sessionCostUsd, currency: info.sessionCurrency || sessionCurrency || usage?.currency };
}
if (usage?.costUsd && usage.costUsd > 0) {
return { amount: usage.costUsd, currency: usage.currency || sessionCurrency || info?.sessionCurrency };
}
return { amount: 0, currency: info?.sessionCurrency || sessionCurrency || usage?.currency };
}
// contextSessionCache picks the session-cumulative cache hit/miss pair for the
// panel's session average. All-sources telemetry (panel info, then ContextInfo)
// wins over the wire session counters, which the Go agent scopes to the
// executor only — the same preference StatusBar applies — and the pair always
// comes from a single source so the computed rate never mixes scopes.
export function contextSessionCache(
info?: Pick<ContextPanelInfo, "sessionCacheHitTokens" | "sessionCacheMissTokens"> | null,
context?: Pick<ContextInfo, "cacheHitTokens" | "cacheMissTokens">,
usage?: Pick<WireUsage, "sessionCacheHitTokens" | "sessionCacheMissTokens">,
): { hit: number; miss: number } {
const infoHit = info?.sessionCacheHitTokens ?? 0;
const infoMiss = info?.sessionCacheMissTokens ?? 0;
if (infoHit + infoMiss > 0) return { hit: infoHit, miss: infoMiss };
const ctxHit = context?.cacheHitTokens ?? 0;
const ctxMiss = context?.cacheMissTokens ?? 0;
if (ctxHit + ctxMiss > 0) return { hit: ctxHit, miss: ctxMiss };
return { hit: usage?.sessionCacheHitTokens ?? 0, miss: usage?.sessionCacheMissTokens ?? 0 };
}
interface ContextBreakdown {
promptTokens: number;
completionTokens: number;
@@ -171,33 +216,11 @@ export function contextBreakdown(
};
}
function contextHealth(usagePct: number, cachePct: number, readCount: number): HealthResult {
if (usagePct >= 85) {
return {
tone: "warn",
shortKey: "context.healthNearLimitShort",
vars: { pct: usagePct },
};
}
if (readCount >= 8) {
return {
tone: "notice",
shortKey: "context.healthManyFilesShort",
vars: { count: readCount },
};
}
if (cachePct > 0 && cachePct < 50) {
return {
tone: "notice",
shortKey: "context.healthLowCacheShort",
vars: { pct: cachePct },
};
}
return {
tone: "good",
shortKey: "context.healthGoodShort",
vars: {},
};
export function contextWindowStatus(usagePct: number, compactPct: number): ContextWindowStatus {
if (usagePct >= 90) return { tone: "warn", key: "context.windowStatusNearLimit" };
if (compactPct > 0 && usagePct >= compactPct) return { tone: "warn", key: "context.windowStatusPastCompact" };
if (compactPct > 0 && usagePct >= Math.max(0, compactPct - 10)) return { tone: "notice", key: "context.windowStatusWatch" };
return { tone: "good", key: "context.windowStatusHealthy" };
}
const SOURCE_ORDER = ["executor", "planner", "subagent", "compaction", "classifier", "title"];
@@ -286,7 +309,6 @@ export function ContextPanel({
sessionTokens,
sessionCost,
sessionCurrency,
sessionTurns,
turnTokens,
turnCost,
balance,
@@ -295,24 +317,24 @@ export function ContextPanel({
}: ContextPanelProps) {
const { locale, t } = useI18n();
const [info, setInfo] = useState<ContextPanelInfo | null>(null);
const [analysisView, setAnalysisView] = useState<UsageAnalysisView>("source");
const refreshSeq = useRef(0);
const lastRefreshTime = useRef(0);
const usageRefreshKey = contextUsageRefreshKey(usage);
const refresh = useCallback(async () => {
if (!tabId) return;
const seq = ++refreshSeq.current;
try {
const next = await app.ContextPanel(tabId);
if (refreshSeq.current === seq) setInfo(next);
if (refreshSeq.current === seq) {
setInfo(next);
}
} catch {
/* bridge unavailable */
}
}, [tabId]);
useEffect(() => {
const id = window.setInterval(() => void refresh(), 2000);
return () => window.clearInterval(id);
}, [refresh]);
useEffect(() => {
refreshSeq.current += 1;
setInfo(null);
@@ -323,19 +345,23 @@ export function ContextPanel({
void refresh();
}, [refresh, refreshKey]);
const hasPanelUsage = Boolean(
(info?.requestCount ?? 0) > 0 ||
(info?.promptTokens ?? 0) > 0 ||
(info?.completionTokens ?? 0) > 0 ||
(info?.totalTokens ?? 0) > 0 ||
(info?.reasoningTokens ?? 0) > 0 ||
(info?.cacheHitTokens ?? 0) > 0 ||
(info?.cacheMissTokens ?? 0) > 0
);
// Refresh the panel snapshot while usage events stream. The key includes
// general token fields so providers without cache telemetry still tick.
useEffect(() => {
if (!usageRefreshKey) return;
const now = Date.now();
if (now - lastRefreshTime.current >= 1000) {
lastRefreshTime.current = now;
void refresh();
}
}, [usageRefreshKey, refresh]);
const usedTokens = context?.used && context.used > 0 ? context.used : info?.usedTokens ?? 0;
const windowTokens = context?.window && context.window > 0 ? context.window : info?.windowTokens ?? 0;
const promptTokens = hasPanelUsage ? info?.promptTokens ?? 0 : usage?.promptTokens ?? 0;
const completionTokens = hasPanelUsage ? info?.completionTokens ?? 0 : usage?.completionTokens ?? 0;
// Prefer live usage props (updated in real-time by the reducer during streaming)
// over the async-fetched info snapshot (only refreshed on turn_done).
const promptTokens = usage?.promptTokens ?? info?.promptTokens ?? 0;
const completionTokens = usage?.completionTokens ?? info?.completionTokens ?? 0;
const totalTokens = info?.totalTokens && info.totalTokens > 0
? info.totalTokens
: sessionTokens && sessionTokens > 0
@@ -343,33 +369,29 @@ export function ContextPanel({
: usage?.totalTokens && usage.totalTokens > 0
? usage.totalTokens
: promptTokens + completionTokens;
const reasoningTokens = hasPanelUsage ? info?.reasoningTokens ?? 0 : usage?.reasoningTokens ?? 0;
const cacheHitTokens = hasPanelUsage ? info?.cacheHitTokens ?? 0 : usage?.cacheHitTokens ?? 0;
const cacheMissTokens = hasPanelUsage ? info?.cacheMissTokens ?? 0 : usage?.cacheMissTokens ?? 0;
// Session-cumulative values for the metrics cards (方案A: 纯前端改数据源)
const sessionCacheHit = info?.sessionCacheHitTokens ?? usage?.sessionCacheHitTokens ?? context?.cacheHitTokens ?? 0;
const sessionCacheMiss = info?.sessionCacheMissTokens ?? usage?.sessionCacheMissTokens ?? context?.cacheMissTokens ?? 0;
const sessionCompletion = info?.sessionCompletionTokens ?? 0;
const sessionCacheHitMetric = formatMetricTokens(sessionCacheHit, locale);
const sessionCacheMissMetric = formatMetricTokens(sessionCacheMiss, locale);
const sessionCompletionMetric = formatMetricTokens(sessionCompletion, locale);
const reasoningTokens = usage?.reasoningTokens ?? info?.reasoningTokens ?? 0;
// Session-cumulative cache tokens for the top summary: all-sources telemetry
// first (matching the session cost and per-source rows in this panel — the
// wire session counters are executor-only), with the live counters bridging
// only a fresh session's first turn before the telemetry refresh. Hit and
// miss come as a pair from one source so the rate cannot mix scopes.
const { hit: sessionCacheHit, miss: sessionCacheMiss } = contextSessionCache(info, context, usage);
const totalTokensMetric = formatMetricTokens(totalTokens, locale);
const cost = contextCostDisplay({ info, sessionCost, sessionCurrency, usage });
const sourceUsageRows = contextSourceRows(info, sessionCurrency);
const showSourceUsageRows = sourceUsageRows.length > 0;
const sourceTotalTokens = sourceUsageRows.reduce((sum, row) => sum + sourceTokenTotal(row), 0);
const visibleSourceRows = sourceUsageRows.slice(0, 3);
const hiddenSourceRows = sourceUsageRows.slice(3);
const readFiles = asArray(info?.readFiles);
const changedFiles = asArray(info?.changedFiles);
const usagePct = windowTokens > 0 ? Math.min(100, Math.round((usedTokens / windowTokens) * 100)) : 0;
const compactPct = context?.compactRatio ? Math.round(context.compactRatio * 100) : 0;
const cacheDenom = cacheHitTokens + cacheMissTokens;
const cachePct = cacheDenom > 0 ? (cacheHitTokens / cacheDenom) * 100 : 0;
const cachePctDisplay = formatCacheHitRate(cacheHitTokens, cacheMissTokens);
const compactRatio = context?.compactRatio && context.compactRatio > 0 ? context.compactRatio : 0.8;
const compactPct = Math.round(compactRatio * 100);
const compactTokens = windowTokens > 0 ? Math.round(windowTokens * compactRatio) : 0;
const tokensUntilCompact = compactTokens > usedTokens ? compactTokens - usedTokens : 0;
const breakdown = contextBreakdown(usedTokens, windowTokens, promptTokens, completionTokens, reasoningTokens);
const donutStyle = {
background: `conic-gradient(#13a7a5 0 ${breakdown.promptPct}%, #2f6df6 ${breakdown.promptPct}% ${breakdown.completionPct}%, #f97316 ${breakdown.completionPct}% ${breakdown.reasoningPct}%, var(--border) ${breakdown.reasoningPct}% ${breakdown.otherPct}%, var(--border-soft) ${breakdown.otherPct}% 100%)`,
};
const eventTimes = [
...readFiles.map((file) => file.time),
...changedFiles.map((file) => file.latestTime ?? 0),
@@ -378,100 +400,165 @@ export function ContextPanel({
const elapsed = info?.elapsedMs && info.elapsedMs > 0 ? info.elapsedMs : derivedElapsed;
const derivedRequestCount = Math.max(readFiles.length + changedFiles.length, 0);
const requestCount = info?.requestCount && info.requestCount > 0 ? info.requestCount : derivedRequestCount;
const health = contextHealth(usagePct, Math.round(cachePct), readFiles.length);
const windowStatus = contextWindowStatus(usagePct, compactPct);
const balanceLabel = balance?.available && balance.display ? balance.display : "-";
const turnCostLabel = formatMoneyLocalized(turnCost, sessionCurrency, { locale, empty: "dash" });
const sessionCostLabel = formatMoneyLocalized(sessionCost, sessionCurrency, { locale, empty: "dash" });
const sessionCostLabel = formatMoneyLocalized(cost.amount, cost.currency, { locale, empty: "dash" });
const totalTokensTitle = totalTokensMetric.exact === "-" ? "-" : t("context.tokensValue", { value: totalTokensMetric.exact });
const usedLabel = fmtFullTokens(usedTokens);
const windowLabel = fmtFullTokens(windowTokens);
const compactRemainingLabel = tokensUntilCompact > 0 ? fmtFullTokens(tokensUntilCompact) : "0";
const compactMarkerPct = Math.max(0, Math.min(100, compactPct));
const usageMarkerPct = Math.max(6, Math.min(94, usagePct));
const compactLabelPct = Math.max(6, Math.min(94, compactMarkerPct));
const usageSummary = t("context.windowUsageSummary", { used: usedLabel, window: windowLabel, pct: usagePct });
const compactSummary = t("context.windowCompactRemaining", { used: usedLabel, window: windowLabel, tokens: compactRemainingLabel, pct: compactPct });
const activeAnalysisView: UsageAnalysisView = showSourceUsageRows ? analysisView : "type";
const tokenTypeRows = [
{ key: "prompt", label: t("context.prompt"), value: breakdown.promptTokens },
{ key: "completion", label: t("context.completion"), value: breakdown.completionTokens },
{ key: "reasoning", label: t("context.reasoning"), value: breakdown.reasoningTokens },
{ key: "other", label: t("context.other"), value: breakdown.otherTokens },
];
const tokenCompositionTotal = tokenTypeRows.reduce((sum, row) => sum + row.value, 0);
const renderSourceRow = (row: ContextSourceRow) => {
const inputMetric = formatMetricTokens(row.promptTokens, locale);
const outputMetric = formatMetricTokens(row.completionTokens, locale);
const hitMetric = formatMetricTokens(row.cacheHitTokens, locale);
const missMetric = formatMetricTokens(row.cacheMissTokens, locale);
const totalMetric = formatMetricTokens(sourceTokenTotal(row), locale);
const cacheReported = row.cacheHitTokens + row.cacheMissTokens > 0;
const cacheRate = cacheReported ? formatCacheHitRate(row.cacheHitTokens, row.cacheMissTokens) : t("context.cacheNotReported");
const costLabel = formatMoneyLocalized(row.cost, row.currency, { locale, empty: "dash" });
return (
<div className="context-panel__source-row" key={row.source}>
<div className="context-panel__source-head">
<span>
<i className={`context-panel__source-dot context-panel__source-tone--${sourceTone(row.source)}`} aria-hidden="true" />
{sourceLabel(row.label, t)}
</span>
<em>{t("context.sourceRequests", { count: row.requests })}</em>
</div>
<div className="context-panel__source-summary">
<SourceMetric label={t("context.total")} value={totalMetric.display} title={totalMetric.exact} />
<SourceMetric label={t("context.sourceCacheRate")} value={cacheRate} />
<SourceMetric label={t("context.sourceCost")} value={costLabel} />
</div>
<details className="context-panel__source-details">
<summary>{t("context.sourceDetails")}</summary>
<div className="context-panel__source-details-body">
<SourceSplitBar
label={`${t("context.sourceInput")}/${t("context.sourceOutput")}`}
segments={[
{ label: t("context.sourceInput"), value: row.promptTokens, tone: "input" },
{ label: t("context.sourceOutput"), value: row.completionTokens, tone: "output" },
]}
/>
{cacheReported ? (
<SourceSplitBar
label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`}
segments={[
{ label: t("context.sourceCacheHit"), value: row.cacheHitTokens, tone: "hit" },
{ label: t("context.sourceCacheMiss"), value: row.cacheMissTokens, tone: "miss" },
]}
compact
/>
) : (
<SourceSplitBar label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`} segments={[]} compact />
)}
<div className="context-panel__source-metrics">
<SourceMetric label={t("context.sourceInput")} value={inputMetric.display} title={inputMetric.exact} />
<SourceMetric label={t("context.sourceOutput")} value={outputMetric.display} title={outputMetric.exact} />
<SourceMetric label={t("context.sourceCacheHit")} value={hitMetric.display} title={hitMetric.exact} />
<SourceMetric label={t("context.sourceCacheMiss")} value={missMetric.display} title={missMetric.exact} />
</div>
</div>
</details>
</div>
);
};
return (
<div className="context-panel">
<div className="context-panel__body">
<section className="context-panel__overview">
<section className="context-panel__usage">
<SectionHeading title={t("context.windowTitle")} meta={t("context.windowSubtitle")} />
<div className="context-panel__usage-visual">
<div className="context-panel__donut" style={donutStyle}>
<div className="context-panel__donut-core">
<strong>{fmtTokens(usedTokens)}</strong>
<span>/ {fmtTokens(windowTokens)} tokens</span>
<SectionHeading title={t("context.windowTitle")} />
<div className={`context-panel__capacity-card context-panel__capacity-card--${windowStatus.tone}`}>
<div className="context-panel__capacity-top">
<span className="context-panel__capacity-status">{t(windowStatus.key)}</span>
<strong>{usedLabel}/{windowLabel}</strong>
</div>
<div className="context-panel__usage-progress context-panel__capacity-meter" aria-label={`${t(windowStatus.key)}. ${usageSummary}. ${compactSummary}`}>
<div className="context-panel__capacity-scale" aria-hidden="true">
<span className="context-panel__capacity-pin context-panel__capacity-pin--used" style={{ left: `${usageMarkerPct}%` }}>{usagePct}%</span>
<span className="context-panel__capacity-pin context-panel__capacity-pin--compact" style={{ left: `${compactLabelPct}%` }}>{compactPct}%</span>
</div>
<div className="context-panel__progress-track" aria-hidden="true">
<span className="context-panel__progress-segment context-panel__progress-segment--prompt" style={{ width: `${breakdown.promptPct}%` }} />
<span className="context-panel__progress-segment context-panel__progress-segment--completion" style={{ width: `${Math.max(0, breakdown.completionPct - breakdown.promptPct)}%` }} />
<span className="context-panel__progress-segment context-panel__progress-segment--reasoning" style={{ width: `${Math.max(0, breakdown.reasoningPct - breakdown.completionPct)}%` }} />
<span className="context-panel__progress-segment context-panel__progress-segment--other" style={{ width: `${Math.max(0, breakdown.otherPct - breakdown.reasoningPct)}%` }} />
<span className="context-panel__compact-marker" style={{ left: `${compactMarkerPct}%` }} />
</div>
</div>
<div className="context-panel__percent">{usagePct}%</div>
</div>
<div className="context-panel__usage-progress" aria-label={t("context.windowSubtitle")}>
<div className="context-panel__progress-head">
<strong>{fmtTokens(usedTokens)} / {fmtTokens(windowTokens)}</strong>
<span>{usagePct}%</span>
</div>
<div className="context-panel__progress-track" aria-hidden="true">
<span className="context-panel__progress-fill" style={{ width: `${usagePct}%` }} />
<div className="context-panel__capacity-foot">
<span>{t("context.windowUsedLabel")}</span>
<span className="context-panel__capacity-remaining">
<span>{t("context.windowCompactDistance")}</span>
<strong>{compactRemainingLabel}</strong>
</span>
</div>
</div>
<div className="context-panel__summary-rows">
<MiniStat label={t("status.compactLabel")} value={compactPct > 0 ? `${compactPct}%` : "-"} />
<MiniStat label={t("status.cacheAvgLabel")} value={formatCacheHitRate(sessionCacheHit, sessionCacheMiss)} />
<MiniStat label={t("context.sessionCost")} value={sessionCostLabel} />
<MiniStat label={t("status.sessionTurnsLabel")} value={fmtTurns(sessionTurns, t)} />
</div>
<div className="context-panel__breakdown">
<TokenLegend label={t("context.prompt")} value={breakdown.promptTokens} color="prompt" />
<TokenLegend label={t("context.completion")} value={breakdown.completionTokens} color="completion" />
<TokenLegend label={t("context.reasoning")} value={breakdown.reasoningTokens} color="reasoning" />
<TokenLegend label={t("context.other")} value={breakdown.otherTokens} color="other" />
<div className="context-panel__total">
<span>{t("context.total")}</span>
<strong>{usedTokens.toLocaleString()} / {windowTokens.toLocaleString()}</strong>
</section>
<section className="context-panel__section context-panel__session-section">
<SectionHeading title={t("context.sessionMetrics")} />
<div className="context-panel__session-metrics">
<div className="context-panel__summary-rows">
<MiniStat label={t("status.cacheAvgLabel")} value={formatCacheHitRate(sessionCacheHit, sessionCacheMiss)} tone={cacheHitTone(sessionCacheHit, sessionCacheMiss)} />
<MiniStat label={t("context.sessionCost")} value={sessionCostLabel} />
<MiniStat label={t("context.time")} value={fmtDuration(elapsed, t)} />
<MiniStat label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
<MiniStat label={t("context.sessionTokensShort")} value={totalTokensMetric.display} title={totalTokensTitle} wide />
</div>
</div>
</section>
<section className="context-panel__creation-grid" aria-label={t("context.overview")}>
<MetricCard label={t("context.time")} value={fmtDuration(elapsed, t)} />
<MetricCard label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
<MetricCard label={t("status.cacheLabel")} value={fmtUsageCacheRate(usage)} tone="accent" />
<MetricCard label={t("status.turnTokensLabel")} value={fmtOptionalTokens(turnTokens)} />
<MetricCard label={t("status.turnCostLabel")} value={turnCostLabel} />
<MetricCard label={t("status.balanceLabel")} value={balanceLabel} tone="accent" />
</section>
<section className="context-panel__section">
<SectionHeading title={t("context.runtimeMetrics")} />
<div className="context-panel__runtime-card">
<div className="context-panel__runtime-summary">
<RuntimeMetric label={t("context.time")} value={fmtDuration(elapsed, t)} />
<RuntimeMetric label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
<RuntimeMetric label={t("context.outputTokens")} value={sessionCompletionMetric.display} title={sessionCompletionMetric.exact} />
<RuntimeMetric label={t("context.sessionTokens")} value={totalTokensMetric.display} title={totalTokensMetric.exact} />
</div>
<div className="context-panel__runtime-cache">
<div className="context-panel__runtime-cache-head">
<span>{t("context.inputCache")}</span>
<strong>{formatCacheHitRate(sessionCacheHit, sessionCacheMiss)}</strong>
<section className="context-panel__section context-panel__analysis">
<SectionHeading title={t("context.usageAnalysis")}>
{showSourceUsageRows && (
<div className="context-panel__view-switch" role="tablist" aria-label={t("context.usageAnalysisView")}>
<button
type="button"
className={`context-panel__view-tab${activeAnalysisView === "source" ? " context-panel__view-tab--active" : ""}`}
role="tab"
aria-selected={activeAnalysisView === "source"}
onClick={() => setAnalysisView("source")}
>
{t("context.usageAnalysisSource")}
</button>
<button
type="button"
className={`context-panel__view-tab${activeAnalysisView === "type" ? " context-panel__view-tab--active" : ""}`}
role="tab"
aria-selected={activeAnalysisView === "type"}
onClick={() => setAnalysisView("type")}
>
{t("context.usageAnalysisType")}
</button>
</div>
<SourceSplitBar
label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`}
segments={[
{ label: t("context.sourceCacheHit"), value: sessionCacheHit, tone: "hit" },
{ label: t("context.sourceCacheMiss"), value: sessionCacheMiss, tone: "miss" },
]}
/>
<div className="context-panel__runtime-cache-values">
<SourceMetric label={t("context.sourceCacheHit")} value={sessionCacheHitMetric.display} title={sessionCacheHitMetric.exact} />
<SourceMetric label={t("context.sourceCacheMiss")} value={sessionCacheMissMetric.display} title={sessionCacheMissMetric.exact} />
</div>
</div>
</div>
</section>
<section className="context-panel__section">
<SectionHeading title={t("context.costMetrics")} />
<div className="context-panel__stats">
<MetricCard label={t("context.cacheHit")} value={cachePctDisplay} tone="accent" />
<MetricCard label={t("context.sessionCost")} value={formatMoneyLocalized(cost.amount, cost.currency, { locale, empty: "dash" })} />
</div>
{showSourceUsageRows && (
<div className="context-panel__source-list" aria-label={t("context.sourceBreakdown")}>
)}
</SectionHeading>
{activeAnalysisView === "source" ? (
<div className="context-panel__source-list" aria-label={t("context.sourceBreakdown")} role="tabpanel">
<div className="context-panel__source-overview">
<div className="context-panel__source-overview-head">
<strong>{t("context.sourceBreakdown")}</strong>
<span>{t("context.sourceShareTokens")}</span>
<strong>{t("context.sourceShareTitle")}</strong>
</div>
<div className="context-panel__source-sharebar" aria-hidden="true">
{sourceUsageRows.map((row) => {
@@ -498,70 +585,51 @@ export function ContextPanel({
})}
</div>
</div>
{sourceUsageRows.map((row) => {
const inputMetric = formatMetricTokens(row.promptTokens, locale);
const outputMetric = formatMetricTokens(row.completionTokens, locale);
const hitMetric = formatMetricTokens(row.cacheHitTokens, locale);
const missMetric = formatMetricTokens(row.cacheMissTokens, locale);
const totalMetric = formatMetricTokens(sourceTokenTotal(row), locale);
const cacheReported = row.cacheHitTokens + row.cacheMissTokens > 0;
const cacheRate = cacheReported ? formatCacheHitRate(row.cacheHitTokens, row.cacheMissTokens) : t("context.cacheNotReported");
const costLabel = formatMoneyLocalized(row.cost, row.currency, { locale, empty: "dash" });
return (
<div className="context-panel__source-row" key={row.source}>
<div className="context-panel__source-head">
<span>
<i className={`context-panel__source-dot context-panel__source-tone--${sourceTone(row.source)}`} aria-hidden="true" />
{sourceLabel(row.label, t)}
</span>
<em>{t("context.sourceRequests", { count: row.requests })}</em>
</div>
<div className="context-panel__source-summary">
<SourceMetric label={t("context.total")} value={totalMetric.display} title={totalMetric.exact} />
<SourceMetric label={t("context.sourceCacheRate")} value={cacheRate} />
<SourceMetric label={t("context.sourceCost")} value={costLabel} />
</div>
<SourceSplitBar
label={`${t("context.sourceInput")}/${t("context.sourceOutput")}`}
segments={[
{ label: t("context.sourceInput"), value: row.promptTokens, tone: "input" },
{ label: t("context.sourceOutput"), value: row.completionTokens, tone: "output" },
]}
/>
{cacheReported ? (
<SourceSplitBar
label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`}
segments={[
{ label: t("context.sourceCacheHit"), value: row.cacheHitTokens, tone: "hit" },
{ label: t("context.sourceCacheMiss"), value: row.cacheMissTokens, tone: "miss" },
]}
compact
/>
) : (
<SourceSplitBar label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`} segments={[]} compact />
)}
<details className="context-panel__source-details">
<summary>{t("context.sourceDetails")}</summary>
<div className="context-panel__source-metrics">
<SourceMetric label={t("context.sourceInput")} value={inputMetric.display} title={inputMetric.exact} />
<SourceMetric label={t("context.sourceOutput")} value={outputMetric.display} title={outputMetric.exact} />
<SourceMetric label={t("context.sourceCacheHit")} value={hitMetric.display} title={hitMetric.exact} />
<SourceMetric label={t("context.sourceCacheMiss")} value={missMetric.display} title={missMetric.exact} />
</div>
</details>
{visibleSourceRows.map(renderSourceRow)}
{hiddenSourceRows.length > 0 && (
<details className="context-panel__source-more">
<summary>{t("context.moreSources", { count: hiddenSourceRows.length })}</summary>
<div className="context-panel__source-more-list">
{hiddenSourceRows.map(renderSourceRow)}
</div>
);
})}
</details>
)}
</div>
) : (
<div className="context-panel__type-panel" aria-label={t("context.tokenBreakdown")} role="tabpanel">
<div className="context-panel__type-overview">
<div className="context-panel__type-overview-head">
<strong>{t("context.tokenBreakdown")}</strong>
</div>
<div className="context-panel__type-sharebar" aria-hidden="true">
{tokenTypeRows.map((row) => row.value > 0 ? (
<span
className={`context-panel__type-share context-panel__type-share--${row.key}`}
key={row.key}
style={{ width: `${(row.value / Math.max(1, tokenCompositionTotal)) * 100}%` }}
/>
) : null)}
</div>
<div className="context-panel__type-legend">
{tokenTypeRows.map((row) => (
<span key={row.key}>
<i className={`context-panel__type-dot context-panel__type-dot--${row.key}`} aria-hidden="true" />
{row.label} {formatSharePercent(row.value, tokenCompositionTotal)}
</span>
))}
</div>
</div>
<details className="context-panel__breakdown-details">
<summary>{t("context.sourceDetails")}</summary>
<div className="context-panel__breakdown">
{tokenTypeRows.map((row) => (
<TokenLegend key={row.key} label={row.label} value={row.value} color={row.key} />
))}
</div>
</details>
</div>
)}
</section>
<section className="context-panel__section context-panel__section--status">
<SectionHeading title={t("context.sessionStatus")} />
<div className="context-panel__stats">
<MetricCard label={t("context.health")} value={t(health.shortKey, health.vars)} tone={health.tone} />
<MetricCard label={t("context.compaction")} value={compactPct > 0 ? `${compactPct}%` : "-"} />
</div>
</section>
</section>
</div>
@@ -569,11 +637,12 @@ export function ContextPanel({
);
}
function SectionHeading({ title, meta }: { title: string; meta?: string }) {
function SectionHeading({ title, meta, children }: { title: string; meta?: string; children?: ReactNode }) {
return (
<header className="context-panel__section-head">
<h3>{title}</h3>
{meta && <span>{meta}</span>}
{children}
</header>
);
}
@@ -588,11 +657,14 @@ function TokenLegend({ label, value, color }: { label: string; value: number; co
);
}
function MiniStat({ label, value }: { label: string; value: string }) {
function MiniStat({ label, value, title, tone, wide }: { label: string; value: string; title?: string; tone?: MetricTone; wide?: boolean }) {
const toneClass = tone ? ` context-panel__mini-stat--${tone}` : "";
const wideClass = wide ? " context-panel__mini-stat--wide" : "";
const exactTitle = title && title !== value ? title : undefined;
return (
<div className="context-panel__mini-stat">
<div className={`context-panel__mini-stat${toneClass}${wideClass}`} aria-label={exactTitle ? `${label}: ${exactTitle}` : undefined}>
<span>{label}</span>
<strong>{value}</strong>
<strong title={exactTitle}>{value}</strong>
</div>
);
}
@@ -609,16 +681,6 @@ function MetricCard({ label, value, valueTitle, tone, wide }: { label: string; v
);
}
function RuntimeMetric({ label, value, title }: { label: string; value: string; title?: string }) {
const exactTitle = title && title !== value ? title : undefined;
return (
<div className="context-panel__runtime-metric" aria-label={exactTitle ? `${label}: ${exactTitle}` : undefined}>
<span>{label}</span>
<strong title={exactTitle}>{value}</strong>
</div>
);
}
function SourceMetric({ label, value, title }: { label: string; value: string; title?: string }) {
const exactTitle = title && title !== value ? title : undefined;
return (
@@ -6,6 +6,13 @@ import { app } from "../lib/bridge";
import type { DirEntry } from "../lib/types";
import { VirtualMenu } from "./VirtualMenu";
const FILE_REF_SEARCH_CACHE_TTL_MS = 5000;
type FileRefSearchCacheEntry = {
entries: DirEntry[];
cachedAt: number;
};
export function dirEntrySubmitPath(entry: DirEntry, atDir: string): string {
return entry.path || atDir + entry.name;
}
@@ -58,7 +65,7 @@ export function useFileReferenceMenu(text: string, cwd?: string) {
const [active, setActive] = useState(0);
const [dismissed, setDismissed] = useState(false);
const dirCache = useRef<Record<string, DirEntry[]>>({});
const searchCache = useRef<Record<string, DirEntry[]>>({});
const searchCache = useRef<Record<string, FileRefSearchCacheEntry>>({});
const prevCwdRef = useRef(cwd);
useEffect(() => {
@@ -82,15 +89,17 @@ export function useFileReferenceMenu(text: string, cwd?: string) {
const cached = dirCache.current[atDir];
if (cached) {
setEntries(cached);
return;
} else {
setEntries([]);
}
let live = true;
app
.ListDir(atDir)
.then((next) => {
const list = asArray(next);
if (!live) return;
dirCache.current[atDir] = list;
if (live) setEntries(list);
setEntries(list);
})
.catch(() => {});
return () => {
@@ -105,17 +114,19 @@ export function useFileReferenceMenu(text: string, cwd?: string) {
}
const cached = searchCache.current[atFrag];
if (cached) {
setSearchEntries(cached);
return;
setSearchEntries(cached.entries);
if (Date.now() - cached.cachedAt < FILE_REF_SEARCH_CACHE_TTL_MS) return;
} else {
setSearchEntries([]);
}
setSearchEntries([]);
let live = true;
app
.SearchFileRefs(atFrag)
.then((next) => {
const list = next ?? [];
searchCache.current[atFrag] = list;
if (live) setSearchEntries(list);
const list = asArray(next);
if (!live) return;
searchCache.current[atFrag] = { entries: list, cachedAt: Date.now() };
setSearchEntries(list);
})
.catch(() => {});
return () => {
@@ -470,6 +470,7 @@ export function HistoryPanel({
{!isTrash && s.current && <span className="hist-item__badge hist-item__badge--current">{tr("history.current")}</span>}
{!isTrash && !s.current && s.open && <span className="hist-item__badge hist-item__badge--open">{tr("history.open")}</span>}
{isTrash && <span className="hist-item__badge hist-item__badge--deleted">{tr("history.deleted")}</span>}
{s.recovered && <span className="hist-item__badge">{tr("recovery.badge")}</span>}
{sessionLocation(s, tr) && <span className="hist-item__scope">{sessionLocation(s, tr)}</span>}
<span className="hist-item__metaspacer" />
<span className="hist-item__stat">{tr(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns })}</span>
@@ -0,0 +1,99 @@
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { useT } from "../lib/i18n";
export interface ImageViewerProps {
open: boolean;
/** data-URL of the image to display */
imageUrl: string;
/** optional filename shown at the bottom */
imageName?: string;
onClose: () => void;
}
/**
* ImageViewer renders a full-size image preview in a portal overlay.
*
* It follows the same portal pattern as MermaidDiagram fullscreen:
* the overlay is portaled into .chat-pane (fallback document.body) so it
* covers the chat area without being clipped by overflow containers.
*/
export function ImageViewer({ open, imageUrl, imageName, onClose }: ImageViewerProps) {
const t = useT();
const [portalTarget, setPortalTarget] = useState<Element | null>(null);
const [visible, setVisible] = useState(false);
// Resolve portal target when opening.
useEffect(() => {
if (!open) {
setVisible(false);
setPortalTarget(null);
return;
}
const target = document.querySelector(".chat-pane") ?? document.body;
setPortalTarget(target);
// Trigger enter animation on the next frame.
const raf = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(raf);
}, [open]);
// Close on Escape.
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open, onClose]);
// Prevent body scroll while open.
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
},
[onClose],
);
if (!open || !portalTarget || !imageUrl) return null;
const overlay = (
<div
className={`image-viewer-backdrop${visible ? " image-viewer--enter" : ""}`}
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-label={imageName || t("imageViewer.title")}
>
<button
className="image-viewer__close"
type="button"
onClick={onClose}
aria-label={t("imageViewer.closePreview")}
>
<X size={22} />
</button>
<div className="image-viewer__content">
<img
className="image-viewer__image"
src={imageUrl}
alt={imageName || ""}
draggable={false}
/>
{imageName && <div className="image-viewer__name">{imageName}</div>}
</div>
</div>
);
return createPortal(overlay, portalTarget);
}
+170 -22
View File
@@ -1,4 +1,4 @@
import { memo, useEffect, useRef, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from "react";
import { BrainCircuit, ChevronDown, ChevronRight, FileText, Folder, GitBranch, Image, MessageSquare, Pencil, RotateCcw, ScrollText } from "lucide-react";
import { Markdown } from "./Markdown";
@@ -10,6 +10,7 @@ import type { DisplayAttachment } from "../lib/attachmentDisplay";
import { app } from "../lib/bridge";
import { replaySubmitText } from "../lib/editReplay";
import { useT } from "../lib/i18n";
import { ImageViewer } from "./ImageViewer";
import { Tooltip } from "./Tooltip";
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
import { displayReasoningText } from "../lib/reasoningDisplay";
@@ -81,6 +82,31 @@ function mergeDisplayAttachments(existing: DisplayAttachment[], incoming: Displa
return merged;
}
type PastedBlockInfo = {
label: string;
content: string;
};
const PASTE_LABEL_RE = /\[(?:已粘贴文本|已貼上文字|Pasted text) #\d+ · \d+ (?:行|lines)\]/g;
export function parsePastedBlocks(text: string, submitText?: string): PastedBlockInfo[] {
const labels = text.match(PASTE_LABEL_RE);
if (!labels || labels.length === 0 || !submitText) return [];
const unique = [...new Set(labels)];
const blocks: PastedBlockInfo[] = [];
for (const label of unique) {
const beginMarker = `--- Begin ${label} ---`;
const endMarker = `--- End ${label} ---`;
const beginIdx = submitText.indexOf(beginMarker);
const endIdx = submitText.indexOf(endMarker);
if (beginIdx < 0 || endIdx <= beginIdx) continue;
const contentStart = beginIdx + beginMarker.length;
const content = submitText.slice(contentStart, endIdx).replace(/^\r?\n/, "");
blocks.push({ label, content });
}
return blocks;
}
function MemoryCitations({ citations }: { citations?: MemoryCitation[] }) {
const t = useT();
const bodyRef = useRef<HTMLDivElement>(null);
@@ -181,6 +207,65 @@ export function UserMessage({
const [editSubmitting, setEditSubmitting] = useState(false);
const editRef = useRef<HTMLTextAreaElement>(null);
const [imagePreviews, setImagePreviews] = useState<Record<string, string>>({});
const [imageViewer, setImageViewer] = useState<{ open: boolean; url: string; name: string }>({ open: false, url: "", name: "" });
const openImageViewer = useCallback(async (path: string, name: string) => {
let url = imagePreviews[path];
if (!url) {
try {
url = await app.AttachmentDataURL(path);
setImagePreviews((prev) => (prev[path] ? prev : { ...prev, [path]: url }));
} catch {
return;
}
}
setImageViewer({ open: true, url, name });
}, [imagePreviews]);
const closeImageViewer = useCallback(() => {
setImageViewer((prev) => (prev.open ? { ...prev, open: false } : prev));
}, []);
const pasteBlocks = useMemo(() => parsePastedBlocks(actionText, submitText), [actionText, submitText]);
const [expandedPasteLabels, setExpandedPasteLabels] = useState<Record<string, boolean>>({});
type DisplaySegment =
| { type: "text"; content: string }
| { type: "paste"; block: PastedBlockInfo };
const displaySegments = useMemo((): DisplaySegment[] => {
if (pasteBlocks.length === 0) return [{ type: "text", content: displayText }];
const segments: DisplaySegment[] = [];
// Order blocks by their position in the text so cards appear inline.
const ordered = pasteBlocks
.map((b) => ({ block: b, pos: displayText.indexOf(b.label) }))
.filter((x) => x.pos >= 0)
.sort((a, b) => a.pos - b.pos);
let remaining = displayText;
for (const { block } of ordered) {
const idx = remaining.indexOf(block.label);
if (idx < 0) continue;
// Text before the label: strip the trailing newline that separated the
// label from the preceding line so the card sits tight against the text.
if (idx > 0) {
let before = remaining.slice(0, idx);
before = before.replace(/\n$/, "");
if (before) segments.push({ type: "text", content: before });
}
segments.push({ type: "paste", block });
remaining = remaining.slice(idx + block.label.length);
}
// Strip the leading newline that followed the label.
remaining = remaining.replace(/^\n/, "");
if (remaining.trim()) segments.push({ type: "text", content: remaining });
return segments.length > 0 ? segments : [{ type: "text", content: displayText }];
}, [displayText, pasteBlocks]);
const togglePasteExpand = (label: string) => {
setExpandedPasteLabels((prev) => ({
...prev,
[label]: !prev[label],
}));
};
const orderedDraftAttachments = sortDisplayAttachments(draftAttachments);
const imagePreviewKey = orderedAttachments
.concat(orderedDraftAttachments)
@@ -305,11 +390,12 @@ export function UserMessage({
<ComposerContextCard
key={attachment.path}
variant={attachment.source === "workspace" ? "workspace" : "attachment"}
tooltipLabel={attachment.source === "workspace" ? formatAttachmentRefForSubmit(attachment) : attachment.path}
tooltipLabel={imagePreview ? `${t("imageViewer.clickToPreview")} — ${attachment.path}` : attachment.source === "workspace" ? formatAttachmentRefForSubmit(attachment) : attachment.path}
removeLabel={attachment.source === "workspace" ? t("composer.removeReference") : t("composer.removeImage")}
removeDisabled={editSubmitting}
onRemove={() => removeDraftAttachment(attachment.path)}
previewUrl={imagePreview}
onImageClick={imagePreview ? () => openImageViewer(attachment.path, attachment.name) : undefined}
imageOnly={imageOnly}
folder={attachment.kind === "folder"}
label={attachment.kind === "folder" ? `${attachment.name}/` : attachment.name}
@@ -355,26 +441,78 @@ export function UserMessage({
)}
</div>
) : (
displayText && <div className="msg__text">{displayText}</div>
<>
{displaySegments.map((seg, i) => {
if (seg.type === "text") {
return seg.content ? <div className="msg__text" key={`s${i}`}>{seg.content}</div> : null;
}
const expanded = Boolean(expandedPasteLabels[seg.block.label]);
return (
<div className="msg-pasted" key={seg.block.label}>
<div className="msg-pasted-block">
<div className="msg-pasted-head">
<FileText size={15} />
<span className="msg-pasted-label">{seg.block.label}</span>
<div className="msg-pasted-actions">
<Tooltip label={t(expanded ? "msg.pastedCollapseTooltip" : "msg.pastedExpandTooltip")}>
<button type="button" onClick={() => togglePasteExpand(seg.block.label)}>
{expanded ? t("common.collapse") : t("composer.pastedExpand")}
</button>
</Tooltip>
</div>
</div>
{expanded && (
<div className="msg-pasted-expanded">{seg.block.content}</div>
)}
</div>
</div>
);
})}
</>
)}
{failed && <div className="msg__send-failed">{t("msg.sendFailed")}</div>}
{orderedAttachments.length > 0 && (
<div className="msg-attachments" aria-label={t("msg.attachments")}>
{orderedAttachments.map((attachment, index) => (
<div className={`msg-attachment msg-attachment--${attachment.kind}`} key={`${attachment.path}:${index}`} title={attachment.path}>
<span className={`msg-attachment__icon msg-attachment__icon--${attachment.kind}`} aria-hidden="true">
{attachment.kind === "image" && imagePreviews[attachment.path] ? <img src={imagePreviews[attachment.path]} alt="" draggable={false} /> : attachmentIcon(attachment.kind)}
</span>
<span className="msg-attachment__main">
<span className="msg-attachment__name">{attachment.name}</span>
<span className="msg-attachment__meta">
{attachment.kind === "folder"
? t("msg.folderReference")
: `${attachment.ext || t("msg.fileAttachment")} · ${attachment.source === "workspace" ? t("msg.workspaceReference") : attachment.kind === "image" ? t("msg.imageAttachment") : t("msg.fileAttachment")}`}
{orderedAttachments.map((attachment, index) => {
const isImage = attachment.kind === "image";
const el = (
<div
className={`msg-attachment msg-attachment--${attachment.kind}`}
key={isImage ? undefined : `${attachment.path}:${index}`}
title={isImage ? undefined : attachment.path}
onClick={isImage ? () => openImageViewer(attachment.path, attachment.name) : undefined}
role={isImage ? "button" : undefined}
tabIndex={isImage ? 0 : undefined}
onKeyDown={isImage ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openImageViewer(attachment.path, attachment.name); } } : undefined}
>
<span className={`msg-attachment__icon msg-attachment__icon--${attachment.kind}`} aria-hidden="true">
{isImage && imagePreviews[attachment.path] ? <img src={imagePreviews[attachment.path]} alt="" draggable={false} /> : attachmentIcon(attachment.kind)}
</span>
</span>
</div>
))}
<span className="msg-attachment__main">
<span className="msg-attachment__name">{attachment.name}</span>
<span className="msg-attachment__meta">
{attachment.kind === "folder"
? t("msg.folderReference")
: `${attachment.ext || t("msg.fileAttachment")} · ${attachment.source === "workspace" ? t("msg.workspaceReference") : attachment.kind === "image" ? t("msg.imageAttachment") : t("msg.fileAttachment")}`}
</span>
</span>
</div>
);
if (isImage) {
return (
<Tooltip key={`${attachment.path}:${index}`} label={t("imageViewer.clickToPreview")} block>
{el}
</Tooltip>
);
}
return el;
})}
<ImageViewer
open={imageViewer.open}
imageUrl={imageViewer.url}
imageName={imageViewer.name}
onClose={closeImageViewer}
/>
</div>
)}
</div>
@@ -419,6 +557,7 @@ export function TurnActions({
actionPending = false,
rewindDisabled = false,
hoverMenus = false,
isLastTurn = false,
}: {
text: string;
turn?: number;
@@ -429,6 +568,8 @@ export function TurnActions({
actionPending?: boolean;
rewindDisabled?: boolean;
hoverMenus?: boolean;
/** true when this is the last user turn — disables "summarize after" */
isLastTurn?: boolean;
}) {
const t = useT();
const [confirmScope, setConfirmScope] = useState<MessageActionScope | null>(null);
@@ -439,6 +580,9 @@ export function TurnActions({
if ((scope === "fork" || scope === "summ-from" || scope === "conversation") && !checkpoint.canConversation) {
return t("rewind.disabledNoBoundary");
}
if (scope === "summ-from" && isLastTurn) {
return t("rewind.disabledNoLater");
}
if (scope === "summ-upto") {
if (!checkpoint.canConversation) return t("rewind.disabledNoBoundary");
if ((turn ?? 0) <= 0) return t("rewind.disabledNoEarlier");
@@ -483,9 +627,9 @@ export function TurnActions({
}
};
const actionMeta = (scope: MessageActionScope): string => {
if ((scope === "code" || scope === "both") && checkpoint?.files?.length) {
const total = checkpoint.files.length;
const turnCount = checkpoint.turnFileCount ?? 0;
const total = checkpoint?.fileCount ?? checkpoint?.files?.length ?? 0;
if ((scope === "code" || scope === "both") && total > 0) {
const turnCount = checkpoint?.turnFileCount ?? 0;
if (turnCount > 0 && turnCount < total) {
return `${t("rewind.filesChanged", { count: total })} (${t("rewind.turnFiles", { count: turnCount })})`;
}
@@ -496,12 +640,16 @@ export function TurnActions({
const actionTooltipLabel = (scope: MessageActionScope) => {
const reason = actionDisabledReason(scope);
if (reason) return <span>{reason}</span>;
if ((scope === "code" || scope === "both") && checkpoint?.files?.length) {
const files = checkpoint?.files ?? [];
const total = checkpoint?.fileCount ?? files.length;
if ((scope === "code" || scope === "both") && total > 0) {
const hidden = Math.max(0, total - files.length);
return (
<div className="rewind__files-tooltip">
{checkpoint.files.map((file) => (
{files.map((file) => (
<div key={file}>{file.split(/[/\\]/).pop() || file}</div>
))}
{hidden > 0 && <div>+{hidden}</div>}
</div>
);
}
+145 -10
View File
@@ -123,16 +123,20 @@ function topicIsActive(node: ProjectNode, activeScope?: string, activeWorkspaceR
);
}
function topicMetaLine(node: ProjectNode, t: Translator, compact = false): string {
export function projectTreeTopicMetaLine(node: ProjectNode, t: Translator, compact = false): string {
const parts: string[] = [];
const turns = node.turns ?? 0;
if (turns > 0) parts.push(t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns }));
const activityAt = node.lastActivityAt || node.createdAt || 0;
if (activityAt) parts.push(topicActivityLabel(activityAt, t, compact));
if (parts.length === 0) parts.push(t("projectTree.justNow"));
if (parts.length === 0) parts.push(t("projectTree.previously"));
return parts.join(" · ");
}
function topicUnknownTimeLabel(node: ProjectNode, t: Translator): string {
return topicActivityAt(node) ? "" : t("projectTree.previously");
}
const topicStatusLabels: Record<ProjectTopicStatus, DictKey> = {
thinking: "projectTree.status.thinking",
streaming: "projectTree.status.streaming",
@@ -159,6 +163,43 @@ function topicStatusLabel(node: ProjectNode, t: Translator): string {
return status ? t(topicStatusLabels[status]) : "";
}
function topicActivityAt(node: ProjectNode): number {
return node.lastActivityAt || node.createdAt || 0;
}
export function projectTreeReadActivityKey(node: ProjectNode): string | null {
const request = projectTreeTopicOpenRequest(node);
if (!request?.topicId) return null;
return [
request.scope,
request.workspaceRoot,
request.topicId,
request.sessionPath ?? "",
].join("\u001f");
}
type ProjectTreeReadActivity = Record<string, number>;
export function projectTreeTopicHasUnreadActivity(
node: ProjectNode,
readActivity: ProjectTreeReadActivity,
activeScope?: string,
activeWorkspaceRoot?: string,
activeTopicId?: string,
activeSessionPath?: string,
): boolean {
if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return false;
if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) return false;
if (topicStatus(node) !== "") return false;
const key = projectTreeReadActivityKey(node);
const activityAt = topicActivityAt(node);
return Boolean(key && activityAt > 0 && (readActivity[key] ?? 0) < activityAt);
}
export function projectTreeShouldRenderTopicActions(isSessionNode: boolean, compactTopics: boolean, unread: boolean): boolean {
return !isSessionNode && compactTopics && !unread;
}
function topicActivityLabel(ms: number, t: Translator, compact = false): string {
if (ms <= 0) return "";
const delta = Date.now() - ms;
@@ -226,6 +267,31 @@ type WorkbenchTreeSections = {
const GLOBAL_PROJECT_ORDER_KEY = "__global__";
const WORKBENCH_ORGANIZE_KEY = "projectTree:workbenchOrganize";
const WORKBENCH_SORT_KEY = "projectTree:workbenchSort";
const READ_ACTIVITY_KEY = "projectTree:readActivity";
const READ_ACTIVITY_INIT_KEY = "projectTree:readActivityInitialized";
function loadReadActivity(): ProjectTreeReadActivity {
try {
const raw = localStorage.getItem(READ_ACTIVITY_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as Record<string, unknown>;
const out: ProjectTreeReadActivity = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "number" && Number.isFinite(value)) out[key] = value;
}
return out;
} catch {
return {};
}
}
function saveReadActivity(readActivity: ProjectTreeReadActivity) {
try {
localStorage.setItem(READ_ACTIVITY_KEY, JSON.stringify(readActivity));
} catch {
/* localStorage unavailable */
}
}
function loadWorkbenchOrganizeMode(): WorkbenchOrganizeMode {
try {
@@ -497,6 +563,7 @@ export function ProjectTree({
const [workbenchHeaderMenu, setWorkbenchHeaderMenu] = useState<WorkbenchHeaderMenu>(null);
const [workbenchOrganizeMode, setWorkbenchOrganizeMode] = useState<WorkbenchOrganizeMode>(loadWorkbenchOrganizeMode);
const [workbenchSortMode, setWorkbenchSortMode] = useState<WorkbenchSortMode>(loadWorkbenchSortMode);
const [readActivity, setReadActivity] = useState<ProjectTreeReadActivity>(loadReadActivity);
const filterRef = useRef<HTMLDivElement>(null);
const filterTriggerRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
@@ -562,6 +629,67 @@ export function ProjectTree({
void refresh();
}, [refresh, refreshSignal]);
const markNodeRead = useCallback((node: ProjectNode) => {
const key = projectTreeReadActivityKey(node);
const activityAt = topicActivityAt(node);
if (!key || activityAt <= 0) return;
setReadActivity((prev) => {
if ((prev[key] ?? 0) >= activityAt) return prev;
const next = { ...prev, [key]: activityAt };
saveReadActivity(next);
return next;
});
}, []);
useEffect(() => {
if (tree.length === 0) return;
try {
if (localStorage.getItem(READ_ACTIVITY_INIT_KEY)) return;
} catch {
return;
}
const baseline: ProjectTreeReadActivity = {};
const collectBaseline = (nodes: ProjectNode[]) => {
for (const node of nodes) {
if ((isTopicNode(node) || isRuntimeSessionNode(node)) && topicStatus(node) === "") {
const key = projectTreeReadActivityKey(node);
const activityAt = topicActivityAt(node);
if (key && activityAt > 0) baseline[key] = Math.max(baseline[key] ?? 0, activityAt);
}
collectBaseline(asArray(node.children));
}
};
collectBaseline(tree);
try {
localStorage.setItem(READ_ACTIVITY_INIT_KEY, "1");
} catch {
/* localStorage unavailable */
}
if (Object.keys(baseline).length === 0) return;
setReadActivity((prev) => {
const next = { ...prev };
let changed = false;
for (const [key, value] of Object.entries(baseline)) {
if ((next[key] ?? 0) >= value) continue;
next[key] = value;
changed = true;
}
if (!changed) return prev;
saveReadActivity(next);
return next;
});
}, [tree]);
useEffect(() => {
const markActive = (nodes: ProjectNode[]) => {
for (const node of nodes) {
if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) markNodeRead(node);
markActive(asArray(node.children));
}
};
markActive(tree);
}, [activeScope, activeSessionPath, activeTopicId, activeWorkspaceRoot, markNodeRead, tree]);
useEffect(() => {
try {
localStorage.setItem(WORKBENCH_ORGANIZE_KEY, workbenchOrganizeMode);
@@ -926,8 +1054,10 @@ export function ProjectTree({
const filtered = tree
.map(filterNode)
.filter((node): node is ProjectNode => node !== null);
return compactTopics ? arrangeWorkbenchTree(filtered, workbenchOrganizeMode, workbenchSortMode) : filtered;
}, [compactTopics, query, tree, timeFilter, workbenchOrganizeMode, workbenchSortMode]);
if (compactTopics) return arrangeWorkbenchTree(filtered, workbenchOrganizeMode, workbenchSortMode);
if (creationTopics) return arrangeWorkbenchTree(filtered, "project", "updated");
return filtered;
}, [compactTopics, creationTopics, query, tree, timeFilter, workbenchOrganizeMode, workbenchSortMode]);
const workbenchTreeSections = useMemo<WorkbenchTreeSections>(() => {
if (!compactTopics) return { pinned: [], projects: visibleTree };
@@ -1002,20 +1132,23 @@ export function ProjectTree({
const accentStyle = projectAccentStyle(node.projectColor, scope === "global" ? "var(--project-tree-global-accent)" : undefined);
const active = topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath);
const label = (node.label || node.topicId || "Untitled").replace(/^●\s*/, "");
const conflictCopyLabel = isSessionNode && node.recovered ? t("recovery.badge") : "";
const activityAt = node.lastActivityAt || node.createdAt || 0;
const sideTimeVisible = compactTopics || creationTopics;
const timeLabel = sideTimeVisible && activityAt ? topicActivityLabel(activityAt, t, true) : "";
const timeLabel = sideTimeVisible ? (activityAt ? topicActivityLabel(activityAt, t, true) : topicUnknownTimeLabel(node, t)) : "";
const exactTimeLabel = sideTimeVisible && activityAt ? topicActivityDateLabel(activityAt) : "";
const meta = topicMetaLine(node, t, compactTopics);
const meta = projectTreeTopicMetaLine(node, t, compactTopics);
const status = topicStatus(node);
const statusLabel = topicStatusLabel(node, t);
const showStatusInSide = status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job";
const unread = projectTreeTopicHasUnreadActivity(node, readActivity, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath);
const topicId = node.topicId ?? "";
const imSource = scope === "global" && topicId ? imTopicSources[topicId] : undefined;
const imSourceLabel = imSource?.label || "";
const imSourceTitle = imSourceLabel ? t("msg.fromIm", { source: imSourceLabel }) : "";
const imSourcePlatform = (imSource?.platform || "im").replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "im";
const title = [label, imSourceTitle, statusLabel, meta, exactTimeLabel].filter(Boolean).join(" · ");
const conflictCopyTitle = isSessionNode && node.recovered ? t("recovery.branch") : "";
const title = [label, conflictCopyTitle, imSourceTitle, statusLabel, meta, exactTimeLabel].filter(Boolean).join(" · ");
const topicMenuOpen = !isSessionNode && menuTopic === topicId;
const pinned = Boolean(node.pinned);
const pinLabel = t(pinned ? "projectTree.unpinTopic" : "projectTree.pinTopic");
@@ -1092,7 +1225,7 @@ export function ProjectTree({
}
const row = (
<div
className={`project-tree__topic${scopeClass}${isSessionNode ? " project-tree__topic--session" : ""}${active ? " project-tree__topic--active" : ""}${node.running ? " project-tree__topic--running" : ""}${status ? ` project-tree__topic--status-${status}` : ""}${!isSessionNode && pinned ? " project-tree__topic--pinned" : ""}${topicMenuOpen ? " project-tree__topic--menu-open" : ""}${sideTimeVisible && (timeLabel || showStatusInSide) ? " project-tree__topic--with-side" : meta ? " project-tree__topic--has-meta" : ""}${imSource ? " project-tree__topic--im-source" : ""}${shortcutIndex > 0 ? " project-tree__topic--show-shortcut" : ""}`}
className={`project-tree__topic${scopeClass}${isSessionNode ? " project-tree__topic--session" : ""}${active ? " project-tree__topic--active" : ""}${node.running ? " project-tree__topic--running" : ""}${status ? ` project-tree__topic--status-${status}` : ""}${unread ? " project-tree__topic--unread" : ""}${!isSessionNode && pinned ? " project-tree__topic--pinned" : ""}${topicMenuOpen ? " project-tree__topic--menu-open" : ""}${sideTimeVisible && (timeLabel || showStatusInSide) ? " project-tree__topic--with-side" : meta ? " project-tree__topic--has-meta" : ""}${imSource ? " project-tree__topic--im-source" : ""}${shortcutIndex > 0 ? " project-tree__topic--show-shortcut" : ""}`}
style={accentStyle}
onContextMenu={isSessionNode ? undefined : openTopicMenu}
>
@@ -1112,6 +1245,7 @@ export function ProjectTree({
}
const timer = setTimeout(() => {
if (clickTimerRef.current?.timer === timer) clickTimerRef.current = null;
markNodeRead(node);
onOpenTopic(openRequest.scope, openRequest.workspaceRoot, openRequest.topicId, openRequest.sessionPath);
}, 200);
clickTimerRef.current = { ...nextClick, timer };
@@ -1133,7 +1267,7 @@ export function ProjectTree({
>
<span className="project-tree__topic-copy">
<span className="project-tree__topic-heading">
<span className="project-tree__topic-label">{label}</span>
<span className="project-tree__topic-label">{conflictCopyLabel ? `${label} · ${conflictCopyLabel}` : label}</span>
{imSource && (
<span
className={`project-tree__topic-im project-tree__topic-im--${imSourcePlatform}`}
@@ -1169,7 +1303,8 @@ export function ProjectTree({
</span>
)}
</button>
{!isSessionNode && compactTopics && (
{unread && <span className="project-tree__topic-unread-dot" aria-hidden="true" />}
{projectTreeShouldRenderTopicActions(isSessionNode, compactTopics, unread) && (
<span className="project-tree__topic-actions" aria-label={t("projectTree.topicActions")}>
<Tooltip label={pinLabel} side="top" className="project-tree__topic-action-slot">
<button
@@ -1,6 +1,8 @@
import type { ReactNode, RefObject } from "react";
export function PromptShelf({
className,
cardClassName,
titleId,
title,
badges,
@@ -13,6 +15,8 @@ export function PromptShelf({
barRef,
role = "dialog",
}: {
className?: string;
cardClassName?: string;
titleId: string;
title: ReactNode;
badges?: ReactNode;
@@ -26,10 +30,10 @@ export function PromptShelf({
role?: "dialog" | "region";
}) {
return (
<div className="prompt-shelf" aria-live="polite">
<div className={["prompt-shelf", className ?? ""].filter(Boolean).join(" ")} aria-live="polite">
<div
ref={barRef}
className="prompt-shelf__card"
className={["prompt-shelf__card", cardClassName ?? ""].filter(Boolean).join(" ")}
role={role}
aria-modal={role === "dialog" ? "false" : undefined}
aria-labelledby={titleId}
File diff suppressed because it is too large. Load diff
+11 -6
View File
@@ -22,9 +22,10 @@ function nowRate(u?: WireUsage): string | null {
}
// avgRate is the SESSION-AGGREGATE cache-hit % — Σhit/Σ(hit+miss) across every
// turn — the steadier, cost-oriented number that matches the legacy dashboard.
// On a non-compacting DeepSeek session it trails nowRate (early cold-start turns
// drag the average down); it overtakes only when compaction craters single turns.
// turn — but scoped to the EXECUTOR agent only: the wire session counters come
// from the main agent and exclude subagent/planner/auxiliary requests. It is
// only the pre-first-refresh fallback; the authoritative all-sources number is
// contextAvgRate below, so the "session average" label reports one scope.
function avgRate(u?: WireUsage): string | null {
if (!u) return null;
const denom = u.sessionCacheHitTokens + u.sessionCacheMissTokens;
@@ -32,8 +33,10 @@ function avgRate(u?: WireUsage): string | null {
}
// contextAvgRate computes the session-aggregate cache-hit % from ContextInfo
// cache tokens (loaded from persisted telemetry on session resume). Used as a
// fallback when no live WireUsage is available yet.
// cache tokens — the tab telemetry that accumulates ALL request sources
// (executor, subagents, planner, auxiliary calls), refreshed at turn
// boundaries. Preferred over avgRate: it matches the 会话费用 tooltip's
// "includes main model, subagents and auxiliary calls" scope.
function contextAvgRate(ctx: ContextInfo): string | null {
const hit = ctx.cacheHitTokens ?? 0;
const miss = ctx.cacheMissTokens ?? 0;
@@ -186,7 +189,9 @@ export function StatusBar({
const compactNear = pct !== null && compactPct !== null && pct >= Math.max(0, compactPct - 10);
const compactReached = pct !== null && compactPct !== null && pct >= compactPct;
const nowPct = nowRate(usage);
const avgPct = avgRate(usage) ?? contextAvgRate(context);
// All-sources telemetry first; the executor-only live counters only bridge
// the gap before the first ContextInfo refresh of a fresh session.
const avgPct = contextAvgRate(context) ?? avgRate(usage);
const turnCostLabel = formatMoneyLocalized(turnCost, currency, { locale });
const costLabel = formatMoneyLocalized(cost, currency, { locale });
const displayWorkspacePath = (workspacePath || workspaceName || "").trim();
+45 -10
View File
@@ -1,22 +1,39 @@
import { useEffect, useRef, useState } from "react";
import { useT } from "../lib/i18n";
import type { Todo } from "../lib/tools";
import { shouldOpenTodoPanelByDefault } from "../lib/todoVisibility";
import { PromptBadge, PromptHeaderAction, PromptShelf } from "./PromptShelf";
const STORAGE_KEY = "todoPanel:open";
const STORAGE_KEY = "todoPanel:openStates";
const MAX_STORED_OPEN_STATES = 80;
function loadOpenState(): boolean {
function loadOpenStates(): Record<string, boolean> {
try {
const saved = localStorage.getItem(STORAGE_KEY);
return saved === null ? true : saved === "1";
if (!saved) return {};
const parsed = JSON.parse(saved) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
const states: Record<string, boolean> = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "boolean") states[key] = value;
}
return states;
} catch {
return true;
return {};
}
}
function saveOpenState(open: boolean): void {
function loadOpenState(stateKey: string, defaultOpen: boolean): boolean {
const states = loadOpenStates();
return Object.prototype.hasOwnProperty.call(states, stateKey) ? states[stateKey] : defaultOpen;
}
function saveOpenState(stateKey: string, open: boolean): void {
try {
localStorage.setItem(STORAGE_KEY, open ? "1" : "0");
const entries = Object.entries(loadOpenStates()).filter(([key]) => key !== stateKey);
entries.push([stateKey, open]);
const trimmed = entries.slice(-MAX_STORED_OPEN_STATES);
localStorage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(trimmed)));
} catch {
/* ignore quota errors */
}
@@ -24,23 +41,35 @@ function saveOpenState(open: boolean): void {
// TodoPanel is the live task list pinned just above the composer — the kernel's
// latest todo_write call drives it, and it updates in place as the agent flips
// items to in_progress / completed. The collapsed/expanded state is persisted
// globally in localStorage so the user's choice sticks across sessions.
// items to in_progress / completed. Open state follows the current todo batch:
// new incomplete work opens by default, finished work collapses to a reviewable
// summary, and manual expand/collapse is restored only for the same batch.
export function TodoPanel({
stateKey,
todos,
onDismiss,
}: {
stateKey: string;
todos: Todo[];
onDismiss: () => void;
}) {
const t = useT();
const [open, setOpen] = useState(loadOpenState);
const currentRef = useRef<HTMLLIElement | null>(null);
const done = todos.filter((t) => t.status === "completed").length;
const current = todos.find((t) => t.status === "in_progress");
const allDone = todos.length > 0 && done === todos.length;
const summary = current?.activeForm || current?.content || todos[todos.length - 1]?.content || "";
const [open, setOpen] = useState(() => loadOpenState(stateKey, shouldOpenTodoPanelByDefault(todos)));
const wasAllDoneRef = useRef(allDone);
useEffect(() => {
if (allDone && !wasAllDoneRef.current) {
saveOpenState(stateKey, false);
setOpen(false);
}
wasAllDoneRef.current = allDone;
}, [allDone, stateKey]);
useEffect(() => {
if (!open) return;
@@ -58,7 +87,13 @@ export function TodoPanel({
role="region"
headerActions={
<>
<PromptHeaderAction onClick={() => setOpen((value) => { const next = !value; saveOpenState(next); return next; })}>
<PromptHeaderAction
onClick={() => setOpen((value) => {
const next = !value;
saveOpenState(stateKey, next);
return next;
})}
>
{open ? t("common.collapse") : t("common.expand")}
</PromptHeaderAction>
{allDone && (
+8 -3
View File
@@ -1,5 +1,5 @@
import { memo, useEffect, useRef, useState, type ReactNode } from "react";
import { ChevronRight } from "lucide-react";
import { ChevronRight, Compass } from "lucide-react";
import { CodeViewer } from "./CodeViewer";
import { DiffView } from "./DiffView";
import { useT } from "../lib/i18n";
@@ -91,7 +91,7 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
app.ToolResultForTab(tabId, item.id).then((d) => {
if (!cancelled && d) setFullData(d);
}).catch(() => {}),
);
).catch(() => {});
return () => { cancelled = true; };
}, [open, item.id, item.dataArchived, fullData, tabId]);
@@ -127,7 +127,12 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
aria-expanded={hasBody ? open : undefined}
>
<span className="tool__label-group">
{hasNested && <span className="tool__nested-count">⊞{nested.length}</span>}
{hasNested && (
<span className="tool__nested-count" aria-label={`${nested.length} nested tool calls`}>
<Compass className="tool__nested-icon" size={14} strokeWidth={2} aria-hidden="true" />
<span>{nested.length}</span>
</span>
)}
{item.status === "error" && <span className="tool__status-icon tool__status-icon--err">✗</span>}
{item.status === "done" && <span className="tool__status-icon tool__status-icon--ok">✓</span>}
{item.status === "stopped" && <span className="tool__status-icon tool__status-icon--stopped">—</span>}
+15 -5
View File
@@ -14,7 +14,8 @@ import { isReadOnlyTool } from "../lib/useController";
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
import { useEntranceAnimation } from "../lib/useEntranceAnimation";
import { useScrollManager } from "../lib/useScrollManager";
import { buildStepGroups, buildTurnGroups, compactQuestionText, createWarmLayerState, questionAnchorId, questionTurnsById, scrollVersion, warmColdPageForTurn, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination, warmUserPreview, type QuestionAnchor, type TurnGroup, type WarmLayerState } from "../lib/transcriptGrouping";
import { buildStepGroups, buildTurnGroups, compactQuestionText, createWarmLayerState, lastQuestionTurn, questionAnchorId, questionTurnsById, scrollVersion, warmColdPageForTurn, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination, warmUserPreview, type QuestionAnchor, type TurnGroup, type WarmLayerState } from "../lib/transcriptGrouping";
import { appendTurnActionCopyText } from "../lib/turnActionCopy";
type ToolItem = Extract<Item, { kind: "tool" }>;
type AssistantItem = Extract<Item, { kind: "assistant" }>;
@@ -314,6 +315,7 @@ export function Transcript({
}, [openAction]);
const userTurn = useMemo(() => questionTurnsById(questions), [questions]);
const lastTurn = useMemo(() => lastQuestionTurn(questions, userTurn), [questions, userTurn]);
const checkpointsByTurn = useMemo(() => new Map(checkpoints.map((checkpoint) => [checkpoint.turn, checkpoint])), [checkpoints]);
// ── JumpBar integration ───────────────────────────────────────────────────
@@ -390,6 +392,7 @@ export function Transcript({
actionPending={actionPending}
rewindDisabled={rewindDisabled}
hoverMenus={actionHoverMenus}
isLastTurn={turn === lastTurn}
onRewind={(targetTurn, scope) => {
onRewind?.(targetTurn, scope);
setOpenAction(null);
@@ -500,7 +503,7 @@ export function Transcript({
/>,
);
if (!it.streaming && it.text.trim() !== "") {
actionText = it.text;
actionText = appendTurnActionCopyText(actionText, it.text);
actionReady = true;
}
}
@@ -573,7 +576,7 @@ export function Transcript({
case "assistant":
out.push(<LiveAssistantMessage key={it.id} item={it as AssistantItem} defaultExpanded={false} creationMode={creationMode} />);
if (!it.streaming && it.text.trim() !== "") {
actionText = it.text;
actionText = appendTurnActionCopyText(actionText, it.text);
actionReady = true;
}
break;
@@ -593,7 +596,7 @@ export function Transcript({
if (!running) pushTurnActions();
}
return out;
}, [hotStartIdx, items, openAction, actionPending, rewindDisabled, running, onEditPrompt, onRewind, subcallsByParent, userTurn, checkpointsByTurn, displayMode, stepGroups, tabId, actionHoverMenus, creationMode]);
}, [hotStartIdx, items, openAction, actionPending, rewindDisabled, running, onEditPrompt, onRewind, subcallsByParent, userTurn, checkpointsByTurn, displayMode, stepGroups, tabId, actionHoverMenus, creationMode, lastTurn]);
// ── Assemble rendered output ──────────────────────────────────────────────
// Warm/cold zone is a separate memo'd WarmZone component so streaming tokens
@@ -631,6 +634,7 @@ export function Transcript({
warmSubcalls={subcallsByParent}
warmUserTurn={userTurn}
warmCheckpoints={checkpointsByTurn}
warmLastTurn={lastTurn}
warmOpenAction={openAction}
warmActionPending={actionPending}
warmRewindDisabled={rewindDisabled}
@@ -686,6 +690,7 @@ const WarmZone = memo(function WarmZone({
warmSubcalls,
warmUserTurn,
warmCheckpoints,
warmLastTurn,
warmOpenAction,
warmActionPending,
warmRewindDisabled,
@@ -708,6 +713,7 @@ const WarmZone = memo(function WarmZone({
warmSubcalls: ReadonlyMap<string, ToolItem[]>;
warmUserTurn: ReadonlyMap<string, number>;
warmCheckpoints: ReadonlyMap<number, CheckpointMeta>;
warmLastTurn?: number;
warmOpenAction: OpenTurnAction | null;
warmActionPending: boolean;
warmRewindDisabled: boolean;
@@ -773,6 +779,7 @@ const WarmZone = memo(function WarmZone({
onEdit={warmOnEdit}
tabId={tabId}
creationMode={creationMode}
lastTurn={warmLastTurn}
/>
</WarmTurnCard>,
);
@@ -820,6 +827,7 @@ function WarmTurnItems({
onEdit,
tabId,
creationMode = false,
lastTurn,
}: {
startIdx: number;
endIdx: number;
@@ -836,6 +844,7 @@ function WarmTurnItems({
onEdit?: (turn: number, displayText: string, submitText?: string) => boolean | void | Promise<boolean | void>;
tabId?: string;
creationMode?: boolean;
lastTurn?: number;
}) {
const nodes: React.ReactNode[] = [];
let actionText = "";
@@ -856,6 +865,7 @@ function WarmTurnItems({
actionPending={actionPending}
rewindDisabled={rewindDisabled}
hoverMenus={actionHoverMenus}
isLastTurn={turn === lastTurn}
onRewind={(targetTurn, scope) => {
onRewind?.(targetTurn, scope);
setOpenAction(null);
@@ -926,7 +936,7 @@ function WarmTurnItems({
case "assistant": {
nodes.push(<AssistantMessage key={it.id} item={it} defaultExpanded={false} creationMode={creationMode} />);
if (!it.streaming && it.text.trim() !== "") {
actionText = it.text;
actionText = appendTurnActionCopyText(actionText, it.text);
actionReady = true;
}
break;
@@ -24,6 +24,7 @@ import {
Search,
X,
} from "lucide-react";
import { asArray } from "../lib/array";
import { app } from "../lib/bridge";
import { useT } from "../lib/i18n";
import {
@@ -206,6 +207,7 @@ export function WorkspacePanel({
onPreviewModeChange,
onAddToChat,
onRequestPanelWidth,
onFileTreeRefresh,
refreshKey,
initialViewMode = "files",
revealPathRequest,
@@ -224,6 +226,7 @@ export function WorkspacePanel({
onPreviewModeChange?: (active: boolean) => void;
onAddToChat?: (text: string) => void;
onRequestPanelWidth?: (width: number) => void;
onFileTreeRefresh?: () => void;
refreshKey?: number;
initialViewMode?: "files" | "changed";
revealPathRequest?: WorkspaceRevealRequest | null;
@@ -275,6 +278,8 @@ export function WorkspacePanel({
const workspaceChangesRequestIdRef = useRef(0);
const gitHistoryRequestIdRef = useRef(0);
const commitDetailRequestIdRef = useRef(0);
const dirLoadGenerationRef = useRef(0);
const dirLoadRequestIdsRef = useRef<Record<string, number>>({});
const recentAnchorRef = useRef<HTMLButtonElement>(null);
const openDirsRef = useRef(openDirs);
const pendingTreeRevealPathRef = useRef<string | null>(null);
@@ -284,8 +289,12 @@ export function WorkspacePanel({
}, [openDirs]);
const loadDir = useCallback(async (dir: string) => {
const entries = await app.ListDir(dir).catch(() => []);
setEntriesByDir((prev) => ({ ...prev, [dir]: entries ?? [] }));
const generation = dirLoadGenerationRef.current;
const requestId = (dirLoadRequestIdsRef.current[dir] ?? 0) + 1;
dirLoadRequestIdsRef.current[dir] = requestId;
const entries = await app.ListDir(dir).catch((): DirEntry[] => []);
if (dirLoadGenerationRef.current !== generation || dirLoadRequestIdsRef.current[dir] !== requestId) return;
setEntriesByDir((prev) => ({ ...prev, [dir]: asArray(entries) }));
}, []);
const loadGitHistory = useCallback(async () => {
@@ -399,15 +408,15 @@ export function WorkspacePanel({
setOpenTabs((tabs) => [...tabs.filter((tab) => tab !== path), path].slice(-WORKSPACE_MAX_PREVIEW_TABS));
const dirs = parentDirs(path);
setOpenDirs((prev) => new Set([...Array.from(prev), ...dirs]));
dirs.forEach((dir) => {
if (!entriesByDir[dir]) void loadDir(dir);
});
dirs.forEach((dir) => void loadDir(dir));
},
[entriesByDir, loadDir, openTabs.length, panelWidth, selectedPath, treeVisible],
[loadDir, openTabs.length, panelWidth, selectedPath, treeVisible],
);
useEffect(() => {
if (!open) return;
dirLoadGenerationRef.current += 1;
dirLoadRequestIdsRef.current = {};
setEntriesByDir({});
setOpenDirs(new Set([""]));
setSelectedPath(null);
@@ -503,10 +512,8 @@ export function WorkspacePanel({
setTreeMenu(null);
const dirs = Array.from(new Set(paths.flatMap(parentDirs)));
setOpenDirs((prev) => new Set([...Array.from(prev), ...dirs]));
dirs.forEach((dir) => {
if (!entriesByDir[dir]) void loadDir(dir);
});
}, [entriesByDir, fileListRequest, loadDir, open, scopedFilePaths, viewMode]);
dirs.forEach((dir) => void loadDir(dir));
}, [fileListRequest, loadDir, open, scopedFilePaths, viewMode]);
useEffect(() => {
if (!open || changeListRequest) return;
@@ -649,10 +656,10 @@ export function WorkspacePanel({
void loadWorkspaceChanges();
return;
}
onFileTreeRefresh?.();
const dirs = Array.from(openDirsRef.current);
setEntriesByDir({});
dirs.forEach((dir) => void loadDir(dir));
}, [loadGitHistory, loadWorkspaceChanges, loadDir, viewMode]);
}, [loadGitHistory, loadWorkspaceChanges, loadDir, onFileTreeRefresh, viewMode]);
const refreshSelected = useCallback(() => {
if (!selectedPath) return;
@@ -698,12 +705,12 @@ export function WorkspacePanel({
next.delete(dir);
} else {
next.add(dir);
if (!entriesByDir[dir]) void loadDir(dir);
void loadDir(dir);
}
return next;
});
},
[entriesByDir, loadDir],
[loadDir],
);
const breadcrumbDirs = selectedPath ? parentDirs(selectedPath) : [""];
@@ -1262,6 +1269,7 @@ export function WorkspacePanel({
setFilter("");
showTreeEvenSplit();
setOpenDirs((prev) => new Set([...Array.from(prev), ""]));
void loadDir("");
}}
>
{shortCwd(cwd) || t("workspace.title")}
@@ -13,11 +13,25 @@ export function isLikelyInlineMath(math: string): boolean {
// Unary plus/minus: +2, -x, +\alpha, - 3.14
if (/^[+\-]\s*(?:\d+(?:\.\d+)?|[A-Za-z\\])/.test(math)) return true;
if (/\\[A-Za-z]+\b/.test(math)) return true;
// LaTeX command (\alpha, \frac{x}{y}, \tfrac12, ...). Do not require a
// word boundary after the command name: \tfrac12 / \sqrt2 / \log3 have no
// boundary between the command letters and the trailing digit.
if (/\\[A-Za-z]+/.test(math)) return true;
if (/[\^_{}|]/.test(math)) return true;
// Lone math operators are common in physics prose: "the sign of $+$",
// "the $<$ relation".
if (/^[+\-=<>±∓]$/.test(math)) return true;
if (/\b(?:alpha|beta|gamma|sum|int|prod|lim|infty|sqrt|frac|sin|cos|tan|log|ln|max|min|partial|nabla|left|right)\b/.test(math)) return true;
if (/^[A-Za-z]\s*\([^)]{1,80}\)$/.test(math)) return true;
if (/[A-Za-z0-9)\]}]\s*[+\-*/=<>]\s*[A-Za-z0-9([{\\]/.test(math)) return true;
// Short identifiers followed by parenthesised arguments cover both
// function calls (f(x)) and group notation (SO(3,1), SU(2), GL(n)).
if (/^[A-Za-z]{1,6}\s*\([^)]{1,80}\)$/.test(math)) return true;
// Primed function call: f'(x), g''(t).
if (/^[A-Za-z]'{1,}\s*\([^)]{1,80}\)$/.test(math)) return true;
// Permutation cycle notation: (12), (123), (12)(34).
if (/^(?:\(\d+\))+$/.test(math)) return true;
// Binary operator between operands. The RHS may start with a unary sign:
// K = -iJ, p = +\alpha, a = -b.
if (/[A-Za-z0-9)\]}]\s*[+\-*/=<>]\s*[+\-]?\s*[A-Za-z0-9([{\\]/.test(math)) return true;
// One-sided comparison: < B, > 0, B < — comparison against an implicit
// operand is common in prose.
if (/^(?:<=?|>=?|≠|≤|≥)\s*[A-Za-z0-9]|[A-Za-z0-9]\s*(?:<=?|>=?|≠|≤|≥)$/.test(math)) return true;
@@ -25,11 +39,19 @@ export function isLikelyInlineMath(math: string): boolean {
// \alpha, \beta. Currency/env-var usage never looks like this.
if (/^\(?(?:[A-Za-z0-9]|\\[A-Za-z]+)(?:\s*,\s*(?:[A-Za-z0-9]|\\[A-Za-z]+)){1,10}\)?$/.test(math)) return true;
// Bracketed labels/indexes such as [56], [8], [N], [\mathbf{56}], [56,0^+]
// are common in group theory and arrays. Markdown links are rejected above
// by the "](" guard.
if (/^\[[A-Za-z0-9^_+\-,.\\\s{}]+\]$/.test(math)) return true;
if (/[A-Za-z]\s+[A-Za-z]/.test(math)) return false;
if (/^[A-Z][A-Z0-9_]{1,}$/.test(math)) return false;
if (/^v\d+(?:\.\d+)*$/i.test(math)) return false;
if (/^[A-Za-z]{2,}$/.test(math)) return false;
// Letter or Greek command followed by one or more primes: x', S', y'', \psi'.
if (/^(?:[A-Za-z]|\\[A-Za-z]+)'{1,}$/.test(math)) return true;
// Single letter (a-z, A-Z). Uppercase single letters (S, A, G, …) are
// common math names (sets, algebras, groups) and $X$ is essentially
// never written for the English word I/A by hand.
@@ -67,30 +67,13 @@ function normalizeMathText(s: string): string {
const escapedDollarToken = unusedEscapedDollarToken(r);
r = r.split("\\$").join(escapedDollarToken);
// Step 3: repair inline $$. CommonMark requires a blank line before
// block math; without it remark-math parses the opening $$ as an
// empty math node and the formula leaks out as literal text.
// Digits are excluded so `c^2$$` inside a formula is left alone.
// Comma is included so `…D(q^2),$$` (closing $$ on the same line as
// the trailing content) is repaired: micromark-extension-math only
// recognises a closing $$ fence at the start of a new line, so
// without this repair it consumes the rest of the document as math
// and katex fails on the stray $ in the next paragraph. Closing
// braces } and { are included too — a model that writes
// `\end{array}$$` or `\frac{a}{b}$$` on one line has the same
// micromark-fence problem, and these are the most common ends of
// LaTeX math content.
r = r.replace(/([A-Za-z\)\]\>\.。!?,{}])\$\$/g, (_m, prev) => prev + "\n\n$$");
// Orphan opening $$ (model forgot the closing $$) is left alone:
// converting it to a lone $ would interact badly with the $…$
// matcher below and wrap whole prose paragraphs in math spans. The
// right fix is upstream — a post-generation lint or stricter prompt.
// Step 4: $$…$$ → display placeholders. KaTeX-specific normalisation
// runs here so |→\vert (with \| protected) and \text{} escapes both
// apply to display math.
r = r.replace(/\$\$([\s\S]*?)\$\$/g, (_m, m) => `${DM}${latexNormalizeForKatex(m)}${DM}`);
// Step 3+4: normalise display $$ blocks and run KaTeX-specific
// normalisation on each recognised display source. remark-math requires
// opening and closing $$ to sit on their own lines; LLMs often emit
// single-line displays, opening fences glued to prose, and adjacent display
// blocks separated by prose. A line parser avoids the old cross-block regex
// capture that swallowed prose between two display blocks.
r = normaliseDisplayBlocks(r);
// Step 5: $\cmd{...}$ pairs where the body may contain a stray $
// (e.g. $\text{price is $5}$). Recognised first so the inner $ doesn't
@@ -246,3 +229,95 @@ function lineEnd(s: string, start: number): number {
const end = s.indexOf("\n", start);
return end < 0 ? s.length : end;
}
function normaliseDisplayBlocks(s: string): string {
const lines = s.split("\n");
const out: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const $$idx = line.indexOf("$$");
if ($$idx >= 0 && line.indexOf("$$", $$idx + 2) >= 0
&& !($$idx > 0 && /\d/.test(line[$$idx - 1]))) {
const m = line.match(/^(.*?)\$\$([^\n]*?)\$\$(.*)$/);
if (m) {
const quote = blockquotePrefix(m[1]);
pushDisplayBefore(out, m[1]);
out.push(DM);
out.push(latexNormalizeForKatex(m[2]));
out.push(DM);
if (m[3]) out.push(normaliseDisplayBlocks(quote ? quote + m[3].trimStart() : m[3]));
i += 1;
continue;
}
}
if ($$idx >= 0 && line.indexOf("$$", $$idx + 2) < 0
&& !($$idx > 0 && /\d/.test(line[$$idx - 1]))) {
const before = line.slice(0, $$idx);
const afterOpen = line.slice($$idx + 2);
const quote = blockquotePrefix(before);
const formulaLines: string[] = [];
if (afterOpen) formulaLines.push(afterOpen);
let j = i + 1;
let found = false;
while (j < lines.length) {
const rawLine = lines[j];
const fLine = quote ? stripBlockquotePrefix(rawLine, quote) : rawLine;
const closeIdx = fLine.indexOf("$$");
if (closeIdx >= 0 && fLine.indexOf("$$", closeIdx + 2) < 0) {
const formulaPart = fLine.slice(0, closeIdx);
const afterClose = fLine.slice(closeIdx + 2);
pushDisplayBefore(out, before);
if (formulaPart) formulaLines.push(formulaPart);
const formula = formulaLines.join("\n");
out.push(DM);
out.push(latexNormalizeForKatex(formula));
out.push(DM);
if (afterClose) out.push(quote ? quote + afterClose.trimStart() : afterClose);
i = j + 1;
found = true;
break;
}
formulaLines.push(fLine);
j += 1;
}
if (found) continue;
pushDisplayBefore(out, before);
out.push("$$");
if (afterOpen) out.push(afterOpen);
i += 1;
continue;
}
out.push(line);
i += 1;
}
return out.join("\n");
}
function pushDisplayBefore(out: string[], before: string): void {
if (!before.trim()) return;
out.push(before);
}
function blockquotePrefix(before: string): string | null {
const m = before.match(/^(\s*>\s*)/);
return m ? m[1] : null;
}
function stripBlockquotePrefix(line: string, prefix: string): string {
if (line.startsWith(prefix)) return line.slice(prefix.length);
const marker = prefix.trimEnd();
if (marker && line.startsWith(marker)) {
const rest = line.slice(marker.length);
return rest.startsWith(" ") ? rest.slice(1) : rest;
}
return line;
}
@@ -190,9 +190,10 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
enabled: true,
approvalMode: "yolo",
newConversationEachRun: false,
notifyChannels: false,
createdAt: Date.now(),
});
});
}).catch(() => {});
}
}, [open, startNew]);
@@ -209,17 +210,22 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
);
const handleAdd = useCallback(async () => {
const id = await heartbeatGenerateID();
setEditing({
id,
title: "",
prompt: "",
interval: "30m",
enabled: true,
approvalMode: "yolo",
newConversationEachRun: false,
createdAt: Date.now(),
});
try {
const id = await heartbeatGenerateID();
setEditing({
id,
title: "",
prompt: "",
interval: "30m",
enabled: true,
approvalMode: "yolo",
newConversationEachRun: false,
notifyChannels: false,
createdAt: Date.now(),
});
} catch {
// ignore
}
}, []);
const handleEdit = useCallback((task: HeartbeatTask) => {
@@ -236,8 +242,12 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
const handleTrigger = useCallback(
async (id: string) => {
await heartbeatTriggerNow(id);
void loadTasks();
try {
await heartbeatTriggerNow(id);
void loadTasks();
} catch {
// ignore
}
},
[loadTasks],
);
@@ -952,37 +962,63 @@ function TaskEditor({
/>
</div>
{/* Approval Mode */}
<div className="heartbeat-editor__field">
<label>{t("heartbeat.fieldApprovalMode")}</label>
<div className="set-seg" style={{ alignSelf: "flex-start" }}>
<button
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "ask" ? " set-seg__btn--on" : ""}`}
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "ask" }))}
title={t("heartbeat.approvalModeAskTooltip")}
>
{t("heartbeat.approvalModeAsk")}
</button>
<button
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "auto" ? " set-seg__btn--on" : ""}`}
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "auto" }))}
title={t("heartbeat.approvalModeAutoTooltip")}
>
{t("heartbeat.approvalModeAuto")}
</button>
<button
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "yolo" ? " set-seg__btn--on" : ""}`}
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "yolo" }))}
title={t("heartbeat.approvalModeYoloTooltip")}
>
{t("heartbeat.approvalModeYolo")}
</button>
{/* Approval Mode + Push to bot (side by side) */}
<div style={{ display: "flex", gap: "16px", flexWrap: "wrap" }}>
<div className="heartbeat-editor__field" style={{ flex: "1 1 45%", minWidth: "200px" }}>
<label>{t("heartbeat.fieldApprovalMode")}</label>
<div className="set-seg" style={{ alignSelf: "flex-start" }}>
<button
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "ask" ? " set-seg__btn--on" : ""}`}
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "ask" }))}
title={t("heartbeat.approvalModeAskTooltip")}
>
{t("heartbeat.approvalModeAsk")}
</button>
<button
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "auto" ? " set-seg__btn--on" : ""}`}
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "auto" }))}
title={t("heartbeat.approvalModeAutoTooltip")}
>
{t("heartbeat.approvalModeAuto")}
</button>
<button
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "yolo" ? " set-seg__btn--on" : ""}`}
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "yolo" }))}
title={t("heartbeat.approvalModeYoloTooltip")}
>
{t("heartbeat.approvalModeYolo")}
</button>
</div>
<span className="heartbeat-editor__mode-hint">
{normalizeMode(draft.approvalMode) === "yolo" ? t("heartbeat.approvalModeYoloHint") :
normalizeMode(draft.approvalMode) === "auto" ? t("heartbeat.approvalModeAutoHint") :
t("heartbeat.approvalModeAskHint")}
</span>
</div>
{/* Push to bot channels */}
<div className="heartbeat-editor__field" style={{ flex: "1 1 45%", minWidth: "200px", textAlign: "left" }}>
<label>{t("heartbeat.notifyChannels")} <span className="heartbeat-editor__optional">{t("heartbeat.optional")}</span></label>
<div className="set-seg" style={{ alignSelf: "flex-start" }}>
<button
className={`set-seg__btn${draft.notifyChannels === true ? " set-seg__btn--on" : ""}`}
onClick={() => setDraft((prev) => ({ ...prev, notifyChannels: true }))}
>
{t("heartbeat.notifyChannelsOn")}
</button>
<button
className={`set-seg__btn${draft.notifyChannels !== true ? " set-seg__btn--on" : ""}`}
onClick={() => setDraft((prev) => ({ ...prev, notifyChannels: false }))}
>
{t("heartbeat.notifyChannelsOff")}
</button>
</div>
<span className="heartbeat-editor__mode-hint">
{draft.notifyChannels === true
? t("heartbeat.notifyChannelsOnHint")
: t("heartbeat.notifyChannelsOffHint")}
</span>
</div>
<span className="heartbeat-editor__mode-hint">
{normalizeMode(draft.approvalMode) === "yolo" ? t("heartbeat.approvalModeYoloHint") :
normalizeMode(draft.approvalMode) === "auto" ? t("heartbeat.approvalModeAutoHint") :
t("heartbeat.approvalModeAskHint")}
</span>
</div>
{/* New conversation per run */}
@@ -15,4 +15,5 @@ export interface HeartbeatTask {
approvalMode?: "ask" | "auto" | "yolo"; // empty defaults to "yolo"
timeWindowStart?: string; // "HH:MM" — interval tasks only run after this time
timeWindowEnd?: string; // "HH:MM" — interval tasks only run before this time
notifyChannels?: boolean; // true = push to bot channels; false/nil = skip
}
+584 -39
View File
@@ -16,6 +16,9 @@ import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems } from "./statusBarIt
import { modeHasAutoApproveTools, modeWithAutoApproveTools, modeWithPlan, normalizeCollaborationMode, normalizeMode, normalizeTokenMode, normalizeToolApprovalMode } from "./types";
import type {
AutoResearchFindingView,
AutoResearchEvidenceView,
AutoResearchStatusView,
BalanceInfo,
BotConnectionDiagnostic,
BotInstallPollResult,
@@ -45,13 +48,18 @@ import type {
Mode,
ModelInfo,
NetworkView,
PluginInstallOptions,
PluginView,
ProjectNode,
PromptHistoryEntry,
PromptHistoryResult,
ProviderPresetView,
ProviderView,
QuestionAnswer,
ServerView,
SessionMeta,
SessionRecoveryFailedEvent,
SessionRecoveryEvent,
SettingsView,
SkillsSettingsView,
SkillRootView,
@@ -115,6 +123,10 @@ interface DesktopWindowState {
// to AppBindings, then run `pnpm typecheck` to verify.
export interface AppBindings {
Platform(): Promise<string>;
MinimiseMainWindow(): Promise<void>;
ToggleMaximiseMainWindow(): Promise<void>;
IsMainWindowMaximised(): Promise<boolean>;
CloseMainWindow(): Promise<void>;
// ── Heartbeat ──
HeartbeatListTasks(): Promise<unknown>;
HeartbeatReloadTasks(): Promise<unknown>;
@@ -190,10 +202,23 @@ export interface AppBindings {
ToolResultForTab(tabID: string, toolID: string): Promise<{ args: string; output: string } | null>;
Meta(): Promise<Meta>;
MetaForTab(tabID: string): Promise<Meta>;
AutoResearchCurrent(): Promise<AutoResearchStatusView>;
AutoResearchStatus(tabID: string): Promise<AutoResearchStatusView>;
AutoResearchList(tabID: string): Promise<AutoResearchStatusView[]>;
AutoResearchFindings(tabID: string, limit: number): Promise<AutoResearchFindingView[]>;
AutoResearchOpenTask(tabID: string): Promise<void>;
AutoResearchRecordEvidence(tabID: string, criterionID: string, input: AutoResearchEvidenceView): Promise<void>;
Commands(): Promise<CommandInfo[]>;
Capabilities(): Promise<CapabilitiesView>;
MCPServers(): Promise<ServerView[]>;
SkillsSettings(): Promise<SkillsSettingsView>;
Plugins(): Promise<PluginView[]>;
PlanPluginInstall(source: string, options: PluginInstallOptions): Promise<string>;
InstallPlugin(source: string, options: PluginInstallOptions): Promise<string>;
RemovePlugin(name: string): Promise<void>;
SetPluginEnabled(name: string, enabled: boolean): Promise<void>;
UpdatePlugin(name: string): Promise<string>;
PluginDoctor(name: string): Promise<PluginView>;
AddMCPServer(input: MCPServerInput): Promise<number>;
UpdateMCPServer(name: string, input: MCPServerInput): Promise<void>;
RemoveMCPServer(name: string): Promise<void>;
@@ -203,6 +228,7 @@ export interface AppBindings {
TrustMCPServerTools(name: string, toolNames: string[]): Promise<void>;
UntrustMCPServerTool(name: string, toolName: string): Promise<void>;
PickSkillFolder(): Promise<string>;
PickPluginFolder(): Promise<string>;
AddSkillPath(path: string): Promise<void>;
RemoveSkillPath(path: string): Promise<void>;
RefreshSkills(): Promise<void>;
@@ -264,18 +290,24 @@ export interface AppBindings {
SetPlannerModel(ref: string): Promise<void>;
SetSubagentModel(ref: string): Promise<void>;
SetSubagentEffort(level: string): Promise<void>;
SetMaxSubagentDepth(depth: number): Promise<void>;
SetAutoPlan(mode: string): Promise<void>;
SetDefaultToolApprovalMode(mode: string): Promise<void>;
SaveProvider(p: ProviderView): Promise<void>;
SaveProviderWithKey(p: ProviderView, key: string): Promise<string>;
AddOfficialProviderAccess(kind: string, key: string): Promise<string>;
AddProviderPresetAccess(id: string, key: string): Promise<string>;
ResetProviderPresetAccess(id: string): Promise<void>;
FetchProviderModels(p: ProviderView): Promise<string[]>;
DeleteProvider(name: string): Promise<void>;
RemoveProviderAccess(name: string): Promise<void>;
SaveProviderKey(apiKeyEnv: string, value: string): Promise<string>;
SetProviderKey(apiKeyEnv: string, value: string): Promise<string>;
ClearProviderKey(apiKeyEnv: string): Promise<void>;
SetPermissionMode(mode: string): Promise<void>;
AddPermissionRule(list: string, rule: string): Promise<void>;
RemovePermissionRule(list: string, rule: string): Promise<void>;
ReloadSettings(): Promise<void>;
SetSandbox(bash: string, network: boolean, workspaceRoot: string, allowWrite: string[], shell: string): Promise<void>;
SetNetwork(n: NetworkView): Promise<void>;
SetBotSettings(b: BotSettingsView): Promise<void>;
@@ -294,6 +326,9 @@ export interface AppBindings {
SetDesktopLanguage(lang: string): Promise<void>;
SetDesktopAppearance(theme: string, style: string): Promise<void>;
SetDesktopLayoutStyle(style: string): Promise<void>;
SetDesktopZoomFactor(factor: number): Promise<void>;
GetDesktopZoomFactor(): Promise<number>;
RestartApplication(): Promise<void>;
SetDesktopCheckUpdates(enabled: boolean): Promise<void>;
SetDesktopTelemetry(enabled: boolean): Promise<void>;
SetDesktopMetrics(enabled: boolean): Promise<void>;
@@ -394,6 +429,8 @@ const EVENT_CHANNEL = "agent:event";
const RECENT_NATIVE_FILE_DRAG_MS = 2000;
const WAILS_NON_FILE_DRAG_MESSAGE = "additional File object is not a file on the disk";
const UNCAUGHT_ERROR_PREFIX_RE = /^Uncaught(?:\s+\(in promise\))?(?:\s+\w*Error)?:\s*/i;
const WAILS_IPC_CONNECTING_RE = /Failed to execute 'send' on 'WebSocket': Still in CONNECTING state/i;
const WAILS_IPC_NULL_SEND_RE = /Cannot read properties of null \(reading 'send'\)/i;
// Resolve the Wails binding at CALL time, not module-load time: in dev the Wails
// runtime can inject window.go AFTER this module first evaluates, so snapshotting
@@ -452,6 +489,11 @@ export function isWailsNonFileDragErrorEvent(
return event.error != null && isWailsNonFileDragError(event.message, recentNativeFileDrag);
}
export function isTransientWailsIPCError(err: unknown): boolean {
const msg = errorMessage(err).trim().replace(UNCAUGHT_ERROR_PREFIX_RE, "");
return WAILS_IPC_CONNECTING_RE.test(msg) || WAILS_IPC_NULL_SEND_RE.test(msg);
}
function dataTransferLooksLikeFileDrag(dt: DataTransfer | null): boolean {
if (!dt) return false;
if (dt.files?.length > 0) return true;
@@ -472,12 +514,12 @@ export function installWailsNonFileDragErrorSuppression(): () => void {
};
const hasRecentNativeFileDrag = () => Date.now() - lastNativeFileDragAt <= RECENT_NATIVE_FILE_DRAG_MS;
const suppressNonFileDragError = (e: ErrorEvent) => {
if (isWailsNonFileDragErrorEvent(e, hasRecentNativeFileDrag())) {
if (isWailsNonFileDragErrorEvent(e, hasRecentNativeFileDrag()) || isTransientWailsIPCError(e.error ?? e.message)) {
e.preventDefault();
}
};
const suppressNonFileDragRejection = (e: PromiseRejectionEvent) => {
if (isWailsNonFileDragError(e.reason, hasRecentNativeFileDrag())) {
if (isWailsNonFileDragError(e.reason, hasRecentNativeFileDrag()) || isTransientWailsIPCError(e.reason)) {
e.preventDefault();
}
};
@@ -548,23 +590,38 @@ export function onProjectTreeChanged(cb: () => void): () => void {
return () => {};
}
export function onSessionRecovered(cb: (payload: SessionRecoveryEvent) => void): () => void {
if (realApp() && typeof window !== "undefined" && window.runtime) {
return window.runtime.EventsOn("session:recovered", (payload?: unknown) => cb((payload ?? {}) as SessionRecoveryEvent));
}
return () => {};
}
export function onSessionRecoveryFailed(cb: (payload: SessionRecoveryFailedEvent) => void): () => void {
if (realApp() && typeof window !== "undefined" && window.runtime) {
return window.runtime.EventsOn("session:recovery-failed", (payload?: unknown) => cb((payload ?? {}) as SessionRecoveryFailedEvent));
}
return () => {};
}
// app proxies each call to the live binding (or the dev mock only when truly
// outside the shell), so a late-injected window.go is picked up transparently.
function bridgeBreadcrumb(method: string): string {
if (method === "ReportCrash") return "";
if (/^(Submit|SubmitDisplay|RunShell|Steer|Cancel|Approve|AnswerQuestion|ReplayPendingPrompts)/.test(method))
return `turn ${method}`;
if (/^(SetModel|SetEffort|SetTokenMode|SetDefaultModel|SetPlannerModel|SetSubagentModel|SetSubagentEffort)/.test(method))
if (/^(SetModel|SetEffort|SetTokenMode|SetDefaultModel|SetPlannerModel|SetSubagentModel|SetSubagentEffort|SetMaxSubagentDepth)/.test(method))
return `model ${method}`;
if (/^(SetDesktop|SetCloseBehavior|SetDisplayMode|SetStatusBar|SetExpandThinking|SetAutoPlan|SetDefaultToolApprovalMode|SetMemoryCompilerEnabled|SetReasoningLanguage)/.test(method))
return `settings ${method}`;
if (/^(SaveProvider|AddOfficialProviderAccess|RemoveProviderAccess|DeleteProvider|SetProviderKey|ClearProviderKey|FetchProviderModels|ConnectKey)/.test(method))
if (/^(SaveProvider|AddOfficialProviderAccess|AddProviderPresetAccess|ResetProviderPresetAccess|RemoveProviderAccess|DeleteProvider|SaveProviderKey|SetProviderKey|ClearProviderKey|FetchProviderModels|ConnectKey)/.test(method))
return `provider ${method}`;
if (/^(CheckUpdate|DownloadUpdate|InstallUpdate|ApplyUpdate|OpenDownloadPage)/.test(method)) return `update ${method}`;
if (/^(AddMCPServer|UpdateMCPServer|RemoveMCPServer|ReconnectMCPServer|ClearMCPServerAuthentication|TrustMCPServerTool|TrustMCPServerTools|UntrustMCPServerTool|SetMCPServer)/.test(method))
return `mcp ${method}`;
if (/^(AddSkillPath|RemoveSkillPath|RefreshSkills|SetSkillEnabled|AcceptSkillSuggestion)/.test(method))
return `skill ${method}`;
if (/^(MinimiseMainWindow|ToggleMaximiseMainWindow|IsMainWindowMaximised|CloseMainWindow)$/.test(method)) return `window ${method}`;
if (/^(OpenProjectTab|OpenGlobalTab|OpenTopicSession|EnsureBlankTab|ActivateTopic|EnsureBlankSurface|SetActiveTab|CloseTab|ReorderTabs|CreateTopic|RenameTopic|DeleteTopic|TrashTopic|RenameProject|RemoveWorkspace|SwitchWorkspace|PickWorkspace)/.test(method))
return `nav ${method}`;
return "";
@@ -662,18 +719,162 @@ function browserPlatformOverride(): "darwin" | "windows" | "linux" | "" {
return value === "darwin" || value === "windows" || value === "linux" ? value : "";
}
function mockScenario(): "demo" | "fresh" | "running" {
function mockScenario(): "demo" | "fresh" | "running" | "guidance" | "sandbox_escape" {
if (typeof window === "undefined") return "demo";
const value = new URLSearchParams(window.location.search).get("mock")?.trim().toLowerCase();
if (value === "fresh" || value === "empty" || value === "first-run") return "fresh";
if (value === "guidance" || value === "guide" || value === "steer") return "guidance";
if (value === "running" || value === "busy" || value === "streaming") return "running";
if (value === "sandbox_escape" || value === "sandbox-escape" || value === "sandboxescape") return "sandbox_escape";
return "demo";
}
type MockProviderPresetTemplate = {
id: string;
label: string;
description: string;
keyEnv: string;
provider: ProviderView;
};
function mockProviderTemplate(p: Pick<ProviderView, "name" | "kind" | "baseUrl" | "models" | "default" | "apiKeyEnv"> & Partial<ProviderView>): ProviderView {
return {
name: p.name,
builtIn: false,
added: true,
kind: p.kind,
baseUrl: p.baseUrl,
modelsUrl: p.modelsUrl ?? "",
models: p.models,
visionModels: p.visionModels ?? [],
visionModelsConfigured: Boolean(p.visionModelsConfigured ?? ((p.visionModels ?? []).length > 0)),
default: p.default,
apiKeyEnv: p.apiKeyEnv,
headers: p.headers,
extraBody: p.extraBody,
authHeader: p.authHeader,
keySet: Boolean(p.keySet),
balanceUrl: p.balanceUrl ?? "",
contextWindow: p.contextWindow ?? 0,
reasoningProtocol: p.reasoningProtocol ?? "",
thinking: p.thinking ?? "",
supportedEfforts: p.supportedEfforts ?? [],
defaultEffort: p.defaultEffort ?? "",
modelOverrides: p.modelOverrides,
};
}
function mockPreset(id: string, label: string, description: string, keyEnv: string, provider: ProviderView): MockProviderPresetTemplate {
return { id, label, description, keyEnv, provider };
}
const mockKimiAPIModels = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"];
const mockLongCatModels = ["LongCat-2.0"];
const mockMiMoV25Models = ["mimo-v2.5-pro", "mimo-v2.5"];
const mockMiniMaxModels = ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed"];
const mockGLMAPIModels = ["glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7", "glm-4.7-flash", "glm-4.7-flashx", "glm-4.6", "glm-4.5", "glm-4.5-air", "glm-4.5-flash"];
const mockGLMCodingModels = ["glm-5.2", "glm-5.1", "glm-5", "glm-4.7"];
const mockGLMAnthropicModels = ["glm-5.2[1m]", "glm-5.2", "glm-5.1", "glm-5", "glm-4.7", "glm-4.5-air"];
const mockQwenAPIModels = ["qwen3.7-plus", "qwen3.7-max", "qwen3.6-plus", "qwen3.5-plus", "qwen3-max-2026-01-23", "qwen3-coder-next", "qwen3-coder-plus", "MiniMax-M2.5", "glm-5", "glm-4.7", "kimi-k2.5"];
const mockQwenPlanModels = ["qwen3.7-plus", "qwen3.6-plus", "kimi-k2.5", "glm-5", "MiniMax-M2.5", "qwen3.5-plus", "qwen3-max-2026-01-23", "qwen3-coder-next", "qwen3-coder-plus", "glm-4.7"];
const mockQwenPlanVisionModels = ["qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", "kimi-k2.5"];
const mockStepFunModels = ["step-3.7-flash", "step-3.5-flash", "step-3.5-flash-2603"];
const mockOpenCodeGoModels = ["glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "deepseek-v4-pro", "deepseek-v4-flash", "mimo-v2.5-pro", "mimo-v2.5"];
const mockOpenCodeGoAnthropicModels = ["qwen3.7-plus", "qwen3.7-max", "qwen3.6-plus", "minimax-m3", "minimax-m2.7", "minimax-m2.5"];
const mockOpenCodeZenAnthropicModels = ["claude-sonnet-4-6", "claude-opus-4-8", "claude-haiku-4-5", "qwen3.6-plus", "qwen3.5-plus", "qwen3.6-plus-free"];
const mockNovitaModels = ["zai-org/glm-5.2", "moonshotai/kimi-k2.7-code", "minimax/minimax-m3", "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", "qwen/qwen3.7-max", "qwen/qwen3.6-plus", "zai-org/glm-5v-turbo"];
const mockGMIModels = ["zai-org/GLM-5.2-FP8", "deepseek-ai/DeepSeek-V4-Pro", "deepseek-ai/DeepSeek-V4-Flash", "moonshotai/Kimi-K2.7-Code", "anthropic/claude-sonnet-4.6", "openai/gpt-5.5"];
const mockVercelModels = ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-4.8", "openai/gpt-5.4", "openai/gpt-5.4-pro", "moonshotai/kimi-k2.7-code", "zai/glm-5.2", "deepseek/deepseek-v4-pro"];
const mockOllamaCloudModels = ["glm-5.2", "kimi-k2.7-code", "deepseek-v4-pro", "deepseek-v4-flash", "minimax-m3", "nemotron-3-nano:30b", "qwen3-coder-next"];
const mockProviderPresetTemplates: MockProviderPresetTemplate[] = [
mockPreset("longcat-openai", "LongCat OpenAI", "LongCat Platform OpenAI-compatible endpoint for LongCat-2.0.", "LONGCAT_API_KEY", mockProviderTemplate({ name: "longcat-openai", kind: "openai", baseUrl: "https://api.longcat.chat/openai/v1", modelsUrl: "https://api.longcat.chat/openai/v1/models", models: mockLongCatModels, default: "LongCat-2.0", apiKeyEnv: "LONGCAT_API_KEY", contextWindow: 131072, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
mockPreset("longcat-anthropic", "LongCat Anthropic", "LongCat Platform Anthropic-compatible Messages endpoint for LongCat-2.0.", "LONGCAT_API_KEY", mockProviderTemplate({ name: "longcat-anthropic", kind: "anthropic", baseUrl: "https://api.longcat.chat/anthropic", modelsUrl: "https://api.longcat.chat/anthropic/v1/models", models: mockLongCatModels, default: "LongCat-2.0", apiKeyEnv: "LONGCAT_API_KEY", authHeader: true, contextWindow: 131072, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
mockPreset("kimi-cn", "Kimi CN API", "Moonshot Kimi China OpenAI-compatible API.", "KIMI_API_KEY", mockProviderTemplate({ name: "kimi-cn", kind: "openai", baseUrl: "https://api.moonshot.cn/v1", models: mockKimiAPIModels, visionModels: mockKimiAPIModels, default: "kimi-k2.7-code", apiKeyEnv: "KIMI_API_KEY", balanceUrl: "https://api.moonshot.cn/v1/users/me/balance", contextWindow: 262144, reasoningProtocol: "none" })),
mockPreset("kimi-global", "Kimi Global API", "Moonshot Kimi international OpenAI-compatible API.", "MOONSHOT_API_KEY", mockProviderTemplate({ name: "kimi-global", kind: "openai", baseUrl: "https://api.moonshot.ai/v1", models: mockKimiAPIModels, visionModels: mockKimiAPIModels, default: "kimi-k2.7-code", apiKeyEnv: "MOONSHOT_API_KEY", balanceUrl: "https://api.moonshot.ai/v1/users/me/balance", contextWindow: 262144, reasoningProtocol: "none" })),
mockPreset("kimi-coding-plan", "Kimi Coding Plan", "Kimi Coding Plan via its dedicated Anthropic-compatible endpoint.", "KIMI_CODING_API_KEY", mockProviderTemplate({ name: "kimi-coding-plan", kind: "anthropic", baseUrl: "https://api.kimi.com/coding/", models: ["kimi-for-coding"], visionModels: ["kimi-for-coding"], default: "kimi-for-coding", apiKeyEnv: "KIMI_CODING_API_KEY", headers: { "User-Agent": "claude-code/0.1.0" }, thinking: "adaptive", contextWindow: 262144 })),
mockPreset("mimo-api", "MiMo API", "Xiaomi MiMo direct API with text and vision-capable models.", "MIMO_API_KEY", mockProviderTemplate({ name: "mimo-api", kind: "openai", baseUrl: "https://api.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", contextWindow: 1048576 })),
mockPreset("mimo-anthropic", "MiMo Anthropic", "Xiaomi MiMo direct Anthropic-compatible endpoint.", "MIMO_API_KEY", mockProviderTemplate({ name: "mimo-anthropic", kind: "anthropic", baseUrl: "https://api.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
mockPreset("mimo-token-plan-cn", "MiMo Token Plan CN", "Xiaomi MiMo token-plan China endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-cn", kind: "openai", baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", contextWindow: 1048576 })),
mockPreset("mimo-token-plan-cn-anthropic", "MiMo Token Plan CN Anthropic", "Xiaomi MiMo token-plan China Anthropic-compatible endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-cn-anthropic", kind: "anthropic", baseUrl: "https://token-plan-cn.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
mockPreset("mimo-token-plan-sgp", "MiMo Token Plan SGP", "Xiaomi MiMo token-plan Singapore endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-sgp", kind: "openai", baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", contextWindow: 1048576 })),
mockPreset("mimo-token-plan-sgp-anthropic", "MiMo Token Plan SGP Anthropic", "Xiaomi MiMo token-plan Singapore Anthropic-compatible endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-sgp-anthropic", kind: "anthropic", baseUrl: "https://token-plan-sgp.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
mockPreset("mimo-token-plan-ams", "MiMo Token Plan AMS", "Xiaomi MiMo token-plan Amsterdam endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-ams", kind: "openai", baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", contextWindow: 1048576 })),
mockPreset("mimo-token-plan-ams-anthropic", "MiMo Token Plan AMS Anthropic", "Xiaomi MiMo token-plan Amsterdam Anthropic-compatible endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-ams-anthropic", kind: "anthropic", baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
mockPreset("minimax-cn-api", "MiniMax CN API", "MiniMax China OpenAI-compatible M-series API endpoint.", "MINIMAX_API_KEY", mockProviderTemplate({ name: "minimax-cn-api", kind: "openai", baseUrl: "https://api.minimaxi.com/v1", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_API_KEY", extraBody: { reasoning_split: true }, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
mockPreset("minimax-global-api", "MiniMax Global API", "MiniMax international OpenAI-compatible M-series API endpoint.", "MINIMAX_API_KEY", mockProviderTemplate({ name: "minimax-global-api", kind: "openai", baseUrl: "https://api.minimax.io/v1", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_API_KEY", extraBody: { reasoning_split: true }, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
mockPreset("minimax-cn-anthropic", "MiniMax CN Anthropic", "MiniMax China Anthropic-compatible M-series endpoint.", "MINIMAX_PLAN_API_KEY", mockProviderTemplate({ name: "minimax-cn-anthropic", kind: "anthropic", baseUrl: "https://api.minimaxi.com/anthropic", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_PLAN_API_KEY", authHeader: true, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
mockPreset("minimax-global-anthropic", "MiniMax Global Anthropic", "MiniMax international Anthropic-compatible endpoint with Bearer auth.", "MINIMAX_API_KEY", mockProviderTemplate({ name: "minimax-global-anthropic", kind: "anthropic", baseUrl: "https://api.minimax.io/anthropic", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_API_KEY", authHeader: true, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
mockPreset("glm-cn", "GLM CN API", "Zhipu GLM China OpenAI-compatible API with thinking controls.", "GLM_API_KEY", mockProviderTemplate({ name: "glm-cn", kind: "openai", baseUrl: "https://open.bigmodel.cn/api/paas/v4", models: mockGLMAPIModels, visionModels: ["glm-5v-turbo"], default: "glm-5.2", apiKeyEnv: "GLM_API_KEY", contextWindow: 1000000, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
mockPreset("zai-global", "Z.AI Global API", "Z.AI international OpenAI-compatible GLM API.", "ZAI_API_KEY", mockProviderTemplate({ name: "zai-global", kind: "openai", baseUrl: "https://api.z.ai/api/paas/v4", models: mockGLMAPIModels, visionModels: ["glm-5v-turbo"], default: "glm-5.2", apiKeyEnv: "ZAI_API_KEY", contextWindow: 1000000, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
mockPreset("glm-coding-plan-cn", "GLM Coding Plan CN", "Zhipu GLM China coding-plan endpoint.", "GLM_PLAN_API_KEY", mockProviderTemplate({ name: "glm-coding-plan-cn", kind: "openai", baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", models: mockGLMCodingModels, default: "glm-5.2", apiKeyEnv: "GLM_PLAN_API_KEY", contextWindow: 1000000, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
mockPreset("glm-coding-plan-cn-anthropic", "GLM Coding Plan CN Anthropic", "Zhipu GLM China coding-plan Anthropic-compatible endpoint.", "GLM_PLAN_API_KEY", mockProviderTemplate({ name: "glm-coding-plan-cn-anthropic", kind: "anthropic", baseUrl: "https://open.bigmodel.cn/api/anthropic", models: mockGLMAnthropicModels, default: "glm-5.2[1m]", apiKeyEnv: "GLM_PLAN_API_KEY", authHeader: true, thinking: "adaptive", contextWindow: 1000000 })),
mockPreset("zai-coding-plan-global", "Z.AI Coding Plan Global", "Z.AI international coding-plan endpoint.", "ZAI_CODING_API_KEY", mockProviderTemplate({ name: "zai-coding-plan-global", kind: "openai", baseUrl: "https://api.z.ai/api/coding/paas/v4", models: mockGLMCodingModels, default: "glm-5.2", apiKeyEnv: "ZAI_CODING_API_KEY", contextWindow: 1000000, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
mockPreset("zai-coding-plan-global-anthropic", "Z.AI Coding Plan Global Anthropic", "Z.AI international coding-plan Anthropic-compatible endpoint.", "ZAI_CODING_API_KEY", mockProviderTemplate({ name: "zai-coding-plan-global-anthropic", kind: "anthropic", baseUrl: "https://api.z.ai/api/anthropic", models: mockGLMAnthropicModels, default: "glm-5.2[1m]", apiKeyEnv: "ZAI_CODING_API_KEY", authHeader: true, thinking: "adaptive", contextWindow: 1000000 })),
mockPreset("opencode-go", "OpenCode Go", "OpenCode Go relay with per-model capability overrides.", "OPENCODE_GO_API_KEY", mockProviderTemplate({ name: "opencode-go", kind: "openai", baseUrl: "https://opencode.ai/zen/go/v1", models: mockOpenCodeGoModels, default: "glm-5.2", apiKeyEnv: "OPENCODE_GO_API_KEY", contextWindow: 128000 })),
mockPreset("opencode-go-anthropic", "OpenCode Go Anthropic", "OpenCode Go subscription Anthropic-compatible route for Qwen and MiniMax models.", "OPENCODE_GO_API_KEY", mockProviderTemplate({ name: "opencode-go-anthropic", kind: "anthropic", baseUrl: "https://opencode.ai/zen/go", models: mockOpenCodeGoAnthropicModels, visionModels: ["qwen3.7-plus", "qwen3.6-plus"], default: "qwen3.7-plus", apiKeyEnv: "OPENCODE_GO_API_KEY", thinking: "adaptive", contextWindow: 262144 })),
mockPreset("opencode-zen-anthropic", "OpenCode Zen Anthropic", "OpenCode Zen Anthropic-compatible route for Claude and Qwen models.", "OPENCODE_API_KEY", mockProviderTemplate({ name: "opencode-zen-anthropic", kind: "anthropic", baseUrl: "https://opencode.ai/zen", models: mockOpenCodeZenAnthropicModels, visionModels: ["claude-sonnet-4-6", "claude-opus-4-8", "claude-haiku-4-5"], default: "claude-sonnet-4-6", apiKeyEnv: "OPENCODE_API_KEY", contextWindow: 262144 })),
mockPreset("qwen-cn", "Qwen CN API", "Alibaba DashScope China standard OpenAI-compatible endpoint.", "QWEN_API_KEY", mockProviderTemplate({ name: "qwen-cn", kind: "openai", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", models: mockQwenAPIModels, visionModels: ["qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", "kimi-k2.5"], default: "qwen3.7-plus", apiKeyEnv: "QWEN_API_KEY" })),
mockPreset("qwen-global", "Qwen Global API", "Alibaba DashScope international standard OpenAI-compatible endpoint.", "QWEN_API_KEY", mockProviderTemplate({ name: "qwen-global", kind: "openai", baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", models: mockQwenAPIModels, visionModels: ["qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", "kimi-k2.5"], default: "qwen3.7-plus", apiKeyEnv: "QWEN_API_KEY" })),
mockPreset("qwen-coding-plan-cn", "Qwen Coding Plan CN", "Alibaba Cloud Qwen Coding Plan China endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-cn", kind: "openai", baseUrl: "https://coding.dashscope.aliyuncs.com/v1", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY" })),
mockPreset("qwen-coding-plan-cn-anthropic", "Qwen Coding Plan CN Anthropic", "Alibaba Cloud Qwen Coding Plan China Anthropic-compatible endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-cn-anthropic", kind: "anthropic", baseUrl: "https://coding.dashscope.aliyuncs.com/apps/anthropic", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY", thinking: "adaptive" })),
mockPreset("qwen-coding-plan-global", "Qwen Coding Plan Global", "Alibaba Cloud Qwen Coding Plan international endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-global", kind: "openai", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY" })),
mockPreset("qwen-coding-plan-global-anthropic", "Qwen Coding Plan Global Anthropic", "Alibaba Cloud Qwen Coding Plan international Anthropic-compatible endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-global-anthropic", kind: "anthropic", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY", thinking: "adaptive" })),
mockPreset("stepfun", "StepFun", "StepFun coding-plan OpenAI-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun", kind: "openai", baseUrl: "https://api.stepfun.com/step_plan/v1", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
mockPreset("stepfun-anthropic", "StepFun Anthropic", "StepFun coding-plan Anthropic-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun-anthropic", kind: "anthropic", baseUrl: "https://api.stepfun.com/step_plan", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", thinking: "adaptive", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
mockPreset("novita", "NovitaAI", "NovitaAI OpenAI-compatible multi-model gateway.", "NOVITA_API_KEY", mockProviderTemplate({ name: "novita", kind: "openai", baseUrl: "https://api.novita.ai/openai/v1", models: mockNovitaModels, default: "zai-org/glm-5.2", apiKeyEnv: "NOVITA_API_KEY" })),
mockPreset("gmi", "GMI Cloud", "GMI Cloud direct multi-model OpenAI-compatible gateway.", "GMI_API_KEY", mockProviderTemplate({ name: "gmi", kind: "openai", baseUrl: "https://api.gmi-serving.com/v1", models: mockGMIModels, default: "zai-org/GLM-5.2-FP8", apiKeyEnv: "GMI_API_KEY", headers: { "User-Agent": "Reasonix" } })),
mockPreset("vercel-ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway via Anthropic-compatible Messages API.", "AI_GATEWAY_API_KEY", mockProviderTemplate({ name: "vercel-ai-gateway", kind: "anthropic", baseUrl: "https://ai-gateway.vercel.sh", models: mockVercelModels, visionModels: ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-4.8", "openai/gpt-5.4", "openai/gpt-5.4-pro", "moonshotai/kimi-k2.7-code"], default: "anthropic/claude-sonnet-4.6", apiKeyEnv: "AI_GATEWAY_API_KEY", authHeader: true, contextWindow: 1000000 })),
mockPreset("huggingface", "HuggingFace Router", "HuggingFace Inference Router OpenAI-compatible endpoint.", "HF_TOKEN", mockProviderTemplate({ name: "huggingface", kind: "openai", baseUrl: "https://router.huggingface.co/v1", models: ["zai-org/GLM-5.2", "deepseek-ai/DeepSeek-V3.2", "Qwen/Qwen3.5-72B-Instruct"], default: "zai-org/GLM-5.2", apiKeyEnv: "HF_TOKEN" })),
mockPreset("nvidia", "NVIDIA NIM", "NVIDIA NIM OpenAI-compatible accelerated inference endpoint.", "NVIDIA_API_KEY", mockProviderTemplate({ name: "nvidia", kind: "openai", baseUrl: "https://integrate.api.nvidia.com/v1", models: ["nvidia/nemotron-3-nano-30b-a3b", "nvidia/nemotron-3-super-120b-a12b", "nvidia/nemotron-3-ultra-550b-a55b", "deepseek-ai/deepseek-v4-pro", "qwen/qwen3.5-397b-a17b"], default: "nvidia/nemotron-3-nano-30b-a3b", apiKeyEnv: "NVIDIA_API_KEY" })),
mockPreset("kilocode", "KiloCode", "Kilo Code gateway OpenAI-compatible endpoint.", "KILOCODE_API_KEY", mockProviderTemplate({ name: "kilocode", kind: "openai", baseUrl: "https://api.kilo.ai/api/gateway", models: ["kilo/auto"], default: "kilo/auto", apiKeyEnv: "KILOCODE_API_KEY" })),
mockPreset("ollama-cloud", "Ollama Cloud", "Hosted Ollama Cloud OpenAI-compatible endpoint with max reasoning effort.", "OLLAMA_API_KEY", mockProviderTemplate({ name: "ollama-cloud", kind: "openai", baseUrl: "https://ollama.com/v1", models: mockOllamaCloudModels, default: "glm-5.2", apiKeyEnv: "OLLAMA_API_KEY" })),
];
function mockProviderPresetViews(): ProviderPresetView[] {
return [...mockProviderPresetTemplates].sort((a, b) => mockProviderPresetDisplayRank(a.id) - mockProviderPresetDisplayRank(b.id)).map((template) => ({
id: template.id,
label: template.label,
description: template.description,
keyEnv: template.keyEnv,
providerNames: [template.provider.name],
models: [...template.provider.models],
added: false,
status: "available",
statusProviderNames: [],
keySet: false,
requiresKey: true,
configured: false,
}));
}
function mockProviderPresetDisplayRank(id: string): number {
if (id === "glm-cn" || id === "zai-global" || id.startsWith("glm-coding-plan-") || id.startsWith("zai-coding-plan-")) return 0;
if (id.startsWith("longcat-")) return 1;
if (id.startsWith("kimi-")) return 2;
if (id.startsWith("minimax-")) return 3;
return 4;
}
function cloneMockProviderTemplate(id: string, key: string): ProviderView | undefined {
const template = mockProviderPresetTemplates.find((candidate) => candidate.id === id);
if (!template) return undefined;
return {
...JSON.parse(JSON.stringify(template.provider)) as ProviderView,
keySet: Boolean(key.trim()),
};
}
const mockPreviewImageDataURL =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='120' viewBox='0 0 160 120'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%23f97316'/%3E%3Cstop offset='1' stop-color='%232563eb'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='160' height='120' rx='14' fill='url(%23g)'/%3E%3Ccircle cx='44' cy='38' r='16' fill='%23fff7ed' opacity='.9'/%3E%3Cpath d='M18 96 62 58l24 22 18-16 38 32z' fill='%23ffffff' opacity='.9'/%3E%3C/svg%3E";
function makeMockApp(): AppBindings {
const scenario = mockScenario();
const freshMock = scenario === "fresh";
const runningMock = scenario === "running";
const guidanceMock = scenario === "guidance";
const runningMock = scenario === "running" || guidanceMock;
const sandboxEscapeMock = scenario === "sandbox_escape";
const mockAttachmentDataURLs = new Map<string, string>();
let cancelled = false;
let pendingAskPreview = false;
let pendingApprovalPreview = false;
@@ -681,6 +882,7 @@ function makeMockApp(): AppBindings {
let cwd = freshMock ? globalWorkspaceRoot : "~/projects/joyquant-db"; // mutable so PickWorkspace is visible in dev
let workspaces = freshMock ? [] : ["~/projects/joyquant-db", "~/projects/joyquant-sys", "~/projects/reasonix", "~/projects/blade"];
let mockEffort = "auto";
let mockDesktopZoomFactor = 1.0;
const day = 86_400_000;
const t0 = Date.now();
// Mutable so MCP add/remove/retry are observable in browser dev.
@@ -762,6 +964,7 @@ function makeMockApp(): AppBindings {
],
},
];
let capPlugins: PluginView[] = [];
const mockSwitchWorkspace = async (path: string) => {
cwd = path || "~";
workspaces = [cwd, ...workspaces.filter((p) => p !== cwd)].slice(0, 12);
@@ -844,37 +1047,64 @@ function makeMockApp(): AppBindings {
subagentEffort: "",
autoPlan: "off",
providers: [
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
],
officialProviders: [
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
],
providerPresets: mockProviderPresetViews(),
permissions: { mode: "ask", allow: ["ls", "read_file"], ask: [], deny: ["Bash(rm:*)"] },
sandbox: { bash: "enforce", network: true, workspaceRoot: "", allowWrite: [], shell: "auto" },
sandbox: { bash: "enforce", network: true, workspaceRoot: "", allowWrite: [], effectiveWorkspaceRoot: cwd, effectiveWriteRoots: [cwd], shell: "auto" },
network: {
proxyMode: "auto",
proxyUrl: "",
noProxy: "",
proxy: { type: "socks5", server: "127.0.0.1", port: 7890, username: "", password: "" },
},
agent: { temperature: 0.2, maxSteps: 0, plannerMaxSteps: 0, systemPrompt: "You are Reasonix, a coding agent.", coldResumePrune: true, reasoningLanguage: "auto" },
agent: { temperature: 0.2, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, systemPrompt: "You are Reasonix, a coding agent.", coldResumePrune: true, reasoningLanguage: "auto" },
bot: {
enabled: !freshMock,
model: "",
toolApprovalMode: "ask",
maxSteps: 25,
debounceMs: 1500,
queueMode: "steer",
queueCap: 20,
queueDrop: "summarize",
ignoreSelfMessages: true,
selfUserIds: {
qq: [],
feishu: [],
weixin: [],
},
control: {
enabled: false,
addr: "127.0.0.1:37913",
tokenEnv: "REASONIX_BOT_CONTROL_TOKEN",
},
pairing: {
enabled: true,
requestTtlMinutes: 60,
maxPendingPerPlatform: 3,
},
routes: [],
allowlist: {
enabled: true,
allowAll: false,
qqUsers: [],
feishuUsers: freshMock ? [] : ["ou_mock_user_001"],
weixinUsers: freshMock ? [] : ["wxid_mock_user_001"],
qqApprovers: [],
feishuApprovers: [],
weixinApprovers: [],
qqAdmins: [],
feishuAdmins: [],
weixinAdmins: [],
qqGroups: [],
feishuGroups: [],
weixinGroups: [],
},
qq: { enabled: false, appId: "", appSecretEnv: "QQ_BOT_APP_SECRET", secretSet: false, sandbox: false },
qq: { enabled: false, appId: "", appSecretEnv: "QQ_BOT_APP_SECRET", secretSet: false, sandbox: false, model: "", toolApprovalMode: "ask", workspaceRoot: "", access: { enabled: true, allowAll: false, pairingEnabled: true, users: [], groups: [], approvers: [], admins: [] } },
feishu: {
enabled: false,
domain: "feishu",
@@ -901,10 +1131,11 @@ function makeMockApp(): AppBindings {
label: "kun",
enabled: true,
status: "connected",
model: "",
toolApprovalMode: "",
workspaceRoot: "",
credential: {
model: "",
toolApprovalMode: "",
workspaceRoot: "",
access: { enabled: true, allowAll: false, pairingEnabled: true, users: ["ou_mock_user_001"], groups: [], approvers: [], admins: [] },
credential: {
appId: "cli_mock_lark",
appSecretEnv: "FEISHU_BOT_APP_SECRET",
accountId: "",
@@ -935,10 +1166,11 @@ function makeMockApp(): AppBindings {
label: "kun",
enabled: true,
status: "connected",
model: "",
toolApprovalMode: "",
workspaceRoot: "",
credential: {
model: "",
toolApprovalMode: "",
workspaceRoot: "",
access: { enabled: true, allowAll: false, pairingEnabled: true, users: ["wxid_mock_user_001"], groups: [], approvers: [], admins: [] },
credential: {
appId: "",
appSecretEnv: "",
accountId: "default",
@@ -978,7 +1210,7 @@ function makeMockApp(): AppBindings {
metrics: true,
memoryCompilerEnabled: true,
configPath: "~/projects/reasonix/reasonix.toml",
providerKinds: ["openai"],
providerKinds: ["openai", "anthropic"],
autoApproveTools: false,
bypass: false,
};
@@ -1362,7 +1594,7 @@ function makeMockApp(): AppBindings {
collaborationMode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
active: true,
active: !guidanceMock,
cwd: "~/projects/joyquant-db",
},
{
@@ -1382,7 +1614,7 @@ function makeMockApp(): AppBindings {
collaborationMode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
active: false,
active: guidanceMock,
cwd: "~/projects/joyquant-sys",
},
{
@@ -1404,6 +1636,23 @@ function makeMockApp(): AppBindings {
cwd: "~/projects/joyquant-db",
},
];
if (sandboxEscapeMock) {
window.setTimeout(() => {
if (pendingApprovalPreview) return;
pendingApprovalPreview = true;
emitMockTurnStarted();
emit({ kind: "reasoning", text: t("mock.sandboxEscapeReasoning") });
emit({
kind: "approval_request",
approval: {
id: "mock-sandbox-escape-preview",
tool: "sandbox_escape",
subject: t("mock.sandboxEscapeSubject"),
reason: t("mock.sandboxEscapeReason"),
},
});
}, 800);
}
const mockModelCatalog = [
{ ref: "deepseek/deepseek-v4-flash", provider: "deepseek", model: "deepseek-v4-flash" },
{ ref: "deepseek/deepseek-v4-pro", provider: "deepseek", model: "deepseek-v4-pro" },
@@ -1434,6 +1683,18 @@ function makeMockApp(): AppBindings {
}
};
return {
async MinimiseMainWindow() {
console.info("mock MinimiseMainWindow");
},
async ToggleMaximiseMainWindow() {
console.info("mock ToggleMaximiseMainWindow");
},
async IsMainWindowMaximised() {
return false;
},
async CloseMainWindow() {
console.info("mock CloseMainWindow");
},
async Platform() {
const override = browserPlatformOverride();
if (override) return override;
@@ -1488,6 +1749,26 @@ function makeMockApp(): AppBindings {
});
return;
}
if (
trimmedInput === "/sandbox-escape-preview" ||
trimmedInput === "sandbox escape preview" ||
trimmedInput === "sandbox_escape preview" ||
trimmedInput === "sandbox escape预览"
) {
pendingApprovalPreview = true;
await delay(250);
if (cancelled) return;
emit({
kind: "approval_request",
approval: {
id: "mock-sandbox-escape-preview",
tool: "sandbox_escape",
subject: t("mock.sandboxEscapeSubject"),
reason: t("mock.sandboxEscapeReason"),
},
});
return;
}
if (
trimmedInput === "/plan-approve-preview" ||
trimmedInput === "plan approve preview" ||
@@ -1602,6 +1883,67 @@ function makeMockApp(): AppBindings {
emitMockTurnDone();
return;
}
if (trimmedInput === "/nested-preview" || trimmedInput === "nested preview" || trimmedInput === "嵌套预览") {
const parentId = "mock-nested-explore";
await delay(180);
if (cancelled) return;
emit({
kind: "reasoning",
text: "我先快速探索相关文件,再整理这个工具行的视觉层级。",
});
emit({
kind: "message",
text: "",
reasoning: "我先快速探索相关文件,再整理这个工具行的视觉层级。",
});
emit({
kind: "tool_dispatch",
tool: {
id: parentId,
name: "explore",
args: JSON.stringify({ task: "在 Reasonix 前端中检查工具调用图标和嵌套调用展示" }),
readOnly: true,
profile: { model: "mock-reasonix", effort: "high" },
},
});
for (let i = 1; i <= 30; i += 1) {
if (cancelled) return;
const id = `mock-nested-${i}`;
const isSearch = i % 3 === 0;
const name = isSearch ? "grep" : "read_file";
const args = isSearch
? { pattern: i % 2 === 0 ? "tool__nested-count" : "explore", path: "desktop/frontend/src" }
: { path: `desktop/frontend/src/${i % 2 === 0 ? "components/ToolCard.tsx" : "styles.css"}`, offset: i * 10, limit: 40 };
emit({ kind: "tool_dispatch", tool: { id, name, args: JSON.stringify(args), readOnly: true, parentId } });
emit({
kind: "tool_result",
tool: {
id,
name,
readOnly: true,
output: isSearch ? "3 matches" : "read 40 lines",
durationMs: 24 + i,
},
});
await delay(18);
}
emit({
kind: "tool_result",
tool: {
id: parentId,
name: "explore",
readOnly: true,
output: "已读 20 个文件 · 搜索 10 个文件",
durationMs: 61510,
},
});
emit({
kind: "message",
text: "Mock nested tool preview complete. The explore row now shows the compass count marker.",
});
emitMockTurnDone();
return;
}
// Simulate the server's pre-first-token latency so the deferred user bubble
// and the "un-send on Esc before any reply" path are observable in browser
// dev. Bail if cancelled during the wait — nothing was streamed yet.
@@ -1805,7 +2147,7 @@ function makeMockApp(): AppBindings {
async ClearSession() {},
async Checkpoints() {
return [
{ turn: 0, prompt: "你好呀", files: ["src/App.tsx"], turnFileCount: 1, time: Date.now() - 30_000, canCode: true, canConversation: true },
{ turn: 0, prompt: "你好呀", files: ["src/App.tsx"], fileCount: 1, turnFileCount: 1, time: Date.now() - 30_000, canCode: true, canConversation: true },
];
},
async CheckpointsForTab() {
@@ -2009,6 +2351,7 @@ function makeMockApp(): AppBindings {
tokenMode: normalizeTokenMode(active?.tokenMode),
goal: active?.goal ?? "",
goalStatus: active?.goalStatus ?? (active?.goal ? "running" : "stopped"),
autoResearch: active?.goal ? { taskId: "mock-autoresearch", status: "running", iteration: 4, pivotRequired: false, staleCount: 0 } : undefined,
};
},
async MetaForTab(tabID) {
@@ -2034,8 +2377,80 @@ function makeMockApp(): AppBindings {
tokenMode: normalizeTokenMode(tab?.tokenMode),
goal: tab?.goal ?? "",
goalStatus: tab?.goalStatus ?? (tab?.goal ? "running" : "stopped"),
autoResearch: tab?.goal ? { taskId: "mock-autoresearch", status: "running", iteration: 4, pivotRequired: false, staleCount: 0 } : undefined,
};
},
async AutoResearchCurrent() {
return {
taskId: "mock-autoresearch",
goal: "Mock long-running research",
status: "running",
iteration: 4,
currentDirection: "Inspect status chip",
staleCount: 0,
pivotCount: 0,
pivotRequired: false,
lastHeartbeatAt: "2026-06-29T00:00:00Z",
findingCount: 1,
openCriteria: [],
blocker: "",
taskPath: "/tmp/mock/.reasonix/autoresearch/mock-autoresearch",
nextRequiredAction: "continue with the next evidence-producing step",
};
},
async AutoResearchStatus(_tabID) {
return {
taskId: "mock-autoresearch",
goal: "Mock long-running research",
status: "running",
iteration: 4,
currentDirection: "Inspect status chip",
staleCount: 0,
pivotCount: 0,
pivotRequired: false,
lastHeartbeatAt: "2026-06-29T00:00:00Z",
findingCount: 1,
openCriteria: [],
blocker: "",
taskPath: "/tmp/mock/.reasonix/autoresearch/mock-autoresearch",
nextRequiredAction: "continue with the next evidence-producing step",
};
},
async AutoResearchList(_tabID) {
return [{
taskId: "mock-autoresearch",
goal: "Mock long-running research",
status: "running",
iteration: 4,
currentDirection: "Inspect status chip",
staleCount: 0,
pivotCount: 0,
pivotRequired: false,
lastHeartbeatAt: "2026-06-29T00:00:00Z",
findingCount: 1,
openCriteria: [],
blocker: "",
taskPath: "/tmp/mock/.reasonix/autoresearch/mock-autoresearch",
nextRequiredAction: "continue with the next evidence-producing step",
}];
},
async AutoResearchFindings(_tabID, limit) {
return [{
id: "f1",
kind: "test",
summary: "Mock accepted finding",
source: "command",
command: "go test ./...",
accepted: true,
createdAt: "2026-06-29T00:00:00Z",
}].slice(0, Math.max(0, limit || 1));
},
async AutoResearchOpenTask(_tabID) {
console.info("mock AutoResearchOpenTask");
},
async AutoResearchRecordEvidence(_tabID, _criterionID, _input) {
console.info("mock AutoResearchRecordEvidence");
},
async Commands() {
return [
{ name: "new", description: "start new session; save transcript", kind: "builtin" as const },
@@ -2044,6 +2459,7 @@ function makeMockApp(): AppBindings {
{ name: "model", description: "Switch model", kind: "builtin" as const },
{ name: "effort", description: "Set reasoning effort", kind: "builtin" as const },
{ name: "skill", description: "List skills", kind: "builtin" as const },
{ name: "plugins", description: "Manage plugin packages", kind: "builtin" as const },
{ name: "explore", description: "Investigate the codebase in an isolated subagent", kind: "skill" as const },
{ name: "review", description: "Review the staged diff", hint: "[focus]", kind: "custom" as const },
];
@@ -2053,6 +2469,7 @@ function makeMockApp(): AppBindings {
servers: capServers.map((s) => ({ ...s })),
skills: capSkills.map((s) => ({ ...s })),
skillRoots: capSkillRoots.map((s) => ({ ...s })),
plugins: capPlugins.map((p) => ({ ...p })),
};
},
async MCPServers() {
@@ -2064,6 +2481,59 @@ function makeMockApp(): AppBindings {
skillRoots: capSkillRoots.map((s) => ({ ...s })),
};
},
async Plugins() {
return capPlugins.map((p) => ({ ...p }));
},
async PlanPluginInstall(source: string, options: PluginInstallOptions) {
const name = options.name || source.split("/").filter(Boolean).pop()?.replace(/\.git$/, "") || "plugin";
return JSON.stringify({
ok: true,
status: "planned",
kind: "plugin",
actions: [{ kind: "plugin", action: "install_plugin_package", name, source, status: "planned" }],
});
},
async InstallPlugin(source: string, options: PluginInstallOptions) {
const name = options.name || source.split("/").filter(Boolean).pop()?.replace(/\.git$/, "") || "plugin";
const existing = capPlugins.findIndex((p) => p.name === name);
const view: PluginView = {
name,
version: "dev",
description: "Mock plugin",
source,
root: `~/.reasonix/plugins/${name}`,
manifestKind: "reasonix",
enabled: true,
skills: 1,
hooks: 0,
mcpServers: 0,
skillDetails: [{ name: "plan", description: "Plan work before implementation", invocation: "/plan", runAs: "inline" }],
};
if (existing >= 0) capPlugins[existing] = view;
else capPlugins.push(view);
return JSON.stringify({ ok: true, status: "done", kind: "plugin", actions: [{ kind: "plugin", name }] });
},
async RemovePlugin(name: string) {
capPlugins = capPlugins.filter((p) => p.name !== name);
},
async SetPluginEnabled(name: string, enabled: boolean) {
capPlugins = capPlugins.map((p) => p.name === name ? { ...p, enabled } : p);
},
async UpdatePlugin(name: string) {
capPlugins = capPlugins.map((p) => p.name === name ? { ...p, version: p.version || "dev" } : p);
return JSON.stringify({ ok: true, status: "done", kind: "plugin", name });
},
async PluginDoctor(name: string) {
return capPlugins.find((p) => p.name === name) || {
name,
root: "",
enabled: false,
skills: 0,
hooks: 0,
mcpServers: 0,
error: "plugin is not installed",
};
},
async AddMCPServer(input: MCPServerInput) {
const tools = input.transport === "stdio" ? 3 : 5;
capServers.push({
@@ -2170,6 +2640,9 @@ function makeMockApp(): AppBindings {
async PickSkillFolder() {
return "~/my-skills";
},
async PickPluginFolder() {
return "~/plugins/superpowers";
},
async AddSkillPath(path: string) {
const dir = path.trim() || "~/my-skills";
if (!capSkillRoots.some((r) => r.scope === "custom" && r.dir === dir)) {
@@ -2340,14 +2813,20 @@ function makeMockApp(): AppBindings {
async RevealPath(path: string) {
console.info("mock RevealPath", path);
},
async SavePastedImage(_dataUrl: string) {
return ".reasonix/attachments/mock.png";
async SavePastedImage(dataUrl: string) {
const path = `.reasonix/attachments/mock-${mockAttachmentDataURLs.size + 1}.png`;
mockAttachmentDataURLs.set(path, dataUrl);
return path;
},
async SaveClipboardImage() {
return ".reasonix/attachments/mock-clipboard.png";
const path = `.reasonix/attachments/mock-clipboard-${mockAttachmentDataURLs.size + 1}.png`;
mockAttachmentDataURLs.set(path, mockPreviewImageDataURL);
return path;
},
async SavePastedFile(name: string, _dataUrl: string) {
return `.reasonix/attachments/mock-${name}`;
async SavePastedFile(name: string, dataUrl: string) {
const path = `.reasonix/attachments/mock-${name}`;
mockAttachmentDataURLs.set(path, dataUrl);
return path;
},
async PickExportFile(defaultFilename: string, _mimeType: string) {
return defaultFilename;
@@ -2374,10 +2853,12 @@ function makeMockApp(): AppBindings {
const tokenName = name.replace(/[^\w.-]+/g, "-") || "folder";
return { kind: "workspace" as const, path: `__reasonix_external_folder/mock/${tokenName}`, isDir: true, displayPath: path };
}
return { kind: "attachment" as const, path: `.reasonix/attachments/mock-${name}` };
const attachmentPath = `.reasonix/attachments/mock-${name}`;
mockAttachmentDataURLs.set(attachmentPath, mockPreviewImageDataURL);
return { kind: "attachment" as const, path: attachmentPath };
},
async AttachmentDataURL(_path: string) {
return "data:image/png;base64,iVBORw0KGgo=";
async AttachmentDataURL(path: string) {
return mockAttachmentDataURLs.get(path) ?? mockPreviewImageDataURL;
},
async Models() {
const active = mockTabs.find((tab) => tab.active) ?? mockTabs[0];
@@ -2576,6 +3057,9 @@ function makeMockApp(): AppBindings {
async SetSubagentEffort(level: string) {
settings.subagentEffort = level;
},
async SetMaxSubagentDepth(depth: number) {
settings.agent = { ...settings.agent, maxSubagentDepth: depth <= 1 ? 1 : 2 };
},
async SetAutoPlan(mode: string) {
settings.autoPlan = mode;
},
@@ -2588,9 +3072,17 @@ function makeMockApp(): AppBindings {
if (i >= 0) settings.providers[i] = p;
else settings.providers.push(p);
},
async SaveProviderWithKey(p: ProviderView, key: string) {
p.added = true;
p.keySet = Boolean(key.trim()) || p.keySet;
const i = settings.providers.findIndex((x) => x.name === p.name);
if (i >= 0) settings.providers[i] = p;
else settings.providers.push(p);
return "";
},
async AddOfficialProviderAccess(kind: string, key: string) {
const templates: Record<string, ProviderView> = {
deepseek: { name: "deepseek", builtIn: true, added: true, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: !!key.trim(), balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
deepseek: { name: "deepseek", builtIn: true, added: true, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: !!key.trim(), balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
};
const next = templates[kind];
if (!next) throw new Error(`unknown official provider template ${kind}`);
@@ -2599,12 +3091,47 @@ function makeMockApp(): AppBindings {
else settings.providers.push(next);
return "";
},
async AddProviderPresetAccess(id: string, key: string) {
const preset = settings.providerPresets.find((p) => p.id === id);
if (!preset) throw new Error(`unknown provider preset ${id}`);
const next = cloneMockProviderTemplate(id, key);
if (!next) throw new Error(`unknown provider preset ${id}`);
const i = settings.providers.findIndex((x) => x.name === next.name);
if (i >= 0) settings.providers[i] = { ...settings.providers[i], ...next, keySet: next.keySet || settings.providers[i].keySet };
else settings.providers.push(next);
preset.added = true;
preset.status = "installed";
preset.statusProviderNames = [...preset.providerNames];
preset.keySet = preset.keySet || !!key.trim();
preset.configured = !preset.requiresKey || preset.keySet;
return "";
},
async ResetProviderPresetAccess(id: string) {
const preset = settings.providerPresets.find((p) => p.id === id);
if (!preset) throw new Error(`unknown provider preset ${id}`);
const next = cloneMockProviderTemplate(id, "");
if (!next) throw new Error(`unknown provider preset ${id}`);
const i = settings.providers.findIndex((x) => x.name === next.name);
if (i < 0) throw new Error(`provider preset ${id} cannot be reset because no same-name provider exists`);
const existing = settings.providers[i];
settings.providers[i] = {
...next,
added: true,
keySet: existing.apiKeyEnv === next.apiKeyEnv ? existing.keySet : next.keySet,
};
preset.added = true;
preset.status = "installed";
preset.statusProviderNames = [...preset.providerNames];
preset.keySet = preset.keySet || settings.providers[i].keySet;
preset.configured = !preset.requiresKey || preset.keySet;
},
async FetchProviderModels(p: ProviderView) {
if (!p.baseUrl.trim()) throw new Error(t("settings.fetchModelsMissingBaseUrl"));
if (providerRequiresKey(p) && !p.apiKeyEnv.trim()) throw new Error(t("settings.fetchModelsMissingKeyEnv"));
await delay(350);
if (p.baseUrl.includes("deepseek")) return ["deepseek-v4-flash", "deepseek-v4-pro"];
if (p.baseUrl.includes("mimo") || p.baseUrl.includes("xiaomimimo")) return ["mimo-v2.5", "mimo-v2.5-pro"];
if (p.baseUrl.includes("token-plan")) return ["mimo-v2.5", "mimo-v2.5-pro"];
if (p.baseUrl.includes("xiaomimimo")) return ["mimo-v2.5-pro", "mimo-v2.5"];
return ["gpt-5", "gpt-5-mini", "qwen3-coder"];
},
async DeleteProvider(name: string) {
@@ -2615,6 +3142,12 @@ function makeMockApp(): AppBindings {
if (p?.builtIn) p.added = false;
else settings.providers = settings.providers.filter((x) => x.name !== name);
},
async SaveProviderKey(apiKeyEnv: string, _value: string) {
settings.providers.forEach((p) => {
if (p.apiKeyEnv === apiKeyEnv) p.keySet = true;
});
return "";
},
async SetProviderKey(apiKeyEnv: string, _value: string) {
settings.providers.forEach((p) => {
if (p.apiKeyEnv === apiKeyEnv) p.keySet = true;
@@ -2637,8 +3170,10 @@ function makeMockApp(): AppBindings {
const k = list as "allow" | "ask" | "deny";
settings.permissions[k] = settings.permissions[k].filter((r) => r !== rule);
},
async ReloadSettings() {},
async SetSandbox(bash: string, network: boolean, workspaceRoot: string, allowWrite: string[], shell: string) {
settings.sandbox = { bash, network, workspaceRoot, allowWrite, shell };
const effectiveWorkspaceRoot = workspaceRoot.trim() || cwd;
settings.sandbox = { bash, network, workspaceRoot, allowWrite, effectiveWorkspaceRoot, effectiveWriteRoots: [effectiveWorkspaceRoot, ...allowWrite], shell };
},
async SetNetwork(n: NetworkView) {
settings.network = n;
@@ -2712,10 +3247,11 @@ function makeMockApp(): AppBindings {
label: domain === "lark" ? "Lark" : domain === "weixin" ? "微信" : "飞书",
enabled: true,
status: "connected",
model: "",
toolApprovalMode: "",
workspaceRoot: "",
credential: {
model: "",
toolApprovalMode: "",
workspaceRoot: "",
access: { enabled: true, allowAll: false, pairingEnabled: true, users: [provider === "weixin" ? "wxid_mock_user_001" : "ou_mock_user_001"], groups: [], approvers: [], admins: [] },
credential: {
appId: provider === "feishu" ? "cli_mock" : "",
appSecretEnv: provider === "feishu" ? (domain === "lark" ? "LARK_BOT_APP_SECRET" : "FEISHU_BOT_APP_SECRET") : "",
accountId: provider === "weixin" ? "mock-account" : "",
@@ -2764,6 +3300,15 @@ function makeMockApp(): AppBindings {
async SetDesktopLayoutStyle(style: string) {
settings.desktopLayoutStyle = style === "workbench" || style === "creation" ? style : "classic";
},
async SetDesktopZoomFactor(factor: number) {
mockDesktopZoomFactor = Math.min(2.0, Math.max(0.5, Number.isFinite(factor) ? factor : 1.0));
},
async GetDesktopZoomFactor() {
return mockDesktopZoomFactor;
},
async RestartApplication() {
// no-op in mock
},
async SetDesktopCheckUpdates(enabled: boolean) {
settings.checkUpdates = enabled;
},
@@ -2851,7 +3396,7 @@ function makeMockApp(): AppBindings {
},
async OpenDownloadPage() {
if (typeof window !== "undefined") {
window.open("https://github.com/esengine/reasonix/releases/latest", "_blank", "noopener");
window.open("https://reasonix.io/#start", "_blank", "noopener");
}
},
// Dev seam: drives the overlay flow in the browser until ConnectKey sets the
+79
View File
@@ -0,0 +1,79 @@
/**
* DPI zoom scale service.
*
* Uses the WebView2 ZoomFactor (Go side) for reliable, layout-safe zooming.
* The frontend only saves the user's preference and lets the Go binding persist
* it to desktop-zoom.json; on next startup main.go reads the file and applies
* ZoomFactor to the WebView2 window options.
*
* Range: 0.50 – 2.00 (50% – 200%), step 0.05.
*/
export const MIN_ZOOM = 0.5;
export const MAX_ZOOM = 2.0;
export const ZOOM_STEP = 0.05;
export type ZoomLevel = number; // 0.5 – 2.0
export const DEFAULT_ZOOM: ZoomLevel = 1.0;
const ZOOM_KEY = "reasonix-zoom-restart";
// ─── helpers ────────────────────────────────────────────────────────
/** Snap a number to the nearest valid step within [MIN, MAX]. */
export function snapZoom(value: number): ZoomLevel {
const clamped = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, value));
const steps = Math.round((clamped - MIN_ZOOM) / ZOOM_STEP);
return parseFloat((MIN_ZOOM + steps * ZOOM_STEP).toFixed(2));
}
/** Convert a zoom value (0.5-2.0) to a percentage integer (50-200). */
export function zoomToPercent(zoom: ZoomLevel): number {
return Math.round(zoom * 100);
}
/** Convert a percentage integer (50-200) back to a zoom value (0.5-2.0). */
export function percentToZoom(pct: number): ZoomLevel {
return snapZoom(pct / 100);
}
function readZoom(fallback: ZoomLevel): ZoomLevel {
const stored = typeof localStorage !== "undefined" ? localStorage.getItem(ZOOM_KEY) : null;
if (stored === null) return fallback;
const parsed = parseFloat(stored);
if (isNaN(parsed)) return fallback;
return snapZoom(parsed);
}
function writeZoom(value: ZoomLevel): void {
try {
localStorage.setItem(ZOOM_KEY, String(value));
} catch {
/* private mode / no storage */
}
}
// ─── public API ─────────────────────────────────────────────────────
/** Read the saved zoom level that will be applied on next restart. */
export function getRestartZoom(): ZoomLevel {
return readZoom(DEFAULT_ZOOM);
}
/**
* Save a zoom factor that will be picked up by Go on next startup and
* applied as the WebView2 `ZoomFactor`. Takes effect after the app is
* restarted (no layout issues — ZoomFactor is engine-level).
*/
export function saveRestartZoom(userZoom: ZoomLevel): void {
writeZoom(snapZoom(userZoom));
}
/**
* Init: no-op for zoom (the Go-side ZoomFactor is applied at WebView2
* creation time).
*/
export function initDpiScale(): void {
/* zoom is handled entirely by the Go side (ZoomFactor) */
}
+25 -4
View File
@@ -4,11 +4,19 @@ export interface Toast {
id: number;
text: string;
level: "info" | "warn" | "error";
actionLabel?: string;
onAction?: () => void;
}
export interface ToastOptions {
actionLabel?: string;
onAction?: () => void;
durationMs?: number;
}
export interface ToastContextValue {
toasts: Toast[];
showToast: (text: string, level?: Toast["level"]) => void;
showToast: (text: string, level?: Toast["level"], options?: ToastOptions) => void;
}
const ToastContext = createContext<ToastContextValue>({ toasts: [], showToast: () => {} });
@@ -23,13 +31,13 @@ export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
const showToast = useCallback((text: string, level: Toast["level"] = "info") => {
const showToast = useCallback((text: string, level: Toast["level"] = "info", options: ToastOptions = {}) => {
const id = nextId++;
setToasts((prev) => [...prev, { id, text, level }]);
setToasts((prev) => [...prev, { id, text, level, actionLabel: options.actionLabel, onAction: options.onAction }]);
const timer = setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
timers.current.delete(id);
}, 2500);
}, options.durationMs ?? (options.actionLabel ? 8000 : 2500));
timers.current.set(id, timer);
}, []);
@@ -49,6 +57,19 @@ export function ToastProvider({ children }: { children: ReactNode }) {
{t.level === "warn" && <span className="toast__icon">⚠️</span>}
{t.level === "error" && <span className="toast__icon">❌</span>}
<span className="toast__text">{t.text}</span>
{t.actionLabel && t.onAction && (
<button
type="button"
className="toast__action"
onClick={(event) => {
event.stopPropagation();
dismissToast(t.id);
t.onAction?.();
}}
>
{t.actionLabel}
</button>
)}
</div>
))}
</div>
@@ -1,5 +1,17 @@
import type { Todo } from "./tools";
export interface TodoPanelScopeInput {
activeTabId?: string | null;
activeTab?: {
id?: string | null;
scope?: string | null;
workspaceRoot?: string | null;
topicId?: string | null;
sessionPath?: string | null;
} | null;
eventChannel?: string | null;
}
export function todoDismissalKey(todos: Todo[]): string {
if (todos.length === 0) return "";
return JSON.stringify(todos.map((todo) => ({
@@ -10,6 +22,50 @@ export function todoDismissalKey(todos: Todo[]): string {
})));
}
export function todoPanelScope({ activeTab, activeTabId, eventChannel }: TodoPanelScopeInput): string {
const tabId = String(activeTabId ?? "").trim();
const tab = !tabId || activeTab?.id === tabId ? activeTab : null;
const sessionPath = tab?.sessionPath?.trim();
if (sessionPath) return `session:${sessionPath}`;
if (tabId) return `tab:${tabId}`;
const topicId = tab?.topicId?.trim();
if (tab && topicId) return `topic:${tab.scope ?? ""}:${tab.workspaceRoot ?? ""}:${topicId}`;
const channel = String(eventChannel ?? "").trim();
return channel ? `event:${channel}` : "";
}
export function scopedTodoDismissalKey(scope: string | null | undefined, todoKey: string | null | undefined): string {
const key = String(todoKey ?? "").trim();
if (!key) return "";
const prefix = String(scope ?? "").trim();
return prefix ? `${prefix}\0${key}` : key;
}
export function dismissedTodoKeyForScope(
scope: string | null | undefined,
dismissedKeys: ReadonlySet<string> | null | undefined,
todoKey: string | null | undefined,
): string | null {
const scopedKey = scopedTodoDismissalKey(scope, todoKey);
if (!scopedKey || !dismissedKeys?.has(scopedKey)) return null;
return todoKey ?? null;
}
export function todoBatchKey(todos: Todo[]): string {
if (todos.length === 0) return "";
return JSON.stringify(todos.map((todo) => ({
content: String(todo.content ?? ""),
level: typeof todo.level === "number" ? todo.level : 0,
})));
}
export function scopedTodoBatchKey(scope: string | null | undefined, batchKey: string | null | undefined): string {
const key = String(batchKey ?? "").trim();
if (!key) return "";
const prefix = String(scope ?? "").trim();
return prefix ? `${prefix}\0${key}` : key;
}
export function shouldShowTodoPanel(
todoKey: string | null | undefined,
dismissedTodoKey: string | null,
@@ -20,6 +76,10 @@ export function shouldShowTodoPanel(
return todoKey !== dismissedTodoKey;
}
export function shouldOpenTodoPanelByDefault(todos: Todo[]): boolean {
return hasIncompleteTodos(todos);
}
function todoStatus(status: unknown): string {
const normalized = String(status ?? "").trim();
return normalized || "pending";
@@ -74,6 +74,14 @@ export function questionTurnsById(questions: QuestionAnchor[]): Map<string, numb
return turns;
}
export function lastQuestionTurn(questions: readonly QuestionAnchor[], turns: ReadonlyMap<string, number>): number | undefined {
for (let i = questions.length - 1; i >= 0; i -= 1) {
const turn = turns.get(questions[i].id);
if (turn != null) return turn;
}
return undefined;
}
export function scrollVersion(items: Item[]): string {
return items
.map((it) => {
@@ -0,0 +1,6 @@
export function appendTurnActionCopyText(current: string, next: string): string {
if (next.trim() === "") return current;
if (current.trim() === "") return next;
if (current.endsWith("\n") || next.startsWith("\n")) return current + next;
return `${current}\n\n${next}`;
}
+218 -1
View File
@@ -207,6 +207,11 @@ export interface TabMeta {
tokenMode?: TokenMode;
goal?: string;
goalStatus?: GoalStatus;
autoResearch?: AutoResearchCompactView;
recovered?: boolean;
recoveryReason?: string;
recoveryDigest?: string;
recoveryParentId?: string;
startupErr?: string;
active: boolean;
cwd: string;
@@ -227,6 +232,10 @@ export interface ProjectNode {
running?: boolean;
status?: ProjectTopicStatus;
pinned?: boolean;
recovered?: boolean;
recoveryReason?: string;
recoveryDigest?: string;
recoveryParentId?: string;
children?: ProjectNode[];
}
@@ -238,6 +247,23 @@ export interface TopicMeta {
createdAt: number;
}
export interface SessionRecoveryEvent {
originalPath?: string;
recoveryPath: string;
scope?: string;
workspaceRoot?: string;
topicId?: string;
topicTitle?: string;
recoveryReason?: string;
recoveryDigest?: string;
recoveryParentId?: string;
existing?: boolean;
}
export interface SessionRecoveryFailedEvent {
reason?: "lease_held" | "lease_unavailable" | string;
}
export interface ContextPanelInfo {
usedTokens: number;
windowTokens: number;
@@ -355,6 +381,8 @@ export interface CheckpointMeta {
turn: number;
prompt: string;
files: string[];
fileCount?: number;
filesTruncated?: boolean;
turnFileCount?: number;
time: number; // unix ms
canCode?: boolean;
@@ -385,6 +413,7 @@ export interface SessionMeta {
userId?: string;
threadId?: string;
sessionSource?: string;
recovered?: boolean; // conflict-recovery copy of another session
}
// SessionReference is a session selected via @ past:chats for context injection.
@@ -434,6 +463,7 @@ export interface Meta {
tokenMode?: TokenMode;
goal?: string;
goalStatus?: GoalStatus;
autoResearch?: AutoResearchCompactView;
}
export type CollaborationMode = "normal" | "plan" | "goal";
@@ -441,6 +471,55 @@ export type ToolApprovalMode = "ask" | "auto" | "yolo";
export type TokenMode = "full" | "economy";
export type GoalStatus = "running" | "complete" | "blocked" | "stopped";
export interface AutoResearchCompactView {
taskId: string;
status: "running" | "blocked" | "complete" | "stopped" | "invalid";
iteration: number;
pivotRequired: boolean;
staleCount: number;
}
export interface AutoResearchCriterionView {
id: string;
description: string;
required: boolean;
evidenceCount: number;
status: string;
}
export interface AutoResearchStatusView extends AutoResearchCompactView {
goal: string;
currentDirection: string;
pivotCount: number;
lastHeartbeatAt: string;
findingCount: number;
openCriteria: AutoResearchCriterionView[];
blocker: string;
taskPath: string;
nextRequiredAction: string;
}
export interface AutoResearchFindingView {
id: string;
kind: string;
summary: string;
source: string;
command?: string;
paths?: string[];
accepted: boolean;
createdAt: string;
}
export interface AutoResearchEvidenceView {
id: string;
kind: string;
summary: string;
source: string;
command?: string;
paths?: string[];
accepted: boolean;
}
export function normalizeCollaborationMode(mode?: string, goal?: string, legacyMode?: Mode): CollaborationMode {
if (mode === "plan" || mode === "goal" || mode === "normal") return mode;
if (legacyMode && modeHasPlan(legacyMode)) return "plan";
@@ -590,6 +669,7 @@ export interface ServerView {
tools: number;
prompts: number;
resources: number;
hasTools?: boolean;
error?: string;
toolList?: MCPToolView[];
trustedReadOnlyTools?: string[];
@@ -630,11 +710,55 @@ export interface CapabilitiesView {
servers: ServerView[];
skills: SkillView[];
skillRoots: SkillRootView[];
plugins: PluginView[];
}
export interface SkillsSettingsView {
skills: SkillView[];
skillRoots: SkillRootView[];
}
export interface PluginView {
name: string;
version?: string;
description?: string;
source?: string;
root: string;
manifestKind?: string;
enabled: boolean;
skills: number;
hooks: number;
mcpServers: number;
skillDetails?: PluginSkillView[];
hookDetails?: PluginHookView[];
mcpServerDetails?: PluginMCPServerView[];
warnings?: string[];
error?: string;
}
export interface PluginSkillView {
name: string;
description?: string;
path?: string;
invocation?: string;
runAs?: string;
}
export interface PluginHookView {
event: string;
match?: string;
command?: string;
contextFile?: string;
description?: string;
}
export interface PluginMCPServerView {
name: string;
transport?: string;
command?: string;
url?: string;
}
export interface PluginInstallOptions {
dryRun?: boolean;
link?: boolean;
replace?: boolean;
name?: string;
}
export interface MCPServerInput {
name: string;
transport: string; // stdio | http | sse
@@ -738,7 +862,7 @@ export interface MemoryView {
}
// SettingsTab is the top-level navigation item in the Settings Centre modal.
export type SettingsTab = "general" | "models" | "providers" | "bots" | "mcp" | "skills" | "memory" | "hooks" | "shortcuts" | "permissions" | "sandbox" | "network" | "appearance" | "updates";
export type SettingsTab = "general" | "models" | "providers" | "bots" | "mcp" | "skills" | "plugins" | "memory" | "hooks" | "shortcuts" | "permissions" | "sandbox" | "network" | "appearance" | "updates";
// Settings panel payloads (desktop/settings_app.go).
export interface ProviderView {
@@ -754,6 +878,9 @@ export interface ProviderView {
modelsUrl: string; // optional override for model discovery; empty derives from baseUrl
default: string;
apiKeyEnv: string;
headers?: Record<string, string> | null; // optional extra request headers for compatible gateways
extraBody?: Record<string, unknown> | null; // optional extra top-level request body fields for compatible gateways
authHeader?: boolean; // Anthropic-compatible: send Authorization: Bearer instead of x-api-key
keySet: boolean; // the env var currently resolves to a value
requiresKey?: boolean; // false for explicit no-auth providers
configured?: boolean; // selectable: key is set or no key is required
@@ -762,8 +889,35 @@ export interface ProviderView {
balanceUrl: string; // optional wallet-balance endpoint; "" disables the readout
contextWindow: number;
reasoningProtocol: string; // auto|deepseek|openai|none; empty = auto/model registry
thinking: string; // provider-specific thinking override: ""|enabled|disabled|adaptive
supportedEfforts: string[]; // custom /effort levels; empty = use built-in Kind/BaseURL default
defaultEffort: string; // /effort level when user picks "auto" or unset; "" = supportedEfforts[0]
modelOverrides?: ProviderModelOverrideView[] | null;
}
export interface ProviderPresetView {
id: string;
label: string;
description: string;
keyEnv: string;
providerNames: string[];
models: string[];
added: boolean;
status?: "available" | "installed" | "installed_modified" | "name_conflict" | "similar_existing";
statusProviderNames?: string[];
keySet: boolean;
requiresKey?: boolean;
configured?: boolean;
keySource?: string;
keySourcePath?: string;
}
export interface ProviderModelOverrideView {
model: string;
reasoningProtocol: string;
supportedEfforts: string[];
defaultEffort: string;
vision?: boolean | null;
}
// BalanceInfo is the wallet-balance readout (desktop/app.go Balance). available
@@ -796,6 +950,8 @@ export interface SandboxView {
network: boolean;
workspaceRoot: string;
allowWrite: string[];
effectiveWorkspaceRoot: string;
effectiveWriteRoots: string[];
shell: string; // "auto" | "bash" | "powershell" | "pwsh"
}
@@ -818,6 +974,7 @@ export interface AgentView {
temperature: number;
maxSteps: number;
plannerMaxSteps: number;
maxSubagentDepth: number;
systemPrompt: string;
coldResumePrune: boolean;
reasoningLanguage: string; // "auto" | "zh" | "en"
@@ -829,17 +986,67 @@ export interface BotAllowlistView {
qqUsers: string[];
feishuUsers: string[];
weixinUsers: string[];
qqApprovers: string[];
feishuApprovers: string[];
weixinApprovers: string[];
qqAdmins: string[];
feishuAdmins: string[];
weixinAdmins: string[];
qqGroups: string[];
feishuGroups: string[];
weixinGroups: string[];
}
export interface BotAccessView {
enabled: boolean;
allowAll: boolean;
pairingEnabled: boolean;
users: string[];
groups: string[];
approvers: string[];
admins: string[];
}
export interface BotSelfUserIDsView {
qq: string[];
feishu: string[];
weixin: string[];
}
export interface BotPairingView {
enabled: boolean;
requestTtlMinutes: number;
maxPendingPerPlatform: number;
}
export interface BotControlView {
enabled: boolean;
addr: string;
tokenEnv: string;
}
export interface BotRouteView {
connectionId: string;
platform: string;
chatType: string;
chatId: string;
userId: string;
threadId: string;
model: string;
toolApprovalMode: ToolApprovalMode | "" | string;
workspaceRoot: string;
}
export interface QQBotView {
enabled: boolean;
appId: string;
appSecretEnv: string;
secretSet: boolean;
sandbox: boolean;
model: string;
toolApprovalMode: ToolApprovalMode | "" | string;
workspaceRoot: string;
access: BotAccessView;
}
export interface FeishuBotView {
@@ -892,6 +1099,7 @@ export interface BotConnectionView {
model: string;
toolApprovalMode: ToolApprovalMode | "" | string;
workspaceRoot: string;
access: BotAccessView;
credential: BotConnectionCredentialView;
sessionMappings: BotConnectionSessionMappingView[];
lastError: string;
@@ -905,6 +1113,14 @@ export interface BotSettingsView {
toolApprovalMode: ToolApprovalMode | "" | string;
maxSteps: number;
debounceMs: number;
queueMode: string;
queueCap: number;
queueDrop: string;
ignoreSelfMessages: boolean;
selfUserIds: BotSelfUserIDsView;
control: BotControlView;
pairing: BotPairingView;
routes: BotRouteView[];
allowlist: BotAllowlistView;
qq: QQBotView;
feishu: FeishuBotView;
@@ -980,6 +1196,7 @@ export interface SettingsView {
autoPlan: string;
providers: ProviderView[];
officialProviders: ProviderView[];
providerPresets: ProviderPresetView[];
permissions: PermissionsView;
sandbox: SandboxView;
network: NetworkView;
+413 -70
View File
@@ -10,7 +10,7 @@ import { app, onEvent, onReady } from "./bridge";
import { invalidateCache } from "./composerHistory";
import { formatGuardianAssessmentNotice } from "./guardianEvents";
import { createRafBatch } from "./rafBatch";
import { t } from "./i18n";
import { t, type DictKey } from "./i18n";
import { fileDiffFromWire, summarize, summarizeFileDiff, type ToolFileDiff } from "./tools";
import { modeHasAutoApproveTools, normalizeMode, normalizeToolApprovalMode } from "./types";
import type {
@@ -43,6 +43,9 @@ export type LiveStream = { id: string; text: string; reasoning: string; reasonin
export type MessageActionScope = "fork" | "summ-from" | "summ-upto" | "conversation" | "code" | "both";
export type MessageActionState = { turn: number; scope: MessageActionScope };
export type HydrateReason = "switch-tab" | "new-session" | "resume-session" | "open-topic" | "startup";
type SyncActiveTabOptions = {
preserveCachedHistory?: boolean;
};
const HISTORY_PAGE_TURNS = 60;
@@ -165,6 +168,7 @@ type RuntimeMetaSnapshot = {
running: boolean;
pendingPrompt?: boolean;
backgroundJobs?: number;
cancelRequested?: boolean;
cancellable?: boolean;
};
@@ -240,6 +244,8 @@ export function sameMeta(a?: Meta, b?: Meta): boolean {
const STALE_TURN_RECONCILE_MS = 30_000;
const CANCEL_RECONCILE_DELAYS_MS = [0, 100, 300, 1_000] as const;
const STARTUP_READY_META_RECONCILE_MS = 250;
const STARTUP_READY_META_RECONCILE_ATTEMPTS = 60;
export function shouldReconcileStaleTurn(
state: Pick<State, "running" | "turnActive"> | undefined,
@@ -351,6 +357,18 @@ type Action =
| { type: "clearAsk" }
| { type: "reset" };
function backendStatusFromRuntimeMeta(meta: RuntimeMetaSnapshot): Extract<Action, { type: "backend_status" }> {
const foregroundRunning = foregroundRunningFromRuntimeMeta(meta);
return {
type: "backend_status",
running: foregroundRunning,
pendingPrompt: Boolean(meta.pendingPrompt),
backgroundJobs: meta.backgroundJobs ?? 0,
cancelRequested: Boolean(meta.cancelRequested),
cancellable: foregroundRunning,
};
}
// ---- reducer helpers (unchanged logic) ----
export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: string, startSeq = 0): { items: Item[]; seq: number } {
@@ -363,7 +381,7 @@ export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: str
const positionalResults = positionalToolResults(messages);
const consumedPositionalToolIndexes = new Set(Array.from(positionalResults.values(), (result) => result.index));
const items: Item[] = [];
let items: Item[] = [];
let seq = startSeq;
const consumedToolIDs = new Set<string>();
for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
@@ -378,8 +396,9 @@ export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: str
}
if (m.role === "notice") {
if (m.content.trim() !== "") {
items.push({ kind: "notice", id: `${idPrefix}${seq}`, level: m.level === "warn" ? "warn" : "info", text: m.content });
seq++;
const next = appendNoticeItem(items, seq, `${idPrefix}${seq}`, m.level === "warn" ? "warn" : "info", m.content);
items = next.items;
seq = next.seq;
}
continue;
}
@@ -709,7 +728,7 @@ function applyEvent(s: State, e: WireEvent): State {
return { ...s, usage, context: { ...s.context, used, sessionTokens }, turnTokens, turnTotalTokens, turnCost, sessionTokens, sessionCost, sessionCurrency };
}
case "notice":
return { ...s, running: s.turnActive ? s.running : false, seq: s.seq + 1, items: [...s.items, { kind: "notice", id: `n${s.seq}`, level: e.level ?? "info", text: e.text ?? "" }] };
return appendNoticeToState(s, e.level ?? "info", e.text ?? "");
case "phase":
return { ...s, seq: s.seq + 1, items: [...s.items, { kind: "phase", id: `p${s.seq}`, text: e.text ?? "" }] };
case "compaction_started":
@@ -937,6 +956,181 @@ function errorMessage(err: unknown): string {
return String(err || "");
}
export function effortSwitchNoticeText(err: unknown): string {
return settingSwitchNoticeText(err, "effort", {
busy: "status.effortSwitchBusy",
leaseHeld: "status.effortSwitchLeaseHeld",
starting: "status.effortSwitchStarting",
startupFailed: "status.effortSwitchStartupFailed",
retry: "status.effortSwitchRetry",
failed: "status.effortSwitchFailed",
});
}
export function modelSwitchNoticeText(err: unknown): string {
const msg = errorMessage(err).trim() || "unknown error";
const unknownModel = /^unknown model (.+)$/i.exec(msg);
if (unknownModel) {
return t("status.modelSwitchUnknown", { model: unknownModel[1] });
}
const unavailable = /^model (.+) is not available because provider (.+) is not added$/i.exec(msg);
if (unavailable) {
return t("status.modelSwitchProviderUnavailable", { model: unavailable[1], provider: unavailable[2] });
}
return settingSwitchNoticeText(msg, "model", {
busy: "status.modelSwitchBusy",
leaseHeld: "status.modelSwitchLeaseHeld",
starting: "status.modelSwitchStarting",
startupFailed: "status.modelSwitchStartupFailed",
retry: "status.modelSwitchRetry",
failed: "status.modelSwitchFailed",
});
}
export function tokenModeSwitchNoticeText(err: unknown): string {
return settingSwitchNoticeText(err, "token mode", {
busy: "status.tokenModeSwitchBusy",
leaseHeld: "status.tokenModeSwitchLeaseHeld",
starting: "status.tokenModeSwitchStarting",
startupFailed: "status.tokenModeSwitchStartupFailed",
retry: "status.tokenModeSwitchRetry",
failed: "status.tokenModeSwitchFailed",
});
}
export function localizedBackendNoticeText(text: string): string {
const msg = text.trim();
const autosave = /^Session autosave failed: (.+)$/s.exec(msg);
if (autosave) {
return t("status.sessionAutosaveFailed", { err: autosave[1] });
}
const saveBefore = /^Session save failed before (.+?): (.+)$/s.exec(msg);
if (saveBefore) {
return t("status.sessionSaveFailedBefore", { action: localizedSessionAction(saveBefore[1]), err: saveBefore[2] });
}
const modelFallback = /^model (.+) is no longer available; switched to (.+)$/s.exec(msg);
if (modelFallback) {
return t("status.modelFallbackSwitched", { model: modelFallback[1], fallback: modelFallback[2] });
}
if (
/^session changed on disk; unsaved local transcript was saved as a conflict copy$/i.test(msg) ||
/^session changed on disk; unsaved local transcript was saved as recovery branch\b/i.test(msg)
) {
return t("recovery.noticeSavedCopy");
}
if (
/^repeated save conflicts were detected; saved the current conflict copy in place$/i.test(msg) ||
/^session conflicts kept recurring; kept the transcript on the current recovery branch$/i.test(msg)
) {
return t("recovery.noticeKeptCurrent");
}
if (/^session changed on disk; adopted the newer transcript \(local changes already covered\)$/i.test(msg)) {
return t("recovery.noticeAdoptedCovered");
}
if (/^session changed on disk; adopted the newer transcript$/i.test(msg)) {
return t("recovery.noticeAdopted");
}
return msg;
}
function recoveryNoticeDedupeKey(text: string): string {
const msg = text.trim();
if (
/^session changed on disk; unsaved local transcript was saved as a conflict copy$/i.test(msg) ||
/^session changed on disk; unsaved local transcript was saved as recovery branch\b/i.test(msg) ||
msg === t("recovery.noticeSavedCopy")
) {
return "recovery:saved-copy";
}
if (
/^repeated save conflicts were detected; saved the current conflict copy in place$/i.test(msg) ||
/^session conflicts kept recurring; kept the transcript on the current recovery branch$/i.test(msg) ||
msg === t("recovery.noticeKeptCurrent")
) {
return "recovery:kept-current";
}
if (
/^session changed on disk; adopted the newer transcript \(local changes already covered\)$/i.test(msg) ||
msg === t("recovery.noticeAdoptedCovered")
) {
return "recovery:adopted-covered";
}
if (
/^session changed on disk; adopted the newer transcript$/i.test(msg) ||
msg === t("recovery.noticeAdopted")
) {
return "recovery:adopted";
}
return "";
}
function appendNoticeItem(items: Item[], seq: number, id: string, level: "info" | "warn", rawText: string): { items: Item[]; seq: number } {
const text = localizedBackendNoticeText(rawText);
const key = recoveryNoticeDedupeKey(rawText) || recoveryNoticeDedupeKey(text);
if (key && items.some((item) => item.kind === "notice" && recoveryNoticeDedupeKey(item.text) === key)) {
return { items, seq };
}
return { items: [...items, { kind: "notice", id, level, text }], seq: seq + 1 };
}
function appendNoticeToState(s: State, level: "info" | "warn", text: string): State {
const next = appendNoticeItem(s.items, s.seq, `n${s.seq}`, level, text);
return { ...s, running: s.turnActive ? s.running : false, seq: next.seq, items: next.items };
}
function localizedSessionAction(action: string): string {
switch (action.trim()) {
case "changing model":
return t("status.actionChangingModel");
case "changing effort":
return t("status.actionChangingEffort");
case "changing token mode":
return t("status.actionChangingTokenMode");
case "rebuilding settings":
return t("status.actionRebuildingSettings");
case "switching sessions":
return t("status.actionSwitchingSessions");
case "switching tabs":
return t("status.actionSwitchingTabs");
case "autosave":
return t("status.actionAutosave");
default:
return action.trim() || t("status.actionCurrentSession");
}
}
function settingSwitchNoticeText(
err: unknown,
setting: "effort" | "model" | "token mode",
keys: {
busy: DictKey;
leaseHeld: DictKey;
starting: DictKey;
startupFailed: DictKey;
retry: DictKey;
failed: DictKey;
},
): string {
const msg = errorMessage(err).trim() || "unknown error";
const lower = msg.toLowerCase();
if (lower.includes("finish or cancel") && lower.includes(`before changing ${setting}`)) {
return t(keys.busy);
}
if (lower.includes("already open in another reasonix window") || lower.includes("session lease held")) {
return t(keys.leaseHeld);
}
if (lower.includes("workspace is still starting")) {
return t(keys.starting);
}
if (lower.startsWith("workspace failed to start")) {
return t(keys.startupFailed, { err: msg });
}
if (lower.includes(`changed while switching ${setting}`) || (lower.includes("tab ") && lower.includes("not found"))) {
return t(keys.retry);
}
return t(keys.failed, { err: msg });
}
async function refreshMetaForTab(tabId: string, dispatchTo: (tabId: string, action: Action) => void): Promise<void> {
try {
dispatchTo(tabId, { type: "meta", meta: await app.MetaForTab(tabId) });
@@ -947,6 +1141,16 @@ async function refreshMetaForTab(tabId: string, dispatchTo: (tabId: string, acti
}
}
async function refreshMetaOnlyForTab(tabId: string, dispatchTo: (tabId: string, action: Action) => void): Promise<Meta | undefined> {
try {
const meta = await app.MetaForTab(tabId);
dispatchTo(tabId, { type: "meta", meta });
return meta;
} catch {
return undefined;
}
}
export function replayPendingPromptsForActiveTab(activeTabId: string | undefined, replay: () => Promise<void> = () => app.ReplayPendingPrompts()): void {
if (!activeTabId) return;
void replay().catch(() => {});
@@ -968,6 +1172,8 @@ export function useController() {
const stateRef = useRef(activeState);
const backendActiveTabIdRef = useRef<string | undefined>(undefined);
const backendActivationPromises = useRef(new Map<string, Promise<boolean>>());
const readyMetaReconcileSeq = useRef(0);
const readyMetaReconcileActive = useRef<{ tabId: string; seq: number } | undefined>(undefined);
activeTabIdRef.current = activeTabId;
stateRef.current = activeState;
@@ -1057,23 +1263,31 @@ export function useController() {
if (reset && sessionLoadCurrent(tabId, seq)) dispatchTo(tabId, { type: "reset" });
const stillCurrent = () => sessionLoadCurrent(tabId, seq);
const requiresVisibleTab = reason === "startup" || reason === "switch-tab" || reason === "open-topic";
const stillVisible = () => !requiresVisibleTab || activeTabIdRef.current === tabId;
const noteFailure = (label: string, err: unknown) => {
addBreadcrumb("tab.hydrate", `${label} failed ${tabId}: ${errorMessage(err)}`);
};
const historyStartedAt = Date.now();
const loadTimed = async <T,>(label: string, load: () => Promise<T>): Promise<T | undefined> => {
const startedAt = Date.now();
addBreadcrumb("tab.hydrate", `${label} start ${reason} ${tabId}`);
try {
const value = await load();
addBreadcrumb("tab.hydrate", `${label} done ${reason} ${tabId} ms=${Date.now() - startedAt}`);
return value;
} catch (err) {
noteFailure(label, err);
return undefined;
}
};
// Phase 1: dispatch fast metadata immediately so the transcript can render
// without waiting for slower ancillary calls (e.g. BalanceForTab network).
const [meta, historyPage] = await Promise.all([
app.MetaForTab(tabId).catch((err) => { noteFailure("meta", err); return undefined; }),
skipHistory
? Promise.resolve(undefined)
: app.HistoryPageForTab(tabId, 0, HISTORY_PAGE_TURNS).catch((err) => { noteFailure("history", err); return undefined; }),
]);
const historyStartedAt = Date.now();
const historyPage = skipHistory
? undefined
: await loadTimed("history", () => app.HistoryPageForTab(tabId, 0, HISTORY_PAGE_TURNS));
if (!stillCurrent()) return;
if (meta !== undefined) dispatchTo(tabId, { type: "meta", meta });
if (!skipHistory && historyPage !== undefined) {
const messages = asArray(historyPage.messages);
dispatchTo(tabId, { type: "history_page", page: historyPage, mode: "replace" });
@@ -1104,26 +1318,51 @@ export function useController() {
// in a loading state.
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
if (!stillCurrent()) return;
if (activeTabIdRef.current !== tabId && (reason === "startup" || reason === "switch-tab" || reason === "open-topic")) {
if (!stillVisible()) {
addBreadcrumb("tab.hydrate", `ancillary skipped inactive ${reason} ${tabId}`);
return;
}
const meta = await loadTimed("meta", () => app.MetaForTab(tabId));
if (!stillCurrent()) return;
if (!stillVisible()) {
addBreadcrumb("tab.hydrate", `meta ignored inactive ${reason} ${tabId}`);
return;
}
if (meta !== undefined) dispatchTo(tabId, { type: "meta", meta });
const ancillaryStartedAt = Date.now();
const [effort, jobs, checkpoints, context] = await Promise.all([
app.EffortForTab(tabId).catch((err) => { noteFailure("effort", err); return undefined; }),
app.JobsForTab(tabId).catch((err) => { noteFailure("jobs", err); return undefined; }),
app.CheckpointsForTab(tabId).catch((err) => { noteFailure("checkpoints", err); return undefined; }),
app.ContextUsageForTab(tabId).catch((err) => { noteFailure("context", err); return undefined; }),
const loadAncillary = async <T,>(label: string, load: () => Promise<T>): Promise<T | undefined> => {
return loadTimed(`ancillary ${label}`, load);
};
const [effort, jobs, context] = await Promise.all([
loadAncillary("effort", () => app.EffortForTab(tabId)),
loadAncillary("jobs", () => app.JobsForTab(tabId)),
loadAncillary("context", () => app.ContextUsageForTab(tabId)),
]);
if (!stillCurrent()) return;
if (!stillVisible()) {
addBreadcrumb("tab.hydrate", `ancillary ignored inactive ${reason} ${tabId}`);
return;
}
if (effort !== undefined) dispatchTo(tabId, { type: "effort", effort });
if (jobs !== undefined) dispatchTo(tabId, { type: "jobs", jobs: asArray(jobs) });
if (checkpoints !== undefined) dispatchTo(tabId, { type: "checkpoints", checkpoints: asArray(checkpoints) });
if (context !== undefined) dispatchTo(tabId, { type: "context", context });
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
if (!stillCurrent()) return;
if (!stillVisible()) {
addBreadcrumb("tab.hydrate", `checkpoints skipped inactive ${reason} ${tabId}`);
return;
}
const checkpoints = await loadAncillary("checkpoints", () => app.CheckpointsForTab(tabId));
if (!stillCurrent()) return;
if (!stillVisible()) {
addBreadcrumb("tab.hydrate", `checkpoints ignored inactive ${reason} ${tabId}`);
return;
}
if (checkpoints !== undefined) dispatchTo(tabId, { type: "checkpoints", checkpoints: asArray(checkpoints) });
addBreadcrumb("tab.hydrate", `ancillary ${reason} ${tabId} ms=${Date.now() - ancillaryStartedAt}`);
app.BalanceForTab(tabId)
.then((balance) => {
if (sessionLoadCurrent(tabId, seq)) dispatchTo(tabId, { type: "balance", balance });
if (sessionLoadCurrent(tabId, seq) && stillVisible()) dispatchTo(tabId, { type: "balance", balance });
})
.catch((err) => { noteFailure("balance", err); });
})();
@@ -1171,6 +1410,22 @@ export function useController() {
return tabs.find((tab) => tab.active) ?? tabs[0];
}, []);
const dispatchRuntimeStatusForTab = useCallback((tabId: string, tab: RuntimeMetaSnapshot) => {
const foregroundRunning = foregroundRunningFromRuntimeMeta(tab);
dispatchTo(tabId, {
type: "backend_status",
running: foregroundRunning,
pendingPrompt: Boolean(tab.pendingPrompt),
backgroundJobs: tab.backgroundJobs ?? 0,
cancelRequested: Boolean(tab.cancelRequested),
cancellable: foregroundRunning,
});
// backend_status reconciliation can clear a live prompt from frontend state.
// If the backend is still blocked, ask it to replay the approval/ask event.
if (tab.pendingPrompt) replayPendingPromptsForActiveTab(tabId);
return foregroundRunning;
}, [dispatchTo]);
const waitForTabReady = useCallback(async (tabId: string): Promise<void> => {
for (let attempt = 0; attempt < 60; attempt += 1) {
const tabs = asArray(await app.ListTabs().catch(() => [] as TabMeta[]));
@@ -1180,7 +1435,7 @@ export function useController() {
}
}, []);
const syncActiveTabFromBackend = useCallback(async (reset = false, guard = false): Promise<string | undefined> => {
const syncActiveTabFromBackend = useCallback(async (reset = false, guard = false, options: SyncActiveTabOptions = {}): Promise<string | undefined> => {
const active = await activeTabFromBackend();
if (!active) return undefined;
// When guard is true, skip if the frontend already settled on a
@@ -1194,9 +1449,15 @@ export function useController() {
activeTabIdRef.current = active.id;
confirmBackendActiveTab(active.id);
dispatchTo(active.id, { type: "optimistic_meta", meta: metaFromTab(active, statesRef.current.get(active.id)?.meta) });
await loadSessionDataForTab(active.id, reset, "startup");
const preserveCachedHistory = options.preserveCachedHistory ?? !reset;
if (!reset) dispatchRuntimeStatusForTab(active.id, active);
await loadSessionDataForTab(active.id, reset, "startup", {
preserveCachedHistory,
sessionPath: active.sessionPath,
});
if (reset) dispatchRuntimeStatusForTab(active.id, active);
return active.id;
}, [activeTabFromBackend, confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
}, [activeTabFromBackend, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab]);
const reconcileTabRuntime = useCallback(async (
tabId: string,
@@ -1208,19 +1469,8 @@ export function useController() {
if (!tab) return undefined;
const local = statesRef.current.get(tabId);
const needsInitialLoad = !local?.meta;
const foregroundRunning = foregroundRunningFromRuntimeMeta(tab);
const foregroundRunning = dispatchRuntimeStatusForTab(tabId, tab);
const missedTurnDone = Boolean(local?.running && !foregroundRunning);
dispatchTo(tabId, {
type: "backend_status",
running: foregroundRunning,
pendingPrompt: Boolean(tab.pendingPrompt),
backgroundJobs: tab.backgroundJobs ?? 0,
cancelRequested: Boolean(tab.cancelRequested),
cancellable: foregroundRunning,
});
// backend_status reconciliation can clear a live prompt from frontend state.
// If the backend is still blocked, ask it to replay the approval/ask event.
if (tab.pendingPrompt) replayPendingPromptsForActiveTab(tabId);
if (hydrateSessionData && (needsInitialLoad || missedTurnDone)) {
await loadSessionDataForTab(tabId, missedTurnDone, "startup");
return tabs;
@@ -1234,7 +1484,7 @@ export function useController() {
if (effort) dispatchTo(tabId, { type: "effort", effort });
if (balance) dispatchTo(tabId, { type: "balance", balance });
return tabs;
}, [dispatchTo, loadSessionDataForTab]);
}, [dispatchRuntimeStatusForTab, loadSessionDataForTab]);
const clearCancelReconcileTimer = useCallback((tabId: string) => {
const timer = cancelReconcileTimers.current.get(tabId);
@@ -1290,7 +1540,6 @@ export function useController() {
.then((turns) => dispatchTo(targetTabId, { type: "history_checkpoint_turns", turns: asArray(turns) }))
.catch(() => {});
}
void refreshMetaForTab(targetTabId, dispatchTo);
app
.ContextUsageForTab(targetTabId)
.then((context) => dispatchTo(targetTabId, { type: "context", context }))
@@ -1298,6 +1547,7 @@ export function useController() {
app.BalanceForTab(targetTabId).then((balance) => dispatchTo(targetTabId, { type: "balance", balance })).catch(() => {});
app.EffortForTab(targetTabId).then((effort) => dispatchTo(targetTabId, { type: "effort", effort })).catch(() => {});
void refreshCheckpoints(targetTabId);
void refreshMetaForTab(targetTabId, dispatchTo);
}
if (e.kind === "turn_done" || e.kind === "notice") {
app.JobsForTab(targetTabId).then((jobs) => dispatchTo(targetTabId, { type: "jobs", jobs: asArray(jobs) })).catch(() => {});
@@ -1310,11 +1560,9 @@ export function useController() {
addBreadcrumb("tab.hydrate", `ready ignored ${readyTabId}`);
return;
}
if (activeId) {
void loadSessionDataForTab(activeId, false, "startup", { preserveCachedHistory: true });
return;
}
void syncActiveTabFromBackend(false, true);
// A ready event can race the initial hydrate. Refresh the tab metadata
// first so a stale ready=false snapshot does not keep the composer locked.
void syncActiveTabFromBackend(false, true, { preserveCachedHistory: true });
});
void syncActiveTabFromBackend(false, true);
@@ -1335,6 +1583,51 @@ export function useController() {
};
}, [dispatchTo, loadSessionDataForTab, refreshCheckpoints, syncActiveTabFromBackend]);
// If the startup ready event is missed, keep the composer lock in sync with
// the active tab's backend metadata without kicking off tab activation work.
useEffect(() => {
const tabId = activeTabId;
const meta = activeState.meta;
if (!tabId || !meta || meta.ready || meta.startupErr || activeState.backendActivationPending) {
readyMetaReconcileSeq.current += 1;
readyMetaReconcileActive.current = undefined;
return;
}
let cancelled = false;
let timer: number | undefined;
const seq = readyMetaReconcileSeq.current + 1;
readyMetaReconcileSeq.current = seq;
readyMetaReconcileActive.current = { tabId, seq };
const stillCurrent = () => {
const active = readyMetaReconcileActive.current;
return !cancelled && active?.tabId === tabId && active.seq === seq && activeTabIdRef.current === tabId;
};
const schedule = (attempt: number) => {
timer = window.setTimeout(() => {
void tick(attempt);
}, STARTUP_READY_META_RECONCILE_MS);
};
const tick = async (attempt: number) => {
if (!stillCurrent()) return;
const current = statesRef.current.get(tabId);
if (!current?.meta || current.meta.ready || current.meta.startupErr || current.backendActivationPending) return;
const nextMeta = await refreshMetaOnlyForTab(tabId, dispatchTo);
if (!stillCurrent()) return;
if (nextMeta?.ready || nextMeta?.startupErr || attempt + 1 >= STARTUP_READY_META_RECONCILE_ATTEMPTS) return;
schedule(attempt + 1);
};
schedule(0);
return () => {
cancelled = true;
if (timer !== undefined) window.clearTimeout(timer);
};
}, [activeTabId, activeState.meta?.ready, activeState.meta?.startupErr, activeState.backendActivationPending, dispatchTo]);
// Stale-turn watchdog: if the frontend thinks the agent is running but the
// turn stream has gone quiet, reconcile with the backend. This catches cases
// where the Wails event channel silently drops turn_done after the final
@@ -1367,7 +1660,7 @@ export function useController() {
}, [activeTabId]);
const sendToTab = useCallback(async (tabId: string, displayText: string, submitText = displayText, originalText?: string) => {
if (!tabId) throw new Error("workspace is still starting");
if (!tabId) throw new Error(t("composer.workspaceStarting"));
const seq = getOrCreateState(statesRef.current, tabId).seq;
const display = displayText.trim();
const submit = submitText.trim();
@@ -1375,9 +1668,12 @@ export function useController() {
dispatchTo(tabId, { type: "user", text: displayText, submitText: display !== submit ? submit : undefined, seq });
invalidateCache();
try {
await (original
const submitPromise = original
? app.SubmitEditedDisplayToTab(tabId, display, submit, original)
: display !== submit ? app.SubmitDisplayToTab(tabId, display, submit) : app.SubmitToTab(tabId, submit));
: display !== submit ? app.SubmitDisplayToTab(tabId, display, submit) : app.SubmitToTab(tabId, submit);
void submitPromise.catch((error) => {
dispatchTo(tabId, { type: "send_failed", error: `Send failed: ${error instanceof Error ? error.message : String(error)}` });
});
} catch (error) {
dispatchTo(tabId, { type: "send_failed", error: `Send failed: ${error instanceof Error ? error.message : String(error)}` });
throw error;
@@ -1390,16 +1686,17 @@ export function useController() {
return sendToTab(tabId, displayText, submitText);
}
return activeTabFromBackend().then((active) => {
if (!active?.id) throw new Error("workspace is still starting");
if (!active?.id) throw new Error(t("composer.workspaceStarting"));
setActiveTabId(active.id);
activeTabIdRef.current = active.id;
confirmBackendActiveTab(active.id);
dispatchRuntimeStatusForTab(active.id, active);
return sendToTab(active.id, displayText, submitText);
});
}, [activeTabFromBackend, activeTabId, confirmBackendActiveTab, sendToTab]);
}, [activeTabFromBackend, activeTabId, confirmBackendActiveTab, dispatchRuntimeStatusForTab, sendToTab]);
const runShell = useCallback(async (command: string) => {
if (!activeTabId) throw new Error("workspace is still starting");
if (!activeTabId) throw new Error(t("composer.workspaceStarting"));
dispatchTo(activeTabId, { type: "user", text: `!${command}`, seq: getOrCreateState(statesRef.current, activeTabId).seq });
try {
await app.RunShellForTab(activeTabId, command);
@@ -1410,7 +1707,7 @@ export function useController() {
}, [activeTabId, dispatchTo]);
const steer = useCallback(async (text: string) => {
if (!activeTabId) throw new Error("workspace is still starting");
if (!activeTabId) throw new Error(t("composer.workspaceStarting"));
// No optimistic user bubble: rewind/fork map turns by counting user items,
// and a steer is not a backend turn — the Steer event's ↪ notice is the
// visible confirmation (#3660).
@@ -1648,7 +1945,7 @@ export function useController() {
try {
await app.SetModelForTab(activeTabId, name);
} catch (err) {
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: t("status.modelSwitchFailed", { err: errorMessage(err) }) });
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: modelSwitchNoticeText(err) });
return;
}
try {
@@ -1660,7 +1957,12 @@ export function useController() {
const setEffort = useCallback(async (level: string) => {
if (!activeTabId) return;
await app.SetEffortForTab(activeTabId, level).catch(() => {});
try {
await app.SetEffortForTab(activeTabId, level);
} catch (err) {
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: effortSwitchNoticeText(err) });
return;
}
try {
dispatchTo(activeTabId, { type: "meta", meta: await app.MetaForTab(activeTabId) });
dispatchTo(activeTabId, { type: "context", context: await app.ContextUsageForTab(activeTabId) });
@@ -1673,7 +1975,7 @@ export function useController() {
try {
await app.SetTokenModeForTab(activeTabId, mode);
} catch (err) {
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: t("status.tokenModeSwitchFailed", { err: errorMessage(err) }) });
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: tokenModeSwitchNoticeText(err) });
return;
}
try {
@@ -1703,11 +2005,13 @@ export function useController() {
setActiveTabId(tab.id);
activeTabIdRef.current = tab.id;
confirmBackendActiveTab(tab.id);
dispatchRuntimeStatusForTab(tab.id, tab);
// The fork's controller builds in a background goroutine: an immediate
// load reads empty history, and the ready-event fallback can still
// target the source tab, leaving the fork blank (#3742).
await waitForTabReady(tab.id);
await loadSessionDataForTab(tab.id, true);
await reconcileTabRuntime(tab.id, { hydrateSessionData: false });
} else {
await syncActiveTabFromBackend(true);
}
@@ -1730,11 +2034,12 @@ export function useController() {
} finally {
dispatchTo(sourceTabId, { type: "message_action_done" });
}
}, [activeTabId, confirmBackendActiveTab, dispatchTo, loadSessionDataForTab, syncActiveTabFromBackend, waitForBackendActiveTab, waitForTabReady]);
}, [activeTabId, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime, syncActiveTabFromBackend, waitForBackendActiveTab, waitForTabReady]);
// Tab management: switch preserves per-tab state; open creates it.
const switchTab = useCallback(async (tabId: string, optimisticTab?: TabMeta): Promise<TabMeta[] | undefined> => {
const startedAt = Date.now();
const previousTabId = activeTabIdRef.current;
const targetSessionPath = optimisticTab?.sessionPath ?? statesRef.current.get(tabId)?.meta?.sessionPath;
const preserveCachedHistory = hasReusableCachedTranscript(statesRef.current.get(tabId), targetSessionPath);
addBreadcrumb("tab.switch", `click ${tabId}`);
@@ -1743,11 +2048,25 @@ export function useController() {
dispatchTo(tabId, { type: "backend_activation_start" });
if (optimisticTab) {
dispatchTo(tabId, { type: "optimistic_meta", meta: metaFromTab(optimisticTab, statesRef.current.get(tabId)?.meta) });
const optimisticStatus = backendStatusFromRuntimeMeta(optimisticTab);
if (optimisticStatus.running) dispatchTo(tabId, optimisticStatus);
}
dispatchTo(tabId, { type: "hydrate_start", reason: "switch-tab" });
addBreadcrumb("tab.switch", `active-rendered ${tabId} ms=${Date.now() - startedAt}`);
const backendActivation = app.SetActiveTab(tabId)
.then(() => {
.then(async () => {
if (activeTabIdRef.current !== tabId) {
const currentTabId = activeTabIdRef.current;
if (currentTabId) {
await app.SetActiveTab(currentTabId).then(() => {
if (activeTabIdRef.current === currentTabId) confirmBackendActiveTab(currentTabId);
}).catch((err) => {
addBreadcrumb("tab.switch", `set-active-stale-reassert-failed ${currentTabId}: ${errorMessage(err)}`);
});
}
addBreadcrumb("tab.switch", `set-active-stale ${tabId} current=${currentTabId ?? ""} ms=${Date.now() - startedAt}`);
return false;
}
confirmBackendActiveTab(tabId);
addBreadcrumb("tab.switch", `set-active-done ${tabId} ms=${Date.now() - startedAt}`);
return true;
@@ -1755,6 +2074,11 @@ export function useController() {
.catch((err) => {
dispatchTo(tabId, { type: "backend_activation_done" });
dispatchTo(tabId, { type: "hydrate_error", reason: "switch-tab", error: errorMessage(err) });
if (previousTabId && activeTabIdRef.current === tabId) {
setActiveTabId(previousTabId);
activeTabIdRef.current = previousTabId;
addBreadcrumb("tab.switch", `set-active-failed-reverted ${tabId} -> ${previousTabId} ms=${Date.now() - startedAt}`);
}
return false;
});
trackBackendActivation(tabId, backendActivation);
@@ -1786,13 +2110,16 @@ export function useController() {
activeTabIdRef.current = meta.id;
confirmBackendActiveTab(meta.id);
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
void loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
dispatchRuntimeStatusForTab(meta.id, meta);
const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
placeholderItems: isNewTab ? prevItems : undefined,
preserveCachedHistory,
sessionPath: meta.sessionPath,
});
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
else void load;
return meta;
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
const openGlobalTab = useCallback(async (topicId: string): Promise<TabMeta> => {
const meta = await app.OpenGlobalTab(topicId);
@@ -1804,13 +2131,16 @@ export function useController() {
activeTabIdRef.current = meta.id;
confirmBackendActiveTab(meta.id);
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
void loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
dispatchRuntimeStatusForTab(meta.id, meta);
const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
placeholderItems: isNewTab ? prevItems : undefined,
preserveCachedHistory,
sessionPath: meta.sessionPath,
});
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
else void load;
return meta;
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
const openTopicSession = useCallback(async (scope: string, workspaceRoot: string, topicId: string, sessionPath: string): Promise<TabMeta> => {
const meta = await app.OpenTopicSession(scope, workspaceRoot, topicId, sessionPath);
@@ -1822,13 +2152,16 @@ export function useController() {
activeTabIdRef.current = meta.id;
confirmBackendActiveTab(meta.id);
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
void loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
dispatchRuntimeStatusForTab(meta.id, meta);
const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
placeholderItems: isNewTab ? prevItems : undefined,
preserveCachedHistory,
sessionPath: meta.sessionPath,
});
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
else void load;
return meta;
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
const activateTopic = useCallback(async (scope: string, workspaceRoot: string, topicId: string, sessionPath = ""): Promise<TabMeta> => {
const meta = await app.ActivateTopic(scope, workspaceRoot, topicId, sessionPath);
@@ -1842,21 +2175,28 @@ export function useController() {
activeTabIdRef.current = meta.id;
confirmBackendActiveTab(meta.id);
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
void loadSessionDataForTab(meta.id, true, "open-topic", { placeholderItems: prevItems });
dispatchRuntimeStatusForTab(meta.id, meta);
void loadSessionDataForTab(meta.id, true, "open-topic", { placeholderItems: prevItems })
.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false }))
.catch(() => {});
return meta;
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
// Ensure a blank tab exists for the given scope — reuses an existing one
// or creates a new tab, then loads its session data.
const ensureBlankTab = useCallback(async (scope: string, workspaceRoot: string): Promise<TabMeta> => {
const meta = await app.EnsureBlankTab(scope, workspaceRoot);
const isNewTab = !statesRef.current.has(meta.id);
setActiveTabId(meta.id);
activeTabIdRef.current = meta.id;
confirmBackendActiveTab(meta.id);
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
void loadSessionDataForTab(meta.id, !statesRef.current.has(meta.id), "open-topic");
dispatchRuntimeStatusForTab(meta.id, meta);
const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic");
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
else void load;
return meta;
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
const ensureBlankSurface = useCallback(async (scope: string, workspaceRoot: string): Promise<TabMeta> => {
const meta = await app.EnsureBlankSurface(scope, workspaceRoot);
@@ -1867,16 +2207,19 @@ export function useController() {
activeTabIdRef.current = meta.id;
confirmBackendActiveTab(meta.id);
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
void loadSessionDataForTab(meta.id, true, "open-topic");
dispatchRuntimeStatusForTab(meta.id, meta);
void loadSessionDataForTab(meta.id, true, "open-topic")
.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false }))
.catch(() => {});
return meta;
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
const closeTab = useCallback(async (tabId: string) => {
try {
await app.CloseTab(tabId);
statesRef.current.delete(tabId);
bump();
if (tabId === activeTabId) await syncActiveTabFromBackend(true);
if (tabId === activeTabId) await syncActiveTabFromBackend(false);
} catch { /* ignore */ }
}, [activeTabId, bump, syncActiveTabFromBackend]);
@@ -0,0 +1,115 @@
import { useEffect } from "react";
/**
* WailsWailsFlags mirrors the `window.wails.flags` object injected by the
* Wails v2 runtime (see internal/frontend/runtime/desktop/main.js).
*/
interface WailsFlags {
enableResize: boolean;
resizeEdge: string | undefined;
borderThickness: number;
defaultCursor: string | null;
cssDragProperty: string;
cssDragValue: string;
cssDropProperty: string;
cssDropValue: string;
shouldDrag: boolean;
deferDragToMouseMove: boolean;
disableScrollbarDrag: boolean;
disableDefaultContextMenu: boolean;
enableWailsDragAndDrop: boolean;
}
interface WailsWindow {
wails: {
flags: WailsFlags;
Callback: (msg: string) => void;
EventsNotify: (msg: string) => void;
};
}
declare global {
interface Window {
wails?: WailsWindow["wails"];
}
}
/**
* useWailsResizeFix
*
* Workaround for a Wails v2.12 frameless-window resize detection bug where
* `window.outerWidth/outerHeight` (device-independent pixels) is compared
* with `e.clientX/e.clientY` (CSS pixels). When WebView2 ZoomFactor ≠ 1
* these coordinate spaces diverge, causing the right/bottom resize zone to
* extend far inward or disappear entirely.
*
* This hook disables the built-in mousemove handler (which uses the wrong
* coordinate space) and replaces it with one that uses `window.innerWidth`
* and `window.innerHeight` — both in CSS pixels, matching `e.clientX/Y`.
*
* The Wails mousedown handler still works because it only reads
* `window.wails.flags.resizeEdge`, which we continue to set here.
*
* Upstream fix: https://github.com/wailsapp/wails/issues/4590 (Wails v3
* sidestepped by clamping zoom ≥ 1.0). Once Wails v2 ships a proper fix
* this hook can be deleted.
*
* @example
* // In App.tsx or any component mounted for the app's lifetime:
* useWailsResizeFix(desktopPlatform === "windows");
*/
export function useWailsResizeFix(enabled: boolean): void {
useEffect(() => {
if (!enabled) return;
const wails = window.wails;
if (!wails) return; // not inside a Wails webview → no-op
const flags = wails.flags;
const bt = flags.borderThickness ?? 6;
const previousEnableResize = flags.enableResize;
const previousResizeEdge = flags.resizeEdge;
const previousCursor = document.documentElement.style.cursor;
// Restore the default cursor when we're done — memoize the initial value.
const defaultCursor = previousCursor;
const onMouseMove = (e: MouseEvent) => {
// Both operands in CSS pixels — the bug fix.
const iw = window.innerWidth;
const ih = window.innerHeight;
const cx = e.clientX;
const cy = e.clientY;
const right = iw - cx < bt;
const left = cx < bt;
const top = cy < bt;
const bottom = ih - cy < bt;
let edge: string | undefined;
if (right && bottom) edge = "se-resize";
else if (left && bottom) edge = "sw-resize";
else if (right && top) edge = "ne-resize";
else if (left && top) edge = "nw-resize";
else if (right) edge = "e-resize";
else if (left) edge = "w-resize";
else if (top) edge = "n-resize";
else if (bottom) edge = "s-resize";
if (edge !== flags.resizeEdge) {
flags.resizeEdge = edge;
document.documentElement.style.cursor = edge ?? (defaultCursor || "");
}
};
// Disable Wails' built-in mousemove handler (the one that uses outerWidth).
flags.enableResize = false;
window.addEventListener("mousemove", onMouseMove);
return () => {
window.removeEventListener("mousemove", onMouseMove);
flags.enableResize = previousEnableResize;
flags.resizeEdge = previousResizeEdge;
document.documentElement.style.cursor = previousCursor;
};
}, [enabled]);
}
+430 -60
View File
@@ -21,6 +21,7 @@ export const en = {
"common.busyHint": "Finish or stop the current turn first",
"common.loading": "Loading…",
"composer.readOnlyChannel": "Channel conversation is read-only",
"composer.workspaceStarting": "Workspace is still starting",
"app.splashSubtitle": "Agent Workspace",
// top bar
@@ -205,6 +206,7 @@ export const en = {
"caps.mcpSummary": "{connected} connected · {failed} issues · {tools} tools",
"caps.connectorsTab": "MCP servers",
"caps.skillsTab": "Agent skills",
"caps.pluginsTab": "Plugins",
"caps.servers": "MCP servers",
"caps.noServers": "No MCP servers configured. Add one with /mcp add.",
"caps.availableServers": "Available MCPs",
@@ -311,6 +313,62 @@ export const en = {
"caps.trustedReadOnlyTitle": "Remembered as read-only for future planning and research",
"caps.searchSkills": "Search skills…",
"caps.noSkillMatches": "No matching skills.",
"caps.pluginRefresh": "Refresh plugins",
"caps.pluginInstallTitle": "Install plugin",
"caps.pluginInstallHint": "Add a local plugin folder or install from a Git repository.",
"caps.pluginInstallMethod": "Plugin install method",
"caps.pluginInstallLocal": "Local folder",
"caps.pluginInstallGit": "Git repository",
"caps.pluginSource": "Plugin source",
"caps.pluginGitSource": "Git repository URL",
"caps.pluginSourcePlaceholder": "git:github.com/obra/superpowers",
"caps.pluginChooseLocalFolder": "Choose plugin folder",
"caps.pluginLocalFolder": "Local plugin folder",
"caps.pluginNoLocalFolder": "No folder selected",
"caps.pluginInstallName": "Install name (optional)",
"caps.pluginInstallNamePlaceholder": "install name (optional)",
"caps.pluginLink": "Developer mode: link source folder",
"caps.pluginLinkHint": "Do not copy the plugin; use the selected folder directly. Useful for plugin debugging. Moving or deleting the folder breaks the plugin.",
"caps.pluginReplace": "Overwrite same-name plugin",
"caps.pluginReplaceHint": "When a plugin with the same name already exists, allow this source to replace it.",
"caps.pluginPreview": "Preview",
"caps.pluginInstall": "Install plugin",
"caps.noPluginsTitle": "No plugins installed",
"caps.noPluginsHint": "Install a plugin package from Git or a local path to add skills, hooks, and MCP servers through one managed unit.",
"caps.installedPlugins": "Installed plugins",
"caps.pluginsSummary": "{enabled} enabled · {total} installed · {issues} issues",
"caps.pluginCounts": "{skills} skills · {hooks} hooks · {mcps} MCPs",
"caps.pluginNoCapabilities": "No exported capabilities",
"caps.pluginWarnings": "{count} warnings",
"caps.pluginEnable": "Enable plugin",
"caps.pluginDisable": "Disable plugin",
"caps.pluginEnabled": "Enabled",
"caps.pluginDisabled": "Disabled",
"caps.pluginVersion": "Version",
"caps.pluginRoot": "Install path",
"caps.pluginUpdate": "Update",
"caps.pluginDoctor": "Doctor",
"caps.pluginRemove": "Remove plugin",
"caps.pluginConfirmRemove": "Confirm remove",
"caps.pluginPlanError": "Install check failed",
"caps.pluginPlanReady": "Install plan",
"caps.pluginAction": "Action",
"caps.pluginPlanStatus": "Plugin operation: {status}",
"caps.pluginPlanComplete": "Plugin operation complete",
"caps.pluginPlanInstalled": "Plugin installed. Expand it below to see how to use the exported skills, hooks, and MCP servers.",
"caps.pluginUsageTitle": "How to use",
"caps.pluginUsageEnabledHint": "Enabled plugins load into new sessions. Use /skills to browse skills, invoke /<skill>, or ask naturally; hooks and MCP servers run through the normal session lifecycle.",
"caps.pluginUsageDisabledHint": "Enable this plugin before its skills, hooks, or MCP servers can participate in sessions.",
"caps.pluginNoCapabilityDetails": "No detailed capability inventory is available for this plugin.",
"caps.pluginSkillsTitle": "Skills",
"caps.pluginSkillsHint": "Browse with /skills, invoke directly, or ask for the workflow by name.",
"caps.pluginHooksTitle": "Hooks",
"caps.pluginHooksHint": "Hooks trigger automatically on matching lifecycle events.",
"caps.pluginMCPTitle": "MCP servers",
"caps.pluginMCPHint": "Tools from these servers are available through the normal MCP/tool flow.",
"caps.pluginNoDescription": "No description",
"caps.pluginHookNoTarget": "No hook target",
"caps.pluginMCPNoTarget": "No launch target",
"caps.sources": "Sources",
"caps.sourcesSummaryActive": "{active} active",
"caps.sourcesSummaryMissing": "{missing} missing",
@@ -411,6 +469,17 @@ export const en = {
"composer.removeCurrentProjectDisabled": "Current project cannot be removed",
"composer.resize": "Drag to resize composer, double-click to reset",
"composer.send": "Send (Enter)",
"composer.queueGuidance": "Add to guidance queue (Enter)",
"composer.steer": "Add guidance to current turn (Enter)",
"composer.steerPlaceholder": "Add guidance to the queue...",
"composer.guidanceQueue": "Queued guidance",
"composer.guidanceCount": "Queued guidance {n}",
"composer.guidanceRemaining": "{n} more queued",
"composer.guidanceCollapse": "Collapse",
"composer.guidanceMode": "Guide",
"composer.guidanceSend": "Send this guidance to the transcript",
"composer.guidanceDismiss": "Dismiss queued guidance",
"composer.guidanceMore": "More guidance options",
"composer.shellMode": "Shell mode: prefix input with !",
"composer.shellModeOn": "Shell mode on: click to remove !",
"composer.stop": "Stop (Esc)",
@@ -421,6 +490,9 @@ export const en = {
"composer.pastedExpand": "Expand",
"composer.pastedRemove": "Remove pasted text",
"composer.removeImage": "Remove image",
"imageViewer.clickToPreview": "Click to preview",
"imageViewer.closePreview": "Close preview",
"imageViewer.title": "Image preview",
"composer.attachImageFailed": "Image paste failed",
"composer.attachFileFailed": "File attach failed",
"composer.attachDropFailed": "Dropped file attach failed",
@@ -460,8 +532,37 @@ export const en = {
"modelSwitcher.searchPlaceholder": "Search models…",
"modelSwitcher.noMatches": "No matching models",
"status.noModels": "no switchable models",
"status.modelSwitchBusy": "The model cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
"status.modelSwitchLeaseHeld": "This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing models.",
"status.modelSwitchStarting": "This session is still starting. Try changing models again in a moment.",
"status.modelSwitchStartupFailed": "This session did not start, so the model cannot change: {err}",
"status.modelSwitchRetry": "The current session changed while switching models. Try once more.",
"status.modelSwitchUnknown": "Unknown model {model}.",
"status.modelSwitchProviderUnavailable": "Model {model} is unavailable because provider {provider} is not added.",
"status.modelSwitchFailed": "Model switch failed: {err}",
"status.modelFallbackSwitched": "Model {model} is no longer available; switched to {fallback}.",
"status.effortSwitchBusy": "Reasoning effort cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
"status.effortSwitchLeaseHeld": "This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing effort.",
"status.effortSwitchStarting": "This session is still starting. Try changing reasoning effort again in a moment.",
"status.effortSwitchStartupFailed": "This session did not start, so reasoning effort cannot change: {err}",
"status.effortSwitchRetry": "The current session changed while switching reasoning effort. Try once more.",
"status.effortSwitchFailed": "Reasoning effort switch failed: {err}",
"status.tokenModeSwitchBusy": "Token saver cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
"status.tokenModeSwitchLeaseHeld": "This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing token saver.",
"status.tokenModeSwitchStarting": "This session is still starting. Try changing token saver again in a moment.",
"status.tokenModeSwitchStartupFailed": "This session did not start, so token saver cannot change: {err}",
"status.tokenModeSwitchRetry": "The current session changed while switching token saver. Try once more.",
"status.tokenModeSwitchFailed": "Token saver switch failed: {err}",
"status.sessionAutosaveFailed": "Session autosave failed: {err}",
"status.sessionSaveFailedBefore": "Session save failed before {action}: {err}",
"status.actionChangingModel": "changing models",
"status.actionChangingEffort": "changing reasoning effort",
"status.actionChangingTokenMode": "changing token saver",
"status.actionRebuildingSettings": "refreshing settings",
"status.actionSwitchingSessions": "switching sessions",
"status.actionSwitchingTabs": "switching tabs",
"status.actionAutosave": "autosave",
"status.actionCurrentSession": "saving the current session",
"status.effort": "effort {level}",
"status.effortTitle": "Reasoning effort",
"status.effortAutoTitle": "Reasoning effort: auto (model default: {def})",
@@ -545,6 +646,11 @@ export const en = {
"heartbeat.approvalModeAskHint": "Each tool call needs manual confirmation",
"heartbeat.approvalModeAutoHint": "Auto-approve safe tools, keep deny rules",
"heartbeat.approvalModeYoloHint": "Skip all permission prompts, tasks run automatically",
"heartbeat.notifyChannels": "Push to bot channels",
"heartbeat.notifyChannelsOn": "On",
"heartbeat.notifyChannelsOff": "Off",
"heartbeat.notifyChannelsOnHint": "AI output is streamed to connected Feishu/QQ bots",
"heartbeat.notifyChannelsOffHint": "Only create a desktop conversation; do not push to bots",
"heartbeat.fieldNewConversation": "Conversation mode",
"heartbeat.newConversationEachRunOn": "New conversation each run",
"heartbeat.newConversationEachRunOff": "Reuse same conversation",
@@ -581,6 +687,29 @@ export const en = {
"approval.allowRulePersistent": "Always allow (save)",
"approval.allowEditsSession": "Allow edits for session",
"approval.allowFilePersistent": "Always allow this file (save)",
"approval.allowSandboxEscapeSession": "Use real environment for this session",
"approval.toolLabelBash": "bash",
"approval.toolLabelEditFile": "edit file",
"approval.toolLabelWriteFile": "write file",
"approval.toolLabelMultiEdit": "multi edit",
"approval.toolLabelMoveFile": "move file",
"approval.toolLabelWebFetch": "web fetch",
"approval.toolLabelRunSkill": "run skill",
"approval.toolLabelRemember": "remember",
"approval.toolLabelForget": "forget",
"approval.toolLabelSandboxEscape": "bash sandbox escape",
"approval.toolLabelPlanModeReadOnly": "plan-mode read-only command",
"approval.memorySaveUpdate": "Save/update memory",
"approval.memoryBodyLabel": "body",
"approval.memoryArchivePrefix": "Archive memory ",
"approval.planModeMcpTrustSubject": "MCP {target} as read-only for planning and research",
"approval.planModeMcpTrustReason": "This MCP tool reports read-only, but external read-only hints need your confirmation before plan mode can use them. Choose always allow to remember this trust for future planning and read-only research.",
"approval.planModeBashTrustSubject": "Trust {prefix} as a read-only command prefix while planning\nCommand: {command}",
"approval.planModeBashTrustReason": "This bash command is not in Reasonix's built-in read-only set. Confirm only if this exact prefix is read-only for planning and research. Auto/YOLO approval cannot answer this trust prompt.",
"approval.sandboxEscapeSubjectFallback": "run shell command unconfined once",
"approval.sandboxEscapeSubjectPrefix": "run unconfined once: ",
"approval.sandboxEscapeWrapReason": "Windows sandbox could not wrap this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
"approval.sandboxEscapeRuntimeReason": "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
// ask card
"ask.title": "Need your decision",
@@ -589,6 +718,7 @@ export const en = {
"ask.hideDetails": "Hide",
"ask.back": "Back",
"ask.next": "Next",
"ask.customAnswer": "Other answer",
"ask.customPlaceholder": "Type your own answer…",
"ask.justChat": "Just chat",
@@ -649,11 +779,25 @@ export const en = {
"history.failedOpenProject": "Cannot open session, \"{name}\" does not exist: {path}",
"history.failedOpenSession": "Cannot open session",
"history.failedOpenGlobal": "Cannot open global session",
"history.missingWorkspaceRoot": "Cannot open session because its workspace path is missing.",
"history.selectSession": "Select a session to preview.",
"history.trashSessionActions": "Trash session actions",
"history.historySessionActions": "History session actions",
"history.trashActions": "Trash actions",
// session recovery
"recovery.branch": "Conflict copy",
"recovery.badge": "Copy",
"recovery.open": "Open conflict copy",
"recovery.toast": "A save conflict was detected, so a conflict copy was kept for {title}.",
"recovery.failedLease": "The conflict copy is in use by another runtime, so this tab did not switch to it.",
"recovery.failedUnavailable": "The conflict copy could not be opened, so this tab stayed on the original session.",
"recovery.banner": "This session is a save-conflict copy. Compare it with the original before continuing.",
"recovery.noticeSavedCopy": "The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
"recovery.noticeKeptCurrent": "Repeated save conflicts were detected, so the current conflict copy was saved in place.",
"recovery.noticeAdopted": "The session changed on disk, so Reasonix adopted the newer transcript.",
"recovery.noticeAdoptedCovered": "The session changed on disk, so Reasonix adopted the newer transcript; the local changes were already covered.",
// project tree
"projectTree.workspaceTitle": "Projects",
"projectTree.pinnedTitle": "Pinned",
@@ -668,6 +812,7 @@ export const en = {
"projectTree.renameProjectWorkbench": "Rename project",
"projectTree.projectHistory": "View project history",
"projectTree.justNow": "just now",
"projectTree.previously": "previously",
"projectTree.running": "responding",
"projectTree.status.thinking": "thinking",
"projectTree.status.streaming": "responding",
@@ -827,6 +972,7 @@ export const en = {
"settings.tab.capabilities": "MCP & Skills",
"settings.tab.mcp": "MCP & Tools",
"settings.tab.skills": "Skills",
"settings.tab.plugins": "Plugins",
"settings.tab.memory": "Memory",
"settings.tab.hooks": "Hooks",
"settings.tab.shortcuts": "Shortcuts",
@@ -842,6 +988,7 @@ export const en = {
"settings.tabSub.capabilities": "Servers, tools & skills",
"settings.tabSub.mcp": "Servers & tools",
"settings.tabSub.skills": "Sources & enablement",
"settings.tabSub.plugins": "Packages & diagnostics",
"settings.tabSub.memory": "Project & global",
"settings.tabSub.hooks": "Shell automation",
"settings.tabSub.shortcuts": "Keys & help",
@@ -923,6 +1070,10 @@ export const en = {
"settings.subagentEffort": "Subagent effort",
"settings.subagentEffortDefault": "auto (provider default)",
"settings.subagentHint": "Applies to task and runAs=subagent skills unless a tool call or skill-specific config overrides it.",
"settings.subagentDepth": "Subagent nesting depth",
"settings.subagentDepthHint": "Controls whether a first-layer subagent may dispatch one more reviewer or worker subagent.",
"settings.subagentDepthOne": "1 layer",
"settings.subagentDepthTwo": "2 layers",
"settings.defaultToolApprovalMode": "New session approval",
"settings.defaultToolApprovalModeHint": "Saved to your global settings file and used only for new sessions; switching or restoring an existing session will not overwrite its Ask / Auto / Yolo state.",
"settings.defaultToolApprovalMode.ask": "Ask",
@@ -945,7 +1096,7 @@ export const en = {
"settings.coldResumePrune.on": "On",
"settings.coldResumePrune.off": "Off",
"settings.reasoningLanguage": "Thinking language",
"settings.reasoningLanguageHint": "Affects visible reasoning only. Auto follows the conversation language without injecting a language instruction.",
"settings.reasoningLanguageHint": "Affects visible reasoning only. Auto anchors clear Chinese prompts; referenced context is ignored.",
"settings.reasoningLanguage.auto": "Auto",
"settings.reasoningLanguage.zh": "中文",
"settings.reasoningLanguage.en": "English",
@@ -1006,7 +1157,7 @@ export const en = {
"settings.providerModelsUrlPlaceholder": "e.g. https://api.openai.com/v1/models",
"settings.providerModelsUrlHint": "Optional. Use when model discovery needs a different URL than the chat endpoint.",
"settings.providerModels": "models (comma-separated)",
"settings.providerApiKeyEnv": "api_key_env (e.g. DEEPSEEK_API_KEY)",
"settings.providerApiKeyEnv": "api_key_env (optional)",
"settings.providerBalanceUrl": "Balance URL (optional)",
"settings.balanceUrlPlaceholder": "balance endpoint (e.g. https://api.deepseek.com/user/balance)",
"settings.balanceUrlHint": "Optional. If set, the wallet balance is queried and shown in the status bar.",
@@ -1016,12 +1167,22 @@ export const en = {
"settings.providerReasoningProtocol": "reasoning protocol",
"settings.providerReasoningProtocolAuto": "auto (model and endpoint detection)",
"settings.providerReasoningProtocolHint": "For OpenAI-compatible reasoning models. deepseek uses DeepSeek reasoning fields, openai uses ordinary reasoning_effort, and none disables it.",
"settings.effortBehavior": "Effort behavior",
"settings.effortUseProtocolDefaults": "Protocol defaults",
"settings.effortUseCustom": "Customize levels",
"settings.effortProtocolDefault.auto": "Protocol defaults stay stored as empty supported_efforts. Reasonix will infer known DeepSeek models/endpoints as high|max, OpenAI reasoning as low|medium|high, and unsupported providers as no /effort.",
"settings.effortProtocolDefault.deepseek": "DeepSeek defaults expose auto|high|max. Reasonix keeps thinking enabled and uses reasoning_effort for depth.",
"settings.effortProtocolDefault.openai": "OpenAI reasoning defaults expose auto|low|medium|high and send ordinary reasoning_effort.",
"settings.effortProtocolDefault.minimax": "MiniMax-style endpoints use provider-specific thinking levels such as adaptive|disabled. Add those as custom levels only when the endpoint supports them.",
"settings.effortProtocolDefault.none": "Effort is disabled for this provider; Reasonix will not send reasoning_effort or thinking controls.",
"settings.supportedEfforts": "Supported efforts",
"settings.supportedEffortsCustomPlaceholder": "Custom level (press Enter)",
"settings.supportedEffortsHint": "Toggle the /effort levels this provider supports, or add a custom name. Empty uses the built-in default (DeepSeek: high|max, Anthropic: low|medium|high|xhigh|max).",
"settings.supportedEffortsNoPresets": "No standard presets",
"settings.supportedEffortsNoPresetsHint": "Use custom levels only for endpoints that document their own effort names.",
"settings.supportedEffortsHint": "Custom levels override protocol defaults. Pick only levels the endpoint documents; unsupported combinations can make the model request fail.",
"settings.defaultEffort": "Default effort",
"settings.defaultEffortAuto": "auto (use built-in default)",
"settings.defaultEffortHint": "Used when /effort is \"auto\" or unset. Must be one of the levels above. Empty falls back to the first level (or the built-in default when none are selected).",
"settings.defaultEffortAuto": "unset (use protocol/custom default)",
"settings.defaultEffortHint": "Used when /effort is \"auto\" or unset. Leave unset to use the protocol default, or the first custom level when levels are customized.",
"settings.proxyMode": "Proxy mode",
"settings.proxyMode.auto": "auto",
"settings.proxyMode.env": "env",
@@ -1042,11 +1203,76 @@ export const en = {
"settings.toggleOff": "Off",
"settings.botSetup": "Bot entry",
"settings.botSetupHint": "Enable the entry, then connect the chat channels you need.",
"settings.botConnectPhoneTitle": "Add IM bot",
"settings.botConnectPhoneSubtitle": "Manual setup for QQ, QR connection for Feishu, Lark, and WeChat.",
"settings.botGateway": "Bot gateway",
"settings.botGatewayHint": "Manage the IM bot gateway, model, and runtime parameters from the desktop app.",
"settings.botGatewayHint": "Manage the IM bot gateway master switch and connection status from the desktop app.",
"settings.botBrowserPreviewWarning": "This is a browser preview. It saves mock settings only and does not write local config or start the QQ/IM bot gateway. Configure it in the desktop app or CLI before testing from QQ.",
"settings.botSimpleEyebrow": "Guided setup",
"settings.botSimpleTitle": "Mobile access and chat bots",
"settings.botSimpleHint": "Scan from a phone to control the current workspace. For longer-lived access from chat apps, pick a Bot Channel and configure it.",
"settings.botMobileRemoteEyebrow": "Separate entry",
"settings.botMobileRemoteTitle": "Mobile remote control",
"settings.botMobileRemoteHint": "Scan or copy a link to open the current workspace control entry from a mobile device. This is configured separately from IM bot channels.",
"settings.botMobileRemoteControlApi": "Control link",
"settings.botMobileRemoteDisabled": "Enable it to generate the QR code and link",
"settings.botMobileRemoteEnableFirst": "Enable to generate QR",
"settings.botMobileRemoteReady": "Waiting for phone connection",
"settings.botMobileRemoteWaiting": "Connect by phone scan",
"settings.botMobileRemoteLoopbackNotice": "The control API is still bound to local loopback, so this is for local preview for now. Cross-device relay can reuse this entry later.",
"settings.botMobileRemoteCopyLink": "Copy link",
"settings.botMobileRemoteCopied": "Copied",
"settings.botMobileRemoteCopyFailed": "Copy failed",
"settings.botChannelEntryTitle": "Use Bot Channel",
"settings.botChannelEntryHint": "Connect chat bots for longer mobile sessions.",
"settings.botManageBots": "Bot management",
"settings.botManageBotsHint": "Choose a channel on the left. If it is not configured, scan or add keys; once configured, manage that bot's runtime settings on the right.",
"settings.botBackToOverview": "Back to entry",
"settings.botChannelOpenConfigured": "Configured. You can continue editing settings.",
"settings.botChannelNeedsSetup": "Not configured",
"settings.botChannelTabsLabel": "Bot channels",
"settings.botChannelSetupTitle": "Configure {provider}",
"settings.botChannelSetupHint": "Before setup, only credentials or scan binding are needed. After it succeeds, this becomes the bot's settings page.",
"settings.botSetupStatus": "Bot setup status",
"settings.botSetupStatusConnected": "{online}/{total} online",
"settings.botSetupStatusAccessTrusted": "Trusted list {count}",
"settings.botSetupStatusAccessEveryone": "Everyone allowed",
"settings.botSetupStatusAccessPerBot": "{configured}/{total} bots configured",
"settings.botMode": "Bot settings mode",
"settings.botModeSimple": "Simple",
"settings.botModeAdvanced": "Advanced",
"settings.botShowAdvancedSettings": "Advanced settings",
"settings.botHideAdvancedSettings": "Hide advanced settings",
"settings.botAdvancedSettingsHint": "Lower-frequency controls: routes, queue details, and bot echo filtering.",
"settings.botShowAddBot": "Add channel",
"settings.botStepConnectTitle": "Connect bots",
"settings.botStepConnectHint": "Manual keys for QQ; scan a QR code for Feishu, Lark, and WeChat.",
"settings.botStepConnectEmpty": "Not connected yet",
"settings.botSharedAllowlistHint": "Every channel shares the trusted list and pairing settings from step 2. Edit once and it applies everywhere.",
"settings.botSharedAllowlistGo": "Edit in step 2",
"settings.botGlobalAllowlist": "Legacy global allowlist",
"settings.botGlobalAllowlistHint": "Compatibility for older configs; new bots should set access in their own detail panel.",
"settings.botAccessShowAllPlatforms": "Show all platforms",
"settings.botAccessShowConnectedOnly": "Show connected platforms only",
"settings.botListUsers": "Users",
"settings.botListGroups": "Groups",
"settings.botListApprovers": "Approvers",
"settings.botListAdmins": "Admins",
"settings.botPlatformFeishuLark": "Feishu / Lark",
"settings.botQueueDropLabel": "When the queue is full",
"settings.botQueueDropHint": "How overflow messages are handled once the queue hits its cap.",
"settings.botPairingDetailHint": "The pairing switch lives in step 2 (Who can use it). Tune only the code lifetime and pending cap here.",
"settings.botSimpleAccessTitle": "Who can use it",
"settings.botSimpleAccessTrustedSummary": "Trusted list is active with {count} member or group entries.",
"settings.botSimpleAccessEveryoneSummary": "Everyone can use it right now. Best for short tests only.",
"settings.botAccessTrusted": "Trusted list",
"settings.botAccessTrustedHint": "Only listed users or groups can call the bot.",
"settings.botAccessEveryone": "Everyone",
"settings.botAccessEveryoneHint": "Convenient for quick testing, but not recommended long term.",
"settings.botAccessPairing": "Pair private chats",
"settings.botAccessPairingHint": "Unknown direct messages get a code and wait for local approval.",
"settings.botRoleAccess": "Admins and approvers",
"settings.botRoleAccessHint": "Optional. Leave this empty unless remote approval or management is needed.",
"settings.botQueueModeSimple": "When a new message arrives",
"settings.botQueueModeSimpleHint": "Choose what happens when the bot is already handling a previous message.",
"settings.saveBotSettings": "Save bot settings",
"settings.botEnableBot": "Enable bot",
"settings.botGatewayMasterSwitch": "Master switch",
@@ -1057,8 +1283,48 @@ export const en = {
"settings.botModelHint": "Leave empty to use the default model.",
"settings.botModelDefault": "Use default model",
"settings.botRuntime": "Runtime limits",
"settings.botRuntimeHint": "Gateway-level limits that control how far each bot task can run.",
"settings.botMaxSteps": "Max steps",
"settings.botDebounceMs": "Debounce ms",
"settings.botQueueCap": "Queue cap",
"settings.botQueueMode.steer": "Steer running turn",
"settings.botQueueMode.followup": "Follow up",
"settings.botQueueMode.collect": "Collect",
"settings.botQueueMode.interrupt": "Interrupt",
"settings.botQueueDrop.summarize": "Summarize overflow",
"settings.botQueueDrop.old": "Drop oldest",
"settings.botQueueDrop.new": "Drop newest",
"settings.botIgnoreSelfMessages": "Ignore bot echoes",
"settings.botIgnoreSelfMessagesHint": "Prevents the gateway from reacting to messages sent by configured bot identities.",
"settings.botSelfUserIds": "Bot self IDs",
"settings.botSelfUserIdsHint": "Optional user/open IDs for each platform, used to filter bot echo messages.",
"settings.botPairing": "Private chat pairing",
"settings.botPairingTTL": "TTL minutes",
"settings.botPairingMaxPending": "Max pending",
"settings.botControlApi": "Local control API",
"settings.botControlApiHint": "Loopback HTTP API for status and controlled outbound sends; requires a Bearer token from the named env var.",
"settings.botControlAddr": "Control address",
"settings.botControlTokenEnv": "Token env",
"settings.botGatewayDefaults": "Gateway runtime settings",
"settings.botGatewayDefaultsHint": "Tune queueing, pairing, and self-message filtering without editing TOML.",
"settings.botRoutes": "Bind chats to projects",
"settings.botRoutesHint": "Pin a group, contact, or thread to a workspace, model, or approval mode.",
"settings.botRoutesEmpty": "No bindings yet. Add one to bind a chat, user, or thread to a project or model.",
"settings.botRouteTitle": "Route {n}",
"settings.botAddRoute": "Add route",
"settings.botRouteConnection": "Bot source",
"settings.botRouteAny": "Any",
"settings.botRoutePlatform": "Platform",
"settings.botRouteChatType": "Chat type",
"settings.botRouteChatType.any": "Any",
"settings.botRouteChatType.dm": "DM",
"settings.botRouteChatType.group": "Group",
"settings.botRouteChatType.guild": "Guild",
"settings.botRouteChatType.direct": "Direct",
"settings.botRouteChatType.thread": "Thread",
"settings.botRouteChatId": "Group/chat ID",
"settings.botRouteUserId": "Contact ID",
"settings.botRouteThreadId": "Thread ID",
"settings.botChannels": "Connect channels",
"settings.botChannelsHint": "Pick a channel; QQ uses manual setup, while Feishu, Lark, and WeChat connect by QR.",
"settings.botQQHint": "QQ official Bot API v2.",
@@ -1113,8 +1379,7 @@ export const en = {
"settings.botInstallLarkHint": "Scan to create a Lark PersonalAgent.",
"settings.botInstallWeixinHint": "Scan WeChat and save a local token.",
"settings.botConnectedBots": "Connected bots",
"settings.botConnectedBotsSummary": "{online}/{total} online",
"settings.botConnectionsEmpty": "No bot channel is connected yet.",
"settings.botSelectBotHint": "Select a bot on the left to configure it.",
"settings.botConnectionColumnChannel": "Channel",
"settings.botConnectionColumnName": "Bot",
"settings.botConnectionColumnRemote": "Remote ID",
@@ -1132,16 +1397,17 @@ export const en = {
"settings.botConnectionConnected": "Connected",
"settings.botConnectionConfigured": "Configured",
"settings.botConnectionDisconnected": "Disconnected",
"settings.botManage": "Advanced settings",
"settings.botManage": "Edit config",
"settings.deleteBot": "Delete bot",
"settings.deleteBotShort": "Delete",
"settings.confirmDeleteBot": "Confirm delete",
"settings.deleteBotHint": "Removes this bot from connected bots; saved secrets are not cleared automatically.",
"settings.botChannelModel": "Model",
"settings.botChannelModelHint": "Leave empty to use the bot default model.",
"settings.botChannelModelAuto": "Auto",
"settings.botChannelModelHint": "The model this bot uses when handling messages.",
"settings.botChannelModelAuto": "Use current desktop model",
"settings.botToolApprovalMode": "Tool approval",
"settings.botToolApprovalModeHint": "Controls whether this channel asks before running tools; plan confirmation and ask questions still wait for a reply.",
"settings.botToolApprovalMode.inherit": "Inherit",
"settings.botToolApprovalMode.inherit": "Follow bot",
"settings.botToolApprovalMode.ask": "Ask",
"settings.botToolApprovalMode.auto": "Auto",
"settings.botToolApprovalMode.yolo": "YOLO",
@@ -1164,26 +1430,17 @@ export const en = {
"settings.botDiagnosticReportFailed": "Failed to send diagnostic report.",
"settings.botDiagnosticPrivacy": "Only sanitized connection metadata is included.",
"settings.botAccessControl": "Access control",
"settings.botAccessControlHint": "Whitelist is the default; switch to everyone for quick testing.",
"settings.botAccessMode": "Who can use it",
"settings.botAccessAll": "Everyone",
"settings.botAccessWhitelist": "Whitelist only",
"settings.botEditAllowlist": "Edit allowlist",
"settings.botAllowlist": "Allowlist",
"settings.botAllowlistHint": "Restrict which users or groups can invoke the bot.",
"settings.botAllowlistEnabled": "Enable allowlist",
"settings.botAllowAll": "Allow everyone",
"settings.botAllowAllWarn": "When enabled, every incoming channel message can invoke the bot.",
"settings.botQQAccessRequired": "Add a QQ user/group allowlist, or temporarily allow everyone in Advanced settings, before enabling QQ Bot.",
"settings.botAllowlistEntries": "Users and groups",
"settings.botQQAccessRequired": "Add QQ users/groups in step 2 (Who can use it), or temporarily allow everyone, before enabling QQ Bot.",
"settings.botQQUsers": "QQ users",
"settings.botQQGroups": "QQ groups",
"settings.botFeishuUsers": "Feishu users",
"settings.botFeishuGroups": "Feishu groups",
"settings.botFeishuLarkUsers": "Feishu / Lark users",
"settings.botFeishuLarkGroups": "Feishu / Lark groups",
"settings.botWeixinUsers": "WeChat users",
"settings.botWeixinGroups": "WeChat groups",
"settings.botListPlaceholder": "One ID per line, or comma-separated",
"settings.permissions": "Permissions",
"settings.writerMode": "Writer mode",
@@ -1195,17 +1452,23 @@ export const en = {
"settings.yoloHint": "YOLO-approves approval-gated tool calls (writers, bash). Deny rules still block, and ask questions plus plan approvals still wait for you. Runtime-only — resets on relaunch.",
"settings.addRule": "add {list} rule…",
"settings.sandboxTitle": "Sandbox & workspace",
"settings.sandboxBoundaryHint": "Sandbox write roots limit where approved Bash and file tools can write; they do not auto-approve commands. If Bash sandbox is enforced but no OS backend is available, shell commands are refused; switch Bash sandbox off only for trusted workspaces.",
"settings.reloadSessionConfig": "Apply manual config changes",
"settings.reloadSessionConfigHint": "Changes made in Settings apply automatically; use this only after manually editing config.toml or reasonix.toml.",
"settings.shellInterpreter": "Shell interpreter",
"settings.shellAuto": "auto (bash, else PowerShell on Windows)",
"settings.shellAuto": "auto (bash first)",
"settings.shellBash": "bash",
"settings.shellPowershell": "PowerShell (Windows PowerShell)",
"settings.shellPwsh": "pwsh (PowerShell 7+)",
"settings.bashSandbox": "Bash sandbox",
"settings.bashEnforce": "enforce (jail bash)",
"settings.bashEnforce": "enforce (jail bash; fail closed)",
"settings.bashOff": "off (run unconfined)",
"settings.allowNetwork": "Allow network egress from sandboxed bash",
"settings.workspaceRoot": "Workspace root",
"settings.workspaceDefault": "(default: cwd)",
"settings.effectiveWriteRoots": "Effective write roots",
"settings.effectiveWriteRootsHint": "Expanded for the active workspace. Settings changes apply automatically; use the button above after manually editing config files.",
"settings.noEffectiveWriteRoots": "No active write roots",
"settings.appearance": "Appearance",
"settings.theme": "Theme",
"settings.themeStyle": "Visual style",
@@ -1290,6 +1553,10 @@ export const en = {
"settings.textSizeLarge": "Large",
"settings.textSizeXLarge": "Extra large",
"settings.textSizeXXLarge": "Huge",
"settings.displayZoom": "Display zoom (restart required)",
"settings.displayZoomDecrease": "Decrease display zoom",
"settings.displayZoomIncrease": "Increase display zoom",
"settings.displayZoomReset": "Reset display zoom to 100%",
"settings.fontFamily": "Interface font",
"settings.fontFamilySystem": "System",
"settings.fontFamilyYaHei": "YaHei",
@@ -1312,6 +1579,7 @@ export const en = {
"settings.pageDesc.bots": "Configure QQ, Feishu, Lark, and WeChat bot channels, including each bot's model and runtime parameters.",
"settings.pageDesc.mcp": "Manage MCP server connections and tool discovery.",
"settings.pageDesc.skills": "Browse, enable, and manage agent skills and skill sources.",
"settings.pageDesc.plugins": "Install and manage plugin packages that can provide skills, hooks, and MCP servers.",
"settings.pageDesc.memory": "Manage preferences, project conventions, and instruction files Reasonix keeps referencing.",
"settings.pageDesc.hooks": "Configure shell hooks around turns, tool calls, compaction, and session lifecycle.",
"settings.pageDesc.shortcuts": "Customize keyboard shortcuts and keep the help sheet in sync.",
@@ -1323,10 +1591,19 @@ export const en = {
"settings.modelUsage": "Model usage",
"settings.modelUnavailable": "{ref} is not available. Pick a configured model or add provider access.",
"settings.modelNeedsKey": "{provider} needs an API key before this model can run.",
"settings.errorUnknown": "Operation failed.",
"settings.errorUnknownModel": "Unknown model {model}.",
"settings.errorModelProviderMissing": "Model {model} is unavailable because provider {provider} is not added.",
"settings.errorModelProviderNoKey": "Model {model} is unavailable because provider {provider} has no API key.",
"settings.errorRemoveAccessBusy": "Stop active work using {provider} before removing provider access.",
"settings.errorDeleteProviderBusy": "Stop active work using {provider} before deleting the provider.",
"settings.errorSaveBeforeRemoveAccess": "Could not save the current session before removing provider access: {err}",
"settings.errorSaveBeforeDeleteProvider": "Could not save the current session before deleting the provider: {err}",
"settings.errorRemoveProviderNoFallback": "Provider {provider} is used by open tabs, and no other configured provider is available.",
"settings.customProviders": "Custom providers",
"settings.customProvidersHint": "OpenAI-compatible or proxy providers you add yourself. These can be edited, deleted, and updated with fetched model lists.",
"settings.customProvidersEmpty": "No custom providers yet. Prefer the built-in access above for common services.",
"settings.providerAccessEmptyHint": "Click “Add provider” to choose DeepSeek or a custom OpenAI-compatible access.",
"settings.providerAccessEmptyHint": "Use Recommended preset to quickly add common providers, usually with only an API key. Use Custom provider for OpenAI-compatible or Anthropic-compatible endpoints.",
"settings.builtinProviderBadge": "built-in",
"settings.customProviderBadge": "custom",
"settings.configureProvider": "Configure",
@@ -1338,24 +1615,77 @@ export const en = {
"settings.searchModels": "Search models...",
"settings.noMatchingModels": "No matching models",
"settings.addProvider.chooseTitle": "Add provider",
"settings.addProvider.chooseHint": "Choose an official preset, or add a custom OpenAI-compatible access.",
"settings.addProvider.officialChoice": "Official provider",
"settings.addProvider.officialChoiceHint": "Choose from Reasonix presets for DeepSeek.",
"settings.addProvider.chooseHint": "Choose a recommended preset, or add a custom provider manually.",
"settings.addProvider.officialChoice": "Recommended preset",
"settings.addProvider.officialChoiceHint": "Choose DeepSeek or curated custom-provider presets.",
"settings.addProvider.customChoice": "Custom provider",
"settings.addProvider.customChoiceHint": "Configure base_url, models, key env, and related parameters manually.",
"settings.addProvider.officialTitle": "Add official provider",
"settings.addProvider.officialHint": "Official presets use fixed endpoints and defaults; choose the provider and set a key.",
"settings.addProvider.officialHint": "Recommended presets fill endpoints, models, vision, and reasoning defaults; usually only the key is needed.",
"settings.addProvider.customTitle": "Add custom provider",
"settings.addProvider.customHint": "Use this for proxies, aggregators, or other OpenAI-compatible services.",
"settings.addProvider.official.deepseek": "DeepSeek Official",
"settings.addProvider.official.deepseekDesc": "Official API key access with DeepSeek V4 Flash / Pro.",
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat Platform OpenAI-compatible endpoint with LongCat-2.0 default.",
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat Platform Anthropic-compatible Messages endpoint using Bearer auth.",
"settings.addProvider.preset.kimiCnDesc": "Kimi China API via Moonshot CN endpoint with K2 vision models.",
"settings.addProvider.preset.kimiGlobalDesc": "Kimi international API via Moonshot global endpoint with K2 vision models.",
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan via the dedicated Anthropic-compatible endpoint; not a CN/Global variant.",
"settings.addProvider.preset.mimoApiDesc": "Xiaomi MiMo direct API for v2.5 pay-as-you-go models.",
"settings.addProvider.preset.mimoAnthropicDesc": "Xiaomi MiMo direct Anthropic-compatible endpoint with MiMo v2.5 models.",
"settings.addProvider.preset.mimoTokenPlanCnDesc": "Xiaomi MiMo token-plan China endpoint.",
"settings.addProvider.preset.mimoTokenPlanCnAnthropicDesc": "Xiaomi MiMo token-plan China Anthropic-compatible endpoint.",
"settings.addProvider.preset.mimoTokenPlanSgpDesc": "Xiaomi MiMo token-plan Singapore endpoint.",
"settings.addProvider.preset.mimoTokenPlanSgpAnthropicDesc": "Xiaomi MiMo token-plan Singapore Anthropic-compatible endpoint.",
"settings.addProvider.preset.mimoTokenPlanAmsDesc": "Xiaomi MiMo token-plan Amsterdam endpoint.",
"settings.addProvider.preset.mimoTokenPlanAmsAnthropicDesc": "Xiaomi MiMo token-plan Amsterdam Anthropic-compatible endpoint.",
"settings.addProvider.preset.minimaxCnApiDesc": "MiniMax China OpenAI-compatible M-series API endpoint.",
"settings.addProvider.preset.minimaxGlobalApiDesc": "MiniMax international OpenAI-compatible M-series API endpoint.",
"settings.addProvider.preset.minimaxCnAnthropicDesc": "MiniMax China Anthropic-compatible M-series endpoint.",
"settings.addProvider.preset.minimaxGlobalAnthropicDesc": "MiniMax international Anthropic-compatible endpoint using Bearer auth.",
"settings.addProvider.preset.glmCnDesc": "Zhipu GLM China API with GLM thinking controls.",
"settings.addProvider.preset.zaiGlobalDesc": "Z.AI international GLM API.",
"settings.addProvider.preset.glmCodingPlanCnDesc": "Zhipu GLM China coding-plan endpoint with GLM-5.2 default.",
"settings.addProvider.preset.glmCodingPlanCnAnthropicDesc": "Zhipu GLM China Anthropic-compatible coding-plan endpoint with GLM-5.2[1m] default.",
"settings.addProvider.preset.zaiCodingPlanGlobalDesc": "Z.AI international coding-plan endpoint with GLM-5.2 default.",
"settings.addProvider.preset.zaiCodingPlanGlobalAnthropicDesc": "Z.AI international Anthropic-compatible coding-plan endpoint with GLM-5.2[1m] default.",
"settings.addProvider.preset.opencodeGoDesc": "OpenCode Go relay with mixed-model capability overrides.",
"settings.addProvider.preset.opencodeGoAnthropicDesc": "OpenCode Go Anthropic route for Qwen and MiniMax models.",
"settings.addProvider.preset.opencodeZenAnthropicDesc": "OpenCode Zen Anthropic route for Claude and Qwen models.",
"settings.addProvider.preset.qwenCnDesc": "Qwen/DashScope China standard API endpoint.",
"settings.addProvider.preset.qwenGlobalDesc": "Qwen/DashScope international standard API endpoint.",
"settings.addProvider.preset.qwenCodingPlanCnDesc": "Qwen Coding Plan China OpenAI-compatible endpoint with qwen3.7-plus default.",
"settings.addProvider.preset.qwenCodingPlanCnAnthropicDesc": "Qwen Coding Plan China Anthropic-compatible endpoint with qwen3.7-plus default.",
"settings.addProvider.preset.qwenCodingPlanGlobalDesc": "Qwen Coding Plan international OpenAI-compatible endpoint with qwen3.7-plus default.",
"settings.addProvider.preset.qwenCodingPlanGlobalAnthropicDesc": "Qwen Coding Plan international Anthropic-compatible endpoint with qwen3.7-plus default.",
"settings.addProvider.preset.stepfunDesc": "StepFun coding-plan OpenAI-compatible endpoint with step-3.7-flash default.",
"settings.addProvider.preset.stepfunAnthropicDesc": "StepFun coding-plan Anthropic-compatible endpoint with step-3.7-flash default.",
"settings.addProvider.preset.novitaDesc": "NovitaAI multi-model gateway with Kimi, MiniMax, GLM, DeepSeek, and Qwen.",
"settings.addProvider.preset.gmiDesc": "GMI Cloud direct multi-model gateway with GLM, DeepSeek, Kimi, Gemini, Claude, and GPT.",
"settings.addProvider.preset.vercelAiGatewayDesc": "Vercel AI Gateway through Anthropic-compatible Messages API.",
"settings.addProvider.preset.huggingfaceDesc": "HuggingFace Inference Router with Qwen and DeepSeek defaults.",
"settings.addProvider.preset.nvidiaDesc": "NVIDIA NIM accelerated inference endpoint with Nemotron defaults.",
"settings.addProvider.preset.kilocodeDesc": "Kilo Code gateway with kilo/auto routing defaults.",
"settings.addProvider.preset.ollamaCloudDesc": "Hosted Ollama Cloud endpoint with none-to-max reasoning effort.",
"settings.addProvider.addedBadge": "Added",
"settings.addProvider.modifiedBadge": "Modified",
"settings.addProvider.nameConflictBadge": "Name taken",
"settings.addProvider.similarExistingBadge": "Similar setup",
"settings.addProvider.confirm": "Add provider",
"settings.addProvider.alreadyAddedAction": "Already added",
"settings.addProvider.nameConflictAction": "Resolve name first",
"settings.addProvider.viewPresetProvider": "View config",
"settings.addProvider.viewConflictProvider": "View same-name config",
"settings.addProvider.resetPreset": "Reset to preset",
"settings.addProvider.confirmResetPreset": "Confirm reset",
"settings.customProviderName": "Custom provider name",
"settings.customProviderNamePlaceholder": "",
"settings.providerNamePlaceholder": "Provider name (e.g. deepseek-proxy)",
"settings.providerProtocol": "Connection protocol",
"settings.providerProtocolOpenAI": "OpenAI-compatible",
"settings.providerProtocolOpenAIHint": "For proxies, aggregators, and self-hosted services compatible with the OpenAI API.",
"settings.providerProtocolAnthropic": "Anthropic-compatible",
"settings.providerProtocolAnthropicHint": "For Claude-style Messages API services such as some coding-plan endpoints.",
"settings.providerBaseUrlLabel": "API address",
"settings.providerChatUrlLabel": "Chat request URL",
"settings.providerChatUrlPlaceholder": "e.g. https://api.openai.com/v1/chat/completions",
@@ -1369,12 +1699,31 @@ export const en = {
"settings.testFetchModels": "Test and fetch models",
"settings.testFetchModelsHint": "Uses the API address and key above to verify the connection and fill the model list.",
"settings.manualModels": "Model list",
"settings.manualModelsHint": "If the endpoint cannot list models, enter model names manually, separated by commas.",
"settings.manualModelsHint": "If the endpoint cannot list models, enter model names manually; mark image-capable models below.",
"settings.visionModels": "Image-capable models",
"settings.visionModelsHint": "Subset of enabled models that accept image input, separated by commas.",
"settings.providerAdvancedSettings": "Advanced settings",
"settings.providerAdvancedSettings": "Compatibility settings (usually leave unchanged)",
"settings.providerAdvancedExpandHint": "Click to open details",
"settings.providerAdvancedCollapseHint": "Click to collapse",
"settings.providerHeaders": "Extra request headers (optional)",
"settings.providerHeadersPlaceholder": "HTTP-Referer: https://example.com\nX-Title: Reasonix",
"settings.providerHeadersHint": "For gateways such as OpenRouter that require site headers. Use one Header: value per line; the API key still belongs in the key field above.",
"settings.providerExtraBody": "Extra request body (optional)",
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
"settings.providerExtraBodyHint": "Top-level JSON fields merged into the chat request body. Core fields such as model, messages, tools, stream, and thinking stay controlled by Reasonix.",
"settings.providerExtraBodyError": "Enter a JSON object without null values, for example { \"enable_thinking\": true }.",
"settings.providerExtraBodyNull": "{path} cannot contain null.",
"settings.providerExtraBodyObjectRequired": "Extra body must be a JSON object.",
"settings.providerAuthHeader": "Use Authorization: Bearer for this key",
"settings.providerAuthHeaderHint": "For Anthropic-compatible gateways such as MiniMax Global or Vercel AI Gateway that do not use x-api-key.",
"settings.fetchingModels": "Refreshing…",
"settings.fetchModelsSuccess": "Fetched {n} models",
"settings.fetchModelsManualFallbackAuth": "The model discovery endpoint rejected the key or permission. Check the API key; if this service does not expose model discovery, enter model IDs manually below.",
"settings.fetchModelsManualFallbackUnsupported": "This service may not support automatic model discovery. Enter model IDs manually below.",
"settings.fetchModelsManualFallbackNetwork": "Could not reach the model discovery endpoint. Check the API address or proxy; if you know the model IDs, enter them manually below.",
"settings.fetchModelsManualFallbackDecode": "The model discovery endpoint returned an unknown format. Enter model IDs manually below.",
"settings.fetchModelsManualFallbackEmpty": "No models were discovered automatically. Enter model IDs manually below.",
"settings.fetchModelsManualFallbackGeneric": "Could not fetch models automatically: {err}. Enter model IDs manually below.",
"settings.fetchModelsUpdatedForProvider": "Updated available models for {provider}, {n} total",
"settings.fetchModelsReadyForProvider": "Found {n} models for {provider}. Review and save the enabled list.",
"settings.enabledModelsSavedForProvider": "Saved {n} enabled models for {provider}",
@@ -1384,12 +1733,18 @@ export const en = {
"settings.fetchModelsAfterKeyFailed": "Key saved, but available models could not be refreshed automatically: {err}",
"settings.fetchModelsAfterKeyFailedForProvider": "Key saved, but available models for {provider} could not be refreshed: {err}",
"settings.newProviderDraftName": "new provider",
"settings.reasoningProtocol": "Reasoning protocol",
"settings.reasoningProtocol.auto": "Auto (detect from model)",
"settings.reasoningProtocol.deepseek": "DeepSeek",
"settings.reasoningProtocol.openai": "OpenAI",
"settings.reasoningProtocol.none": "None (off)",
"settings.reasoningProtocolHint": "Controls the request shape behind effort. Auto uses the model capability registry and official endpoints; switch to OpenAI or None if a proxy is incompatible.",
"settings.reasoningProtocol": "Model capability mode",
"settings.reasoningProtocol.auto": "Auto-detect (recommended)",
"settings.reasoningProtocol.deepseek": "DeepSeek thinking (thinking + depth)",
"settings.reasoningProtocol.openai": "OpenAI reasoning (reasoning_effort)",
"settings.reasoningProtocol.none": "Plain chat (no reasoning fields)",
"settings.reasoningProtocolHint": "Auto-detect is best for most gateways. Switch only when a proxy is incompatible or the model documentation requires a specific reasoning shape.",
"settings.thinkingMode": "Thinking override",
"settings.thinkingMode.auto": "Auto (provider default)",
"settings.thinkingMode.enabled": "Enabled",
"settings.thinkingMode.disabled": "Disabled",
"settings.thinkingMode.adaptive": "Adaptive (self-adjusting)",
"settings.thinkingModeHint": "Provider-specific override. Use enabled/disabled for compatible gateways, adaptive only for providers that document it.",
"settings.updateKey": "update {env} (saved globally)",
"settings.updateKeyAction": "Update key",
"settings.keyStatus": "Key",
@@ -1441,6 +1796,10 @@ export const en = {
"settings.hooksJsonApplied": "JSON validated. Save to persist it.",
"settings.hooksJsonFormatted": "JSON formatted. Save to persist it.",
"settings.hooksJsonInvalid": "Invalid JSON: {error}",
"settings.hooksJsonExpectedObjectArray": "Expected an object or array.",
"settings.hooksJsonItemObject": "Hook item must be an object.",
"settings.hooksJsonUnknownEvent": "Unknown hook event {event}.",
"settings.hooksJsonEventItemObject": "Hook {event} item must be an object.",
"settings.hooksJsonClipboardEmpty": "Clipboard is empty",
"settings.hooksJsonClipboardUnavailable": "Clipboard is unavailable; copy the textarea manually.",
"settings.hooksJsonPasteFailed": "Paste failed: {error}",
@@ -1504,6 +1863,8 @@ export const en = {
"msg.imageAttachment": "Image",
"msg.workspaceReference": "Workspace reference",
"msg.folderReference": "Folder · Workspace reference",
"msg.pastedExpandTooltip": "Expand pasted text",
"msg.pastedCollapseTooltip": "Collapse pasted text",
"turnActions.summary": "Summary",
"turnActions.rewind": "Rewind",
"transcript.jumpToBottom": "Jump to bottom",
@@ -1528,6 +1889,7 @@ export const en = {
"rewind.disabledNoBoundary": "Conversation boundary is unavailable for this message",
"rewind.disabledNoCode": "No code changes were captured after this message",
"rewind.disabledNoEarlier": "There is nothing before this message to summarize",
"rewind.disabledNoLater": "There is nothing after this message to summarize",
"rewind.filesChanged": "{count} files",
"rewind.turnFiles": "{count} this turn",
"rewind.both": "Rewind code and conversation",
@@ -1548,6 +1910,7 @@ export const en = {
"rewind.busyConversation": "Rewinding conversation…",
"rewind.busyCode": "Rewinding code…",
"rewind.busyBoth": "Rewinding code and conversation…",
"rewind.failed": "Rewind failed",
"undoRewind.turns": "Rolled back {n} turn(s)",
"undoRewind.filesRestored": "{n} file(s) restored",
"undoRewind.filesRemoved": "{n} file(s) removed",
@@ -1585,7 +1948,7 @@ export const en = {
"settings.telemetryLabel": "Anonymous usage ping",
"settings.telemetryHint": "On launch, send a random install id plus version and OS to count active installs, and — if the app crashed on a previous run — resend that crash report. Never includes conversations, keys, or file contents.",
"settings.metricsLabel": "Share aggregate quality metrics",
"settings.metricsHint": "On by default. Sends anonymous turn-end counts, settings preference snapshots, and Memory v5 aggregate buckets such as injection on/off, size buckets, IR count buckets, and memory graph size buckets. Includes the random install id used to de-duplicate DAU. Buckets may include normalized custom provider and model names — never conversations, prompts, keys, paths, base URLs, memory text, tool outputs, or file contents.",
"settings.metricsHint": "On by default. Sends anonymous turn-end counts, updater error categories, settings preference snapshots, and Memory v5 aggregate buckets such as injection on/off, size buckets, IR count buckets, and memory graph size buckets. Includes the random install id used to de-duplicate DAU. Buckets may include normalized custom provider and model names — never conversations, prompts, keys, paths, base URLs, memory text, tool outputs, or file contents.",
"updater.currentVersion": "Current version: {v}",
"updater.channelLabel": "Update channel: {channel}",
"updater.checkButton": "Check for updates",
@@ -1628,10 +1991,20 @@ export const en = {
"context.scopeGlobal": "Scope: Global",
"context.scopeProject": "Project scope · {name}",
"context.windowTitle": "Context window",
"context.windowSubtitle": "Current context window usage",
"context.windowStatusHealthy": "Context healthy",
"context.windowStatusWatch": "Compression soon",
"context.windowStatusPastCompact": "Compression threshold reached",
"context.windowStatusNearLimit": "Near context limit",
"context.windowUsageSummary": "{pct}% used",
"context.windowCompactRemaining": "{used} / {window} · {tokens} until compression",
"context.windowCompactDistance": "To compact",
"context.windowUsedLabel": "Used",
"context.windowUsed": "Used context",
"context.windowCompactThreshold": "Compression threshold",
"context.windowCompactHeadroom": "Before compression",
"context.sessionMetrics": "Session metrics",
"context.runtimeMetrics": "Runtime",
"context.costMetrics": "Cost",
"context.sessionStatus": "Session status",
"context.sessionChanges": "Session changes",
"context.referencedFiles": "Dependency files",
"context.noChanges": "No changed files in this session yet",
@@ -1639,34 +2012,28 @@ export const en = {
"context.changedMeta": "{count} change records",
"context.readMeta": "{count} read records",
"context.viewAll": "View all",
"context.health": "Context health",
"context.healthGood": "Context healthy",
"context.healthGoodShort": "Healthy",
"context.healthGoodBody": "Usage, source files, and session changes are traceable.",
"context.healthNearLimit": "Context near limit",
"context.healthNearLimitShort": "Near limit",
"context.healthNearLimitBody": "{pct}% used, keep critical sources and consider compaction.",
"context.healthManyFiles": "Many source files",
"context.healthManyFilesShort": "Many files",
"context.healthManyFilesBody": "{count} file records read, verify sources are still relevant.",
"context.healthLowCache": "Low cache hit",
"context.healthLowCacheShort": "Low cache",
"context.healthLowCacheBody": "Cache hit at {pct}%, watch context stability on long tasks.",
"context.prompt": "Prompt",
"context.completion": "Completion",
"context.reasoning": "Reasoning",
"context.other": "Other",
"context.total": "Total",
"context.usageAnalysis": "Usage analysis",
"context.usageAnalysisView": "Usage analysis view",
"context.usageAnalysisSource": "By source",
"context.usageAnalysisType": "By type",
"context.tokenBreakdown": "Token composition",
"context.cacheHit": "Cache hit",
"context.inputCache": "Input cache",
"context.inputCacheHit": "Input (cache hit)",
"context.inputCacheMiss": "Input (cache miss)",
"context.outputTokens": "Output tokens",
"context.sessionTokens": "Session tokens",
"context.sessionTokens": "Session total tokens",
"context.sessionTokensShort": "Total tokens",
"context.tokensValue": "{value} tokens",
"context.sessionCost": "Session cost",
"context.costBreakdown": "Cost breakdown",
"context.sourceBreakdown": "Model usage breakdown",
"context.sourceShareTokens": "By tokens",
"context.sourceBreakdown": "Usage source",
"context.sourceShareTitle": "Source share",
"context.sourceInput": "Input",
"context.sourceOutput": "Output",
"context.sourceCacheHit": "Hit",
@@ -1682,9 +2049,9 @@ export const en = {
"context.sourceClassifier": "Classifier",
"context.sourceTitle": "Title",
"context.sourceRequests": "{count} req",
"context.moreSources": "{count} more sources",
"context.requests": "Requests",
"context.time": "Time",
"context.compaction": "Compaction",
"context.time": "Runtime",
"context.durationSeconds": "{seconds}s",
"context.durationMinutesSeconds": "{minutes}m {seconds}s",
@@ -1726,6 +2093,9 @@ export const en = {
"mock.topicAi": "AI engineering best practices",
"mock.topicLab": "Exploratory experiments",
"mock.approvalSubject": "npm run build\n\nRun the build command to verify frontend artifacts and style packaging.",
"mock.sandboxEscapeReasoning": "Windows sandbox helper failed; requesting a one-time unconfined rerun approval.",
"mock.sandboxEscapeSubject": "run unconfined once: go test ./...",
"mock.sandboxEscapeReason": "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
"mock.askQ1Header": "Approach",
"mock.askQ1Prompt": "How do you want to handle the git pull conflicts?",
"mock.askQ1Opt1Label": "git stash then pull",
Loaded 100 of 619 files, more files were not shown because too many files have changed in this diff. Show more