Compare commits

...
148 Commits
Author SHA1 Message Date
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
GTC2080 c146456d8a fix(cli): repaint wide-character input on Windows 2026-07-01 20:15:05 +10:00
258 changed files with 22426 additions and 2015 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:
+62 -4
View File
@@ -269,7 +269,9 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
args=(--title "Reasonix Desktop ${{ steps.ver.outputs.version }}" --generate-notes)
# Repository-wide "latest" is reserved for the CLI line (see header);
# without this a desktop release published after the CLI one steals it.
args=(--title "Reasonix Desktop ${{ steps.ver.outputs.version }}" --generate-notes --latest=false)
[ "${{ steps.ver.outputs.prerelease }}" = "true" ] && args+=(--prerelease)
gh release create "${{ steps.ver.outputs.tag }}" dist/* "${args[@]}"
@@ -367,6 +369,10 @@ 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:
@@ -374,8 +380,11 @@ jobs:
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
@@ -384,7 +393,8 @@ jobs:
all(type == "string" and startswith($prefix) and (contains("/releases/latest/") | not)))
' "$f" >/dev/null
curl --retry 5 --retry-delay 2 -fsSL "https://dl.reasonix.io/${TAG}/latest.json" >/tmp/reasonix-desktop-tag-latest.json
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
@@ -393,14 +403,62 @@ jobs:
pointer="latest"
fi
if [ -n "$pointer" ]; then
curl --retry 5 --retry-delay 2 -fsSL "https://dl.reasonix.io/${pointer}/latest.json" >/tmp/reasonix-desktop-pointer-latest.json
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
curl --retry 5 --retry-delay 2 -fsSI "$asset" >/dev/null
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:
+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
+52 -1
View File
@@ -53,6 +53,9 @@ jobs:
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
@@ -61,10 +64,58 @@ jobs:
exit 0
;;
esac
curl --retry 5 --retry-delay 2 -fsSL https://dl.reasonix.io/latest/latest.json -o latest.raw.json
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 — so verify it 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
+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 重写版。
+18
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
+1040 -379
View File
File diff suppressed because it is too large. Load diff
+10 -10
View File
@@ -248,8 +248,8 @@ func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
if tab.SessionPath != recoveryPath {
t.Fatalf("tab session path = %q, want recovery path %q", tab.SessionPath, recoveryPath)
}
if tab.TopicID == "" || tab.TopicID == originalTopic {
t.Fatalf("tab topic ID = %q, want fresh recovery topic", tab.TopicID)
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 {
@@ -263,22 +263,22 @@ func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
t.Fatalf("tab recovery meta = %+v, want digest %q parent %q", tabMeta, meta.RecoveryDigest, meta.ParentID)
}
nodes := app.ListProjectTree()
found := false
foundOriginal := false
var walk func([]ProjectNode)
walk = func(list []ProjectNode) {
for _, node := range list {
if node.TopicID == tab.TopicID {
found = true
if !node.Recovered || node.RecoveryDigest != meta.RecoveryDigest || node.RecoveryParentID != string(meta.ParentID) {
t.Fatalf("project tree recovery node = %+v, want digest %q parent %q", node, meta.RecoveryDigest, meta.ParentID)
}
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 !found {
t.Fatalf("project tree did not include recovery topic %q: %#v", tab.TopicID, nodes)
if !foundOriginal {
t.Fatalf("project tree did not include original topic %q: %#v", originalTopic, nodes)
}
}
+89
View File
@@ -168,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) {
@@ -588,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) {
+1576 -8
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")
}
}
+9 -2
View File
@@ -138,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
+50 -23
View File
@@ -23,10 +23,15 @@ 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 {
@@ -65,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 {
@@ -77,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
}
@@ -105,11 +117,11 @@ 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
}
@@ -176,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()
@@ -194,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{
@@ -203,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
}
@@ -274,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()
+26 -5
View File
@@ -302,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)
}
@@ -310,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)
@@ -351,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)
}
@@ -359,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) {
@@ -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"
+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"
+237
View File
@@ -0,0 +1,237 @@
package main
import (
"errors"
"fmt"
"log/slog"
"os"
"strings"
"sync"
"time"
"reasonix/internal/agent"
)
// deferredRebuildRetryInterval is how often the retry loop probes a held
// session lease. Package-level so tests can shorten it.
var deferredRebuildRetryInterval = 2 * time.Second
// deferredRebuildState tracks tabs whose settings were saved to disk but whose
// runtime could not refresh 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) 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 {
d := &a.deferredRebuild
d.mu.Lock()
if d.stopped || len(d.pending) == 0 {
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) 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
}
// 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()))
}
// 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__/message-pasted-blocks.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__/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__/message-pasted-blocks.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__/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",
+2
View File
@@ -35,6 +35,7 @@ import {
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";
@@ -1041,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);
@@ -54,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/",
@@ -62,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;
@@ -129,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 () => [],
@@ -178,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({
@@ -260,8 +341,8 @@ console.log("\napproval modal file references");
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|4Deny",
"fresh-human approval keeps allow/deny shortcut keys",
"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;
@@ -280,5 +361,108 @@ console.log("\napproval modal file references");
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);
@@ -9,6 +9,7 @@ import {
projectTreeReadActivityKey,
projectTreeTopicHasUnreadActivity,
projectTreeShouldRenderTopicActions,
projectTreeTopicMetaLine,
} from "../components/ProjectTree";
import type { ProjectNode } from "../lib/types";
@@ -27,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",
@@ -107,6 +114,29 @@ 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",
@@ -165,6 +165,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;
@@ -191,6 +192,7 @@ await act(async () => {
<LocaleProvider>
<SettingsPanel
initialTab="general"
desktopPlatform="linux"
onClose={() => {}}
onChanged={(settings?: SettingsView) => {
onChangedSettings = settings;
@@ -238,6 +240,7 @@ await act(async () => {
<LocaleProvider>
<SettingsPanel
initialTab="general"
desktopPlatform="linux"
onClose={() => {}}
onChanged={() => {}}
/>
@@ -266,6 +269,55 @@ 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);
@@ -301,7 +353,7 @@ window.go = {
await act(async () => {
botsRoot.render(
<LocaleProvider>
<SettingsPanel initialTab="bots" onClose={() => {}} onChanged={() => {}} />
<SettingsPanel initialTab="bots" desktopPlatform="linux" onClose={() => {}} onChanged={() => {}} />
</LocaleProvider>,
);
await flushPromises();
@@ -133,9 +133,12 @@ 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>();
@@ -144,12 +147,15 @@ 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, tabE, tabF, tabG].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> = [];
@@ -172,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;
@@ -191,6 +204,12 @@ window.go = {
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) => {
@@ -198,9 +217,15 @@ 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;
@@ -509,6 +534,70 @@ 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();
});
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);
@@ -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,6 +1,6 @@
// Run: tsx src/__tests__/use-controller-meta.test.ts
import { foregroundRunningFromRuntimeMeta, initialState, metaFromTab, reducer, sameMeta, shouldReconcileStaleTurn } from "../lib/useController";
import { effortSwitchNoticeText, foregroundRunningFromRuntimeMeta, initialState, metaFromTab, reducer, sameMeta, shouldReconcileStaleTurn } from "../lib/useController";
import type { Meta, TabMeta, WireUsage } from "../lib/types";
type LooseTabMeta = Omit<TabMeta, "toolApprovalMode"> & { toolApprovalMode?: TabMeta["toolApprovalMode"] | "" };
@@ -82,6 +82,34 @@ function usage(source: string): WireUsage {
console.log("\nuse controller meta");
{
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(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,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);
+111 -13
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";
@@ -28,7 +28,89 @@ function animateShelfExit(
}
function requiresFreshHumanApproval(tool: string): boolean {
return tool === "remember" || tool === "forget" || tool === "exit_plan_mode";
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({
@@ -52,13 +134,14 @@ export function ApprovalModal({
}) {
const t = useT();
const isPlanApproval = approval.tool === "exit_plan_mode";
const isPlanModeReadOnlyCommand = approval.tool === "plan_mode_read_only_command";
const toolLabel = isPlanModeReadOnlyCommand ? "bash" : approval.tool;
const toolLabel = approvalToolLabel(approval.tool, t);
const isFreshHumanApproval = requiresFreshHumanApproval(approval.tool);
const subject = approval.subject.trim();
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 = approval.reason?.trim() || subjectSummary || approval.tool;
const hasToolDetails = Boolean(approval.reason || subject);
const toolMeta = reason || subjectSummary || approval.tool;
const hasToolDetails = Boolean(reason || subject);
const showToolDetailsByDefault = !isPlanApproval && hasToolDetails;
const [revisionOpen, setRevisionOpen] = useState(false);
const [revisionText, setRevisionText] = useState("");
@@ -100,6 +183,9 @@ export function ApprovalModal({
const chooseToolAction = (key: string) => {
if (key === "1") answerWithExit(() => onAnswer(true, 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));
@@ -107,6 +193,12 @@ export function ApprovalModal({
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);
@@ -116,7 +208,7 @@ export function ApprovalModal({
playAttentionChime();
}, [approval.id, isPlanApproval, showToolDetailsByDefault]);
const actionCount = isPlanApproval ? 3 : isFreshHumanApproval ? 2 : 4;
const actionCount = isPlanApproval ? 3 : isFreshHumanApproval ? (hasFreshSessionGrant ? 3 : 2) : 4;
const selectedIndexRef = useRef(selectedIndex);
selectedIndexRef.current = selectedIndex;
@@ -137,8 +229,7 @@ export function ApprovalModal({
event.preventDefault();
const key = String(selectedIndexRef.current + 1);
if (isPlanApproval) choosePlanAction(key);
else if (isFreshHumanApproval) chooseToolAction(selectedIndexRef.current === 0 ? "1" : "4");
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);
@@ -147,7 +238,7 @@ export function ApprovalModal({
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [isPlanApproval, isFreshHumanApproval, onAnswer, onExitPlan, onStop, actionCount]);
}, [isPlanApproval, isFreshHumanApproval, hasFreshSessionGrant, onAnswer, onExitPlan, onStop, actionCount]);
useEffect(() => {
if (revisionOpen) {
@@ -318,7 +409,14 @@ export function ApprovalModal({
<>
<PromptAction keyLabel="1" label={t("approval.allowOnce")} onClick={() => answerWithExit(() => onAnswer(true, false, false))} selected={selectedIndex === 0} />
{isFreshHumanApproval ? (
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 1} />
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} />
@@ -331,7 +429,7 @@ export function ApprovalModal({
>
{detailsOpen && (
<div className="approval-details">
{approval.reason && <div className="approval-reason">{approval.reason}</div>}
{reason && <div className="approval-reason">{reason}</div>}
{subject && (
<pre className="approval-subject">{subject}</pre>
)}
@@ -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>
@@ -557,6 +557,7 @@ export function TurnActions({
actionPending = false,
rewindDisabled = false,
hoverMenus = false,
isLastTurn = false,
}: {
text: string;
turn?: number;
@@ -567,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);
@@ -577,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");
+17 -27
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",
@@ -1050,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 };
@@ -1126,11 +1132,12 @@ 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";
@@ -1140,15 +1147,8 @@ export function ProjectTree({
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 recovered = Boolean(node.recovered);
const recoveryTitle = recovered
? [
t("recovery.branch"),
node.recoveryReason ? t("recovery.reason", { reason: node.recoveryReason }) : "",
node.recoveryDigest ? t("recovery.digest", { digest: node.recoveryDigest.slice(0, 12) }) : "",
].filter(Boolean).join(" · ")
: "";
const title = [label, recoveryTitle, 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");
@@ -1225,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}` : ""}${unread ? " project-tree__topic--unread" : ""}${!isSessionNode && pinned ? " project-tree__topic--pinned" : ""}${recovered ? " project-tree__topic--recovered" : ""}${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}
>
@@ -1267,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}`}
@@ -1279,11 +1279,6 @@ export function ProjectTree({
</span>
)}
{!compactTopics && statusLabel && <span className={`project-tree__topic-status project-tree__topic-status--${status}`}>{statusLabel}</span>}
{recovered && (
<span className="project-tree__topic-recovery" title={recoveryTitle}>
{t("recovery.badge")}
</span>
)}
</span>
{!compactTopics && !creationTopics && meta && (
<span className="project-tree__topic-meta">
@@ -1307,11 +1302,6 @@ export function ProjectTree({
{meta}
</span>
)}
{compactTopics && recovered && (
<span className="sr-only">
{t("recovery.branch")}
</span>
)}
</button>
{unread && <span className="project-tree__topic-unread-dot" aria-hidden="true" />}
{projectTreeShouldRenderTopicActions(isSessionNode, compactTopics, unread) && (
+111 -37
View File
@@ -1,5 +1,5 @@
import { lazy, memo, Suspense, useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent, type ReactNode } from "react";
import { Bot as BotIcon, Check, CheckCircle2, ChevronDown, ChevronUp, Clipboard, GripVertical, KeyRound, Loader2, MessageCircle, Play, QrCode, RefreshCw, Send } from "lucide-react";
import { Bot as BotIcon, Check, CheckCircle2, ChevronDown, ChevronUp, Clipboard, GripVertical, KeyRound, Loader2, MessageCircle, Minus, Play, Plus, QrCode, RefreshCw, RotateCcw, Send } from "lucide-react";
import { asArray } from "../lib/array";
import { useDeferredClose } from "../lib/useMountTransition";
import { app } from "../lib/bridge";
@@ -17,7 +17,7 @@ import {
type ThemeStyle,
} from "../lib/theme";
import { TEXT_SIZES, applyTextSize, getTextSize, type TextSize } from "../lib/textSize";
import { snapZoom, zoomToPercent, saveRestartZoom, getRestartZoom, type ZoomLevel } from "../lib/dpiScale";
import { DEFAULT_ZOOM, MAX_ZOOM, MIN_ZOOM, ZOOM_STEP, snapZoom, zoomToPercent, saveRestartZoom, getRestartZoom, type ZoomLevel } from "../lib/dpiScale";
import {
applyFontFamily,
applyMonoFontFamily,
@@ -102,6 +102,7 @@ export function SettingsPanel({
const [tab, setTab] = useState<SettingsTab>(initialTab === "providers" ? "models" : initialTab ?? "general");
// Play the modal exit animation, then let the parent unmount us.
const { status, requestClose } = useDeferredClose(onClose, 240);
const zoomSaveSeq = useRef(0);
const reload = useCallback(async () => {
setLoadingSettings(true);
@@ -129,6 +130,24 @@ export function SettingsPanel({
setThemeState(nextTheme);
setThemeStyleState(nextStyle);
}, [s?.desktopTheme, s?.desktopThemeStyle]);
useEffect(() => {
if (desktopPlatform !== "windows") return;
let cancelled = false;
void (async () => {
try {
const persisted = await app.GetDesktopZoomFactor();
if (cancelled || typeof persisted !== "number" || !Number.isFinite(persisted)) return;
const snapped = snapZoom(persisted);
saveRestartZoom(snapped);
setZoomPct(zoomToPercent(snapped));
} catch {
// Older mocks or startup races can lack the binding; keep the local fallback.
}
})();
return () => {
cancelled = true;
};
}, [desktopPlatform]);
// apply runs a mutation, re-reads settings, and refreshes the topbar/model.
const apply = useCallback(async (fn: () => Promise<unknown>) => {
@@ -159,6 +178,21 @@ export function SettingsPanel({
setErr(String((e as Error)?.message ?? e));
}
}, [reload, onChanged]);
const setRestartZoom = useCallback(async (zoom: ZoomLevel) => {
const snapped = snapZoom(zoom);
const seq = ++zoomSaveSeq.current;
setErr(null);
setWarning(null);
setZoomPct(zoomToPercent(snapped));
try {
await app.SetDesktopZoomFactor(snapped);
if (seq === zoomSaveSeq.current) saveRestartZoom(snapped);
} catch (e) {
if (seq !== zoomSaveSeq.current) return;
setErr(String((e as Error)?.message ?? e));
setZoomPct(zoomToPercent(getRestartZoom()));
}
}, []);
// Close on Esc
useEffect(() => {
@@ -248,18 +282,7 @@ export function SettingsPanel({
applyTextSize(size);
setTextSizeState(size);
}}
onRestartZoom={async (zoom) => {
const snapped = snapZoom(zoom);
setErr(null);
setWarning(null);
try {
await app.SetDesktopZoomFactor(snapped);
saveRestartZoom(snapped);
setZoomPct(zoomToPercent(snapped));
} catch (e) {
setErr(String((e as Error)?.message ?? e));
}
}}
onRestartZoom={setRestartZoom}
onFontFamily={(font) => {
applyFontFamily(font);
setFontFamilyState(font);
@@ -4479,7 +4502,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
setGroupModelDraft(group.id, null);
try {
await apply(async () => {
await app.SetProviderKey(apiKeyEnv, value);
await app.SaveProviderKey(apiKeyEnv, value);
try {
const fetched = await app.FetchProviderModels({ ...probe, apiKeyEnv });
if (fetched.length > 0) {
@@ -4585,7 +4608,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
setAdding(null);
}}
onResetPreset={(id) => apply(() => app.ResetProviderPresetAccess(id)).then(() => setAdding(null))}
onAddCustom={(pv) => apply(() => app.SaveProvider(pv)).then(() => setAdding(null))}
onAddCustom={(pv, key) => apply(() => key ? app.SaveProviderWithKey(pv, key) : app.SaveProvider(pv)).then(() => setAdding(null))}
/>
)}
{adding === null && groups.map((group) => (
@@ -4601,7 +4624,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
kinds={s.providerKinds}
onEdit={setEditing}
onCancelEdit={() => setEditing(null)}
onSave={(pv) => apply(() => app.SaveProvider(pv)).then(() => {
onSave={(pv, key) => apply(() => key ? app.SaveProviderWithKey(pv, key) : app.SaveProvider(pv)).then(() => {
setEditing(null);
setGroupModelDraft(group.id, null);
})}
@@ -4705,6 +4728,10 @@ function providerTemplateConflictProviderName(choice: ProviderTemplateChoice): s
function providerPresetDescription(preset: ProviderPresetView, t: ReturnType<typeof useT>): string {
switch (preset.id) {
case "longcat-openai":
return t("settings.addProvider.preset.longcatOpenAIDesc");
case "longcat-anthropic":
return t("settings.addProvider.preset.longcatAnthropicDesc");
case "kimi-cn":
return t("settings.addProvider.preset.kimiCnDesc");
case "kimi-global":
@@ -4811,7 +4838,7 @@ function AddProviderPanel({
onAddPreset: (id: string, key: string) => Promise<void>;
onViewPresetConflict: (providerName: string) => void;
onResetPreset: (id: string) => Promise<void>;
onAddCustom: (p: ProviderView) => void | Promise<void>;
onAddCustom: (p: ProviderView, key?: string) => void | Promise<void>;
}) {
const t = useT();
const templateChoices = useMemo<ProviderTemplateChoice[]>(() => [
@@ -5026,7 +5053,7 @@ function ProviderAccessCard({
kinds: string[];
onEdit: (name: string) => void;
onCancelEdit: () => void;
onSave: (p: ProviderView) => void | Promise<void>;
onSave: (p: ProviderView, key?: string) => void | Promise<void>;
onRefresh: () => void;
onToggleDraftModel: (model: string) => void;
onToggleDraftVision: (model: string) => void;
@@ -5517,7 +5544,7 @@ function ProviderEditor({
kinds: string[];
busy: boolean;
onCancel: () => void;
onSave: (p: ProviderView) => void;
onSave: (p: ProviderView, key?: string) => void | Promise<void>;
onSaveKey?: (apiKeyEnv: string, value: string) => Promise<void>;
onClearKey?: (apiKeyEnv: string) => Promise<void>;
}) {
@@ -5594,7 +5621,7 @@ function ProviderEditor({
try {
const effectiveApiKeyEnv = providerApiKeyEnvForSave(name, apiKeyEnv, keyDraft);
if (!apiKeyEnv.trim()) setApiKeyEnv(effectiveApiKeyEnv);
if (keyDraft.trim()) await app.SetProviderKey(effectiveApiKeyEnv, keyDraft.trim());
if (keyDraft.trim()) await app.SaveProviderKey(effectiveApiKeyEnv, keyDraft.trim());
const fetched = await app.FetchProviderModels({
name: name.trim() || t("settings.newProviderDraftName"),
builtIn: initial?.builtIn ?? false,
@@ -5642,11 +5669,12 @@ function ProviderEditor({
const save = async () => {
if (extraBodyInvalid) return;
setFetchStatus(null);
setFetchFallback(null);
const ms = parseProviderListInput(models);
const vms = parseProviderListInput(visionModels).filter((model) => ms.includes(model));
const effectiveApiKeyEnv = providerApiKeyEnvForSave(name, apiKeyEnv, keyDraft);
if (keyDraft.trim()) await app.SetProviderKey(effectiveApiKeyEnv, keyDraft.trim());
onSave({
const provider: ProviderView = {
name: name.trim(),
builtIn: initial?.builtIn ?? false,
added: initial?.added ?? true,
@@ -5672,7 +5700,12 @@ function ProviderEditor({
// NormalizeEffort would otherwise silently ignore an unsupported value.
defaultEffort: cleanedSupportedEfforts.length > 0 ? cleanDefaultEffort : "",
modelOverrides: initial?.modelOverrides ?? [],
});
};
try {
await onSave(provider, keyDraft.trim() || undefined);
} catch (e) {
setFetchFallback(String((e as Error)?.message ?? e));
}
};
if (builtIn) {
@@ -6582,6 +6615,15 @@ function AppearanceSection({
const themeOptions: Theme[] = ["auto", "light", "dark"];
const availableFontFamilies = useMemo(() => getAvailableFontFamilies(fontFamily), [fontFamily]);
const availableMonoFontFamilies = useMemo(() => getAvailableMonoFontFamilies(monoFontFamily), [monoFontFamily]);
const zoomMinPct = zoomToPercent(MIN_ZOOM);
const zoomMaxPct = zoomToPercent(MAX_ZOOM);
const zoomStepPct = Math.round(ZOOM_STEP * 100);
const zoomProgressPct = Math.min(100, Math.max(0, ((zoomPct - zoomMinPct) / (zoomMaxPct - zoomMinPct)) * 100));
const canDecreaseZoom = zoomPct > zoomMinPct;
const canIncreaseZoom = zoomPct < zoomMaxPct;
const setZoomPercent = (pct: number) => {
void onRestartZoom(pct / 100);
};
return (
<SettingsSection title={t("settings.appearance")}>
<SettingsField label={t("settings.theme")}>
@@ -6647,30 +6689,62 @@ function AppearanceSection({
{showDisplayZoom && (
<SettingsField label={t("settings.displayZoom")}>
<div className="zoom-slider-wrap">
<div className="zoom-slider__value">{zoomPct}%</div>
<div className="zoom-slider__head">
<div className="zoom-slider__value">{zoomPct}%</div>
<div className="zoom-stepper">
<button
type="button"
className="zoom-stepper__btn"
aria-label={t("settings.displayZoomDecrease")}
title={t("settings.displayZoomDecrease")}
disabled={!canDecreaseZoom}
onClick={() => setZoomPercent(zoomPct - zoomStepPct)}
>
<Minus size={13} aria-hidden="true" />
</button>
<button
type="button"
className="zoom-stepper__reset"
aria-label={t("settings.displayZoomReset")}
title={t("settings.displayZoomReset")}
disabled={zoomPct === zoomToPercent(DEFAULT_ZOOM)}
onClick={() => { void onRestartZoom(DEFAULT_ZOOM); }}
>
<RotateCcw size={12} aria-hidden="true" />
<span>100%</span>
</button>
<button
type="button"
className="zoom-stepper__btn"
aria-label={t("settings.displayZoomIncrease")}
title={t("settings.displayZoomIncrease")}
disabled={!canIncreaseZoom}
onClick={() => setZoomPercent(zoomPct + zoomStepPct)}
>
<Plus size={13} aria-hidden="true" />
</button>
</div>
</div>
<div className="zoom-slider-row">
<span className="zoom-slider__label">50%</span>
<span className="zoom-slider__label">{zoomMinPct}%</span>
<div className="slider-track">
<div className="slider-track__bg" />
<div
className="slider-track__fill"
style={{ width: `calc(${((zoomPct - 50) / 150) * 100}% + 15px)` }}
style={{ width: `calc(${zoomProgressPct}% + 15px)` }}
/>
<div className="slider-thumb" style={{ left: `${((zoomPct - 50) / 150) * 100}%` }}>
<div className="slider-thumb__left" />
<div className="slider-thumb__mid" />
<div className="slider-thumb__right" />
</div>
<div className="slider-thumb" style={{ left: `${zoomProgressPct}%` }} />
<input
aria-label={t("settings.displayZoom")}
type="range"
min={50}
max={200}
step={5}
min={zoomMinPct}
max={zoomMaxPct}
step={zoomStepPct}
value={zoomPct}
onChange={(e) => { void onRestartZoom(Number(e.target.value) / 100); }}
onChange={(e) => setZoomPercent(Number(e.target.value))}
/>
</div>
<span className="zoom-slider__label">200%</span>
<span className="zoom-slider__label">{zoomMaxPct}%</span>
</div>
</div>
</SettingsField>
+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;
@@ -193,7 +193,7 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
notifyChannels: false,
createdAt: Date.now(),
});
});
}).catch(() => {});
}
}, [open, startNew]);
@@ -210,18 +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,
notifyChannels: 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) => {
@@ -238,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],
);
+134 -6
View File
@@ -294,12 +294,14 @@ export interface AppBindings {
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>;
@@ -612,7 +614,7 @@ function bridgeBreadcrumb(method: string): string {
return `model ${method}`;
if (/^(SetDesktop|SetCloseBehavior|SetDisplayMode|SetStatusBar|SetExpandThinking|SetAutoPlan|SetDefaultToolApprovalMode|SetMemoryCompilerEnabled|SetReasoningLanguage)/.test(method))
return `settings ${method}`;
if (/^(SaveProvider|AddOfficialProviderAccess|AddProviderPresetAccess|ResetProviderPresetAccess|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))
@@ -717,12 +719,13 @@ function browserPlatformOverride(): "darwin" | "windows" | "linux" | "" {
return value === "darwin" || value === "windows" || value === "linux" ? value : "";
}
function mockScenario(): "demo" | "fresh" | "running" | "guidance" {
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";
}
@@ -766,6 +769,7 @@ function mockPreset(id: string, label: string, description: string, keyEnv: stri
}
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"];
@@ -784,6 +788,8 @@ const mockVercelModels = ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-
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 })),
@@ -826,7 +832,7 @@ const mockProviderPresetTemplates: MockProviderPresetTemplate[] = [
];
function mockProviderPresetViews(): ProviderPresetView[] {
return mockProviderPresetTemplates.map((template) => ({
return [...mockProviderPresetTemplates].sort((a, b) => mockProviderPresetDisplayRank(a.id) - mockProviderPresetDisplayRank(b.id)).map((template) => ({
id: template.id,
label: template.label,
description: template.description,
@@ -842,6 +848,14 @@ function mockProviderPresetViews(): ProviderPresetView[] {
}));
}
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;
@@ -859,6 +873,7 @@ function makeMockApp(): AppBindings {
const freshMock = scenario === "fresh";
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;
@@ -867,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.
@@ -1620,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" },
@@ -1716,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" ||
@@ -1830,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.
@@ -2958,6 +3072,14 @@ 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: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
@@ -3020,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;
@@ -3172,11 +3300,11 @@ function makeMockApp(): AppBindings {
async SetDesktopLayoutStyle(style: string) {
settings.desktopLayoutStyle = style === "workbench" || style === "creation" ? style : "classic";
},
async SetDesktopZoomFactor(_factor: number) {
// no-op in mock; in production this writes desktop-zoom.json via Go
async SetDesktopZoomFactor(factor: number) {
mockDesktopZoomFactor = Math.min(2.0, Math.max(0.5, Number.isFinite(factor) ? factor : 1.0));
},
async GetDesktopZoomFactor() {
return 1.0; // default in mock
return mockDesktopZoomFactor;
},
async RestartApplication() {
// no-op in mock
@@ -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}`;
}
+2
View File
@@ -413,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.
@@ -668,6 +669,7 @@ export interface ServerView {
tools: number;
prompts: number;
resources: number;
hasTools?: boolean;
error?: string;
toolList?: MCPToolView[];
trustedReadOnlyTools?: string[];
+64 -23
View File
@@ -953,6 +953,27 @@ function errorMessage(err: unknown): string {
return String(err || "");
}
export function effortSwitchNoticeText(err: unknown): string {
const msg = errorMessage(err).trim() || "unknown error";
const lower = msg.toLowerCase();
if (lower.includes("finish or cancel") && lower.includes("before changing effort")) {
return t("status.effortSwitchBusy");
}
if (lower.includes("already open in another reasonix window") || lower.includes("session lease held")) {
return t("status.effortSwitchLeaseHeld");
}
if (lower.includes("workspace is still starting")) {
return t("status.effortSwitchStarting");
}
if (lower.startsWith("workspace failed to start")) {
return t("status.effortSwitchStartupFailed", { err: msg });
}
if (lower.includes("changed while switching effort") || (lower.includes("tab ") && lower.includes("not found"))) {
return t("status.effortSwitchRetry");
}
return t("status.effortSwitchFailed", { 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) });
@@ -1073,23 +1094,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" });
@@ -1120,21 +1149,20 @@ 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 loadAncillary = async <T,>(label: string, load: () => Promise<T>): Promise<T | undefined> => {
const startedAt = Date.now();
try {
const value = await load();
addBreadcrumb("tab.hydrate", `ancillary ${label} ${reason} ${tabId} ms=${Date.now() - startedAt}`);
return value;
} catch (err) {
noteFailure(label, err);
return undefined;
}
return loadTimed(`ancillary ${label}`, load);
};
const [effort, jobs, context] = await Promise.all([
loadAncillary("effort", () => app.EffortForTab(tabId)),
@@ -1142,22 +1170,30 @@ export function useController() {
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 (context !== undefined) dispatchTo(tabId, { type: "context", context });
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", `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); });
})();
@@ -1709,7 +1745,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) });
@@ -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]);
}
+48 -9
View File
@@ -532,6 +532,12 @@ export const en = {
"modelSwitcher.noMatches": "No matching models",
"status.noModels": "no switchable models",
"status.modelSwitchFailed": "Model switch failed: {err}",
"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.tokenModeSwitchFailed": "Token saver switch failed: {err}",
"status.effort": "effort {level}",
"status.effortTitle": "Reasoning effort",
@@ -657,6 +663,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",
@@ -732,13 +761,13 @@ export const en = {
"history.trashActions": "Trash actions",
// session recovery
"recovery.branch": "Recovery branch",
"recovery.badge": "Recovery",
"recovery.open": "Open recovery branch",
"recovery.toast": "Unsaved local changes were saved as {title}.",
"recovery.failedLease": "The recovery branch is in use by another runtime, so this tab did not switch to it.",
"recovery.failedUnavailable": "The recovery branch could not be opened, so this tab stayed on the original session.",
"recovery.banner": "This session was recovered from a save conflict. Compare it with the original before continuing.",
"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.reason": "Reason: {reason}",
"recovery.digest": "Digest: {digest}",
"recovery.parent": "Parent: {parent}",
@@ -757,6 +786,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",
@@ -1396,7 +1426,7 @@ 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. Use Permissions or approval mode for deploy-style commands.",
"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",
@@ -1405,7 +1435,7 @@ export const en = {
"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",
@@ -1498,6 +1528,9 @@ export const en = {
"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",
@@ -1558,6 +1591,8 @@ export const en = {
"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.",
@@ -1813,6 +1848,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",
@@ -2015,6 +2051,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",
+48 -9
View File
@@ -386,6 +386,12 @@ export const zhTW: Record<DictKey, string> = {
"modelSwitcher.noMatches": "沒有匹配的模型",
"status.noModels": "沒有可切換模型",
"status.modelSwitchFailed": "模型切換失敗:{err}",
"status.effortSwitchBusy": "暫時不能切換推理力度。請先停止目前回答、處理待確認問題,或等待背景任務結束。",
"status.effortSwitchLeaseHeld": "這個會話正在另一個 Reasonix 視窗或背景執行。請關閉那個視窗、停止背景執行,或開啟副本後再切換推理力度。",
"status.effortSwitchStarting": "會話還在啟動,稍後再切換推理力度。",
"status.effortSwitchStartupFailed": "目前會話啟動失敗,無法切換推理力度:{err}",
"status.effortSwitchRetry": "目前會話已變更,請再試一次切換推理力度。",
"status.effortSwitchFailed": "推理力度切換失敗:{err}",
"status.effort": "effort {level}",
"status.effortTitle": "推理力度",
"status.effortAutoTitle": "推理力度:auto(模型預設:{def})",
@@ -509,6 +515,29 @@ export const zhTW: Record<DictKey, string> = {
"approval.allowRulePersistent": "總是允許(儲存)",
"approval.allowEditsSession": "本會話允許編輯",
"approval.allowFilePersistent": "總是允許此檔案(儲存)",
"approval.allowSandboxEscapeSession": "本會話使用真實環境",
"approval.toolLabelBash": "bash",
"approval.toolLabelEditFile": "編輯檔案",
"approval.toolLabelWriteFile": "寫入檔案",
"approval.toolLabelMultiEdit": "批次編輯",
"approval.toolLabelMoveFile": "移動檔案",
"approval.toolLabelWebFetch": "讀取網頁",
"approval.toolLabelRunSkill": "執行技能",
"approval.toolLabelRemember": "儲存記憶",
"approval.toolLabelForget": "刪除記憶",
"approval.toolLabelSandboxEscape": "bash 沙箱降級核准",
"approval.toolLabelPlanModeReadOnly": "計劃模式唯讀命令",
"approval.memorySaveUpdate": "儲存/更新記憶",
"approval.memoryBodyLabel": "正文",
"approval.memoryArchivePrefix": "封存記憶 ",
"approval.planModeMcpTrustSubject": "將 MCP {target} 作為計劃/研究唯讀工具信任",
"approval.planModeMcpTrustReason": "這個 MCP 工具宣告自己是唯讀的,但外部唯讀提示需要你確認後,計劃模式才能使用。選擇總是允許可為後續計劃和唯讀研究記住這份信任。",
"approval.planModeBashTrustSubject": "在計劃模式中信任 {prefix} 為唯讀命令前綴\n命令:{command}",
"approval.planModeBashTrustReason": "這條 bash 命令不在 Reasonix 內建唯讀集合中。只有在確認這個精確前綴用於計劃和研究時是唯讀的,才應核准。自動/YOLO 核准不能回答這個信任提示。",
"approval.sandboxEscapeSubjectFallback": "僅本次不進沙箱執行 shell 命令",
"approval.sandboxEscapeSubjectPrefix": "僅本次不進沙箱執行:",
"approval.sandboxEscapeWrapReason": "Windows 沙箱無法包裝這條命令。是否僅本次不進 OS 沙箱執行?這只會對此命令繞過 OS 沙箱。",
"approval.sandboxEscapeRuntimeReason": "Windows 沙箱啟動這條命令時失敗。是否僅本次不進 OS 沙箱執行?這只會對此命令繞過 OS 沙箱。",
// 提問卡片
"ask.title": "需要你決定",
@@ -575,13 +604,13 @@ export const zhTW: Record<DictKey, string> = {
"history.trashActions": "回收站操作",
// 會話恢復
"recovery.branch": "恢復分支",
"recovery.badge": "恢復",
"recovery.open": "開啟恢復分支",
"recovery.toast": "未儲存的本機內容已儲存為 {title}。",
"recovery.failedLease": "恢復分支被另一個執行階段占用,未切換過去。",
"recovery.failedUnavailable": "無法開啟恢復分支,目前標籤仍停留在原會話。",
"recovery.banner": "目前會話來自儲存衝突恢復。繼續前建議與原會話對比。",
"recovery.branch": "衝突副本",
"recovery.badge": "副本",
"recovery.open": "開啟衝突副本",
"recovery.toast": "偵測到儲存衝突,已為 {title} 保留一份衝突副本。",
"recovery.failedLease": "衝突副本被另一個執行階段占用,未切換過去。",
"recovery.failedUnavailable": "無法開啟衝突副本,目前標籤仍停留在原會話。",
"recovery.banner": "目前會話是儲存衝突副本。繼續前建議與原會話對比。",
"recovery.reason": "原因:{reason}",
"recovery.digest": "摘要:{digest}",
"recovery.parent": "父會話:{parent}",
@@ -885,11 +914,11 @@ export const zhTW: Record<DictKey, string> = {
"settings.yoloHint": "所有工具呼叫(寫檔案、bash)自動執行、不再詢問。deny 規則仍會攔截。僅執行時生效,重啟後失效。",
"settings.addRule": "新增 {list} 規則…",
"settings.sandboxTitle": "沙箱與工作區",
"settings.sandboxBoundaryHint": "沙箱可寫根只限制已核准的 Bash 和檔案工具能寫到哪裡;它不會自動核准命令。mvn deploy 這類命令還需要權限規則或核准模式放行。",
"settings.sandboxBoundaryHint": "沙箱可寫根只限制已核准的 Bash 和檔案工具能寫到哪裡;它不會自動核准命令。若 Bash 沙箱為 enforce 但宿主機沒有 OS 沙箱後端,shell 命令會被拒絕;只在可信工作區把 Bash 沙箱切到 off。",
"settings.reloadSessionConfig": "套用手動設定修改",
"settings.reloadSessionConfigHint": "設定頁內修改會自動生效;此按鈕用於套用手動編輯 config.toml / reasonix.toml 後的變更。",
"settings.bashSandbox": "Bash 沙箱",
"settings.bashEnforce": "enforce(隔離 bash)",
"settings.bashEnforce": "enforce(隔離 bash;失敗即拒絕)",
"settings.bashOff": "off(不受限執行)",
"settings.allowNetwork": "允許沙箱內 bash 存取網路",
"settings.workspaceRoot": "工作區根目錄",
@@ -913,6 +942,9 @@ export const zhTW: Record<DictKey, string> = {
"settings.textSizeXLarge": "特大",
"settings.textSizeXXLarge": "超大",
"settings.displayZoom": "顯示縮放(需重啟生效)",
"settings.displayZoomDecrease": "縮小顯示縮放",
"settings.displayZoomIncrease": "放大顯示縮放",
"settings.displayZoomReset": "恢復顯示縮放到 100%",
"settings.fontFamily": "介面字型",
"settings.fontFamilySystem": "系統預設",
"settings.fontFamilyYaHei": "微軟正黑體",
@@ -967,6 +999,8 @@ export const zhTW: Record<DictKey, string> = {
"settings.addProvider.customHint": "適合代理、聚合平台或其它 OpenAI-compatible 服務。",
"settings.addProvider.official.deepseek": "DeepSeek 官方",
"settings.addProvider.official.deepseekDesc": "官方 API Key 接入,可用 DeepSeek V4 Flash / Pro。",
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat 開放平台 OpenAI-compatible 端點,預設 LongCat-2.0。",
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat 開放平台 Anthropic-compatible Messages 端點,使用 Bearer 認證。",
"settings.addProvider.preset.kimiCnDesc": "Kimi 中國 API,走 Moonshot CN 端點,預置 K2 視覺模型。",
"settings.addProvider.preset.kimiGlobalDesc": "Kimi 國際 API,走 Moonshot global 端點,預置 K2 視覺模型。",
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan,走獨立 Anthropic-compatible 端點,不歸屬 CN/Global。",
@@ -1159,6 +1193,7 @@ export const zhTW: Record<DictKey, string> = {
"rewind.disabledNoBoundary": "這條訊息的會話邊界不可用",
"rewind.disabledNoCode": "這條訊息之後沒有捕獲到程式碼變更",
"rewind.disabledNoEarlier": "這條訊息之前沒有可總結內容",
"rewind.disabledNoLater": "這條訊息之後沒有可總結內容",
"rewind.filesChanged": "{count} 個檔案",
"rewind.turnFiles": "本輪 {count} 個",
"rewind.both": "回滾程式碼和對話",
@@ -1305,6 +1340,9 @@ export const zhTW: Record<DictKey, string> = {
"mock.topicAi": "AI 工程化最佳實踐",
"mock.topicLab": "臨時探索與實驗",
"mock.approvalSubject": "npm run build\n\n需要執行建構命令來驗證前端產物和樣式打包是否正常。",
"mock.sandboxEscapeReasoning": "Windows 沙箱 helper 失敗,需要確認是否僅本次不進沙箱重跑。",
"mock.sandboxEscapeSubject": "僅本次不進沙箱執行:go test ./...",
"mock.sandboxEscapeReason": "Windows 沙箱啟動這條命令時失敗。是否僅本次不進 OS 沙箱執行?這只會對此命令繞過 OS 沙箱。",
"mock.askQ1Header": "處理方向",
"mock.askQ1Prompt": "git pull 的衝突你想怎麼處理?",
"mock.askQ1Opt1Label": "git stash 後 pull",
@@ -1486,6 +1524,7 @@ export const zhTW: Record<DictKey, string> = {
"clearContext.done": "已清空當前上下文",
"clearContext.failed": "清空當前上下文失敗",
"projectTree.justNow": "剛剛",
"projectTree.previously": "之前",
"projectTree.running": "輸出中",
"projectTree.status.thinking": "思考中",
"projectTree.status.streaming": "輸出中",
+48 -9
View File
@@ -533,6 +533,12 @@ export const zh: Record<DictKey, string> = {
"modelSwitcher.noMatches": "没有匹配的模型",
"status.noModels": "没有可切换模型",
"status.modelSwitchFailed": "模型切换失败:{err}",
"status.effortSwitchBusy": "暂时不能切换推理力度。请先停止当前回答、处理待确认问题,或等待后台任务结束。",
"status.effortSwitchLeaseHeld": "这个会话正在另一个 Reasonix 窗口或后台运行。请关闭那个窗口、停止后台运行,或打开副本后再切换推理力度。",
"status.effortSwitchStarting": "会话还在启动,稍后再切换推理力度。",
"status.effortSwitchStartupFailed": "当前会话启动失败,无法切换推理力度:{err}",
"status.effortSwitchRetry": "当前会话已变化,请再试一次切换推理力度。",
"status.effortSwitchFailed": "推理力度切换失败:{err}",
"status.tokenModeSwitchFailed": "省 token 模式切换失败:{err}",
"status.effort": "effort {level}",
"status.effortTitle": "推理力度",
@@ -658,6 +664,29 @@ export const zh: Record<DictKey, string> = {
"approval.allowRulePersistent": "总是允许(保存)",
"approval.allowEditsSession": "本会话允许编辑",
"approval.allowFilePersistent": "总是允许此文件(保存)",
"approval.allowSandboxEscapeSession": "本会话使用真实环境",
"approval.toolLabelBash": "bash",
"approval.toolLabelEditFile": "编辑文件",
"approval.toolLabelWriteFile": "写入文件",
"approval.toolLabelMultiEdit": "批量编辑",
"approval.toolLabelMoveFile": "移动文件",
"approval.toolLabelWebFetch": "读取网页",
"approval.toolLabelRunSkill": "运行技能",
"approval.toolLabelRemember": "保存记忆",
"approval.toolLabelForget": "删除记忆",
"approval.toolLabelSandboxEscape": "bash 沙箱降级审批",
"approval.toolLabelPlanModeReadOnly": "计划模式只读命令",
"approval.memorySaveUpdate": "保存/更新记忆",
"approval.memoryBodyLabel": "正文",
"approval.memoryArchivePrefix": "归档记忆 ",
"approval.planModeMcpTrustSubject": "将 MCP {target} 作为计划/研究只读工具信任",
"approval.planModeMcpTrustReason": "这个 MCP 工具声明自己是只读的,但外部只读提示需要你确认后,计划模式才能使用。选择总是允许可为后续计划和只读研究记住这份信任。",
"approval.planModeBashTrustSubject": "在计划模式中信任 {prefix} 为只读命令前缀\n命令:{command}",
"approval.planModeBashTrustReason": "这条 bash 命令不在 Reasonix 内置只读集合中。只有在确认这个精确前缀用于计划和研究时是只读的,才应批准。自动/YOLO 审批不能回答这个信任提示。",
"approval.sandboxEscapeSubjectFallback": "仅本次不进沙箱运行 shell 命令",
"approval.sandboxEscapeSubjectPrefix": "仅本次不进沙箱运行:",
"approval.sandboxEscapeWrapReason": "Windows 沙箱无法包装这条命令。是否仅本次不进 OS 沙箱运行?这会只对此命令绕过 OS 沙箱。",
"approval.sandboxEscapeRuntimeReason": "Windows 沙箱启动这条命令时失败。是否仅本次不进 OS 沙箱运行?这会只对此命令绕过 OS 沙箱。",
// 提问卡片
"ask.title": "需要你决定",
@@ -733,13 +762,13 @@ export const zh: Record<DictKey, string> = {
"history.trashActions": "回收站操作",
// 会话恢复
"recovery.branch": "恢复分支",
"recovery.badge": "恢复",
"recovery.open": "打开恢复分支",
"recovery.toast": "未保存的本地内容已保存为 {title}。",
"recovery.failedLease": "恢复分支被另一个运行时占用,未切换过去。",
"recovery.failedUnavailable": "无法打开恢复分支,当前标签仍停留在原会话。",
"recovery.banner": "当前会话来自保存冲突恢复。继续前建议与原会话对比。",
"recovery.branch": "冲突副本",
"recovery.badge": "副本",
"recovery.open": "打开冲突副本",
"recovery.toast": "检测到保存冲突,已为 {title} 保留一份冲突副本。",
"recovery.failedLease": "冲突副本被另一个运行时占用,未切换过去。",
"recovery.failedUnavailable": "无法打开冲突副本,当前标签仍停留在原会话。",
"recovery.banner": "当前会话是保存冲突副本。继续前建议与原会话对比。",
"recovery.reason": "原因:{reason}",
"recovery.digest": "摘要:{digest}",
"recovery.parent": "父会话:{parent}",
@@ -758,6 +787,7 @@ export const zh: Record<DictKey, string> = {
"projectTree.renameProjectWorkbench": "重命名项目",
"projectTree.projectHistory": "查看项目历史",
"projectTree.justNow": "刚刚",
"projectTree.previously": "之前",
"projectTree.running": "输出中",
"projectTree.status.thinking": "思考中",
"projectTree.status.streaming": "输出中",
@@ -1398,7 +1428,7 @@ export const zh: Record<DictKey, string> = {
"settings.yoloHint": "Yolo批准会跳过所有工具权限审批(写文件、bash)。deny 规则仍会拦截,ask 问题和计划确认仍会等待你选择。仅运行时生效,重启后失效。",
"settings.addRule": "添加 {list} 规则…",
"settings.sandboxTitle": "沙箱与工作区",
"settings.sandboxBoundaryHint": "沙箱可写根只限制已批准的 Bash 和文件工具能写到哪里;它不会自动批准命令。mvn deploy 这类命令还需要权限规则或审批模式放行。",
"settings.sandboxBoundaryHint": "沙箱可写根只限制已批准的 Bash 和文件工具能写到哪里;它不会自动批准命令。若 Bash 沙箱为 enforce 但宿主机没有 OS 沙箱后端,shell 命令会被拒绝;只在可信工作区把 Bash 沙箱切到 off。",
"settings.reloadSessionConfig": "应用手动配置修改",
"settings.reloadSessionConfigHint": "设置页内修改会自动生效;此按钮用于应用手动编辑 config.toml / reasonix.toml 后的变更。",
"settings.shellInterpreter": "Shell 解释器",
@@ -1407,7 +1437,7 @@ export const zh: Record<DictKey, string> = {
"settings.shellPowershell": "PowerShell(Windows PowerShell)",
"settings.shellPwsh": "pwsh(PowerShell 7+)",
"settings.bashSandbox": "Bash 沙箱",
"settings.bashEnforce": "enforce(隔离 bash)",
"settings.bashEnforce": "enforce(隔离 bash;失败即拒绝)",
"settings.bashOff": "off(不受限运行)",
"settings.allowNetwork": "允许沙箱内 bash 访问网络",
"settings.workspaceRoot": "工作区根目录",
@@ -1500,6 +1530,9 @@ export const zh: Record<DictKey, string> = {
"settings.textSizeXLarge": "特大",
"settings.textSizeXXLarge": "超大",
"settings.displayZoom": "显示缩放(需重启生效)",
"settings.displayZoomDecrease": "缩小显示缩放",
"settings.displayZoomIncrease": "放大显示缩放",
"settings.displayZoomReset": "恢复显示缩放到 100%",
"settings.fontFamily": "界面字体",
"settings.fontFamilySystem": "系统默认",
"settings.fontFamilyYaHei": "微软雅黑",
@@ -1560,6 +1593,8 @@ export const zh: Record<DictKey, string> = {
"settings.addProvider.customHint": "适合代理、聚合平台或其它 OpenAI-compatible 服务。",
"settings.addProvider.official.deepseek": "DeepSeek 官方",
"settings.addProvider.official.deepseekDesc": "官方 API Key 接入,可用 DeepSeek V4 Flash / Pro。",
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat 开放平台 OpenAI-compatible 端点,默认 LongCat-2.0。",
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat 开放平台 Anthropic-compatible Messages 端点,使用 Bearer 认证。",
"settings.addProvider.preset.kimiCnDesc": "Kimi 国内 API,走 Moonshot CN 端点,预置 K2 视觉模型。",
"settings.addProvider.preset.kimiGlobalDesc": "Kimi 国际 API,走 Moonshot global 端点,预置 K2 视觉模型。",
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan,走独立 Anthropic-compatible 端点,不归属 CN/Global。",
@@ -1815,6 +1850,7 @@ export const zh: Record<DictKey, string> = {
"rewind.disabledNoBoundary": "这条消息的会话边界不可用",
"rewind.disabledNoCode": "这条消息之后没有捕获到代码改动",
"rewind.disabledNoEarlier": "这条消息之前没有可总结内容",
"rewind.disabledNoLater": "这条消息之后没有可总结内容",
"rewind.filesChanged": "{count} 个文件",
"rewind.turnFiles": "本轮 {count} 个",
"rewind.both": "回滚代码和对话",
@@ -2017,6 +2053,9 @@ export const zh: Record<DictKey, string> = {
"mock.topicAi": "AI 工程化最佳实践",
"mock.topicLab": "临时探索与实验",
"mock.approvalSubject": "npm run build\n\n需要运行构建命令来验证前端产物和样式打包是否正常。",
"mock.sandboxEscapeReasoning": "Windows 沙箱 helper 失败,需要确认是否仅本次不进沙箱重跑。",
"mock.sandboxEscapeSubject": "仅本次不进沙箱运行:go test ./...",
"mock.sandboxEscapeReason": "Windows 沙箱启动这条命令时失败。是否仅本次不进 OS 沙箱运行?这会只对此命令绕过 OS 沙箱。",
"mock.askQ1Header": "处理方向",
"mock.askQ1Prompt": "git pull 的冲突你想怎么处理?",
"mock.askQ1Opt1Label": "git stash 后 pull",
+83 -54
View File
@@ -5207,11 +5207,22 @@ body > .mermaid-diagram--fullscreen {
min-width: 0;
}
.tool__nested-count {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 34px;
font-family: var(--mono);
font-size: 11px;
color: var(--accent);
color: color-mix(in srgb, var(--fg-dim) 86%, var(--fg));
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.tool__nested-icon {
width: 14px;
height: 14px;
color: inherit;
flex: 0 0 auto;
}
.tool__status-icon {
font-size: 13px;
line-height: 1;
@@ -14257,10 +14268,18 @@ body > .mermaid-diagram--fullscreen {
.zoom-slider-wrap {
display: flex;
flex-direction: column;
gap: 6px;
gap: 8px;
width: 100%;
}
.zoom-slider__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 26px;
}
.zoom-slider-row {
display: flex;
align-items: center;
@@ -14275,14 +14294,67 @@ body > .mermaid-diagram--fullscreen {
flex-shrink: 0;
}
/* 百分比值 — 居中在轨道上方 */
/* 百分比值,与右侧步进控件同排显示 */
.zoom-slider__value {
font-size: var(--font-control-small);
font-weight: 700;
color: var(--accent);
line-height: 1;
text-align: center;
width: 100%;
text-align: left;
min-width: 42px;
}
.zoom-stepper {
display: inline-flex;
align-items: center;
flex: none;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg);
}
.zoom-stepper__btn,
.zoom-stepper__reset {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 25px;
border: 0;
border-right: 1px solid var(--border);
background: transparent;
color: var(--fg-dim);
font: inherit;
font-size: var(--font-control-small);
line-height: 1;
}
.zoom-stepper__btn {
width: 28px;
}
.zoom-stepper__reset {
min-width: 58px;
padding: 0 8px;
font-weight: 650;
}
.zoom-stepper__btn:last-child {
border-right: 0;
}
.zoom-stepper__btn:not(:disabled):hover,
.zoom-stepper__reset:not(:disabled):hover {
background: var(--accent-soft);
color: var(--accent);
}
.zoom-stepper__btn:disabled,
.zoom-stepper__reset:disabled {
color: var(--fg-faint);
opacity: .55;
cursor: default;
}
/* 让 label 在 settings-field 网格中靠下对齐 */
@@ -14329,40 +14401,17 @@ body > .mermaid-diagram--fullscreen {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 28px;
height: 18px;
width: 26px;
height: 16px;
pointer-events: none;
z-index: var(--z-local-raised);
box-shadow: 0 0 0 1px var(--accent);
background: #fff;
border: 1px solid var(--accent);
border-radius: 10px;
box-sizing: border-box;
}
.slider-thumb__left {
position: absolute;
left: 0;
top: 0;
width: 9px;
height: 18px;
background: #fff;
border-radius: 9px 0 0 9px;
}
.slider-thumb__mid {
position: absolute;
left: 9px;
top: 0;
width: 10px;
height: 18px;
background: #fff;
}
.slider-thumb__right {
position: absolute;
right: 0;
top: 0;
width: 9px;
height: 18px;
background: #fff;
border-radius: 0 9px 9px 0;
}
/* slider-thumb is now a single element; child segments were removed. */
/* 透明 input range — 事件捕获 */
.slider-track input[type="range"] {
@@ -20475,26 +20524,6 @@ body > .mermaid-diagram--fullscreen {
color: color-mix(in srgb, var(--err) 84%, var(--fg));
}
.project-tree__topic--recovered {
color: color-mix(in srgb, var(--warn) 18%, var(--fg-dim));
}
.project-tree__topic-recovery {
flex: 0 0 auto;
height: 17px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 5px;
border-radius: 5px;
background: color-mix(in srgb, var(--warn) 12%, transparent);
color: color-mix(in srgb, var(--warn) 82%, var(--fg));
font-size: 10.5px;
font-weight: 650;
line-height: 1;
white-space: nowrap;
}
.project-tree__topic-meta {
max-width: 100%;
min-width: 0;
+1
View File
@@ -25,6 +25,7 @@ require (
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/SivanCola/windows-sandbox v0.0.0-20260705154605-6b29dd09f9cb // indirect
github.com/aymanbagabas/go-udiff v0.4.1 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
+2
View File
@@ -7,6 +7,8 @@ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/SivanCola/windows-sandbox v0.0.0-20260705154605-6b29dd09f9cb h1:au4YwGV3d5zAB6CcboeNocRLwFoE7XGxnx2RYI78iv4=
github.com/SivanCola/windows-sandbox v0.0.0-20260705154605-6b29dd09f9cb/go.mod h1:qL+ewlVGGeJLO97u+7E4K20gLu9oST/lc8FaGo2JJSw=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
+172
View File
@@ -0,0 +1,172 @@
package main
import (
"context"
"fmt"
"log/slog"
"sync/atomic"
"time"
)
const (
mainThreadHeartbeatInterval = time.Second
mainThreadHangThreshold = 12 * time.Second
mainThreadHangCheckInterval = 2 * time.Second
mainThreadSleepSkip = 30 * time.Second
)
var (
mainThreadClockBase = time.Now()
mainThreadLastHeartbeatElapsed atomic.Int64
mainThreadLastHeartbeatWall atomic.Int64
mainThreadHangReported atomic.Bool
)
func recordMainThreadHeartbeat(t time.Time) {
elapsed := t.Sub(mainThreadClockBase)
if elapsed < 0 {
elapsed = 0
}
mainThreadLastHeartbeatElapsed.Store(int64(elapsed))
mainThreadLastHeartbeatWall.Store(t.UnixNano())
}
func mainThreadHeartbeatAge(now time.Time) (time.Duration, time.Time, bool) {
lastElapsed := time.Duration(mainThreadLastHeartbeatElapsed.Load())
lastWall := mainThreadLastHeartbeatWall.Load()
if lastWall <= 0 {
return 0, time.Time{}, false
}
age := now.Sub(mainThreadClockBase) - lastElapsed
if age < 0 {
age = 0
}
return age, time.Unix(0, lastWall), true
}
func (a *App) startMainThreadWatchdog() {
if !mainThreadWatchdogSupported() {
return
}
a.hangWatchdogMu.Lock()
if a.hangWatchdogCancel != nil {
a.hangWatchdogMu.Unlock()
return
}
ctx, cancel := context.WithCancel(context.Background())
a.hangWatchdogCancel = cancel
mainThreadHangReported.Store(false)
recordMainThreadHeartbeat(time.Now())
startNativeMainThreadHeartbeat(uint64(mainThreadHeartbeatInterval / time.Millisecond))
a.hangWatchdogMu.Unlock()
a.goSafe("mainThreadHangWatchdog", func() {
a.watchMainThreadHeartbeat(ctx)
})
}
func (a *App) stopMainThreadWatchdog() {
if !mainThreadWatchdogSupported() {
return
}
a.hangWatchdogMu.Lock()
cancel := a.hangWatchdogCancel
a.hangWatchdogCancel = nil
a.hangWatchdogMu.Unlock()
if cancel != nil {
cancel()
}
stopNativeMainThreadHeartbeat()
}
func (a *App) watchMainThreadHeartbeat(ctx context.Context) {
ticker := time.NewTicker(mainThreadHangCheckInterval)
defer ticker.Stop()
lastCheck := time.Now()
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
if now.Sub(lastCheck) > mainThreadSleepSkip {
lastCheck = now
continue
}
lastCheck = now
age, last, ok := mainThreadHeartbeatAge(now)
if !ok {
continue
}
if age < mainThreadHangThreshold {
continue
}
if mainThreadHangReported.CompareAndSwap(false, true) {
a.recordMainThreadHang(age, last, now)
}
}
}
}
func (a *App) recordMainThreadHang(age time.Duration, lastHeartbeat, observedAt time.Time) {
report := mainThreadHangReport(age, lastHeartbeat, observedAt)
wrote := writePendingReport(report, false)
if m := a.metrics.Load(); m != nil {
m.inc("desktop_hang", "main_thread")
m.inc("desktop_hang_age", hangAgeBucket(age))
m.persist()
}
slog.Warn("desktop: mac main thread heartbeat stalled",
"age", age.Round(time.Millisecond).String(),
"lastHeartbeat", lastHeartbeat.Format(time.RFC3339),
"pendingReport", wrote,
)
}
func mainThreadHangReport(age time.Duration, lastHeartbeat, observedAt time.Time) crashReport {
age = age.Round(time.Second)
message := fmt.Sprintf(`[mac.main_thread.hang]
Reasonix detected that the macOS main-thread heartbeat stopped for %s.
--- watchdog context ---
last heartbeat: %s
observed at: %s
threshold: %s
bucket: %s
--- native runtime context ---
%s`,
age,
lastHeartbeat.UTC().Format(time.RFC3339),
observedAt.UTC().Format(time.RFC3339),
mainThreadHangThreshold,
hangAgeBucket(age),
nativeResourceContext(),
)
report := baseCrashReport("performance")
report.SchemaVersion = 2
report.Source = "native.watchdog"
report.Label = "mac.main_thread.hang"
report.ErrorType = "MacMainThreadHang"
report.ErrorMessage = sanitizeCrashText("macOS main thread heartbeat stopped; AppKit/Wails run loop may be blocked.", maxCrashFieldBytes)
report.TopFrame = "mac.main_thread.heartbeat"
report.OccurredAt = observedAt.UTC().Format(time.RFC3339)
report.Message = sanitizeCrashText(message, maxCrashDetailBytes)
return report
}
func hangAgeBucket(age time.Duration) string {
seconds := age.Seconds()
switch {
case seconds < 15:
return "s_10_15"
case seconds < 30:
return "s_15_30"
case seconds < 60:
return "s_30_60"
case seconds < 300:
return "m_1_5"
default:
return "m_5_plus"
}
}
+55
View File
@@ -0,0 +1,55 @@
//go:build darwin
package main
/*
#include <stdint.h>
#include <dispatch/dispatch.h>
extern void reasonixDesktopMainHeartbeat(void);
static dispatch_source_t reasonix_main_heartbeat_timer;
static void reasonix_main_heartbeat_handler(void *ctx) {
reasonixDesktopMainHeartbeat();
}
static void reasonix_start_main_heartbeat(uint64_t interval_ms) {
if (reasonix_main_heartbeat_timer != NULL) {
return;
}
reasonix_main_heartbeat_timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue());
dispatch_set_context(reasonix_main_heartbeat_timer, NULL);
dispatch_source_set_event_handler_f(reasonix_main_heartbeat_timer, reasonix_main_heartbeat_handler);
dispatch_source_set_timer(reasonix_main_heartbeat_timer, dispatch_time(DISPATCH_TIME_NOW, 0), interval_ms * NSEC_PER_MSEC, 100 * NSEC_PER_MSEC);
dispatch_resume(reasonix_main_heartbeat_timer);
}
static void reasonix_stop_main_heartbeat(void) {
if (reasonix_main_heartbeat_timer == NULL) {
return;
}
dispatch_source_cancel(reasonix_main_heartbeat_timer);
reasonix_main_heartbeat_timer = NULL;
}
*/
import "C"
import "time"
func mainThreadWatchdogSupported() bool {
return true
}
func startNativeMainThreadHeartbeat(intervalMS uint64) {
C.reasonix_start_main_heartbeat(C.uint64_t(intervalMS))
}
func stopNativeMainThreadHeartbeat() {
C.reasonix_stop_main_heartbeat()
}
//export reasonixDesktopMainHeartbeat
func reasonixDesktopMainHeartbeat() {
recordMainThreadHeartbeat(time.Now())
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !darwin
package main
func mainThreadWatchdogSupported() bool {
return false
}
func startNativeMainThreadHeartbeat(uint64) {}
func stopNativeMainThreadHeartbeat() {}
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/config"
)
func TestHangAgeBucket(t *testing.T) {
cases := []struct {
age time.Duration
want string
}{
{12 * time.Second, "s_10_15"},
{16 * time.Second, "s_15_30"},
{45 * time.Second, "s_30_60"},
{2 * time.Minute, "m_1_5"},
{10 * time.Minute, "m_5_plus"},
}
for _, c := range cases {
if got := hangAgeBucket(c.age); got != c.want {
t.Errorf("hangAgeBucket(%s) = %q, want %q", c.age, got, c.want)
}
}
}
func TestMainThreadHangReportIsStructuredPerformanceReport(t *testing.T) {
last := time.Date(2026, 7, 4, 9, 19, 11, 0, time.UTC)
observed := last.Add(16 * time.Second)
r := mainThreadHangReport(16*time.Second, last, observed)
if r.Kind != "performance" || r.Source != "native.watchdog" || r.Label != "mac.main_thread.hang" {
t.Fatalf("unexpected report identity: %+v", r)
}
if r.SchemaVersion != 2 || r.ErrorType != "MacMainThreadHang" || r.TopFrame == "" || r.OccurredAt == "" {
t.Fatalf("structured fields missing: %+v", r)
}
for _, want := range []string{"Reasonix detected", "last heartbeat:", "bucket: s_15_30", "goroutines:"} {
if !strings.Contains(r.Message, want) {
t.Fatalf("hang report message missing %q:\n%s", want, r.Message)
}
}
}
func TestMainThreadHeartbeatAgeIgnoresWallClockJump(t *testing.T) {
oldBase := mainThreadClockBase
oldElapsed := mainThreadLastHeartbeatElapsed.Load()
oldWall := mainThreadLastHeartbeatWall.Load()
t.Cleanup(func() {
mainThreadClockBase = oldBase
mainThreadLastHeartbeatElapsed.Store(oldElapsed)
mainThreadLastHeartbeatWall.Store(oldWall)
})
base := time.Now()
mainThreadClockBase = base
mainThreadLastHeartbeatElapsed.Store(int64(time.Second))
mainThreadLastHeartbeatWall.Store(base.Add(-time.Hour).UnixNano())
age, _, ok := mainThreadHeartbeatAge(base.Add(2 * time.Second))
if !ok {
t.Fatal("expected heartbeat age")
}
if age != time.Second {
t.Fatalf("age = %s, want monotonic elapsed 1s despite wall-clock jump", age)
}
}
func TestRecordMainThreadHangWritesPendingReportAndMetrics(t *testing.T) {
t.Cleanup(func() {
os.Remove(pendingCrashPath())
os.Remove(filepath.Join(config.MemoryUserDir(), metricsPendingFile))
})
app := NewApp()
app.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))
last := time.Now().Add(-20 * time.Second)
app.recordMainThreadHang(20*time.Second, last, time.Now())
r, ok := readPending(t)
if !ok {
t.Fatal("expected pending hang report")
}
if r.Kind != "performance" || r.Source != "native.watchdog" || r.Label != "mac.main_thread.hang" {
t.Fatalf("pending report = %+v", r)
}
c := readCounters(filepath.Join(config.MemoryUserDir(), metricsPendingFile))
if got := c["desktop_hang"]["main_thread"]; got != 1 {
t.Fatalf("desktop_hang/main_thread = %d, want 1", got)
}
if got := c["desktop_hang_age"]["s_15_30"]; got != 1 {
t.Fatalf("desktop_hang_age/s_15_30 = %d, want 1", got)
}
}
+17 -2
View File
@@ -136,6 +136,9 @@ func (a *App) InstallPlugin(source string, opts PluginInstallOptions) (string, e
}
a.invalidateSkillRootsCache()
if rebuildErr := a.rebuild(); rebuildErr != nil {
if _, ok := a.deferredRebuildWarning("plugins", rebuildErr); ok {
return out, nil
}
return out, rebuildErr
}
return out, nil
@@ -161,7 +164,13 @@ func (a *App) RemovePlugin(name string) error {
return err
}
a.invalidateSkillRootsCache()
return a.rebuild()
if err := a.rebuild(); err != nil {
if _, ok := a.deferredRebuildWarning("plugins", err); ok {
return nil
}
return err
}
return nil
}
func (a *App) SetPluginEnabled(name string, enabled bool) error {
@@ -172,7 +181,13 @@ func (a *App) SetPluginEnabled(name string, enabled bool) error {
return err
}
a.invalidateSkillRootsCache()
return a.rebuild()
if err := a.rebuild(); err != nil {
if _, ok := a.deferredRebuildWarning("plugins", err); ok {
return nil
}
return err
}
return nil
}
func (a *App) UpdatePlugin(name string) (string, error) {
+480
View File
@@ -0,0 +1,480 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/event"
)
// TestSessionLeaseHelpersConcurrentAccess hammers the sessionLeaseMu helpers
// (ensure/take/adopt/release/key) from concurrent goroutines. Run with -race:
// any residual raw access to tab.sessionLease shows up as a data race, and the
// final acquire asserts no lease leaked through an interleaving.
func TestSessionLeaseHelpersConcurrentAccess(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := filepath.Join(dir, "lease-helper-hammer.jsonl")
key := sessionRuntimeKey(path)
tabA := &WorkspaceTab{ID: "a"}
tabB := &WorkspaceTab{ID: "b"}
const iterations = 300
var wg sync.WaitGroup
wg.Add(3)
go func() {
defer wg.Done()
for i := 0; i < iterations; i++ {
_ = tabA.ensureSessionLease(path)
_ = tabA.sessionLeaseRuntimeKey()
}
}()
go func() {
defer wg.Done()
for i := 0; i < iterations; i++ {
// The applyRuntimeTab transfer shape: move A's lease to B and back.
tabB.adoptSessionLease(tabA.takeSessionLease())
tabA.adoptSessionLease(tabB.takeSessionLease())
}
}()
go func() {
defer wg.Done()
for i := 0; i < iterations; i++ {
tabA.releaseSessionLease()
}
}()
wg.Wait()
tabA.releaseSessionLease()
tabB.releaseSessionLease()
lease, err := agent.TryAcquireSessionLease(key)
if err != nil {
t.Fatalf("lease leaked through concurrent helper interleavings: %v", err)
}
lease.Release()
}
// TestDetachRuntimeForReplacementTransfersLease asserts the detach clone takes
// lease ownership through the locked helpers and the visible tab keeps none.
func TestDetachRuntimeForReplacementTransfersLease(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := filepath.Join(dir, "detach-transfer.jsonl")
key := sessionRuntimeKey(path)
tab := &WorkspaceTab{ID: "tab", Scope: "global", SessionPath: path}
app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
if err := tab.ensureSessionLease(path); err != nil {
t.Fatalf("ensureSessionLease: %v", err)
}
t.Cleanup(func() {
tab.releaseSessionLease()
for _, d := range app.detachedSessions {
d.releaseSessionLease()
}
})
if !app.detachRuntimeForReplacement(tab) {
t.Fatal("detachRuntimeForReplacement failed for a live tab")
}
detached := app.detachedSessions[key]
if detached == nil {
t.Fatal("detached runtime was not registered")
}
if got := detached.sessionLeaseRuntimeKey(); got != key {
t.Fatalf("detached clone lease key = %q, want %q", got, key)
}
if got := tab.sessionLeaseRuntimeKey(); got != "" {
t.Fatalf("visible tab still holds lease key %q after detach", got)
}
}
// TestDetachRuntimeForReplacementSkipsRemovedTab: a tab that DeleteSession /
// CloseTab already unlinked must not be re-published into detachedSessions
// (the "session resurrects" class, #4384).
func TestDetachRuntimeForReplacementSkipsRemovedTab(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := filepath.Join(dir, "detach-removed.jsonl")
removed := &WorkspaceTab{ID: "removed", SessionPath: path, removed: true}
app := &App{tabs: map[string]*WorkspaceTab{"removed": removed}}
if app.detachRuntimeForReplacement(removed) {
t.Fatal("removed tab was detached into the background registry")
}
if len(app.detachedSessions) != 0 {
t.Fatal("removed tab left an entry in detachedSessions")
}
orphan := &WorkspaceTab{ID: "orphan", SessionPath: path}
if app.detachRuntimeForReplacement(orphan) {
t.Fatal("tab absent from a.tabs was detached into the background registry")
}
if len(app.detachedSessions) != 0 {
t.Fatal("orphan tab left an entry in detachedSessions")
}
}
// TestTabEventSinkEmitConcurrentRebind: Emit keeps running on the controller
// goroutine while detach/reattach rebinds the sink's tab routing. Run with
// -race; before setBinding existed the tabID write raced every Emit.
func TestTabEventSinkEmitConcurrentRebind(t *testing.T) {
sink := &tabEventSink{tabID: "before"}
const iterations = 500
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for i := 0; i < iterations; i++ {
sink.Emit(event.Event{Kind: event.Notice, Text: "hammer"})
}
}()
go func() {
defer wg.Done()
for i := 0; i < iterations; i++ {
sink.setBinding(fmt.Sprintf("tab-%d", i%2), nil)
sink.clearContext()
}
}()
wg.Wait()
if tabID, _ := sink.binding(); tabID == "" {
t.Fatal("sink lost its tab binding")
}
}
// TestTabBuildSupersededByRebindGeneration: a session rebind bumps
// buildGeneration to strand any in-flight async build, so its swap (and every
// mid-build field write) is rejected; synchronous rebuilds pass generation 0
// and rely on runtimeRebuildMu instead.
func TestTabBuildSupersededByRebindGeneration(t *testing.T) {
tab := &WorkspaceTab{ID: "tab"}
app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
app.mu.Lock()
tab.buildGeneration = 3
app.mu.Unlock()
if app.tabBuildSuperseded(tab, 3) {
t.Fatal("build with the current generation must not be superseded")
}
app.mu.Lock()
tab.buildGeneration++ // the rebind-side invalidation
app.mu.Unlock()
if !app.tabBuildSuperseded(tab, 3) {
t.Fatal("stale-generation build must be superseded after a rebind bump")
}
if app.tabBuildSuperseded(tab, 0) {
t.Fatal("synchronous rebuild (generation 0) must not be superseded by generation bumps")
}
app.mu.Lock()
tab.removed = true
app.mu.Unlock()
if !app.tabBuildSuperseded(tab, 0) {
t.Fatal("removed tab must supersede every build, including synchronous ones")
}
}
// TestReleaseSessionLeaseForKeyOnlyMatchesOwnKey: superseded builds may only
// release the lease bound to their own path; a mismatched key (the rebind's
// replacement session) must be left untouched.
func TestReleaseSessionLeaseForKeyOnlyMatchesOwnKey(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
pathA := filepath.Join(dir, "lease-key-a.jsonl")
pathB := filepath.Join(dir, "lease-key-b.jsonl")
tab := &WorkspaceTab{ID: "tab"}
t.Cleanup(tab.releaseSessionLease)
if err := tab.ensureSessionLease(pathB); err != nil {
t.Fatalf("ensureSessionLease: %v", err)
}
tab.releaseSessionLeaseForKey(sessionRuntimeKey(pathA))
if _, err := agent.TryAcquireSessionLease(sessionRuntimeKey(pathB)); err == nil {
t.Fatal("mismatched key released the tab's live lease")
}
tab.releaseSessionLeaseForKey(sessionRuntimeKey(pathB))
lease, err := agent.TryAcquireSessionLease(sessionRuntimeKey(pathB))
if err != nil {
t.Fatalf("matching key did not release the lease: %v", err)
}
lease.Release()
}
// TestAbandonSupersededBuildPreservesNewBuildOwnership reproduces the rebind
// interleaving from the #5968 review: a stale async build cleans up after a
// rebind's replacement build already published its own SharedHostKey and
// session lease on the same live tab. The stale build must drop only its own
// host reference and lease, never the tab's.
func TestAbandonSupersededBuildPreservesNewBuildOwnership(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
oldPath := filepath.Join(dir, "superseded-old.jsonl")
newPath := filepath.Join(dir, "superseded-new.jsonl")
newKey := sessionRuntimeKey(newPath)
app := &App{tabs: map[string]*WorkspaceTab{}}
// The stale build and the replacement build each hold one reference on
// the same workspace-root host (the common same-root rebind).
app.acquireSharedHost("root")
app.acquireSharedHost("root")
tab := &WorkspaceTab{ID: "tab", SharedHostKey: "root"}
app.tabs["tab"] = tab
t.Cleanup(tab.releaseSessionLease)
if err := tab.ensureSessionLease(newPath); err != nil {
t.Fatalf("ensureSessionLease(new): %v", err)
}
// Stale build abandons with ITS key material: the old lease path and the
// root key it acquired.
app.abandonSupersededBuild(tab, nil, "root", sessionRuntimeKey(oldPath))
app.mu.RLock()
hostKey := tab.SharedHostKey
app.mu.RUnlock()
if hostKey != "root" {
t.Fatalf("stale build cleared the tab's SharedHostKey: %q", hostKey)
}
app.sharedHostsMu.Lock()
entry := app.sharedHosts["root"]
refs := 0
if entry != nil {
refs = entry.refs
}
app.sharedHostsMu.Unlock()
if refs != 1 {
t.Fatalf("shared host refs = %d after stale-build cleanup, want 1 (the live build's reference)", refs)
}
if got := tab.sessionLeaseRuntimeKey(); got != newKey {
t.Fatalf("stale build released the replacement build's lease: key = %q, want %q", got, newKey)
}
// Removal shape: the abandoned build's own key still on the tab is the
// last reference and must be released.
app.abandonSupersededBuild(tab, nil, "", newKey)
lease, err := agent.TryAcquireSessionLease(newKey)
if err != nil {
t.Fatalf("matching-key abandon did not release the lease: %v", err)
}
lease.Release()
}
// TestRebindInvalidatesInFlightAsyncBuildBeforeSnapshot reproduces the #5968
// review scenario: an async startTabControllerBuild is stalled right after
// binding its session lease (holding its controller, lease, and shared-host
// reference), a rebind to a different session starts meanwhile, and the
// stalled build then tries to finish. The rebind must bump the generation
// BEFORE snapshotting tab.Ctrl, so the stale build can only fall into its
// superseded branches: the rebound controller must survive un-overwritten,
// the stale build's lease and host reference must be released, and the
// replacement build's ownership must stay intact.
func TestRebindInvalidatesInFlightAsyncBuildBeforeSnapshot(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
oldPath := filepath.Join(dir, "rebind-race-old.jsonl")
newPath := filepath.Join(dir, "rebind-race-new.jsonl")
writeHistoryTestSession(t, oldPath, "old prompt")
writeHistoryTestSession(t, newPath, "new prompt")
oldKey := sessionRuntimeKey(oldPath)
newKey := sessionRuntimeKey(newPath)
app := NewApp()
app.readyHook = func() {}
tab := &WorkspaceTab{
ID: "tab",
Scope: "global",
WorkspaceRoot: root,
SessionPath: oldPath,
sink: &tabEventSink{tabID: "tab", app: app},
disabledMCP: map[string]ServerView{},
}
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
t.Cleanup(func() {
app.mu.RLock()
ctrl := tab.Ctrl
app.mu.RUnlock()
if ctrl != nil {
ctrl.Close()
}
tab.releaseSessionLease()
})
// Stall the async build inside its lease bind: at that point it already
// holds its controller, its lease, and its shared-host reference.
stalled := make(chan struct{})
releaseHook := make(chan struct{})
var once sync.Once
sessionLeaseAcquireHookForTest = func() {
once.Do(func() {
close(stalled)
<-releaseHook
})
}
t.Cleanup(func() { sessionLeaseAcquireHookForTest = nil })
t.Cleanup(func() {
select {
case <-releaseHook:
default:
close(releaseHook)
}
})
// startTabControllerBuild only backgrounds the build when a Wails context
// exists; expand its goroutine branch by hand so a.ctx can stay nil (the
// nil-ctx emit guards are what every other build test relies on too).
buildCtx, cancel := context.WithCancel(context.Background())
app.mu.Lock()
tab.buildGeneration++
generation := tab.buildGeneration
tab.buildCancel = cancel
app.mu.Unlock()
buildDone := make(chan struct{})
go func() {
defer close(buildDone)
app.buildTabControllerWithContext(tab, loadedTabSession{}, buildCtx, generation, cancel)
}()
select {
case <-stalled:
case <-time.After(15 * time.Second):
t.Fatal("async build did not reach the lease bind")
}
loaded, err := agent.LoadSession(newPath)
if err != nil {
t.Fatalf("LoadSession(new): %v", err)
}
rebindErr := make(chan error, 1)
go func() {
rebindErr <- app.rebindTabToLoadedSessionPath(tab, newPath, loaded)
}()
// Wait until the rebind's validation section has invalidated the async
// build (startTabControllerBuild set generation 1; the rebind bumps to 2)
// while the async build is still stalled pre-swap.
deadline := time.Now().Add(15 * time.Second)
for {
app.mu.RLock()
generation := tab.buildGeneration
app.mu.RUnlock()
if generation >= 2 {
break
}
if time.Now().After(deadline) {
t.Fatal("rebind did not bump the build generation")
}
time.Sleep(time.Millisecond)
}
close(releaseHook)
select {
case err := <-rebindErr:
if err != nil {
t.Fatalf("rebindTabToLoadedSessionPath: %v", err)
}
case <-time.After(30 * time.Second):
t.Fatal("rebind did not finish after releasing the stalled build")
}
select {
case <-buildDone:
case <-time.After(30 * time.Second):
t.Fatal("stale async build did not finish its superseded cleanup")
}
app.mu.RLock()
builtCtrl := tab.Ctrl
sessionPath := tab.SessionPath
app.mu.RUnlock()
if builtCtrl == nil {
t.Fatal("rebind left the tab without a controller")
}
if got := builtCtrl.SessionPath(); got != newPath {
t.Fatalf("stale async build overwrote the rebound controller: session path = %q, want %q", got, newPath)
}
if sessionPath != newPath {
t.Fatalf("tab.SessionPath = %q, want %q", sessionPath, newPath)
}
if got := tab.sessionLeaseRuntimeKey(); got != newKey {
t.Fatalf("tab lease key = %q, want the rebound session %q", got, newKey)
}
// The stale build's lease must be gone (released by its superseded
// cleanup or replaced by the rebound build's adopt) — the old session
// must be acquirable again.
lease, err := agent.TryAcquireSessionLease(oldKey)
if err != nil {
t.Fatalf("stale build leaked its session lease: %v", err)
}
lease.Release()
// Exactly one shared-host reference may remain: the rebound build's. The
// stale build must have released its own.
app.sharedHostsMu.Lock()
refs := 0
for _, entry := range app.sharedHosts {
refs += entry.refs
}
app.sharedHostsMu.Unlock()
if refs != 1 {
t.Fatalf("shared host refs = %d after stale-build cleanup, want 1 (the rebound build's)", refs)
}
}
// TestMetaForTabConcurrentWithBuildSwap polls MetaForTab (the frontend's boot
// probe) while a fake build goroutine flips Ready/Label/StartupErr/model under
// a.mu — the write pattern of buildTabControllerWithContext. Run with -race.
func TestMetaForTabConcurrentWithBuildSwap(t *testing.T) {
isolateDesktopUserDirs(t)
tab := &WorkspaceTab{ID: "tab", Scope: "project", WorkspaceRoot: t.TempDir()}
app := &App{
tabs: map[string]*WorkspaceTab{"tab": tab},
tabOrder: []string{"tab"},
activeTabID: "tab",
}
const iterations = 100
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < iterations; i++ {
app.mu.Lock()
tab.Ready = !tab.Ready
tab.Label = fmt.Sprintf("model-%d", i)
tab.StartupErr = ""
tab.model = fmt.Sprintf("provider/m%d", i)
tab.goal = fmt.Sprintf("goal-%d", i)
tab.tokenMode = "full"
app.mu.Unlock()
}
}()
for i := 0; i < iterations; i++ {
meta := app.MetaForTab("tab")
if meta.EventChannel == "" {
t.Fatal("MetaForTab returned zero meta for a live tab")
}
}
<-done
}
+4
View File
@@ -34,6 +34,10 @@ func (t desktopCountingTool) Execute(context.Context, json.RawMessage) (string,
}
func TestDesktopE2EBlocksRepeatedSuccessfulBashFileWrite(t *testing.T) {
if testing.Short() {
t.Skip("skipping desktop E2E repeat-guard test in short mode")
}
var calls int32
reg := tool.NewRegistry()
reg.Add(desktopCountingTool{name: "bash", calls: &calls})
+137
View File
@@ -0,0 +1,137 @@
package main
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"reasonix/internal/agent"
)
// The lease registry folds session paths through agent.CanonicalSessionPath
// (lowercased on Windows). Every desktop "same session" comparison must fold
// the same way, or a lease the tab itself holds looks foreign and every
// model/effort/token switch self-locks with the "already open in another
// window" error (#5999, #6006, #5996).
func TestSessionRuntimeKeyMatchesLeaseKey(t *testing.T) {
dir := t.TempDir()
// Mixed-case segment on every platform; on Windows the lease key folds it.
path := filepath.Join(dir, "Sessions-Dir", "20260705-Test.jsonl")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
lease, err := agent.TryAcquireSessionLease(path)
if err != nil {
t.Fatalf("acquire lease: %v", err)
}
defer lease.Release()
if got, want := sessionRuntimeKey(lease.Path()), sessionRuntimeKey(path); got != want {
t.Fatalf("lease path key %q != session path key %q; lease reuse checks will self-lock", got, want)
}
}
func TestSessionRuntimeKeyIsIdempotent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "Case-Mix", "20260705-Test.jsonl")
key := sessionRuntimeKey(path)
if key == "" {
t.Fatal("empty key for non-empty path")
}
if again := sessionRuntimeKey(key); again != key {
t.Fatalf("sessionRuntimeKey not idempotent: %q -> %q", key, again)
}
}
func TestSessionRuntimeKeyFoldsCaseOnWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("case-insensitive path identity is Windows-only")
}
dir := t.TempDir()
path := filepath.Join(dir, "Sessions", "20260705-Test.jsonl")
upper := strings.ToUpper(path)
if sessionRuntimeKey(path) != sessionRuntimeKey(upper) {
t.Fatalf("case variants of one file produced distinct keys: %q vs %q",
sessionRuntimeKey(path), sessionRuntimeKey(upper))
}
}
func TestCanonicalTabSessionPathNormalizesOutsideSessionDir(t *testing.T) {
// Project-scope sessions live outside config.SessionDir(); the fallback
// must still clean the shape so one file cannot split into two keys.
dir := t.TempDir()
base := filepath.Join(dir, "projects", "p1", "sessions", "20260705-a.jsonl")
messy := filepath.Join(dir, "projects", "p1", ".", "sessions") + string(filepath.Separator) + "20260705-a.jsonl"
if sessionRuntimeKey(base) != sessionRuntimeKey(messy) {
t.Fatalf("path shape variants split keys: %q vs %q",
sessionRuntimeKey(base), sessionRuntimeKey(messy))
}
}
func TestEnsureSessionLeaseReusesHeldLease(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "Sessions", "20260705-Reuse.jsonl")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
tab := &WorkspaceTab{ID: "tab_reuse"}
if err := tab.ensureSessionLease(path); err != nil {
t.Fatalf("first ensure: %v", err)
}
defer tab.releaseSessionLease()
acquires := 0
sessionLeaseAcquireHookForTest = func() { acquires++ }
defer func() { sessionLeaseAcquireHookForTest = nil }()
// Same path again — and the lease's own (canonical) form: both must hit
// the fast path instead of re-acquiring against our own registry entry.
if err := tab.ensureSessionLease(path); err != nil {
t.Fatalf("re-ensure same path: %v", err)
}
if err := tab.ensureSessionLease(tab.sessionLeaseRuntimeKey()); err != nil {
t.Fatalf("re-ensure canonical form: %v", err)
}
if acquires != 0 {
t.Fatalf("expected fast-path reuse, got %d new acquires", acquires)
}
}
func TestCanReclaimAllowsMissingLeaseInfo(t *testing.T) {
// lease.json deleted or torn: Info is nil but nothing actually holds the
// session. The reclaim attempt must be allowed — the OS lock arbitrates —
// instead of wedging the session as busy on missing metadata (#5999's
// fresh-install variant).
app := NewApp()
tab := &WorkspaceTab{ID: "tab_nil_info"}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
path := filepath.Join(t.TempDir(), "Sessions", "20260705-a.jsonl")
errNil := &agent.SessionLeaseError{Path: path}
if !app.canReclaimCurrentProcessSessionLease(tab, path, errNil) {
t.Fatal("nil-Info lease error must allow a reclaim attempt")
}
foreign := &agent.SessionLeaseError{Path: path, Info: &agent.SessionLeaseInfo{
SessionPath: path, WriterID: "other-host-1-deadbeef", PID: os.Getpid() + 1,
}}
if app.canReclaimCurrentProcessSessionLease(tab, path, foreign) {
t.Fatal("foreign-holder lease error must not allow reclaim")
}
}
func TestSessionLeaseBusyErrorOmitsSettingClause(t *testing.T) {
generic := (&sessionLeaseBusyError{}).Error()
if strings.Contains(generic, "before changing") {
t.Fatalf("empty-setting busy error still names a setting: %q", generic)
}
withSetting := (&sessionLeaseBusyError{setting: "model"}).Error()
if !strings.Contains(withSetting, "before changing model") {
t.Fatalf("setting busy error lost its clause: %q", withSetting)
}
}
+11 -2
View File
@@ -43,7 +43,7 @@ func sessionWithFreshSystemPrompt(session *agent.Session, system string) *agent.
if systemPromptFrom(messages) == "" {
return session
}
return &agent.Session{Messages: withFreshSystemPrompt(messages, system)}
return session.CloneWithMessages(withFreshSystemPrompt(messages, system))
}
func resumeWithFreshSystemPrompt(ctrl interface {
@@ -55,7 +55,16 @@ func resumeWithFreshSystemPrompt(ctrl interface {
return
}
if len(messages) > 0 {
ctrl.Resume(&agent.Session{Messages: withFreshSystemPrompt(messages, systemPromptFrom(ctrl.History()))}, path)
next := withFreshSystemPrompt(messages, systemPromptFrom(ctrl.History()))
if path != "" {
if loaded, err := agent.LoadSession(path); err == nil && loaded != nil {
if resumed, ok := loaded.CloneWithMessagesIfCompatible(next); ok {
ctrl.Resume(resumed, path)
return
}
}
}
ctrl.Resume(agent.NewSession("").CloneWithMessages(next), path)
return
}
if path != "" {
+148
View File
@@ -0,0 +1,148 @@
package main
import (
"errors"
"path/filepath"
"strings"
"testing"
"reasonix/internal/agent"
"reasonix/internal/provider"
)
type promptResumeCtrl struct {
history []provider.Message
resumed *agent.Session
path string
}
func (c *promptResumeCtrl) History() []provider.Message {
return append([]provider.Message(nil), c.history...)
}
func (c *promptResumeCtrl) Resume(s *agent.Session, path string) {
c.resumed = s
c.path = path
}
func (c *promptResumeCtrl) SetSessionPath(path string) {
c.path = path
}
func TestSessionWithFreshSystemPromptPreservesLoadedRewriteBaseline(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
s := agent.NewSession("old sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "tool-1", Name: "read_file", Arguments: "{}"}}})
s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "tool-1", Name: "read_file", Content: strings.Repeat("detail ", 100)})
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
if err := s.Save(path); err != nil {
t.Fatalf("Save base: %v", err)
}
loaded, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
resumed := sessionWithFreshSystemPrompt(loaded, "new sys")
msgs := resumed.Snapshot()
msgs[3].Content = "[elided tool result]"
resumed.Replace(msgs)
if err := resumed.SaveRewrite(path); err != nil {
t.Fatalf("SaveRewrite fresh-system resume: %v", err)
}
reloaded, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession rewritten: %v", err)
}
if got := reloaded.Messages[0].Content; got != "new sys" {
t.Fatalf("system prompt after rewrite = %q, want new sys", got)
}
if got := reloaded.Messages[3].Content; got != "[elided tool result]" {
t.Fatalf("tool result after rewrite = %q, want elided", got)
}
if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
t.Fatalf("recovery branches after owned resume rewrite = %v err=%v, want none", matches, err)
}
}
func TestResumeWithFreshSystemPromptPreservesLoadedRewriteBaseline(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
s := agent.NewSession("old sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "tool-1", Name: "read_file", Arguments: "{}"}}})
s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "tool-1", Name: "read_file", Content: strings.Repeat("detail ", 100)})
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
if err := s.Save(path); err != nil {
t.Fatalf("Save base: %v", err)
}
loaded, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
ctrl := &promptResumeCtrl{history: []provider.Message{{Role: provider.RoleSystem, Content: "new sys"}}}
resumeWithFreshSystemPrompt(ctrl, loaded.Snapshot(), path)
if ctrl.resumed == nil {
t.Fatalf("Resume was not called")
}
msgs := ctrl.resumed.Snapshot()
msgs[3].Content = "[elided tool result]"
ctrl.resumed.Replace(msgs)
if err := ctrl.resumed.SaveRewrite(path); err != nil {
t.Fatalf("SaveRewrite resumed history: %v", err)
}
if got := ctrl.path; got != path {
t.Fatalf("resume path = %q, want %q", got, path)
}
reloaded, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession rewritten: %v", err)
}
if got := reloaded.Messages[0].Content; got != "new sys" {
t.Fatalf("system prompt after rewrite = %q, want new sys", got)
}
if got := reloaded.Messages[3].Content; got != "[elided tool result]" {
t.Fatalf("tool result after rewrite = %q, want elided", got)
}
if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
t.Fatalf("recovery branches after resume rewrite = %v err=%v, want none", matches, err)
}
}
func TestResumeWithFreshSystemPromptRejectsStaleCarriedHistoryBaseline(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
current := agent.NewSession("old 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"})
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk two"})
if err := current.Save(path); err != nil {
t.Fatalf("Save current: %v", err)
}
stale := []provider.Message{
{Role: provider.RoleSystem, Content: "old sys"},
{Role: provider.RoleUser, Content: "first"},
{Role: provider.RoleAssistant, Content: "one"},
}
ctrl := &promptResumeCtrl{history: []provider.Message{{Role: provider.RoleSystem, Content: "new sys"}}}
resumeWithFreshSystemPrompt(ctrl, stale, path)
if ctrl.resumed == nil {
t.Fatalf("Resume was not called")
}
if err := ctrl.resumed.SaveRewrite(path); !errors.Is(err, agent.ErrSessionSnapshotConflict) {
t.Fatalf("SaveRewrite stale carried history err = %v, want ErrSessionSnapshotConflict", err)
}
reloaded, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession current: %v", err)
}
if got := reloaded.Messages[len(reloaded.Messages)-1].Content; got != "disk two" {
t.Fatalf("original tail after stale resume rewrite = %q, want disk two", got)
}
}
+243
View File
@@ -0,0 +1,243 @@
package main
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/provider"
"reasonix/internal/store"
)
func saveSnapshotTurns(t *testing.T, path string, turns int) *agent.Session {
t.Helper()
s := agent.NewSession("sys")
for i := 0; i < turns; i++ {
s.Add(provider.Message{Role: provider.RoleUser, Content: "prompt " + string(rune('a'+i))})
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "reply"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot turn %d: %v", i, err)
}
}
return s
}
func TestMergeSessionInfosCountsRecoveryActivity(t *testing.T) {
summaries := map[string]topicSummary{}
now := time.Now()
infos := []agent.SessionInfo{
{
Path: "/tmp/original.jsonl",
Turns: 3,
LastActivityAt: now.Add(-time.Hour),
Scope: "global",
TopicID: "topic-1",
},
{
Path: "/tmp/original-recovery-abc.jsonl",
Turns: 5,
LastActivityAt: now,
Scope: "global",
TopicID: "topic-1",
Recovered: true,
},
}
mergeSessionInfos("/tmp", infos, map[string]string{}, map[string]agent.SessionInfo{}, map[string]string{}, summaries)
summary := summaries[topicSummaryKey("global", "", "topic-1")]
if !summary.hasNormalSession || !summary.hasRecoveryOnly {
t.Fatalf("summary flags = %+v, want both normal and recovery seen", summary)
}
if summary.turns != 3 {
t.Fatalf("turns = %d, want 3 (recovery copies must not double-count)", summary.turns)
}
// The copy is the live transcript after recovery: its newer activity must
// drive topic recency, unread state, and time filters.
if summary.lastActivityAt != now.UnixMilli() {
t.Fatalf("lastActivityAt = %d, want recovery activity %d", summary.lastActivityAt, now.UnixMilli())
}
}
func TestTopicHiddenAsRecoveryOnly(t *testing.T) {
recoveryOnly := topicSummary{hasRecoveryOnly: true}
cases := []struct {
name string
summary topicSummary
pinned bool
sessions []runtimeSessionStatus
want bool
}{
{"recovery-only idle", recoveryOnly, false, nil, true},
{"normal session present", topicSummary{hasRecoveryOnly: true, hasNormalSession: true}, false, nil, false},
{"pinned stays visible", recoveryOnly, true, nil, false},
{"single open runtime", recoveryOnly, false, []runtimeSessionStatus{{open: true}}, false},
// topicRuntimeStatus reports open/running only for single-session
// topics; the hide rule must still see a two-session topic as live.
{"two runtime sessions one open", recoveryOnly, false, []runtimeSessionStatus{{open: true}, {running: false}}, false},
{"detached running runtime", recoveryOnly, false, []runtimeSessionStatus{{running: true}, {}}, false},
{"idle runtime entries only", recoveryOnly, false, []runtimeSessionStatus{{}, {}}, true},
}
for _, c := range cases {
if got := topicHiddenAsRecoveryOnly(c.summary, c.pinned, c.sessions); got != c.want {
t.Errorf("%s: hidden = %v, want %v", c.name, got, c.want)
}
}
}
func TestTrashSessionMatchesLiveSeesEventLogDivergence(t *testing.T) {
dir := t.TempDir()
live := filepath.Join(dir, "session.jsonl")
s := saveSnapshotTurns(t, live, 1)
// Simulate an old trash copy taken at checkpoint time: same anchor bytes,
// same event log state.
trashDir := filepath.Join(dir, "trash")
if err := os.MkdirAll(trashDir, 0o755); err != nil {
t.Fatal(err)
}
trashPath := filepath.Join(trashDir, "session.jsonl")
for _, pair := range [][2]string{
{live, trashPath},
{store.SessionEventLog(live), store.SessionEventLog(trashPath)},
} {
b, err := os.ReadFile(pair[0])
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(pair[1], b, 0o644); err != nil {
t.Fatal(err)
}
}
same, err := trashSessionMatchesLive(live, trashPath)
if err != nil {
t.Fatalf("trashSessionMatchesLive identical: %v", err)
}
if !same {
t.Fatal("identical live/trash reported as different")
}
// The live session keeps chatting: growth lands in the event log only, so
// the two .jsonl checkpoints stay byte-identical. Byte comparison would
// call this a duplicate and delete the live session's newer history.
s.Add(provider.Message{Role: provider.RoleUser, Content: "newer work"})
if err := s.SaveSnapshot(live); err != nil {
t.Fatalf("SaveSnapshot diverge: %v", err)
}
liveAnchor, _ := os.ReadFile(live)
trashAnchor, _ := os.ReadFile(trashPath)
if string(liveAnchor) != string(trashAnchor) {
t.Skip("checkpoints diverged on disk; byte-compare trap not reproducible here")
}
same, err = trashSessionMatchesLive(live, trashPath)
if err != nil {
t.Fatalf("trashSessionMatchesLive diverged: %v", err)
}
if same {
t.Fatal("live session with newer event log reported as duplicate of trash copy")
}
}
func TestTrashPathsBlockedWhileLeaseHeld(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
saveSnapshotTurns(t, path, 1)
// A live owner (any runtime — this process or another) holds the lease
// lock on an open handle for its whole hold. Every destructive path must
// refuse while it is held: probing once and deleting later would let the
// owner's freshly locked lease file be unlinked out from under it.
lease, err := agent.TryAcquireSessionLease(path)
if err != nil {
t.Fatalf("TryAcquireSessionLease: %v", err)
}
released := false
defer func() {
if !released {
lease.Release()
}
}()
if err := trashSessionArtifactsBeforeMove(dir, path, "session.jsonl", nil); !errors.Is(err, errSessionBusyElsewhere) {
t.Fatalf("trashSessionArtifactsBeforeMove err = %v, want errSessionBusyElsewhere", err)
}
if err := reconcileDesktopTrashSessionArtifacts(dir, path, "session.jsonl"); !errors.Is(err, errSessionBusyElsewhere) {
t.Fatalf("reconcileDesktopTrashSessionArtifacts err = %v, want errSessionBusyElsewhere", err)
}
if err := removeDesktopSessionArtifacts(path); !errors.Is(err, errSessionBusyElsewhere) {
t.Fatalf("removeDesktopSessionArtifacts err = %v, want errSessionBusyElsewhere", err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("session file touched despite live owner: %v", err)
}
if _, err := os.Stat(store.SessionEventLog(path)); err != nil {
t.Fatalf("event log touched despite live owner: %v", err)
}
if _, err := os.Stat(store.SessionLeaseLock(path)); err != nil {
t.Fatalf("lease lock deleted while held: %v", err)
}
// Once the owner releases, the same trash call succeeds and the lock
// sidecars are gone with it.
lease.Release()
released = true
if err := trashSessionArtifactsBeforeMove(dir, path, "session.jsonl", nil); err != nil {
t.Fatalf("trashSessionArtifactsBeforeMove after release: %v", err)
}
for _, p := range []string{
path,
store.SessionLockFile(path),
store.SessionLeaseLock(path),
store.SessionLeaseInfo(path),
} {
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Errorf("artifact survived trash: %s (err=%v)", p, err)
}
}
}
func TestPromptHistorySeesEventLogPrompts(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
s := agent.NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "first prompt"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot: %v", err)
}
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "reply"})
s.Add(provider.Message{Role: provider.RoleUser, Content: "second prompt"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot append: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
entries, err := collectPromptHistoryEntries(path, info, func(s string) string { return s })
if err != nil {
t.Fatalf("collectPromptHistoryEntries: %v", err)
}
if len(entries) != 2 {
t.Fatalf("prompt history entries = %d, want 2 (event-log prompts must appear)", len(entries))
}
if entries[0].Text != "first prompt" || entries[1].Text != "second prompt" {
t.Fatalf("prompt history texts = %q, %q", entries[0].Text, entries[1].Text)
}
if entries[1].At == 0 {
t.Fatal("appended prompt lost its timestamp")
}
}
func TestTopicTitleUserTurnsSeesEventLogTurns(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
saveSnapshotTurns(t, path, 3)
users := topicTitleUserTurnsFromSession(path)
if len(users) != 3 {
t.Fatalf("user turns = %d, want 3 (≥3-turn title upgrade depends on this)", len(users))
}
}
+282 -42
View File
@@ -3,6 +3,7 @@ package main
import (
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"os"
@@ -144,12 +145,36 @@ func sessionTrashArtifacts(sessionPath, key string) []sessionTrashArtifact {
{src: sessionPath, name: key},
{src: store.SessionMeta(sessionPath), name: key + ".meta"},
{src: store.SessionGoalState(sessionPath), name: stem + ".goal-state.json"},
{src: store.SessionEventLog(sessionPath), name: stem + ".events.jsonl"},
{src: store.SessionEventIndex(sessionPath), name: stem + ".event-index.json"},
{src: sessionTelemetryPath(sessionPath), name: key + ".telemetry.json"},
{src: store.SessionCheckpointDir(sessionPath), name: stem + ".ckpt"},
{src: store.SessionJobsDir(sessionPath), name: stem + ".jobs"},
}
}
// errSessionBusyElsewhere is the sanitized error surfaced when a destructive
// session operation is blocked by a live owner. It intentionally carries no
// writer id, hostname, or path.
var errSessionBusyElsewhere = errors.New("session is in use by another Reasonix window or process")
// acquireSessionRemovalGuard wraps agent.TryAcquireSessionRemovalGuard with
// the sanitized busy error. The guard holds the session's save and lease
// locks across the destructive operation and deletes the lock files
// atomically with the release — a one-shot busy probe followed by RemoveAll
// would let another process acquire the lease in between and then lose its
// freshly locked lease file, breaking cross-process mutual exclusion.
func acquireSessionRemovalGuard(sessionPath string) (*agent.SessionRemovalGuard, error) {
guard, err := agent.TryAcquireSessionRemovalGuard(sessionPath)
if err != nil {
if errors.Is(err, agent.ErrSessionLeaseHeld) {
return nil, errSessionBusyElsewhere
}
return nil, err
}
return guard, nil
}
func sessionOwnedArtifactPaths(sessionPath string) []string {
key := filepath.Base(sessionPath)
artifacts := sessionTrashArtifacts(sessionPath, key)
@@ -180,8 +205,39 @@ func reconcileDesktopCleanupPending(dir string) error {
}
func reconcileDesktopTrashSessionArtifacts(dir, sessionPath, key string) error {
// Hold the removal guard across the whole move so no runtime can acquire
// the session (or save into it) while its artifacts are relocated; the
// lock sidecars are deleted atomically with the guard release.
guard, err := acquireSessionRemovalGuard(sessionPath)
if err != nil {
return err
}
defer guard.Release()
itemDir := filepath.Join(sessionTrashPath(dir), key)
if err := os.MkdirAll(itemDir, 0o755); err != nil {
if info, err := os.Stat(itemDir); err == nil {
if !info.IsDir() {
return fmt.Errorf("session trash target is not a directory: %s", key)
}
trashPath := filepath.Join(itemDir, key)
if trashInfo, err := os.Stat(trashPath); err == nil && !trashInfo.IsDir() {
matches, err := trashSessionMatchesLive(sessionPath, trashPath)
if err != nil {
return err
}
if !matches {
itemDir, err = reserveUniqueSessionTrashItemDir(dir, key)
if err != nil {
return err
}
}
} else if err != nil && !os.IsNotExist(err) {
return err
}
} else if os.IsNotExist(err) {
if err := os.MkdirAll(itemDir, 0o755); err != nil {
return err
}
} else {
return err
}
for _, artifact := range sessionTrashArtifacts(sessionPath, key) {
@@ -192,6 +248,9 @@ func reconcileDesktopTrashSessionArtifacts(dir, sessionPath, key string) error {
if err := trashSubagentArtifacts(dir, sessionPath, itemDir); err != nil {
return err
}
if err := guard.RemoveSidecarsAndRelease(); err != nil {
return err
}
meta := trashedSessionMeta{Key: key, DeletedAt: time.Now().UnixMilli()}
b, err := json.MarshalIndent(meta, "", " ")
if err != nil {
@@ -216,14 +275,17 @@ func validateSessionTrashTarget(dir, sessionPath, key string) error {
}
trashPath := filepath.Join(itemDir, key)
if trashInfo, err := os.Stat(trashPath); err == nil && !trashInfo.IsDir() {
discardable, err := liveSessionDiscardable(sessionPath)
removable, err := liveSessionRemovableWithExistingTrash(sessionPath, trashPath)
if err != nil {
return err
}
if discardable {
if removable {
return nil
}
return fmt.Errorf("session already exists in trash: %s", key)
if agent.SessionLeaseHeldByOtherRuntime(sessionPath) {
return errSessionBusyElsewhere
}
return nil
} else if err != nil && !os.IsNotExist(err) {
return err
}
@@ -234,37 +296,87 @@ func validateSessionTrashTarget(dir, sessionPath, key string) error {
return nil
}
func prepareSessionTrashTarget(dir, sessionPath, key string) (bool, error) {
type preparedSessionTrashTarget struct {
shouldMove bool
itemDir string
allocateUnique bool
}
func prepareSessionTrashTarget(dir, sessionPath, key string) (preparedSessionTrashTarget, error) {
if _, err := os.Stat(sessionPath); os.IsNotExist(err) {
return false, nil
return preparedSessionTrashTarget{}, nil
} else if err != nil {
return false, err
return preparedSessionTrashTarget{}, err
}
itemDir := filepath.Join(sessionTrashPath(dir), key)
if info, err := os.Stat(itemDir); err == nil {
if !info.IsDir() {
return false, fmt.Errorf("session trash target is not a directory: %s", key)
return preparedSessionTrashTarget{}, fmt.Errorf("session trash target is not a directory: %s", key)
}
trashPath := filepath.Join(itemDir, key)
if trashInfo, err := os.Stat(trashPath); err == nil && !trashInfo.IsDir() {
discardable, err := liveSessionDiscardable(sessionPath)
removable, err := liveSessionRemovableWithExistingTrash(sessionPath, trashPath)
if err != nil {
return false, err
return preparedSessionTrashTarget{}, err
}
if discardable {
return false, removeDesktopSessionArtifacts(sessionPath)
if removable {
return preparedSessionTrashTarget{}, removeDesktopSessionArtifacts(sessionPath)
}
return false, fmt.Errorf("session already exists in trash: %s", key)
if agent.SessionLeaseHeldByOtherRuntime(sessionPath) {
return preparedSessionTrashTarget{}, errSessionBusyElsewhere
}
return preparedSessionTrashTarget{shouldMove: true, allocateUnique: true}, nil
} else if err != nil && !os.IsNotExist(err) {
return false, err
return preparedSessionTrashTarget{}, err
}
if err := os.RemoveAll(itemDir); err != nil {
return false, err
return preparedSessionTrashTarget{}, err
}
} else if !os.IsNotExist(err) {
return preparedSessionTrashTarget{}, err
}
return preparedSessionTrashTarget{shouldMove: true, itemDir: itemDir}, nil
}
func reserveUniqueSessionTrashItemDir(dir, key string) (string, error) {
root := sessionTrashPath(dir)
if err := os.MkdirAll(root, 0o755); err != nil {
return "", err
}
stem := strings.TrimSuffix(key, ".jsonl")
for i := 0; i < 100; i++ {
name := fmt.Sprintf("%s.jsonl-deleted-%d-%02d", stem, time.Now().UnixNano(), i)
itemDir := filepath.Join(root, name)
if err := os.Mkdir(itemDir, 0o755); err == nil {
return itemDir, nil
} else if !os.IsExist(err) {
return "", err
}
}
return "", fmt.Errorf("could not allocate unique trash target for session: %s", key)
}
// liveSessionRemovableWithExistingTrash reports whether a live session file may
// be removed even though a trash copy already exists under the same key: the
// live file must be discardable (empty stub) or byte-identical to the trash
// copy, and no other runtime may hold its session lease — another process could
// be mid-write, and removing the file would silently drop its next save.
func liveSessionRemovableWithExistingTrash(sessionPath, trashPath string) (bool, error) {
discardable, err := liveSessionDiscardable(sessionPath)
if err != nil {
return false, err
}
return true, nil
duplicate := false
if !discardable {
duplicate, err = trashSessionMatchesLive(sessionPath, trashPath)
if err != nil {
return false, err
}
}
if !discardable && !duplicate {
return false, nil
}
return !agent.SessionLeaseHeldByOtherRuntime(sessionPath), nil
}
func liveSessionDiscardable(sessionPath string) (bool, error) {
@@ -291,6 +403,20 @@ func liveSessionDiscardable(sessionPath string) (bool, error) {
return !session.HasContent(), nil
}
func trashSessionMatchesLive(sessionPath, trashPath string) (bool, error) {
if _, err := os.Stat(sessionPath); err != nil {
if os.IsNotExist(err) {
return true, nil
}
return false, err
}
// Compare decoded transcripts, not .jsonl bytes: the checkpoint only
// changes at checkpoints, so two byte-identical .jsonl files can hide
// diverged event logs — and treating them as duplicates would delete the
// live session's newer history.
return agent.SessionsShareContent(sessionPath, trashPath)
}
func sessionFileHasConversationContent(sessionPath string) bool {
if strings.TrimSpace(sessionPath) == "" || agent.IsCleanupPending(sessionPath) {
return false
@@ -310,15 +436,27 @@ func trashSessionArtifactsBeforeMove(dir, sessionPath, key string, beforeMove fu
if err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {
return err
}
shouldMove, err := prepareSessionTrashTarget(dir, sessionPath, key)
target, err := prepareSessionTrashTarget(dir, sessionPath, key)
if err != nil {
return err
}
if !shouldMove {
if !target.shouldMove {
return nil
}
itemDir := filepath.Join(sessionTrashPath(dir), key)
if err := os.MkdirAll(itemDir, 0o755); err != nil {
// Acquired after prepareSessionTrashTarget: the duplicate-trash path in
// there takes its own removal guard, and the guard is not reentrant.
guard, err := acquireSessionRemovalGuard(sessionPath)
if err != nil {
return err
}
defer guard.Release()
itemDir := target.itemDir
if target.allocateUnique {
itemDir, err = reserveUniqueSessionTrashItemDir(dir, key)
if err != nil {
return err
}
} else if err := os.MkdirAll(itemDir, 0o755); err != nil {
return err
}
if beforeMove != nil {
@@ -332,6 +470,9 @@ func trashSessionArtifactsBeforeMove(dir, sessionPath, key string, beforeMove fu
if err := trashSubagentArtifacts(dir, sessionPath, itemDir); err != nil {
return err
}
if err := guard.RemoveSidecarsAndRelease(); err != nil {
return err
}
meta := trashedSessionMeta{Key: key, DeletedAt: time.Now().UnixMilli()}
b, err := json.MarshalIndent(meta, "", " ")
if err != nil {
@@ -360,17 +501,27 @@ func listTrashedSessionFiles(dir string) ([]string, error) {
if !e.IsDir() {
continue
}
key := e.Name()
if filepath.Ext(key) != ".jsonl" || filepath.Base(key) != key {
continue
itemDir := filepath.Join(root, e.Name())
keys := []string{}
if b, err := os.ReadFile(filepath.Join(itemDir, sessionTrashMetaFile)); err == nil {
var meta trashedSessionMeta
if json.Unmarshal(b, &meta) == nil && store.IsSessionTranscriptName(meta.Key) {
keys = append(keys, meta.Key)
}
}
path := filepath.Join(root, key, key)
validPath, _, _, err := validateTrashedSessionPath(dir, path)
if err != nil {
continue
if store.IsSessionTranscriptName(e.Name()) {
keys = append(keys, e.Name())
}
if info, err := os.Stat(validPath); err == nil && !info.IsDir() {
paths = append(paths, validPath)
for _, key := range keys {
path := filepath.Join(itemDir, key)
validPath, _, _, err := validateTrashedSessionPath(dir, path)
if err != nil {
continue
}
if info, err := os.Stat(validPath); err == nil && !info.IsDir() {
paths = append(paths, validPath)
break
}
}
}
return paths, nil
@@ -443,11 +594,8 @@ func purgeTrashedSessionFile(dir, path string) error {
return err
}
}
if dm := loadSessionDisplays(dir); dm[key] != nil {
delete(dm, key)
if err := saveSessionDisplays(dir, dm); err != nil {
return err
}
if err := removeSessionDisplayKey(dir, key); err != nil {
return err
}
return nil
}
@@ -580,11 +728,15 @@ func trashSubagentArtifacts(dir, sessionPath, itemDir string) error {
}
trashSubagentDir := filepath.Join(itemDir, "subagents")
for _, artifact := range artifacts {
if err := movePathIfExists(artifact.SessionPath, filepath.Join(trashSubagentDir, filepath.Base(artifact.SessionPath))); err != nil {
return err
}
if err := movePathIfExists(artifact.MetaPath, filepath.Join(trashSubagentDir, filepath.Base(artifact.MetaPath))); err != nil {
return err
paths := []string{artifact.SessionPath, artifact.MetaPath}
paths = append(paths, store.SessionSidecarFiles(artifact.SessionPath)...)
for _, src := range paths {
if strings.TrimSpace(src) == "" {
continue
}
if err := movePathIfExists(src, filepath.Join(trashSubagentDir, filepath.Base(src))); err != nil {
return err
}
}
}
return nil
@@ -649,7 +801,7 @@ func validateSessionPath(dir, sessionPath string) (string, string, error) {
if err != nil {
return "", "", err
}
if filepath.Ext(absPath) != ".jsonl" {
if !store.IsSessionTranscriptName(filepath.Base(absPath)) {
return "", "", fmt.Errorf("not a session file: %s", sessionPath)
}
rel, err := filepath.Rel(absDir, absPath)
@@ -694,7 +846,7 @@ func validateTrashedSessionPath(dir, sessionPath string) (string, string, string
if err != nil {
return "", "", "", err
}
if filepath.Ext(absPath) != ".jsonl" {
if !store.IsSessionTranscriptName(filepath.Base(absPath)) {
return "", "", "", fmt.Errorf("not a session file: %s", sessionPath)
}
rel, err := filepath.Rel(root, absPath)
@@ -702,9 +854,19 @@ func validateTrashedSessionPath(dir, sessionPath string) (string, string, string
return "", "", "", fmt.Errorf("session path outside trash dir: %s", sessionPath)
}
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) != 2 || parts[0] != parts[1] {
if len(parts) != 2 {
return "", "", "", fmt.Errorf("invalid trash session path: %s", sessionPath)
}
if parts[0] != parts[1] {
b, err := os.ReadFile(filepath.Join(root, parts[0], sessionTrashMetaFile))
if err != nil {
return "", "", "", fmt.Errorf("invalid trash session path: %s", sessionPath)
}
var meta trashedSessionMeta
if err := json.Unmarshal(b, &meta); err != nil || meta.Key != parts[1] {
return "", "", "", fmt.Errorf("invalid trash session path: %s", sessionPath)
}
}
if info, err := os.Lstat(absPath); err == nil {
if info.IsDir() {
return "", "", "", fmt.Errorf("not a session file: %s", sessionPath)
@@ -853,6 +1015,84 @@ func saveSessionDisplays(dir string, m sessionDisplayMap) error {
return fileutil.ReplaceFile(tmpPath, sessionDisplayPath(dir))
}
func saveOrRemoveSessionDisplays(dir string, m sessionDisplayMap) error {
if len(m) == 0 {
err := os.Remove(sessionDisplayPath(dir))
if os.IsNotExist(err) {
return nil
}
return err
}
return saveSessionDisplays(dir, m)
}
func removeSessionDisplayKey(dir, key string) error {
key = strings.TrimSpace(key)
if key == "" {
return nil
}
m := loadSessionDisplays(dir)
if m[key] == nil {
return nil
}
delete(m, key)
return saveOrRemoveSessionDisplays(dir, m)
}
func removeSessionDisplay(dir, sessionPath string) error {
if strings.TrimSpace(sessionPath) == "" {
return nil
}
return removeSessionDisplayKey(dir, filepath.Base(sessionPath))
}
func pruneSessionDisplays(dir string, protected map[string]struct{}) error {
m := loadSessionDisplays(dir)
if len(m) == 0 {
return nil
}
changed := false
for key := range m {
if sessionDisplayKeyStillOwned(dir, key, protected) {
continue
}
delete(m, key)
changed = true
}
if !changed {
return nil
}
return saveOrRemoveSessionDisplays(dir, m)
}
func sessionDisplayKeyStillOwned(dir, key string, protected map[string]struct{}) bool {
key = strings.TrimSpace(key)
if key == "" || filepath.Base(key) != key || !store.IsSessionTranscriptName(key) {
return false
}
if protected != nil {
if _, ok := protected[key]; ok {
return true
}
}
sessionPath := filepath.Join(dir, key)
if info, err := os.Stat(sessionPath); err == nil && !info.IsDir() {
return true
}
trashPath := filepath.Join(sessionTrashPath(dir), key, key)
if info, err := os.Stat(trashPath); err == nil && !info.IsDir() {
return true
}
if paths, err := listTrashedSessionFiles(dir); err == nil {
for _, path := range paths {
if filepath.Base(path) == key {
return true
}
}
}
return false
}
func recordSessionDisplay(dir, sessionPath, content, display string) error {
if strings.TrimSpace(sessionPath) == "" || content == display || strings.TrimSpace(display) == "" {
return nil
+90
View File
@@ -0,0 +1,90 @@
//go:build !windows
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"golang.org/x/sys/unix"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
)
// simulateForeignSessionLeaseHolder holds path's lease the way another process
// would: the on-disk info names a foreign writer and a raw flock on a separate
// fd keeps the lock file locked without registering in this process's
// in-process lease bookkeeping (flock excludes other fds even within one
// process, so this faithfully mimics a foreign holder).
func simulateForeignSessionLeaseHolder(t *testing.T, path string) {
t.Helper()
if err := agent.SaveSessionLeaseInfo(path, agent.SessionLeaseInfo{
SessionPath: path,
WriterID: "other-host-4242-cafebabe",
PID: os.Getpid() + 1,
AcquiredAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("SaveSessionLeaseInfo: %v", err)
}
f, err := os.OpenFile(path+".lease.lock", os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
t.Fatalf("open lease lock: %v", err)
}
if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
_ = f.Close()
t.Fatalf("flock lease lock: %v", err)
}
t.Cleanup(func() {
_ = unix.Flock(int(f.Fd()), unix.LOCK_UN)
_ = f.Close()
_ = os.Remove(path + ".lease.lock")
_ = os.Remove(path + ".lease.json")
})
}
func TestDeleteSessionKeepsDuplicateLiveSessionHeldByOtherRuntime(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
path := filepath.Join(dir, "duplicate-foreign-held.jsonl")
content := []byte(`{"role":"user","content":"same recovery"}` + "\n")
if err := os.WriteFile(path, content, 0o644); err != nil {
t.Fatalf("write live session: %v", err)
}
trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
t.Fatalf("create trash dir: %v", err)
}
if err := os.WriteFile(trashPath, content, 0o644); err != nil {
t.Fatalf("write trash session: %v", err)
}
simulateForeignSessionLeaseHolder(t, path)
activePath := filepath.Join(dir, "active.jsonl")
if err := os.WriteFile(activePath, []byte(`{"role":"user","content":"active"}`+"\n"), 0o644); err != nil {
t.Fatalf("write active session: %v", err)
}
activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"})
defer activeCtrl.Close()
app := &App{
tabs: map[string]*WorkspaceTab{"active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true}},
activeTabID: "active",
tabOrder: []string{"active"},
}
err := app.DeleteSession(filepath.Base(path))
if err == nil || !strings.Contains(err.Error(), errSessionBusyElsewhere.Error()) {
t.Fatalf("DeleteSession err = %v, want refusal while a foreign runtime holds the lease", err)
}
if got, readErr := os.ReadFile(path); readErr != nil || string(got) != string(content) {
t.Fatalf("live session must stay intact, got %q err=%v", string(got), readErr)
}
}
+159
View File
@@ -165,8 +165,18 @@ func TestDeleteSessionFile(t *testing.T) {
os.WriteFile(metaPath, []byte("{}"), 0o644)
goalPath := store.SessionGoalState(sessionPath)
os.WriteFile(goalPath, []byte(`{"goal":"ship"}`), 0o644)
eventLogPath := store.SessionEventLog(sessionPath)
os.WriteFile(eventLogPath, []byte(`{"schema_version":1,"type":"replace","messages":[{"role":"user","content":"event"}]}`+"\n"), 0o644)
eventIndexPath := store.SessionEventIndex(sessionPath)
os.WriteFile(eventIndexPath, []byte(`{"schema_version":1,"message_count":1}`), 0o644)
telemetryPath := sessionTelemetryPath(sessionPath)
os.WriteFile(telemetryPath, []byte(`{"version":2,"readFiles":[]}`), 0o644)
lockPath := store.SessionLockFile(sessionPath)
os.WriteFile(lockPath, nil, 0o644)
leaseLockPath := store.SessionLeaseLock(sessionPath)
os.WriteFile(leaseLockPath, nil, 0o644)
leaseInfoPath := store.SessionLeaseInfo(sessionPath)
os.WriteFile(leaseInfoPath, []byte(`{"writer_id":"stale"}`), 0o644)
ckptDir := filepath.Join(dir, "session.ckpt")
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
t.Fatalf("mkdir ckpt: %v", err)
@@ -192,6 +202,8 @@ func TestDeleteSessionFile(t *testing.T) {
trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
trashMetaPath := trashPath + ".meta"
trashGoalPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.goal-state.json")
trashEventLogPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.events.jsonl")
trashEventIndexPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.event-index.json")
trashTelemetryPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.telemetry.json")
trashCkptDir := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.ckpt")
trashJobsDir := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jobs")
@@ -206,9 +218,24 @@ func TestDeleteSessionFile(t *testing.T) {
if _, err := os.Stat(goalPath); !os.IsNotExist(err) {
t.Error("session goal state should be removed from active sessions")
}
if _, err := os.Stat(eventLogPath); !os.IsNotExist(err) {
t.Error("session event log should be removed from active sessions")
}
if _, err := os.Stat(eventIndexPath); !os.IsNotExist(err) {
t.Error("session event index should be removed from active sessions")
}
if _, err := os.Stat(telemetryPath); !os.IsNotExist(err) {
t.Error("session telemetry should be removed from active sessions")
}
if _, err := os.Stat(lockPath); !os.IsNotExist(err) {
t.Error("session lock should be removed from active sessions")
}
if _, err := os.Stat(leaseLockPath); !os.IsNotExist(err) {
t.Error("session lease lock should be removed from active sessions")
}
if _, err := os.Stat(leaseInfoPath); !os.IsNotExist(err) {
t.Error("session lease info should be removed from active sessions")
}
if _, err := os.Stat(ckptDir); !os.IsNotExist(err) {
t.Error("session checkpoints should be removed from active sessions")
}
@@ -224,9 +251,24 @@ func TestDeleteSessionFile(t *testing.T) {
if _, err := os.Stat(trashGoalPath); err != nil {
t.Fatalf("session goal state should be in trash: %v", err)
}
if _, err := os.Stat(trashEventLogPath); err != nil {
t.Fatalf("session event log should be in trash: %v", err)
}
if _, err := os.Stat(trashEventIndexPath); err != nil {
t.Fatalf("session event index should be in trash: %v", err)
}
if _, err := os.Stat(trashTelemetryPath); err != nil {
t.Fatalf("session telemetry should be in trash: %v", err)
}
for _, p := range []string{
filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.lock"),
filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.lease.lock"),
filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.lease.json"),
} {
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Fatalf("ephemeral session artifact should not be moved to trash: %s (err=%v)", p, err)
}
}
if _, err := os.Stat(trashCkptDir); err != nil {
t.Fatalf("session checkpoints should be in trash: %v", err)
}
@@ -442,6 +484,14 @@ func TestRestoreTrashedSessionFile(t *testing.T) {
if err := os.WriteFile(goalPath, []byte(`{"goal":"restore"}`), 0o644); err != nil {
t.Fatal(err)
}
eventLogPath := store.SessionEventLog(sessionPath)
if err := os.WriteFile(eventLogPath, []byte(`{"schema_version":1,"type":"replace","messages":[{"role":"user","content":"event"}]}`+"\n"), 0o644); err != nil {
t.Fatal(err)
}
eventIndexPath := store.SessionEventIndex(sessionPath)
if err := os.WriteFile(eventIndexPath, []byte(`{"schema_version":1,"message_count":1}`), 0o644); err != nil {
t.Fatal(err)
}
telemetryPath := sessionTelemetryPath(sessionPath)
if err := os.WriteFile(telemetryPath, []byte(`{"version":2,"readFiles":[]}`), 0o644); err != nil {
t.Fatal(err)
@@ -481,6 +531,12 @@ func TestRestoreTrashedSessionFile(t *testing.T) {
if _, err := os.Stat(goalPath); err != nil {
t.Fatalf("session goal state should be restored: %v", err)
}
if _, err := os.Stat(eventLogPath); err != nil {
t.Fatalf("session event log should be restored: %v", err)
}
if _, err := os.Stat(eventIndexPath); err != nil {
t.Fatalf("session event index should be restored: %v", err)
}
if _, err := os.Stat(telemetryPath); err != nil {
t.Fatalf("session telemetry should be restored: %v", err)
}
@@ -523,6 +579,51 @@ func TestRestoreTrashedSessionFileWithEmptyLiveStub(t *testing.T) {
}
}
func TestRestoreTrashedSessionFileFromUniqueTrashItem(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "restore-unique.jsonl")
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"old"}`+"\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := deleteSessionFile(dir, sessionPath); err != nil {
t.Fatalf("trash old: %v", err)
}
fixedTrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"new"}`+"\n"), 0o644); err != nil {
t.Fatalf("write new live session: %v", err)
}
if err := deleteSessionFile(dir, sessionPath); err != nil {
t.Fatalf("trash new with fixed trash conflict: %v", err)
}
trashed, err := listTrashedSessionFiles(dir)
if err != nil {
t.Fatalf("list trash: %v", err)
}
var uniqueTrashPath string
for _, candidate := range trashed {
if candidate != fixedTrashPath && filepath.Base(candidate) == filepath.Base(sessionPath) {
uniqueTrashPath = candidate
break
}
}
if uniqueTrashPath == "" {
t.Fatalf("unique trash path not listed, got %#v", trashed)
}
if err := restoreTrashedSessionFile(dir, uniqueTrashPath); err != nil {
t.Fatalf("restore unique trash item: %v", err)
}
if got, err := os.ReadFile(sessionPath); err != nil || !strings.Contains(string(got), "new") {
t.Fatalf("restored session = %q err=%v, want new content", string(got), err)
}
if _, err := os.Stat(filepath.Dir(uniqueTrashPath)); !os.IsNotExist(err) {
t.Fatalf("unique trash item should be removed after restore, stat err = %v", err)
}
if _, err := os.Stat(fixedTrashPath); err != nil {
t.Fatalf("original fixed trash item should remain: %v", err)
}
}
func TestValidateSessionTrashTargetKeepsDiscardableLiveStub(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "discardable-live.jsonl")
@@ -731,12 +832,20 @@ func TestRemoveDesktopSessionArtifactsRemovesOwnedSidecars(t *testing.T) {
sessionPath,
store.SessionMeta(sessionPath),
store.SessionGoalState(sessionPath),
store.SessionEventLog(sessionPath),
store.SessionEventIndex(sessionPath),
sessionTelemetryPath(sessionPath),
store.SessionLockFile(sessionPath),
store.SessionLeaseLock(sessionPath),
store.SessionLeaseInfo(sessionPath),
} {
if err := os.WriteFile(p, []byte("{}"), 0o644); err != nil {
t.Fatalf("write %s: %v", p, err)
}
}
if err := recordSessionDisplay(dir, sessionPath, "expanded prompt", "[Pasted text #1 · 2 lines]"); err != nil {
t.Fatalf("record display: %v", err)
}
ckptDir := store.SessionCheckpointDir(sessionPath)
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
t.Fatal(err)
@@ -760,7 +869,12 @@ func TestRemoveDesktopSessionArtifactsRemovesOwnedSidecars(t *testing.T) {
sessionPath,
store.SessionMeta(sessionPath),
store.SessionGoalState(sessionPath),
store.SessionEventLog(sessionPath),
store.SessionEventIndex(sessionPath),
sessionTelemetryPath(sessionPath),
store.SessionLockFile(sessionPath),
store.SessionLeaseLock(sessionPath),
store.SessionLeaseInfo(sessionPath),
ckptDir,
jobsDir,
} {
@@ -768,6 +882,9 @@ func TestRemoveDesktopSessionArtifactsRemovesOwnedSidecars(t *testing.T) {
t.Fatalf("%s should be removed, stat err = %v", p, err)
}
}
if got := resolveSessionDisplay(dir, sessionPath, "expanded prompt"); got != "expanded prompt" {
t.Fatalf("session display key should be removed, got %q", got)
}
}
func TestDeleteSessionFileRejectsOutsideDir(t *testing.T) {
@@ -956,6 +1073,48 @@ func TestSessionDisplayRoundTrip(t *testing.T) {
}
}
func TestPruneSessionDisplaysRemovesOnlyOrphans(t *testing.T) {
dir := t.TempDir()
content := "expanded prompt"
if err := saveSessionDisplays(dir, sessionDisplayMap{
"live.jsonl": map[string]string{messageDisplayKey(content): "live display"},
"trashed.jsonl": map[string]string{messageDisplayKey(content): "trash display"},
"protected.jsonl": map[string]string{messageDisplayKey(content): "protected display"},
"missing.jsonl": map[string]string{messageDisplayKey(content): "missing display"},
"sidecar.events.jsonl": map[string]string{
messageDisplayKey(content): "invalid display",
},
}); err != nil {
t.Fatalf("save displays: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "live.jsonl"), []byte("data"), 0o644); err != nil {
t.Fatalf("write live session: %v", err)
}
trashDir := filepath.Join(dir, sessionTrashDir, "trashed.jsonl")
if err := os.MkdirAll(trashDir, 0o755); err != nil {
t.Fatalf("mkdir trash: %v", err)
}
if err := os.WriteFile(filepath.Join(trashDir, "trashed.jsonl"), []byte("data"), 0o644); err != nil {
t.Fatalf("write trashed session: %v", err)
}
if err := pruneSessionDisplays(dir, map[string]struct{}{"protected.jsonl": {}}); err != nil {
t.Fatalf("prune displays: %v", err)
}
got := loadSessionDisplays(dir)
for _, key := range []string{"live.jsonl", "trashed.jsonl", "protected.jsonl"} {
if got[key] == nil {
t.Fatalf("%s display should be retained; got %#v", key, got)
}
}
for _, key := range []string{"missing.jsonl", "sidecar.events.jsonl"} {
if got[key] != nil {
t.Fatalf("%s display should be pruned; got %#v", key, got)
}
}
}
func TestRecordSessionDisplaySkipsNoop(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "s.jsonl")
+516 -171
View File
File diff suppressed because it is too large. Load diff
+131
View File
@@ -1217,3 +1217,134 @@ func TestSaveHooksSettingsForRootUsesDisplayedProjectRoot(t *testing.T) {
t.Fatal("active project root was written instead of displayed project root")
}
}
// TestLoadDesktopUserConfigForViewDoesNotPersistLegacyProviderAccess locks the
// read-path contract: loading a legacy-form config (configured providers but
// no declared desktop.provider_access) through the View helpers returns a
// normalized in-memory view while leaving the file bytes untouched. The
// on-disk migration only happens once a locked write path runs.
func TestLoadDesktopUserConfigForViewDoesNotPersistLegacyProviderAccess(t *testing.T) {
isolateDesktopUserDirs(t)
userPath := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
t.Fatal(err)
}
legacy := "default_model = \"local/m1\"\n\n[[providers]]\nname = \"local\"\nbase_url = \"http://127.0.0.1:9999/v1\"\nmodels = [\"m1\"]\n"
if err := os.WriteFile(userPath, []byte(legacy), 0o644); err != nil {
t.Fatal(err)
}
app := NewApp()
for name, load := range map[string]func() (*config.Config, string, error){
"view": app.loadDesktopUserConfigForView,
"view-with-credentials": app.loadDesktopUserConfigForViewWithCredentials,
} {
cfg, _, err := load()
if err != nil {
t.Fatalf("%s load: %v", name, err)
}
if len(cfg.Desktop.ProviderAccess) == 0 {
t.Fatalf("%s load should normalize legacy provider access in memory", name)
}
raw, err := os.ReadFile(userPath)
if err != nil {
t.Fatal(err)
}
if string(raw) != legacy {
t.Fatalf("%s load must not rewrite the user config, got:\n%s", name, raw)
}
}
// The first locked write path persists the pending migration.
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
t.Fatalf("applyConfigOnly: %v", err)
}
if !configDeclaresProviderAccess(userPath) {
t.Fatal("locked write path should persist the provider access migration to disk")
}
migrated := config.LoadForEditWithoutCredentials(userPath)
if len(migrated.Desktop.ProviderAccess) == 0 {
t.Fatalf("migrated config lost provider access: %v", migrated.Desktop.ProviderAccess)
}
}
// TestLoadDesktopUserConfigViewKeepsLegacyBotConfigMigrationInMemory locks the
// same contract for the legacy bot-config migration: read paths (including the
// bot runtime's credential-loading view) see the merged bot config in memory
// without any file being written; the locked write path performs the on-disk
// migration.
func TestLoadDesktopUserConfigViewKeepsLegacyBotConfigMigrationInMemory(t *testing.T) {
isolateDesktopUserDirs(t)
userPath := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
t.Fatal(err)
}
userBody := "default_model = \"local/m1\"\n"
if err := os.WriteFile(userPath, []byte(userBody), 0o644); err != nil {
t.Fatal(err)
}
legacyRoot := t.TempDir()
legacyPath := filepath.Join(legacyRoot, "reasonix.toml")
legacyBody := "[bot]\nenabled = true\nmodel = \"local/m1\"\n"
if err := os.WriteFile(legacyPath, []byte(legacyBody), 0o644); err != nil {
t.Fatal(err)
}
app := NewApp()
app.tabs = map[string]*WorkspaceTab{
"t": {ID: "t", Scope: "project", WorkspaceRoot: legacyRoot, Ready: true},
}
app.activeTabID = "t"
assertFilesUntouched := func(step string) {
t.Helper()
rawUser, err := os.ReadFile(userPath)
if err != nil {
t.Fatal(err)
}
if string(rawUser) != userBody {
t.Fatalf("%s must not rewrite the user config, got:\n%s", step, rawUser)
}
rawLegacy, err := os.ReadFile(legacyPath)
if err != nil {
t.Fatal(err)
}
if string(rawLegacy) != legacyBody {
t.Fatalf("%s must not rewrite the legacy config, got:\n%s", step, rawLegacy)
}
}
cfg, _, err := app.loadDesktopUserConfigForView()
if err != nil {
t.Fatalf("loadDesktopUserConfigForView: %v", err)
}
if !cfg.Bot.Enabled {
t.Fatal("view load should merge the legacy bot config in memory")
}
assertFilesUntouched("loadDesktopUserConfigForView")
botCfg, err := app.loadDesktopBotConfig()
if err != nil {
t.Fatalf("loadDesktopBotConfig: %v", err)
}
if !botCfg.Bot.Enabled {
t.Fatal("bot runtime load should see the merged legacy bot config")
}
assertFilesUntouched("loadDesktopBotConfig")
// The first locked write path migrates the bot config into the user file.
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
t.Fatalf("applyConfigOnly: %v", err)
}
migrated := config.LoadForEditWithoutCredentials(userPath)
if !migrated.Bot.Enabled {
t.Fatal("locked write path should persist the legacy bot config migration")
}
rawLegacy, err := os.ReadFile(legacyPath)
if err != nil {
t.Fatal(err)
}
if string(rawLegacy) != legacyBody {
t.Fatalf("migration must not rewrite the legacy config, got:\n%s", rawLegacy)
}
}
+23 -2
View File
@@ -135,12 +135,33 @@ func (a *App) releaseSharedHost(root string) {
}
func (a *App) releaseTabSharedHost(tab *WorkspaceTab) {
if tab == nil || tab.SharedHostKey == "" {
if tab == nil {
return
}
// SharedHostKey is a.mu-guarded (the build goroutine publishes it under
// the lock); do the take under the lock and the slow host release after.
// Callers must not hold a.mu.
a.mu.Lock()
key := takeTabSharedHostKey(tab)
a.mu.Unlock()
if key == "" {
return
}
a.releaseSharedHost(key)
}
// takeTabSharedHostKey clears the tab's shared-host key and returns it so the
// caller can release it later. Use from inside a.mu critical sections:
// releaseSharedHost may close the host and reap MCP subprocesses, which is far
// too slow to run under the app lock — call a.releaseSharedHost(key) after
// unlocking.
func takeTabSharedHostKey(tab *WorkspaceTab) string {
if tab == nil || tab.SharedHostKey == "" {
return ""
}
key := tab.SharedHostKey
tab.SharedHostKey = ""
a.releaseSharedHost(key)
return key
}
// closeAllSharedHosts closes every shared host. Called during app shutdown.
+1 -1
View File
@@ -18,7 +18,7 @@ func sharedHostRefsForTest(t *testing.T, app *App, root string) (int, bool) {
func TestCloneDetachedRuntimeTabPreservesSharedHostKey(t *testing.T) {
tab := &WorkspaceTab{ID: "tab", SharedHostKey: "workspace-root"}
detached := cloneDetachedRuntimeTab(tab, "session-key")
detached := cloneDetachedRuntimeTab(tab, "session-key", "session-key")
if detached == nil {
t.Fatal("cloneDetachedRuntimeTab returned nil")
}
+1409 -416
View File
File diff suppressed because it is too large. Load diff
+208 -3
View File
@@ -74,6 +74,30 @@ func (s *snapshotObservingSession) Snapshot() error {
return nil
}
func expectAppMutexAvailableDuringSnapshot(t *testing.T, app *App, checks chan<- struct{}) func() {
t.Helper()
return func() {
acquired := make(chan struct{})
go func() {
app.mu.Lock()
app.mu.Unlock() //nolint:staticcheck // probe: lock must be immediately acquirable
close(acquired)
}()
select {
case <-acquired:
case <-time.After(500 * time.Millisecond):
t.Error("Snapshot ran while holding app mutex")
}
if checks == nil {
return
}
select {
case checks <- struct{}{}:
default:
}
}
}
func TestListTabsKeepsExplicitOrderWhenActiveChanges(t *testing.T) {
app := testAppWithOrderedTabs(t, "b", "a", "b", "c")
@@ -182,6 +206,90 @@ func TestKeepOnlyVisibleTabDetachesRunningHiddenTab(t *testing.T) {
ctrl.Close()
}
func TestKeepOnlyVisibleTabSnapshotsHiddenTabWithoutAppLock(t *testing.T) {
isolateDesktopUserDirs(t)
app := &App{
tabs: map[string]*WorkspaceTab{},
tabOrder: []string{"hidden", "target"},
activeTabID: "hidden",
}
snapshotChecks := make(chan struct{}, 2)
hiddenCtrl := &snapshotObservingSession{
SessionAPI: control.New(control.Options{Label: "hidden"}),
onSnapshot: expectAppMutexAvailableDuringSnapshot(t, app, snapshotChecks),
}
hidden := &WorkspaceTab{
ID: "hidden",
Scope: "global",
WorkspaceRoot: globalTabWorkspaceRoot(),
TopicID: "topic-hidden",
Ctrl: hiddenCtrl,
Ready: true,
sink: &tabEventSink{tabID: "hidden"},
disabledMCP: map[string]ServerView{},
}
target := &WorkspaceTab{
ID: "target",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
app.tabs["hidden"] = hidden
app.tabs["target"] = target
if _, err := app.keepOnlyVisibleTab("target"); err != nil {
t.Fatalf("keepOnlyVisibleTab: %v", err)
}
select {
case <-snapshotChecks:
case <-time.After(time.Second):
t.Fatal("hidden tab was not snapshotted before pruning")
}
assertTabIDs(t, app.ListTabs(), "target")
}
func TestCloseTabSnapshotsWithoutAppLock(t *testing.T) {
isolateDesktopUserDirs(t)
app := &App{
tabs: map[string]*WorkspaceTab{},
tabOrder: []string{"closing", "survivor"},
activeTabID: "closing",
}
snapshotChecks := make(chan struct{}, 1)
closingCtrl := &snapshotObservingSession{
SessionAPI: control.New(control.Options{Label: "closing"}),
onSnapshot: expectAppMutexAvailableDuringSnapshot(t, app, snapshotChecks),
}
closing := &WorkspaceTab{
ID: "closing",
Scope: "global",
WorkspaceRoot: globalTabWorkspaceRoot(),
TopicID: "topic-closing",
Ctrl: closingCtrl,
Ready: true,
sink: &tabEventSink{tabID: "closing"},
disabledMCP: map[string]ServerView{},
}
survivor := &WorkspaceTab{
ID: "survivor",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
app.tabs["closing"] = closing
app.tabs["survivor"] = survivor
if err := app.CloseTab("closing"); err != nil {
t.Fatalf("CloseTab: %v", err)
}
select {
case <-snapshotChecks:
case <-time.After(time.Second):
t.Fatal("closing tab was not snapshotted")
}
assertTabIDs(t, app.ListTabs(), "survivor")
}
func TestKeepOnlyVisibleTabCancelsBuildingHiddenTab(t *testing.T) {
isolateDesktopUserDirs(t)
cancelled := false
@@ -293,6 +401,60 @@ func TestClearTabBuildCancelCancelsAbandonedBuildContext(t *testing.T) {
}
}
func TestEnsureSessionLeaseSerializesConcurrentSameTabAcquire(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := filepath.Join(dir, "same-tab-concurrent-lease.jsonl")
tab := &WorkspaceTab{ID: "tab"}
t.Cleanup(tab.releaseSessionLease)
acquired := make(chan struct{})
releaseHook := make(chan struct{})
var once sync.Once
sessionLeaseAcquireHookForTest = func() {
once.Do(func() {
close(acquired)
<-releaseHook
})
}
t.Cleanup(func() { sessionLeaseAcquireHookForTest = nil })
firstErr := make(chan error, 1)
go func() {
firstErr <- tab.ensureSessionLease(path)
}()
select {
case <-acquired:
case err := <-firstErr:
t.Fatalf("first ensureSessionLease returned before hook: %v", err)
case <-time.After(2 * time.Second):
t.Fatal("first ensureSessionLease did not acquire lease")
}
secondErr := make(chan error, 1)
go func() {
secondErr <- tab.ensureSessionLease(path)
}()
select {
case err := <-secondErr:
t.Fatalf("second ensureSessionLease returned while first acquire was unbound: %v", err)
case <-time.After(50 * time.Millisecond):
}
close(releaseHook)
if err := <-firstErr; err != nil {
t.Fatalf("first ensureSessionLease: %v", err)
}
if err := <-secondErr; err != nil {
t.Fatalf("second ensureSessionLease should reuse the tab lease: %v", err)
}
}
func TestAttachExistingSessionRuntimeSkipsRemovedTab(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
@@ -399,6 +561,38 @@ func TestRemoveWorkspaceSnapshotsProjectTabBeforeRemovingBinding(t *testing.T) {
}
}
func TestRemoveWorkspaceSnapshotsProjectTabWithoutAppLock(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
if err := addProject(projectRoot, "Project"); err != nil {
t.Fatalf("add project: %v", err)
}
app := &App{
tabs: map[string]*WorkspaceTab{
"project": {ID: "project", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "topic-project", Ready: true, disabledMCP: map[string]ServerView{}},
"global": {ID: "global", Scope: "global", WorkspaceRoot: globalTabWorkspaceRoot(), TopicID: "topic-global", Ready: true, disabledMCP: map[string]ServerView{}},
},
tabOrder: []string{"project", "global"},
activeTabID: "project",
detachedSessions: map[string]*WorkspaceTab{},
}
snapshotChecks := make(chan struct{}, 1)
app.tabs["project"].Ctrl = &snapshotObservingSession{
SessionAPI: control.New(control.Options{Label: "project"}),
onSnapshot: expectAppMutexAvailableDuringSnapshot(t, app, snapshotChecks),
}
if err := app.RemoveWorkspace(projectRoot); err != nil {
t.Fatalf("RemoveWorkspace: %v", err)
}
select {
case <-snapshotChecks:
case <-time.After(time.Second):
t.Fatal("project tab was not snapshotted before removing workspace")
}
assertTabIDs(t, app.ListTabs(), "global")
}
func TestRemoveWorkspaceRejectsRunningProjectRuntime(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
@@ -454,12 +648,16 @@ func TestListTabsRepairsStaleOrderWithoutRacing(t *testing.T) {
var wg sync.WaitGroup
start := make(chan struct{})
errs := make(chan string, 8)
iterations := 100
if testing.Short() {
iterations = 5
}
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
for j := 0; j < 100; j++ {
for j := 0; j < iterations; j++ {
if got := strings.Join(tabIDs(app.ListTabs()), ","); got != "a,b,c" {
errs <- got
return
@@ -711,8 +909,15 @@ func TestBuildTabControllerBlocksWhenSessionLeaseHeld(t *testing.T) {
if !tab.Ready {
t.Fatal("tab should be ready with startup error")
}
if !strings.Contains(tab.StartupErr, agent.ErrSessionLeaseHeld.Error()) {
t.Fatalf("startup error = %q, want lease-held error", tab.StartupErr)
// The surfaced startup error is the sanitized busy message: the raw lease
// error would leak the session path and the holder's host-pid-writer id
// into the topbar banner.
if !strings.Contains(tab.StartupErr, "already open in another Reasonix window") {
t.Fatalf("startup error = %q, want user-facing busy message", tab.StartupErr)
}
if strings.Contains(tab.StartupErr, agent.ErrSessionLeaseHeld.Error()) ||
strings.Contains(tab.StartupErr, path) {
t.Fatalf("startup error leaked raw lease details: %q", tab.StartupErr)
}
}
+348
View File
@@ -491,6 +491,333 @@ func TestTopicMigrationMarkerRescansWhenSessionFileChanges(t *testing.T) {
}
}
func TestProjectTreeRepairsIndexedGlobalTopicsAfterMigrationMarker(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
if err := addProject(t.TempDir(), "Existing project"); err != nil {
t.Fatalf("add project: %v", err)
}
sessionPath := writeLegacySession(t, dir, "desktop-legacy.jsonl", "who are you", time.Now().Add(-time.Hour))
topicID := "legacy_desktop-legacy_1234"
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
ID: agent.BranchID(sessionPath),
CreatedAt: time.Now().Add(-2 * time.Hour),
UpdatedAt: time.Now().Add(-time.Hour),
Scope: "global",
TopicID: topicID,
TopicTitle: "你是谁",
Turns: 1,
Preview: "who are you",
}); err != nil {
t.Fatalf("save meta: %v", err)
}
markTopicMigrationDone(dir)
repaired := migrateLegacySessionsIntoGlobalTopics(dir)
if len(repaired) != 1 || repaired[0] != topicID {
t.Fatalf("repaired topics = %#v, want %q", repaired, topicID)
}
nodes := NewApp().ListProjectTree()
var global *ProjectNode
for i := range nodes {
if nodes[i].Kind == "global_folder" {
global = &nodes[i]
break
}
}
if global == nil {
t.Fatalf("project tree = %#v, want repaired Global folder", nodes)
}
if len(global.Children) != 1 || global.Children[0].TopicID != topicID || global.Children[0].Label != "你是谁" {
t.Fatalf("global children = %#v, want repaired topic %q with preserved title", global.Children, topicID)
}
f := loadProjectsFile()
if !containsDesktopString(f.GlobalTopics, topicID) {
t.Fatalf("globalTopics = %#v, want %q", f.GlobalTopics, topicID)
}
if got := loadTopicTitle("", topicID); got != "你是谁" {
t.Fatalf("global topic title = %q, want 你是谁", got)
}
}
func TestDeletedRepairedGlobalTopicIsNotAutoRestored(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
if err := addProject(t.TempDir(), "Existing project"); err != nil {
t.Fatalf("add project: %v", err)
}
sessionPath := writeLegacySession(t, dir, "desktop-delete.jsonl", "delete repaired topic", time.Now().Add(-time.Hour))
topicID := "legacy_desktop-delete_1234"
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
ID: agent.BranchID(sessionPath),
CreatedAt: time.Now().Add(-2 * time.Hour),
UpdatedAt: time.Now().Add(-time.Hour),
Scope: "global",
TopicID: topicID,
TopicTitle: "临时 Global",
Turns: 1,
Preview: "delete repaired topic",
}); err != nil {
t.Fatalf("save meta: %v", err)
}
markTopicMigrationDone(dir)
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 1 || repaired[0] != topicID {
t.Fatalf("initial repaired topics = %#v, want %q", repaired, topicID)
}
if err := NewApp().DeleteTopic(topicID); err != nil {
t.Fatalf("delete repaired topic: %v", err)
}
time.Sleep(10 * time.Millisecond)
later := time.Now()
if err := os.Chtimes(sessionPath, later, later); err != nil {
t.Fatalf("touch session after delete: %v", err)
}
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 0 {
t.Fatalf("deleted repaired topic was restored: %#v", repaired)
}
f := loadProjectsFile()
if containsDesktopString(f.GlobalTopics, topicID) {
t.Fatalf("globalTopics = %#v, deleted topic %q should stay removed", f.GlobalTopics, topicID)
}
if got := loadTopicTitle("", topicID); got != "" {
t.Fatalf("global topic title = %q, want deleted", got)
}
if !containsDesktopString(f.DeletedTopics, topicID) {
t.Fatalf("deletedTopics = %#v, want tombstone for %q", f.DeletedTopics, topicID)
}
}
func TestRepairRescanKeepsIndexedTopicsUntouched(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
if err := addProject(t.TempDir(), "Existing project"); err != nil {
t.Fatalf("add project: %v", err)
}
writeIndexedSession := func(name, topicID, title string, at time.Time) string {
p := writeLegacySession(t, dir, name, "prompt "+title, at)
if err := agent.SaveBranchMetaPreserveUpdated(p, agent.BranchMeta{
ID: agent.BranchID(p),
CreatedAt: at.Add(-time.Hour),
UpdatedAt: at,
Scope: "global",
TopicID: topicID,
TopicTitle: title,
Turns: 1,
Preview: "prompt " + title,
}); err != nil {
t.Fatalf("save meta %s: %v", name, err)
}
if err := os.Chtimes(p, at, at); err != nil {
t.Fatalf("chtimes %s: %v", name, err)
}
return p
}
now := time.Now()
olderPath := writeIndexedSession("older.jsonl", "legacy_older_000000000001", "旧话题", now.Add(-3*time.Hour))
writeIndexedSession("newer.jsonl", "legacy_newer_000000000002", "新话题", now.Add(-time.Hour))
markTopicMigrationDone(dir)
// First pass repairs both missing topics.
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 2 {
t.Fatalf("initial repaired topics = %#v, want 2 entries", repaired)
}
before := loadProjectsFile()
projPath := filepath.Join(desktopConfigDir(), desktopProjectsFile)
statBefore, err := os.Stat(projPath)
if err != nil {
t.Fatalf("stat projects file: %v", err)
}
// Ordinary session activity invalidates the repair marker; the rescan must
// not reorder the indexed topics, rewrite the projects file, or report the
// already-visible topics as repaired (the callers bind blank Global tabs
// to repaired[0]).
time.Sleep(10 * time.Millisecond)
later := time.Now()
if err := os.Chtimes(olderPath, later, later); err != nil {
t.Fatalf("touch session: %v", err)
}
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 0 {
t.Fatalf("steady-state rescan reported repairs: %#v", repaired)
}
after := loadProjectsFile()
if !sameStringList(before.GlobalTopics, after.GlobalTopics) {
t.Fatalf("rescan reordered globalTopics: before=%v after=%v", before.GlobalTopics, after.GlobalTopics)
}
statAfter, err := os.Stat(projPath)
if err != nil {
t.Fatalf("stat projects file: %v", err)
}
if !statAfter.ModTime().Equal(statBefore.ModTime()) {
t.Fatalf("steady-state rescan rewrote the projects file")
}
}
func TestBatchPrependRespectsTombstoneWrittenAfterScanSnapshot(t *testing.T) {
isolateDesktopUserDirs(t)
topicID := "legacy_race_000000000001"
// Simulate a DeleteTopic landing between a repair scan's DeletedTopics
// snapshot and its batch write: the tombstone exists by the time the
// prepend runs, so the batch must drop the topic instead of resurrecting it.
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
f.DeletedTopics = prependUniqueString(f.DeletedTopics, topicID)
return true, nil
}); err != nil {
t.Fatalf("seed tombstone: %v", err)
}
if err := prependTopicsInProjectsFile("", []string{topicID, "legacy_live_000000000002"}, false); err != nil {
t.Fatalf("batch prepend: %v", err)
}
f := loadProjectsFile()
if containsDesktopString(f.GlobalTopics, topicID) {
t.Fatalf("globalTopics = %#v, tombstoned topic %q must not be batch-prepended", f.GlobalTopics, topicID)
}
if !containsDesktopString(f.GlobalTopics, "legacy_live_000000000002") {
t.Fatalf("globalTopics = %#v, live topic should still be prepended", f.GlobalTopics)
}
if !containsDesktopString(f.DeletedTopics, topicID) {
t.Fatalf("deletedTopics = %#v, tombstone should survive a batch prepend", f.DeletedTopics)
}
// Intentional single-topic writes (create/restore/tab indexing) clear the
// tombstone and bring the topic back in the same projects-file transaction.
if err := prependTopicInProjectsFile("", topicID, false); err != nil {
t.Fatalf("single prepend: %v", err)
}
f = loadProjectsFile()
if !containsDesktopString(f.GlobalTopics, topicID) {
t.Fatalf("globalTopics = %#v, single prepend should restore %q", f.GlobalTopics, topicID)
}
if containsDesktopString(f.DeletedTopics, topicID) {
t.Fatalf("deletedTopics = %#v, single prepend should clear the tombstone", f.DeletedTopics)
}
}
func TestTombstonedTitleOnlyTopicStaysHiddenInProjectTree(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
if err := addProject(t.TempDir(), "Existing project"); err != nil {
t.Fatalf("add project: %v", err)
}
// Control: a legitimate title-only topic (not in GlobalTopics) must keep
// rendering through the orderedTopicIDs title-map fallback.
controlID := "topic_control_visible"
if err := setTopicTitle("", controlID, "正常话题"); err != nil {
t.Fatalf("set control title: %v", err)
}
// Race product: DeleteTopic landed, but a stale whole-map save wrote the
// topic's title back — tombstoned, absent from GlobalTopics, title present.
tombstonedID := "legacy_raced_000000000009"
if err := setTopicTitle("", tombstonedID, "被删除的话题"); err != nil {
t.Fatalf("set stale 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)
}
nodes := NewApp().ListProjectTree()
var global *ProjectNode
for i := range nodes {
if nodes[i].Kind == "global_folder" {
global = &nodes[i]
break
}
}
if global == nil {
t.Fatalf("project tree = %#v, want Global folder", nodes)
}
seen := map[string]bool{}
for _, c := range global.Children {
seen[c.TopicID] = true
}
if seen[tombstonedID] {
t.Fatalf("global children = %#v, tombstoned title-only topic %q must stay hidden", global.Children, tombstonedID)
}
if !seen[controlID] {
t.Fatalf("global children = %#v, legitimate title-only topic %q should still render", global.Children, controlID)
}
}
func TestRepairPassPrunesStaleTitleOfDeletedTopic(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
if err := addProject(t.TempDir(), "Existing project"); err != nil {
t.Fatalf("add project: %v", err)
}
// Stale race product on disk: tombstoned topic whose title lingers in the
// global title map. The next repair pass that saves the whole map must
// prune it instead of persisting it again.
tombstonedID := "legacy_raced_000000000010"
if err := setTopicTitle("", tombstonedID, "被删除的话题"); err != nil {
t.Fatalf("set stale 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)
}
sessionPath := writeLegacySession(t, dir, "desktop-prune.jsonl", "needs repair", time.Now().Add(-time.Hour))
repairID := "legacy_desktop-prune_1234"
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
ID: agent.BranchID(sessionPath),
CreatedAt: time.Now().Add(-2 * time.Hour),
UpdatedAt: time.Now().Add(-time.Hour),
Scope: "global",
TopicID: repairID,
TopicTitle: "待修复话题",
Turns: 1,
Preview: "needs repair",
}); err != nil {
t.Fatalf("save meta: %v", err)
}
markTopicMigrationDone(dir)
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 1 || repaired[0] != repairID {
t.Fatalf("repaired topics = %#v, want %q", repaired, repairID)
}
if got := loadTopicTitle("", tombstonedID); got != "" {
t.Fatalf("stale title = %q, repair save should prune the tombstoned entry", got)
}
if got := loadTopicTitle("", repairID); got != "待修复话题" {
t.Fatalf("repaired title = %q, want 待修复话题", got)
}
f := loadProjectsFile()
if containsDesktopString(f.GlobalTopics, tombstonedID) {
t.Fatalf("globalTopics = %#v, tombstoned topic must stay out", f.GlobalTopics)
}
if !containsDesktopString(f.GlobalTopics, repairID) {
t.Fatalf("globalTopics = %#v, want repaired topic %q", f.GlobalTopics, repairID)
}
}
func TestTopicMigrationDefersEmptyLegacySession(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
@@ -1170,6 +1497,27 @@ func TestCreateTopicDefaultsToAutoNewSessionTitle(t *testing.T) {
}
}
func TestListProjectTreeFallsBackToTopicIDCreatedAt(t *testing.T) {
isolateDesktopUserDirs(t)
const topicID = "legacy_20260606-114914_2276f13fd87c"
if err := setTopicTitleWithSource("", topicID, "你好,你是谁", topicTitleSourceManual); err != nil {
t.Fatalf("set topic title: %v", err)
}
if err := prependTopicInProjectsFile("", topicID, false); err != nil {
t.Fatalf("prepend topic: %v", err)
}
nodes := NewApp().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)
}
expected := time.Date(2026, 6, 6, 11, 49, 14, 0, time.UTC).UnixMilli()
if got := nodes[0].Children[0].CreatedAt; got != expected {
t.Fatalf("project tree createdAt = %d, want %d", got, expected)
}
}
func TestCreateTopicAppearsFirstInProjectTree(t *testing.T) {
isolateDesktopUserDirs(t)
+29 -13
View File
@@ -38,7 +38,7 @@ func (a *App) startTray() bool {
a.tray = t
a.mu.Unlock()
t.end = startDesktopTray(func() {
end := startDesktopTray(func() {
systray.SetIcon(trayIconBytes)
systray.SetTitle("Reasonix")
systray.SetTooltip("Reasonix")
@@ -51,21 +51,25 @@ func (a *App) startTray() bool {
systray.SetOnSecondaryTapped(nil)
labels := trayMenuLabels(a.trayLocale())
t.openItem = systray.AddMenuItem(labels.openTitle, labels.openTooltip)
t.quitItem = systray.AddMenuItem(labels.quitTitle, labels.quitTooltip)
openItem := systray.AddMenuItem(labels.openTitle, labels.openTooltip)
quitItem := systray.AddMenuItem(labels.quitTitle, labels.quitTooltip)
// Publish the menu items under a.mu: this callback runs on the systray
// goroutine while bound settings calls (updateTrayLocale) read them.
a.mu.Lock()
t.openItem = openItem
t.quitItem = quitItem
a.trayReady = true
a.mu.Unlock()
t.markReady()
a.goSafe("trayOpenLoop", func() {
for range t.openItem.ClickedCh {
for range openItem.ClickedCh {
a.showFromTray()
}
})
a.goSafe("trayQuitLoop", func() {
for range t.quitItem.ClickedCh {
for range quitItem.ClickedCh {
a.quitFromTray()
}
})
@@ -77,35 +81,47 @@ func (a *App) startTray() bool {
}
a.mu.Unlock()
})
a.mu.Lock()
t.end = end
a.mu.Unlock()
return true
}
func (a *App) stopTray() {
a.mu.RLock()
t := a.tray
var end func()
if t != nil {
end = t.end
}
a.mu.RUnlock()
if t == nil || t.end == nil {
if t == nil || end == nil {
return
}
t.once.Do(t.end)
t.once.Do(end)
}
func (a *App) updateTrayLocale(locale string) {
a.mu.RLock()
t := a.tray
var openItem, quitItem *systray.MenuItem
if t != nil {
openItem = t.openItem
quitItem = t.quitItem
}
a.mu.RUnlock()
if t == nil || t.openItem == nil || t.quitItem == nil {
if openItem == nil || quitItem == nil {
return
}
labels := trayMenuLabels(locale)
t.openItem.SetTitle(labels.openTitle)
t.openItem.SetTooltip(labels.openTooltip)
t.quitItem.SetTitle(labels.quitTitle)
t.quitItem.SetTooltip(labels.quitTooltip)
openItem.SetTitle(labels.openTitle)
openItem.SetTooltip(labels.openTooltip)
quitItem.SetTitle(labels.quitTitle)
quitItem.SetTooltip(labels.quitTooltip)
}
func (a *App) trayLocale() string {
cfg, _, err := a.loadDesktopUserConfigForEdit()
cfg, _, err := a.loadDesktopUserConfigForView()
if err != nil {
return ""
}
+55 -18
View File
@@ -8,6 +8,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -34,11 +35,12 @@ import (
// the thin Wails binding that wires these into App methods and progress events.
// Manifest endpoints — R2 CDN first (fast, especially in CN), then the crash
// worker release gateway. The build channel picks the rolling pointer so a
// canary build polls the canary line and a stable build polls latest; the two
// never cross. The gateway deliberately avoids GitHub's repository-wide
// /releases/latest shortcut because CLI tags (v*) and desktop tags (desktop-v*)
// are separate release lines in the same repo.
// worker release gateway, then GitHub as the stable channel's last resort. The
// build channel picks the rolling pointer so a canary build polls the canary
// line and a stable build polls latest; the two never cross. The gateway
// deliberately avoids GitHub's repository-wide /releases/latest shortcut
// because CLI tags (v*) and desktop tags (desktop-v*) are separate release
// lines in the same repo.
const (
r2Base = "https://dl.reasonix.io"
releaseGatewayBase = "https://crash.reasonix.io/v1/desktop/releases"
@@ -46,8 +48,19 @@ const (
httpTimeout = 15 * time.Second
)
// manifestEndpoints returns the primary (R2) then fallback (GitHub) manifest URLs
// for the running build's channel.
// githubManifestFallback is the stable channel's last-resort manifest source.
// dl.reasonix.io and crash.reasonix.io share one Cloudflare zone, so bot
// protection that 403s a user's egress IP takes out both first-party endpoints
// at once (#6005); GitHub is separate infrastructure. The URL is safe despite
// the repository-wide /releases/latest caveat above: release.yml pins the
// repo-wide latest badge to the CLI line and attaches a desktop-manifest mirror
// to every stable CLI release ("desktop manifest compatibility asset"), so this
// asset is always the desktop manifest. Canary has no GitHub release, so its
// chain stays two-deep.
const githubManifestFallback = "https://github.com/esengine/DeepSeek-Reasonix/releases/latest/download/latest.json"
// manifestEndpoints returns the manifest URLs for the running build's channel,
// in the order fetchManifest tries them.
func manifestEndpoints() []string {
if channel == "canary" {
return []string{
@@ -58,9 +71,18 @@ func manifestEndpoints() []string {
return []string{
r2Base + "/latest/latest.json",
releaseGatewayBase + "/stable/latest.json",
githubManifestFallback,
}
}
// updaterUserAgent identifies updater traffic. Go's default Go-http-client UA
// is exactly what edge bot protection scores worst (#6005); a descriptive UA
// lets the release edge allowlist updater requests and makes them attributable
// in server logs.
func updaterUserAgent() string {
return fmt.Sprintf("Reasonix-Updater/%s (%s/%s; %s)", version, runtime.GOOS, runtime.GOARCH, channel)
}
// downloadPage is the human-facing releases page shown when self-update is
// unavailable (macOS) or the manifest omits its own link.
func downloadPage() string {
@@ -147,24 +169,26 @@ func normalizeVersion(v string) (string, bool) {
return semver.Canonical(v), true
}
// fetchManifest pulls latest.json from the primary endpoint, then the fallback,
// and decodes it.
// fetchManifest pulls latest.json from each endpoint in order until one both
// responds and decodes. Every endpoint's failure is kept — a user staring at a
// gateway 403 (#6005) needs to see that the R2 pointer failed too, not just
// whichever endpoint happened to die last.
func fetchManifest(ctx context.Context, c *http.Client) (*update.Manifest, error) {
var lastErr error
var errs []error
for _, url := range manifestEndpoints() {
b, err := fetchBytes(ctx, c, url)
if err != nil {
lastErr = err
errs = append(errs, err)
continue
}
var m update.Manifest
if err := json.Unmarshal(b, &m); err != nil {
lastErr = err
errs = append(errs, fmt.Errorf("%s: %w", url, err))
continue
}
return &m, nil
}
return nil, fmt.Errorf("update: fetch manifest: %w", lastErr)
return nil, fmt.Errorf("update: fetch manifest: %w", errors.Join(errs...))
}
// evaluate compares the running version against the manifest and builds the
@@ -214,6 +238,9 @@ type cachedUpdate struct {
var updateCacheBaseDir = defaultUpdateCacheBaseDir
func defaultUpdateCacheBaseDir() (string, error) {
if cd := config.CacheDir(); cd != "" {
return filepath.Join(cd, "updates"), nil
}
base, err := os.UserCacheDir()
if err != nil {
base = os.TempDir()
@@ -426,6 +453,7 @@ func fetchBytesOnce(ctx context.Context, c *http.Client, url string) ([]byte, er
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", updaterUserAgent())
resp, err := c.Do(req)
if err != nil {
return nil, err
@@ -467,6 +495,7 @@ func downloadInto(ctx context.Context, c *http.Client, url string, buf *bytes.Bu
if err != nil {
return err
}
req.Header.Set("User-Agent", updaterUserAgent())
if buf.Len() > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", buf.Len()))
}
@@ -572,13 +601,10 @@ func applyLinux(targz []byte) error {
}
func applyWindowsFile(path string) error {
return installerCommand(path, currentInstallDir()).Start()
return startWindowsUpdateHandoff(path, currentInstallDir(), currentExecutablePath())
}
// currentInstallDir is the directory of the running executable — the location a
// Windows update must overwrite. Empty when it can't be resolved, in which case
// the installer falls back to its own InstallDir logic.
func currentInstallDir() string {
func currentExecutablePath() string {
exe, err := os.Executable()
if err != nil {
return ""
@@ -586,6 +612,17 @@ func currentInstallDir() string {
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
return exe
}
// currentInstallDir is the directory of the running executable — the location a
// Windows update must overwrite. Empty when it can't be resolved, in which case
// the installer falls back to its own InstallDir logic.
func currentInstallDir() string {
exe := currentExecutablePath()
if exe == "" {
return ""
}
return filepath.Dir(exe)
}
+4
View File
@@ -9,3 +9,7 @@ import "os/exec"
func installerCommand(name, _ string) *exec.Cmd {
return exec.Command(name)
}
func startWindowsUpdateHandoff(name, dir, _ string) error {
return installerCommand(name, dir).Start()
}
+8 -1
View File
@@ -101,7 +101,14 @@ func TestChannelSelectsDistinctPointers(t *testing.T) {
if stable[1] != releaseGatewayBase+"/stable/latest.json" {
t.Errorf("stable fallback = %q, want the release gateway", stable[1])
}
for _, u := range append(stable, canary...) {
// GitHub is stable's explicit last resort only (#6005: both first-party
// endpoints share one Cloudflare zone). release.yml pins the desktop
// manifest mirror to the repo-wide latest release, which is what makes this
// URL safe; no other slot may lean on repository-wide latest.
if len(stable) != 3 || stable[2] != githubManifestFallback {
t.Errorf("stable endpoints = %q, want the GitHub compatibility manifest last", stable)
}
for _, u := range append(stable[:2:2], canary...) {
if strings.Contains(u, "/releases/latest") {
t.Errorf("manifest endpoint uses GitHub's repository-wide latest release: %q", u)
}
+54 -4
View File
@@ -3,11 +3,15 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"syscall"
"time"
)
const windowsUpdateHelperFileName = "reasonix-update-helper.exe"
// installerCommand runs the NSIS updater, forcing $INSTDIR to dir via /D= so the
// update overwrites the current install in place. NSIS requires /D= to be the
// final, unquoted token taken verbatim to the end of the line, so the raw command
@@ -15,8 +19,54 @@ import (
// C:\Users\Jane Doe\...) and NSIS would then mis-parse the target directory.
func installerCommand(name, dir string) *exec.Cmd {
cmd := exec.Command(name)
if dir != "" {
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: fmt.Sprintf(`"%s" /D=%s`, name, dir)}
}
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(name, dir), HideWindow: true}
return cmd
}
func startWindowsUpdateHandoff(installerPath, installDir, relaunchPath string) error {
if err := startWindowsUpdateHelper(installerPath, installDir, relaunchPath); err == nil {
return nil
}
return installerCommand(installerPath, installDir).Start()
}
func startWindowsUpdateHelper(installerPath, installDir, relaunchPath string) error {
if installDir == "" {
return os.ErrNotExist
}
helperPath, err := prepareWindowsUpdateHelper(installDir)
if err != nil {
return err
}
cmd := exec.Command(helperPath, windowsUpdateHandoffArgs(os.Getpid(), installerPath, installDir, relaunchPath)...)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
return cmd.Start()
}
func prepareWindowsUpdateHelper(installDir string) (string, error) {
src := filepath.Join(installDir, windowsUpdateHelperFileName)
data, err := os.ReadFile(src)
if err != nil {
return "", err
}
dir, err := updateCacheDir()
if err != nil {
return "", err
}
cleanupWindowsUpdateHelpers(dir)
dst := filepath.Join(dir, "reasonix-update-helper-"+time.Now().UTC().Format("20060102150405.000000000")+".exe")
if err := os.WriteFile(dst, data, 0o700); err != nil {
return "", err
}
return dst, nil
}
func cleanupWindowsUpdateHelpers(dir string) {
matches, err := filepath.Glob(filepath.Join(dir, "reasonix-update-helper-*.exe"))
if err != nil {
return
}
for _, name := range matches {
_ = os.Remove(name)
}
}
+28
View File
@@ -0,0 +1,28 @@
package main
import (
"fmt"
"strconv"
)
func installerCommandLine(installer, dir string) string {
line := fmt.Sprintf(`"%s" /S`, installer)
if dir != "" {
line += " /D=" + dir
}
return line
}
func windowsUpdateHandoffArgs(parentPID int, installer, installDir, relaunch string) []string {
args := []string{
"--parent-pid", strconv.Itoa(parentPID),
"--installer", installer,
}
if installDir != "" {
args = append(args, "--install-dir", installDir)
}
if relaunch != "" {
args = append(args, "--relaunch", relaunch)
}
return args
}
+8 -3
View File
@@ -10,7 +10,7 @@ func TestInstallerCommandPassesUnquotedDFlagLast(t *testing.T) {
t.Fatal("expected a raw command line forcing the install dir")
}
got := cmd.SysProcAttr.CmdLine
want := `"C:\Temp\reasonix-update-1.exe" /D=D:\Tools\Reasonix App`
want := `"C:\Temp\reasonix-update-1.exe" /S /D=D:\Tools\Reasonix App`
if got != want {
t.Fatalf("CmdLine = %q, want %q", got, want)
}
@@ -18,7 +18,12 @@ func TestInstallerCommandPassesUnquotedDFlagLast(t *testing.T) {
func TestInstallerCommandWithoutDirSkipsDFlag(t *testing.T) {
cmd := installerCommand(`C:\Temp\reasonix-update-1.exe`, "")
if cmd.SysProcAttr != nil {
t.Fatalf("no dir should leave NSIS InstallDir logic intact, got CmdLine %q", cmd.SysProcAttr.CmdLine)
if cmd.SysProcAttr == nil {
t.Fatal("expected a raw command line for silent updater installs")
}
got := cmd.SysProcAttr.CmdLine
want := `"C:\Temp\reasonix-update-1.exe" /S`
if got != want {
t.Fatalf("CmdLine = %q, want %q", got, want)
}
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"os"
"strings"
"testing"
)
func TestInstallerCommandLineIsSilentAndKeepsDFlagLast(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)
}
}
func TestWindowsUpdateHandoffArgsCarryParentInstallAndRelaunch(t *testing.T) {
got := windowsUpdateHandoffArgs(
4242,
`C:\Users\Jane Doe\AppData\Local\Reasonix\updates\Reasonix-windows-amd64-installer.exe`,
`D:\Tools\Reasonix App`,
`D:\Tools\Reasonix App\reasonix-desktop.exe`,
)
want := []string{
"--parent-pid", "4242",
"--installer", `C:\Users\Jane Doe\AppData\Local\Reasonix\updates\Reasonix-windows-amd64-installer.exe`,
"--install-dir", `D:\Tools\Reasonix App`,
"--relaunch", `D:\Tools\Reasonix App\reasonix-desktop.exe`,
}
if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
t.Fatalf("args = %#v, want %#v", got, want)
}
}
func TestWindowsInstallerScriptWaitsBeforeCopyingExecutable(t *testing.T) {
data, err := os.ReadFile("build/windows/installer/project.nsi")
if err != nil {
t.Fatal(err)
}
script := string(data)
for _, want := range []string{
`!define REASONIX_UPDATE_HELPER "reasonix-update-helper.exe"`,
"Function reasonix.waitForExecutableUnlock",
`FileOpen $1 "$INSTDIR\${PRODUCT_EXECUTABLE}" a`,
"SetErrorLevel 1618",
"Call reasonix.waitForExecutableUnlock",
`File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"`,
`Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"`,
} {
if !strings.Contains(script, want) {
t.Fatalf("project.nsi missing %q", want)
}
}
wait := strings.Index(script, "Call reasonix.waitForExecutableUnlock")
copyFiles := strings.Index(script, "!insertmacro wails.files")
if wait < 0 || copyFiles < 0 || wait > copyFiles {
t.Fatalf("installer must wait for the running exe to unlock before wails.files (wait=%d copy=%d)", wait, copyFiles)
}
}
func TestDesktopBuildScriptCompilesAndPackagesWindowsUpdateHelper(t *testing.T) {
data, err := os.ReadFile("../scripts/desktop-build.sh")
if err != nil {
t.Fatal(err)
}
script := string(data)
for _, want := range []string{
`UPDATE_HELPER="reasonix-update-helper.exe"`,
`GOOS=windows GOARCH="$arch" go build`,
`./cmd/update-helper`,
`build/windows/installer/$UPDATE_HELPER`,
`cp "$helper" "$staging/$UPDATE_HELPER"`,
} {
if !strings.Contains(script, want) {
t.Fatalf("desktop-build.sh missing %q", want)
}
}
}
+50 -7
View File
@@ -8,6 +8,7 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"time"
"reasonix/internal/control"
@@ -26,6 +27,18 @@ type workspaceChangeAccumulator struct {
hasGit bool
}
const workspaceGitBranchCacheTTL = 2 * time.Second
type workspaceGitBranchCacheEntry struct {
branch string
expires time.Time
}
var workspaceGitBranchCache = struct {
sync.Mutex
entries map[string]workspaceGitBranchCacheEntry
}{entries: map[string]workspaceGitBranchCacheEntry{}}
func (a *App) WorkspaceChanges(tabID string) WorkspaceChangesView {
out := WorkspaceChangesView{Files: []WorkspaceChangeView{}, GitAvailable: true}
tabID = strings.TrimSpace(tabID)
@@ -149,6 +162,12 @@ func workspaceGitCommand(ctx context.Context, args ...string) *exec.Cmd {
return cmd
}
func workspaceGitOutputWithTimeout(timeout time.Duration, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return workspaceGitCommand(ctx, args...).Output()
}
func workspaceGitStatus(base string) ([]gitStatusEntry, error) {
cmd := workspaceGit("-C", base, "status", "--porcelain=v1", "-z", "--untracked-files=all")
raw, err := cmd.Output()
@@ -225,15 +244,40 @@ func workspaceRelPathFromGitStatus(repoRoot, base, path string) string {
return normalizeWorkspaceRelPath(base, path)
}
// workspaceGitBranchForMeta is the cached variant used by high-frequency UI
// metadata refreshes. Workflows that need an immediate git read, such as
// WorkspaceChanges, should call workspaceGitBranch directly.
func workspaceGitBranchForMeta(base string) string {
key := filepath.Clean(base)
now := time.Now()
workspaceGitBranchCache.Lock()
if cached, ok := workspaceGitBranchCache.entries[key]; ok && now.Before(cached.expires) {
workspaceGitBranchCache.Unlock()
return cached.branch
}
workspaceGitBranchCache.Unlock()
branch := workspaceGitBranch(base)
storeNow := time.Now()
workspaceGitBranchCache.Lock()
if len(workspaceGitBranchCache.entries) > 256 {
for k, cached := range workspaceGitBranchCache.entries {
if storeNow.After(cached.expires) {
delete(workspaceGitBranchCache.entries, k)
}
}
}
workspaceGitBranchCache.entries[key] = workspaceGitBranchCacheEntry{branch: branch, expires: storeNow.Add(workspaceGitBranchCacheTTL)}
workspaceGitBranchCache.Unlock()
return branch
}
// workspaceGitBranch returns the current git branch name for the repo rooted
// at base, or an empty string when base is not inside a git repository or when
// git is unavailable.
func workspaceGitBranch(base string) string {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
cmd := workspaceGitCommand(ctx, "-C", base, "branch", "--show-current")
raw, err := cmd.Output()
raw, err := workspaceGitOutputWithTimeout(2*time.Second, "-C", base, "branch", "--show-current")
if err != nil {
return ""
}
@@ -241,8 +285,7 @@ func workspaceGitBranch(base string) string {
return branch
}
headCmd := workspaceGitCommand(ctx, "-C", base, "rev-parse", "--short", "HEAD")
raw, err = headCmd.Output()
raw, err = workspaceGitOutputWithTimeout(2*time.Second, "-C", base, "rev-parse", "--short", "HEAD")
if err != nil {
return ""
}
+30
View File
@@ -45,6 +45,36 @@ func TestWorkspaceGitBranch(t *testing.T) {
}
}
func TestWorkspaceGitBranchReflectsImmediateCheckout(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
orig, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
defer func() {
if err := os.Chdir(orig); err != nil {
t.Fatal(err)
}
}()
repo := t.TempDir()
if err := os.Chdir(repo); err != nil {
t.Fatal(err)
}
runGit(t, "init")
runGit(t, "checkout", "-b", "feature/one")
if got := workspaceGitBranch(repo); got != "feature/one" {
t.Fatalf("branch before checkout = %q, want feature/one", got)
}
runGit(t, "checkout", "-b", "feature/two")
if got := workspaceGitBranch(repo); got != "feature/two" {
t.Fatalf("branch after checkout = %q, want feature/two", got)
}
}
func TestWorkspaceGitBranchDetachedHead(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
+4
View File
@@ -7,6 +7,10 @@ here. Snapshot-based and aligned with Claude Code. An optional git-backed mode i
the remaining (lower-priority) follow-up. Tracks the most requested missing
capability from v1 — an edit safety net / undo.
This document describes rewind snapshots. For the autonomous-run rule about when
the agent should pause and ask the user, see
[`TASK_CONTRACT.md`](TASK_CONTRACT.md).
## Goal
Let a user rewind a session to a previous point and restore **code**,
+7
View File
@@ -52,6 +52,13 @@ Reasonix 桌面端输入框左下角的“协作方式”菜单包含三类常
“不要原地打转”。这类目标会自动进入 AutoResearch 策略,把状态写到
`.reasonix/autoresearch/<task-id>/`。
### 推荐目标写法
复杂目标可以写成一份任务合约:Context、Request、Output format、Constraints、Pause policy。
Goal 模式会把这些部分当作任务边界;除非下一步涉及不可逆或对外可见操作、任务范围变化,
或需要你提供信息,否则会继续完成任务后再汇报。完整模板见
[`TASK_CONTRACT.zh-CN.md`](./TASK_CONTRACT.zh-CN.md)。
### 注意事项
- 目标要写得具体。推荐包含范围、成功标准和限制,例如“只改桌面端输入栏,补前端测试,不改后端协议”。
+20 -4
View File
@@ -15,6 +15,17 @@ location.
Set `REASONIX_HOME` to override Reasonix home for tests, CI, or portable
installations. Normal users should not need it.
When `REASONIX_HOME` is set, the runtime is fully self-contained: all
configuration, state, cache, and data live under that directory tree. Legacy
migration, OS-home convention directory scanning, and all other fallback paths
are skipped so no data leaks in from a system-wide production install.
Advanced test and portable setups may set `REASONIX_STATE_HOME` to move runtime
state such as sessions, archives, and memory. It does not move global config or
provider credentials: those remain under `REASONIX_HOME`. If an older build wrote
provider keys to `REASONIX_STATE_HOME/.env`, Reasonix imports those keys
non-destructively when `<Reasonix home>/.env` is missing them.
## What Lives There
| Data | Path |
@@ -26,9 +37,12 @@ installations. Normal users should not need it.
| Global skills | `<Reasonix home>/skills/` |
| Global hooks | `<Reasonix home>/settings.json` |
| Hook trust store | `<Reasonix home>/trust.json` |
| Sessions | `<Reasonix home>/sessions/` |
| Archives | `<Reasonix home>/archive/` |
| Memory | `<Reasonix home>/memory/` and `<Reasonix home>/projects/` |
| Sessions | `<state root>/sessions/` |
| Archives | `<state root>/archive/` |
| Memory | `<state root>/memory/` and `<state root>/projects/` |
`<state root>` defaults to `<Reasonix home>`. It only differs when
`REASONIX_STATE_HOME` is set.
The global user config is named `config.toml`. Project-local config files keep
the name `reasonix.toml`. If someone says "global reasonix.toml", they usually
@@ -165,7 +179,9 @@ into the process environment, and Reasonix control variables such as
Caches remain in the OS cache directory, for example
`~/Library/Caches/reasonix` on macOS, `$XDG_CACHE_HOME/reasonix` or
`~/.cache/reasonix` on Linux, and `%LOCALAPPDATA%\reasonix\cache` on Windows.
Set `REASONIX_CACHE_HOME` to override the cache root.
Set `REASONIX_CACHE_HOME` to override the cache root. When `REASONIX_HOME` is
set, the cache is placed under `$REASONIX_HOME/cache` (unless
`REASONIX_CACHE_HOME` is also set, which takes precedence).
## Config Priority
+15 -3
View File
@@ -12,6 +12,13 @@
可以设置 `REASONIX_HOME` 覆盖 Reasonix home,主要用于测试、CI 或便携安装。普通用户通常不需要设置。
设置 `REASONIX_HOME` 后,运行时会变成完整自包含模式:配置、状态、缓存和数据都会位于该目录树下。
Legacy 迁移、OS home 约定目录扫描以及其他 fallback 路径都会跳过,避免从系统级正式安装带入或写回数据。
高级测试或便携安装可以设置 `REASONIX_STATE_HOME` 来移动 sessions、archive、memory 等运行状态。
它不会移动全局配置或 provider 凭据;这些仍然位于 `REASONIX_HOME` 下。如果旧版本曾把 provider key
写到 `REASONIX_STATE_HOME/.env`,Reasonix 会在 `<Reasonix home>/.env` 缺少对应 key 时非破坏性导入。
## 目录内容
| 数据 | 路径 |
@@ -23,9 +30,12 @@
| 全局 skills | `<Reasonix home>/skills/` |
| 全局 hooks | `<Reasonix home>/settings.json` |
| hooks 信任状态 | `<Reasonix home>/trust.json` |
| 会话 | `<Reasonix home>/sessions/` |
| 归档 | `<Reasonix home>/archive/` |
| 记忆 | `<Reasonix home>/memory/` 与 `<Reasonix home>/projects/` |
| 会话 | `<state root>/sessions/` |
| 归档 | `<state root>/archive/` |
| 记忆 | `<state root>/memory/` 与 `<state root>/projects/` |
`<state root>` 默认等于 `<Reasonix home>`;只有设置 `REASONIX_STATE_HOME`
时才会不同。
全局用户配置文件名是 `config.toml`。项目本地配置文件仍叫 `reasonix.toml`。
如果有人说“全局 reasonix.toml”,通常指的是 `<Reasonix home>/config.toml`。
@@ -135,6 +145,8 @@ Provider 请求只会从这个全局 `.env` 解析 key。项目 `.env`、home `.
缓存仍放在系统缓存目录,例如 macOS 的 `~/Library/Caches/reasonix`、
Linux 的 `$XDG_CACHE_HOME/reasonix` 或 `~/.cache/reasonix`、Windows 的
`%LOCALAPPDATA%\reasonix\cache`。可以设置 `REASONIX_CACHE_HOME` 覆盖缓存根目录。
设置 `REASONIX_HOME` 后,缓存会放在 `$REASONIX_HOME/cache`;如果同时设置
`REASONIX_CACHE_HOME`,后者优先。
## 配置优先级
+6
View File
@@ -43,6 +43,12 @@ Reasonix 的 Goal 模式(`/goal`)在 v1.9+ 中增加了来自 Oh-My-OpenAgen
- 需要完整测试覆盖的任务
- 多步骤复杂流程
### 任务合约
复杂目标可以直接写成 Context / Request / Output format / Constraints /
Pause policy。Goal 模式会把这些段落当作执行边界:满足请求、输出格式、约束和必要验证后才结束;
除非下一步涉及不可逆或对外可见操作、范围变化,或必须由用户提供信息,否则继续采用合理默认值推进。
### 并行子任务
```bash
+35 -7
View File
@@ -16,6 +16,7 @@
- [Serve web frontend](#serve-web-frontend)
- [Configuration paths](./CONFIG_PATHS.md)
- [Reasoning language](./REASONING_LANGUAGE.md)
- [Task contracts and pause policy](./TASK_CONTRACT.md)
- [Custom OpenAI-compatible providers](#custom-openai-compatible-providers)
- [Desktop hooks](#desktop-hooks)
- [Keyboard shortcuts](#keyboard-shortcuts)
@@ -479,13 +480,33 @@ out. `forbid_read` optionally hides sensitive directories from the agent's
read/list/search tools; use absolute paths or `${HOME}` / `${VAR}` references,
not `~`, because config expansion is environment-variable based. `bash` is
itself jailed by default when an OS sandbox is available (`[sandbox] bash`,
Seatbelt on macOS and bubblewrap on Linux): commands may write only those same
roots (plus temp and toolchain caches), cannot read configured `forbid_read`
roots while the OS sandbox is active, and reach the network only when
`[sandbox] network` is set. When no OS sandbox is available, `bash = "enforce"`
refuses bash execution instead of running unconfined (see
[`SPEC.md` §9](./SPEC.md#9-roadmap-not-in-current-scope) for the escape-prompt and
broader OS support still to come).
Seatbelt on macOS, bubblewrap on Linux, and a native helper on Windows):
commands may write only those same roots plus platform-specific command
temp/cache roots, cannot read configured `forbid_read` roots while the OS
sandbox is active, and reach the network only when `[sandbox] network` is set.
The native Windows helper delegates the low-level isolation to
`github.com/SivanCola/windows-sandbox`, which uses AppContainer for read-only
commands and a low-integrity token for writable commands, temporarily grants
access to the workspace, a per-command temp root, and the target executable,
applies deny ACEs for `forbid_read` (files as well as directories), snapshots
touched DACLs before editing them, and restores those snapshots best-effort
after the command exits. Concurrent commands touching the same workspace are
serialized so their ACL edits cannot corrupt each other, and residue from a
force-killed command (a lingering low-integrity label or `forbid_read` deny) is
cleaned up by the next run. Because a writable command runs under a
low-integrity token, it can still write the few locations Windows leaves
writable to any low-integrity process (for example `%USERPROFILE%\AppData\LocalLow`)
in addition to the configured roots; the workspace boundary and `forbid_read`
denials still hold. Read-only AppContainer commands omit network capabilities
when networking is disabled; writable Windows commands fail closed when
`[sandbox] network = false`.
When no OS sandbox backend is available, `bash = "enforce"` refuses bash
execution instead of running unconfined. Install the platform sandbox backend
(bubblewrap/`bwrap` on Linux, `sandbox-exec` on macOS) or set
`[sandbox] bash = "off"` to explicitly restore the pre-1.16 unconfined shell
behavior (see
[`SPEC.md` §9](./SPEC.md#9-roadmap-not-in-current-scope) for the escape-prompt
and optional elevated Windows hardening still to come).
## Plugins (MCP)
@@ -659,6 +680,13 @@ skill in Settings -> Skills or the slash menu. If an ordinary chat prompt has a
very strong long-horizon signal, the host also upgrades it into the equivalent
of `/goal --research <original prompt>`.
For complex work, write the objective as a
[task contract](./TASK_CONTRACT.md): Context, Request, Output format,
Constraints, and Pause policy. Goal mode treats those sections as the boundary
for autonomous work. It keeps going with sensible defaults unless the next step
requires an irreversible or externally visible operation, a scope change, or
information only the user can provide.
AutoResearch is enabled for goals with strong signals such as "keep
researching", "long-running", "thoroughly", "debug until the root cause is
clear", "do not spin", "run experiments", "verify repeatedly", or "turn this
+19 -3
View File
@@ -16,6 +16,7 @@
- [Serve Web 前端](#serve-web-前端)
- [配置路径](./CONFIG_PATHS.zh-CN.md)
- [思考语言](./REASONING_LANGUAGE.zh-CN.md)
- [任务合约与暂停策略](./TASK_CONTRACT.zh-CN.md)
- [自定义 OpenAI-compatible provider](#自定义-openai-compatible-provider)
- [桌面端 Hooks](./DESKTOP_HOOKS.zh-CN.md)
- [快捷键](#快捷键)
@@ -391,9 +392,20 @@ CJK 双宽字符,造成视觉错位。想保留旧的终端块状光标可设
之外的任何路径(默认当前目录,编辑不出项目),并解析符号链接与 `..`,使链接无法
打洞越界。`forbid_read` 可选地隐藏敏感目录,使 agent 的读文件、列目录和搜索工具不能读取或列出它们;
建议使用绝对路径或 `${HOME}` / `${VAR}`,不要写 `~`,因为配置只做环境变量展开。
`bash` 本身在 macOS 默认进沙盒(`[sandbox] bash`,Seatbelt):命令只能写这些 root(外加临时目录与工具链缓存),
OS 沙盒生效时也不能读取配置的 `forbid_read` roots,`[sandbox] network` 为真时才能联网;
其它平台在没有可用 OS 沙盒时会回退为不沙盒运行(越界问一次与 Linux 支持见
`bash` 本身默认进 OS 沙盒(`[sandbox] bash`:macOS 使用 Seatbelt,Linux 使用 bubblewrap,
原生 Windows 使用 native helper):命令只能写这些 root(外加平台按命令提供的临时/缓存 root),
OS 沙盒生效时也不能读取配置的 `forbid_read` roots,`[sandbox] network` 为真时才能联网。
原生 Windows helper 把底层隔离委托给 `github.com/SivanCola/windows-sandbox`:
只读命令使用 AppContainer,可写命令使用 low-integrity token;它会临时授予
workspace、每次命令专用 temp root 和目标可执行文件的访问权,对 `forbid_read`
(文件和目录皆可)临时添加 deny ACE,修改前记录被触碰目录的 DACL,命令结束后尽力恢复。
作用于同一 workspace 的并发命令会被串行化,避免各自的 ACL 修改互相破坏;被强杀命令
残留的 low-integrity 标签或 `forbid_read` deny ACE 会由下一次运行清理。由于可写命令跑在
low-integrity token 下,除配置的 root 外它仍能写入 Windows 对任何 low-integrity 进程开放的
少数位置(例如 `%USERPROFILE%\AppData\LocalLow`),但 workspace 边界与 `forbid_read`
拒绝依然有效。只读 AppContainer 命令在关闭网络时不给 network capability;可写 Windows 命令遇到
`[sandbox] network = false` 时会 fail closed。没有可用 OS 沙盒时,`bash = "enforce"` 会拒绝 bash 执行,不会无沙盒运行
(越界询问与可选的 Windows elevated 加固见
[`SPEC.md` §9](./SPEC.md#9-roadmap-not-in-current-scope))。
## 插件(MCP)
@@ -529,6 +541,10 @@ Goal 是长期目标的统一运行机制。普通 `/goal` 继续走轻量 Goal
Settings -> Skills 或斜杠菜单里。普通聊天输入如果命中很强的长周期信号,也会被 host 自动
升级为等价的 `/goal --research <原输入>`。
复杂任务建议把目标写成[任务合约](./TASK_CONTRACT.zh-CN.md):Context、Request、
Output format、Constraints 和 Pause policy。Goal 模式会把这些部分当作自主执行的边界;
除非下一步需要不可逆或对外可见操作、任务范围变化,或必须由用户提供信息,否则会继续采用合理默认值推进,并在最后汇报假设与结果。
AutoResearch 会在这些目标里自动启用:包含“持续”“长期”“彻底”“直到根因明确”“多轮排查”
“不要原地打转”“完整方案”“跑实验”“反复验证”“系统性研究”等强信号;或者目标同时包含
研究/排查、实现/修复、验证/测试、优化/文档/发布等多个阶段;或者用户明确给出
+43 -10
View File
@@ -362,7 +362,17 @@ func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Dec
model reports completion, repeats the same blocked state three times, the user
stops it, or the safety continuation limit is reached. Blocked-state matching
is normalized for casing, whitespace, and punctuation so minor wording drift
does not reset the audit; restarting a goal begins a fresh blocked audit.
does not reset the audit; restarting a goal begins a fresh blocked audit. A
goal is treated as a task contract: if the objective includes Context,
Request, Output format, Constraints, or Pause policy sections, those sections
define the autonomous work boundary. When they are absent, the model infers a
lightweight contract from the conversation and workspace. The injected goal
block tells the model to pause only for irreversible or externally visible
operations, scope changes, or information only the user can provide; ordinary
uncertainty should be handled with sensible defaults and reported as an
assumption. Completion requires the concrete request, output format,
constraints, and relevant verification expectations to be satisfied or
explicitly reported as unverified.
Goals that look like long-horizon research, debugging, optimization, or
implementation work automatically add an AutoResearch protocol to the same
transient active-goal user block. AutoResearch is a Goal strategy, not a
@@ -646,12 +656,34 @@ not read, list, or search; entries support `${VAR}` / `${VAR:-default}` expansio
and should be absolute, or use `${HOME}` for home-relative secrets such as
`${HOME}/.ssh`. `bash` is itself jailed by default when an OS sandbox is
available (`[sandbox] bash = "enforce"`: Seatbelt on macOS, bubblewrap on
Linux): each command is allowed to write only the same roots (+ temp and
toolchain caches), denied reads under `forbid_read`, and allowed to reach the
network only when `network = true`.
Linux, and a native helper on Windows): each command is allowed to write only
the same roots plus platform-specific command temp/cache roots, denied reads
under `forbid_read`, and allowed to reach the network only when
`network = true`.
The native Windows helper delegates the low-level isolation to
`github.com/SivanCola/windows-sandbox`: AppContainer for read-only commands and
a low-integrity token for writable commands, with temporary ACL grants for
writable roots and tool executables, a per-command temp root instead of mutating
the global Temp directory, temporary deny ACEs for `forbid_read` (files and
directories), best-effort restoration from pre-run DACL snapshots for touched
directories, and a kill-on-close Job Object. Because the sandbox works by
temporarily mutating shared-path ACLs and integrity labels, concurrent commands
against the same root are serialized with a per-root lock, and residue from a
force-killed command (a lingering low-integrity label or `forbid_read` deny ACE)
is swept by the next run so a crash cannot durably lower a workspace's integrity
or lock the user out of a `forbid_read` path. A writable command runs under a
low-integrity token, so beyond the configured roots it retains write access to
the narrow set of locations Windows leaves writable to any low-integrity process
(e.g. `AppData\LocalLow`); the workspace boundary and `forbid_read` denials are
unaffected. Read-only AppContainer commands omit network capabilities when
networking is disabled; writable Windows commands fail closed when
`network = false` because the low-integrity token does not provide a reliable
per-process network block without elevated firewall/WFP setup.
When no OS sandbox is available, `bash = "enforce"` refuses bash execution
instead of running unconfined. The escape-prompt and broader OS support are Phase
1's remainder (§9).
instead of running unconfined. Install the platform sandbox backend
(bubblewrap/`bwrap` on Linux, `sandbox-exec` on macOS) or set
`[sandbox] bash = "off"` to explicitly restore the pre-1.16 unconfined shell
behavior. The escape-prompt and broader OS support are Phase 1's remainder (§9).
## 6. Error Handling
@@ -679,14 +711,15 @@ instead of running unconfined. The escape-prompt and broader OS support are Phas
- Sandbox Phase 1: an OS-level jail for `bash` so commands — not just the
file-writer built-ins (Phase 0) — are confined to the workspace. **Seatbelt on
macOS and bubblewrap on Linux ship, on by default when available** (see §5).
macOS, bubblewrap on Linux, and a native Windows helper ship, on by default
when available** (see §5).
Remaining: (a)
the escape-prompt — detect sandbox-unavailable or sandbox-denied failures and
offer an explicit, permission-gated unconfined rerun (in `reasonix run`, the
command just fails and the model adapts), which completes the "allow inside the
box, prompt at its edge" model; (b) broader OS sandbox support beyond current
Seatbelt/bubblewrap hosts. Shells out to OS tooling so the binary stays
dependency-free. With this in
box, prompt at its edge" model; (b) an optional elevated Windows backend with a
dedicated sandbox user for enterprise hardening. Shells out to OS tooling so
the binary stays dependency-free. With this in
place, "always allow" rule persistence becomes optional rather than load-bearing.
- MCP long tail (deferred deliberately — no consumer / no foundation yet): OAuth
2.0 + `headersHelper` auth for remote servers; the remaining `.mcp.json` scopes
+83
View File
@@ -0,0 +1,83 @@
# Task Contracts and Pause Policy
<a href="./GUIDE.md">Guide</a>
&nbsp;·&nbsp;
<a href="./TASK_CONTRACT.zh-CN.md">Simplified Chinese</a>
Reasonix works best when nontrivial work is described as a **task contract**:
what the work is for, what action is requested, how the result should be
delivered, what boundaries must be preserved, and when the agent should pause.
Some prompt templates call the last section "Checkpoint"; Reasonix documents it
as "Pause policy" to avoid confusion with Checkpoints/Rewind snapshots.
This is intentionally not a larger role prompt. Strong coding agents usually do
not need step-by-step thinking instructions. They need clear boundaries and
acceptance criteria.
## Template
```text
Context:
I am working on [larger task].
The target audience/user is [who].
This result should help them [achieve what outcome].
Request:
Please complete [one clear action].
Output format:
Return the result as [specific structure].
It must include [required sections].
Keep it within [length or scope].
Constraints:
Do not [bad assumption].
Do not [out-of-scope content].
Do not [low-quality output shape].
If information is missing, mark uncertainty explicitly.
Pause policy:
Unless the next step involves an irreversible or externally visible operation,
a scope change, or information only I can provide, keep working and report back
after the task is complete.
```
## How Reasonix Uses It
- **Normal chat** can use the template directly for one-off work.
- **Goal mode** treats the goal as a task contract and keeps working until the
request, output format, constraints, and verification expectations are
satisfied.
- **Plan mode** is still the right choice when you want a read-only plan before
edits or side effects.
- **Tool approval** remains separate: file writes, shell commands, publishing,
credentials, and external effects still follow the configured approval policy.
- **Checkpoints/Rewind** are file and conversation snapshots. The task contract's
pause policy is about when the agent should ask the user before continuing.
The Goal-mode task contract rides the provider-visible user turn. It does not
rewrite the cache-stable system prompt, memory prefix, or tool schemas.
## Example
```text
/goal Context:
I am improving the desktop composer.
The target user is someone doing repeated code review sessions.
This should help them avoid accidental interruptions.
Request:
Make the slash-command menu keep keyboard focus while suggestions are open.
Output format:
After implementation, summarize changed files and verification results.
Constraints:
Do not change the Wails JSON contract.
Do not refactor unrelated composer state.
If browser verification cannot run, say why.
Pause policy:
Unless the next step requires a product decision, a public push, or credentials,
continue through implementation and verification before reporting back.
```
+69
View File
@@ -0,0 +1,69 @@
# 任务合约与暂停策略
<a href="./GUIDE.zh-CN.md">使用指南</a>
&nbsp;·&nbsp;
<a href="./TASK_CONTRACT.md">English</a>
复杂任务最适合写成一份**任务合约**:背景是什么、要完成什么动作、结果怎么交付、哪些边界不能越过,以及什么时候才需要暂停问用户。
有些 prompt 模板把最后一段叫 “Checkpoint”;Reasonix 文档里叫 “Pause policy”,避免和 Checkpoints/Rewind 快照混淆。
这不是更长的角色设定。更强的 coding agent 通常不需要用户教它一步步思考;它更需要清楚的任务边界和验收标准。
## 模板
```text
Context:
我正在做 [大任务]。
目标对象是 [谁]。
这个结果要帮助他们 [达成什么结果]。
Request:
请完成 [一个明确动作]。
Output format:
请按照 [具体结构] 输出。
必须包含 [必要模块]。
不要超过 [长度/范围]。
Constraints:
不要 [错误假设]。
不要 [越界内容]。
不要 [低质量输出形式]。
如果 [信息不足],请明确标注不确定性。
Pause policy:
除非下一步涉及不可逆或对外可见操作、任务范围变化,或需要我提供信息,否则请继续完成任务后再汇报。
```
## Reasonix 如何使用它
- **普通聊天**可以直接粘贴这份模板,适合一次性任务。
- **Goal 模式**会把目标当作任务合约持续推进,直到 request、output format、constraints 和必要验证都满足。
- **计划模式**仍然适合“先只读分析、确认方案后再改”的场景。
- **工具审批**仍然独立生效:写文件、跑 shell、发布、凭证、外部副作用都会继续遵守配置的审批策略。
- **Checkpoints/Rewind** 是代码和会话快照;这里的暂停策略只描述 agent 什么时候应该停下来问用户。
Goal 模式里的任务合约会随 user turn 注入 provider 可见上下文,不会改写 cache-stable system prompt、memory prefix 或 tool schema。
## 示例
```text
/goal Context:
我正在改进桌面端输入框。
目标对象是需要连续做代码审查的用户。
这个结果要帮助他们避免被补全菜单打断输入。
Request:
修复 slash-command 菜单打开时键盘焦点丢失的问题。
Output format:
完成后按“改了什么 / 验证结果 / 剩余风险”汇报。
Constraints:
不要改变 Wails JSON 合约。
不要顺手重构无关的 composer 状态。
如果无法跑浏览器验证,请说明原因。
Pause policy:
除非下一步需要产品判断、公开 push 或凭证,否则继续完成实现和验证后再汇报。
```
+1
View File
@@ -10,6 +10,7 @@ require (
charm.land/lipgloss/v2 v2.0.4
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3
github.com/BurntSushi/toml v1.6.0
github.com/SivanCola/windows-sandbox v0.0.0-20260705154605-6b29dd09f9cb
github.com/alecthomas/chroma/v2 v2.27.0
github.com/atotto/clipboard v0.1.4
github.com/aymanbagabas/go-udiff v0.4.1
+2
View File
@@ -10,6 +10,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/SivanCola/windows-sandbox v0.0.0-20260705154605-6b29dd09f9cb h1:au4YwGV3d5zAB6CcboeNocRLwFoE7XGxnx2RYI78iv4=
github.com/SivanCola/windows-sandbox v0.0.0-20260705154605-6b29dd09f9cb/go.mod h1:qL+ewlVGGeJLO97u+7E4K20gLu9oST/lc8FaGo2JJSw=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
+19
View File
@@ -9,6 +9,7 @@ import (
"sync"
"unicode/utf8"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/permission"
"reasonix/internal/provider"
@@ -330,11 +331,27 @@ func (s *updateSink) requestAskQuestion(ctx context.Context, askID string, q eve
}
func approvalSessionOptionName(tool, subject string) string {
if tool == control.SandboxEscapeApprovalTool {
return "Use real environment for this session"
}
sessionRule := permission.SessionGrantRuleForScope(tool, subject)
return "Allow " + sessionRule + " for this session"
}
func approvalOptions(tool, subject string) []PermissionOption {
if control.RequiresFreshHumanApprovalTool(tool) {
if tool == control.SandboxEscapeApprovalTool {
return []PermissionOption{
{OptionID: string(OptAllowOnce), Name: "Allow", Kind: OptAllowOnce},
{OptionID: string(OptAllowAlways), Name: approvalSessionOptionName(tool, subject), Kind: OptAllowAlways},
{OptionID: string(OptRejectOnce), Name: "Reject", Kind: OptRejectOnce},
}
}
return []PermissionOption{
{OptionID: string(OptAllowOnce), Name: "Allow", Kind: OptAllowOnce},
{OptionID: string(OptRejectOnce), Name: "Reject", Kind: OptRejectOnce},
}
}
allowSessionName := approvalSessionOptionName(tool, subject)
options := []PermissionOption{
{OptionID: string(OptAllowOnce), Name: "Allow", Kind: OptAllowOnce},
@@ -383,6 +400,8 @@ func toolKindFor(name string) string {
return "edit"
case "bash":
return "execute"
case control.SandboxEscapeApprovalTool:
return "execute"
}
n := strings.ToLower(name)
switch {
Loaded 100 of 258 files, more files were not shown because too many files have changed in this diff. Show more