Compare commits

...
268 Commits
Author SHA1 Message Date
SivanCola f4ef59f2ff Merge pull request #5976 from SivanCola/fix/session-recovery-event-log-cleanup
Fix session recovery persistence cleanup / 修复会话恢复持久化清理
2026-07-05 14:24:14 +08:00
SivanCola 7bb7290c2f Hold session locks across trash/delete and fix lint
Review follow-ups on the event-log hardening:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Rework the owner registry around identity:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Cache-guard: go test ./internal/boot

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

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

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

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

Cache-impact: none — test-only change.

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

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

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

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

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

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

Out of scope for this pass:

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

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

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

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

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

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

Add "/mouse" to release mouse capture back to the terminal on demand,
restoring its native click-drag selection and right-click context
menu at the cost of in-app drag-select, the scrollbar, and
wheel-scroll while it's off. REASONIX_DISABLE_MOUSE=1 starts every
session with it off.
2026-07-02 15:02:17 +08:00
SivanCola d5ba463964 Merge pull request #5774 from SivanCola/fix/windows-classic-drag-rail
Fix Windows classic titlebar dragging / 修复 Windows 经典标题栏拖动
2026-07-02 12:00:44 +08:00
SivanCola 4762cd33d6 fix(desktop): add Windows classic drag rail 2026-07-02 11:53:20 +08:00
GTC2080 9838a27280 fix: keep stop state when switching to running tabs 2026-07-02 13:49:44 +10:00
GTC2080 ef1d287fc0 fix(desktop): hide collapsed creation tool bodies 2026-07-01 20:51:18 +10:00
liaoyl830 5b81febdab fix(provider): include empty properties in tool schemas (#5167) 2026-06-23 21:58:36 +08:00
maoxin1234 c58805afea docs: add reasoning-controls-by-provider reference
Document which OpenAI-compatible backend uses which reasoning protocol
(thinking.type vs reasoning_effort), which params each ignores, and how to
drive thinking on a non-auto-detected backend via the `thinking` field.
Includes a troubleshooting section for telling provider behaviour apart
from a Reasonix bug. Suggested by @eghrhegpe (#5063).
2026-06-23 09:46:05 +08:00
maoxin1234 44876d2607 feat(provider): generic thinking toggle + DeepSeek disable
Broaden the thinking-mode support beyond Zhipu auto-detection (credit
@eghrhegpe, #5063):

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

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

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

No files matched your search

+53 -5
View File
@@ -8,7 +8,8 @@ name: Release desktop
# Wails cannot cross-compile a CGO/WebKit binary, so build/ fans out to one native
# runner per platform. Artifacts are minisign-signed (MINISIGN_* secrets), a
# latest.json manifest is generated, and everything is published to a GitHub
# release and mirrored to R2 (the updater reads R2 first, GitHub as fallback).
# release and mirrored to R2 (the updater reads R2 first, then the crash worker
# release gateway; GitHub's repository-wide "latest" is reserved for the CLI line).
#
# The same build/sign/manifest pipeline serves two channels. A `desktop-v*` tag
# (or manual stable dispatch) publishes a GitHub release and moves R2's latest/
@@ -260,9 +261,9 @@ jobs:
working-directory: desktop
run: go run ./cmd/sign manifest ../dist "${{ steps.ver.outputs.version }}" "${{ steps.ver.outputs.tag }}"
# Canary is R2-only and never appears on the GitHub releases page; the mirror
# job picks up the signed dist via the canary-dist artifact below. Stable
# publishes a GitHub release as usual.
# Canary never appears on the GitHub releases page; the mirror job picks up
# the signed dist via the canary-dist artifact below. Stable publishes a
# GitHub release as usual.
- name: Publish GitHub release
if: steps.ver.outputs.channel != 'canary'
env:
@@ -285,7 +286,7 @@ jobs:
needs: publish
runs-on: ubuntu-latest
permissions:
contents: read # gh release download
contents: write # gh release download + compatibility manifest upload
actions: write # dispatch pages.yml to re-bake the site version
# Skip cleanly when R2 isn't configured; GitHub release still works as fallback.
if: ${{ github.repository_owner == 'esengine' }}
@@ -366,6 +367,53 @@ jobs:
aws s3 cp assets/ "s3://${R2_BUCKET}/latest/" --recursive --endpoint-url "$ENDPOINT"
fi
- name: Smoke desktop release pointers
if: env.HAS_R2 == 'true'
env:
TAG: ${{ steps.ver.outputs.tag }}
VERSION: ${{ steps.ver.outputs.version }}
CHANNEL: ${{ steps.ver.outputs.channel }}
PRERELEASE: ${{ steps.ver.outputs.prerelease }}
run: |
set -euo pipefail
f=assets/latest.json
jq -e --arg version "$VERSION" --arg prefix "https://dl.reasonix.io/${TAG}/" '
.version == $version and
.download_page == "https://reasonix.io/#start" and
([.platforms[] | (.url, .sig)] |
all(type == "string" and startswith($prefix) and (contains("/releases/latest/") | not)))
' "$f" >/dev/null
curl --retry 5 --retry-delay 2 -fsSL "https://dl.reasonix.io/${TAG}/latest.json" >/tmp/reasonix-desktop-tag-latest.json
if [ "$CHANNEL" = "canary" ]; then
pointer="canary"
elif [ "$PRERELEASE" = "true" ]; then
pointer=""
else
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
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
done
- name: Attach desktop manifest to matching CLI release
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' && steps.ver.outputs.prerelease != 'true'
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
if gh release view "$VERSION" >/dev/null 2>&1; then
gh release upload "$VERSION" assets/latest.json --clobber
else
echo "CLI release $VERSION does not exist yet; release.yml will attach the compatibility latest.json when it publishes."
fi
# Stable release moved R2 latest/ — rebuild the site so its build-time baked
# version + JSON-LD follow (site.js's runtime .rxv refresh can't touch first paint / SEO).
- name: Refresh site to the new version
+20
View File
@@ -48,3 +48,23 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
- name: Attach desktop manifest compatibility asset
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
case "$TAG" in
*-*)
echo "prerelease $TAG — GitHub latest does not move here; skipping desktop manifest compatibility asset"
exit 0
;;
esac
curl --retry 5 --retry-delay 2 -fsSL https://dl.reasonix.io/latest/latest.json -o latest.raw.json
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
+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)"
-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>
-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>
+10 -40
View File
@@ -10,6 +10,8 @@ import (
"strings"
"time"
"unicode/utf8"
"reasonix/internal/shellparse"
)
type diffOpts struct {
@@ -546,57 +548,25 @@ func failingTestNames(out string) []string {
}
func runTests(repo, testCmd string, pkgs []string) (bool, string) {
fields := splitShellFields(testCmd)
test, err := shellparse.ParseStaticCommand(testCmd, shellparse.StaticCommandPolicy{AllowEnvAssignments: true, AllowStderrToStdout: true})
if err != nil {
return false, "invalid test command: " + err.Error()
}
fields := test.Argv
if len(fields) == 0 {
fields = []string{"go", "test"}
}
args := append(fields[1:], pkgs...)
cmd := exec.Command(fields[0], args...)
cmd.Dir = repo
if len(test.Env) > 0 {
cmd.Env = append(os.Environ(), test.Env...)
}
cmd.WaitDelay = 5 * time.Minute // bound the wait if `go test` hangs
out, err := cmd.CombinedOutput()
return err == nil, string(out)
}
// splitShellFields splits on whitespace, honoring single/double-quoted spans and stripping the quotes.
func splitShellFields(s string) []string {
var out []string
var cur strings.Builder
inSingle, inDouble := false, false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case inSingle:
if c == '\'' {
inSingle = false
} else {
cur.WriteByte(c)
}
case inDouble:
if c == '"' {
inDouble = false
} else {
cur.WriteByte(c)
}
case c == '\'':
inSingle = true
case c == '"':
inDouble = true
case c == ' ' || c == '\t' || c == '\n':
if cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
}
default:
cur.WriteByte(c)
}
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}
// changedGoFiles lists .go files changed by base...HEAD, excluding *_test.go
// when includeTests is false (we want the source under test).
func changedGoFiles(repo, base string, includeTests bool) []string {
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestRunTestsAllowsStaticEnvAssignment(t *testing.T) {
repo := t.TempDir()
writeE2EFile(t, filepath.Join(repo, "go.mod"), "module example.com/e2ebenchtest\n\ngo 1.23\n")
writeE2EFile(t, filepath.Join(repo, "env_test.go"), `package e2ebenchtest
import (
"os"
"testing"
)
func TestEnv(t *testing.T) {
if got := os.Getenv("REASONIX_E2E_ENV"); got != "ok" {
t.Fatalf("REASONIX_E2E_ENV = %q", got)
}
}
`)
ok, out := runTests(repo, "GOWORK=off REASONIX_E2E_ENV=ok go test", []string{"./..."})
if !ok {
t.Fatalf("runTests failed:\n%s", out)
}
}
func TestRunTestsRejectsDynamicEnvAssignment(t *testing.T) {
ok, out := runTests(t.TempDir(), "REASONIX_E2E_ENV=$(echo ok) go test", []string{"./..."})
if ok {
t.Fatal("runTests accepted dynamic env assignment")
}
if !strings.Contains(out, "invalid test command: shell expansion") {
t.Fatalf("output = %q, want shell expansion rejection", out)
}
}
func writeE2EFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
+27 -4
View File
@@ -62,6 +62,24 @@ In a plain browser the native bindings are absent, so `bridge.ts` falls back to
exact same event contract — so layout, streaming, markdown, tool cards, and the
diff seam can all be built without rebuilding Go.
## Test
The desktop package is a nested Go module, so parent `go test ./...` does not run
it. Use the full lane before merging desktop changes, and the short lane for fast
local feedback:
```sh
make desktop-test # cd desktop && go test .
make desktop-test-short # skips slow desktop integration/e2e checks
```
To find the next bottleneck, rank individual test cases from the JSON stream:
```sh
make desktop-test-times
# or: cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
```
### Frontend UI review checklist
For anchored menus, dropdowns, tooltips, and other portaled UI, review both the
@@ -101,7 +119,9 @@ Desktop releases ride their own tag namespace, `desktop-v<semver>` (plain `v*`
tags are the CLI release). Pushing one triggers `.github/workflows/release-desktop.yml`,
which builds on a native runner per platform (Wails can't cross-compile a
CGO/WebKit binary), packages each artifact, signs it with minisign, generates a
`latest.json` manifest, publishes a GitHub release, and mirrors everything to R2.
`latest.json` manifest, publishes a GitHub release, mirrors everything to R2,
and attaches the current desktop manifest to the matching CLI release for old
clients that still ask GitHub's repository-wide `latest` release for it.
The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
`libwebkit2gtk-4.1-0` at runtime — present by default on Ubuntu 22.04+, Fedora 40+.
@@ -109,9 +129,12 @@ The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
git tag desktop-v1.1.0 && git push origin desktop-v1.1.0
```
The app checks `latest.json` on startup (R2 first, GitHub as fallback) and shows
an update banner when a newer version is published; **Settings → Software update**
has a manual check. Self-update behavior by platform:
The app checks `latest.json` on startup (R2 first, then the
`crash.reasonix.io` desktop release gateway) and shows an update banner when a
newer version is published; **Settings → Software update** has a manual check.
The gateway resolves only the desktop `desktop-v*` release line and never uses
GitHub's repository-wide `/releases/latest` shortcut, because plain `v*` tags are
the CLI release line. Self-update behavior by platform:
- **Linux / Windows** — download, verify the minisign signature, then update in
place: Linux replaces the binary and relaunches; Windows runs the per-user NSIS
+1050 -372
View File
File diff suppressed because it is too large. Load diff
+224
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
@@ -173,6 +174,229 @@ func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
}
}
func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
originalPath := filepath.Join(dir, "session.jsonl")
originalTopic := "topic_original"
if err := setTopicTitle("", originalTopic, "Original"); err != nil {
t.Fatalf("set original topic title: %v", err)
}
current := agent.NewSession("sys")
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
if err := current.Save(originalPath); err != nil {
t.Fatalf("Save current: %v", err)
}
if err := agent.SaveBranchMeta(originalPath, agent.BranchMeta{
Scope: "global",
TopicID: originalTopic,
TopicTitle: "Original",
Preview: "first",
Turns: 2,
SchemaVersion: agent.BranchMetaCountsVersion,
}); err != nil {
t.Fatalf("SaveBranchMeta original: %v", err)
}
staleSess := agent.NewSession("sys")
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
app := &App{
tabs: map[string]*WorkspaceTab{},
detachedSessions: map[string]*WorkspaceTab{},
activeTabID: "recovery_tab",
}
tab := &WorkspaceTab{
ID: "recovery_tab",
Scope: "global",
WorkspaceRoot: root,
TopicID: originalTopic,
TopicTitle: "Original",
SessionPath: originalPath,
Ready: true,
model: "test-model",
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
tab.Ctrl = control.New(control.Options{
Executor: staleExec,
SessionDir: dir,
SessionPath: originalPath,
Label: "test",
Sink: tab.sink,
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
OnSessionRecovered: app.handleTabSessionRecovered(tab),
})
app.tabs[tab.ID] = tab
if err := tab.Ctrl.Snapshot(); err != nil {
t.Fatalf("Snapshot: %v", err)
}
recoveryPath := tab.Ctrl.SessionPath()
if recoveryPath == "" || recoveryPath == originalPath {
t.Fatalf("recovery path = %q, want distinct path", recoveryPath)
}
if tab.SessionPath != recoveryPath {
t.Fatalf("tab session path = %q, want recovery path %q", tab.SessionPath, recoveryPath)
}
if tab.TopicID != originalTopic {
t.Fatalf("tab topic ID = %q, want original topic %q", tab.TopicID, originalTopic)
}
meta, ok, err := agent.LoadBranchMeta(recoveryPath)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
}
if !meta.Recovered || meta.TopicID != tab.TopicID || meta.TopicTitle != tab.TopicTitle {
t.Fatalf("recovery meta = %+v, tab topic=%q/%q", meta, tab.TopicID, tab.TopicTitle)
}
tabMeta := app.tabMeta(tab, true)
if !tabMeta.Recovered || tabMeta.RecoveryDigest != meta.RecoveryDigest || tabMeta.RecoveryParentID != string(meta.ParentID) {
t.Fatalf("tab recovery meta = %+v, want digest %q parent %q", tabMeta, meta.RecoveryDigest, meta.ParentID)
}
nodes := app.ListProjectTree()
foundOriginal := false
var walk func([]ProjectNode)
walk = func(list []ProjectNode) {
for _, node := range list {
if node.Recovered {
t.Fatalf("project tree should hide recovery metadata, got node %+v", node)
}
if node.TopicID == originalTopic {
foundOriginal = true
}
walk(node.Children)
}
}
walk(nodes)
if !foundOriginal {
t.Fatalf("project tree did not include original topic %q: %#v", originalTopic, nodes)
}
}
func TestDesktopSnapshotConflictRecoveryRequiresRecoveryLease(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
originalPath := filepath.Join(dir, "session.jsonl")
current := agent.NewSession("sys")
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
if err := current.Save(originalPath); err != nil {
t.Fatalf("Save current: %v", err)
}
staleSess := agent.NewSession("sys")
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
recovery, err := staleSess.SaveRecoveryBranch(agent.RecoveryBranchOptions{
OriginalPath: originalPath,
BranchMeta: agent.BranchMeta{
Name: agent.RecoveryBranchDefaultName,
Scope: "global",
TopicID: "topic_recovery",
TopicTitle: "Recovery",
},
})
if err != nil {
t.Fatalf("SaveRecoveryBranch: %v", err)
}
lease, err := agent.TryAcquireSessionLease(recovery.Path)
if err != nil {
t.Fatalf("TryAcquireSessionLease recovery: %v", err)
}
defer lease.Release()
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
runtimeEvents := make(chan runtimeEventEnvelope, 4)
app := &App{
ctx: context.Background(),
tabs: map[string]*WorkspaceTab{},
detachedSessions: map[string]*WorkspaceTab{},
activeTabID: "recovery_tab",
}
app.runtimeEvents.emit = func(ctx context.Context, name string, payload ...interface{}) {
runtimeEvents <- runtimeEventEnvelope{
ctx: ctx,
name: name,
payload: append([]interface{}(nil), payload...),
}
}
tab := &WorkspaceTab{
ID: "recovery_tab",
Scope: "global",
WorkspaceRoot: root,
TopicID: "topic_original",
TopicTitle: "Original",
SessionPath: originalPath,
Ready: true,
model: "test-model",
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
tab.Ctrl = control.New(control.Options{
Executor: staleExec,
SessionDir: dir,
SessionPath: originalPath,
Label: "test",
Sink: tab.sink,
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
OnSessionRecovered: app.handleTabSessionRecovered(tab),
})
app.tabs[tab.ID] = tab
err = tab.Ctrl.Snapshot()
if !errors.Is(err, agent.ErrSessionLeaseHeld) {
t.Fatalf("Snapshot err = %v, want ErrSessionLeaseHeld", err)
}
if got := tab.Ctrl.SessionPath(); got != originalPath {
t.Fatalf("controller session path = %q, want original %q", got, originalPath)
}
if tab.SessionPath != originalPath {
t.Fatalf("tab session path = %q, want original %q", tab.SessionPath, originalPath)
}
if tab.TopicID != "topic_original" {
t.Fatalf("tab topic ID = %q, want original topic", tab.TopicID)
}
deadline := time.After(time.Second)
for {
select {
case emitted := <-runtimeEvents:
if emitted.name != "session:recovery-failed" {
continue
}
if len(emitted.payload) != 1 {
t.Fatalf("session:recovery-failed payload count = %d, want 1", len(emitted.payload))
}
failed, ok := emitted.payload[0].(sessionRecoveryFailedEvent)
if !ok {
t.Fatalf("session:recovery-failed payload type = %T, want sessionRecoveryFailedEvent", emitted.payload[0])
}
if failed.Reason != "lease_held" {
t.Fatalf("session:recovery-failed reason = %q, want lease_held", failed.Reason)
}
return
case <-deadline:
t.Fatal("session:recovery-failed event was not emitted")
}
}
}
func TestSetActiveTabBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
+271
View File
@@ -3,6 +3,8 @@ package main
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
@@ -166,6 +168,60 @@ func TestEnsureBlankTabReusesIndexedTopicWithEmptyStub(t *testing.T) {
}
}
func TestEnsureBlankTabStoresCreatedAt(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
before := time.Now().UnixMilli()
meta, err := app.EnsureBlankTab("global", "")
after := time.Now().UnixMilli()
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
createdAt := loadTopicCreatedAt("", meta.TopicID)
if createdAt < before || createdAt > after {
t.Fatalf("createdAt = %d, want between %d and %d", createdAt, before, after)
}
nodes := app.ListProjectTree()
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 {
t.Fatalf("project tree = %#v, want Global with one topic", nodes)
}
if got := nodes[0].Children[0].CreatedAt; got != createdAt {
t.Fatalf("project tree createdAt = %d, want %d", got, createdAt)
}
}
func TestEnsureBlankTabRepairsMissingCreatedAtForReusedTopic(t *testing.T) {
isolateDesktopUserDirs(t)
const topicID = "topic_20260704-104018_deadbeef"
if err := setTopicTitleWithSource("", topicID, defaultTopicTitle, topicTitleSourceAuto); err != nil {
t.Fatalf("set topic title: %v", err)
}
if err := prependTopicInProjectsFile("", topicID, false); err != nil {
t.Fatalf("prepend topic: %v", err)
}
if got := loadTopicCreatedAt("", topicID); got != 0 {
t.Fatalf("createdAt before reuse = %d, want 0", got)
}
app := NewApp()
meta, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if meta.TopicID != topicID {
t.Fatalf("EnsureBlankTab topic = %q, want reused topic %q", meta.TopicID, topicID)
}
expected := time.Date(2026, 7, 4, 10, 40, 18, 0, time.UTC).UnixMilli()
if got := loadTopicCreatedAt("", topicID); got != expected {
t.Fatalf("repaired createdAt = %d, want %d", got, expected)
}
}
// EnsureBlankTab reuses an already-open project-scoped blank tab.
func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
@@ -189,6 +245,221 @@ func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
}
}
func TestEnsureBlankTabStartsProjectRuntimeWithCurrentWorkspacePrompt(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := robustTempDir(t)
projectB := robustTempDir(t)
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
app := NewApp()
first, err := app.EnsureBlankTab("project", projectA)
if err != nil {
t.Fatalf("EnsureBlankTab(project A): %v", err)
}
tabA := waitForTabReady(t, app, first.ID)
if got := normalizeProjectRoot(tabA.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectA) {
t.Fatalf("project A controller workspace root = %q, want %q", got, normalizeProjectRoot(projectA))
}
second, err := app.EnsureBlankTab("project", projectB)
if err != nil {
t.Fatalf("EnsureBlankTab(project B): %v", err)
}
if second.ID == first.ID {
t.Fatalf("EnsureBlankTab reused project A tab %q for project B", second.ID)
}
tabB := waitForTabReady(t, app, second.ID)
if got := normalizeProjectRoot(tabB.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
t.Fatalf("project B tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
t.Fatalf("project B controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
if !sameDesktopPath(tabB.Ctrl.SessionDir(), desktopSessionDir(projectB)) {
t.Fatalf("project B controller session dir = %q, want %q", tabB.Ctrl.SessionDir(), desktopSessionDir(projectB))
}
if !sameDesktopPath(filepath.Dir(tabB.Ctrl.SessionPath()), desktopSessionDir(projectB)) {
t.Fatalf("project B controller session path = %q, want under %q", tabB.Ctrl.SessionPath(), desktopSessionDir(projectB))
}
sys := systemPromptFrom(tabB.Ctrl.History())
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
t.Fatalf("project B system prompt missing current workspace %q:\n%s", projectB, sys)
}
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
t.Fatalf("project B system prompt retained project A workspace %q:\n%s", projectA, sys)
}
}
func TestBlankTabSessionPathRejectsOtherProjectWorkspace(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := robustTempDir(t)
projectB := robustTempDir(t)
pathA, err := createEmptySessionFile(desktopSessionDir(projectA), "test-model")
if err != nil {
t.Fatalf("create project A empty session: %v", err)
}
tab := &WorkspaceTab{
ID: "blank-project-b",
Scope: "project",
WorkspaceRoot: projectB,
SessionPath: pathA,
}
if blankTabSessionPathHasNoContent(tab) {
t.Fatalf("blank tab treated session %q from project A as reusable for project B %q", pathA, projectB)
}
}
func TestForkKeepsProjectWorkspacePrompt(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := robustTempDir(t)
projectB := robustTempDir(t)
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
app := NewApp()
first, err := app.EnsureBlankTab("project", projectA)
if err != nil {
t.Fatalf("EnsureBlankTab(project A): %v", err)
}
waitForTabReady(t, app, first.ID)
second, err := app.EnsureBlankTab("project", projectB)
if err != nil {
t.Fatalf("EnsureBlankTab(project B): %v", err)
}
tabB := waitForTabReady(t, app, second.ID)
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
forked, err := app.Fork(turn)
if err != nil {
t.Fatalf("Fork: %v", err)
}
if forked.ID == "" || forked.ID == second.ID {
t.Fatalf("forked tab ID = %q, want a fresh tab distinct from %q", forked.ID, second.ID)
}
forkTab := waitForTabReady(t, app, forked.ID)
if got := normalizeProjectRoot(forkTab.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
t.Fatalf("fork tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
if got := normalizeProjectRoot(forkTab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
t.Fatalf("fork controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
sys := systemPromptFrom(forkTab.Ctrl.History())
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
t.Fatalf("fork system prompt missing project B workspace %q:\n%s", projectB, sys)
}
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
t.Fatalf("fork system prompt retained project A workspace %q:\n%s", projectA, sys)
}
}
func TestRewindKeepsProjectWorkspacePrompt(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := robustTempDir(t)
projectB := robustTempDir(t)
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
app := NewApp()
first, err := app.EnsureBlankTab("project", projectA)
if err != nil {
t.Fatalf("EnsureBlankTab(project A): %v", err)
}
waitForTabReady(t, app, first.ID)
second, err := app.EnsureBlankTab("project", projectB)
if err != nil {
t.Fatalf("EnsureBlankTab(project B): %v", err)
}
tabB := waitForTabReady(t, app, second.ID)
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
if err := app.Rewind(turn, "conversation"); err != nil {
t.Fatalf("Rewind: %v", err)
}
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
t.Fatalf("rewound controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
sys := systemPromptFrom(tabB.Ctrl.History())
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
t.Fatalf("rewound system prompt missing project B workspace %q:\n%s", projectB, sys)
}
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
t.Fatalf("rewound system prompt retained project A workspace %q:\n%s", projectA, sys)
}
}
func installStubControllerWithCurrentPrompt(t *testing.T, app *App, tab *WorkspaceTab) *control.Controller {
t.Helper()
if tab == nil || tab.Ctrl == nil {
t.Fatal("tab controller is required")
}
sys := systemPromptFrom(tab.Ctrl.History())
if strings.TrimSpace(sys) == "" {
t.Fatal("tab controller did not expose a system prompt")
}
sessionDir := tab.Ctrl.SessionDir()
sessionPath := tab.Ctrl.SessionPath()
workspaceRoot := tab.Ctrl.WorkspaceRoot()
label := tab.Ctrl.Label()
tab.Ctrl.Close()
sess := agent.NewSession(sys)
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
ctrl := control.New(control.Options{
Runner: ag,
Executor: ag,
SessionDir: sessionDir,
SessionPath: sessionPath,
WorkspaceRoot: workspaceRoot,
Label: label,
SystemPrompt: sys,
Sink: event.Discard,
})
tab.Ctrl = ctrl
app.bindControllerDisplayRecorder(ctrl)
return ctrl
}
func submitStubTurnAndWaitForCheckpoint(t *testing.T, ctrl control.SessionAPI, input string) int {
t.Helper()
ctrl.SubmitUserTurn(input, input)
waitNotRunning(t, ctrl)
deadline := time.Now().Add(time.Second)
for {
checkpoints := ctrl.Checkpoints()
if len(checkpoints) > 0 {
return checkpoints[len(checkpoints)-1].Turn
}
if time.Now().After(deadline) {
t.Fatal("controller did not record a checkpoint")
}
time.Sleep(10 * time.Millisecond)
}
}
func TestEnsureBlankTabResetsReusableAutoTopicTitle(t *testing.T) {
isolateDesktopUserDirs(t)
+1759 -3
View File
File diff suppressed because it is too large. Load diff
+31 -3
View File
@@ -15,6 +15,7 @@ import (
"reasonix/internal/bot"
"reasonix/internal/bot/feishu"
"reasonix/internal/bot/weixin"
"reasonix/internal/botruntime"
"reasonix/internal/config"
)
@@ -48,6 +49,7 @@ type BotConnectionView struct {
Model string `json:"model"`
ToolApprovalMode string `json:"toolApprovalMode"`
WorkspaceRoot string `json:"workspaceRoot"`
Access BotAccessView `json:"access"`
Credential BotConnectionCredentialView `json:"credential"`
SessionMappings []BotConnectionSessionMappingView `json:"sessionMappings"`
LastError string `json:"lastError"`
@@ -136,12 +138,19 @@ func (a *App) StartBotConnectionInstall(provider, domain string) (BotInstallStar
func (a *App) PollBotConnectionInstall(installID string) (BotInstallPollResult, error) {
installID = strings.TrimSpace(installID)
// Copy the session under a.mu: overlapping polls of the same install can
// race the locked PollDomain upgrade below with unlocked field reads.
a.mu.RLock()
session := a.botInstalls[installID]
sessionPtr := a.botInstalls[installID]
var sessionCopy botInstallSession
if sessionPtr != nil {
sessionCopy = *sessionPtr
}
a.mu.RUnlock()
if session == nil {
if sessionPtr == nil {
return BotInstallPollResult{Error: "install session not found"}, nil
}
session := &sessionCopy
if time.Now().After(session.ExpireAt) {
a.deleteBotInstall(installID)
return BotInstallPollResult{Status: "expired", Error: "install session expired"}, nil
@@ -164,6 +173,7 @@ func (a *App) PollBotConnectionInstall(installID string) (BotInstallPollResult,
Label: "微信",
Enabled: true,
Status: "connected",
Access: botInstallAccess(result.UserID),
Credential: config.BotConnectionCredential{AccountID: result.AccountID, TokenEnv: "WEIXIN_BOT_TOKEN"},
}, func(c *config.Config) {
c.Bot.Enabled = true
@@ -518,6 +528,7 @@ func (a *App) pollFeishuConnectionInstall(installID string, session *botInstallS
Label: label,
Enabled: true,
Status: "connected",
Access: botInstallAccess(userID),
Credential: config.BotConnectionCredential{AppID: appID, AppSecretEnv: secretEnv},
}, func(c *config.Config) {
c.Bot.Enabled = true
@@ -545,6 +556,9 @@ func (a *App) upsertBotConnection(conn config.BotConnectionConfig, updateLegacy
if conn.Status == "" {
conn.Status = "connected"
}
if normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode) == "" {
conn.ToolApprovalMode = "ask"
}
if conn.ID == "" {
conn.ID = connectionID(conn.Provider, conn.Domain)
}
@@ -556,6 +570,9 @@ func (a *App) upsertBotConnection(conn config.BotConnectionConfig, updateLegacy
for i, existing := range c.Bot.Connections {
if existing.ID == conn.ID {
conn.CreatedAt = firstNonEmptyBot(existing.CreatedAt, conn.CreatedAt)
if !botruntime.BotAccessActive(conn.Access) && botruntime.BotAccessActive(existing.Access) {
conn.Access = existing.Access
}
c.Bot.Connections[i] = conn
replaced = true
break
@@ -697,6 +714,7 @@ func botConnectionView(conn config.BotConnectionConfig) BotConnectionView {
return BotConnectionView{
ID: conn.ID, Provider: conn.Provider, Domain: conn.Domain, Label: conn.Label, Enabled: conn.Enabled, Status: conn.Status,
Model: conn.Model, ToolApprovalMode: normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode), WorkspaceRoot: conn.WorkspaceRoot,
Access: botAccessViewFromConfig(conn.Access),
Credential: BotConnectionCredentialView{
AppID: conn.Credential.AppID, AppSecretEnv: conn.Credential.AppSecretEnv, AccountID: conn.Credential.AccountID, TokenEnv: conn.Credential.TokenEnv,
SecretSet: botCredentialSecretSet(conn),
@@ -763,8 +781,9 @@ func botConnectionConfig(view BotConnectionView) config.BotConnectionConfig {
Enabled: view.Enabled,
Status: strings.TrimSpace(view.Status),
Model: strings.TrimSpace(view.Model),
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(view.ToolApprovalMode),
ToolApprovalMode: firstNonEmptyBot(normalizeBotConnectionToolApprovalMode(view.ToolApprovalMode), "ask"),
WorkspaceRoot: strings.TrimSpace(view.WorkspaceRoot),
Access: botAccessConfigFromView(view.Access),
Credential: config.BotConnectionCredential{
AppID: strings.TrimSpace(view.Credential.AppID),
AppSecretEnv: strings.TrimSpace(view.Credential.AppSecretEnv),
@@ -873,6 +892,15 @@ func connectionID(provider, domain string) string {
return strings.Trim(strings.ToLower(provider+"-"+domain), "-")
}
func botInstallAccess(userID string) config.BotAccessConfig {
userID = strings.TrimSpace(userID)
access := config.BotAccessConfig{Enabled: true, PairingEnabled: true}
if userID != "" {
access.Users = []string{userID}
}
return access
}
func randomInstallID() string {
var b [12]byte
if _, err := rand.Read(b[:]); err != nil {
+108 -27
View File
@@ -23,16 +23,45 @@ type BotRuntimeStatusView struct {
}
type desktopBotRuntime struct {
mu sync.Mutex
cancel context.CancelFunc
gw *bot.BotGateway
status BotRuntimeStatusView
// lifecycleMu serializes start/stop transitions so two apply/stop calls
// can't race a gateway into existence. The slow work (gw.Stop teardown,
// gw.Start dials) runs while holding it but NOT r.mu, so status/send reads
// never block on a restart.
lifecycleMu sync.Mutex
mu sync.Mutex
cancel context.CancelFunc
gw *bot.BotGateway
status BotRuntimeStatusView
}
func newDesktopBotRuntime() *desktopBotRuntime {
return &desktopBotRuntime{status: BotRuntimeStatusView{Status: "stopped", Message: "bot runtime is not started"}}
}
func desktopBotChannelsWithLegacyQQ(qq config.QQBotConfig, channels map[bot.Platform]bot.ChannelConfig, connectionChannels map[string]bot.ChannelConfig) (map[bot.Platform]bot.ChannelConfig, map[string]bot.ChannelConfig) {
channel := bot.ChannelConfig{
Model: strings.TrimSpace(qq.Model),
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(qq.ToolApprovalMode),
WorkspaceRoot: strings.TrimSpace(qq.WorkspaceRoot),
}
if channel.Model == "" && channel.ToolApprovalMode == "" && channel.WorkspaceRoot == "" {
return channels, connectionChannels
}
if channels == nil {
channels = make(map[bot.Platform]bot.ChannelConfig)
}
if _, ok := channels[bot.PlatformQQ]; !ok {
channels[bot.PlatformQQ] = channel
}
if connectionChannels == nil {
connectionChannels = make(map[string]bot.ChannelConfig)
}
if _, ok := connectionChannels[string(bot.PlatformQQ)]; !ok {
connectionChannels[string(bot.PlatformQQ)] = channel
}
return channels, connectionChannels
}
func (a *App) refreshBotRuntimeAsync() {
if a.ctx == nil {
return
@@ -41,8 +70,11 @@ func (a *App) refreshBotRuntimeAsync() {
}
func (a *App) refreshBotRuntime() {
// NewApp always pre-fills botRuntime; a nil here means a test-constructed
// App with no bot runtime, which must not lazily create one from a
// background goroutine (that would race a concurrent refresh).
if a.botRuntime == nil {
a.botRuntime = newDesktopBotRuntime()
return
}
cfg, err := a.loadDesktopBotConfig()
if err != nil {
@@ -53,7 +85,11 @@ func (a *App) refreshBotRuntime() {
}
func (a *App) loadDesktopBotConfig() (*config.Config, error) {
cfg, _, err := a.loadDesktopUserConfigForEdit()
// Read-only load feeding the bot runtime and connection diagnostics. It
// must load credentials: the runtime resolves app secrets and control
// tokens from the process env (AppSecretEnv, Control.TokenEnv), which the
// credential-free view load would leave unset on a fresh process.
cfg, _, err := a.loadDesktopUserConfigForViewWithCredentials()
if err != nil {
return nil, err
}
@@ -81,24 +117,44 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
parent = context.Background()
}
plan := desktopBotRuntimePlan(cfg)
r.mu.Lock()
defer r.mu.Unlock()
r.stopLocked()
r.lifecycleMu.Lock()
defer r.lifecycleMu.Unlock()
r.stopCurrent()
if !plan.Start {
r.status = BotRuntimeStatusView{Status: plan.Status, Message: plan.Message}
r.setStatus(BotRuntimeStatusView{Status: plan.Status, Message: plan.Message})
return nil
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
ctx, cancel := context.WithCancel(parent)
modelName := botruntime.ModelName(cfg, "")
channels := botruntime.ChannelConfigs(cfg.Bot.Connections, true, true)
connectionChannels := botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, true, true)
channels, connectionChannels = desktopBotChannelsWithLegacyQQ(cfg.Bot.QQ, channels, connectionChannels)
gwCfg := bot.GatewayConfig{
Model: modelName,
ToolApprovalMode: cfg.Bot.ToolApprovalMode,
MaxSteps: cfg.Bot.MaxSteps,
QueueMode: cfg.Bot.QueueMode,
QueueCap: cfg.Bot.QueueCap,
QueueDrop: cfg.Bot.QueueDrop,
PairingEnabled: cfg.Bot.Pairing.Enabled,
PairingTTL: time.Duration(cfg.Bot.Pairing.RequestTTLMinutes) * time.Minute,
PairingMaxPending: cfg.Bot.Pairing.MaxPendingPerPlatform,
IgnoreSelfMessages: cfg.Bot.IgnoreSelfMessages,
SelfUserIDs: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.SelfUserIDs.QQ,
bot.PlatformFeishu: cfg.Bot.SelfUserIDs.Feishu,
bot.PlatformWeixin: cfg.Bot.SelfUserIDs.Weixin,
},
ControlEnabled: cfg.Bot.Control.Enabled,
ControlAddr: cfg.Bot.Control.Addr,
ControlToken: os.Getenv(strings.TrimSpace(cfg.Bot.Control.TokenEnv)),
WorkspaceRoot: workspaceRoot,
Channels: botruntime.ChannelConfigs(cfg.Bot.Connections, true, true),
ConnectionChannels: botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, true, true),
Channels: channels,
ConnectionChannels: connectionChannels,
Routes: botruntime.RouteConfigs(cfg.Bot.Routes, true, true),
ConnectionAccess: botruntime.ConnectionAccessConfigs(cfg),
Enabled: plan.Enabled,
Allowlist: bot.AllowlistConfig{
Enabled: cfg.Bot.Allowlist.Enabled,
@@ -108,6 +164,16 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuUsers,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinUsers,
},
Approvers: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.Allowlist.QQApprovers,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuApprovers,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinApprovers,
},
Admins: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.Allowlist.QQAdmins,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuAdmins,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinAdmins,
},
Groups: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.Allowlist.QQGroups,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuGroups,
@@ -122,14 +188,14 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
bindings := botruntime.AdapterBindings(cfg, plan.Enabled, nil, logger)
if len(bindings) == 0 {
cancel()
r.status = BotRuntimeStatusView{Status: "stopped", Message: "no bot adapters configured"}
r.setStatus(BotRuntimeStatusView{Status: "stopped", Message: "no bot adapters configured"})
return nil
}
gw := bot.NewGatewayWithAdapterBindings(gwCfg, bindings, logger)
if err := gw.Start(ctx); err != nil {
cancel()
gw.Stop()
r.status = BotRuntimeStatusView{Status: "error", Message: err.Error(), Connections: gw.AdapterCount()}
r.setStatus(BotRuntimeStatusView{Status: "error", Message: err.Error(), Connections: gw.AdapterCount()})
return err
}
runningConnections := gw.AdapterCount()
@@ -140,6 +206,7 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
status = "degraded"
message = fmt.Sprintf("%d bot connection(s) running; %d failed to start: %s", runningConnections, len(startErrors), summarizeBotRuntimeErrors(startErrors))
}
r.mu.Lock()
r.cancel = cancel
r.gw = gw
r.status = BotRuntimeStatusView{
@@ -149,6 +216,7 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
Connections: runningConnections,
StartedAt: time.Now().UTC().Format(time.RFC3339),
}
r.mu.Unlock()
return nil
}
@@ -206,8 +274,8 @@ func desktopBotRuntimePlan(cfg *config.Config) botRuntimePlan {
if !cfg.Bot.Enabled {
return botRuntimePlan{Status: "stopped", Message: "bot is disabled"}
}
if !cfg.Bot.Allowlist.AllowAll && (!cfg.Bot.Allowlist.Enabled || botruntime.AllowlistUserCount(cfg.Bot.Allowlist) == 0) {
return botRuntimePlan{Status: "blocked", Message: "bot requires an allowlist or allow_all=true"}
if !botruntime.BotConfigHasAccessControl(cfg.Bot) {
return botRuntimePlan{Status: "blocked", Message: "bot requires an allowlist, pairing, per-bot access, or allow_all=true"}
}
enabled, unknown := botruntime.EnabledPlatforms(cfg, nil)
if len(unknown) > 0 {
@@ -220,23 +288,36 @@ func desktopBotRuntimePlan(cfg *config.Config) botRuntimePlan {
}
func (r *desktopBotRuntime) stop(status, message string) {
r.mu.Lock()
defer r.mu.Unlock()
r.stopLocked()
r.status = BotRuntimeStatusView{Status: status, Message: message}
r.lifecycleMu.Lock()
defer r.lifecycleMu.Unlock()
r.stopCurrent()
r.setStatus(BotRuntimeStatusView{Status: status, Message: message})
}
func (r *desktopBotRuntime) stopLocked() {
if r.cancel != nil {
r.cancel()
r.cancel = nil
// stopCurrent detaches the running gateway under r.mu, then tears it down
// off-lock: gw.Stop() closes every session controller (up to the jobs teardown
// grace each) and must not stall status/send readers. Callers hold lifecycleMu.
func (r *desktopBotRuntime) stopCurrent() {
r.mu.Lock()
cancel := r.cancel
gw := r.gw
r.cancel = nil
r.gw = nil
r.mu.Unlock()
if cancel != nil {
cancel()
}
if r.gw != nil {
r.gw.Stop()
r.gw = nil
if gw != nil {
gw.Stop()
}
}
func (r *desktopBotRuntime) setStatus(status BotRuntimeStatusView) {
r.mu.Lock()
r.status = status
r.mu.Unlock()
}
func (r *desktopBotRuntime) snapshot() BotRuntimeStatusView {
r.mu.Lock()
defer r.mu.Unlock()
+65 -5
View File
@@ -39,6 +39,7 @@ func TestDesktopBotRuntimePlanBlocksWithoutAllowlist(t *testing.T) {
cfg := config.Default()
cfg.Bot.Enabled = true
cfg.Bot.Allowlist.Enabled = true
cfg.Bot.Pairing.Enabled = false
cfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
}
@@ -49,6 +50,44 @@ func TestDesktopBotRuntimePlanBlocksWithoutAllowlist(t *testing.T) {
}
}
func TestDesktopBotRuntimePlanStartsWithPairing(t *testing.T) {
cfg := config.Default()
cfg.Bot.Enabled = true
cfg.Bot.Allowlist.Enabled = true
cfg.Bot.Pairing.Enabled = true
cfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
}
plan := desktopBotRuntimePlan(cfg)
if !plan.Start {
t.Fatalf("plan = %+v, want start with pairing enabled", plan)
}
}
func TestDesktopBotChannelsWithLegacyQQConfig(t *testing.T) {
channels, connectionChannels := desktopBotChannelsWithLegacyQQ(config.QQBotConfig{
Model: "qq-model",
ToolApprovalMode: "auto",
WorkspaceRoot: "/tmp/qq-project",
}, nil, nil)
channel, ok := channels[bot.PlatformQQ]
if !ok {
t.Fatalf("platform QQ channel missing: %+v", channels)
}
if channel.Model != "qq-model" || channel.ToolApprovalMode != "auto" || channel.WorkspaceRoot != "/tmp/qq-project" {
t.Fatalf("platform channel = %+v, want QQ-specific runtime fields", channel)
}
connectionChannel, ok := connectionChannels["qq"]
if !ok {
t.Fatalf("connection QQ channel missing: %+v", connectionChannels)
}
if connectionChannel.Model != "qq-model" || connectionChannel.ToolApprovalMode != "auto" || connectionChannel.WorkspaceRoot != "/tmp/qq-project" {
t.Fatalf("connection channel = %+v, want QQ-specific runtime fields", connectionChannel)
}
}
func TestDesktopBotRuntimePlanStopsWhenBotDisabled(t *testing.T) {
cfg := config.Default()
cfg.Bot.Enabled = false
@@ -263,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)
}
@@ -271,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)
@@ -312,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)
}
@@ -320,13 +372,21 @@ status = "connected"
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
}
// The bot-runtime load is a pure read: it serves the legacy config from
// memory and must not create the user config file.
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
t.Fatalf("read path must not create the user config, stat err = %v", err)
}
// The first locked write path creates the user config with the migrated
// bot settings (adopting the legacy config, ConfigVersion-bumped).
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
t.Fatalf("applyConfigOnly: %v", err)
}
persisted := config.LoadForEdit(config.UserConfigPath())
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
}
if persisted.DesktopTheme() == "dark" {
t.Fatal("legacy project desktop theme should not be persisted during bot-only migration")
}
}
func TestDesktopSettingsBotMigrationPersistsOnlyBotBeforeFirstEdit(t *testing.T) {
+2 -2
View File
@@ -42,9 +42,9 @@ func TestBoundArrayPayloadsAreNonNilBeforeStartup(t *testing.T) {
if got := app.HooksSettings("global"); got.Hooks == nil || got.Events == nil {
t.Fatalf("HooksSettings(global) arrays = hooks:%v events:%v, want non-nil", got.Hooks, got.Events)
}
if got := app.Settings(); got.Providers == nil || got.OfficialProviders == nil || got.ProviderKinds == nil ||
if got := app.Settings(); got.Providers == nil || got.OfficialProviders == nil || got.ProviderPresets == nil || got.ProviderKinds == nil ||
got.Permissions.Allow == nil || got.Permissions.Ask == nil || got.Permissions.Deny == nil ||
got.Sandbox.AllowWrite == nil ||
got.Sandbox.AllowWrite == nil || got.Sandbox.EffectiveWriteRoots == nil ||
got.Bot.Allowlist.QQUsers == nil || got.Bot.Allowlist.FeishuUsers == nil || got.Bot.Allowlist.WeixinUsers == nil ||
got.Bot.Allowlist.QQGroups == nil || got.Bot.Allowlist.FeishuGroups == nil || got.Bot.Allowlist.WeixinGroups == nil {
t.Fatalf("Settings() contains nil array fields: %+v", got)
@@ -36,6 +36,7 @@ Unicode true
####
!include "wails_tools.nsh"
!include "FileFunc.nsh"
!include "LogicLib.nsh"
# The version information for this two must consist of 4 parts
VIProductVersion "${INFO_PRODUCTVERSION}.0"
@@ -76,6 +77,8 @@ ManifestDPIAware true
Name "${INFO_PRODUCTNAME}"
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
!define REASONIX_DEFAULT_INSTALLDIR "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}"
!define REASONIX_UPDATE_HELPER "reasonix-update-helper.exe"
!define REASONIX_UNLOCK_RETRIES 60
InstallDirRegKey HKCU "${UNINST_KEY}" "InstallLocation" # Reuse the previous install path on update; .onInit falls back to the default on first install.
InstallDir "${REASONIX_DEFAULT_INSTALLDIR}" # Per-user install location (no admin rights required).
ShowInstDetails show # This will always show the installation details.
@@ -130,14 +133,54 @@ fallback:
done:
FunctionEnd
Function reasonix.waitForExecutableUnlock
IfFileExists "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 done
StrCpy $0 0
retry:
ClearErrors
FileOpen $1 "$INSTDIR\${PRODUCT_EXECUTABLE}" a
IfErrors locked
FileClose $1
Goto done
locked:
IntOp $0 $0 + 1
IntCmp $0 ${REASONIX_UNLOCK_RETRIES} failed 0 0
Sleep 1000
Goto retry
failed:
IfSilent silent interactive
interactive:
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "Reasonix is still running. Close Reasonix, then click Retry to continue the installation." IDRETRY retry IDCANCEL abort
Goto retry
silent:
SetErrorLevel 1618
abort:
Abort "Reasonix is still running. Close Reasonix and run the installer again."
done:
FunctionEnd
Section
!insertmacro wails.setShellContext
!insertmacro wails.webview2runtime
Call reasonix.waitForExecutableUnlock
SetOutPath $INSTDIR
!insertmacro wails.files
!if /FileExists "${REASONIX_UPDATE_HELPER}"
File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
!else
!warning "${REASONIX_UPDATE_HELPER} was not found; Windows auto-update will fall back to installer-side waiting only."
!endif
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
@@ -155,6 +198,7 @@ Section "uninstall"
; Precision uninstall: delete main application files
Delete "$INSTDIR\${PRODUCT_EXECUTABLE}"
Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
+3 -3
View File
@@ -166,12 +166,12 @@ func signFiles(files []string) error {
// release tag used in download URLs (e.g. "desktop-v1.1.0").
func genManifest(dir, version, tag string) error {
repo := os.Getenv("GITHUB_REPOSITORY")
if repo == "" {
repo = "esengine/reasonix"
if repo == "" || repo == "esengine/reasonix" {
repo = "esengine/DeepSeek-Reasonix"
}
m := update.Manifest{
Version: version,
DownloadPage: fmt.Sprintf("https://github.com/%s/releases/latest", repo),
DownloadPage: "https://reasonix.io/#start",
Platforms: map[string]update.Asset{},
}
entries, err := os.ReadDir(dir)
+4 -1
View File
@@ -85,6 +85,9 @@ func TestGenManifest(t *testing.T) {
if m.Version != "v1.2.0" {
t.Fatalf("version = %q, want v1.2.0", m.Version)
}
if m.DownloadPage != "https://reasonix.io/#start" {
t.Fatalf("download_page = %q, want official install page", m.DownloadPage)
}
if len(m.Platforms) != 5 {
t.Fatalf("want 5 platforms, got %d: %v", len(m.Platforms), m.Platforms)
}
@@ -92,7 +95,7 @@ func TestGenManifest(t *testing.T) {
if !ok {
t.Fatal("windows-amd64 missing")
}
wantURL := "https://github.com/esengine/reasonix/releases/download/desktop-v1.2.0/Reasonix-windows-amd64-installer.exe"
wantURL := "https://github.com/esengine/DeepSeek-Reasonix/releases/download/desktop-v1.2.0/Reasonix-windows-amd64-installer.exe"
if win.URL != wantURL {
t.Fatalf("windows url = %q, want %q", win.URL, wantURL)
}
+11
View File
@@ -0,0 +1,11 @@
package main
import "fmt"
func installerCommandLine(installer, dir string) string {
line := fmt.Sprintf(`"%s" /S`, installer)
if dir != "" {
line += " /D=" + dir
}
return line
}
+17
View File
@@ -0,0 +1,17 @@
package main
import (
"strings"
"testing"
)
func TestInstallerCommandLineIsSilentAndLeavesDFlagLast(t *testing.T) {
got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`)
want := `"C:\Temp\Reasonix Installer.exe" /S /D=D:\Tools\Reasonix App`
if got != want {
t.Fatalf("installerCommandLine = %q, want %q", got, want)
}
if !strings.HasSuffix(got, `/D=D:\Tools\Reasonix App`) {
t.Fatalf("/D= must be the final unquoted NSIS token, got %q", got)
}
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !windows
package main
import (
"fmt"
"os"
)
func main() {
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Windows desktop builds")
os.Exit(2)
}
+108
View File
@@ -0,0 +1,108 @@
//go:build windows
package main
import (
"flag"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"syscall"
"time"
"golang.org/x/sys/windows"
)
const parentExitTimeout = 2 * time.Minute
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
var parentPID uint
var installer, installDir, relaunch string
fs := flag.NewFlagSet("reasonix-update-helper", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
fs.UintVar(&parentPID, "parent-pid", 0, "Reasonix process id to wait for before installing")
fs.StringVar(&installer, "installer", "", "verified NSIS installer path")
fs.StringVar(&installDir, "install-dir", "", "Reasonix installation directory")
fs.StringVar(&relaunch, "relaunch", "", "Reasonix executable to start after the installer succeeds")
if err := fs.Parse(args); err != nil {
return 2
}
logger := newLogger()
if installer == "" {
logger.Print("missing --installer")
return 2
}
if parentPID != 0 {
if err := waitForProcessExit(uint32(parentPID), parentExitTimeout); err != nil {
logger.Printf("wait for parent process %d: %v", parentPID, err)
return 1
}
}
if err := runInstaller(installer, installDir); err != nil {
logger.Printf("run installer: %v", err)
return 1
}
if relaunch != "" {
if err := startRelaunch(relaunch, installDir); err != nil {
logger.Printf("relaunch: %v", err)
return 1
}
}
return 0
}
func newLogger() *log.Logger {
dir, err := os.UserCacheDir()
if err == nil {
dir = filepath.Join(dir, "Reasonix", "updates")
if err := os.MkdirAll(dir, 0o700); err == nil {
if f, err := os.OpenFile(filepath.Join(dir, "update-helper.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
return log.New(f, "", log.LstdFlags)
}
}
}
return log.New(os.Stderr, "", log.LstdFlags)
}
func waitForProcessExit(pid uint32, timeout time.Duration) error {
h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, pid)
if err != nil {
if err == windows.ERROR_INVALID_PARAMETER {
return nil
}
return err
}
defer windows.CloseHandle(h)
waitMS := uint32(timeout / time.Millisecond)
result, err := windows.WaitForSingleObject(h, waitMS)
if err != nil {
return err
}
switch result {
case windows.WAIT_OBJECT_0:
return nil
case uint32(windows.WAIT_TIMEOUT):
return fmt.Errorf("timed out after %s", timeout)
default:
return fmt.Errorf("unexpected wait result %d", result)
}
}
func runInstaller(installer, installDir string) error {
cmd := exec.Command(installer)
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(installer, installDir), HideWindow: true}
return cmd.Run()
}
func startRelaunch(relaunch, installDir string) error {
cmd := exec.Command(relaunch)
cmd.Dir = installDir
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
return cmd.Start()
}
+20 -3
View File
@@ -46,15 +46,32 @@ func writePendingCrash(site string, r any, stack []byte) {
report.Stack = sanitizeCrashText(stackText, maxCrashStackBytes)
report.TopFrame = topFrameFromStack(report.Stack)
report.Message = msg
_ = writePendingReport(report, true)
}
func writePendingReport(report crashReport, overwrite bool) bool {
body, err := json.Marshal(report)
if err != nil {
return
return false
}
path := pendingCrashPath()
if os.MkdirAll(filepath.Dir(path), 0o755) != nil {
return
return false
}
_ = os.WriteFile(path, body, 0o644)
if overwrite {
return os.WriteFile(path, body, 0o644) == nil
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return false
}
defer f.Close()
n, err := f.Write(body)
if err != nil || n != len(body) {
_ = os.Remove(path)
return false
}
return true
}
func (a *App) goSafe(site string, fn func()) {
+61
View File
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
)
@@ -67,6 +68,66 @@ func TestWritePendingCrashCaps(t *testing.T) {
}
}
func TestWritePendingReportCanAvoidOverwritingExistingCrash(t *testing.T) {
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
writePendingCrash("panic", "boom", []byte("stack"))
before, ok := readPending(t)
if !ok {
t.Fatal("expected initial pending crash")
}
hang := baseCrashReport("performance")
hang.Source = "native.watchdog"
hang.Label = "mac.main_thread.hang"
hang.Message = "hang"
if writePendingReport(hang, false) {
t.Fatal("writePendingReport overwrite=false should not replace existing report")
}
after, ok := readPending(t)
if !ok {
t.Fatal("expected pending crash after skipped write")
}
if after.Label != before.Label || after.Message != before.Message {
t.Fatalf("pending crash was overwritten: before=%+v after=%+v", before, after)
}
}
func TestWritePendingReportNonOverwriteAllowsOneConcurrentWriter(t *testing.T) {
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
const writers = 32
start := make(chan struct{})
var ready sync.WaitGroup
var done sync.WaitGroup
var successes atomic.Int32
for i := 0; i < writers; i++ {
ready.Add(1)
done.Add(1)
go func() {
defer done.Done()
report := baseCrashReport("performance")
report.Source = "native.watchdog"
report.Label = "mac.main_thread.hang"
report.Message = strings.Repeat("hang", 1024)
ready.Done()
<-start
if writePendingReport(report, false) {
successes.Add(1)
}
}()
}
ready.Wait()
close(start)
done.Wait()
if got := successes.Load(); got != 1 {
t.Fatalf("successful non-overwrite writers = %d, want 1", got)
}
if _, ok := readPending(t); !ok {
t.Fatal("expected one pending report")
}
}
func TestWritePendingCrashScrubsSensitiveText(t *testing.T) {
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
apiKey := "sk-proj-" + "abcdefghijklmnopqrstuvwxyz1234567890"
+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__/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__/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",
+53 -1
View File
@@ -35,10 +35,11 @@ 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";
import { app, onEvent, onProjectTreeChanged } from "./lib/bridge";
import { app, onEvent, onProjectTreeChanged, onSessionRecovered, onSessionRecoveryFailed } from "./lib/bridge";
import { generativeMusic, isGenerativeMusicEnabled } from "./lib/generative-music";
import { playSuccessChime } from "./lib/sound";
import { Transcript } from "./components/Transcript";
@@ -1030,6 +1031,9 @@ export default function App() {
const rightDockMode = useLayoutStore((s) => s.rightDockMode);
const setRightDockMode = useLayoutStore((s) => s.setRightDockMode);
const [dockRefreshKey, setDockRefreshKey] = useState(0);
const [fileRefRefreshKey, setFileRefRefreshKey] = useState(0);
const refreshComposerFileRefs = useCallback(() => setFileRefRefreshKey((value) => value + 1), []);
const composerFileRefRefreshKey = `${dockRefreshKey}:${fileRefRefreshKey}`;
const [projectRevision, setProjectRevision] = useState(0);
const [activeTopicTurns, setActiveTopicTurns] = useState<number | undefined>(undefined);
const [composerInsertRequest, setComposerInsertRequest] = useState<ComposerInsertRequest | null>(null);
@@ -1038,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);
@@ -2588,6 +2593,37 @@ export default function App() {
enqueueNavigation({ kind: "blank", scope, workspaceRoot: scope === "project" ? workspaceRoot : "" }),
[enqueueNavigation]);
useEffect(() => {
return onSessionRecovered((event) => {
const scope = event.scope === "project" ? "project" : "global";
const workspaceRoot = scope === "project" ? event.workspaceRoot || "" : "";
setProjectRevision((value) => value + 1);
void refreshTabMetas();
showToast(t("recovery.toast", { title: event.topicTitle || t("recovery.branch") }), "warn", event.topicId
? {
actionLabel: t("recovery.open"),
durationMs: 9000,
onAction: () => {
void enqueueNavigation({
kind: "topic",
scope,
workspaceRoot,
topicId: event.topicId || "",
sessionPath: event.recoveryPath || "",
});
},
}
: { durationMs: 9000 });
});
}, [enqueueNavigation, refreshTabMetas, showToast, t]);
useEffect(() => {
return onSessionRecoveryFailed((event) => {
const key = event.reason === "lease_held" ? "recovery.failedLease" : "recovery.failedUnavailable";
showToast(t(key), "error", { durationMs: 9000 });
});
}, [showToast, t]);
const handleNewTab = useCallback(async () => {
closeTransientOverlays();
setSidebarImDetailConnectionId("");
@@ -2848,6 +2884,13 @@ export default function App() {
: [topicbarWorkspacePath || topicbarWorkspaceLabel, topicbarImSourceLabel].filter(Boolean).join(" · ");
const topicbarCanRename = !sidebarImDetailConnection && Boolean(activeTab?.topicId);
const topicbarTitleEditSize = Math.min(56, Math.max(4, topicTitleDraft.length || topicbarTitle.length || 1));
const recoveryBannerTitle = activeTab?.recovered
? [
activeTab.recoveryReason ? t("recovery.reason", { reason: activeTab.recoveryReason }) : "",
activeTab.recoveryDigest ? t("recovery.digest", { digest: activeTab.recoveryDigest.slice(0, 12) }) : "",
activeTab.recoveryParentId ? t("recovery.parent", { parent: activeTab.recoveryParentId }) : "",
].filter(Boolean).join(" · ")
: "";
const sidebarWorkbench = desktopLayoutStyle === "workbench";
const windowsFramelessChrome = desktopPlatform === "windows";
const handleWindowsTitlebarDoubleClick = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
@@ -3399,6 +3442,13 @@ export default function App() {
<div className="banner banner--error">{t("topbar.startupError", { msg: state.meta.startupErr })}</div>
)}
{activeTab?.recovered && !sidebarImDetailConnection && (
<div className="banner banner--recovery" title={recoveryBannerTitle || undefined}>
<span className="banner__badge">{t("recovery.branch")}</span>
<span>{t("recovery.banner")}</span>
</div>
)}
<UpdateBanner enabled={startupUpdateChecksEnabled === true} />
<main className="main">
@@ -3538,6 +3588,7 @@ export default function App() {
retry={state.retry}
transientDismissSignal={transientOverlayDismissSignal}
sessionKey={composerSessionKey}
fileRefRefreshKey={composerFileRefRefreshKey}
guidanceConsumedKey={latestGuidanceConsumed?.key}
guidanceConsumedText={latestGuidanceConsumed?.text}
guidanceQueuePreviewItems={guidanceQueueMockItems}
@@ -3656,6 +3707,7 @@ export default function App() {
onPreviewModeChange={handleWorkspacePreviewModeChange}
onAddToChat={addWorkspaceTextToComposer}
onRequestPanelWidth={ensureWorkspacePanelWidth}
onFileTreeRefresh={refreshComposerFileRefs}
refreshKey={dockRefreshKey}
initialViewMode={rightDockMode === "changed" ? "changed" : "files"}
showViewTabs={false}
@@ -69,6 +69,12 @@ ok(
"AppChrome markup includes classic tab strip containers",
);
ok(
/const titlebarDragRail = darwinChrome \|\| platform === "windows";/.test(appChromeSource) &&
/\{titlebarDragRail && <span className="app-chrome__drag-rail"/.test(appChromeSource),
"AppChrome exposes the classic drag rail on macOS and Windows",
);
ok(
/const WORKSPACE_PANEL_DEFAULT_OPEN = false;/.test(layoutStoreSource) &&
/workspacePanelOpen:\s*WORKSPACE_PANEL_DEFAULT_OPEN/.test(layoutStoreSource),
@@ -276,6 +282,13 @@ for (const selector of [
);
}
ok(
finalDeclaration(".app--windows-frameless:not(.app--workbench):not(.app--creation) .app-chrome--native-tabs .app-chrome__drag-rail", "--wails-draggable") === "drag" &&
finalDeclaration(".app--windows-frameless:not(.app--workbench):not(.app--creation) .app-chrome--native-tabs .app-chrome__drag-rail", "right")?.includes("--windows-window-controls-safe") &&
finalDeclaration(".app--windows .app-chrome--native-tabs .tabbar", "--wails-draggable") === "no-drag",
"Windows classic chrome keeps a draggable rail while tabs remain clickable",
);
for (const selector of [
".layout--workbench-chrome-hidden",
":root[data-theme-style] .layout--workbench-chrome-hidden",
@@ -4,6 +4,7 @@ import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import gsap from "gsap";
import { ApprovalModal } from "../components/ApprovalModal";
import { LocaleProvider } from "../lib/i18n";
import type { AppBindings } from "../lib/bridge";
@@ -12,6 +13,17 @@ import type { WireApproval } from "../lib/types";
let passed = 0;
let failed = 0;
type GsapToOptions = { onComplete?: () => void };
const gsapForTests = (typeof gsap.to === "function" ? gsap : (gsap as unknown as { default?: typeof gsap }).default) as unknown as {
to?: (target: unknown, vars: GsapToOptions) => unknown;
};
if (typeof gsapForTests.to === "function") {
gsapForTests.to = (_target: unknown, vars: GsapToOptions) => {
vars.onComplete?.();
return {};
};
}
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
@@ -225,5 +237,48 @@ 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",
tool: "remember",
subject: "Save/update memory \"prefers-vitest\": Preferred test framework",
},
onAnswer: (allow, session, persist) => answers.push([allow, session, persist]),
});
const text = document.body.textContent ?? "";
ok(text.includes("Allow once"), "fresh-human approval shows allow once");
ok(text.includes("Deny"), "fresh-human approval shows deny");
ok(!text.includes("Allow for session"), "fresh-human approval hides session grant");
ok(!text.includes("Always allow"), "fresh-human approval hides persistent grant");
eq(
Array.from(document.querySelectorAll(".prompt-shelf__actions button")).map((button) => button.textContent).join("|"),
"1Allow once|4Deny",
"fresh-human approval keeps allow/deny shortcut keys",
);
const allowButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("Allow once")) as HTMLButtonElement | undefined;
if (!allowButton) throw new Error("allow once button did not render");
await act(async () => {
allowButton.click();
await flushTimers();
});
eq(JSON.stringify(answers), JSON.stringify([[true, false, false]]), "fresh-human approval allows only once");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -43,7 +43,7 @@ ok(
"App loads secondary drawers on demand",
);
ok(
!/import\s+\{[^}]*\b(?:MCPServersSettingsPage|SkillsSettingsPage)\b[^}]*\}\s+from\s+["']\.\/CapabilitiesPanel["']/.test(settingsSource) &&
!/import\s+\{[^}]*\b(?:MCPServersSettingsPage|SkillsSettingsPage|PluginsSettingsPage)\b[^}]*\}\s+from\s+["']\.\/CapabilitiesPanel["']/.test(settingsSource) &&
!/import\s+\{[^}]*\bMemorySettingsPage\b[^}]*\}\s+from\s+["']\.\/MemoryPanel["']/.test(settingsSource),
"SettingsPanel keeps secondary settings pages out of the first settings chunk",
);
@@ -2,11 +2,11 @@ import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { MCPServersSettingsPage } from "../components/CapabilitiesPanel";
import { MCPServersSettingsPage, PluginsSettingsPage } from "../components/CapabilitiesPanel";
import type { AppBindings } from "../lib/bridge";
import { LocaleProvider } from "../lib/i18n";
import { mcpServerLifecycleActions, mcpServerRetryableFromAvailableList } from "../lib/mcpServerLifecycle";
import type { Meta, ServerView, TabMeta } from "../lib/types";
import type { Meta, PluginInstallOptions, PluginView, ServerView, TabMeta } from "../lib/types";
function ok(value: unknown, message: string) {
if (!value) throw new Error(message);
@@ -80,6 +80,7 @@ function installDom() {
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
globalThis.HTMLInputElement = dom.window.HTMLInputElement;
globalThis.Event = dom.window.Event;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
@@ -106,6 +107,16 @@ function findButton(label: string): HTMLButtonElement | undefined {
return Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === label) as HTMLButtonElement | undefined;
}
function setInputValue(input: HTMLInputElement, value: string) {
const win = input.ownerDocument.defaultView;
const setter = Object.getOwnPropertyDescriptor((win?.HTMLInputElement ?? HTMLInputElement).prototype, "value")?.set;
setter?.call(input, value);
const eventCtor = win?.Event ?? Event;
const inputEventCtor = win?.InputEvent ?? eventCtor;
input.dispatchEvent(new inputEventCtor("input", { bubbles: true, data: value, inputType: "insertText" } as InputEventInit));
input.dispatchEvent(new eventCtor("change", { bubbles: true }));
}
console.log("capabilities panel MCP actions");
{
@@ -233,3 +244,221 @@ console.log("capabilities panel MCP actions");
});
dom.window.close();
}
console.log("capabilities panel plugin actions");
{
const dom = installDom();
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
const meta: Meta = { label: "test", ready: true, eventChannel: "plugin-channel", cwd: "/tmp/reasonix-test", workspaceRoot: "/tmp/reasonix-test" };
const tabs: TabMeta[] = [{
id: "tab-plugin",
scope: "project",
workspaceRoot: "/tmp/reasonix-test",
workspaceName: "reasonix-test",
topicId: "topic-plugin",
topicTitle: "Plugins",
label: "Plugins",
ready: true,
running: false,
mode: "normal",
toolApprovalMode: "auto",
active: true,
cwd: "/tmp/reasonix-test",
}];
let planCalls = 0;
let installCalls = 0;
let toggleCalls = 0;
let updateCalls = 0;
let doctorCalls = 0;
let removeCalls = 0;
let pickFolderCalls = 0;
const plannedSources: string[] = [];
const installedSources: string[] = [];
let plugins: PluginView[] = [{
name: "superpowers",
version: "0.1.0",
description: "Shared agent skills and hooks.",
source: "git:github.com/obra/superpowers",
root: "~/.reasonix/plugins/superpowers",
manifestKind: "reasonix",
enabled: true,
skills: 2,
hooks: 1,
mcpServers: 0,
}];
window.go = {
main: {
App: {
Meta: async () => meta,
ListTabs: async () => tabs,
Plugins: async () => plugins.map((plugin) => ({ ...plugin, warnings: [...(plugin.warnings ?? [])] })),
PlanPluginInstall: async (source: string, options: PluginInstallOptions) => {
planCalls += 1;
plannedSources.push(source);
ok(options.dryRun === true, "plugin preview asks for dry-run planning");
return JSON.stringify({
ok: true,
status: "planned",
name: "superpowers",
actions: [{ kind: "plugin", action: "install_plugin_package", name: "superpowers", source, status: "planned" }],
});
},
InstallPlugin: async (source: string, _options: PluginInstallOptions) => {
installCalls += 1;
installedSources.push(source);
const next: PluginView = {
name: "superpowers",
version: "0.1.1",
description: "Shared agent skills and hooks.",
source,
root: "~/.reasonix/plugins/superpowers",
manifestKind: "reasonix",
enabled: true,
skills: 3,
hooks: 1,
mcpServers: 1,
skillDetails: [{ name: "plan", description: "Plan work before implementation.", invocation: "/plan", runAs: "inline" }],
hookDetails: [{ event: "SessionStart", contextFile: "CLAUDE.md", description: "Load startup context." }],
mcpServerDetails: [{ name: "context", transport: "stdio", command: "node server.js" }],
};
plugins = plugins.filter((plugin) => plugin.name !== next.name).concat(next);
return JSON.stringify({ ok: true, status: "done", actions: [{ action: "install_plugin_package", name: next.name, status: "done" }] });
},
SetPluginEnabled: async (name: string, enabled: boolean) => {
toggleCalls += 1;
plugins = plugins.map((plugin) => plugin.name === name ? { ...plugin, enabled } : plugin);
},
UpdatePlugin: async (name: string) => {
updateCalls += 1;
plugins = plugins.map((plugin) => plugin.name === name ? { ...plugin, version: "0.1.2" } : plugin);
return JSON.stringify({ ok: true, status: "done", name });
},
PluginDoctor: async (name: string) => {
doctorCalls += 1;
return { ...(plugins.find((plugin) => plugin.name === name) ?? plugins[0]), warnings: ["manifest exports no MCP auth metadata"] };
},
RemovePlugin: async (name: string) => {
removeCalls += 1;
plugins = plugins.filter((plugin) => plugin.name !== name);
},
PickPluginFolder: async () => {
pickFolderCalls += 1;
return "/tmp/superpowers-plugin";
},
} as Partial<AppBindings> as AppBindings,
},
};
await act(async () => {
root.render(React.createElement(LocaleProvider, null, React.createElement(PluginsSettingsPage)));
await flush();
});
await waitFor("superpowers plugin row", () => Boolean(document.querySelector(".cap-row__name")?.textContent?.includes("superpowers")));
ok(Boolean(document.querySelector(".cap-plugin-form-grid .cap-plugin-fields--local")), "local plugin install mode uses the shared form grid");
const localOptionTexts = Array.from(document.querySelectorAll(".cap-plugin-installer__options > .cap-plugin-option-block"))
.map((option) => option.textContent ?? "");
ok(localOptionTexts[0]?.includes("Overwrite same-name plugin"), "local install mode shows overwrite before link mode");
ok(localOptionTexts[1]?.includes("Developer mode: link source folder"), "local install mode shows link mode after overwrite");
const chooseFolder = findButton("Choose plugin folder");
if (!chooseFolder) throw new Error("missing plugin folder picker button");
await act(async () => {
chooseFolder.click();
await flush();
});
await waitFor("picked plugin folder source", () => document.body.textContent?.includes("/tmp/superpowers-plugin") ?? false);
ok(pickFolderCalls === 1, "clicking Choose folder invokes the plugin folder picker once");
const gitMode = findButton("Git repository");
if (!gitMode) throw new Error("missing Git repository install mode");
await act(async () => {
gitMode.click();
await flush();
});
ok(Boolean(document.querySelector(".cap-plugin-form-grid .cap-plugin-fields--git")), "Git plugin install mode uses the shared form grid");
const sourceInput = document.querySelector<HTMLInputElement>('input[aria-label="Git repository URL"]');
if (!sourceInput) throw new Error("missing plugin git source input");
await act(async () => {
setInputValue(sourceInput, "git:github.com/obra/superpowers");
await flush();
});
await waitFor("plugin preview enabled", () => findButton("Preview")?.disabled === false);
const preview = findButton("Preview");
if (!preview) throw new Error("missing plugin preview button");
await act(async () => {
preview.click();
await flush();
});
await waitFor("plugin install plan", () => document.body.textContent?.includes("install_plugin_package") ?? false);
ok(planCalls === 1, "clicking Preview invokes plugin install planning once");
ok(plannedSources[0] === "git:github.com/obra/superpowers", "plugin preview receives the entered Git source");
const install = findButton("Install plugin");
if (!install) throw new Error("missing plugin install button");
await act(async () => {
install.click();
await flush();
});
await waitFor("plugin install result", () => installCalls === 1 && plugins[0]?.version === "0.1.1");
ok(installedSources[0] === "git:github.com/obra/superpowers", "plugin install receives the entered Git source");
const disclosure = document.querySelector<HTMLButtonElement>(".cap-plugin-entry .cap-disclosure");
if (!disclosure) throw new Error("missing plugin disclosure");
await act(async () => {
disclosure.click();
await flush();
});
await waitFor("plugin update action", () => Boolean(findButton("Update")));
ok(document.body.textContent?.includes("How to use") ?? false, "expanded plugin details explain how to use the plugin");
ok(document.body.textContent?.includes("/plan") ?? false, "expanded plugin details list exported skill invocations");
ok(document.body.textContent?.includes("SessionStart") ?? false, "expanded plugin details list exported hooks");
ok(document.body.textContent?.includes("context") ?? false, "expanded plugin details list exported MCP servers");
const update = findButton("Update");
if (!update) throw new Error("missing plugin update button");
await act(async () => {
update.click();
await flush();
});
await waitFor("plugin update call", () => updateCalls === 1 && plugins[0]?.version === "0.1.2");
const doctor = findButton("Doctor");
if (!doctor) throw new Error("missing plugin doctor button");
await act(async () => {
doctor.click();
await flush();
});
await waitFor("plugin diagnostic warning", () => document.body.textContent?.includes("manifest exports no MCP auth metadata") ?? false);
ok(doctorCalls === 1, "clicking Doctor invokes plugin diagnostics once");
const toggle = document.querySelector<HTMLInputElement>(".cap-plugin-entry .cap-switch input");
if (!toggle) throw new Error("missing plugin enable toggle");
await act(async () => {
toggle.click();
await flush();
});
await waitFor("plugin disabled", () => toggleCalls === 1 && plugins[0]?.enabled === false);
const remove = findButton("Remove plugin");
if (!remove) throw new Error("missing plugin remove button");
await act(async () => {
remove.click();
await flush();
});
const confirmRemove = findButton("Confirm remove");
if (!confirmRemove) throw new Error("missing plugin confirm remove button");
await act(async () => {
confirmRemove.click();
await flush();
});
await waitFor("plugin removed", () => removeCalls === 1 && plugins.length === 0);
await act(async () => {
root.unmount();
});
dom.window.close();
}
@@ -8,7 +8,7 @@ import { Composer, composerPickFileEntry } from "../components/Composer";
import { LocaleProvider } from "../lib/i18n";
import { ToastProvider } from "../lib/toast";
import type { AppBindings } from "../lib/bridge";
import type { CollaborationMode, ToolApprovalMode, TokenMode } from "../lib/types";
import type { CollaborationMode, DirEntry, ToolApprovalMode, TokenMode } from "../lib/types";
let passed = 0;
let failed = 0;
@@ -200,6 +200,16 @@ async function waitFor(label: string, predicate: () => boolean) {
throw new Error(`timed out waiting for ${label}`);
}
type RenderedComposer = Awaited<ReturnType<typeof renderComposer>>;
function fileEntry(name: string): DirEntry {
return { name, isDir: false };
}
async function replaceComposerDraft(rerender: RenderedComposer["rerender"], id: number, text: string) {
await rerender({ insertRequest: { id, text, mode: "replace" } });
}
console.log("\ncomposer goal toggle");
{
@@ -739,5 +749,156 @@ console.log("\ncomposer goal toggle");
dom.window.close();
}
{
const dom = installDom();
let listDirCalls = 0;
mockApp({
ListDir: async () => {
listDirCalls += 1;
return listDirCalls === 1 ? [fileEntry("cached-dir.txt")] : [fileEntry("fresh-dir.txt")];
},
SearchFileRefs: async () => [],
});
const { root, rerender } = await renderComposer();
await replaceComposerDraft(rerender, 101, "@");
await waitFor("initial @ directory load", () => listDirCalls === 1);
await replaceComposerDraft(rerender, 102, "");
await replaceComposerDraft(rerender, 103, "@");
await waitFor("@ directory revalidation call", () => listDirCalls === 2);
eq(listDirCalls, 2, "@ directory cache hit still revalidates ListDir");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
let listDirCalls = 0;
mockApp({
ListDir: async () => {
listDirCalls += 1;
return listDirCalls === 1 ? [fileEntry("manual-refresh-stale.txt")] : [fileEntry("manual-refresh-fresh.txt")];
},
SearchFileRefs: async () => [],
});
const { root, rerender } = await renderComposer({ fileRefRefreshKey: "0" });
await replaceComposerDraft(rerender, 201, "@");
await waitFor("initial @ directory load before refresh key", () => listDirCalls === 1);
await rerender({ fileRefRefreshKey: "1" });
await waitFor("@ directory reload after refresh key", () => listDirCalls === 2);
eq(listDirCalls, 2, "fileRefRefreshKey refreshes @ directory cache while the menu is open");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const realDateNow = Date.now;
let now = 1000;
let searchCalls = 0;
Date.now = () => now;
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => {
searchCalls += 1;
return searchCalls === 1 ? [fileEntry("alpha-old.ts")] : [fileEntry("alpha-new.ts")];
},
});
const { root, rerender } = await renderComposer();
try {
await replaceComposerDraft(rerender, 301, "@alpha");
await waitFor("initial @ search request", () => searchCalls === 1);
eq(searchCalls, 1, "@ search fetches the first query");
await replaceComposerDraft(rerender, 302, "");
now = 2000;
await replaceComposerDraft(rerender, 303, "@alpha");
await act(async () => {
await flushTimers();
});
eq(searchCalls, 1, "@ search cache is reused inside the TTL");
await replaceComposerDraft(rerender, 304, "");
now = 7001;
await replaceComposerDraft(rerender, 305, "@alpha");
await waitFor("expired @ search cache refresh", () => searchCalls === 2);
eq(searchCalls, 2, "@ search cache revalidates after the TTL");
} finally {
Date.now = realDateNow;
}
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
let staleListDirResolve: ((entries: DirEntry[]) => void) | undefined;
let thirdListDirResolve: ((entries: DirEntry[]) => void) | undefined;
let listDirCalls = 0;
mockApp({
ListDir: async () => {
listDirCalls += 1;
if (listDirCalls === 1) {
return new Promise<DirEntry[]>((resolve) => {
staleListDirResolve = resolve;
});
}
if (listDirCalls === 2) return [fileEntry("cache-live.txt")];
return new Promise<DirEntry[]>((resolve) => {
thirdListDirResolve = resolve;
});
},
SearchFileRefs: async () => [],
});
const { root, rerender } = await renderComposer({ fileRefRefreshKey: "0" });
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("composer textarea did not render");
await replaceComposerDraft(rerender, 401, "@cache");
await waitFor("initial stale @ directory request", () => listDirCalls === 1);
await rerender({ fileRefRefreshKey: "1" });
await waitFor("fresh @ directory request after refresh key", () => listDirCalls === 2);
await act(async () => {
await flushTimers();
});
staleListDirResolve?.([fileEntry("cache-stale.txt")]);
await act(async () => {
await flushTimers();
});
await replaceComposerDraft(rerender, 402, "");
await replaceComposerDraft(rerender, 403, "@cache");
await waitFor("second fresh @ directory request", () => listDirCalls === 3);
await act(async () => {
textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
await flushTimers();
});
eq(textarea.value, "@cache-live.txt ", "stale @ directory request cannot repopulate cache after refresh");
thirdListDirResolve?.([fileEntry("cache-later.txt")]);
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -5,6 +5,7 @@ import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { Composer } from "../components/Composer";
import { UserMessage } from "../components/Message";
import { LocaleProvider } from "../lib/i18n";
import { ToastProvider } from "../lib/toast";
import type { CollaborationMode, TokenMode, ToolApprovalMode } from "../lib/types";
@@ -137,7 +138,9 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
root.render(
<LocaleProvider>
<ToastProvider>
<Composer {...currentProps} />
<div className="chat-pane">
<Composer {...currentProps} />
</div>
</ToastProvider>
</LocaleProvider>,
);
@@ -183,6 +186,29 @@ function imagePasteEvent(file: File): Event {
return event;
}
function imageViewerOpen(): boolean {
return Boolean(document.querySelector(".image-viewer-backdrop .image-viewer__image"));
}
function renderUserMessage(text: string, props: Partial<Parameters<typeof UserMessage>[0]> = {}) {
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
const paint = async () => {
await act(async () => {
root.render(
<LocaleProvider>
<div className="chat-pane">
<UserMessage text={text} {...props} />
</div>
</LocaleProvider>,
);
await flushTimers();
});
};
return { root, paint };
}
console.log("\ncomposer image capability");
{
@@ -255,5 +281,107 @@ console.log("\ncomposer image capability");
dom.window.close();
}
{
const dom = installDom();
installBridgeApp({
SavePastedImage: async () => ".reasonix/attachments/mock.png",
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
});
const { root } = await renderComposer({ imageInputEnabled: true });
const file = new File(["img"], "photo.png", { type: "image/png", lastModified: 1 });
await act(async () => {
textarea().dispatchEvent(imagePasteEvent(file));
await flushTimers();
await flushTimers();
});
await waitFor(() => Boolean(document.querySelector(".composer-context__thumb img")));
const thumb = document.querySelector(".composer-context__thumb") as HTMLElement | null;
if (!thumb) throw new Error("missing composer image thumbnail");
await act(async () => {
thumb.click();
await flushTimers();
});
await waitFor(imageViewerOpen);
ok(imageViewerOpen(), "composer image thumbnail opens the image viewer");
await act(async () => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
await flushTimers();
});
await waitFor(() => !document.querySelector(".image-viewer-backdrop"));
ok(!document.querySelector(".image-viewer-backdrop"), "composer image viewer closes on Escape");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
installBridgeApp({
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
});
const { root, paint } = renderUserMessage("check @[photo.png](.reasonix/attachments/mock.png)");
await paint();
await waitFor(() => Boolean(document.querySelector(".msg-attachment--image img")));
const thumb = document.querySelector(".msg-attachment--image") as HTMLElement | null;
if (!thumb) throw new Error("missing message image thumbnail");
await act(async () => {
thumb.click();
await flushTimers();
});
await waitFor(() => Boolean(document.querySelector(".chat-pane > .image-viewer-backdrop")));
ok(Boolean(document.querySelector(".chat-pane > .image-viewer-backdrop")), "sent message image preview portals into the chat pane");
const close = document.querySelector(".image-viewer__close") as HTMLButtonElement | null;
if (!close) throw new Error("missing image viewer close button");
await act(async () => {
close.click();
await flushTimers();
});
await waitFor(() => !document.querySelector(".image-viewer-backdrop"));
ok(!document.querySelector(".image-viewer-backdrop"), "sent message image viewer closes from the close button");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
installBridgeApp({
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
});
const { root, paint } = renderUserMessage("check @[photo.png](.reasonix/attachments/mock.png)", {
turn: 1,
onEdit: () => true,
});
await paint();
await waitFor(() => Boolean(document.querySelector(".msg-attachment--image img")));
const edit = document.querySelector("button.msg-meta__btn:not(.msg-meta__copy)") as HTMLButtonElement | null;
if (!edit) throw new Error("missing message edit button");
await act(async () => {
edit.click();
await flushTimers();
});
await waitFor(() => Boolean(document.querySelector(".msg-edit .composer-context__thumb img")));
const thumb = document.querySelector(".msg-edit .composer-context__thumb") as HTMLElement | null;
if (!thumb) throw new Error("missing edit image thumbnail");
await act(async () => {
thumb.click();
await flushTimers();
});
await waitFor(imageViewerOpen);
ok(imageViewerOpen(), "edit message image thumbnail opens the image viewer");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -1,6 +1,6 @@
// Run: tsx src/__tests__/context-panel-breakdown.test.ts
import { cacheHitTone, contextBreakdown, contextCostDisplay, contextSourceRows, contextWindowStatus, formatCacheHitRate, formatMetricTokens } from "../components/ContextPanel";
import { cacheHitTone, contextBreakdown, contextCostDisplay, contextSourceRows, contextUsageRefreshKey, contextWindowStatus, formatCacheHitRate, formatMetricTokens } from "../components/ContextPanel";
import { currencySymbol, formatMoney, formatMoneyLocalized } from "../lib/money";
let passed = 0;
@@ -130,6 +130,28 @@ eq(cacheHitTone(6000, 4000), "notice", "mid cache hit rate uses notice tone");
eq(cacheHitTone(5999, 4001), "warn", "low cache hit rate uses warning tone");
eq(cacheHitTone(0, 0), undefined, "missing cache data stays uncolored");
console.log("\ncontext panel usage refresh key");
eq(contextUsageRefreshKey(undefined), "", "missing usage does not request a streaming refresh");
ok(
contextUsageRefreshKey({
totalTokens: 10,
promptTokens: 10,
completionTokens: 0,
reasoningTokens: 0,
sessionCacheHitTokens: 0,
sessionCacheMissTokens: 0,
}) !== contextUsageRefreshKey({
totalTokens: 11,
promptTokens: 10,
completionTokens: 1,
reasoningTokens: 0,
sessionCacheHitTokens: 0,
sessionCacheMissTokens: 0,
}),
"general token changes refresh even when cache counters stay unchanged",
);
console.log("\ncontext panel source rows");
const sourceRows = contextSourceRows({
@@ -0,0 +1,39 @@
// Run: tsx src/__tests__/message-pasted-blocks.test.ts
import { parsePastedBlocks } from "../components/Message";
let passed = 0;
let failed = 0;
function eq(a: unknown, b: unknown, label: string) {
if (JSON.stringify(a) === JSON.stringify(b)) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
failed += 1;
}
}
function wrapped(label: string, content: string): string {
return `${label}\n\n--- Begin ${label} ---\n${content}\n--- End ${label} ---`;
}
console.log("\nmessage pasted blocks");
for (const [label, name] of [
["[已粘贴文本 #2 · 31 行]", "Simplified Chinese"],
["[已貼上文字 #2 · 31 行]", "Traditional Chinese"],
["[Pasted text #2 · 31 lines]", "English"],
] as const) {
eq(
parsePastedBlocks(`before\n${label}\nafter`, wrapped(label, "line 1\nline 2")),
[{ label, content: "line 1\nline 2\n" }],
`${name} pasted text labels are parsed from submit text`,
);
}
eq(parsePastedBlocks("[unknown paste #1]", "--- Begin [unknown paste #1] ---\nnope\n--- End [unknown paste #1] ---"), [], "unknown labels are ignored");
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -74,7 +74,7 @@ function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta {
};
}
function meta(): Meta {
function meta(overrides: Partial<Meta> = {}): Meta {
return {
label: "model",
ready: true,
@@ -91,6 +91,7 @@ function meta(): Meta {
tokenMode: "full",
goal: "",
goalStatus: "stopped",
...overrides,
};
}
@@ -211,10 +212,14 @@ await act(async () => {
});
const guardedStartupTabs = deferred<TabMeta[]>();
let ensureBlankSurfaceCalls = 0;
const staleProjectA = "/repo/project-a";
const targetProjectB = "/repo/project-b";
const ensureBlankSurfaceCalls: Array<{ scope: string; workspaceRoot: string }> = [];
window.go.main.App = {
ListTabs: async () => guardedStartupTabs.promise,
MetaForTab: async () => meta(),
MetaForTab: async (tabID: string) => tabID === "tab-new"
? meta({ cwd: targetProjectB, workspaceRoot: targetProjectB, workspaceName: "project-b", workspacePath: targetProjectB })
: meta({ cwd: staleProjectA, workspaceRoot: staleProjectA, workspaceName: "project-a", workspacePath: staleProjectA }),
ContextUsageForTab: async () => context,
EffortForTab: async () => effort,
BalanceForTab: async () => balance,
@@ -224,9 +229,17 @@ window.go.main.App = {
HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
HistoryCheckpointTurnsForTab: async () => [],
ReplayPendingPrompts: async () => {},
EnsureBlankSurface: async () => {
ensureBlankSurfaceCalls += 1;
return tabMeta({ id: "tab-new", topicId: "topic-new", topicTitle: "New session" });
EnsureBlankSurface: async (scope: string, workspaceRoot: string) => {
ensureBlankSurfaceCalls.push({ scope, workspaceRoot });
return tabMeta({
id: "tab-new",
topicId: "topic-new",
topicTitle: "New session",
workspaceRoot: targetProjectB,
workspaceName: "project-b",
workspacePath: targetProjectB,
cwd: targetProjectB,
});
},
} as Partial<AppBindings> as AppBindings;
@@ -239,20 +252,31 @@ await act(async () => {
});
await act(async () => {
await controller?.ensureBlankSurface("project", "/repo");
await controller?.ensureBlankSurface("project", targetProjectB);
await flushPromises();
});
eq(ensureBlankSurfaceCalls, 1, "EnsureBlankSurface is called once");
eq(ensureBlankSurfaceCalls.length, 1, "EnsureBlankSurface is called once");
eq(ensureBlankSurfaceCalls[0]?.workspaceRoot, targetProjectB, "EnsureBlankSurface keeps the requested project root");
eq(controller?.activeTabId, "tab-new", "blank surface becomes active before startup sync resolves");
eq(controller?.state.meta?.workspaceRoot, targetProjectB, "blank surface exposes the new project root");
await act(async () => {
guardedStartupTabs.resolve([tabMeta({ id: "tab-old", topicId: "topic-old", topicTitle: "Old session" })]);
guardedStartupTabs.resolve([tabMeta({
id: "tab-old",
topicId: "topic-old",
topicTitle: "Old session",
workspaceRoot: staleProjectA,
workspaceName: "project-a",
workspacePath: staleProjectA,
cwd: staleProjectA,
})]);
await guardedStartupTabs.promise;
await flushPromises();
});
eq(controller?.activeTabId, "tab-new", "guarded startup sync cannot restore an older active tab");
eq(controller?.state.meta?.workspaceRoot, targetProjectB, "guarded startup sync cannot restore the old project root");
await act(async () => {
guardRoot.unmount();
@@ -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",
@@ -47,14 +47,14 @@ eq(
);
eq(
mergedFetchedProviderModels(["mimo-v2.5-pro"], ["mimo-v2-flash", "mimo-v2-omni", "mimo-v2.5-pro"], { preserveCurated: true }),
mergedFetchedProviderModels(["mimo-v2.5-pro"], ["mimo-v2.5", "mimo-v2.5-pro"], { preserveCurated: true }),
["mimo-v2.5-pro"],
"manual access refresh preserves selected MiMo model instead of importing provider catalog",
);
eq(
providerModelCandidates(["mimo-v2.5-pro"], ["mimo-v2-flash", "mimo-v2-omni", "mimo-v2.5-pro"]),
["mimo-v2.5-pro", "mimo-v2-flash", "mimo-v2-omni"],
providerModelCandidates(["mimo-v2.5-pro"], ["mimo-v2.5", "mimo-v2.5-pro"]),
["mimo-v2.5-pro", "mimo-v2.5"],
"manual access refresh can show provider catalog as unsaved candidates",
);
@@ -6,6 +6,8 @@ import { act } from "react";
import { createRoot } from "react-dom/client";
import {
SettingsPanel,
formatProviderExtraBody,
parseProviderExtraBody,
providerBaseURLFromChatURL,
providerChatURLPreview,
providerEditorEffectiveKind,
@@ -58,18 +60,52 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
autoPlan: "off",
providers: [],
officialProviders: [],
providerPresets: [],
permissions: { mode: "ask", allow: [], ask: [], deny: [] },
sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], shell: "auto" },
sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], effectiveWorkspaceRoot: "/work", effectiveWriteRoots: ["/work"], shell: "auto" },
network: { proxyMode: "auto", proxyUrl: "", noProxy: "", proxy: { type: "socks5", server: "", port: 0, username: "", password: "" } },
agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" },
agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" },
bot: {
enabled: false,
model: "",
toolApprovalMode: "",
maxSteps: 0,
debounceMs: 0,
allowlist: { enabled: false, allowAll: false, qqUsers: [], feishuUsers: [], weixinUsers: [], qqGroups: [], feishuGroups: [], weixinGroups: [] },
qq: { enabled: false, appId: "", appSecretEnv: "", secretSet: false, sandbox: false },
toolApprovalMode: "",
maxSteps: 0,
debounceMs: 0,
queueMode: "steer",
queueCap: 20,
queueDrop: "summarize",
ignoreSelfMessages: true,
selfUserIds: { qq: [], feishu: [], weixin: [] },
control: { enabled: false, addr: "127.0.0.1:37913", tokenEnv: "REASONIX_BOT_CONTROL_TOKEN" },
pairing: { enabled: true, requestTtlMinutes: 60, maxPendingPerPlatform: 3 },
routes: [],
allowlist: {
enabled: false,
allowAll: false,
qqUsers: [],
feishuUsers: [],
weixinUsers: [],
qqApprovers: [],
feishuApprovers: [],
weixinApprovers: [],
qqAdmins: [],
feishuAdmins: [],
weixinAdmins: [],
qqGroups: [],
feishuGroups: [],
weixinGroups: [],
},
qq: {
enabled: false,
appId: "",
appSecretEnv: "",
secretSet: false,
sandbox: false,
model: "",
toolApprovalMode: "ask",
workspaceRoot: "",
access: { enabled: true, allowAll: false, pairingEnabled: true, users: [], groups: [], approvers: [], admins: [] },
},
feishu: { enabled: false, domain: "feishu", appId: "", appSecretEnv: "", secretSet: false, verificationToken: "", mode: "webhook", webhookPort: 0, requireMention: false },
weixin: { enabled: false, accountId: "", tokenEnv: "", tokenSet: false, apiBase: "" },
connections: [],
@@ -96,11 +132,20 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
console.log("\nsettings refresh snapshot");
eq(providerEditorEffectiveKind(true, "anthropic", ["anthropic", "openai"]), "openai", "new custom providers ignore sorted providerKinds and default to OpenAI");
eq(providerEditorEffectiveKind(true, "anthropic", ["anthropic", "openai"]), "anthropic", "new custom providers keep the selected Anthropic-compatible kind");
eq(providerEditorEffectiveKind(false, "anthropic", ["anthropic", "openai"]), "anthropic", "existing providers preserve their stored kind");
eq(providerChatURLPreview("https://proxy.example.com/v1", "", false), "https://proxy.example.com/v1/chat/completions", "base URL mode previews chat completions URL");
eq(providerChatURLPreview("", "https://proxy.example.com/custom/chat", true), "https://proxy.example.com/custom/chat", "full URL mode previews configured URL");
eq(providerBaseURLFromChatURL("https://proxy.example.com/v1/chat/completions"), "https://proxy.example.com/v1", "chat URL derives base URL for model discovery");
eq(formatProviderExtraBody({ top_p: 0.7, enable_thinking: true }), "{\n \"enable_thinking\": true,\n \"top_p\": 0.7\n}", "extra body editor formats stable JSON");
eq(JSON.stringify(parseProviderExtraBody('{ "enable_thinking": true, "top_p": 0.7 }')), "{\"enable_thinking\":true,\"top_p\":0.7}", "extra body editor parses JSON object");
let extraBodyRejected = false;
try {
parseProviderExtraBody("[true]");
} catch {
extraBodyRejected = true;
}
ok(extraBodyRejected, "extra body editor rejects non-object JSON");
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
@@ -120,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;
@@ -146,6 +192,7 @@ await act(async () => {
<LocaleProvider>
<SettingsPanel
initialTab="general"
desktopPlatform="linux"
onClose={() => {}}
onChanged={(settings?: SettingsView) => {
onChangedSettings = settings;
@@ -193,6 +240,7 @@ await act(async () => {
<LocaleProvider>
<SettingsPanel
initialTab="general"
desktopPlatform="linux"
onClose={() => {}}
onChanged={() => {}}
/>
@@ -220,6 +268,137 @@ ok(document.body.textContent?.includes("Settings could not be loaded.") === fals
await act(async () => {
retryRoot.unmount();
});
const zoomRootEl = document.createElement("div");
document.body.appendChild(zoomRootEl);
const zoomRoot = createRoot(zoomRootEl);
let persistedZoom = 0.5;
const savedZoomFactors: number[] = [];
window.go = {
main: {
App: {
Settings: async () => baseSettings("standard"),
GetDesktopZoomFactor: async () => persistedZoom,
SetDesktopZoomFactor: async (factor: number) => {
persistedZoom = factor;
savedZoomFactors.push(factor);
},
} as Partial<AppBindings> as AppBindings,
},
};
localStorage.setItem("reasonix-zoom-restart", "1");
await act(async () => {
zoomRoot.render(
<LocaleProvider>
<SettingsPanel
initialTab="appearance"
desktopPlatform="windows"
onClose={() => {}}
onChanged={() => {}}
/>
</LocaleProvider>,
);
await flushPromises();
});
await waitFor("persisted display zoom sync", () => document.querySelector(".zoom-slider__value")?.textContent?.trim() === "50%");
const resetZoomButton = document.querySelector("button[aria-label='Reset display zoom to 100%']") as HTMLButtonElement | null;
if (!resetZoomButton) throw new Error("display zoom reset button did not render");
await act(async () => {
resetZoomButton.click();
await flushPromises();
});
await waitFor("display zoom reset", () => document.querySelector(".zoom-slider__value")?.textContent?.trim() === "100%");
eq(savedZoomFactors.at(-1), 1, "display zoom reset writes the default zoom factor");
eq(localStorage.getItem("reasonix-zoom-restart"), "1", "display zoom reset updates the local restart zoom cache");
await act(async () => {
zoomRoot.unmount();
});
// Bots tab: direct four-channel bot manager.
const botsRootEl = document.createElement("div");
document.body.appendChild(botsRootEl);
const botsRoot = createRoot(botsRootEl);
const botsSettings = baseSettings("standard");
botsSettings.bot.connections = [
{
id: "conn-feishu-1",
provider: "feishu",
domain: "feishu",
label: "kun",
enabled: true,
status: "connected",
model: "",
toolApprovalMode: "",
workspaceRoot: "",
credential: { appId: "cli_mock", appSecretEnv: "FEISHU_BOT_APP_SECRET", accountId: "", tokenEnv: "", secretSet: true },
sessionMappings: [],
lastError: "",
createdAt: "",
updatedAt: "",
access: { enabled: true, allowAll: false, pairingEnabled: true, users: ["ou_mock_user_001"], groups: [], approvers: [], admins: [] },
},
];
window.go = {
main: {
App: {
Settings: async () => botsSettings,
} as Partial<AppBindings> as AppBindings,
},
};
await act(async () => {
botsRoot.render(
<LocaleProvider>
<SettingsPanel initialTab="bots" desktopPlatform="linux" onClose={() => {}} onChanged={() => {}} />
</LocaleProvider>,
);
await flushPromises();
});
await waitFor("bot channel manager", () => Boolean(document.querySelector(".bot-channel-manager")));
ok(!document.querySelector(".bot-overview-grid"), "bots tab does not render the removed entry overview");
ok(!document.getElementById("bot-mobile-remote"), "bots tab no longer renders the mobile remote entry card");
ok(!document.querySelector(".bot-channel-entry"), "bots tab no longer renders the Bot Channel entry panel");
ok(!document.getElementById("bot-step-access"), "bots tab omits the old global access step card");
ok(!document.getElementById("bot-step-behavior"), "bots tab omits global default behavior card");
eq(document.querySelectorAll(".bot-step-chip").length, 0, "hero no longer shows the old two-step chips");
eq(document.querySelectorAll(".bot-channel-tabs [role=\"tab\"]").length, 4, "bot manager uses four fixed channel tabs on the left");
ok(document.querySelector(".bot-channel-setup-card")?.textContent?.includes("Configure QQ") === true, "unconfigured QQ tab shows key setup on the right");
ok(document.body.textContent?.includes("Back to entry") === false, "bot manager does not show a return-to-entry action");
const feishuTab = Array.from(document.querySelectorAll(".bot-channel-tabs [role=\"tab\"]")).find((button) => button.textContent?.includes("Feishu")) as HTMLButtonElement | undefined;
if (!feishuTab) throw new Error("Feishu channel tab did not render");
await act(async () => {
feishuTab.click();
await flushPromises();
});
await waitFor("selected Feishu detail", () => Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-card")));
ok(Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-card")), "configured channel renders selected bot detail on the right");
ok(Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-section--access")), "selected bot detail owns its access control");
ok(document.body.textContent?.includes("Access control") === true, "selected bot detail labels per-bot access control");
const selectedBotDetailText = document.querySelector(".bot-channel-manager__detail .bot-detail-card")?.textContent ?? "";
const connectionSummaryIndex = selectedBotDetailText.indexOf("Connection summary");
const enableBotIndex = selectedBotDetailText.indexOf("Enable bot");
const toolApprovalIndex = selectedBotDetailText.indexOf("Tool approval");
const modelIndex = selectedBotDetailText.indexOf("Model");
const accessControlIndex = selectedBotDetailText.indexOf("Access control");
ok(
connectionSummaryIndex >= 0 && enableBotIndex > connectionSummaryIndex && toolApprovalIndex > enableBotIndex && modelIndex > toolApprovalIndex && accessControlIndex > modelIndex,
"selected bot detail places enable, approval, and model controls between summary and access control",
);
ok(document.body.textContent?.includes("ou_mock_user_001") === true, "selected bot detail shows its trusted user");
ok(document.body.textContent?.includes("Legacy global allowlist") === true, "advanced area keeps the legacy global allowlist");
ok(document.querySelector(".bot-simple-advanced")?.textContent?.includes("local control API") === false, "advanced area no longer owns mobile/control API setup");
await act(async () => {
botsRoot.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
@@ -130,25 +130,40 @@ const tabA = tabMeta("tab-a", { active: true });
const tabB = tabMeta("tab-b");
const tabC = tabMeta("tab-c");
const tabD = tabMeta("tab-d");
const tabE = tabMeta("tab-e");
const tabF = tabMeta("tab-f");
const tabG = tabMeta("tab-g");
const tabH = tabMeta("tab-h");
let backendActiveId = "tab-a";
const historyB = deferred<HistoryMessage[]>();
const historyD = deferred<HistoryMessage[]>();
let metaH = deferred<Meta>();
let historyH = deferred<HistoryMessage[]>();
const contextDGate = deferred<ContextInfo>();
const setActiveBGate = deferred<void>();
const setActiveEGate = deferred<void>();
const setActiveFGate = deferred<void>();
const submitTabCGate = deferred<void>();
const historyCalls: string[] = [];
const cancelCalls: string[] = [];
let contextDCalls = 0;
let metaHCalls = 0;
let holdNextContextForD = false;
let holdNextMetaForH = false;
let holdNextHistoryForH = false;
let setActiveCalls = 0;
let newSessionCalls = 0;
let failSetActiveFor = "";
const runningTabs = new Set<string>();
const tabsById = new Map([tabA, tabB, tabC, tabD].map((tab) => [tab.id, tab]));
const tabsById = new Map([tabA, tabB, tabC, tabD, tabE, tabF, tabG, tabH].map((tab) => [tab.id, tab]));
const eventHandlers: Array<(e: WireEvent) => void> = [];
const readyHandlers: Array<(tabId?: string) => void> = [];
function currentTabs(): TabMeta[] {
return [tabA, tabB, tabC, tabD].map((tab) => ({ ...tab, active: tab.id === backendActiveId, running: runningTabs.has(tab.id) }));
return Array.from(tabsById.values()).map((tab) => {
const running = runningTabs.has(tab.id);
return { ...tab, active: tab.id === backendActiveId, running, cancellable: running };
});
}
window.runtime = {
@@ -163,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;
@@ -181,6 +203,13 @@ window.go = {
historyCalls.push(tabID);
if (tabID === "tab-b") return historyB.promise;
if (tabID === "tab-d") return historyD.promise;
if (tabID === "tab-e") return [userMessage("fork E")];
if (tabID === "tab-g") return [userMessage("history G")];
if (tabID === "tab-h" && holdNextHistoryForH) {
holdNextHistoryForH = false;
return historyH.promise;
}
if (tabID === "tab-h") return [userMessage("history H")];
return [userMessage("cached A")];
},
HistoryPageForTab: async (tabID: string) => {
@@ -188,20 +217,38 @@ window.go = {
return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false };
},
HistoryCheckpointTurnsForTab: async () => [],
OpenProjectTab: async () => {
backendActiveId = "tab-d";
return { ...(tabsById.get("tab-d") ?? tabD), active: true };
OpenProjectTab: async (workspaceRoot: string, topicId: string) => {
const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === workspaceRoot && tab.topicId === topicId) ?? tabD;
backendActiveId = target.id;
return { ...target, active: true };
},
ActivateTopic: async (_scope: string, workspaceRoot: string, topicId: string) => {
const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === workspaceRoot && tab.topicId === topicId) ?? tabG;
backendActiveId = target.id;
return { ...target, active: true };
},
NewSession: async () => {
newSessionCalls += 1;
},
Fork: async () => {
tabsById.set("tab-e", tabE);
backendActiveId = "tab-e";
runningTabs.add("tab-e");
return { ...tabE, active: true, running: true };
},
ReplayPendingPrompts: async () => {},
SetActiveTab: async (tabID: string) => {
setActiveCalls += 1;
if (tabID === "tab-b") await setActiveBGate.promise;
if (tabID === "tab-e") await setActiveEGate.promise;
if (tabID === "tab-f") await setActiveFGate.promise;
if (tabID === failSetActiveFor) throw new Error("persist failed");
backendActiveId = tabID;
},
CancelTab: async (tabID: string) => {
cancelCalls.push(tabID);
runningTabs.delete(tabID);
},
SubmitToTab: async (tabID: string) => {
runningTabs.add(tabID);
if (tabID === "tab-c") await submitTabCGate.promise;
@@ -280,6 +327,10 @@ ok(controller?.state.items.some((item) => item.kind === "user" && item.text ===
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "late B") ?? false), "late history stays scoped to its tab state");
const historyCallsBeforeFallbackSync = historyCalls.length;
await act(async () => {
for (const handler of eventHandlers) handler({ kind: "approval_request", tabId: "tab-b", approval: { id: "stale-fallback-approval", tool: "bash", subject: "stale fallback approval" } });
await flushPromises();
});
backendActiveId = "tab-b";
await act(async () => {
await controller?.syncActiveTab(false);
@@ -288,12 +339,69 @@ await act(async () => {
eq(controller?.activeTabId, "tab-b", "backend fallback sync activates the backend-selected cached tab");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "late B") ?? false, "backend fallback sync keeps the cached transcript");
eq(historyCalls.length, historyCallsBeforeFallbackSync, "backend fallback sync preserves cached history instead of reloading it");
eq(controller?.state.approval?.id, undefined, "backend fallback sync reconciles stale approval state");
eq(controller?.state.running, false, "backend fallback sync reconciles stale running state");
await act(async () => {
await controller?.switchTab("tab-a", tabA);
await flushPromises();
});
await waitFor("tab-a restored after fallback sync", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
runningTabs.add("tab-e");
let switchToE: Promise<TabMeta[] | undefined> | undefined;
await act(async () => {
switchToE = controller?.switchTab("tab-e", { ...tabE, running: true, cancellable: true });
await flushPromises();
});
eq(controller?.activeTabId, "tab-e", "switching to a backend-running tab updates the active tab immediately");
eq(controller?.state.running, true, "backend-running tab restores the stop state before backend activation settles");
eq(controller?.state.cancellable, true, "backend-running tab remains cancellable before backend activation settles");
await act(async () => {
controller?.cancel();
await flushPromises();
});
eq(cancelCalls.join(","), "tab-e", "cancel targets the backend-running tab while activation is pending");
await act(async () => {
setActiveEGate.resolve();
await switchToE;
await flushPromises();
});
eq(controller?.state.running, false, "cancelled backend-running tab reconciles to idle after activation");
await act(async () => {
await controller?.switchTab("tab-a", tabA);
await flushPromises();
});
await waitFor("tab-a restored after backend-running switch", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
let switchToF: Promise<TabMeta[] | undefined> | undefined;
await act(async () => {
switchToF = controller?.switchTab("tab-f", tabF);
await flushPromises();
});
eq(controller?.activeTabId, "tab-f", "first rapid switch activates the slow target optimistically");
let switchToG: Promise<TabMeta[] | undefined> | undefined;
await act(async () => {
switchToG = controller?.switchTab("tab-g", tabG);
await switchToG;
await flushPromises();
});
eq(controller?.activeTabId, "tab-g", "second rapid switch wins immediately");
eq(backendActiveId, "tab-g", "second rapid switch activates the backend");
await act(async () => {
setActiveFGate.resolve();
await switchToF;
await flushPromises();
});
eq(controller?.activeTabId, "tab-g", "late completion from the first rapid switch does not replace the visible tab");
eq(backendActiveId, "tab-g", "late completion from the first rapid switch reasserts the last-clicked backend tab");
ok(!historyCalls.includes("tab-f"), "late completion from the first rapid switch does not hydrate the stale target");
await act(async () => {
await controller?.switchTab("tab-a", tabA);
await flushPromises();
});
await waitFor("tab-a restored after rapid switch", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
failSetActiveFor = "tab-b";
const historyCallsBeforeFailedSwitch = historyCalls.length;
await act(async () => {
@@ -382,6 +490,11 @@ ok(controller?.state.items.some((item) => item.kind === "user" && item.text ===
eq(controller?.state.hydratePlaceholderItems?.length ?? 0, 0, "topic history clears the hydration placeholder");
const historyCallsBeforeReopenD = historyCalls.length;
await act(async () => {
for (const handler of eventHandlers) handler({ kind: "approval_request", tabId: "tab-d", approval: { id: "stale-approval", tool: "bash", subject: "stale approval" } });
await controller?.switchTab("tab-a", tabA);
await flushPromises();
});
await act(async () => {
await controller?.openProjectTab(tabD.workspaceRoot, tabD.topicId || "");
await flushPromises();
@@ -389,6 +502,17 @@ await act(async () => {
eq(controller?.activeTabId, "tab-d", "reopening an already hydrated topic keeps it active");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history D") ?? false, "reopened cached topic keeps its transcript");
eq(historyCalls.length, historyCallsBeforeReopenD, "reopening an already hydrated topic skips history hydration");
eq(controller?.state.approval?.id, undefined, "reopening a topic reconciles stale approval state");
eq(controller?.state.running, false, "reopening a topic reconciles stale running state");
await act(async () => {
await controller?.rewind(0, "fork");
await flushPromises();
});
eq(controller?.activeTabId, "tab-e", "fork activates the forked tab");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "fork E") ?? false, "fork loads the forked transcript");
eq(controller?.state.running, true, "fork reconciles backend running state after reset hydration");
runningTabs.delete("tab-e");
const contextCallsBeforeInactiveD = contextDCalls;
await act(async () => {
@@ -410,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();
});
@@ -0,0 +1,51 @@
// Run: tsx src/__tests__/turn-action-copy.test.ts
import { appendTurnActionCopyText } from "../lib/turnActionCopy";
let passed = 0;
let failed = 0;
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n`);
failed += 1;
}
}
console.log("\nturn action copy");
eq(
appendTurnActionCopyText("", "final answer"),
"final answer",
"keeps single assistant output unchanged",
);
eq(
appendTurnActionCopyText("first assistant block", "second assistant block"),
"first assistant block\n\nsecond assistant block",
"accumulates multiple assistant outputs in one turn",
);
eq(
appendTurnActionCopyText("first assistant block\n", "second assistant block"),
"first assistant block\nsecond assistant block",
"does not add an extra blank line when the previous block already ends with newline",
);
eq(
appendTurnActionCopyText("first assistant block", "\nsecond assistant block"),
"first assistant block\nsecond assistant block",
"does not add an extra blank line when the next block starts with newline",
);
eq(
appendTurnActionCopyText("final answer", " \n\t"),
"final answer",
"ignores whitespace-only assistant fragments",
);
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -113,6 +113,10 @@ eq(
"var(--statusbar-dock-height)",
"status bar min-height follows the scaled dock token",
);
eq(finalDeclaration(".provider-template-grid", "grid-auto-rows"), "92px", "provider preset cards use compact equal-height grid rows");
eq(finalDeclaration(".provider-template-card", "height"), "100%", "provider preset cards stretch to the grid row height");
eq(finalDeclaration(".provider-template-card strong", "-webkit-line-clamp"), "1", "provider preset card titles clamp to one line");
eq(finalDeclaration(".provider-template-card span", "-webkit-line-clamp"), "2", "provider preset card descriptions clamp to two lines");
eq(finalDeclaration(".statusbar", "white-space"), "nowrap", "status bar keeps metrics on one row");
eq(finalDeclaration(".statusbar", "overflow"), "hidden", "status bar clips instead of overflowing");
@@ -160,6 +164,16 @@ ok(
);
eq(finalDeclaration(".composer-modebar", "overflow"), "hidden", "chat mode switcher contains enlarged labels");
eq(
finalDeclaration(".app--creation .tool:not(.tool--open) > .tool__body", "height"),
"0 !important",
"collapsed creation tool bodies keep mounted content clipped",
);
eq(
finalDeclaration(".app--creation .tool:not(.tool--open) > .tool__body", "visibility"),
"hidden",
"collapsed creation tool bodies do not paint hidden tool text",
);
ok(
/@container\s*\(max-width:\s*760px\)[\s\S]*?\.composer-meta__control--model\s*\{[\s\S]*?flex\s*:\s*0 1 auto[\s\S]*?width\s*:\s*fit-content[\s\S]*?max-width\s*:\s*min\(240px,\s*42vw\)[\s\S]*?\.composer-meta--has-intent-chip\s+\.composer-meta__control--model\s*\{[\s\S]*?flex\s*:\s*0 1 auto[\s\S]*?width\s*:\s*fit-content[\s\S]*?max-width\s*:\s*min\(220px,\s*38vw\)[\s\S]*?\.composer-meta__control--effort\s*\{[\s\S]*?display\s*:\s*none[\s\S]*?\.composer-meta__control--more\s*\{[\s\S]*?display\s*:\s*inline-flex/.test(styles),
"composer compact controls activate at the capped theme width",
@@ -102,6 +102,7 @@ globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.windo
const eventHandlers: Array<(e: WireEvent) => void> = [];
let backendRunning = false;
let cancelCalls = 0;
let effortCalls = 0;
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
@@ -119,6 +120,10 @@ window.go = {
MetaForTab: async () => meta(),
ContextUsageForTab: async () => context,
EffortForTab: async () => effort,
SetEffortForTab: async () => {
effortCalls += 1;
throw new Error("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing effort");
},
BalanceForTab: async () => ({ available: false, display: "" }),
JobsForTab: async () => [],
CheckpointsForTab: async () => [],
@@ -178,6 +183,15 @@ eq(controller?.state.running, false, "cancel reconciliation clears the running s
eq(cancelCalls, 1, "CancelTab is called once");
eq(controller?.state.cancelRequested, false, "cancel reconciliation clears cancelRequested");
await act(async () => {
await controller?.setEffort("max");
await flushPromises();
});
const effortNotice = controller?.state.items.find((item) => item.kind === "notice" && item.text.includes("cannot change yet"));
eq(effortCalls, 1, "SetEffortForTab is called once");
ok(Boolean(effortNotice), "busy effort switch surfaces a non-failure warning notice");
await act(async () => {
root.unmount();
});
@@ -1,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,163 @@
// Run: tsx src/__tests__/use-controller-send-fallback.test.tsx
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { AppBindings } from "../lib/bridge";
import { useController } from "../lib/useController";
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`);
}
function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta {
return {
id: "tab-send",
scope: "project",
workspaceRoot: "/repo/send",
workspaceName: "send",
workspacePath: "/repo/send",
gitBranch: "main",
topicId: "topic-send",
topicTitle: "Send",
label: "model-send",
ready: true,
running: false,
mode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
active: true,
cwd: "/repo/send",
...overrides,
};
}
function metaFor(tab: TabMeta): Meta {
return {
label: tab.label,
ready: tab.ready,
startupErr: tab.startupErr,
eventChannel: "agent:event",
cwd: tab.cwd || tab.workspaceRoot,
workspaceRoot: tab.workspaceRoot,
workspaceName: tab.workspaceName,
workspacePath: tab.workspacePath,
gitBranch: tab.gitBranch,
autoApproveTools: false,
bypass: false,
collaborationMode: tab.collaborationMode ?? "normal",
toolApprovalMode: tab.toolApprovalMode ?? "ask",
tokenMode: tab.tokenMode ?? "full",
goal: "",
goalStatus: "stopped",
};
}
console.log("\nuse controller send fallback");
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
const backendTab = tabMeta({ backgroundJobs: 2 });
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
const balance: BalanceInfo = { available: false, display: "" };
const jobs: JobView[] = [];
const checkpoints: CheckpointMeta[] = [];
let tabsAvailable = false;
let submitCalls = 0;
window.runtime = {
EventsOn: () => () => {},
BrowserOpenURL: () => {},
};
window.go = {
main: {
App: {
ListTabs: async () => (tabsAvailable ? [backendTab] : []),
MetaForTab: async () => metaFor(backendTab),
ContextUsageForTab: async () => context,
EffortForTab: async () => effort,
BalanceForTab: async () => balance,
JobsForTab: async () => jobs,
CheckpointsForTab: async () => checkpoints,
HistoryForTab: async (): Promise<HistoryMessage[]> => [],
HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
HistoryCheckpointTurnsForTab: async () => [],
ReplayPendingPrompts: async () => {},
SubmitToTab: async (tabId: string) => {
submitCalls += tabId === "tab-send" ? 1 : 0;
},
} as Partial<AppBindings> as AppBindings,
},
};
type Controller = ReturnType<typeof useController>;
let controller: Controller | undefined;
function Probe() {
controller = useController();
return null;
}
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(<Probe />);
await flushPromises();
});
eq(controller?.activeTabId, undefined, "startup has no active tab when backend has no tabs");
tabsAvailable = true;
await act(async () => {
await controller?.send("hello from fallback");
await flushPromises();
});
eq(controller?.activeTabId, "tab-send", "send fallback activates the backend-selected tab");
eq(controller?.state.backgroundJobs, 2, "send fallback reconciles backend runtime metadata");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello from fallback") ?? false, "send fallback keeps the optimistic user turn");
eq(submitCalls, 1, "send fallback submits to the activated tab");
await act(async () => {
root.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,124 @@
// Run: tsx src/__tests__/wails-resize-fix.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { useWailsResizeFix } from "../lib/useWailsResizeFix";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) {
ok(true, label);
} else {
ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
function installDom(): JSDOM {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.MouseEvent = dom.window.MouseEvent;
Object.defineProperty(window, "innerWidth", { configurable: true, value: 100 });
Object.defineProperty(window, "innerHeight", { configurable: true, value: 80 });
return dom;
}
function installWailsFlags(flags?: Partial<NonNullable<Window["wails"]>["flags"]>) {
window.wails = {
Callback: () => undefined,
EventsNotify: () => undefined,
flags: {
enableResize: true,
resizeEdge: undefined,
borderThickness: 6,
defaultCursor: null,
cssDragProperty: "--wails-draggable",
cssDragValue: "drag",
cssDropProperty: "--wails-drop-target",
cssDropValue: "drop",
shouldDrag: false,
deferDragToMouseMove: false,
disableScrollbarDrag: false,
disableDefaultContextMenu: false,
enableWailsDragAndDrop: false,
...flags,
},
};
}
function Harness({ enabled }: { enabled: boolean }) {
useWailsResizeFix(enabled);
return null;
}
async function renderHarness(root: Root, enabled: boolean) {
await act(async () => {
root.render(<Harness enabled={enabled} />);
});
}
async function unmount(root: Root) {
await act(async () => {
root.unmount();
});
}
console.log("\nwails resize fix");
{
const dom = installDom();
installWailsFlags({ enableResize: true, resizeEdge: "n-resize" });
const root = createRoot(document.getElementById("root")!);
await renderHarness(root, false);
window.dispatchEvent(new MouseEvent("mousemove", { clientX: 97, clientY: 40 }));
eq(window.wails?.flags.enableResize, true, "disabled hook leaves Wails resize enabled");
eq(window.wails?.flags.resizeEdge, "n-resize", "disabled hook leaves resize edge unchanged");
await unmount(root);
dom.window.close();
}
{
const dom = installDom();
document.documentElement.style.cursor = "grab";
installWailsFlags({ enableResize: true, resizeEdge: "n-resize" });
const root = createRoot(document.getElementById("root")!);
await renderHarness(root, true);
eq(window.wails?.flags.enableResize, false, "enabled hook disables Wails built-in mousemove resize");
window.dispatchEvent(new MouseEvent("mousemove", { clientX: 97, clientY: 40 }));
eq(window.wails?.flags.resizeEdge, "e-resize", "enabled hook detects right edge using innerWidth CSS pixels");
eq(document.documentElement.style.cursor, "e-resize", "enabled hook mirrors detected edge cursor");
await unmount(root);
eq(window.wails?.flags.enableResize, true, "cleanup restores previous enableResize");
eq(window.wails?.flags.resizeEdge, "n-resize", "cleanup restores previous resizeEdge");
eq(document.documentElement.style.cursor, "grab", "cleanup restores previous cursor");
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -7,7 +7,7 @@ import { createRoot } from "react-dom/client";
import { WorkspacePanel } from "../components/WorkspacePanel";
import { LocaleProvider } from "../lib/i18n";
import type { AppBindings } from "../lib/bridge";
import type { WorkspaceChangesView } from "../lib/types";
import type { DirEntry, WorkspaceChangesView } from "../lib/types";
let passed = 0;
let failed = 0;
@@ -101,6 +101,47 @@ async function renderWorkspace(changes: WorkspaceChangesView) {
return { dom, root };
}
async function renderFilesWorkspace(methods: Partial<AppBindings>, props: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) {
const dom = installDom();
window.go = {
main: {
App: {
ListDir: async () => [],
SearchFileRefs: async () => [],
WorkspaceGitHistory: async () => [],
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
...methods,
} as Partial<AppBindings> as AppBindings,
},
};
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
let currentProps: Parameters<typeof WorkspacePanel>[0] = {
open: true,
tabId: "tab-a",
cwd: "/repo",
maximized: false,
initialViewMode: "files",
onClose: () => {},
onToggleMaximized: () => {},
...props,
};
const rerender = async (nextProps: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) => {
currentProps = { ...currentProps, ...nextProps };
await act(async () => {
root.render(
<LocaleProvider>
<WorkspacePanel {...currentProps} />
</LocaleProvider>,
);
await flushPromises();
});
};
await rerender();
return { dom, root, rerender };
}
console.log("\nworkspace changes git errors");
{
@@ -151,5 +192,28 @@ console.log("\nworkspace changes git errors");
dom.window.close();
}
{
const calls: string[] = [];
const listDir = async (dir: string): Promise<DirEntry[]> => {
calls.push(dir);
return [];
};
const { dom, root, rerender } = await renderFilesWorkspace(
{ ListDir: listDir },
{ fileListRequest: { id: 1, paths: ["src/app.ts"] } },
);
await waitFor("initial referenced file dirs", () => calls.filter((dir) => dir === "src/").length === 1);
await rerender({ fileListRequest: { id: 2, paths: ["src/app.ts"] } });
await waitFor("referenced file dirs revalidated", () => calls.filter((dir) => dir === "src/").length === 2);
ok(calls.filter((dir) => dir === "src/").length === 2, "workspace file tree revalidates cached directories for repeated file-list requests");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -58,6 +58,7 @@ export function AppChrome({
}: AppChromeProps) {
const t = useT();
const darwinChrome = platform === "darwin";
const titlebarDragRail = darwinChrome || platform === "windows";
const chromeClassName = [
"app-chrome",
"app-chrome--tabs",
@@ -90,7 +91,7 @@ export function AppChrome({
<span />
</div>
)}
{darwinChrome && <span className="app-chrome__drag-rail" aria-hidden="true" />}
{titlebarDragRail && <span className="app-chrome__drag-rail" aria-hidden="true" />}
<button
className={[
"app-chrome__panel-toggle",
@@ -27,6 +27,10 @@ function animateShelfExit(
options.onComplete();
}
function requiresFreshHumanApproval(tool: string): boolean {
return tool === "remember" || tool === "forget" || tool === "exit_plan_mode";
}
export function ApprovalModal({
approval,
onAnswer,
@@ -48,6 +52,9 @@ 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 isFreshHumanApproval = requiresFreshHumanApproval(approval.tool);
const subject = approval.subject.trim();
const subjectSummary = subject.split(/\r?\n/).find((line) => line.trim())?.trim() ?? "";
const toolMeta = approval.reason?.trim() || subjectSummary || approval.tool;
@@ -93,9 +100,10 @@ export function ApprovalModal({
const chooseToolAction = (key: string) => {
if (key === "1") answerWithExit(() => onAnswer(true, false, false));
else if (key === "2") answerWithExit(() => onAnswer(true, true, false));
else if (key === "3") answerWithExit(() => onAnswer(true, true, true));
else if (key === "4") answerWithExit(() => onAnswer(false, false, false));
else if (isFreshHumanApproval && key === "4") answerWithExit(() => onAnswer(false, false, false));
else if (!isFreshHumanApproval && key === "2") answerWithExit(() => onAnswer(true, true, false));
else if (!isFreshHumanApproval && key === "3") answerWithExit(() => onAnswer(true, true, true));
else if (!isFreshHumanApproval && key === "4") answerWithExit(() => onAnswer(false, false, false));
else if (key === "Escape") answerWithExit(onStop);
};
@@ -108,7 +116,7 @@ export function ApprovalModal({
playAttentionChime();
}, [approval.id, isPlanApproval, showToolDetailsByDefault]);
const actionCount = isPlanApproval ? 3 : 4;
const actionCount = isPlanApproval ? 3 : isFreshHumanApproval ? 2 : 4;
const selectedIndexRef = useRef(selectedIndex);
selectedIndexRef.current = selectedIndex;
@@ -129,6 +137,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 if (event.key === "1" || event.key === "2" || event.key === "3" || event.key === "4" || event.key === "Escape") {
event.preventDefault();
@@ -138,7 +147,7 @@ export function ApprovalModal({
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [isPlanApproval, onAnswer, onExitPlan, onStop, actionCount]);
}, [isPlanApproval, isFreshHumanApproval, onAnswer, onExitPlan, onStop, actionCount]);
useEffect(() => {
if (revisionOpen) {
@@ -291,7 +300,7 @@ export function ApprovalModal({
barRef={cardRef}
titleId="tool-approval-title"
title={t("approval.toolPending")}
badges={<PromptBadge>{approval.tool}</PromptBadge>}
badges={<PromptBadge>{toolLabel}</PromptBadge>}
meta={toolMeta}
headerActions={
<>
@@ -308,9 +317,15 @@ export function ApprovalModal({
actions={
<>
<PromptAction keyLabel="1" label={t("approval.allowOnce")} onClick={() => answerWithExit(() => onAnswer(true, false, false))} selected={selectedIndex === 0} />
<PromptAction keyLabel="2" label={t("approval.allowRuleSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
<PromptAction keyLabel="3" label={t("approval.allowRulePersistent")} onClick={() => answerWithExit(() => onAnswer(true, true, true))} selected={selectedIndex === 2} />
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 3} />
{isFreshHumanApproval ? (
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 1} />
) : (
<>
<PromptAction keyLabel="2" label={t("approval.allowRuleSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
<PromptAction keyLabel="3" label={t("approval.allowRulePersistent")} onClick={() => answerWithExit(() => onAnswer(true, true, true))} selected={selectedIndex === 2} />
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 3} />
</>
)}
</>
}
>
@@ -4,7 +4,7 @@ import { asArray } from "../lib/array";
import { app, openExternal } from "../lib/bridge";
import { useT } from "../lib/i18n";
import { mcpServerLifecycleActions, mcpServerRetryableFromAvailableList } from "../lib/mcpServerLifecycle";
import type { CapabilitiesView, MCPServerInput, ServerView, SkillRootSkillView, SkillRootView, SkillsSettingsView, SkillView, TabMeta } from "../lib/types";
import type { CapabilitiesView, MCPServerInput, PluginHookView, PluginInstallOptions, PluginMCPServerView, PluginSkillView, PluginView, ServerView, SkillRootSkillView, SkillRootView, SkillsSettingsView, SkillView, TabMeta } from "../lib/types";
import { InlineConfirmButton } from "./InlineConfirmButton";
import { ResizableDrawer } from "./ResizableDrawer";
import { Tooltip } from "./Tooltip";
@@ -20,6 +20,7 @@ type SettingsSnapshot<T> = { key: string; value: T };
let mcpSettingsSnapshot: SettingsSnapshot<ServerView[]> | null = null;
let skillsSettingsSnapshot: SettingsSnapshot<SkillsSettingsView> | null = null;
let pluginsSettingsSnapshot: SettingsSnapshot<PluginView[]> | null = null;
function settingsSnapshotKey(meta: Awaited<ReturnType<typeof app.Meta>> | null | undefined, tabs: TabMeta[] | null | undefined): string {
const active = tabs?.find((tab) => tab.active);
@@ -50,7 +51,7 @@ export function CapabilitiesPanel({
const [expandedServerTools, setExpandedServerTools] = useState<Set<string>>(() => new Set());
const reload = useCallback(async () => {
setView(normalizeCapabilitiesView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [] }))));
setView(normalizeCapabilitiesView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [], plugins: [] }))));
}, []);
useEffect(() => {
void reload();
@@ -314,6 +315,7 @@ export function CapabilitiesPanel({
function normalizeCapabilitiesView(view: CapabilitiesView | null | undefined): CapabilitiesView {
return {
servers: normalizeServerViews(view?.servers),
plugins: asArray(view?.plugins),
...normalizeSkillsSettingsView(view),
};
}
@@ -1590,6 +1592,619 @@ function AddServerForm({
);
}
type PluginInstallPlanAction = {
action?: string;
kind?: string;
name?: string;
source?: string;
status?: string;
message?: string;
error?: string;
};
type PluginInstallPlanView = {
raw: string;
ok?: boolean;
status?: string;
name?: string;
actions: PluginInstallPlanAction[];
warnings: string[];
error?: string;
};
type PluginInstallMode = "local" | "git";
// PluginsSettingsPage is the desktop plugin package manager embedded inside
// Settings. It mirrors the MCP/Skills density: install planning on top, package
// rows below, and diagnostics/details only when a row is expanded.
export function PluginsSettingsPage() {
const t = useT();
const [snapshotKey, setSnapshotKey] = useState("");
const [plugins, setPlugins] = useState<PluginView[] | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [installMode, setInstallMode] = useState<PluginInstallMode>("local");
const [localSource, setLocalSource] = useState("");
const [gitSource, setGitSource] = useState("");
const [name, setName] = useState("");
const [link, setLink] = useState(false);
const [replace, setReplace] = useState(false);
const [plan, setPlan] = useState<PluginInstallPlanView | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const [diagnostics, setDiagnostics] = useState<Record<string, PluginView>>({});
const reload = useCallback(async () => {
const [meta, tabs] = await Promise.all([
app.Meta().catch(() => null),
app.ListTabs().catch(() => []),
]);
const key = settingsSnapshotKey(meta, tabs);
setSnapshotKey(key);
const cached = key ? pluginsSettingsSnapshot : null;
if (cached?.key === key) {
setPlugins(cached.value);
} else {
setPlugins(null);
}
const next = normalizePluginViews(await app.Plugins().catch(() => []));
pluginsSettingsSnapshot = { key, value: next };
setPlugins(next);
}, []);
useEffect(() => { void reload(); }, [reload]);
const run = async (fn: () => Promise<unknown>, reloadAfter = true) => {
setBusy(true);
setErr(null);
setNotice(null);
try {
const result = await fn();
if (typeof result === "string" && result.trim()) {
const parsed = parsePluginInstallPlan(result);
setNotice(pluginPlanNotice(parsed, t));
}
if (reloadAfter) await reload();
return true;
} catch (e) {
setErr(String((e as Error)?.message ?? e));
if (reloadAfter) await reload();
return false;
} finally {
setBusy(false);
}
};
const sourceValue = (installMode === "local" ? localSource : gitSource).trim();
const installOptions = (): PluginInstallOptions => ({
dryRun: false,
link: installMode === "local" ? link : false,
replace,
name: installMode === "git" ? name.trim() || undefined : undefined,
});
const actionBusy = busy || !snapshotKey || !plugins;
const canPlan = sourceValue.length > 0 && !actionBusy;
const summary = plugins ? pluginListSummary(plugins, t) : "";
const togglePlugin = useCallback((pluginName: string) => {
setExpanded((prev) => { const next = new Set(prev); if (next.has(pluginName)) next.delete(pluginName); else next.add(pluginName); return next; });
}, []);
const setMode = (mode: PluginInstallMode) => {
setInstallMode(mode);
setPlan(null);
};
const previewInstall = () => {
if (!sourceValue) return;
void run(async () => {
const raw = await app.PlanPluginInstall(sourceValue, { ...installOptions(), dryRun: true });
setPlan(parsePluginInstallPlan(raw));
}, false);
};
const install = () => {
if (!sourceValue) return;
void run(async () => {
const raw = await app.InstallPlugin(sourceValue, installOptions());
setPlan(parsePluginInstallPlan(raw));
return raw;
});
};
const runDoctor = (pluginName: string) => {
void run(async () => {
const view = normalizePluginView(await app.PluginDoctor(pluginName));
setDiagnostics((prev) => ({ ...prev, [pluginName]: view }));
setExpanded((prev) => {
const next = new Set(prev);
next.add(pluginName);
return next;
});
}, false);
};
const updateLocalSource = (value: string) => {
setLocalSource(value);
setPlan(null);
};
const updateGitSource = (value: string) => {
setGitSource(value);
setPlan(null);
};
const pickPluginFolder = () => {
void run(async () => {
const path = await app.PickPluginFolder();
if (path) {
setInstallMode("local");
updateLocalSource(path);
}
}, false);
};
return (
<section className="mem-section">
{err && <div className="banner banner--error">{err}</div>}
{notice && !err && <div className="banner banner--success">{notice}</div>}
<div className="cap-plugin-installer">
<div className="cap-plugin-installer__head">
<div className="cap-plugin-installer__copy">
<div className="cap-plugin-installer__title">{t("caps.pluginInstallTitle")}</div>
<div className="cap-plugin-installer__hint">{t("caps.pluginInstallHint")}</div>
</div>
<div className="cap-tabs cap-plugin-installer__mode" role="group" aria-label={t("caps.pluginInstallMethod")}>
<button
className={`cap-tab${installMode === "local" ? " cap-tab--active" : ""}`}
type="button"
aria-pressed={installMode === "local"}
onClick={() => setMode("local")}
>
{t("caps.pluginInstallLocal")}
</button>
<button
className={`cap-tab${installMode === "git" ? " cap-tab--active" : ""}`}
type="button"
aria-pressed={installMode === "git"}
onClick={() => setMode("git")}
>
{t("caps.pluginInstallGit")}
</button>
</div>
</div>
<div className="cap-plugin-form-grid">
{installMode === "local" ? (
<div className="cap-plugin-fields cap-plugin-fields--local">
<div className="cap-plugin-folder-field">
<button className="btn btn--small" disabled={actionBusy} type="button" onClick={pickPluginFolder}>
{t("caps.pluginChooseLocalFolder")}
</button>
<div
className={`cap-plugin-path${localSource ? "" : " cap-plugin-path--empty"}`}
aria-label={t("caps.pluginLocalFolder")}
>
{localSource || t("caps.pluginNoLocalFolder")}
</div>
</div>
</div>
) : (
<div className="cap-plugin-fields cap-plugin-fields--git">
<input
className="mem-input"
aria-label={t("caps.pluginGitSource")}
placeholder={t("caps.pluginSourcePlaceholder")}
value={gitSource}
onInput={(e) => updateGitSource(e.currentTarget.value)}
onChange={(e) => updateGitSource(e.target.value)}
/>
<div className="cap-plugin-field">
<input
className="mem-input"
aria-label={t("caps.pluginInstallName")}
placeholder={t("caps.pluginInstallNamePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
</div>
)}
<div className="cap-plugin-installer__options">
<div className="cap-plugin-option-block">
<label className="cap-plugin-option">
<input type="checkbox" checked={replace} disabled={actionBusy} onChange={(e) => setReplace(e.target.checked)} />
<span>{t("caps.pluginReplace")}</span>
</label>
<div className="cap-plugin-option-hint">{t("caps.pluginReplaceHint")}</div>
</div>
{installMode === "local" && (
<div className="cap-plugin-option-block">
<label className="cap-plugin-option">
<input type="checkbox" checked={link} disabled={actionBusy} onChange={(e) => setLink(e.target.checked)} />
<span>{t("caps.pluginLink")}</span>
</label>
<div className="cap-plugin-option-hint">{t("caps.pluginLinkHint")}</div>
</div>
)}
</div>
<div className="cap-plugin-installer__actions">
<button className="btn btn--small" type="button" disabled={!canPlan} onClick={previewInstall}>
{t("caps.pluginPreview")}
</button>
<button className="btn btn--primary btn--small" type="button" disabled={!canPlan} onClick={install}>
{t("caps.pluginInstall")}
</button>
</div>
</div>
</div>
{plan && <PluginPlanPreview plan={plan} />}
<div className="cap-server-section cap-plugin-section">
<div className="cap-server-section__head">
<div className="cap-server-section__copy">
<div className="cap-server-section__title">{t("caps.installedPlugins")}</div>
{plugins && plugins.length > 0 && <div className="drawer__summary">{summary}</div>}
</div>
<button className="btn btn--small" disabled={actionBusy} type="button" onClick={() => void reload()}>
{t("caps.pluginRefresh")}
</button>
</div>
{!plugins ? (
<div className="mem-empty">{t("caps.loading")}</div>
) : plugins.length === 0 ? (
<div className="mem-empty mem-empty--cta">
<strong>{t("caps.noPluginsTitle")}</strong>
<span>{t("caps.noPluginsHint")}</span>
</div>
) : (
<div className="cap-server-group">
{plugins.map((plugin) => (
<PluginRow
key={plugin.name}
plugin={plugin}
diagnostic={diagnostics[plugin.name]}
busy={actionBusy}
expanded={expanded.has(plugin.name)}
onToggleDetails={() => togglePlugin(plugin.name)}
onToggleEnabled={(enabled) => void run(() => app.SetPluginEnabled(plugin.name, enabled))}
onUpdate={() => void run(() => app.UpdatePlugin(plugin.name))}
onDoctor={() => runDoctor(plugin.name)}
onRemove={() => void run(() => app.RemovePlugin(plugin.name))}
/>
))}
</div>
)}
</div>
</section>
);
}
function PluginPlanPreview({ plan }: { plan: PluginInstallPlanView }) {
const t = useT();
return (
<div className={`cap-plugin-plan${plan.error ? " cap-plugin-plan--error" : ""}`}>
<div className="cap-plugin-plan__head">
<div className="cap-plugin-plan__title">{plan.error ? t("caps.pluginPlanError") : t("caps.pluginPlanReady")}</div>
{plan.status && <span className="cap-source-badge">{plan.status}</span>}
</div>
{plan.name && <div className="cap-plugin-plan__meta">{plan.name}</div>}
{plan.error && <div className="cap-plugin-plan__warning">{plan.error}</div>}
{plan.warnings.map((warning, idx) => (
<div className="cap-plugin-plan__warning" key={`${warning}-${idx}`}>{warning}</div>
))}
{plan.actions.length > 0 ? (
<div className="cap-plugin-actions">
{plan.actions.map((action, idx) => (
<div className="cap-plugin-action" key={`${action.action || action.kind || "action"}-${idx}`}>
<span className="cap-plugin-action__name">{pluginPlanActionLabel(action, t)}</span>
{action.status && <span className="cap-source-badge">{action.status}</span>}
{action.source && <span className="cap-plugin-action__source">{action.source}</span>}
{action.message && <span className="cap-plugin-action__source">{action.message}</span>}
{action.error && <span className="cap-plugin-plan__warning">{action.error}</span>}
</div>
))}
</div>
) : (
<pre className="cap-plugin-plan__raw">{plan.raw}</pre>
)}
</div>
);
}
function PluginRow({
plugin,
diagnostic,
busy,
expanded,
onToggleDetails,
onToggleEnabled,
onUpdate,
onDoctor,
onRemove,
}: {
plugin: PluginView;
diagnostic?: PluginView;
busy: boolean;
expanded: boolean;
onToggleDetails: () => void;
onToggleEnabled: (enabled: boolean) => void;
onUpdate: () => void;
onDoctor: () => void;
onRemove: () => void;
}) {
const t = useT();
const status = plugin.error ? "failed" : plugin.enabled ? "connected" : "disabled";
const warnings = pluginWarnings(plugin, diagnostic);
const sub = plugin.error || pluginCapabilitiesSummary(plugin, t);
return (
<div className={`cap-server-entry cap-plugin-entry${plugin.enabled ? "" : " cap-server-entry--disabled"}`}>
<Tooltip label={plugin.error} disabled={!plugin.error} fill block>
<div className={`cap-row${plugin.enabled ? "" : " cap-row--disabled"}`}>
<Tooltip label={expanded ? t("caps.collapseDetails") : t("caps.expandDetails")}>
<button
className="cap-disclosure"
aria-expanded={expanded}
type="button"
onClick={onToggleDetails}
>
{expanded ? "⌄" : "›"}
</button>
</Tooltip>
<span className={`cap-dot cap-dot--${status}`} />
<div className="cap-row__text">
<div className="cap-row__head">
<span className="cap-row__name">{plugin.name}</span>
{plugin.manifestKind && <span className="cap-row__transport">{plugin.manifestKind}</span>}
{plugin.version && <span className="cap-source-badge">{plugin.version}</span>}
{warnings.length > 0 && <span className="cap-row__update cap-row__update--error">{t("caps.pluginWarnings", { count: warnings.length })}</span>}
</div>
<div className="cap-row__sub">{sub}</div>
</div>
<div className="cap-row__actions">
<Tooltip label={plugin.enabled ? t("caps.pluginDisable") : t("caps.pluginEnable")}>
<label className="cap-switch">
<input
type="checkbox"
checked={plugin.enabled}
disabled={busy}
onChange={(e) => onToggleEnabled(e.target.checked)}
/>
<span className="cap-switch__track" />
</label>
</Tooltip>
</div>
</div>
</Tooltip>
{expanded && (
<div className="cap-server-details">
<div className="cap-detail-grid">
<div className="cap-detail">
<span className="cap-detail__label">{t("caps.status")}</span>
<span className="cap-detail__value">{plugin.enabled ? t("caps.pluginEnabled") : t("caps.pluginDisabled")}</span>
</div>
{plugin.version && (
<div className="cap-detail">
<span className="cap-detail__label">{t("caps.pluginVersion")}</span>
<span className="cap-detail__value">{plugin.version}</span>
</div>
)}
{plugin.source && (
<div className="cap-detail cap-detail--wide">
<span className="cap-detail__label">{t("caps.pluginSource")}</span>
<span className="cap-detail__code">{plugin.source}</span>
</div>
)}
{plugin.root && (
<div className="cap-detail cap-detail--wide">
<span className="cap-detail__label">{t("caps.pluginRoot")}</span>
<span className="cap-detail__code">{plugin.root}</span>
</div>
)}
</div>
{plugin.description && <div className="cap-plugin-description">{plugin.description}</div>}
<PluginUsageDetails plugin={plugin} />
{diagnostic?.error && <div className="cap-source__warning">{diagnostic.error}</div>}
{warnings.map((warning, idx) => (
<div className="cap-source__warning" key={`${plugin.name}-warning-${idx}`}>{warning}</div>
))}
<div className="cap-detail-actions">
<button className="btn btn--small" disabled={busy} type="button" onClick={onUpdate}>
{t("caps.pluginUpdate")}
</button>
<button className="btn btn--small" disabled={busy} type="button" onClick={onDoctor}>
{t("caps.pluginDoctor")}
</button>
<InlineConfirmButton
label={t("caps.pluginRemove")}
confirmLabel={t("caps.pluginConfirmRemove")}
cancelLabel={t("common.cancel")}
disabled={busy}
danger
onConfirm={onRemove}
/>
</div>
</div>
)}
</div>
);
}
function PluginUsageDetails({ plugin }: { plugin: PluginView }) {
const t = useT();
const skills = asArray(plugin.skillDetails);
const hooks = asArray(plugin.hookDetails);
const mcps = asArray(plugin.mcpServerDetails);
const hasDetails = skills.length > 0 || hooks.length > 0 || mcps.length > 0;
return (
<div className="cap-plugin-usage">
<div className="cap-plugin-usage__title">{t("caps.pluginUsageTitle")}</div>
<div className="cap-plugin-usage__hint">
{plugin.enabled ? t("caps.pluginUsageEnabledHint") : t("caps.pluginUsageDisabledHint")}
</div>
{hasDetails ? (
<div className="cap-plugin-capabilities">
{skills.length > 0 && <PluginSkillList skills={skills} />}
{hooks.length > 0 && <PluginHookList hooks={hooks} />}
{mcps.length > 0 && <PluginMCPList servers={mcps} />}
</div>
) : (
<div className="cap-plugin-usage__empty">{t("caps.pluginNoCapabilityDetails")}</div>
)}
</div>
);
}
function PluginSkillList({ skills }: { skills: PluginSkillView[] }) {
const t = useT();
return (
<div className="cap-plugin-capability">
<div className="cap-plugin-capability__head">{t("caps.pluginSkillsTitle")}</div>
<div className="cap-plugin-capability__hint">{t("caps.pluginSkillsHint")}</div>
<div className="cap-plugin-capability__list">
{skills.map((skill) => (
<div className="cap-plugin-capability__item" key={`${skill.name}-${skill.path || skill.invocation || ""}`}>
<div className="cap-plugin-capability__line">
<span className="cap-plugin-capability__name">{skill.invocation || `/${skill.name}`}</span>
{skill.runAs && <span className="cap-source-badge">{skill.runAs}</span>}
</div>
<div className="cap-plugin-capability__desc">{skill.description || t("caps.pluginNoDescription")}</div>
</div>
))}
</div>
</div>
);
}
function PluginHookList({ hooks }: { hooks: PluginHookView[] }) {
const t = useT();
return (
<div className="cap-plugin-capability">
<div className="cap-plugin-capability__head">{t("caps.pluginHooksTitle")}</div>
<div className="cap-plugin-capability__hint">{t("caps.pluginHooksHint")}</div>
<div className="cap-plugin-capability__list">
{hooks.map((hook, idx) => {
const target = hook.command || hook.contextFile || t("caps.pluginHookNoTarget");
return (
<div className="cap-plugin-capability__item" key={`${hook.event}-${hook.match || "*"}-${target}-${idx}`}>
<div className="cap-plugin-capability__line">
<span className="cap-plugin-capability__name">{hook.event}</span>
<span className="cap-source-badge">{hook.match || "*"}</span>
</div>
<div className="cap-plugin-capability__desc">{hook.description || target}</div>
</div>
);
})}
</div>
</div>
);
}
function PluginMCPList({ servers }: { servers: PluginMCPServerView[] }) {
const t = useT();
return (
<div className="cap-plugin-capability">
<div className="cap-plugin-capability__head">{t("caps.pluginMCPTitle")}</div>
<div className="cap-plugin-capability__hint">{t("caps.pluginMCPHint")}</div>
<div className="cap-plugin-capability__list">
{servers.map((server) => (
<div className="cap-plugin-capability__item" key={server.name}>
<div className="cap-plugin-capability__line">
<span className="cap-plugin-capability__name">{server.name}</span>
{server.transport && <span className="cap-source-badge">{server.transport}</span>}
</div>
<div className="cap-plugin-capability__desc">{server.command || server.url || t("caps.pluginMCPNoTarget")}</div>
</div>
))}
</div>
</div>
);
}
function normalizePluginViews(plugins: PluginView[] | null | undefined): PluginView[] {
return sortPluginsForDisplay(asArray(plugins).map(normalizePluginView));
}
function normalizePluginView(plugin: PluginView): PluginView {
return {
...plugin,
name: plugin.name || "plugin",
root: plugin.root || "",
enabled: Boolean(plugin.enabled),
skills: Number.isFinite(plugin.skills) ? plugin.skills : 0,
hooks: Number.isFinite(plugin.hooks) ? plugin.hooks : 0,
mcpServers: Number.isFinite(plugin.mcpServers) ? plugin.mcpServers : 0,
skillDetails: asArray(plugin.skillDetails),
hookDetails: asArray(plugin.hookDetails),
mcpServerDetails: asArray(plugin.mcpServerDetails),
warnings: asArray(plugin.warnings),
};
}
function sortPluginsForDisplay(plugins: PluginView[]): PluginView[] {
return [...plugins].sort((a, b) => {
const priority = pluginDisplayPriority(a) - pluginDisplayPriority(b);
if (priority !== 0) return priority;
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
});
}
function pluginDisplayPriority(plugin: PluginView): number {
if (plugin.error) return 0;
if (plugin.enabled) return 1;
return 2;
}
function pluginListSummary(plugins: PluginView[], t: ReturnType<typeof useT>): string {
const enabled = plugins.filter((plugin) => plugin.enabled && !plugin.error).length;
const issues = plugins.filter((plugin) => Boolean(plugin.error) || asArray(plugin.warnings).length > 0).length;
return t("caps.pluginsSummary", { enabled, total: plugins.length, issues });
}
function pluginCapabilitiesSummary(plugin: PluginView, t: ReturnType<typeof useT>): string {
if (plugin.skills === 0 && plugin.hooks === 0 && plugin.mcpServers === 0) return t("caps.pluginNoCapabilities");
return t("caps.pluginCounts", { skills: plugin.skills, hooks: plugin.hooks, mcps: plugin.mcpServers });
}
function pluginWarnings(plugin: PluginView, diagnostic?: PluginView): string[] {
const warnings = [...asArray(plugin.warnings), ...asArray(diagnostic?.warnings)];
return Array.from(new Set(warnings.filter((warning) => warning.trim().length > 0)));
}
function parsePluginInstallPlan(raw: string): PluginInstallPlanView {
try {
const value = JSON.parse(raw) as Record<string, unknown>;
const actions = (Array.isArray(value.actions) ? value.actions : []).flatMap((action) => {
if (!action || typeof action !== "object") return [];
const item = action as Record<string, unknown>;
return [{
action: stringValue(item.action),
kind: stringValue(item.kind),
name: stringValue(item.name),
source: stringValue(item.source),
status: stringValue(item.status),
message: stringValue(item.message),
error: stringValue(item.error),
}];
});
return {
raw,
ok: typeof value.ok === "boolean" ? value.ok : undefined,
status: stringValue(value.status),
name: stringValue(value.name),
actions,
warnings: (Array.isArray(value.warnings) ? value.warnings : []).flatMap((warning) => typeof warning === "string" ? [warning] : []),
error: stringValue(value.error),
};
} catch {
return { raw, actions: [], warnings: [] };
}
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function pluginPlanActionLabel(action: PluginInstallPlanAction, t: ReturnType<typeof useT>): string {
const verb = action.action || action.kind || t("caps.pluginAction");
return [verb, action.name].filter(Boolean).join(" · ");
}
function pluginPlanNotice(plan: PluginInstallPlanView, t: ReturnType<typeof useT>): string {
if (plan.error) return plan.error;
if (plan.status === "done" || plan.status === "applied" || plan.status === "complete") return t("caps.pluginPlanInstalled");
return plan.status ? t("caps.pluginPlanStatus", { status: plan.status }) : t("caps.pluginPlanComplete");
}
// MCPServersSettingsPage is a self-contained MCP servers management page
// embedded inside the settings centre.
export function MCPServersSettingsPage() {
+63 -22
View File
@@ -26,6 +26,7 @@ import { EffortSwitcher } from "./EffortSwitcher";
import { ModelSwitcher } from "./ModelSwitcher";
import { Tooltip } from "./Tooltip";
import { ComposerContextCard } from "./ComposerContextCard";
import { ImageViewer } from "./ImageViewer";
import { VirtualMenu } from "./VirtualMenu";
import { dirEntryMenuLabel, dirEntrySubmitPath } from "./FileReferenceMenu";
@@ -57,6 +58,7 @@ const PROMPT_HISTORY_PREFETCH_REMAINING = 3;
// it; the real gap is a few ms, so keep it short or a deliberate quick second
// Enter (submit) gets eaten too.
const IME_CONFIRM_GRACE_MS = 100;
const FILE_REF_SEARCH_CACHE_TTL_MS = 5000;
type PastedBlock = {
label: string;
@@ -69,6 +71,11 @@ type PendingGuidance = {
submitText: string;
};
type FileRefSearchCacheEntry = {
entries: DirEntry[];
cachedAt: number;
};
type ComposerDraft = {
text: string;
attachments: Attachment[];
@@ -444,6 +451,7 @@ export function Composer({
retry,
transientDismissSignal,
sessionKey,
fileRefRefreshKey,
guidanceConsumedKey,
guidanceConsumedText,
guidanceQueuePreviewItems,
@@ -486,6 +494,7 @@ export function Composer({
retry?: { attempt: number; max: number };
transientDismissSignal?: number;
sessionKey?: string;
fileRefRefreshKey?: number | string;
guidanceConsumedKey?: string;
guidanceConsumedText?: string;
guidanceQueuePreviewItems?: readonly string[];
@@ -496,6 +505,15 @@ export function Composer({
const now = useTick(running);
const [text, setText] = useState("");
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [imageViewer, setImageViewer] = useState<{ open: boolean; url: string; name: string }>({ open: false, url: "", name: "" });
const openComposerImageViewer = useCallback((url: string, name: string) => {
setImageViewer({ open: true, url, name });
}, []);
const closeComposerImageViewer = useCallback(() => {
setImageViewer((prev) => (prev.open ? { ...prev, open: false } : prev));
}, []);
const [workspaceRefs, setWorkspaceRefs] = useState<WorkspaceReference[]>([]);
const [pastedBlocks, setPastedBlocks] = useState<PastedBlock[]>([]);
const [openPastedLabels, setOpenPastedLabels] = useState<string[]>([]);
@@ -743,16 +761,9 @@ export function Composer({
const [entries, setEntries] = useState<DirEntry[]>([]);
const [searchEntries, setSearchEntries] = useState<DirEntry[]>([]);
const dirCache = useRef<Record<string, DirEntry[]>>({});
const searchCache = useRef<Record<string, DirEntry[]>>({});
const searchCache = useRef<Record<string, FileRefSearchCacheEntry>>({});
// When the workspace/project changes (cwd prop), invalidate all @ mention
// state so the picker reloads candidates for the new project. Without this,
// dirCache/searchCache retain entries from the old project and the picker
// shows stale results (issue #3601).
const prevCwdRef = useRef(cwd);
useEffect(() => {
if (prevCwdRef.current === cwd) return; // skip mount — state already initial
prevCwdRef.current = cwd;
const clearFileRefState = useCallback(() => {
dirCache.current = {};
searchCache.current = {};
setEntries([]);
@@ -763,29 +774,50 @@ export function Composer({
setLoadingPastChats(false);
setActive(0);
setDismissed(false);
}, [cwd]);
}, []);
// When the workspace/project changes (cwd prop), invalidate all @ mention
// state so the picker reloads candidates for the new project. Without this,
// dirCache/searchCache retain entries from the old project and the picker
// shows stale results (issue #3601).
const prevCwdRef = useRef(cwd);
useEffect(() => {
if (prevCwdRef.current === cwd) return; // skip mount — state already initial
prevCwdRef.current = cwd;
clearFileRefState();
}, [clearFileRefState, cwd]);
const prevFileRefRefreshKeyRef = useRef(fileRefRefreshKey);
useEffect(() => {
if (prevFileRefRefreshKeyRef.current === fileRefRefreshKey) return;
prevFileRefRefreshKeyRef.current = fileRefRefreshKey;
clearFileRefState();
}, [clearFileRefState, fileRefRefreshKey]);
useEffect(() => {
if (atRaw === null) return;
const cached = dirCache.current[atDir];
if (cached) {
setEntries(cached);
return;
} else {
setEntries([]);
}
let live = true;
app
.ListDir(atDir)
.then((es) => {
const list = asArray(es);
if (!live) return;
dirCache.current[atDir] = list;
if (live) setEntries(list);
setEntries(list);
})
.catch(() => {});
return () => {
live = false;
};
// re-fetch when the menu opens or the directory level changes
}, [atRaw === null, atDir, cwd]);
// Re-fetch when the menu opens, the directory level changes, or the
// workspace tree refreshes; cached data is only a fast first paint.
}, [atRaw === null, atDir, cwd, fileRefRefreshKey]);
useEffect(() => {
if (atRaw === null || atDir !== "" || atFrag === "") {
setSearchEntries([]);
@@ -793,23 +825,25 @@ export function Composer({
}
const cached = searchCache.current[atFrag];
if (cached) {
setSearchEntries(cached);
return;
setSearchEntries(cached.entries);
if (Date.now() - cached.cachedAt < FILE_REF_SEARCH_CACHE_TTL_MS) return;
} else {
setSearchEntries([]);
}
setSearchEntries([]);
let live = true;
app
.SearchFileRefs(atFrag)
.then((es) => {
const list = es ?? [];
searchCache.current[atFrag] = list;
if (live) setSearchEntries(list);
const list = asArray(es);
if (!live) return;
searchCache.current[atFrag] = { entries: list, cachedAt: Date.now() };
setSearchEntries(list);
})
.catch(() => {});
return () => {
live = false;
};
}, [atRaw === null, atDir, atFrag, cwd]);
}, [atRaw === null, atDir, atFrag, cwd, fileRefRefreshKey]);
const atMatches = useMemo(
() => {
if (atRaw === null) return [];
@@ -2362,10 +2396,11 @@ export function Composer({
<ComposerContextCard
key={a.path}
variant="attachment"
tooltipLabel={a.path}
tooltipLabel={a.previewUrl ? `${t("imageViewer.clickToPreview")} — ${a.path}` : a.path}
removeLabel={t("composer.removeImage")}
onRemove={() => removeAttachment(a.path)}
previewUrl={a.previewUrl}
onImageClick={a.previewUrl ? () => openComposerImageViewer(a.previewUrl!, attachmentName(a)) : undefined}
imageOnly={imageOnly}
name={attachmentName(a)}
meta={attachmentExt(attachmentName(a)) || t("msg.fileAttachment")}
@@ -2409,6 +2444,12 @@ export function Composer({
))}
</div>
)}
<ImageViewer
open={imageViewer.open}
imageUrl={imageViewer.url}
imageName={imageViewer.name}
onClose={closeComposerImageViewer}
/>
{activePastedBlocks.length > 0 && (
<div className="composer__pasted">
{activePastedBlocks.map((block) => {
@@ -12,6 +12,7 @@ export function ComposerContextCard({
removeDisabled = false,
removeIconSize,
previewUrl,
onImageClick,
imageOnly = false,
folder = false,
name,
@@ -26,6 +27,8 @@ export function ComposerContextCard({
removeDisabled?: boolean;
removeIconSize?: number;
previewUrl?: string;
/** called when the image thumbnail is clicked (only for image attachments with previewUrl) */
onImageClick?: () => void;
imageOnly?: boolean;
folder?: boolean;
name?: string;
@@ -46,7 +49,13 @@ export function ComposerContextCard({
<Tooltip label={tooltipLabel}>
<span className="composer-context__label">
{previewUrl ? (
<span className="composer-context__thumb">
<span
className={`composer-context__thumb${onImageClick ? " composer-context__thumb--interactive" : ""}`}
onClick={onImageClick}
role={onImageClick ? "button" : undefined}
tabIndex={onImageClick ? 0 : undefined}
onKeyDown={onImageClick ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onImageClick(); } } : undefined}
>
<img src={previewUrl} alt="" draggable={false} />
</span>
) : variant === "attachment" ? (
@@ -77,6 +77,22 @@ export function formatCacheHitRate(hitTokens: number, missTokens: number): strin
type MetricTone = "accent" | "good" | "notice" | "warn";
type UsageAnalysisView = "source" | "type";
type ContextUsageRefreshFields = Pick<
WireUsage,
"totalTokens" | "promptTokens" | "completionTokens" | "reasoningTokens" | "sessionCacheHitTokens" | "sessionCacheMissTokens"
>;
export function contextUsageRefreshKey(usage?: ContextUsageRefreshFields): string {
if (!usage) return "";
return [
usage.totalTokens ?? 0,
usage.promptTokens ?? 0,
usage.completionTokens ?? 0,
usage.reasoningTokens ?? 0,
usage.sessionCacheHitTokens ?? 0,
usage.sessionCacheMissTokens ?? 0,
].join(":");
}
export function cacheHitTone(hitTokens: number, missTokens: number): MetricTone | undefined {
const denom = hitTokens + missTokens;
@@ -287,6 +303,8 @@ export function ContextPanel({
const [info, setInfo] = useState<ContextPanelInfo | null>(null);
const [analysisView, setAnalysisView] = useState<UsageAnalysisView>("source");
const refreshSeq = useRef(0);
const lastRefreshTime = useRef(0);
const usageRefreshKey = contextUsageRefreshKey(usage);
const refresh = useCallback(async () => {
if (!tabId) return;
@@ -311,19 +329,23 @@ export function ContextPanel({
void refresh();
}, [refresh, refreshKey]);
const hasPanelUsage = Boolean(
(info?.requestCount ?? 0) > 0 ||
(info?.promptTokens ?? 0) > 0 ||
(info?.completionTokens ?? 0) > 0 ||
(info?.totalTokens ?? 0) > 0 ||
(info?.reasoningTokens ?? 0) > 0 ||
(info?.cacheHitTokens ?? 0) > 0 ||
(info?.cacheMissTokens ?? 0) > 0
);
// Refresh the panel snapshot while usage events stream. The key includes
// general token fields so providers without cache telemetry still tick.
useEffect(() => {
if (!usageRefreshKey) return;
const now = Date.now();
if (now - lastRefreshTime.current >= 1000) {
lastRefreshTime.current = now;
void refresh();
}
}, [usageRefreshKey, refresh]);
const usedTokens = context?.used && context.used > 0 ? context.used : info?.usedTokens ?? 0;
const windowTokens = context?.window && context.window > 0 ? context.window : info?.windowTokens ?? 0;
const promptTokens = hasPanelUsage ? info?.promptTokens ?? 0 : usage?.promptTokens ?? 0;
const completionTokens = hasPanelUsage ? info?.completionTokens ?? 0 : usage?.completionTokens ?? 0;
// Prefer live usage props (updated in real-time by the reducer during streaming)
// over the async-fetched info snapshot (only refreshed on turn_done).
const promptTokens = usage?.promptTokens ?? info?.promptTokens ?? 0;
const completionTokens = usage?.completionTokens ?? info?.completionTokens ?? 0;
const totalTokens = info?.totalTokens && info.totalTokens > 0
? info.totalTokens
: sessionTokens && sessionTokens > 0
@@ -331,10 +353,12 @@ export function ContextPanel({
: usage?.totalTokens && usage.totalTokens > 0
? usage.totalTokens
: promptTokens + completionTokens;
const reasoningTokens = hasPanelUsage ? info?.reasoningTokens ?? 0 : usage?.reasoningTokens ?? 0;
const reasoningTokens = usage?.reasoningTokens ?? info?.reasoningTokens ?? 0;
// Session-cumulative values for the top summary.
const sessionCacheHit = info?.sessionCacheHitTokens ?? usage?.sessionCacheHitTokens ?? context?.cacheHitTokens ?? 0;
const sessionCacheMiss = info?.sessionCacheMissTokens ?? usage?.sessionCacheMissTokens ?? context?.cacheMissTokens ?? 0;
// Prefer usage.sessionCacheHitTokens — it is the session-cumulative value from
// the Go agent (includes background tasks) and arrives with every usage event.
const sessionCacheHit = usage?.sessionCacheHitTokens ?? info?.sessionCacheHitTokens ?? context?.cacheHitTokens ?? 0;
const sessionCacheMiss = usage?.sessionCacheMissTokens ?? info?.sessionCacheMissTokens ?? context?.cacheMissTokens ?? 0;
const totalTokensMetric = formatMetricTokens(totalTokens, locale);
const cost = contextCostDisplay({ info, sessionCost, sessionCurrency, usage });
const sourceUsageRows = contextSourceRows(info, sessionCurrency);
@@ -6,6 +6,13 @@ import { app } from "../lib/bridge";
import type { DirEntry } from "../lib/types";
import { VirtualMenu } from "./VirtualMenu";
const FILE_REF_SEARCH_CACHE_TTL_MS = 5000;
type FileRefSearchCacheEntry = {
entries: DirEntry[];
cachedAt: number;
};
export function dirEntrySubmitPath(entry: DirEntry, atDir: string): string {
return entry.path || atDir + entry.name;
}
@@ -58,7 +65,7 @@ export function useFileReferenceMenu(text: string, cwd?: string) {
const [active, setActive] = useState(0);
const [dismissed, setDismissed] = useState(false);
const dirCache = useRef<Record<string, DirEntry[]>>({});
const searchCache = useRef<Record<string, DirEntry[]>>({});
const searchCache = useRef<Record<string, FileRefSearchCacheEntry>>({});
const prevCwdRef = useRef(cwd);
useEffect(() => {
@@ -82,15 +89,17 @@ export function useFileReferenceMenu(text: string, cwd?: string) {
const cached = dirCache.current[atDir];
if (cached) {
setEntries(cached);
return;
} else {
setEntries([]);
}
let live = true;
app
.ListDir(atDir)
.then((next) => {
const list = asArray(next);
if (!live) return;
dirCache.current[atDir] = list;
if (live) setEntries(list);
setEntries(list);
})
.catch(() => {});
return () => {
@@ -105,17 +114,19 @@ export function useFileReferenceMenu(text: string, cwd?: string) {
}
const cached = searchCache.current[atFrag];
if (cached) {
setSearchEntries(cached);
return;
setSearchEntries(cached.entries);
if (Date.now() - cached.cachedAt < FILE_REF_SEARCH_CACHE_TTL_MS) return;
} else {
setSearchEntries([]);
}
setSearchEntries([]);
let live = true;
app
.SearchFileRefs(atFrag)
.then((next) => {
const list = next ?? [];
searchCache.current[atFrag] = list;
if (live) setSearchEntries(list);
const list = asArray(next);
if (!live) return;
searchCache.current[atFrag] = { entries: list, cachedAt: Date.now() };
setSearchEntries(list);
})
.catch(() => {});
return () => {
@@ -470,6 +470,7 @@ export function HistoryPanel({
{!isTrash && s.current && <span className="hist-item__badge hist-item__badge--current">{tr("history.current")}</span>}
{!isTrash && !s.current && s.open && <span className="hist-item__badge hist-item__badge--open">{tr("history.open")}</span>}
{isTrash && <span className="hist-item__badge hist-item__badge--deleted">{tr("history.deleted")}</span>}
{s.recovered && <span className="hist-item__badge">{tr("recovery.badge")}</span>}
{sessionLocation(s, tr) && <span className="hist-item__scope">{sessionLocation(s, tr)}</span>}
<span className="hist-item__metaspacer" />
<span className="hist-item__stat">{tr(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns })}</span>
@@ -0,0 +1,99 @@
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { useT } from "../lib/i18n";
export interface ImageViewerProps {
open: boolean;
/** data-URL of the image to display */
imageUrl: string;
/** optional filename shown at the bottom */
imageName?: string;
onClose: () => void;
}
/**
* ImageViewer renders a full-size image preview in a portal overlay.
*
* It follows the same portal pattern as MermaidDiagram fullscreen:
* the overlay is portaled into .chat-pane (fallback document.body) so it
* covers the chat area without being clipped by overflow containers.
*/
export function ImageViewer({ open, imageUrl, imageName, onClose }: ImageViewerProps) {
const t = useT();
const [portalTarget, setPortalTarget] = useState<Element | null>(null);
const [visible, setVisible] = useState(false);
// Resolve portal target when opening.
useEffect(() => {
if (!open) {
setVisible(false);
setPortalTarget(null);
return;
}
const target = document.querySelector(".chat-pane") ?? document.body;
setPortalTarget(target);
// Trigger enter animation on the next frame.
const raf = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(raf);
}, [open]);
// Close on Escape.
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open, onClose]);
// Prevent body scroll while open.
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
},
[onClose],
);
if (!open || !portalTarget || !imageUrl) return null;
const overlay = (
<div
className={`image-viewer-backdrop${visible ? " image-viewer--enter" : ""}`}
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-label={imageName || t("imageViewer.title")}
>
<button
className="image-viewer__close"
type="button"
onClick={onClose}
aria-label={t("imageViewer.closePreview")}
>
<X size={22} />
</button>
<div className="image-viewer__content">
<img
className="image-viewer__image"
src={imageUrl}
alt={imageName || ""}
draggable={false}
/>
{imageName && <div className="image-viewer__name">{imageName}</div>}
</div>
</div>
);
return createPortal(overlay, portalTarget);
}
+155 -17
View File
@@ -1,4 +1,4 @@
import { memo, useEffect, useRef, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from "react";
import { BrainCircuit, ChevronDown, ChevronRight, FileText, Folder, GitBranch, Image, MessageSquare, Pencil, RotateCcw, ScrollText } from "lucide-react";
import { Markdown } from "./Markdown";
@@ -10,6 +10,7 @@ import type { DisplayAttachment } from "../lib/attachmentDisplay";
import { app } from "../lib/bridge";
import { replaySubmitText } from "../lib/editReplay";
import { useT } from "../lib/i18n";
import { ImageViewer } from "./ImageViewer";
import { Tooltip } from "./Tooltip";
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
import { displayReasoningText } from "../lib/reasoningDisplay";
@@ -81,6 +82,31 @@ function mergeDisplayAttachments(existing: DisplayAttachment[], incoming: Displa
return merged;
}
type PastedBlockInfo = {
label: string;
content: string;
};
const PASTE_LABEL_RE = /\[(?:已粘贴文本|已貼上文字|Pasted text) #\d+ · \d+ (?:行|lines)\]/g;
export function parsePastedBlocks(text: string, submitText?: string): PastedBlockInfo[] {
const labels = text.match(PASTE_LABEL_RE);
if (!labels || labels.length === 0 || !submitText) return [];
const unique = [...new Set(labels)];
const blocks: PastedBlockInfo[] = [];
for (const label of unique) {
const beginMarker = `--- Begin ${label} ---`;
const endMarker = `--- End ${label} ---`;
const beginIdx = submitText.indexOf(beginMarker);
const endIdx = submitText.indexOf(endMarker);
if (beginIdx < 0 || endIdx <= beginIdx) continue;
const contentStart = beginIdx + beginMarker.length;
const content = submitText.slice(contentStart, endIdx).replace(/^\r?\n/, "");
blocks.push({ label, content });
}
return blocks;
}
function MemoryCitations({ citations }: { citations?: MemoryCitation[] }) {
const t = useT();
const bodyRef = useRef<HTMLDivElement>(null);
@@ -181,6 +207,65 @@ export function UserMessage({
const [editSubmitting, setEditSubmitting] = useState(false);
const editRef = useRef<HTMLTextAreaElement>(null);
const [imagePreviews, setImagePreviews] = useState<Record<string, string>>({});
const [imageViewer, setImageViewer] = useState<{ open: boolean; url: string; name: string }>({ open: false, url: "", name: "" });
const openImageViewer = useCallback(async (path: string, name: string) => {
let url = imagePreviews[path];
if (!url) {
try {
url = await app.AttachmentDataURL(path);
setImagePreviews((prev) => (prev[path] ? prev : { ...prev, [path]: url }));
} catch {
return;
}
}
setImageViewer({ open: true, url, name });
}, [imagePreviews]);
const closeImageViewer = useCallback(() => {
setImageViewer((prev) => (prev.open ? { ...prev, open: false } : prev));
}, []);
const pasteBlocks = useMemo(() => parsePastedBlocks(actionText, submitText), [actionText, submitText]);
const [expandedPasteLabels, setExpandedPasteLabels] = useState<Record<string, boolean>>({});
type DisplaySegment =
| { type: "text"; content: string }
| { type: "paste"; block: PastedBlockInfo };
const displaySegments = useMemo((): DisplaySegment[] => {
if (pasteBlocks.length === 0) return [{ type: "text", content: displayText }];
const segments: DisplaySegment[] = [];
// Order blocks by their position in the text so cards appear inline.
const ordered = pasteBlocks
.map((b) => ({ block: b, pos: displayText.indexOf(b.label) }))
.filter((x) => x.pos >= 0)
.sort((a, b) => a.pos - b.pos);
let remaining = displayText;
for (const { block } of ordered) {
const idx = remaining.indexOf(block.label);
if (idx < 0) continue;
// Text before the label: strip the trailing newline that separated the
// label from the preceding line so the card sits tight against the text.
if (idx > 0) {
let before = remaining.slice(0, idx);
before = before.replace(/\n$/, "");
if (before) segments.push({ type: "text", content: before });
}
segments.push({ type: "paste", block });
remaining = remaining.slice(idx + block.label.length);
}
// Strip the leading newline that followed the label.
remaining = remaining.replace(/^\n/, "");
if (remaining.trim()) segments.push({ type: "text", content: remaining });
return segments.length > 0 ? segments : [{ type: "text", content: displayText }];
}, [displayText, pasteBlocks]);
const togglePasteExpand = (label: string) => {
setExpandedPasteLabels((prev) => ({
...prev,
[label]: !prev[label],
}));
};
const orderedDraftAttachments = sortDisplayAttachments(draftAttachments);
const imagePreviewKey = orderedAttachments
.concat(orderedDraftAttachments)
@@ -305,11 +390,12 @@ export function UserMessage({
<ComposerContextCard
key={attachment.path}
variant={attachment.source === "workspace" ? "workspace" : "attachment"}
tooltipLabel={attachment.source === "workspace" ? formatAttachmentRefForSubmit(attachment) : attachment.path}
tooltipLabel={imagePreview ? `${t("imageViewer.clickToPreview")} — ${attachment.path}` : attachment.source === "workspace" ? formatAttachmentRefForSubmit(attachment) : attachment.path}
removeLabel={attachment.source === "workspace" ? t("composer.removeReference") : t("composer.removeImage")}
removeDisabled={editSubmitting}
onRemove={() => removeDraftAttachment(attachment.path)}
previewUrl={imagePreview}
onImageClick={imagePreview ? () => openImageViewer(attachment.path, attachment.name) : undefined}
imageOnly={imageOnly}
folder={attachment.kind === "folder"}
label={attachment.kind === "folder" ? `${attachment.name}/` : attachment.name}
@@ -355,26 +441,78 @@ export function UserMessage({
)}
</div>
) : (
displayText && <div className="msg__text">{displayText}</div>
<>
{displaySegments.map((seg, i) => {
if (seg.type === "text") {
return seg.content ? <div className="msg__text" key={`s${i}`}>{seg.content}</div> : null;
}
const expanded = Boolean(expandedPasteLabels[seg.block.label]);
return (
<div className="msg-pasted" key={seg.block.label}>
<div className="msg-pasted-block">
<div className="msg-pasted-head">
<FileText size={15} />
<span className="msg-pasted-label">{seg.block.label}</span>
<div className="msg-pasted-actions">
<Tooltip label={t(expanded ? "msg.pastedCollapseTooltip" : "msg.pastedExpandTooltip")}>
<button type="button" onClick={() => togglePasteExpand(seg.block.label)}>
{expanded ? t("common.collapse") : t("composer.pastedExpand")}
</button>
</Tooltip>
</div>
</div>
{expanded && (
<div className="msg-pasted-expanded">{seg.block.content}</div>
)}
</div>
</div>
);
})}
</>
)}
{failed && <div className="msg__send-failed">{t("msg.sendFailed")}</div>}
{orderedAttachments.length > 0 && (
<div className="msg-attachments" aria-label={t("msg.attachments")}>
{orderedAttachments.map((attachment, index) => (
<div className={`msg-attachment msg-attachment--${attachment.kind}`} key={`${attachment.path}:${index}`} title={attachment.path}>
<span className={`msg-attachment__icon msg-attachment__icon--${attachment.kind}`} aria-hidden="true">
{attachment.kind === "image" && imagePreviews[attachment.path] ? <img src={imagePreviews[attachment.path]} alt="" draggable={false} /> : attachmentIcon(attachment.kind)}
</span>
<span className="msg-attachment__main">
<span className="msg-attachment__name">{attachment.name}</span>
<span className="msg-attachment__meta">
{attachment.kind === "folder"
? t("msg.folderReference")
: `${attachment.ext || t("msg.fileAttachment")} · ${attachment.source === "workspace" ? t("msg.workspaceReference") : attachment.kind === "image" ? t("msg.imageAttachment") : t("msg.fileAttachment")}`}
{orderedAttachments.map((attachment, index) => {
const isImage = attachment.kind === "image";
const el = (
<div
className={`msg-attachment msg-attachment--${attachment.kind}`}
key={isImage ? undefined : `${attachment.path}:${index}`}
title={isImage ? undefined : attachment.path}
onClick={isImage ? () => openImageViewer(attachment.path, attachment.name) : undefined}
role={isImage ? "button" : undefined}
tabIndex={isImage ? 0 : undefined}
onKeyDown={isImage ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openImageViewer(attachment.path, attachment.name); } } : undefined}
>
<span className={`msg-attachment__icon msg-attachment__icon--${attachment.kind}`} aria-hidden="true">
{isImage && imagePreviews[attachment.path] ? <img src={imagePreviews[attachment.path]} alt="" draggable={false} /> : attachmentIcon(attachment.kind)}
</span>
</span>
</div>
))}
<span className="msg-attachment__main">
<span className="msg-attachment__name">{attachment.name}</span>
<span className="msg-attachment__meta">
{attachment.kind === "folder"
? t("msg.folderReference")
: `${attachment.ext || t("msg.fileAttachment")} · ${attachment.source === "workspace" ? t("msg.workspaceReference") : attachment.kind === "image" ? t("msg.imageAttachment") : t("msg.fileAttachment")}`}
</span>
</span>
</div>
);
if (isImage) {
return (
<Tooltip key={`${attachment.path}:${index}`} label={t("imageViewer.clickToPreview")} block>
{el}
</Tooltip>
);
}
return el;
})}
<ImageViewer
open={imageViewer.open}
imageUrl={imageViewer.url}
imageName={imageViewer.name}
onClose={closeImageViewer}
/>
</div>
)}
</div>
@@ -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,7 +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 title = [label, imSourceTitle, statusLabel, meta, exactTimeLabel].filter(Boolean).join(" · ");
const conflictCopyTitle = isSessionNode && node.recovered ? t("recovery.branch") : "";
const title = [label, conflictCopyTitle, imSourceTitle, statusLabel, meta, exactTimeLabel].filter(Boolean).join(" · ");
const topicMenuOpen = !isSessionNode && menuTopic === topicId;
const pinned = Boolean(node.pinned);
const pinLabel = t(pinned ? "projectTree.unpinTopic" : "projectTree.pinTopic");
@@ -1259,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}`}
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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]);
@@ -15,6 +15,7 @@ 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 { appendTurnActionCopyText } from "../lib/turnActionCopy";
type ToolItem = Extract<Item, { kind: "tool" }>;
type AssistantItem = Extract<Item, { kind: "assistant" }>;
@@ -500,7 +501,7 @@ export function Transcript({
/>,
);
if (!it.streaming && it.text.trim() !== "") {
actionText = it.text;
actionText = appendTurnActionCopyText(actionText, it.text);
actionReady = true;
}
}
@@ -573,7 +574,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;
@@ -926,7 +927,7 @@ function WarmTurnItems({
case "assistant": {
nodes.push(<AssistantMessage key={it.id} item={it} defaultExpanded={false} creationMode={creationMode} />);
if (!it.streaming && it.text.trim() !== "") {
actionText = it.text;
actionText = appendTurnActionCopyText(actionText, it.text);
actionReady = true;
}
break;
@@ -24,6 +24,7 @@ import {
Search,
X,
} from "lucide-react";
import { asArray } from "../lib/array";
import { app } from "../lib/bridge";
import { useT } from "../lib/i18n";
import {
@@ -206,6 +207,7 @@ export function WorkspacePanel({
onPreviewModeChange,
onAddToChat,
onRequestPanelWidth,
onFileTreeRefresh,
refreshKey,
initialViewMode = "files",
revealPathRequest,
@@ -224,6 +226,7 @@ export function WorkspacePanel({
onPreviewModeChange?: (active: boolean) => void;
onAddToChat?: (text: string) => void;
onRequestPanelWidth?: (width: number) => void;
onFileTreeRefresh?: () => void;
refreshKey?: number;
initialViewMode?: "files" | "changed";
revealPathRequest?: WorkspaceRevealRequest | null;
@@ -275,6 +278,8 @@ export function WorkspacePanel({
const workspaceChangesRequestIdRef = useRef(0);
const gitHistoryRequestIdRef = useRef(0);
const commitDetailRequestIdRef = useRef(0);
const dirLoadGenerationRef = useRef(0);
const dirLoadRequestIdsRef = useRef<Record<string, number>>({});
const recentAnchorRef = useRef<HTMLButtonElement>(null);
const openDirsRef = useRef(openDirs);
const pendingTreeRevealPathRef = useRef<string | null>(null);
@@ -284,8 +289,12 @@ export function WorkspacePanel({
}, [openDirs]);
const loadDir = useCallback(async (dir: string) => {
const entries = await app.ListDir(dir).catch(() => []);
setEntriesByDir((prev) => ({ ...prev, [dir]: entries ?? [] }));
const generation = dirLoadGenerationRef.current;
const requestId = (dirLoadRequestIdsRef.current[dir] ?? 0) + 1;
dirLoadRequestIdsRef.current[dir] = requestId;
const entries = await app.ListDir(dir).catch((): DirEntry[] => []);
if (dirLoadGenerationRef.current !== generation || dirLoadRequestIdsRef.current[dir] !== requestId) return;
setEntriesByDir((prev) => ({ ...prev, [dir]: asArray(entries) }));
}, []);
const loadGitHistory = useCallback(async () => {
@@ -399,15 +408,15 @@ export function WorkspacePanel({
setOpenTabs((tabs) => [...tabs.filter((tab) => tab !== path), path].slice(-WORKSPACE_MAX_PREVIEW_TABS));
const dirs = parentDirs(path);
setOpenDirs((prev) => new Set([...Array.from(prev), ...dirs]));
dirs.forEach((dir) => {
if (!entriesByDir[dir]) void loadDir(dir);
});
dirs.forEach((dir) => void loadDir(dir));
},
[entriesByDir, loadDir, openTabs.length, panelWidth, selectedPath, treeVisible],
[loadDir, openTabs.length, panelWidth, selectedPath, treeVisible],
);
useEffect(() => {
if (!open) return;
dirLoadGenerationRef.current += 1;
dirLoadRequestIdsRef.current = {};
setEntriesByDir({});
setOpenDirs(new Set([""]));
setSelectedPath(null);
@@ -503,10 +512,8 @@ export function WorkspacePanel({
setTreeMenu(null);
const dirs = Array.from(new Set(paths.flatMap(parentDirs)));
setOpenDirs((prev) => new Set([...Array.from(prev), ...dirs]));
dirs.forEach((dir) => {
if (!entriesByDir[dir]) void loadDir(dir);
});
}, [entriesByDir, fileListRequest, loadDir, open, scopedFilePaths, viewMode]);
dirs.forEach((dir) => void loadDir(dir));
}, [fileListRequest, loadDir, open, scopedFilePaths, viewMode]);
useEffect(() => {
if (!open || changeListRequest) return;
@@ -649,10 +656,10 @@ export function WorkspacePanel({
void loadWorkspaceChanges();
return;
}
onFileTreeRefresh?.();
const dirs = Array.from(openDirsRef.current);
setEntriesByDir({});
dirs.forEach((dir) => void loadDir(dir));
}, [loadGitHistory, loadWorkspaceChanges, loadDir, viewMode]);
}, [loadGitHistory, loadWorkspaceChanges, loadDir, onFileTreeRefresh, viewMode]);
const refreshSelected = useCallback(() => {
if (!selectedPath) return;
@@ -698,12 +705,12 @@ export function WorkspacePanel({
next.delete(dir);
} else {
next.add(dir);
if (!entriesByDir[dir]) void loadDir(dir);
void loadDir(dir);
}
return next;
});
},
[entriesByDir, loadDir],
[loadDir],
);
const breadcrumbDirs = selectedPath ? parentDirs(selectedPath) : [""];
@@ -1262,6 +1269,7 @@ export function WorkspacePanel({
setFilter("");
showTreeEvenSplit();
setOpenDirs((prev) => new Set([...Array.from(prev), ""]));
void loadDir("");
}}
>
{shortCwd(cwd) || t("workspace.title")}
@@ -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],
);
+360 -35
View File
@@ -48,13 +48,18 @@ import type {
Mode,
ModelInfo,
NetworkView,
PluginInstallOptions,
PluginView,
ProjectNode,
PromptHistoryEntry,
PromptHistoryResult,
ProviderPresetView,
ProviderView,
QuestionAnswer,
ServerView,
SessionMeta,
SessionRecoveryFailedEvent,
SessionRecoveryEvent,
SettingsView,
SkillsSettingsView,
SkillRootView,
@@ -207,6 +212,13 @@ export interface AppBindings {
Capabilities(): Promise<CapabilitiesView>;
MCPServers(): Promise<ServerView[]>;
SkillsSettings(): Promise<SkillsSettingsView>;
Plugins(): Promise<PluginView[]>;
PlanPluginInstall(source: string, options: PluginInstallOptions): Promise<string>;
InstallPlugin(source: string, options: PluginInstallOptions): Promise<string>;
RemovePlugin(name: string): Promise<void>;
SetPluginEnabled(name: string, enabled: boolean): Promise<void>;
UpdatePlugin(name: string): Promise<string>;
PluginDoctor(name: string): Promise<PluginView>;
AddMCPServer(input: MCPServerInput): Promise<number>;
UpdateMCPServer(name: string, input: MCPServerInput): Promise<void>;
RemoveMCPServer(name: string): Promise<void>;
@@ -216,6 +228,7 @@ export interface AppBindings {
TrustMCPServerTools(name: string, toolNames: string[]): Promise<void>;
UntrustMCPServerTool(name: string, toolName: string): Promise<void>;
PickSkillFolder(): Promise<string>;
PickPluginFolder(): Promise<string>;
AddSkillPath(path: string): Promise<void>;
RemoveSkillPath(path: string): Promise<void>;
RefreshSkills(): Promise<void>;
@@ -277,18 +290,24 @@ export interface AppBindings {
SetPlannerModel(ref: string): Promise<void>;
SetSubagentModel(ref: string): Promise<void>;
SetSubagentEffort(level: string): Promise<void>;
SetMaxSubagentDepth(depth: number): Promise<void>;
SetAutoPlan(mode: string): Promise<void>;
SetDefaultToolApprovalMode(mode: string): Promise<void>;
SaveProvider(p: ProviderView): Promise<void>;
SaveProviderWithKey(p: ProviderView, key: string): Promise<string>;
AddOfficialProviderAccess(kind: string, key: string): Promise<string>;
AddProviderPresetAccess(id: string, key: string): Promise<string>;
ResetProviderPresetAccess(id: string): Promise<void>;
FetchProviderModels(p: ProviderView): Promise<string[]>;
DeleteProvider(name: string): Promise<void>;
RemoveProviderAccess(name: string): Promise<void>;
SaveProviderKey(apiKeyEnv: string, value: string): Promise<string>;
SetProviderKey(apiKeyEnv: string, value: string): Promise<string>;
ClearProviderKey(apiKeyEnv: string): Promise<void>;
SetPermissionMode(mode: string): Promise<void>;
AddPermissionRule(list: string, rule: string): Promise<void>;
RemovePermissionRule(list: string, rule: string): Promise<void>;
ReloadSettings(): Promise<void>;
SetSandbox(bash: string, network: boolean, workspaceRoot: string, allowWrite: string[], shell: string): Promise<void>;
SetNetwork(n: NetworkView): Promise<void>;
SetBotSettings(b: BotSettingsView): Promise<void>;
@@ -571,17 +590,31 @@ export function onProjectTreeChanged(cb: () => void): () => void {
return () => {};
}
export function onSessionRecovered(cb: (payload: SessionRecoveryEvent) => void): () => void {
if (realApp() && typeof window !== "undefined" && window.runtime) {
return window.runtime.EventsOn("session:recovered", (payload?: unknown) => cb((payload ?? {}) as SessionRecoveryEvent));
}
return () => {};
}
export function onSessionRecoveryFailed(cb: (payload: SessionRecoveryFailedEvent) => void): () => void {
if (realApp() && typeof window !== "undefined" && window.runtime) {
return window.runtime.EventsOn("session:recovery-failed", (payload?: unknown) => cb((payload ?? {}) as SessionRecoveryFailedEvent));
}
return () => {};
}
// app proxies each call to the live binding (or the dev mock only when truly
// outside the shell), so a late-injected window.go is picked up transparently.
function bridgeBreadcrumb(method: string): string {
if (method === "ReportCrash") return "";
if (/^(Submit|SubmitDisplay|RunShell|Steer|Cancel|Approve|AnswerQuestion|ReplayPendingPrompts)/.test(method))
return `turn ${method}`;
if (/^(SetModel|SetEffort|SetTokenMode|SetDefaultModel|SetPlannerModel|SetSubagentModel|SetSubagentEffort)/.test(method))
if (/^(SetModel|SetEffort|SetTokenMode|SetDefaultModel|SetPlannerModel|SetSubagentModel|SetSubagentEffort|SetMaxSubagentDepth)/.test(method))
return `model ${method}`;
if (/^(SetDesktop|SetCloseBehavior|SetDisplayMode|SetStatusBar|SetExpandThinking|SetAutoPlan|SetDefaultToolApprovalMode|SetMemoryCompilerEnabled|SetReasoningLanguage)/.test(method))
return `settings ${method}`;
if (/^(SaveProvider|AddOfficialProviderAccess|RemoveProviderAccess|DeleteProvider|SetProviderKey|ClearProviderKey|FetchProviderModels|ConnectKey)/.test(method))
if (/^(SaveProvider|AddOfficialProviderAccess|AddProviderPresetAccess|ResetProviderPresetAccess|RemoveProviderAccess|DeleteProvider|SaveProviderKey|SetProviderKey|ClearProviderKey|FetchProviderModels|ConnectKey)/.test(method))
return `provider ${method}`;
if (/^(CheckUpdate|DownloadUpdate|InstallUpdate|ApplyUpdate|OpenDownloadPage)/.test(method)) return `update ${method}`;
if (/^(AddMCPServer|UpdateMCPServer|RemoveMCPServer|ReconnectMCPServer|ClearMCPServerAuthentication|TrustMCPServerTool|TrustMCPServerTools|UntrustMCPServerTool|SetMCPServer)/.test(method))
@@ -695,11 +728,151 @@ function mockScenario(): "demo" | "fresh" | "running" | "guidance" {
return "demo";
}
type MockProviderPresetTemplate = {
id: string;
label: string;
description: string;
keyEnv: string;
provider: ProviderView;
};
function mockProviderTemplate(p: Pick<ProviderView, "name" | "kind" | "baseUrl" | "models" | "default" | "apiKeyEnv"> & Partial<ProviderView>): ProviderView {
return {
name: p.name,
builtIn: false,
added: true,
kind: p.kind,
baseUrl: p.baseUrl,
modelsUrl: p.modelsUrl ?? "",
models: p.models,
visionModels: p.visionModels ?? [],
visionModelsConfigured: Boolean(p.visionModelsConfigured ?? ((p.visionModels ?? []).length > 0)),
default: p.default,
apiKeyEnv: p.apiKeyEnv,
headers: p.headers,
extraBody: p.extraBody,
authHeader: p.authHeader,
keySet: Boolean(p.keySet),
balanceUrl: p.balanceUrl ?? "",
contextWindow: p.contextWindow ?? 0,
reasoningProtocol: p.reasoningProtocol ?? "",
thinking: p.thinking ?? "",
supportedEfforts: p.supportedEfforts ?? [],
defaultEffort: p.defaultEffort ?? "",
modelOverrides: p.modelOverrides,
};
}
function mockPreset(id: string, label: string, description: string, keyEnv: string, provider: ProviderView): MockProviderPresetTemplate {
return { id, label, description, keyEnv, provider };
}
const mockKimiAPIModels = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"];
const mockLongCatModels = ["LongCat-2.0"];
const mockMiMoV25Models = ["mimo-v2.5-pro", "mimo-v2.5"];
const mockMiniMaxModels = ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed"];
const mockGLMAPIModels = ["glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7", "glm-4.7-flash", "glm-4.7-flashx", "glm-4.6", "glm-4.5", "glm-4.5-air", "glm-4.5-flash"];
const mockGLMCodingModels = ["glm-5.2", "glm-5.1", "glm-5", "glm-4.7"];
const mockGLMAnthropicModels = ["glm-5.2[1m]", "glm-5.2", "glm-5.1", "glm-5", "glm-4.7", "glm-4.5-air"];
const mockQwenAPIModels = ["qwen3.7-plus", "qwen3.7-max", "qwen3.6-plus", "qwen3.5-plus", "qwen3-max-2026-01-23", "qwen3-coder-next", "qwen3-coder-plus", "MiniMax-M2.5", "glm-5", "glm-4.7", "kimi-k2.5"];
const mockQwenPlanModels = ["qwen3.7-plus", "qwen3.6-plus", "kimi-k2.5", "glm-5", "MiniMax-M2.5", "qwen3.5-plus", "qwen3-max-2026-01-23", "qwen3-coder-next", "qwen3-coder-plus", "glm-4.7"];
const mockQwenPlanVisionModels = ["qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", "kimi-k2.5"];
const mockStepFunModels = ["step-3.7-flash", "step-3.5-flash", "step-3.5-flash-2603"];
const mockOpenCodeGoModels = ["glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "deepseek-v4-pro", "deepseek-v4-flash", "mimo-v2.5-pro", "mimo-v2.5"];
const mockOpenCodeGoAnthropicModels = ["qwen3.7-plus", "qwen3.7-max", "qwen3.6-plus", "minimax-m3", "minimax-m2.7", "minimax-m2.5"];
const mockOpenCodeZenAnthropicModels = ["claude-sonnet-4-6", "claude-opus-4-8", "claude-haiku-4-5", "qwen3.6-plus", "qwen3.5-plus", "qwen3.6-plus-free"];
const mockNovitaModels = ["zai-org/glm-5.2", "moonshotai/kimi-k2.7-code", "minimax/minimax-m3", "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", "qwen/qwen3.7-max", "qwen/qwen3.6-plus", "zai-org/glm-5v-turbo"];
const mockGMIModels = ["zai-org/GLM-5.2-FP8", "deepseek-ai/DeepSeek-V4-Pro", "deepseek-ai/DeepSeek-V4-Flash", "moonshotai/Kimi-K2.7-Code", "anthropic/claude-sonnet-4.6", "openai/gpt-5.5"];
const mockVercelModels = ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-4.8", "openai/gpt-5.4", "openai/gpt-5.4-pro", "moonshotai/kimi-k2.7-code", "zai/glm-5.2", "deepseek/deepseek-v4-pro"];
const mockOllamaCloudModels = ["glm-5.2", "kimi-k2.7-code", "deepseek-v4-pro", "deepseek-v4-flash", "minimax-m3", "nemotron-3-nano:30b", "qwen3-coder-next"];
const mockProviderPresetTemplates: MockProviderPresetTemplate[] = [
mockPreset("longcat-openai", "LongCat OpenAI", "LongCat Platform OpenAI-compatible endpoint for LongCat-2.0.", "LONGCAT_API_KEY", mockProviderTemplate({ name: "longcat-openai", kind: "openai", baseUrl: "https://api.longcat.chat/openai/v1", modelsUrl: "https://api.longcat.chat/openai/v1/models", models: mockLongCatModels, default: "LongCat-2.0", apiKeyEnv: "LONGCAT_API_KEY", contextWindow: 131072, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
mockPreset("longcat-anthropic", "LongCat Anthropic", "LongCat Platform Anthropic-compatible Messages endpoint for LongCat-2.0.", "LONGCAT_API_KEY", mockProviderTemplate({ name: "longcat-anthropic", kind: "anthropic", baseUrl: "https://api.longcat.chat/anthropic", modelsUrl: "https://api.longcat.chat/anthropic/v1/models", models: mockLongCatModels, default: "LongCat-2.0", apiKeyEnv: "LONGCAT_API_KEY", authHeader: true, contextWindow: 131072, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
mockPreset("kimi-cn", "Kimi CN API", "Moonshot Kimi China OpenAI-compatible API.", "KIMI_API_KEY", mockProviderTemplate({ name: "kimi-cn", kind: "openai", baseUrl: "https://api.moonshot.cn/v1", models: mockKimiAPIModels, visionModels: mockKimiAPIModels, default: "kimi-k2.7-code", apiKeyEnv: "KIMI_API_KEY", balanceUrl: "https://api.moonshot.cn/v1/users/me/balance", contextWindow: 262144, reasoningProtocol: "none" })),
mockPreset("kimi-global", "Kimi Global API", "Moonshot Kimi international OpenAI-compatible API.", "MOONSHOT_API_KEY", mockProviderTemplate({ name: "kimi-global", kind: "openai", baseUrl: "https://api.moonshot.ai/v1", models: mockKimiAPIModels, visionModels: mockKimiAPIModels, default: "kimi-k2.7-code", apiKeyEnv: "MOONSHOT_API_KEY", balanceUrl: "https://api.moonshot.ai/v1/users/me/balance", contextWindow: 262144, reasoningProtocol: "none" })),
mockPreset("kimi-coding-plan", "Kimi Coding Plan", "Kimi Coding Plan via its dedicated Anthropic-compatible endpoint.", "KIMI_CODING_API_KEY", mockProviderTemplate({ name: "kimi-coding-plan", kind: "anthropic", baseUrl: "https://api.kimi.com/coding/", models: ["kimi-for-coding"], visionModels: ["kimi-for-coding"], default: "kimi-for-coding", apiKeyEnv: "KIMI_CODING_API_KEY", headers: { "User-Agent": "claude-code/0.1.0" }, thinking: "adaptive", contextWindow: 262144 })),
mockPreset("mimo-api", "MiMo API", "Xiaomi MiMo direct API with text and vision-capable models.", "MIMO_API_KEY", mockProviderTemplate({ name: "mimo-api", kind: "openai", baseUrl: "https://api.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", contextWindow: 1048576 })),
mockPreset("mimo-anthropic", "MiMo Anthropic", "Xiaomi MiMo direct Anthropic-compatible endpoint.", "MIMO_API_KEY", mockProviderTemplate({ name: "mimo-anthropic", kind: "anthropic", baseUrl: "https://api.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
mockPreset("mimo-token-plan-cn", "MiMo Token Plan CN", "Xiaomi MiMo token-plan China endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-cn", kind: "openai", baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", contextWindow: 1048576 })),
mockPreset("mimo-token-plan-cn-anthropic", "MiMo Token Plan CN Anthropic", "Xiaomi MiMo token-plan China Anthropic-compatible endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-cn-anthropic", kind: "anthropic", baseUrl: "https://token-plan-cn.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
mockPreset("mimo-token-plan-sgp", "MiMo Token Plan SGP", "Xiaomi MiMo token-plan Singapore endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-sgp", kind: "openai", baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", contextWindow: 1048576 })),
mockPreset("mimo-token-plan-sgp-anthropic", "MiMo Token Plan SGP Anthropic", "Xiaomi MiMo token-plan Singapore Anthropic-compatible endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-sgp-anthropic", kind: "anthropic", baseUrl: "https://token-plan-sgp.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
mockPreset("mimo-token-plan-ams", "MiMo Token Plan AMS", "Xiaomi MiMo token-plan Amsterdam endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-ams", kind: "openai", baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", contextWindow: 1048576 })),
mockPreset("mimo-token-plan-ams-anthropic", "MiMo Token Plan AMS Anthropic", "Xiaomi MiMo token-plan Amsterdam Anthropic-compatible endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-ams-anthropic", kind: "anthropic", baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
mockPreset("minimax-cn-api", "MiniMax CN API", "MiniMax China OpenAI-compatible M-series API endpoint.", "MINIMAX_API_KEY", mockProviderTemplate({ name: "minimax-cn-api", kind: "openai", baseUrl: "https://api.minimaxi.com/v1", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_API_KEY", extraBody: { reasoning_split: true }, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
mockPreset("minimax-global-api", "MiniMax Global API", "MiniMax international OpenAI-compatible M-series API endpoint.", "MINIMAX_API_KEY", mockProviderTemplate({ name: "minimax-global-api", kind: "openai", baseUrl: "https://api.minimax.io/v1", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_API_KEY", extraBody: { reasoning_split: true }, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
mockPreset("minimax-cn-anthropic", "MiniMax CN Anthropic", "MiniMax China Anthropic-compatible M-series endpoint.", "MINIMAX_PLAN_API_KEY", mockProviderTemplate({ name: "minimax-cn-anthropic", kind: "anthropic", baseUrl: "https://api.minimaxi.com/anthropic", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_PLAN_API_KEY", authHeader: true, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
mockPreset("minimax-global-anthropic", "MiniMax Global Anthropic", "MiniMax international Anthropic-compatible endpoint with Bearer auth.", "MINIMAX_API_KEY", mockProviderTemplate({ name: "minimax-global-anthropic", kind: "anthropic", baseUrl: "https://api.minimax.io/anthropic", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_API_KEY", authHeader: true, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
mockPreset("glm-cn", "GLM CN API", "Zhipu GLM China OpenAI-compatible API with thinking controls.", "GLM_API_KEY", mockProviderTemplate({ name: "glm-cn", kind: "openai", baseUrl: "https://open.bigmodel.cn/api/paas/v4", models: mockGLMAPIModels, visionModels: ["glm-5v-turbo"], default: "glm-5.2", apiKeyEnv: "GLM_API_KEY", contextWindow: 1000000, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
mockPreset("zai-global", "Z.AI Global API", "Z.AI international OpenAI-compatible GLM API.", "ZAI_API_KEY", mockProviderTemplate({ name: "zai-global", kind: "openai", baseUrl: "https://api.z.ai/api/paas/v4", models: mockGLMAPIModels, visionModels: ["glm-5v-turbo"], default: "glm-5.2", apiKeyEnv: "ZAI_API_KEY", contextWindow: 1000000, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
mockPreset("glm-coding-plan-cn", "GLM Coding Plan CN", "Zhipu GLM China coding-plan endpoint.", "GLM_PLAN_API_KEY", mockProviderTemplate({ name: "glm-coding-plan-cn", kind: "openai", baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", models: mockGLMCodingModels, default: "glm-5.2", apiKeyEnv: "GLM_PLAN_API_KEY", contextWindow: 1000000, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
mockPreset("glm-coding-plan-cn-anthropic", "GLM Coding Plan CN Anthropic", "Zhipu GLM China coding-plan Anthropic-compatible endpoint.", "GLM_PLAN_API_KEY", mockProviderTemplate({ name: "glm-coding-plan-cn-anthropic", kind: "anthropic", baseUrl: "https://open.bigmodel.cn/api/anthropic", models: mockGLMAnthropicModels, default: "glm-5.2[1m]", apiKeyEnv: "GLM_PLAN_API_KEY", authHeader: true, thinking: "adaptive", contextWindow: 1000000 })),
mockPreset("zai-coding-plan-global", "Z.AI Coding Plan Global", "Z.AI international coding-plan endpoint.", "ZAI_CODING_API_KEY", mockProviderTemplate({ name: "zai-coding-plan-global", kind: "openai", baseUrl: "https://api.z.ai/api/coding/paas/v4", models: mockGLMCodingModels, default: "glm-5.2", apiKeyEnv: "ZAI_CODING_API_KEY", contextWindow: 1000000, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
mockPreset("zai-coding-plan-global-anthropic", "Z.AI Coding Plan Global Anthropic", "Z.AI international coding-plan Anthropic-compatible endpoint.", "ZAI_CODING_API_KEY", mockProviderTemplate({ name: "zai-coding-plan-global-anthropic", kind: "anthropic", baseUrl: "https://api.z.ai/api/anthropic", models: mockGLMAnthropicModels, default: "glm-5.2[1m]", apiKeyEnv: "ZAI_CODING_API_KEY", authHeader: true, thinking: "adaptive", contextWindow: 1000000 })),
mockPreset("opencode-go", "OpenCode Go", "OpenCode Go relay with per-model capability overrides.", "OPENCODE_GO_API_KEY", mockProviderTemplate({ name: "opencode-go", kind: "openai", baseUrl: "https://opencode.ai/zen/go/v1", models: mockOpenCodeGoModels, default: "glm-5.2", apiKeyEnv: "OPENCODE_GO_API_KEY", contextWindow: 128000 })),
mockPreset("opencode-go-anthropic", "OpenCode Go Anthropic", "OpenCode Go subscription Anthropic-compatible route for Qwen and MiniMax models.", "OPENCODE_GO_API_KEY", mockProviderTemplate({ name: "opencode-go-anthropic", kind: "anthropic", baseUrl: "https://opencode.ai/zen/go", models: mockOpenCodeGoAnthropicModels, visionModels: ["qwen3.7-plus", "qwen3.6-plus"], default: "qwen3.7-plus", apiKeyEnv: "OPENCODE_GO_API_KEY", thinking: "adaptive", contextWindow: 262144 })),
mockPreset("opencode-zen-anthropic", "OpenCode Zen Anthropic", "OpenCode Zen Anthropic-compatible route for Claude and Qwen models.", "OPENCODE_API_KEY", mockProviderTemplate({ name: "opencode-zen-anthropic", kind: "anthropic", baseUrl: "https://opencode.ai/zen", models: mockOpenCodeZenAnthropicModels, visionModels: ["claude-sonnet-4-6", "claude-opus-4-8", "claude-haiku-4-5"], default: "claude-sonnet-4-6", apiKeyEnv: "OPENCODE_API_KEY", contextWindow: 262144 })),
mockPreset("qwen-cn", "Qwen CN API", "Alibaba DashScope China standard OpenAI-compatible endpoint.", "QWEN_API_KEY", mockProviderTemplate({ name: "qwen-cn", kind: "openai", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", models: mockQwenAPIModels, visionModels: ["qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", "kimi-k2.5"], default: "qwen3.7-plus", apiKeyEnv: "QWEN_API_KEY" })),
mockPreset("qwen-global", "Qwen Global API", "Alibaba DashScope international standard OpenAI-compatible endpoint.", "QWEN_API_KEY", mockProviderTemplate({ name: "qwen-global", kind: "openai", baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", models: mockQwenAPIModels, visionModels: ["qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", "kimi-k2.5"], default: "qwen3.7-plus", apiKeyEnv: "QWEN_API_KEY" })),
mockPreset("qwen-coding-plan-cn", "Qwen Coding Plan CN", "Alibaba Cloud Qwen Coding Plan China endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-cn", kind: "openai", baseUrl: "https://coding.dashscope.aliyuncs.com/v1", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY" })),
mockPreset("qwen-coding-plan-cn-anthropic", "Qwen Coding Plan CN Anthropic", "Alibaba Cloud Qwen Coding Plan China Anthropic-compatible endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-cn-anthropic", kind: "anthropic", baseUrl: "https://coding.dashscope.aliyuncs.com/apps/anthropic", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY", thinking: "adaptive" })),
mockPreset("qwen-coding-plan-global", "Qwen Coding Plan Global", "Alibaba Cloud Qwen Coding Plan international endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-global", kind: "openai", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY" })),
mockPreset("qwen-coding-plan-global-anthropic", "Qwen Coding Plan Global Anthropic", "Alibaba Cloud Qwen Coding Plan international Anthropic-compatible endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-global-anthropic", kind: "anthropic", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY", thinking: "adaptive" })),
mockPreset("stepfun", "StepFun", "StepFun coding-plan OpenAI-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun", kind: "openai", baseUrl: "https://api.stepfun.ai/step_plan/v1", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
mockPreset("stepfun-anthropic", "StepFun Anthropic", "StepFun coding-plan Anthropic-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun-anthropic", kind: "anthropic", baseUrl: "https://api.stepfun.ai/step_plan", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", thinking: "adaptive", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
mockPreset("novita", "NovitaAI", "NovitaAI OpenAI-compatible multi-model gateway.", "NOVITA_API_KEY", mockProviderTemplate({ name: "novita", kind: "openai", baseUrl: "https://api.novita.ai/openai/v1", models: mockNovitaModels, default: "zai-org/glm-5.2", apiKeyEnv: "NOVITA_API_KEY" })),
mockPreset("gmi", "GMI Cloud", "GMI Cloud direct multi-model OpenAI-compatible gateway.", "GMI_API_KEY", mockProviderTemplate({ name: "gmi", kind: "openai", baseUrl: "https://api.gmi-serving.com/v1", models: mockGMIModels, default: "zai-org/GLM-5.2-FP8", apiKeyEnv: "GMI_API_KEY", headers: { "User-Agent": "Reasonix" } })),
mockPreset("vercel-ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway via Anthropic-compatible Messages API.", "AI_GATEWAY_API_KEY", mockProviderTemplate({ name: "vercel-ai-gateway", kind: "anthropic", baseUrl: "https://ai-gateway.vercel.sh", models: mockVercelModels, visionModels: ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-4.8", "openai/gpt-5.4", "openai/gpt-5.4-pro", "moonshotai/kimi-k2.7-code"], default: "anthropic/claude-sonnet-4.6", apiKeyEnv: "AI_GATEWAY_API_KEY", authHeader: true, contextWindow: 1000000 })),
mockPreset("huggingface", "HuggingFace Router", "HuggingFace Inference Router OpenAI-compatible endpoint.", "HF_TOKEN", mockProviderTemplate({ name: "huggingface", kind: "openai", baseUrl: "https://router.huggingface.co/v1", models: ["zai-org/GLM-5.2", "deepseek-ai/DeepSeek-V3.2", "Qwen/Qwen3.5-72B-Instruct"], default: "zai-org/GLM-5.2", apiKeyEnv: "HF_TOKEN" })),
mockPreset("nvidia", "NVIDIA NIM", "NVIDIA NIM OpenAI-compatible accelerated inference endpoint.", "NVIDIA_API_KEY", mockProviderTemplate({ name: "nvidia", kind: "openai", baseUrl: "https://integrate.api.nvidia.com/v1", models: ["nvidia/nemotron-3-nano-30b-a3b", "nvidia/nemotron-3-super-120b-a12b", "nvidia/nemotron-3-ultra-550b-a55b", "deepseek-ai/deepseek-v4-pro", "qwen/qwen3.5-397b-a17b"], default: "nvidia/nemotron-3-nano-30b-a3b", apiKeyEnv: "NVIDIA_API_KEY" })),
mockPreset("kilocode", "KiloCode", "Kilo Code gateway OpenAI-compatible endpoint.", "KILOCODE_API_KEY", mockProviderTemplate({ name: "kilocode", kind: "openai", baseUrl: "https://api.kilo.ai/api/gateway", models: ["kilo/auto"], default: "kilo/auto", apiKeyEnv: "KILOCODE_API_KEY" })),
mockPreset("ollama-cloud", "Ollama Cloud", "Hosted Ollama Cloud OpenAI-compatible endpoint with max reasoning effort.", "OLLAMA_API_KEY", mockProviderTemplate({ name: "ollama-cloud", kind: "openai", baseUrl: "https://ollama.com/v1", models: mockOllamaCloudModels, default: "glm-5.2", apiKeyEnv: "OLLAMA_API_KEY" })),
];
function mockProviderPresetViews(): ProviderPresetView[] {
return [...mockProviderPresetTemplates].sort((a, b) => mockProviderPresetDisplayRank(a.id) - mockProviderPresetDisplayRank(b.id)).map((template) => ({
id: template.id,
label: template.label,
description: template.description,
keyEnv: template.keyEnv,
providerNames: [template.provider.name],
models: [...template.provider.models],
added: false,
status: "available",
statusProviderNames: [],
keySet: false,
requiresKey: true,
configured: false,
}));
}
function mockProviderPresetDisplayRank(id: string): number {
if (id === "glm-cn" || id === "zai-global" || id.startsWith("glm-coding-plan-") || id.startsWith("zai-coding-plan-")) return 0;
if (id.startsWith("longcat-")) return 1;
if (id.startsWith("kimi-")) return 2;
if (id.startsWith("minimax-")) return 3;
return 4;
}
function cloneMockProviderTemplate(id: string, key: string): ProviderView | undefined {
const template = mockProviderPresetTemplates.find((candidate) => candidate.id === id);
if (!template) return undefined;
return {
...JSON.parse(JSON.stringify(template.provider)) as ProviderView,
keySet: Boolean(key.trim()),
};
}
const mockPreviewImageDataURL =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='120' viewBox='0 0 160 120'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%23f97316'/%3E%3Cstop offset='1' stop-color='%232563eb'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='160' height='120' rx='14' fill='url(%23g)'/%3E%3Ccircle cx='44' cy='38' r='16' fill='%23fff7ed' opacity='.9'/%3E%3Cpath d='M18 96 62 58l24 22 18-16 38 32z' fill='%23ffffff' opacity='.9'/%3E%3C/svg%3E";
function makeMockApp(): AppBindings {
const scenario = mockScenario();
const freshMock = scenario === "fresh";
const guidanceMock = scenario === "guidance";
const runningMock = scenario === "running" || guidanceMock;
const mockAttachmentDataURLs = new Map<string, string>();
let cancelled = false;
let pendingAskPreview = false;
let pendingApprovalPreview = false;
@@ -707,6 +880,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.
@@ -788,6 +962,7 @@ function makeMockApp(): AppBindings {
],
},
];
let capPlugins: PluginView[] = [];
const mockSwitchWorkspace = async (path: string) => {
cwd = path || "~";
workspaces = [cwd, ...workspaces.filter((p) => p !== cwd)].slice(0, 12);
@@ -870,37 +1045,64 @@ function makeMockApp(): AppBindings {
subagentEffort: "",
autoPlan: "off",
providers: [
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
],
officialProviders: [
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
],
providerPresets: mockProviderPresetViews(),
permissions: { mode: "ask", allow: ["ls", "read_file"], ask: [], deny: ["Bash(rm:*)"] },
sandbox: { bash: "enforce", network: true, workspaceRoot: "", allowWrite: [], shell: "auto" },
sandbox: { bash: "enforce", network: true, workspaceRoot: "", allowWrite: [], effectiveWorkspaceRoot: cwd, effectiveWriteRoots: [cwd], shell: "auto" },
network: {
proxyMode: "auto",
proxyUrl: "",
noProxy: "",
proxy: { type: "socks5", server: "127.0.0.1", port: 7890, username: "", password: "" },
},
agent: { temperature: 0.2, maxSteps: 0, plannerMaxSteps: 0, systemPrompt: "You are Reasonix, a coding agent.", coldResumePrune: true, reasoningLanguage: "auto" },
agent: { temperature: 0.2, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, systemPrompt: "You are Reasonix, a coding agent.", coldResumePrune: true, reasoningLanguage: "auto" },
bot: {
enabled: !freshMock,
model: "",
toolApprovalMode: "ask",
maxSteps: 25,
debounceMs: 1500,
queueMode: "steer",
queueCap: 20,
queueDrop: "summarize",
ignoreSelfMessages: true,
selfUserIds: {
qq: [],
feishu: [],
weixin: [],
},
control: {
enabled: false,
addr: "127.0.0.1:37913",
tokenEnv: "REASONIX_BOT_CONTROL_TOKEN",
},
pairing: {
enabled: true,
requestTtlMinutes: 60,
maxPendingPerPlatform: 3,
},
routes: [],
allowlist: {
enabled: true,
allowAll: false,
qqUsers: [],
feishuUsers: freshMock ? [] : ["ou_mock_user_001"],
weixinUsers: freshMock ? [] : ["wxid_mock_user_001"],
qqApprovers: [],
feishuApprovers: [],
weixinApprovers: [],
qqAdmins: [],
feishuAdmins: [],
weixinAdmins: [],
qqGroups: [],
feishuGroups: [],
weixinGroups: [],
},
qq: { enabled: false, appId: "", appSecretEnv: "QQ_BOT_APP_SECRET", secretSet: false, sandbox: false },
qq: { enabled: false, appId: "", appSecretEnv: "QQ_BOT_APP_SECRET", secretSet: false, sandbox: false, model: "", toolApprovalMode: "ask", workspaceRoot: "", access: { enabled: true, allowAll: false, pairingEnabled: true, users: [], groups: [], approvers: [], admins: [] } },
feishu: {
enabled: false,
domain: "feishu",
@@ -927,10 +1129,11 @@ function makeMockApp(): AppBindings {
label: "kun",
enabled: true,
status: "connected",
model: "",
toolApprovalMode: "",
workspaceRoot: "",
credential: {
model: "",
toolApprovalMode: "",
workspaceRoot: "",
access: { enabled: true, allowAll: false, pairingEnabled: true, users: ["ou_mock_user_001"], groups: [], approvers: [], admins: [] },
credential: {
appId: "cli_mock_lark",
appSecretEnv: "FEISHU_BOT_APP_SECRET",
accountId: "",
@@ -961,10 +1164,11 @@ function makeMockApp(): AppBindings {
label: "kun",
enabled: true,
status: "connected",
model: "",
toolApprovalMode: "",
workspaceRoot: "",
credential: {
model: "",
toolApprovalMode: "",
workspaceRoot: "",
access: { enabled: true, allowAll: false, pairingEnabled: true, users: ["wxid_mock_user_001"], groups: [], approvers: [], admins: [] },
credential: {
appId: "",
appSecretEnv: "",
accountId: "default",
@@ -1004,7 +1208,7 @@ function makeMockApp(): AppBindings {
metrics: true,
memoryCompilerEnabled: true,
configPath: "~/projects/reasonix/reasonix.toml",
providerKinds: ["openai"],
providerKinds: ["openai", "anthropic"],
autoApproveTools: false,
bypass: false,
};
@@ -2155,6 +2359,7 @@ function makeMockApp(): AppBindings {
{ name: "model", description: "Switch model", kind: "builtin" as const },
{ name: "effort", description: "Set reasoning effort", kind: "builtin" as const },
{ name: "skill", description: "List skills", kind: "builtin" as const },
{ name: "plugins", description: "Manage plugin packages", kind: "builtin" as const },
{ name: "explore", description: "Investigate the codebase in an isolated subagent", kind: "skill" as const },
{ name: "review", description: "Review the staged diff", hint: "[focus]", kind: "custom" as const },
];
@@ -2164,6 +2369,7 @@ function makeMockApp(): AppBindings {
servers: capServers.map((s) => ({ ...s })),
skills: capSkills.map((s) => ({ ...s })),
skillRoots: capSkillRoots.map((s) => ({ ...s })),
plugins: capPlugins.map((p) => ({ ...p })),
};
},
async MCPServers() {
@@ -2175,6 +2381,59 @@ function makeMockApp(): AppBindings {
skillRoots: capSkillRoots.map((s) => ({ ...s })),
};
},
async Plugins() {
return capPlugins.map((p) => ({ ...p }));
},
async PlanPluginInstall(source: string, options: PluginInstallOptions) {
const name = options.name || source.split("/").filter(Boolean).pop()?.replace(/\.git$/, "") || "plugin";
return JSON.stringify({
ok: true,
status: "planned",
kind: "plugin",
actions: [{ kind: "plugin", action: "install_plugin_package", name, source, status: "planned" }],
});
},
async InstallPlugin(source: string, options: PluginInstallOptions) {
const name = options.name || source.split("/").filter(Boolean).pop()?.replace(/\.git$/, "") || "plugin";
const existing = capPlugins.findIndex((p) => p.name === name);
const view: PluginView = {
name,
version: "dev",
description: "Mock plugin",
source,
root: `~/.reasonix/plugins/${name}`,
manifestKind: "reasonix",
enabled: true,
skills: 1,
hooks: 0,
mcpServers: 0,
skillDetails: [{ name: "plan", description: "Plan work before implementation", invocation: "/plan", runAs: "inline" }],
};
if (existing >= 0) capPlugins[existing] = view;
else capPlugins.push(view);
return JSON.stringify({ ok: true, status: "done", kind: "plugin", actions: [{ kind: "plugin", name }] });
},
async RemovePlugin(name: string) {
capPlugins = capPlugins.filter((p) => p.name !== name);
},
async SetPluginEnabled(name: string, enabled: boolean) {
capPlugins = capPlugins.map((p) => p.name === name ? { ...p, enabled } : p);
},
async UpdatePlugin(name: string) {
capPlugins = capPlugins.map((p) => p.name === name ? { ...p, version: p.version || "dev" } : p);
return JSON.stringify({ ok: true, status: "done", kind: "plugin", name });
},
async PluginDoctor(name: string) {
return capPlugins.find((p) => p.name === name) || {
name,
root: "",
enabled: false,
skills: 0,
hooks: 0,
mcpServers: 0,
error: "plugin is not installed",
};
},
async AddMCPServer(input: MCPServerInput) {
const tools = input.transport === "stdio" ? 3 : 5;
capServers.push({
@@ -2281,6 +2540,9 @@ function makeMockApp(): AppBindings {
async PickSkillFolder() {
return "~/my-skills";
},
async PickPluginFolder() {
return "~/plugins/superpowers";
},
async AddSkillPath(path: string) {
const dir = path.trim() || "~/my-skills";
if (!capSkillRoots.some((r) => r.scope === "custom" && r.dir === dir)) {
@@ -2451,14 +2713,20 @@ function makeMockApp(): AppBindings {
async RevealPath(path: string) {
console.info("mock RevealPath", path);
},
async SavePastedImage(_dataUrl: string) {
return ".reasonix/attachments/mock.png";
async SavePastedImage(dataUrl: string) {
const path = `.reasonix/attachments/mock-${mockAttachmentDataURLs.size + 1}.png`;
mockAttachmentDataURLs.set(path, dataUrl);
return path;
},
async SaveClipboardImage() {
return ".reasonix/attachments/mock-clipboard.png";
const path = `.reasonix/attachments/mock-clipboard-${mockAttachmentDataURLs.size + 1}.png`;
mockAttachmentDataURLs.set(path, mockPreviewImageDataURL);
return path;
},
async SavePastedFile(name: string, _dataUrl: string) {
return `.reasonix/attachments/mock-${name}`;
async SavePastedFile(name: string, dataUrl: string) {
const path = `.reasonix/attachments/mock-${name}`;
mockAttachmentDataURLs.set(path, dataUrl);
return path;
},
async PickExportFile(defaultFilename: string, _mimeType: string) {
return defaultFilename;
@@ -2485,10 +2753,12 @@ function makeMockApp(): AppBindings {
const tokenName = name.replace(/[^\w.-]+/g, "-") || "folder";
return { kind: "workspace" as const, path: `__reasonix_external_folder/mock/${tokenName}`, isDir: true, displayPath: path };
}
return { kind: "attachment" as const, path: `.reasonix/attachments/mock-${name}` };
const attachmentPath = `.reasonix/attachments/mock-${name}`;
mockAttachmentDataURLs.set(attachmentPath, mockPreviewImageDataURL);
return { kind: "attachment" as const, path: attachmentPath };
},
async AttachmentDataURL(_path: string) {
return "data:image/png;base64,iVBORw0KGgo=";
async AttachmentDataURL(path: string) {
return mockAttachmentDataURLs.get(path) ?? mockPreviewImageDataURL;
},
async Models() {
const active = mockTabs.find((tab) => tab.active) ?? mockTabs[0];
@@ -2687,6 +2957,9 @@ function makeMockApp(): AppBindings {
async SetSubagentEffort(level: string) {
settings.subagentEffort = level;
},
async SetMaxSubagentDepth(depth: number) {
settings.agent = { ...settings.agent, maxSubagentDepth: depth <= 1 ? 1 : 2 };
},
async SetAutoPlan(mode: string) {
settings.autoPlan = mode;
},
@@ -2699,9 +2972,17 @@ function makeMockApp(): AppBindings {
if (i >= 0) settings.providers[i] = p;
else settings.providers.push(p);
},
async SaveProviderWithKey(p: ProviderView, key: string) {
p.added = true;
p.keySet = Boolean(key.trim()) || p.keySet;
const i = settings.providers.findIndex((x) => x.name === p.name);
if (i >= 0) settings.providers[i] = p;
else settings.providers.push(p);
return "";
},
async AddOfficialProviderAccess(kind: string, key: string) {
const templates: Record<string, ProviderView> = {
deepseek: { name: "deepseek", builtIn: true, added: true, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: !!key.trim(), balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
deepseek: { name: "deepseek", builtIn: true, added: true, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: !!key.trim(), balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
};
const next = templates[kind];
if (!next) throw new Error(`unknown official provider template ${kind}`);
@@ -2710,12 +2991,47 @@ function makeMockApp(): AppBindings {
else settings.providers.push(next);
return "";
},
async AddProviderPresetAccess(id: string, key: string) {
const preset = settings.providerPresets.find((p) => p.id === id);
if (!preset) throw new Error(`unknown provider preset ${id}`);
const next = cloneMockProviderTemplate(id, key);
if (!next) throw new Error(`unknown provider preset ${id}`);
const i = settings.providers.findIndex((x) => x.name === next.name);
if (i >= 0) settings.providers[i] = { ...settings.providers[i], ...next, keySet: next.keySet || settings.providers[i].keySet };
else settings.providers.push(next);
preset.added = true;
preset.status = "installed";
preset.statusProviderNames = [...preset.providerNames];
preset.keySet = preset.keySet || !!key.trim();
preset.configured = !preset.requiresKey || preset.keySet;
return "";
},
async ResetProviderPresetAccess(id: string) {
const preset = settings.providerPresets.find((p) => p.id === id);
if (!preset) throw new Error(`unknown provider preset ${id}`);
const next = cloneMockProviderTemplate(id, "");
if (!next) throw new Error(`unknown provider preset ${id}`);
const i = settings.providers.findIndex((x) => x.name === next.name);
if (i < 0) throw new Error(`provider preset ${id} cannot be reset because no same-name provider exists`);
const existing = settings.providers[i];
settings.providers[i] = {
...next,
added: true,
keySet: existing.apiKeyEnv === next.apiKeyEnv ? existing.keySet : next.keySet,
};
preset.added = true;
preset.status = "installed";
preset.statusProviderNames = [...preset.providerNames];
preset.keySet = preset.keySet || settings.providers[i].keySet;
preset.configured = !preset.requiresKey || preset.keySet;
},
async FetchProviderModels(p: ProviderView) {
if (!p.baseUrl.trim()) throw new Error(t("settings.fetchModelsMissingBaseUrl"));
if (providerRequiresKey(p) && !p.apiKeyEnv.trim()) throw new Error(t("settings.fetchModelsMissingKeyEnv"));
await delay(350);
if (p.baseUrl.includes("deepseek")) return ["deepseek-v4-flash", "deepseek-v4-pro"];
if (p.baseUrl.includes("mimo") || p.baseUrl.includes("xiaomimimo")) return ["mimo-v2.5", "mimo-v2.5-pro"];
if (p.baseUrl.includes("token-plan")) return ["mimo-v2.5", "mimo-v2.5-pro"];
if (p.baseUrl.includes("xiaomimimo")) return ["mimo-v2.5-pro", "mimo-v2.5"];
return ["gpt-5", "gpt-5-mini", "qwen3-coder"];
},
async DeleteProvider(name: string) {
@@ -2726,6 +3042,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;
@@ -2748,8 +3070,10 @@ function makeMockApp(): AppBindings {
const k = list as "allow" | "ask" | "deny";
settings.permissions[k] = settings.permissions[k].filter((r) => r !== rule);
},
async ReloadSettings() {},
async SetSandbox(bash: string, network: boolean, workspaceRoot: string, allowWrite: string[], shell: string) {
settings.sandbox = { bash, network, workspaceRoot, allowWrite, shell };
const effectiveWorkspaceRoot = workspaceRoot.trim() || cwd;
settings.sandbox = { bash, network, workspaceRoot, allowWrite, effectiveWorkspaceRoot, effectiveWriteRoots: [effectiveWorkspaceRoot, ...allowWrite], shell };
},
async SetNetwork(n: NetworkView) {
settings.network = n;
@@ -2823,10 +3147,11 @@ function makeMockApp(): AppBindings {
label: domain === "lark" ? "Lark" : domain === "weixin" ? "微信" : "飞书",
enabled: true,
status: "connected",
model: "",
toolApprovalMode: "",
workspaceRoot: "",
credential: {
model: "",
toolApprovalMode: "",
workspaceRoot: "",
access: { enabled: true, allowAll: false, pairingEnabled: true, users: [provider === "weixin" ? "wxid_mock_user_001" : "ou_mock_user_001"], groups: [], approvers: [], admins: [] },
credential: {
appId: provider === "feishu" ? "cli_mock" : "",
appSecretEnv: provider === "feishu" ? (domain === "lark" ? "LARK_BOT_APP_SECRET" : "FEISHU_BOT_APP_SECRET") : "",
accountId: provider === "weixin" ? "mock-account" : "",
@@ -2875,11 +3200,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
@@ -2971,7 +3296,7 @@ function makeMockApp(): AppBindings {
},
async OpenDownloadPage() {
if (typeof window !== "undefined") {
window.open("https://github.com/esengine/reasonix/releases/latest", "_blank", "noopener");
window.open("https://reasonix.io/#start", "_blank", "noopener");
}
},
// Dev seam: drives the overlay flow in the browser until ConnectKey sets the
+25 -4
View File
@@ -4,11 +4,19 @@ export interface Toast {
id: number;
text: string;
level: "info" | "warn" | "error";
actionLabel?: string;
onAction?: () => void;
}
export interface ToastOptions {
actionLabel?: string;
onAction?: () => void;
durationMs?: number;
}
export interface ToastContextValue {
toasts: Toast[];
showToast: (text: string, level?: Toast["level"]) => void;
showToast: (text: string, level?: Toast["level"], options?: ToastOptions) => void;
}
const ToastContext = createContext<ToastContextValue>({ toasts: [], showToast: () => {} });
@@ -23,13 +31,13 @@ export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
const showToast = useCallback((text: string, level: Toast["level"] = "info") => {
const showToast = useCallback((text: string, level: Toast["level"] = "info", options: ToastOptions = {}) => {
const id = nextId++;
setToasts((prev) => [...prev, { id, text, level }]);
setToasts((prev) => [...prev, { id, text, level, actionLabel: options.actionLabel, onAction: options.onAction }]);
const timer = setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
timers.current.delete(id);
}, 2500);
}, options.durationMs ?? (options.actionLabel ? 8000 : 2500));
timers.current.set(id, timer);
}, []);
@@ -49,6 +57,19 @@ export function ToastProvider({ children }: { children: ReactNode }) {
{t.level === "warn" && <span className="toast__icon">⚠️</span>}
{t.level === "error" && <span className="toast__icon">❌</span>}
<span className="toast__text">{t.text}</span>
{t.actionLabel && t.onAction && (
<button
type="button"
className="toast__action"
onClick={(event) => {
event.stopPropagation();
dismissToast(t.id);
t.onAction?.();
}}
>
{t.actionLabel}
</button>
)}
</div>
))}
</div>
@@ -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}`;
}
+155 -1
View File
@@ -208,6 +208,10 @@ export interface TabMeta {
goal?: string;
goalStatus?: GoalStatus;
autoResearch?: AutoResearchCompactView;
recovered?: boolean;
recoveryReason?: string;
recoveryDigest?: string;
recoveryParentId?: string;
startupErr?: string;
active: boolean;
cwd: string;
@@ -228,6 +232,10 @@ export interface ProjectNode {
running?: boolean;
status?: ProjectTopicStatus;
pinned?: boolean;
recovered?: boolean;
recoveryReason?: string;
recoveryDigest?: string;
recoveryParentId?: string;
children?: ProjectNode[];
}
@@ -239,6 +247,23 @@ export interface TopicMeta {
createdAt: number;
}
export interface SessionRecoveryEvent {
originalPath?: string;
recoveryPath: string;
scope?: string;
workspaceRoot?: string;
topicId?: string;
topicTitle?: string;
recoveryReason?: string;
recoveryDigest?: string;
recoveryParentId?: string;
existing?: boolean;
}
export interface SessionRecoveryFailedEvent {
reason?: "lease_held" | "lease_unavailable" | string;
}
export interface ContextPanelInfo {
usedTokens: number;
windowTokens: number;
@@ -388,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.
@@ -643,6 +669,7 @@ export interface ServerView {
tools: number;
prompts: number;
resources: number;
hasTools?: boolean;
error?: string;
toolList?: MCPToolView[];
trustedReadOnlyTools?: string[];
@@ -683,11 +710,55 @@ export interface CapabilitiesView {
servers: ServerView[];
skills: SkillView[];
skillRoots: SkillRootView[];
plugins: PluginView[];
}
export interface SkillsSettingsView {
skills: SkillView[];
skillRoots: SkillRootView[];
}
export interface PluginView {
name: string;
version?: string;
description?: string;
source?: string;
root: string;
manifestKind?: string;
enabled: boolean;
skills: number;
hooks: number;
mcpServers: number;
skillDetails?: PluginSkillView[];
hookDetails?: PluginHookView[];
mcpServerDetails?: PluginMCPServerView[];
warnings?: string[];
error?: string;
}
export interface PluginSkillView {
name: string;
description?: string;
path?: string;
invocation?: string;
runAs?: string;
}
export interface PluginHookView {
event: string;
match?: string;
command?: string;
contextFile?: string;
description?: string;
}
export interface PluginMCPServerView {
name: string;
transport?: string;
command?: string;
url?: string;
}
export interface PluginInstallOptions {
dryRun?: boolean;
link?: boolean;
replace?: boolean;
name?: string;
}
export interface MCPServerInput {
name: string;
transport: string; // stdio | http | sse
@@ -791,7 +862,7 @@ export interface MemoryView {
}
// SettingsTab is the top-level navigation item in the Settings Centre modal.
export type SettingsTab = "general" | "models" | "providers" | "bots" | "mcp" | "skills" | "memory" | "hooks" | "shortcuts" | "permissions" | "sandbox" | "network" | "appearance" | "updates";
export type SettingsTab = "general" | "models" | "providers" | "bots" | "mcp" | "skills" | "plugins" | "memory" | "hooks" | "shortcuts" | "permissions" | "sandbox" | "network" | "appearance" | "updates";
// Settings panel payloads (desktop/settings_app.go).
export interface ProviderView {
@@ -808,6 +879,8 @@ export interface ProviderView {
default: string;
apiKeyEnv: string;
headers?: Record<string, string> | null; // optional extra request headers for compatible gateways
extraBody?: Record<string, unknown> | null; // optional extra top-level request body fields for compatible gateways
authHeader?: boolean; // Anthropic-compatible: send Authorization: Bearer instead of x-api-key
keySet: boolean; // the env var currently resolves to a value
requiresKey?: boolean; // false for explicit no-auth providers
configured?: boolean; // selectable: key is set or no key is required
@@ -816,11 +889,29 @@ export interface ProviderView {
balanceUrl: string; // optional wallet-balance endpoint; "" disables the readout
contextWindow: number;
reasoningProtocol: string; // auto|deepseek|openai|none; empty = auto/model registry
thinking: string; // provider-specific thinking override: ""|enabled|disabled|adaptive
supportedEfforts: string[]; // custom /effort levels; empty = use built-in Kind/BaseURL default
defaultEffort: string; // /effort level when user picks "auto" or unset; "" = supportedEfforts[0]
modelOverrides?: ProviderModelOverrideView[] | null;
}
export interface ProviderPresetView {
id: string;
label: string;
description: string;
keyEnv: string;
providerNames: string[];
models: string[];
added: boolean;
status?: "available" | "installed" | "installed_modified" | "name_conflict" | "similar_existing";
statusProviderNames?: string[];
keySet: boolean;
requiresKey?: boolean;
configured?: boolean;
keySource?: string;
keySourcePath?: string;
}
export interface ProviderModelOverrideView {
model: string;
reasoningProtocol: string;
@@ -859,6 +950,8 @@ export interface SandboxView {
network: boolean;
workspaceRoot: string;
allowWrite: string[];
effectiveWorkspaceRoot: string;
effectiveWriteRoots: string[];
shell: string; // "auto" | "bash" | "powershell" | "pwsh"
}
@@ -881,6 +974,7 @@ export interface AgentView {
temperature: number;
maxSteps: number;
plannerMaxSteps: number;
maxSubagentDepth: number;
systemPrompt: string;
coldResumePrune: boolean;
reasoningLanguage: string; // "auto" | "zh" | "en"
@@ -892,17 +986,67 @@ export interface BotAllowlistView {
qqUsers: string[];
feishuUsers: string[];
weixinUsers: string[];
qqApprovers: string[];
feishuApprovers: string[];
weixinApprovers: string[];
qqAdmins: string[];
feishuAdmins: string[];
weixinAdmins: string[];
qqGroups: string[];
feishuGroups: string[];
weixinGroups: string[];
}
export interface BotAccessView {
enabled: boolean;
allowAll: boolean;
pairingEnabled: boolean;
users: string[];
groups: string[];
approvers: string[];
admins: string[];
}
export interface BotSelfUserIDsView {
qq: string[];
feishu: string[];
weixin: string[];
}
export interface BotPairingView {
enabled: boolean;
requestTtlMinutes: number;
maxPendingPerPlatform: number;
}
export interface BotControlView {
enabled: boolean;
addr: string;
tokenEnv: string;
}
export interface BotRouteView {
connectionId: string;
platform: string;
chatType: string;
chatId: string;
userId: string;
threadId: string;
model: string;
toolApprovalMode: ToolApprovalMode | "" | string;
workspaceRoot: string;
}
export interface QQBotView {
enabled: boolean;
appId: string;
appSecretEnv: string;
secretSet: boolean;
sandbox: boolean;
model: string;
toolApprovalMode: ToolApprovalMode | "" | string;
workspaceRoot: string;
access: BotAccessView;
}
export interface FeishuBotView {
@@ -955,6 +1099,7 @@ export interface BotConnectionView {
model: string;
toolApprovalMode: ToolApprovalMode | "" | string;
workspaceRoot: string;
access: BotAccessView;
credential: BotConnectionCredentialView;
sessionMappings: BotConnectionSessionMappingView[];
lastError: string;
@@ -968,6 +1113,14 @@ export interface BotSettingsView {
toolApprovalMode: ToolApprovalMode | "" | string;
maxSteps: number;
debounceMs: number;
queueMode: string;
queueCap: number;
queueDrop: string;
ignoreSelfMessages: boolean;
selfUserIds: BotSelfUserIDsView;
control: BotControlView;
pairing: BotPairingView;
routes: BotRouteView[];
allowlist: BotAllowlistView;
qq: QQBotView;
feishu: FeishuBotView;
@@ -1043,6 +1196,7 @@ export interface SettingsView {
autoPlan: string;
providers: ProviderView[];
officialProviders: ProviderView[];
providerPresets: ProviderPresetView[];
permissions: PermissionsView;
sandbox: SandboxView;
network: NetworkView;
+149 -52
View File
@@ -168,6 +168,7 @@ type RuntimeMetaSnapshot = {
running: boolean;
pendingPrompt?: boolean;
backgroundJobs?: number;
cancelRequested?: boolean;
cancellable?: boolean;
};
@@ -354,6 +355,18 @@ type Action =
| { type: "clearAsk" }
| { type: "reset" };
function backendStatusFromRuntimeMeta(meta: RuntimeMetaSnapshot): Extract<Action, { type: "backend_status" }> {
const foregroundRunning = foregroundRunningFromRuntimeMeta(meta);
return {
type: "backend_status",
running: foregroundRunning,
pendingPrompt: Boolean(meta.pendingPrompt),
backgroundJobs: meta.backgroundJobs ?? 0,
cancelRequested: Boolean(meta.cancelRequested),
cancellable: foregroundRunning,
};
}
// ---- reducer helpers (unchanged logic) ----
export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: string, startSeq = 0): { items: Item[]; seq: number } {
@@ -940,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) });
@@ -1060,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" });
@@ -1107,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)),
@@ -1129,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); });
})();
@@ -1192,6 +1241,22 @@ export function useController() {
return tabs.find((tab) => tab.active) ?? tabs[0];
}, []);
const dispatchRuntimeStatusForTab = useCallback((tabId: string, tab: RuntimeMetaSnapshot) => {
const foregroundRunning = foregroundRunningFromRuntimeMeta(tab);
dispatchTo(tabId, {
type: "backend_status",
running: foregroundRunning,
pendingPrompt: Boolean(tab.pendingPrompt),
backgroundJobs: tab.backgroundJobs ?? 0,
cancelRequested: Boolean(tab.cancelRequested),
cancellable: foregroundRunning,
});
// backend_status reconciliation can clear a live prompt from frontend state.
// If the backend is still blocked, ask it to replay the approval/ask event.
if (tab.pendingPrompt) replayPendingPromptsForActiveTab(tabId);
return foregroundRunning;
}, [dispatchTo]);
const waitForTabReady = useCallback(async (tabId: string): Promise<void> => {
for (let attempt = 0; attempt < 60; attempt += 1) {
const tabs = asArray(await app.ListTabs().catch(() => [] as TabMeta[]));
@@ -1216,12 +1281,14 @@ export function useController() {
confirmBackendActiveTab(active.id);
dispatchTo(active.id, { type: "optimistic_meta", meta: metaFromTab(active, statesRef.current.get(active.id)?.meta) });
const preserveCachedHistory = options.preserveCachedHistory ?? !reset;
if (!reset) dispatchRuntimeStatusForTab(active.id, active);
await loadSessionDataForTab(active.id, reset, "startup", {
preserveCachedHistory,
sessionPath: active.sessionPath,
});
if (reset) dispatchRuntimeStatusForTab(active.id, active);
return active.id;
}, [activeTabFromBackend, confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
}, [activeTabFromBackend, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab]);
const reconcileTabRuntime = useCallback(async (
tabId: string,
@@ -1233,19 +1300,8 @@ export function useController() {
if (!tab) return undefined;
const local = statesRef.current.get(tabId);
const needsInitialLoad = !local?.meta;
const foregroundRunning = foregroundRunningFromRuntimeMeta(tab);
const foregroundRunning = dispatchRuntimeStatusForTab(tabId, tab);
const missedTurnDone = Boolean(local?.running && !foregroundRunning);
dispatchTo(tabId, {
type: "backend_status",
running: foregroundRunning,
pendingPrompt: Boolean(tab.pendingPrompt),
backgroundJobs: tab.backgroundJobs ?? 0,
cancelRequested: Boolean(tab.cancelRequested),
cancellable: foregroundRunning,
});
// backend_status reconciliation can clear a live prompt from frontend state.
// If the backend is still blocked, ask it to replay the approval/ask event.
if (tab.pendingPrompt) replayPendingPromptsForActiveTab(tabId);
if (hydrateSessionData && (needsInitialLoad || missedTurnDone)) {
await loadSessionDataForTab(tabId, missedTurnDone, "startup");
return tabs;
@@ -1259,7 +1315,7 @@ export function useController() {
if (effort) dispatchTo(tabId, { type: "effort", effort });
if (balance) dispatchTo(tabId, { type: "balance", balance });
return tabs;
}, [dispatchTo, loadSessionDataForTab]);
}, [dispatchRuntimeStatusForTab, loadSessionDataForTab]);
const clearCancelReconcileTimer = useCallback((tabId: string) => {
const timer = cancelReconcileTimers.current.get(tabId);
@@ -1422,9 +1478,10 @@ export function useController() {
setActiveTabId(active.id);
activeTabIdRef.current = active.id;
confirmBackendActiveTab(active.id);
dispatchRuntimeStatusForTab(active.id, active);
return sendToTab(active.id, displayText, submitText);
});
}, [activeTabFromBackend, activeTabId, confirmBackendActiveTab, sendToTab]);
}, [activeTabFromBackend, activeTabId, confirmBackendActiveTab, dispatchRuntimeStatusForTab, sendToTab]);
const runShell = useCallback(async (command: string) => {
if (!activeTabId) throw new Error("workspace is still starting");
@@ -1688,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) });
@@ -1731,11 +1793,13 @@ export function useController() {
setActiveTabId(tab.id);
activeTabIdRef.current = tab.id;
confirmBackendActiveTab(tab.id);
dispatchRuntimeStatusForTab(tab.id, tab);
// The fork's controller builds in a background goroutine: an immediate
// load reads empty history, and the ready-event fallback can still
// target the source tab, leaving the fork blank (#3742).
await waitForTabReady(tab.id);
await loadSessionDataForTab(tab.id, true);
await reconcileTabRuntime(tab.id, { hydrateSessionData: false });
} else {
await syncActiveTabFromBackend(true);
}
@@ -1758,7 +1822,7 @@ export function useController() {
} finally {
dispatchTo(sourceTabId, { type: "message_action_done" });
}
}, [activeTabId, confirmBackendActiveTab, dispatchTo, loadSessionDataForTab, syncActiveTabFromBackend, waitForBackendActiveTab, waitForTabReady]);
}, [activeTabId, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime, syncActiveTabFromBackend, waitForBackendActiveTab, waitForTabReady]);
// Tab management: switch preserves per-tab state; open creates it.
const switchTab = useCallback(async (tabId: string, optimisticTab?: TabMeta): Promise<TabMeta[] | undefined> => {
@@ -1772,11 +1836,25 @@ export function useController() {
dispatchTo(tabId, { type: "backend_activation_start" });
if (optimisticTab) {
dispatchTo(tabId, { type: "optimistic_meta", meta: metaFromTab(optimisticTab, statesRef.current.get(tabId)?.meta) });
const optimisticStatus = backendStatusFromRuntimeMeta(optimisticTab);
if (optimisticStatus.running) dispatchTo(tabId, optimisticStatus);
}
dispatchTo(tabId, { type: "hydrate_start", reason: "switch-tab" });
addBreadcrumb("tab.switch", `active-rendered ${tabId} ms=${Date.now() - startedAt}`);
const backendActivation = app.SetActiveTab(tabId)
.then(() => {
.then(async () => {
if (activeTabIdRef.current !== tabId) {
const currentTabId = activeTabIdRef.current;
if (currentTabId) {
await app.SetActiveTab(currentTabId).then(() => {
if (activeTabIdRef.current === currentTabId) confirmBackendActiveTab(currentTabId);
}).catch((err) => {
addBreadcrumb("tab.switch", `set-active-stale-reassert-failed ${currentTabId}: ${errorMessage(err)}`);
});
}
addBreadcrumb("tab.switch", `set-active-stale ${tabId} current=${currentTabId ?? ""} ms=${Date.now() - startedAt}`);
return false;
}
confirmBackendActiveTab(tabId);
addBreadcrumb("tab.switch", `set-active-done ${tabId} ms=${Date.now() - startedAt}`);
return true;
@@ -1820,13 +1898,16 @@ export function useController() {
activeTabIdRef.current = meta.id;
confirmBackendActiveTab(meta.id);
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
void loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
dispatchRuntimeStatusForTab(meta.id, meta);
const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
placeholderItems: isNewTab ? prevItems : undefined,
preserveCachedHistory,
sessionPath: meta.sessionPath,
});
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
else void load;
return meta;
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
const openGlobalTab = useCallback(async (topicId: string): Promise<TabMeta> => {
const meta = await app.OpenGlobalTab(topicId);
@@ -1838,13 +1919,16 @@ export function useController() {
activeTabIdRef.current = meta.id;
confirmBackendActiveTab(meta.id);
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
void loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
dispatchRuntimeStatusForTab(meta.id, meta);
const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
placeholderItems: isNewTab ? prevItems : undefined,
preserveCachedHistory,
sessionPath: meta.sessionPath,
});
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
else void load;
return meta;
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
const openTopicSession = useCallback(async (scope: string, workspaceRoot: string, topicId: string, sessionPath: string): Promise<TabMeta> => {
const meta = await app.OpenTopicSession(scope, workspaceRoot, topicId, sessionPath);
@@ -1856,13 +1940,16 @@ export function useController() {
activeTabIdRef.current = meta.id;
confirmBackendActiveTab(meta.id);
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
void loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
dispatchRuntimeStatusForTab(meta.id, meta);
const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
placeholderItems: isNewTab ? prevItems : undefined,
preserveCachedHistory,
sessionPath: meta.sessionPath,
});
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
else void load;
return meta;
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
const activateTopic = useCallback(async (scope: string, workspaceRoot: string, topicId: string, sessionPath = ""): Promise<TabMeta> => {
const meta = await app.ActivateTopic(scope, workspaceRoot, topicId, sessionPath);
@@ -1876,21 +1963,28 @@ export function useController() {
activeTabIdRef.current = meta.id;
confirmBackendActiveTab(meta.id);
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
void loadSessionDataForTab(meta.id, true, "open-topic", { placeholderItems: prevItems });
dispatchRuntimeStatusForTab(meta.id, meta);
void loadSessionDataForTab(meta.id, true, "open-topic", { placeholderItems: prevItems })
.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false }))
.catch(() => {});
return meta;
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
// Ensure a blank tab exists for the given scope — reuses an existing one
// or creates a new tab, then loads its session data.
const ensureBlankTab = useCallback(async (scope: string, workspaceRoot: string): Promise<TabMeta> => {
const meta = await app.EnsureBlankTab(scope, workspaceRoot);
const isNewTab = !statesRef.current.has(meta.id);
setActiveTabId(meta.id);
activeTabIdRef.current = meta.id;
confirmBackendActiveTab(meta.id);
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
void loadSessionDataForTab(meta.id, !statesRef.current.has(meta.id), "open-topic");
dispatchRuntimeStatusForTab(meta.id, meta);
const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic");
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
else void load;
return meta;
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
const ensureBlankSurface = useCallback(async (scope: string, workspaceRoot: string): Promise<TabMeta> => {
const meta = await app.EnsureBlankSurface(scope, workspaceRoot);
@@ -1901,9 +1995,12 @@ export function useController() {
activeTabIdRef.current = meta.id;
confirmBackendActiveTab(meta.id);
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
void loadSessionDataForTab(meta.id, true, "open-topic");
dispatchRuntimeStatusForTab(meta.id, meta);
void loadSessionDataForTab(meta.id, true, "open-topic")
.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false }))
.catch(() => {});
return meta;
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
const closeTab = useCallback(async (tabId: string) => {
try {
@@ -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]);
}
+288 -30
View File
@@ -205,6 +205,7 @@ export const en = {
"caps.mcpSummary": "{connected} connected · {failed} issues · {tools} tools",
"caps.connectorsTab": "MCP servers",
"caps.skillsTab": "Agent skills",
"caps.pluginsTab": "Plugins",
"caps.servers": "MCP servers",
"caps.noServers": "No MCP servers configured. Add one with /mcp add.",
"caps.availableServers": "Available MCPs",
@@ -311,6 +312,62 @@ export const en = {
"caps.trustedReadOnlyTitle": "Remembered as read-only for future planning and research",
"caps.searchSkills": "Search skills…",
"caps.noSkillMatches": "No matching skills.",
"caps.pluginRefresh": "Refresh plugins",
"caps.pluginInstallTitle": "Install plugin",
"caps.pluginInstallHint": "Add a local plugin folder or install from a Git repository.",
"caps.pluginInstallMethod": "Plugin install method",
"caps.pluginInstallLocal": "Local folder",
"caps.pluginInstallGit": "Git repository",
"caps.pluginSource": "Plugin source",
"caps.pluginGitSource": "Git repository URL",
"caps.pluginSourcePlaceholder": "git:github.com/obra/superpowers",
"caps.pluginChooseLocalFolder": "Choose plugin folder",
"caps.pluginLocalFolder": "Local plugin folder",
"caps.pluginNoLocalFolder": "No folder selected",
"caps.pluginInstallName": "Install name (optional)",
"caps.pluginInstallNamePlaceholder": "install name (optional)",
"caps.pluginLink": "Developer mode: link source folder",
"caps.pluginLinkHint": "Do not copy the plugin; use the selected folder directly. Useful for plugin debugging. Moving or deleting the folder breaks the plugin.",
"caps.pluginReplace": "Overwrite same-name plugin",
"caps.pluginReplaceHint": "When a plugin with the same name already exists, allow this source to replace it.",
"caps.pluginPreview": "Preview",
"caps.pluginInstall": "Install plugin",
"caps.noPluginsTitle": "No plugins installed",
"caps.noPluginsHint": "Install a plugin package from Git or a local path to add skills, hooks, and MCP servers through one managed unit.",
"caps.installedPlugins": "Installed plugins",
"caps.pluginsSummary": "{enabled} enabled · {total} installed · {issues} issues",
"caps.pluginCounts": "{skills} skills · {hooks} hooks · {mcps} MCPs",
"caps.pluginNoCapabilities": "No exported capabilities",
"caps.pluginWarnings": "{count} warnings",
"caps.pluginEnable": "Enable plugin",
"caps.pluginDisable": "Disable plugin",
"caps.pluginEnabled": "Enabled",
"caps.pluginDisabled": "Disabled",
"caps.pluginVersion": "Version",
"caps.pluginRoot": "Install path",
"caps.pluginUpdate": "Update",
"caps.pluginDoctor": "Doctor",
"caps.pluginRemove": "Remove plugin",
"caps.pluginConfirmRemove": "Confirm remove",
"caps.pluginPlanError": "Install check failed",
"caps.pluginPlanReady": "Install plan",
"caps.pluginAction": "Action",
"caps.pluginPlanStatus": "Plugin operation: {status}",
"caps.pluginPlanComplete": "Plugin operation complete",
"caps.pluginPlanInstalled": "Plugin installed. Expand it below to see how to use the exported skills, hooks, and MCP servers.",
"caps.pluginUsageTitle": "How to use",
"caps.pluginUsageEnabledHint": "Enabled plugins load into new sessions. Use /skills to browse skills, invoke /<skill>, or ask naturally; hooks and MCP servers run through the normal session lifecycle.",
"caps.pluginUsageDisabledHint": "Enable this plugin before its skills, hooks, or MCP servers can participate in sessions.",
"caps.pluginNoCapabilityDetails": "No detailed capability inventory is available for this plugin.",
"caps.pluginSkillsTitle": "Skills",
"caps.pluginSkillsHint": "Browse with /skills, invoke directly, or ask for the workflow by name.",
"caps.pluginHooksTitle": "Hooks",
"caps.pluginHooksHint": "Hooks trigger automatically on matching lifecycle events.",
"caps.pluginMCPTitle": "MCP servers",
"caps.pluginMCPHint": "Tools from these servers are available through the normal MCP/tool flow.",
"caps.pluginNoDescription": "No description",
"caps.pluginHookNoTarget": "No hook target",
"caps.pluginMCPNoTarget": "No launch target",
"caps.sources": "Sources",
"caps.sourcesSummaryActive": "{active} active",
"caps.sourcesSummaryMissing": "{missing} missing",
@@ -432,6 +489,9 @@ export const en = {
"composer.pastedExpand": "Expand",
"composer.pastedRemove": "Remove pasted text",
"composer.removeImage": "Remove image",
"imageViewer.clickToPreview": "Click to preview",
"imageViewer.closePreview": "Close preview",
"imageViewer.title": "Image preview",
"composer.attachImageFailed": "Image paste failed",
"composer.attachFileFailed": "File attach failed",
"composer.attachDropFailed": "Dropped file attach failed",
@@ -472,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",
@@ -671,6 +737,18 @@ export const en = {
"history.historySessionActions": "History session actions",
"history.trashActions": "Trash actions",
// session recovery
"recovery.branch": "Conflict copy",
"recovery.badge": "Copy",
"recovery.open": "Open conflict copy",
"recovery.toast": "A save conflict was detected, so a conflict copy was kept for {title}.",
"recovery.failedLease": "The conflict copy is in use by another runtime, so this tab did not switch to it.",
"recovery.failedUnavailable": "The conflict copy could not be opened, so this tab stayed on the original session.",
"recovery.banner": "This session is a save-conflict copy. Compare it with the original before continuing.",
"recovery.reason": "Reason: {reason}",
"recovery.digest": "Digest: {digest}",
"recovery.parent": "Parent: {parent}",
// project tree
"projectTree.workspaceTitle": "Projects",
"projectTree.pinnedTitle": "Pinned",
@@ -685,6 +763,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",
@@ -844,6 +923,7 @@ export const en = {
"settings.tab.capabilities": "MCP & Skills",
"settings.tab.mcp": "MCP & Tools",
"settings.tab.skills": "Skills",
"settings.tab.plugins": "Plugins",
"settings.tab.memory": "Memory",
"settings.tab.hooks": "Hooks",
"settings.tab.shortcuts": "Shortcuts",
@@ -859,6 +939,7 @@ export const en = {
"settings.tabSub.capabilities": "Servers, tools & skills",
"settings.tabSub.mcp": "Servers & tools",
"settings.tabSub.skills": "Sources & enablement",
"settings.tabSub.plugins": "Packages & diagnostics",
"settings.tabSub.memory": "Project & global",
"settings.tabSub.hooks": "Shell automation",
"settings.tabSub.shortcuts": "Keys & help",
@@ -940,6 +1021,10 @@ export const en = {
"settings.subagentEffort": "Subagent effort",
"settings.subagentEffortDefault": "auto (provider default)",
"settings.subagentHint": "Applies to task and runAs=subagent skills unless a tool call or skill-specific config overrides it.",
"settings.subagentDepth": "Subagent nesting depth",
"settings.subagentDepthHint": "Controls whether a first-layer subagent may dispatch one more reviewer or worker subagent.",
"settings.subagentDepthOne": "1 layer",
"settings.subagentDepthTwo": "2 layers",
"settings.defaultToolApprovalMode": "New session approval",
"settings.defaultToolApprovalModeHint": "Saved to your global settings file and used only for new sessions; switching or restoring an existing session will not overwrite its Ask / Auto / Yolo state.",
"settings.defaultToolApprovalMode.ask": "Ask",
@@ -962,7 +1047,7 @@ export const en = {
"settings.coldResumePrune.on": "On",
"settings.coldResumePrune.off": "Off",
"settings.reasoningLanguage": "Thinking language",
"settings.reasoningLanguageHint": "Affects visible reasoning only. Auto follows the conversation language without injecting a language instruction.",
"settings.reasoningLanguageHint": "Affects visible reasoning only. Auto anchors clear Chinese prompts; referenced context is ignored.",
"settings.reasoningLanguage.auto": "Auto",
"settings.reasoningLanguage.zh": "中文",
"settings.reasoningLanguage.en": "English",
@@ -1069,11 +1154,76 @@ export const en = {
"settings.toggleOff": "Off",
"settings.botSetup": "Bot entry",
"settings.botSetupHint": "Enable the entry, then connect the chat channels you need.",
"settings.botConnectPhoneTitle": "Add IM bot",
"settings.botConnectPhoneSubtitle": "Manual setup for QQ, QR connection for Feishu, Lark, and WeChat.",
"settings.botGateway": "Bot gateway",
"settings.botGatewayHint": "Manage the IM bot gateway, model, and runtime parameters from the desktop app.",
"settings.botGatewayHint": "Manage the IM bot gateway master switch and connection status from the desktop app.",
"settings.botBrowserPreviewWarning": "This is a browser preview. It saves mock settings only and does not write local config or start the QQ/IM bot gateway. Configure it in the desktop app or CLI before testing from QQ.",
"settings.botSimpleEyebrow": "Guided setup",
"settings.botSimpleTitle": "Mobile access and chat bots",
"settings.botSimpleHint": "Scan from a phone to control the current workspace. For longer-lived access from chat apps, pick a Bot Channel and configure it.",
"settings.botMobileRemoteEyebrow": "Separate entry",
"settings.botMobileRemoteTitle": "Mobile remote control",
"settings.botMobileRemoteHint": "Scan or copy a link to open the current workspace control entry from a mobile device. This is configured separately from IM bot channels.",
"settings.botMobileRemoteControlApi": "Control link",
"settings.botMobileRemoteDisabled": "Enable it to generate the QR code and link",
"settings.botMobileRemoteEnableFirst": "Enable to generate QR",
"settings.botMobileRemoteReady": "Waiting for phone connection",
"settings.botMobileRemoteWaiting": "Connect by phone scan",
"settings.botMobileRemoteLoopbackNotice": "The control API is still bound to local loopback, so this is for local preview for now. Cross-device relay can reuse this entry later.",
"settings.botMobileRemoteCopyLink": "Copy link",
"settings.botMobileRemoteCopied": "Copied",
"settings.botMobileRemoteCopyFailed": "Copy failed",
"settings.botChannelEntryTitle": "Use Bot Channel",
"settings.botChannelEntryHint": "Connect chat bots for longer mobile sessions.",
"settings.botManageBots": "Bot management",
"settings.botManageBotsHint": "Choose a channel on the left. If it is not configured, scan or add keys; once configured, manage that bot's runtime settings on the right.",
"settings.botBackToOverview": "Back to entry",
"settings.botChannelOpenConfigured": "Configured. You can continue editing settings.",
"settings.botChannelNeedsSetup": "Not configured",
"settings.botChannelTabsLabel": "Bot channels",
"settings.botChannelSetupTitle": "Configure {provider}",
"settings.botChannelSetupHint": "Before setup, only credentials or scan binding are needed. After it succeeds, this becomes the bot's settings page.",
"settings.botSetupStatus": "Bot setup status",
"settings.botSetupStatusConnected": "{online}/{total} online",
"settings.botSetupStatusAccessTrusted": "Trusted list {count}",
"settings.botSetupStatusAccessEveryone": "Everyone allowed",
"settings.botSetupStatusAccessPerBot": "{configured}/{total} bots configured",
"settings.botMode": "Bot settings mode",
"settings.botModeSimple": "Simple",
"settings.botModeAdvanced": "Advanced",
"settings.botShowAdvancedSettings": "Advanced settings",
"settings.botHideAdvancedSettings": "Hide advanced settings",
"settings.botAdvancedSettingsHint": "Lower-frequency controls: routes, queue details, and bot echo filtering.",
"settings.botShowAddBot": "Add channel",
"settings.botStepConnectTitle": "Connect bots",
"settings.botStepConnectHint": "Manual keys for QQ; scan a QR code for Feishu, Lark, and WeChat.",
"settings.botStepConnectEmpty": "Not connected yet",
"settings.botSharedAllowlistHint": "Every channel shares the trusted list and pairing settings from step 2. Edit once and it applies everywhere.",
"settings.botSharedAllowlistGo": "Edit in step 2",
"settings.botGlobalAllowlist": "Legacy global allowlist",
"settings.botGlobalAllowlistHint": "Compatibility for older configs; new bots should set access in their own detail panel.",
"settings.botAccessShowAllPlatforms": "Show all platforms",
"settings.botAccessShowConnectedOnly": "Show connected platforms only",
"settings.botListUsers": "Users",
"settings.botListGroups": "Groups",
"settings.botListApprovers": "Approvers",
"settings.botListAdmins": "Admins",
"settings.botPlatformFeishuLark": "Feishu / Lark",
"settings.botQueueDropLabel": "When the queue is full",
"settings.botQueueDropHint": "How overflow messages are handled once the queue hits its cap.",
"settings.botPairingDetailHint": "The pairing switch lives in step 2 (Who can use it). Tune only the code lifetime and pending cap here.",
"settings.botSimpleAccessTitle": "Who can use it",
"settings.botSimpleAccessTrustedSummary": "Trusted list is active with {count} member or group entries.",
"settings.botSimpleAccessEveryoneSummary": "Everyone can use it right now. Best for short tests only.",
"settings.botAccessTrusted": "Trusted list",
"settings.botAccessTrustedHint": "Only listed users or groups can call the bot.",
"settings.botAccessEveryone": "Everyone",
"settings.botAccessEveryoneHint": "Convenient for quick testing, but not recommended long term.",
"settings.botAccessPairing": "Pair private chats",
"settings.botAccessPairingHint": "Unknown direct messages get a code and wait for local approval.",
"settings.botRoleAccess": "Admins and approvers",
"settings.botRoleAccessHint": "Optional. Leave this empty unless remote approval or management is needed.",
"settings.botQueueModeSimple": "When a new message arrives",
"settings.botQueueModeSimpleHint": "Choose what happens when the bot is already handling a previous message.",
"settings.saveBotSettings": "Save bot settings",
"settings.botEnableBot": "Enable bot",
"settings.botGatewayMasterSwitch": "Master switch",
@@ -1084,8 +1234,48 @@ export const en = {
"settings.botModelHint": "Leave empty to use the default model.",
"settings.botModelDefault": "Use default model",
"settings.botRuntime": "Runtime limits",
"settings.botRuntimeHint": "Gateway-level limits that control how far each bot task can run.",
"settings.botMaxSteps": "Max steps",
"settings.botDebounceMs": "Debounce ms",
"settings.botQueueCap": "Queue cap",
"settings.botQueueMode.steer": "Steer running turn",
"settings.botQueueMode.followup": "Follow up",
"settings.botQueueMode.collect": "Collect",
"settings.botQueueMode.interrupt": "Interrupt",
"settings.botQueueDrop.summarize": "Summarize overflow",
"settings.botQueueDrop.old": "Drop oldest",
"settings.botQueueDrop.new": "Drop newest",
"settings.botIgnoreSelfMessages": "Ignore bot echoes",
"settings.botIgnoreSelfMessagesHint": "Prevents the gateway from reacting to messages sent by configured bot identities.",
"settings.botSelfUserIds": "Bot self IDs",
"settings.botSelfUserIdsHint": "Optional user/open IDs for each platform, used to filter bot echo messages.",
"settings.botPairing": "Private chat pairing",
"settings.botPairingTTL": "TTL minutes",
"settings.botPairingMaxPending": "Max pending",
"settings.botControlApi": "Local control API",
"settings.botControlApiHint": "Loopback HTTP API for status and controlled outbound sends; requires a Bearer token from the named env var.",
"settings.botControlAddr": "Control address",
"settings.botControlTokenEnv": "Token env",
"settings.botGatewayDefaults": "Gateway runtime settings",
"settings.botGatewayDefaultsHint": "Tune queueing, pairing, and self-message filtering without editing TOML.",
"settings.botRoutes": "Bind chats to projects",
"settings.botRoutesHint": "Pin a group, contact, or thread to a workspace, model, or approval mode.",
"settings.botRoutesEmpty": "No bindings yet. Add one to bind a chat, user, or thread to a project or model.",
"settings.botRouteTitle": "Route {n}",
"settings.botAddRoute": "Add route",
"settings.botRouteConnection": "Bot source",
"settings.botRouteAny": "Any",
"settings.botRoutePlatform": "Platform",
"settings.botRouteChatType": "Chat type",
"settings.botRouteChatType.any": "Any",
"settings.botRouteChatType.dm": "DM",
"settings.botRouteChatType.group": "Group",
"settings.botRouteChatType.guild": "Guild",
"settings.botRouteChatType.direct": "Direct",
"settings.botRouteChatType.thread": "Thread",
"settings.botRouteChatId": "Group/chat ID",
"settings.botRouteUserId": "Contact ID",
"settings.botRouteThreadId": "Thread ID",
"settings.botChannels": "Connect channels",
"settings.botChannelsHint": "Pick a channel; QQ uses manual setup, while Feishu, Lark, and WeChat connect by QR.",
"settings.botQQHint": "QQ official Bot API v2.",
@@ -1140,8 +1330,7 @@ export const en = {
"settings.botInstallLarkHint": "Scan to create a Lark PersonalAgent.",
"settings.botInstallWeixinHint": "Scan WeChat and save a local token.",
"settings.botConnectedBots": "Connected bots",
"settings.botConnectedBotsSummary": "{online}/{total} online",
"settings.botConnectionsEmpty": "No bot channel is connected yet.",
"settings.botSelectBotHint": "Select a bot on the left to configure it.",
"settings.botConnectionColumnChannel": "Channel",
"settings.botConnectionColumnName": "Bot",
"settings.botConnectionColumnRemote": "Remote ID",
@@ -1159,16 +1348,17 @@ export const en = {
"settings.botConnectionConnected": "Connected",
"settings.botConnectionConfigured": "Configured",
"settings.botConnectionDisconnected": "Disconnected",
"settings.botManage": "Advanced settings",
"settings.botManage": "Edit config",
"settings.deleteBot": "Delete bot",
"settings.deleteBotShort": "Delete",
"settings.confirmDeleteBot": "Confirm delete",
"settings.deleteBotHint": "Removes this bot from connected bots; saved secrets are not cleared automatically.",
"settings.botChannelModel": "Model",
"settings.botChannelModelHint": "Leave empty to use the bot default model.",
"settings.botChannelModelAuto": "Auto",
"settings.botChannelModelHint": "The model this bot uses when handling messages.",
"settings.botChannelModelAuto": "Use current desktop model",
"settings.botToolApprovalMode": "Tool approval",
"settings.botToolApprovalModeHint": "Controls whether this channel asks before running tools; plan confirmation and ask questions still wait for a reply.",
"settings.botToolApprovalMode.inherit": "Inherit",
"settings.botToolApprovalMode.inherit": "Follow bot",
"settings.botToolApprovalMode.ask": "Ask",
"settings.botToolApprovalMode.auto": "Auto",
"settings.botToolApprovalMode.yolo": "YOLO",
@@ -1191,26 +1381,17 @@ export const en = {
"settings.botDiagnosticReportFailed": "Failed to send diagnostic report.",
"settings.botDiagnosticPrivacy": "Only sanitized connection metadata is included.",
"settings.botAccessControl": "Access control",
"settings.botAccessControlHint": "Whitelist is the default; switch to everyone for quick testing.",
"settings.botAccessMode": "Who can use it",
"settings.botAccessAll": "Everyone",
"settings.botAccessWhitelist": "Whitelist only",
"settings.botEditAllowlist": "Edit allowlist",
"settings.botAllowlist": "Allowlist",
"settings.botAllowlistHint": "Restrict which users or groups can invoke the bot.",
"settings.botAllowlistEnabled": "Enable allowlist",
"settings.botAllowAll": "Allow everyone",
"settings.botAllowAllWarn": "When enabled, every incoming channel message can invoke the bot.",
"settings.botQQAccessRequired": "Add a QQ user/group allowlist, or temporarily allow everyone in Advanced settings, before enabling QQ Bot.",
"settings.botAllowlistEntries": "Users and groups",
"settings.botQQAccessRequired": "Add QQ users/groups in step 2 (Who can use it), or temporarily allow everyone, before enabling QQ Bot.",
"settings.botQQUsers": "QQ users",
"settings.botQQGroups": "QQ groups",
"settings.botFeishuUsers": "Feishu users",
"settings.botFeishuGroups": "Feishu groups",
"settings.botFeishuLarkUsers": "Feishu / Lark users",
"settings.botFeishuLarkGroups": "Feishu / Lark groups",
"settings.botWeixinUsers": "WeChat users",
"settings.botWeixinGroups": "WeChat groups",
"settings.botListPlaceholder": "One ID per line, or comma-separated",
"settings.permissions": "Permissions",
"settings.writerMode": "Writer mode",
@@ -1222,17 +1403,23 @@ export const en = {
"settings.yoloHint": "YOLO-approves approval-gated tool calls (writers, bash). Deny rules still block, and ask questions plus plan approvals still wait for you. Runtime-only — resets on relaunch.",
"settings.addRule": "add {list} rule…",
"settings.sandboxTitle": "Sandbox & workspace",
"settings.sandboxBoundaryHint": "Sandbox write roots limit where approved Bash and file tools can write; they do not auto-approve commands. If Bash sandbox is enforced but no OS backend is available, shell commands are refused; switch Bash sandbox off only for trusted workspaces.",
"settings.reloadSessionConfig": "Apply manual config changes",
"settings.reloadSessionConfigHint": "Changes made in Settings apply automatically; use this only after manually editing config.toml or reasonix.toml.",
"settings.shellInterpreter": "Shell interpreter",
"settings.shellAuto": "auto (bash, else PowerShell on Windows)",
"settings.shellAuto": "auto (bash first)",
"settings.shellBash": "bash",
"settings.shellPowershell": "PowerShell (Windows PowerShell)",
"settings.shellPwsh": "pwsh (PowerShell 7+)",
"settings.bashSandbox": "Bash sandbox",
"settings.bashEnforce": "enforce (jail bash)",
"settings.bashEnforce": "enforce (jail bash; fail closed)",
"settings.bashOff": "off (run unconfined)",
"settings.allowNetwork": "Allow network egress from sandboxed bash",
"settings.workspaceRoot": "Workspace root",
"settings.workspaceDefault": "(default: cwd)",
"settings.effectiveWriteRoots": "Effective write roots",
"settings.effectiveWriteRootsHint": "Expanded for the active workspace. Settings changes apply automatically; use the button above after manually editing config files.",
"settings.noEffectiveWriteRoots": "No active write roots",
"settings.appearance": "Appearance",
"settings.theme": "Theme",
"settings.themeStyle": "Visual style",
@@ -1318,6 +1505,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",
@@ -1340,6 +1530,7 @@ export const en = {
"settings.pageDesc.bots": "Configure QQ, Feishu, Lark, and WeChat bot channels, including each bot's model and runtime parameters.",
"settings.pageDesc.mcp": "Manage MCP server connections and tool discovery.",
"settings.pageDesc.skills": "Browse, enable, and manage agent skills and skill sources.",
"settings.pageDesc.plugins": "Install and manage plugin packages that can provide skills, hooks, and MCP servers.",
"settings.pageDesc.memory": "Manage preferences, project conventions, and instruction files Reasonix keeps referencing.",
"settings.pageDesc.hooks": "Configure shell hooks around turns, tool calls, compaction, and session lifecycle.",
"settings.pageDesc.shortcuts": "Customize keyboard shortcuts and keep the help sheet in sync.",
@@ -1354,7 +1545,7 @@ export const en = {
"settings.customProviders": "Custom providers",
"settings.customProvidersHint": "OpenAI-compatible or proxy providers you add yourself. These can be edited, deleted, and updated with fetched model lists.",
"settings.customProvidersEmpty": "No custom providers yet. Prefer the built-in access above for common services.",
"settings.providerAccessEmptyHint": "Click “Add provider” to choose DeepSeek or a custom OpenAI-compatible access.",
"settings.providerAccessEmptyHint": "Use Recommended preset to quickly add common providers, usually with only an API key. Use Custom provider for OpenAI-compatible or Anthropic-compatible endpoints.",
"settings.builtinProviderBadge": "built-in",
"settings.customProviderBadge": "custom",
"settings.configureProvider": "Configure",
@@ -1366,24 +1557,77 @@ export const en = {
"settings.searchModels": "Search models...",
"settings.noMatchingModels": "No matching models",
"settings.addProvider.chooseTitle": "Add provider",
"settings.addProvider.chooseHint": "Choose an official preset, or add a custom OpenAI-compatible access.",
"settings.addProvider.officialChoice": "Official provider",
"settings.addProvider.officialChoiceHint": "Choose from Reasonix presets for DeepSeek.",
"settings.addProvider.chooseHint": "Choose a recommended preset, or add a custom provider manually.",
"settings.addProvider.officialChoice": "Recommended preset",
"settings.addProvider.officialChoiceHint": "Choose DeepSeek or curated custom-provider presets.",
"settings.addProvider.customChoice": "Custom provider",
"settings.addProvider.customChoiceHint": "Configure base_url, models, key env, and related parameters manually.",
"settings.addProvider.officialTitle": "Add official provider",
"settings.addProvider.officialHint": "Official presets use fixed endpoints and defaults; choose the provider and set a key.",
"settings.addProvider.officialHint": "Recommended presets fill endpoints, models, vision, and reasoning defaults; usually only the key is needed.",
"settings.addProvider.customTitle": "Add custom provider",
"settings.addProvider.customHint": "Use this for proxies, aggregators, or other OpenAI-compatible services.",
"settings.addProvider.official.deepseek": "DeepSeek Official",
"settings.addProvider.official.deepseekDesc": "Official API key access with DeepSeek V4 Flash / Pro.",
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat Platform OpenAI-compatible endpoint with LongCat-2.0 default.",
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat Platform Anthropic-compatible Messages endpoint using Bearer auth.",
"settings.addProvider.preset.kimiCnDesc": "Kimi China API via Moonshot CN endpoint with K2 vision models.",
"settings.addProvider.preset.kimiGlobalDesc": "Kimi international API via Moonshot global endpoint with K2 vision models.",
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan via the dedicated Anthropic-compatible endpoint; not a CN/Global variant.",
"settings.addProvider.preset.mimoApiDesc": "Xiaomi MiMo direct API for v2.5 pay-as-you-go models.",
"settings.addProvider.preset.mimoAnthropicDesc": "Xiaomi MiMo direct Anthropic-compatible endpoint with MiMo v2.5 models.",
"settings.addProvider.preset.mimoTokenPlanCnDesc": "Xiaomi MiMo token-plan China endpoint.",
"settings.addProvider.preset.mimoTokenPlanCnAnthropicDesc": "Xiaomi MiMo token-plan China Anthropic-compatible endpoint.",
"settings.addProvider.preset.mimoTokenPlanSgpDesc": "Xiaomi MiMo token-plan Singapore endpoint.",
"settings.addProvider.preset.mimoTokenPlanSgpAnthropicDesc": "Xiaomi MiMo token-plan Singapore Anthropic-compatible endpoint.",
"settings.addProvider.preset.mimoTokenPlanAmsDesc": "Xiaomi MiMo token-plan Amsterdam endpoint.",
"settings.addProvider.preset.mimoTokenPlanAmsAnthropicDesc": "Xiaomi MiMo token-plan Amsterdam Anthropic-compatible endpoint.",
"settings.addProvider.preset.minimaxCnApiDesc": "MiniMax China OpenAI-compatible M-series API endpoint.",
"settings.addProvider.preset.minimaxGlobalApiDesc": "MiniMax international OpenAI-compatible M-series API endpoint.",
"settings.addProvider.preset.minimaxCnAnthropicDesc": "MiniMax China Anthropic-compatible M-series endpoint.",
"settings.addProvider.preset.minimaxGlobalAnthropicDesc": "MiniMax international Anthropic-compatible endpoint using Bearer auth.",
"settings.addProvider.preset.glmCnDesc": "Zhipu GLM China API with GLM thinking controls.",
"settings.addProvider.preset.zaiGlobalDesc": "Z.AI international GLM API.",
"settings.addProvider.preset.glmCodingPlanCnDesc": "Zhipu GLM China coding-plan endpoint with GLM-5.2 default.",
"settings.addProvider.preset.glmCodingPlanCnAnthropicDesc": "Zhipu GLM China Anthropic-compatible coding-plan endpoint with GLM-5.2[1m] default.",
"settings.addProvider.preset.zaiCodingPlanGlobalDesc": "Z.AI international coding-plan endpoint with GLM-5.2 default.",
"settings.addProvider.preset.zaiCodingPlanGlobalAnthropicDesc": "Z.AI international Anthropic-compatible coding-plan endpoint with GLM-5.2[1m] default.",
"settings.addProvider.preset.opencodeGoDesc": "OpenCode Go relay with mixed-model capability overrides.",
"settings.addProvider.preset.opencodeGoAnthropicDesc": "OpenCode Go Anthropic route for Qwen and MiniMax models.",
"settings.addProvider.preset.opencodeZenAnthropicDesc": "OpenCode Zen Anthropic route for Claude and Qwen models.",
"settings.addProvider.preset.qwenCnDesc": "Qwen/DashScope China standard API endpoint.",
"settings.addProvider.preset.qwenGlobalDesc": "Qwen/DashScope international standard API endpoint.",
"settings.addProvider.preset.qwenCodingPlanCnDesc": "Qwen Coding Plan China OpenAI-compatible endpoint with qwen3.7-plus default.",
"settings.addProvider.preset.qwenCodingPlanCnAnthropicDesc": "Qwen Coding Plan China Anthropic-compatible endpoint with qwen3.7-plus default.",
"settings.addProvider.preset.qwenCodingPlanGlobalDesc": "Qwen Coding Plan international OpenAI-compatible endpoint with qwen3.7-plus default.",
"settings.addProvider.preset.qwenCodingPlanGlobalAnthropicDesc": "Qwen Coding Plan international Anthropic-compatible endpoint with qwen3.7-plus default.",
"settings.addProvider.preset.stepfunDesc": "StepFun coding-plan OpenAI-compatible endpoint with step-3.7-flash default.",
"settings.addProvider.preset.stepfunAnthropicDesc": "StepFun coding-plan Anthropic-compatible endpoint with step-3.7-flash default.",
"settings.addProvider.preset.novitaDesc": "NovitaAI multi-model gateway with Kimi, MiniMax, GLM, DeepSeek, and Qwen.",
"settings.addProvider.preset.gmiDesc": "GMI Cloud direct multi-model gateway with GLM, DeepSeek, Kimi, Gemini, Claude, and GPT.",
"settings.addProvider.preset.vercelAiGatewayDesc": "Vercel AI Gateway through Anthropic-compatible Messages API.",
"settings.addProvider.preset.huggingfaceDesc": "HuggingFace Inference Router with Qwen and DeepSeek defaults.",
"settings.addProvider.preset.nvidiaDesc": "NVIDIA NIM accelerated inference endpoint with Nemotron defaults.",
"settings.addProvider.preset.kilocodeDesc": "Kilo Code gateway with kilo/auto routing defaults.",
"settings.addProvider.preset.ollamaCloudDesc": "Hosted Ollama Cloud endpoint with none-to-max reasoning effort.",
"settings.addProvider.addedBadge": "Added",
"settings.addProvider.modifiedBadge": "Modified",
"settings.addProvider.nameConflictBadge": "Name taken",
"settings.addProvider.similarExistingBadge": "Similar setup",
"settings.addProvider.confirm": "Add provider",
"settings.addProvider.alreadyAddedAction": "Already added",
"settings.addProvider.nameConflictAction": "Resolve name first",
"settings.addProvider.viewPresetProvider": "View config",
"settings.addProvider.viewConflictProvider": "View same-name config",
"settings.addProvider.resetPreset": "Reset to preset",
"settings.addProvider.confirmResetPreset": "Confirm reset",
"settings.customProviderName": "Custom provider name",
"settings.customProviderNamePlaceholder": "",
"settings.providerNamePlaceholder": "Provider name (e.g. deepseek-proxy)",
"settings.providerProtocol": "Connection protocol",
"settings.providerProtocolOpenAI": "OpenAI-compatible",
"settings.providerProtocolOpenAIHint": "For proxies, aggregators, and self-hosted services compatible with the OpenAI API.",
"settings.providerProtocolAnthropic": "Anthropic-compatible",
"settings.providerProtocolAnthropicHint": "For Claude-style Messages API services such as some coding-plan endpoints.",
"settings.providerBaseUrlLabel": "API address",
"settings.providerChatUrlLabel": "Chat request URL",
"settings.providerChatUrlPlaceholder": "e.g. https://api.openai.com/v1/chat/completions",
@@ -1406,6 +1650,12 @@ export const en = {
"settings.providerHeaders": "Extra request headers (optional)",
"settings.providerHeadersPlaceholder": "HTTP-Referer: https://example.com\nX-Title: Reasonix",
"settings.providerHeadersHint": "For gateways such as OpenRouter that require site headers. Use one Header: value per line; the API key still belongs in the key field above.",
"settings.providerExtraBody": "Extra request body (optional)",
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
"settings.providerExtraBodyHint": "Top-level JSON fields merged into the chat request body. Core fields such as model, messages, tools, stream, and thinking stay controlled by Reasonix.",
"settings.providerExtraBodyError": "Enter a JSON object without null values, for example { \"enable_thinking\": true }.",
"settings.providerAuthHeader": "Use Authorization: Bearer for this key",
"settings.providerAuthHeaderHint": "For Anthropic-compatible gateways such as MiniMax Global or Vercel AI Gateway that do not use x-api-key.",
"settings.fetchingModels": "Refreshing…",
"settings.fetchModelsSuccess": "Fetched {n} models",
"settings.fetchModelsManualFallbackAuth": "The model discovery endpoint rejected the key or permission. Check the API key; if this service does not expose model discovery, enter model IDs manually below.",
@@ -1425,10 +1675,16 @@ export const en = {
"settings.newProviderDraftName": "new provider",
"settings.reasoningProtocol": "Model capability mode",
"settings.reasoningProtocol.auto": "Auto-detect (recommended)",
"settings.reasoningProtocol.deepseek": "DeepSeek thinking",
"settings.reasoningProtocol.openai": "OpenAI reasoning",
"settings.reasoningProtocol.deepseek": "DeepSeek thinking (thinking + depth)",
"settings.reasoningProtocol.openai": "OpenAI reasoning (reasoning_effort)",
"settings.reasoningProtocol.none": "Plain chat (no reasoning fields)",
"settings.reasoningProtocolHint": "Auto-detect is best for most gateways. Switch only when a proxy is incompatible or the model documentation requires a specific reasoning shape.",
"settings.thinkingMode": "Thinking override",
"settings.thinkingMode.auto": "Auto (provider default)",
"settings.thinkingMode.enabled": "Enabled",
"settings.thinkingMode.disabled": "Disabled",
"settings.thinkingMode.adaptive": "Adaptive (self-adjusting)",
"settings.thinkingModeHint": "Provider-specific override. Use enabled/disabled for compatible gateways, adaptive only for providers that document it.",
"settings.updateKey": "update {env} (saved globally)",
"settings.updateKeyAction": "Update key",
"settings.keyStatus": "Key",
@@ -1543,6 +1799,8 @@ export const en = {
"msg.imageAttachment": "Image",
"msg.workspaceReference": "Workspace reference",
"msg.folderReference": "Folder · Workspace reference",
"msg.pastedExpandTooltip": "Expand pasted text",
"msg.pastedCollapseTooltip": "Collapse pasted text",
"turnActions.summary": "Summary",
"turnActions.rewind": "Rewind",
"transcript.jumpToBottom": "Jump to bottom",
@@ -1624,7 +1882,7 @@ export const en = {
"settings.telemetryLabel": "Anonymous usage ping",
"settings.telemetryHint": "On launch, send a random install id plus version and OS to count active installs, and — if the app crashed on a previous run — resend that crash report. Never includes conversations, keys, or file contents.",
"settings.metricsLabel": "Share aggregate quality metrics",
"settings.metricsHint": "On by default. Sends anonymous turn-end counts, settings preference snapshots, and Memory v5 aggregate buckets such as injection on/off, size buckets, IR count buckets, and memory graph size buckets. Includes the random install id used to de-duplicate DAU. Buckets may include normalized custom provider and model names — never conversations, prompts, keys, paths, base URLs, memory text, tool outputs, or file contents.",
"settings.metricsHint": "On by default. Sends anonymous turn-end counts, updater error categories, settings preference snapshots, and Memory v5 aggregate buckets such as injection on/off, size buckets, IR count buckets, and memory graph size buckets. Includes the random install id used to de-duplicate DAU. Buckets may include normalized custom provider and model names — never conversations, prompts, keys, paths, base URLs, memory text, tool outputs, or file contents.",
"updater.currentVersion": "Current version: {v}",
"updater.channelLabel": "Update channel: {channel}",
"updater.checkButton": "Check for updates",
+288 -30
View File
@@ -134,6 +134,7 @@ export const zhTW: Record<DictKey, string> = {
"caps.summary": "{connected} 個已連線 · {failed} 個異常 · {skills} 個技能",
"caps.connectorsTab": "MCP 伺服器",
"caps.skillsTab": "Agent 技能",
"caps.pluginsTab": "插件",
"caps.servers": "MCP 伺服器",
"caps.noServers": "未設定 MCP 伺服器。用 /mcp add 新增。",
"caps.failureTitle": "{failed} 個 MCP 啟動異常",
@@ -227,6 +228,62 @@ export const zhTW: Record<DictKey, string> = {
"caps.trustedReadOnlyTitle": "已記住為之後規劃和唯讀 research 可用的唯讀工具",
"caps.searchSkills": "搜尋技能…",
"caps.noSkillMatches": "沒有匹配的技能。",
"caps.pluginRefresh": "重新整理插件",
"caps.pluginInstallTitle": "安裝插件",
"caps.pluginInstallHint": "選擇本機插件目錄,或從 Git 倉庫安裝。",
"caps.pluginInstallMethod": "插件安裝方式",
"caps.pluginInstallLocal": "本機目錄",
"caps.pluginInstallGit": "Git 倉庫",
"caps.pluginSource": "插件來源",
"caps.pluginGitSource": "Git 倉庫地址",
"caps.pluginSourcePlaceholder": "git:github.com/obra/superpowers",
"caps.pluginChooseLocalFolder": "選擇插件目錄",
"caps.pluginLocalFolder": "本機插件目錄",
"caps.pluginNoLocalFolder": "尚未選擇目錄",
"caps.pluginInstallName": "安裝名稱(可選)",
"caps.pluginInstallNamePlaceholder": "安裝名稱(可選)",
"caps.pluginLink": "開發模式:連結來源目錄",
"caps.pluginLinkHint": "不複製插件,直接使用所選目錄;適合調試插件。移動或刪除該目錄會導致插件失效。",
"caps.pluginReplace": "覆蓋同名插件",
"caps.pluginReplaceHint": "插件名已存在時,允許用目前來源替換它。",
"caps.pluginPreview": "預檢",
"caps.pluginInstall": "安裝插件",
"caps.noPluginsTitle": "尚未安裝插件",
"caps.noPluginsHint": "從 Git 或本機路徑安裝插件包,把技能、hooks 和 MCP 伺服器作為一個統一單元管理。",
"caps.installedPlugins": "已安裝插件",
"caps.pluginsSummary": "{enabled} 個啟用 · 共 {total} 個 · {issues} 個異常",
"caps.pluginCounts": "{skills} 技能 · {hooks} hooks · {mcps} MCP",
"caps.pluginNoCapabilities": "未匯出能力",
"caps.pluginWarnings": "{count} 個警告",
"caps.pluginEnable": "啟用插件",
"caps.pluginDisable": "停用插件",
"caps.pluginEnabled": "已啟用",
"caps.pluginDisabled": "已停用",
"caps.pluginVersion": "版本",
"caps.pluginRoot": "安裝路徑",
"caps.pluginUpdate": "更新",
"caps.pluginDoctor": "診斷",
"caps.pluginRemove": "移除插件",
"caps.pluginConfirmRemove": "確認移除",
"caps.pluginPlanError": "安裝檢查失敗",
"caps.pluginPlanReady": "安裝計畫",
"caps.pluginAction": "操作",
"caps.pluginPlanStatus": "插件操作:{status}",
"caps.pluginPlanComplete": "插件操作完成",
"caps.pluginPlanInstalled": "插件已安裝。展開下方插件,可查看它匯出的 skills、hooks 和 MCP 伺服器的使用方式。",
"caps.pluginUsageTitle": "使用方法",
"caps.pluginUsageEnabledHint": "啟用的插件會載入到新會話中。可用 /skills 查看技能、用 /<skill> 直接呼叫,或自然描述任務;hooks 和 MCP 伺服器會按正常會話流程自動參與。",
"caps.pluginUsageDisabledHint": "啟用此插件後,它的 skills、hooks 或 MCP 伺服器才會參與會話。",
"caps.pluginNoCapabilityDetails": "此插件暫無可展示的能力明細。",
"caps.pluginSkillsTitle": "Skills",
"caps.pluginSkillsHint": "用 /skills 瀏覽,直接呼叫,或在對話中說出工作流名稱。",
"caps.pluginHooksTitle": "Hooks",
"caps.pluginHooksHint": "Hooks 會在匹配的生命週期事件中自動觸發。",
"caps.pluginMCPTitle": "MCP 伺服器",
"caps.pluginMCPHint": "這些伺服器提供的工具會透過正常 MCP/工具流程可用。",
"caps.pluginNoDescription": "無描述",
"caps.pluginHookNoTarget": "未指定 hook 目標",
"caps.pluginMCPNoTarget": "未指定啟動目標",
"caps.sources": "來源",
"caps.sourcesSummaryActive": "{active} 個有效",
"caps.sourcesSummaryMissing": "{missing} 個缺失",
@@ -309,6 +366,9 @@ export const zhTW: Record<DictKey, string> = {
"composer.pastedExpand": "展開",
"composer.pastedRemove": "刪除貼上文字",
"composer.removeImage": "移除圖片",
"imageViewer.clickToPreview": "點擊查看大圖",
"imageViewer.closePreview": "關閉預覽",
"imageViewer.title": "圖片預覽",
"composer.imageInputUnsupported": "目前模型不會直接接收圖片。可切換到支援圖片的模型,或讓 OCR/識圖 MCP 工具讀取附件路徑。",
"composer.contextItems": "上下文項目",
"composer.workspaceReferences": "工作區引用",
@@ -326,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})",
@@ -514,6 +580,18 @@ export const zhTW: Record<DictKey, string> = {
"history.historySessionActions": "歷史會話操作",
"history.trashActions": "回收站操作",
// 會話恢復
"recovery.branch": "衝突副本",
"recovery.badge": "副本",
"recovery.open": "開啟衝突副本",
"recovery.toast": "偵測到儲存衝突,已為 {title} 保留一份衝突副本。",
"recovery.failedLease": "衝突副本被另一個執行階段占用,未切換過去。",
"recovery.failedUnavailable": "無法開啟衝突副本,目前標籤仍停留在原會話。",
"recovery.banner": "目前會話是儲存衝突副本。繼續前建議與原會話對比。",
"recovery.reason": "原因:{reason}",
"recovery.digest": "摘要:{digest}",
"recovery.parent": "父會話:{parent}",
// 專案樹
"projectTree.workspaceTitle": "專案",
"projectTree.pinnedTitle": "置頂",
@@ -629,6 +707,7 @@ export const zhTW: Record<DictKey, string> = {
"settings.tab.capabilities": "MCP 與技能",
"settings.tab.mcp": "MCP 與工具",
"settings.tab.skills": "技能",
"settings.tab.plugins": "插件",
"settings.tab.memory": "記憶",
"settings.tab.shortcuts": "快捷鍵",
"settings.tab.network": "網路",
@@ -642,6 +721,7 @@ export const zhTW: Record<DictKey, string> = {
"settings.tabSub.capabilities": "伺服器、工具與技能",
"settings.tabSub.mcp": "伺服器與工具",
"settings.tabSub.skills": "來源與啟用",
"settings.tabSub.plugins": "包與診斷",
"settings.tabSub.memory": "專案與全域",
"settings.tabSub.shortcuts": "按鍵與幫助",
"settings.tabSub.network": "代理與連線",
@@ -698,6 +778,10 @@ export const zhTW: Record<DictKey, string> = {
"settings.subagentEffort": "子代理 effort",
"settings.subagentEffortDefault": "auto(模型服務預設)",
"settings.subagentHint": "作用於 task 和 runAs=subagent skills;工具呼叫參數或單個 skill 設定會覆蓋它。",
"settings.subagentDepth": "子代理巢狀深度",
"settings.subagentDepthHint": "控制第一層子代理能否再派發一層 reviewer 或 worker 子代理。",
"settings.subagentDepthOne": "1 層",
"settings.subagentDepthTwo": "2 層",
"settings.defaultToolApprovalMode": "新會話預設審批",
"settings.defaultToolApprovalModeHint": "保存在全域設定中,僅用於新建會話;切換或恢復已有會話時不會覆蓋其 Ask / Auto / Yolo 狀態。",
"settings.defaultToolApprovalMode.ask": "詢問",
@@ -720,7 +804,7 @@ export const zhTW: Record<DictKey, string> = {
"settings.coldResumePrune.on": "開",
"settings.coldResumePrune.off": "關",
"settings.reasoningLanguage": "思考語言",
"settings.reasoningLanguageHint": "只影響可見思考過程。自動跟隨對話語言,不額外注入語言指令。",
"settings.reasoningLanguageHint": "只影響可見思考過程。自動在原始提問明顯為中文時錨定中文,引用內容不參與判斷。",
"settings.reasoningLanguage.auto": "自動",
"settings.reasoningLanguage.zh": "中文",
"settings.reasoningLanguage.en": "English",
@@ -807,12 +891,18 @@ export const zhTW: Record<DictKey, string> = {
"settings.yoloHint": "所有工具呼叫(寫檔案、bash)自動執行、不再詢問。deny 規則仍會攔截。僅執行時生效,重啟後失效。",
"settings.addRule": "新增 {list} 規則…",
"settings.sandboxTitle": "沙箱與工作區",
"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": "工作區根目錄",
"settings.workspaceDefault": "(預設:當前目錄)",
"settings.effectiveWriteRoots": "實際可寫根",
"settings.effectiveWriteRootsHint": "按目前工作區展開。設定頁內修改會自動生效;手動編輯設定檔後可用上方按鈕刷新目前會話。",
"settings.noEffectiveWriteRoots": "暫無可寫根",
"settings.appearance": "外觀",
"settings.theme": "主題",
"settings.themeStyle": "強調色",
@@ -829,6 +919,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": "微軟正黑體",
@@ -847,6 +940,7 @@ export const zhTW: Record<DictKey, string> = {
"settings.pageDesc.providers": "管理模型供應商、API 位址、金鑰與可用模型。",
"settings.pageDesc.mcp": "管理 MCP 伺服器連線和工具發現。",
"settings.pageDesc.skills": "瀏覽、啟用和管理智慧體技能與來源。",
"settings.pageDesc.plugins": "安裝和管理插件包,用統一單元提供技能、hooks 與 MCP 伺服器。",
"settings.pageDesc.memory": "管理 Reasonix 會長期參考的偏好、專案約定和指令檔案。",
"settings.pageDesc.network": "代理與網路設定。",
"settings.pageDesc.permissions": "寫操作模式與細粒度工具權限規則。",
@@ -859,7 +953,7 @@ export const zhTW: Record<DictKey, string> = {
"settings.customProviders": "自訂供應商",
"settings.customProvidersHint": "你自己新增的 OpenAI-compatible 或代理接入,可以編輯、刪除並持久化模型列表。",
"settings.customProvidersEmpty": "還沒有自訂供應商。常用服務優先使用上方內建接入。",
"settings.providerAccessEmptyHint": "點擊「新增供應商」,選擇 DeepSeek 或自訂 OpenAI-compatible 接入。",
"settings.providerAccessEmptyHint": "使用「推薦預設」可快速接入常用供應商,通常只需填寫 API Key;使用「自訂供應商」可手動設定 OpenAI-compatible 或 Anthropic-compatible 端點。",
"settings.builtinProviderBadge": "內建",
"settings.customProviderBadge": "自訂",
"settings.configureProvider": "設定",
@@ -871,24 +965,77 @@ export const zhTW: Record<DictKey, string> = {
"settings.searchModels": "搜尋模型...",
"settings.noMatchingModels": "沒有匹配的模型",
"settings.addProvider.chooseTitle": "新增供應商",
"settings.addProvider.chooseHint": "選擇官方預設,或新增一個自訂 OpenAI-compatible 接入。",
"settings.addProvider.officialChoice": "官方供應商",
"settings.addProvider.officialChoiceHint": "從 Reasonix 預設的 DeepSeek 接入中選擇。",
"settings.addProvider.chooseHint": "選擇推薦預設,或手動新增一個自訂供應商。",
"settings.addProvider.officialChoice": "推薦預設",
"settings.addProvider.officialChoiceHint": "選擇 DeepSeek 或已適配的自訂供應商預設。",
"settings.addProvider.customChoice": "自訂供應商",
"settings.addProvider.customChoiceHint": "按 base_url、模型、金鑰環境變數等參數手動設定。",
"settings.addProvider.officialTitle": "新增官方供應商",
"settings.addProvider.officialHint": "官方預設會寫入固定端點和預設參數,只需要選擇供應商並設定金鑰。",
"settings.addProvider.officialHint": "推薦預設會填好端點、模型、視覺和推理預設值,通常只需要填 API Key。",
"settings.addProvider.customTitle": "新增自訂供應商",
"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。",
"settings.addProvider.preset.mimoApiDesc": "小米 MiMo 直連 API,適合 v2.5 按量付費模型。",
"settings.addProvider.preset.mimoAnthropicDesc": "小米 MiMo 直連 Anthropic-compatible 端點,自動使用 MiMo v2.5 模型。",
"settings.addProvider.preset.mimoTokenPlanCnDesc": "小米 MiMo token plan 中國區端點。",
"settings.addProvider.preset.mimoTokenPlanCnAnthropicDesc": "小米 MiMo token plan 中國區 Anthropic-compatible 端點。",
"settings.addProvider.preset.mimoTokenPlanSgpDesc": "小米 MiMo token plan 新加坡端點。",
"settings.addProvider.preset.mimoTokenPlanSgpAnthropicDesc": "小米 MiMo token plan 新加坡 Anthropic-compatible 端點。",
"settings.addProvider.preset.mimoTokenPlanAmsDesc": "小米 MiMo token plan 阿姆斯特丹端點。",
"settings.addProvider.preset.mimoTokenPlanAmsAnthropicDesc": "小米 MiMo token plan 阿姆斯特丹 Anthropic-compatible 端點。",
"settings.addProvider.preset.minimaxCnApiDesc": "MiniMax 中國 OpenAI-compatible M 系列 API 端點。",
"settings.addProvider.preset.minimaxGlobalApiDesc": "MiniMax 國際 OpenAI-compatible M 系列 API 端點。",
"settings.addProvider.preset.minimaxCnAnthropicDesc": "MiniMax 中國 Anthropic-compatible M 系列端點。",
"settings.addProvider.preset.minimaxGlobalAnthropicDesc": "MiniMax 國際 Anthropic-compatible 端點,自動使用 Bearer 認證。",
"settings.addProvider.preset.glmCnDesc": "智譜 GLM 中國 API,預置 GLM thinking 控制。",
"settings.addProvider.preset.zaiGlobalDesc": "Z.AI 國際 GLM API。",
"settings.addProvider.preset.glmCodingPlanCnDesc": "智譜 GLM 中國 coding plan 端點,預設 GLM-5.2。",
"settings.addProvider.preset.glmCodingPlanCnAnthropicDesc": "智譜 GLM 中國 Anthropic-compatible coding plan 端點,預設 GLM-5.2[1m]。",
"settings.addProvider.preset.zaiCodingPlanGlobalDesc": "Z.AI 國際 coding plan 端點,預設 GLM-5.2。",
"settings.addProvider.preset.zaiCodingPlanGlobalAnthropicDesc": "Z.AI 國際 Anthropic-compatible coding plan 端點,預設 GLM-5.2[1m]。",
"settings.addProvider.preset.opencodeGoDesc": "OpenCode Go 混合模型閘道,內建每模型能力覆蓋。",
"settings.addProvider.preset.opencodeGoAnthropicDesc": "OpenCode Go Anthropic 線路,預置 Qwen 與 MiniMax 模型。",
"settings.addProvider.preset.opencodeZenAnthropicDesc": "OpenCode Zen Anthropic 線路,預置 Claude 與 Qwen 模型。",
"settings.addProvider.preset.qwenCnDesc": "Qwen/DashScope 中國標準 API 端點。",
"settings.addProvider.preset.qwenGlobalDesc": "Qwen/DashScope 國際標準 API 端點。",
"settings.addProvider.preset.qwenCodingPlanCnDesc": "Qwen Coding Plan 中國 OpenAI-compatible 端點,預設 qwen3.7-plus。",
"settings.addProvider.preset.qwenCodingPlanCnAnthropicDesc": "Qwen Coding Plan 中國 Anthropic-compatible 端點,預設 qwen3.7-plus。",
"settings.addProvider.preset.qwenCodingPlanGlobalDesc": "Qwen Coding Plan 國際 OpenAI-compatible 端點,預設 qwen3.7-plus。",
"settings.addProvider.preset.qwenCodingPlanGlobalAnthropicDesc": "Qwen Coding Plan 國際 Anthropic-compatible 端點,預設 qwen3.7-plus。",
"settings.addProvider.preset.stepfunDesc": "階躍星辰 StepFun coding plan OpenAI-compatible 端點,預設 step-3.7-flash。",
"settings.addProvider.preset.stepfunAnthropicDesc": "階躍星辰 StepFun coding plan Anthropic-compatible 端點,預設 step-3.7-flash。",
"settings.addProvider.preset.novitaDesc": "NovitaAI 多模型閘道,預置 Kimi、MiniMax、GLM、DeepSeek、Qwen。",
"settings.addProvider.preset.gmiDesc": "GMI Cloud 多模型直連閘道,預置 GLM、DeepSeek、Kimi、Gemini、Claude、GPT。",
"settings.addProvider.preset.vercelAiGatewayDesc": "Vercel AI Gateway,走 Anthropic-compatible Messages API。",
"settings.addProvider.preset.huggingfaceDesc": "HuggingFace Inference Router,預置 Qwen 與 DeepSeek 模型。",
"settings.addProvider.preset.nvidiaDesc": "NVIDIA NIM 加速推理端點,預置 Nemotron 模型。",
"settings.addProvider.preset.kilocodeDesc": "Kilo Code 閘道,預置 kilo/auto 自動路由模型。",
"settings.addProvider.preset.ollamaCloudDesc": "Ollama Cloud 託管端點,支援 none 到 max 的 reasoning effort。",
"settings.addProvider.addedBadge": "已新增",
"settings.addProvider.modifiedBadge": "已修改",
"settings.addProvider.nameConflictBadge": "同名占用",
"settings.addProvider.similarExistingBadge": "有相似設定",
"settings.addProvider.confirm": "新增供應商",
"settings.addProvider.alreadyAddedAction": "已新增",
"settings.addProvider.nameConflictAction": "先處理同名設定",
"settings.addProvider.viewPresetProvider": "查看設定",
"settings.addProvider.viewConflictProvider": "查看同名設定",
"settings.addProvider.resetPreset": "重設為預設",
"settings.addProvider.confirmResetPreset": "確認重設",
"settings.customProviderName": "自訂供應商名稱",
"settings.customProviderNamePlaceholder": "",
"settings.providerNamePlaceholder": "供應商名稱(如 deepseek-proxy)",
"settings.providerProtocol": "接入協定",
"settings.providerProtocolOpenAI": "OpenAI-compatible",
"settings.providerProtocolOpenAIHint": "適用於相容 OpenAI API 的代理、聚合平台和自建服務。",
"settings.providerProtocolAnthropic": "Anthropic-compatible",
"settings.providerProtocolAnthropicHint": "適用於 Claude Messages API 風格的服務,例如部分 coding plan 端點。",
"settings.providerBaseUrlLabel": "API 位址",
"settings.providerChatUrlLabel": "完整請求 URL",
"settings.providerChatUrlPlaceholder": "例如 https://api.openai.com/v1/chat/completions",
@@ -911,6 +1058,12 @@ export const zhTW: Record<DictKey, string> = {
"settings.providerHeaders": "額外請求標頭(可選)",
"settings.providerHeadersPlaceholder": "HTTP-Referer: https://example.com\nX-Title: Reasonix",
"settings.providerHeadersHint": "用於 OpenRouter 等要求站點來源的中轉站。一行一個 Header: value;API Key 仍透過上方金鑰保存。",
"settings.providerExtraBody": "額外請求體(可選)",
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
"settings.providerExtraBodyHint": "會合併到聊天請求體頂層 JSON。model、messages、tools、stream、thinking 等核心欄位仍由 Reasonix 控制。",
"settings.providerExtraBodyError": "請輸入不含 null 值的 JSON 物件,例如 { \"enable_thinking\": true }。",
"settings.providerAuthHeader": "使用 Authorization: Bearer 發送金鑰",
"settings.providerAuthHeaderHint": "適用於 MiniMax Global、Vercel AI Gateway 等不使用 x-api-key 的 Anthropic-compatible 閘道。",
"settings.fetchingModels": "重新整理中…",
"settings.fetchModelsSuccess": "已取得 {n} 個模型",
"settings.fetchModelsManualFallbackAuth": "模型發現介面拒絕了金鑰或權限。請檢查 API Key;如果該服務不開放模型發現,也可以在下方手動填寫模型 ID。",
@@ -928,10 +1081,16 @@ export const zhTW: Record<DictKey, string> = {
"settings.newProviderDraftName": "新供應商",
"settings.reasoningProtocol": "模型能力模式",
"settings.reasoningProtocol.auto": "自動識別(推薦)",
"settings.reasoningProtocol.deepseek": "DeepSeek 思考",
"settings.reasoningProtocol.openai": "OpenAI reasoning",
"settings.reasoningProtocol.deepseek": "DeepSeek 思考(thinking + 深度)",
"settings.reasoningProtocol.openai": "OpenAI reasoning(reasoning_effort)",
"settings.reasoningProtocol.none": "普通聊天(不傳送思考參數)",
"settings.reasoningProtocolHint": "預設自動識別。只有代理不相容,或你確定模型需要特定 reasoning 格式時再切換。",
"settings.thinkingMode": "Thinking 覆蓋",
"settings.thinkingMode.auto": "Auto(使用服務預設)",
"settings.thinkingMode.enabled": "Enabled(開啟)",
"settings.thinkingMode.disabled": "Disabled(關閉)",
"settings.thinkingMode.adaptive": "Adaptive(自適應)",
"settings.thinkingModeHint": "服務商專用覆蓋項。相容閘道可用 enabled/disabled;只有服務文件明確支援時才使用 adaptive。",
"settings.updateKey": "更新 {env}(全域儲存)",
"settings.updateKeyAction": "更新金鑰",
"settings.keyStatus": "金鑰",
@@ -985,6 +1144,8 @@ export const zhTW: Record<DictKey, string> = {
"msg.imageAttachment": "圖片",
"msg.workspaceReference": "工作區引用",
"msg.folderReference": "資料夾 · 工作區引用",
"msg.pastedExpandTooltip": "展開貼上文字",
"msg.pastedCollapseTooltip": "收起貼上文字",
"turnActions.summary": "總結",
"turnActions.rewind": "回溯",
"transcript.jumpToBottom": "跳到底部",
@@ -1336,6 +1497,7 @@ export const zhTW: Record<DictKey, string> = {
"clearContext.done": "已清空當前上下文",
"clearContext.failed": "清空當前上下文失敗",
"projectTree.justNow": "剛剛",
"projectTree.previously": "之前",
"projectTree.running": "輸出中",
"projectTree.status.thinking": "思考中",
"projectTree.status.streaming": "輸出中",
@@ -1451,11 +1613,76 @@ export const zhTW: Record<DictKey, string> = {
"settings.toggleOff": "關閉",
"settings.botSetup": "機器人入口",
"settings.botSetupHint": "先開啟入口,再選擇要連線的聊天頻道。",
"settings.botConnectPhoneTitle": "新增 IM Bot",
"settings.botConnectPhoneSubtitle": "QQ 手動配置,飛書、Lark 和微信掃碼接入。",
"settings.botGateway": "機器人閘道器",
"settings.botGatewayHint": "桌面端統一管理 IM bot 的總開關、模型和執行參數。",
"settings.botGatewayHint": "桌面端統一管理 IM bot 的總開關和連線狀態。",
"settings.botBrowserPreviewWarning": "目前是瀏覽器預覽,只會儲存 mock 設定,不會寫入本機配置或啟動 QQ/IM Bot gateway。請在桌面應用或 CLI 中配置後再到 QQ 用戶端測試。",
"settings.botSimpleEyebrow": "傻瓜式設定",
"settings.botSimpleTitle": "行動端與聊天機器人",
"settings.botSimpleHint": "先用手機掃碼遠端控制目前工作區;需要長時間從聊天工具存取時,再選擇一個 Bot Channel 配置。",
"settings.botMobileRemoteEyebrow": "獨立入口",
"settings.botMobileRemoteTitle": "手機遠端控制",
"settings.botMobileRemoteHint": "掃碼或複製連結,從行動端打開目前工作區的控制入口;它和 IM Bot 頻道管理分開配置。",
"settings.botMobileRemoteControlApi": "控制連結",
"settings.botMobileRemoteDisabled": "開啟後生成二維碼和連結",
"settings.botMobileRemoteEnableFirst": "開啟後生成二維碼",
"settings.botMobileRemoteReady": "等待手機連線",
"settings.botMobileRemoteWaiting": "手機掃碼連線",
"settings.botMobileRemoteLoopbackNotice": "目前控制 API 仍是本機 loopback,只適合本機預覽;跨裝置 relay 接入後會沿用這個入口。",
"settings.botMobileRemoteCopyLink": "複製連結",
"settings.botMobileRemoteCopied": "已複製",
"settings.botMobileRemoteCopyFailed": "複製失敗",
"settings.botChannelEntryTitle": "使用 Bot Channel",
"settings.botChannelEntryHint": "連接聊天 Bot,適合更長時間的行動端存取。",
"settings.botManageBots": "機器人管理",
"settings.botManageBotsHint": "左側選擇頻道;未配置時掃碼或填寫金鑰,配置後在右側管理這個機器人的執行設定。",
"settings.botBackToOverview": "返回入口",
"settings.botChannelOpenConfigured": "已配置,可繼續修改設定。",
"settings.botChannelNeedsSetup": "未配置",
"settings.botChannelTabsLabel": "機器人頻道",
"settings.botChannelSetupTitle": "配置 {provider}",
"settings.botChannelSetupHint": "未配置前只需要完成憑據或掃碼綁定;成功後會自動切換為具體設定。",
"settings.botSetupStatus": "機器人設定狀態",
"settings.botSetupStatusConnected": "{online}/{total} 在線",
"settings.botSetupStatusAccessTrusted": "受信任名單 {count}",
"settings.botSetupStatusAccessEveryone": "所有人可用",
"settings.botSetupStatusAccessPerBot": "{configured}/{total} 個 Bot 已配置",
"settings.botMode": "機器人設定模式",
"settings.botModeSimple": "普通",
"settings.botModeAdvanced": "高階",
"settings.botShowAdvancedSettings": "高階設定",
"settings.botHideAdvancedSettings": "收起高階設定",
"settings.botAdvancedSettingsHint": "低頻能力:路由、佇列細項和自身訊息過濾。",
"settings.botShowAddBot": "新增頻道",
"settings.botStepConnectTitle": "連接機器人",
"settings.botStepConnectHint": "QQ 手動填密鑰;飛書、Lark、微信掃碼即可接入。",
"settings.botStepConnectEmpty": "還沒有連接",
"settings.botSharedAllowlistHint": "所有渠道共用第 2 步「誰可以使用」的名單與配對設定,改一處即可全部生效。",
"settings.botSharedAllowlistGo": "前往第 2 步編輯",
"settings.botGlobalAllowlist": "舊全域白名單",
"settings.botGlobalAllowlistHint": "相容舊配置;新 Bot 建議在各自詳情裡設定誰可以使用。",
"settings.botAccessShowAllPlatforms": "顯示全部平台",
"settings.botAccessShowConnectedOnly": "只看已連接平台",
"settings.botListUsers": "使用者",
"settings.botListGroups": "群組",
"settings.botListApprovers": "審批人",
"settings.botListAdmins": "管理員",
"settings.botPlatformFeishuLark": "飛書 / Lark",
"settings.botQueueDropLabel": "佇列滿時",
"settings.botQueueDropHint": "佇列達到上限後,如何處理放不下的訊息。",
"settings.botPairingDetailHint": "配對開關在第 2 步「誰可以使用」裡;這裡只調整配對碼有效期和待批數量。",
"settings.botSimpleAccessTitle": "誰可以使用",
"settings.botSimpleAccessTrustedSummary": "目前使用受信任名單,已填寫 {count} 條成員或群組。",
"settings.botSimpleAccessEveryoneSummary": "目前允許所有人使用,適合短時間測試。",
"settings.botAccessTrusted": "受信任名單",
"settings.botAccessTrustedHint": "只允許填寫過的使用者或群組呼叫機器人。",
"settings.botAccessEveryone": "所有人可用",
"settings.botAccessEveryoneHint": "臨時測試方便,但不建議長期開啟。",
"settings.botAccessPairing": "私聊先配對",
"settings.botAccessPairingHint": "陌生私聊先發配對碼,本機批准後再放行。",
"settings.botRoleAccess": "管理員與審批人",
"settings.botRoleAccessHint": "可選。普通使用者不用填;需要遠端審批或管理時再配置。",
"settings.botQueueModeSimple": "新訊息怎麼處理",
"settings.botQueueModeSimpleHint": "當機器人還在處理上一條訊息時,選擇下一條訊息的處理方式。",
"settings.saveBotSettings": "儲存機器人設定",
"settings.botEnableBot": "啟用機器人",
"settings.botGatewayMasterSwitch": "總開關",
@@ -1466,8 +1693,48 @@ export const zhTW: Record<DictKey, string> = {
"settings.botModelHint": "留空時使用預設模型。",
"settings.botModelDefault": "使用預設模型",
"settings.botRuntime": "執行限制",
"settings.botRuntimeHint": "閘道器行程級限制,控制每次機器人任務最多走多遠。",
"settings.botMaxSteps": "最大步數",
"settings.botDebounceMs": "合併視窗 ms",
"settings.botQueueCap": "佇列上限",
"settings.botQueueMode.steer": "引導目前回合",
"settings.botQueueMode.followup": "追加跟進",
"settings.botQueueMode.collect": "收集等待",
"settings.botQueueMode.interrupt": "打斷目前",
"settings.botQueueDrop.summarize": "彙總溢出",
"settings.botQueueDrop.old": "丟棄最舊",
"settings.botQueueDrop.new": "丟棄最新",
"settings.botIgnoreSelfMessages": "忽略自身回聲",
"settings.botIgnoreSelfMessagesHint": "避免 gateway 再次處理由已配置 Bot 身份發出的訊息。",
"settings.botSelfUserIds": "Bot 自身 ID",
"settings.botSelfUserIdsHint": "可選填寫各平台 Bot 自己的 user/open ID,用於過濾回聲訊息。",
"settings.botPairing": "私聊配對",
"settings.botPairingTTL": "有效分鐘",
"settings.botPairingMaxPending": "待批上限",
"settings.botControlApi": "本機控制 API",
"settings.botControlApiHint": "僅 loopback 的 HTTP API,可查狀態和受控傳送;需要從指定環境變數讀取 Bearer token。",
"settings.botControlAddr": "控制地址",
"settings.botControlTokenEnv": "Token 變數",
"settings.botGatewayDefaults": "閘道器執行設定",
"settings.botGatewayDefaultsHint": "不用編輯 TOML,也能配置排隊、配對和自身訊息過濾。",
"settings.botRoutes": "聊天綁定到專案",
"settings.botRoutesHint": "把某個群組、聯絡人或執行緒固定到指定工作目錄、模型或審批方式。",
"settings.botRoutesEmpty": "還沒有綁定規則。新增一條規則,把某個聊天、使用者或執行緒綁定到專案或模型。",
"settings.botRouteTitle": "路由 {n}",
"settings.botAddRoute": "新增路由",
"settings.botRouteConnection": "來自哪個 Bot",
"settings.botRouteAny": "任意",
"settings.botRoutePlatform": "平台",
"settings.botRouteChatType": "聊天類型",
"settings.botRouteChatType.any": "任意",
"settings.botRouteChatType.dm": "私聊",
"settings.botRouteChatType.group": "群聊",
"settings.botRouteChatType.guild": "頻道",
"settings.botRouteChatType.direct": "Direct",
"settings.botRouteChatType.thread": "Thread",
"settings.botRouteChatId": "群組/聊天 ID",
"settings.botRouteUserId": "聯絡人 ID",
"settings.botRouteThreadId": "執行緒 ID",
"settings.botChannels": "連線頻道",
"settings.botChannelsHint": "選擇頻道;QQ 手動配置,飛書、Lark 和微信掃碼連線。",
"settings.botQQHint": "QQ 官方 Bot API v2。",
@@ -1522,8 +1789,7 @@ export const zhTW: Record<DictKey, string> = {
"settings.botInstallLarkHint": "掃碼建立 Lark PersonalAgent。",
"settings.botInstallWeixinHint": "微信掃碼登入並儲存本地 token。",
"settings.botConnectedBots": "已連線 Bot",
"settings.botConnectedBotsSummary": "{online}/{total} 線上",
"settings.botConnectionsEmpty": "還沒有連線的機器人頻道。",
"settings.botSelectBotHint": "選擇左側 Bot 進入配置。",
"settings.botConnectionColumnChannel": "頻道",
"settings.botConnectionColumnName": "機器人",
"settings.botConnectionColumnRemote": "遠端 ID",
@@ -1541,16 +1807,17 @@ export const zhTW: Record<DictKey, string> = {
"settings.botConnectionConnected": "已連線",
"settings.botConnectionConfigured": "已配置",
"settings.botConnectionDisconnected": "未連線",
"settings.botManage": "高階設定",
"settings.botManage": "修改配置",
"settings.deleteBot": "刪除機器人",
"settings.deleteBotShort": "刪除",
"settings.confirmDeleteBot": "確認刪除",
"settings.deleteBotHint": "從已連線 Bot 中移除這個機器人;不會自動清除已儲存的金鑰。",
"settings.botChannelModel": "模型",
"settings.botChannelModelHint": "留空時使用 Bot 預設模型。",
"settings.botChannelModelAuto": "自動",
"settings.botChannelModelHint": "這個機器人處理訊息時使用的模型。",
"settings.botChannelModelAuto": "使用桌面目前模型",
"settings.botToolApprovalMode": "工具審批",
"settings.botToolApprovalModeHint": "控制該頻道是否在工具執行前請求確認;計劃確認和 ask 問題仍會等待回覆。",
"settings.botToolApprovalMode.inherit": "跟隨預設",
"settings.botToolApprovalMode.inherit": "跟隨機器人",
"settings.botToolApprovalMode.ask": "詢問",
"settings.botToolApprovalMode.auto": "自動",
"settings.botToolApprovalMode.yolo": "YOLO",
@@ -1573,29 +1840,20 @@ export const zhTW: Record<DictKey, string> = {
"settings.botDiagnosticReportFailed": "傳送診斷報告失敗。",
"settings.botDiagnosticPrivacy": "僅包含已脫敏的連線元資訊。",
"settings.botAccessControl": "存取控制",
"settings.botAccessControlHint": "預設使用白名單;臨時測試可以改為允許所有人。",
"settings.botAccessMode": "誰可以使用",
"settings.botAccessAll": "所有人",
"settings.botAccessWhitelist": "僅白名單",
"settings.botEditAllowlist": "編輯白名單",
"settings.botAllowlist": "白名單",
"settings.botAllowlistHint": "限制哪些使用者或群可以呼叫機器人。",
"settings.botAllowlistEnabled": "啟用白名單",
"settings.botAllowAll": "允許所有人",
"settings.botAllowAllWarn": "開啟後所有進入頻道的訊息都可以呼叫機器人。",
"settings.botQQAccessRequired": "啟用 QQ Bot 前,請先在高階設定裡新增 QQ 使用者/群白名單,或臨時改為允許所有人。",
"settings.botAllowlistEntries": "使用者與群",
"settings.botQQAccessRequired": "啟用 QQ Bot 前,請先在第 2 步「誰可以使用」裡新增 QQ 使用者/群組,或臨時改為所有人可用。",
"settings.botQQUsers": "QQ 使用者",
"settings.botQQGroups": "QQ群",
"settings.botFeishuUsers": "飛書使用者",
"settings.botFeishuGroups": "飛書群",
"settings.botFeishuLarkUsers": "飛書 / Lark 使用者",
"settings.botFeishuLarkGroups": "飛書 / Lark 群",
"settings.botWeixinUsers": "微信使用者",
"settings.botWeixinGroups": "微信群",
"settings.botListPlaceholder": "每行一個 ID,也可用逗號分隔",
"settings.shellInterpreter": "Shell 直譯器",
"settings.shellAuto": "自動(優先 bash,Windows 無 bash 時用 PowerShell)",
"settings.shellAuto": "自動(bash 優先)",
"settings.shellBash": "bash",
"settings.shellPowershell": "PowerShell(Windows PowerShell)",
"settings.shellPwsh": "pwsh(PowerShell 7+)",
@@ -1745,7 +2003,7 @@ export const zhTW: Record<DictKey, string> = {
"settings.telemetryLabel": "匿名啟動統計",
"settings.telemetryHint": "啟動時傳送隨機安裝 ID、版本號和作業系統用於統計活躍安裝量;若上次執行發生崩潰,則在本次啟動補發該崩潰報告。絕不包含對話、金鑰或檔案內容。",
"settings.metricsLabel": "共享聚合品質指標",
"settings.metricsHint": "預設開啟。傳送匿名的輪次結束統計、設定偏好快照,以及 Memory v5 聚合桶,例如是否注入、大小桶、IR 數量桶和記憶圖規模桶。包含用於 DAU 去重的隨機安裝 ID。Bucket 可能包含正規化後的自訂 Provider 名和模型名——絕不包含對話、提示詞、金鑰、路徑、base URL、記憶正文、工具輸出或檔案內容。",
"settings.metricsHint": "預設開啟。傳送匿名的輪次結束統計、更新器錯誤類別、設定偏好快照,以及 Memory v5 聚合桶,例如是否注入、大小桶、IR 數量桶和記憶圖規模桶。包含用於 DAU 去重的隨機安裝 ID。Bucket 可能包含正規化後的自訂 Provider 名和模型名——絕不包含對話、提示詞、金鑰、路徑、base URL、記憶正文、工具輸出或檔案內容。",
"context.windowTitle": "上下文視窗",
"context.windowStatusHealthy": "上下文充足",
"context.windowStatusWatch": "即將壓縮",
+288 -30
View File
@@ -206,6 +206,7 @@ export const zh: Record<DictKey, string> = {
"caps.mcpSummary": "{connected} 个已连接 · {failed} 个异常 · {tools} 个工具",
"caps.connectorsTab": "MCP 服务器",
"caps.skillsTab": "Agent 技能",
"caps.pluginsTab": "插件",
"caps.servers": "MCP 服务器",
"caps.noServers": "未配置 MCP 服务器。用 /mcp add 添加。",
"caps.availableServers": "可用 MCP",
@@ -312,6 +313,62 @@ export const zh: Record<DictKey, string> = {
"caps.trustedReadOnlyTitle": "已记住为之后规划和只读 research 可用的只读工具",
"caps.searchSkills": "搜索技能…",
"caps.noSkillMatches": "没有匹配的技能。",
"caps.pluginRefresh": "刷新插件",
"caps.pluginInstallTitle": "安装插件",
"caps.pluginInstallHint": "选择本地插件目录,或从 Git 仓库安装。",
"caps.pluginInstallMethod": "插件安装方式",
"caps.pluginInstallLocal": "本地目录",
"caps.pluginInstallGit": "Git 仓库",
"caps.pluginSource": "插件来源",
"caps.pluginGitSource": "Git 仓库地址",
"caps.pluginSourcePlaceholder": "git:github.com/obra/superpowers",
"caps.pluginChooseLocalFolder": "选择插件目录",
"caps.pluginLocalFolder": "本地插件目录",
"caps.pluginNoLocalFolder": "尚未选择目录",
"caps.pluginInstallName": "安装名称(可选)",
"caps.pluginInstallNamePlaceholder": "安装名称(可选)",
"caps.pluginLink": "开发模式:链接源目录",
"caps.pluginLinkHint": "不复制插件,直接使用所选目录;适合调试插件。移动或删除该目录会导致插件失效。",
"caps.pluginReplace": "覆盖同名插件",
"caps.pluginReplaceHint": "插件名已存在时,允许用当前来源替换它。",
"caps.pluginPreview": "预检",
"caps.pluginInstall": "安装插件",
"caps.noPluginsTitle": "尚未安装插件",
"caps.noPluginsHint": "从 Git 或本地路径安装插件包,把技能、hooks 和 MCP 服务器作为一个统一单元管理。",
"caps.installedPlugins": "已安装插件",
"caps.pluginsSummary": "{enabled} 个启用 · 共 {total} 个 · {issues} 个异常",
"caps.pluginCounts": "{skills} 技能 · {hooks} hooks · {mcps} MCP",
"caps.pluginNoCapabilities": "未导出能力",
"caps.pluginWarnings": "{count} 个告警",
"caps.pluginEnable": "启用插件",
"caps.pluginDisable": "停用插件",
"caps.pluginEnabled": "已启用",
"caps.pluginDisabled": "已停用",
"caps.pluginVersion": "版本",
"caps.pluginRoot": "安装路径",
"caps.pluginUpdate": "更新",
"caps.pluginDoctor": "诊断",
"caps.pluginRemove": "移除插件",
"caps.pluginConfirmRemove": "确认移除",
"caps.pluginPlanError": "安装检查失败",
"caps.pluginPlanReady": "安装计划",
"caps.pluginAction": "操作",
"caps.pluginPlanStatus": "插件操作:{status}",
"caps.pluginPlanComplete": "插件操作完成",
"caps.pluginPlanInstalled": "插件已安装。展开下方插件,可查看它导出的 skills、hooks 和 MCP 服务器的使用方式。",
"caps.pluginUsageTitle": "使用方法",
"caps.pluginUsageEnabledHint": "启用的插件会加载到新会话中。可用 /skills 查看技能、用 /<skill> 直接调用,或自然描述任务;hooks 和 MCP 服务器会按正常会话流程自动参与。",
"caps.pluginUsageDisabledHint": "启用此插件后,它的 skills、hooks 或 MCP 服务器才会参与会话。",
"caps.pluginNoCapabilityDetails": "此插件暂无可展示的能力明细。",
"caps.pluginSkillsTitle": "Skills",
"caps.pluginSkillsHint": "用 /skills 浏览,直接调用,或在对话中说出工作流名称。",
"caps.pluginHooksTitle": "Hooks",
"caps.pluginHooksHint": "Hooks 会在匹配的生命周期事件中自动触发。",
"caps.pluginMCPTitle": "MCP 服务器",
"caps.pluginMCPHint": "这些服务器提供的工具会通过正常 MCP/工具流程可用。",
"caps.pluginNoDescription": "无描述",
"caps.pluginHookNoTarget": "未指定 hook 目标",
"caps.pluginMCPNoTarget": "未指定启动目标",
"caps.sources": "来源",
"caps.sourcesSummaryActive": "{active} 个有效",
"caps.sourcesSummaryMissing": "{missing} 个缺失",
@@ -433,6 +490,9 @@ export const zh: Record<DictKey, string> = {
"composer.pastedExpand": "展开",
"composer.pastedRemove": "删除粘贴文本",
"composer.removeImage": "移除图片",
"imageViewer.clickToPreview": "点击查看大图",
"imageViewer.closePreview": "关闭预览",
"imageViewer.title": "图片预览",
"composer.attachImageFailed": "图片粘贴失败",
"composer.attachFileFailed": "文件附加失败",
"composer.attachDropFailed": "拖放文件附加失败",
@@ -473,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": "推理力度",
@@ -672,6 +738,18 @@ export const zh: Record<DictKey, string> = {
"history.historySessionActions": "历史会话操作",
"history.trashActions": "回收站操作",
// 会话恢复
"recovery.branch": "冲突副本",
"recovery.badge": "副本",
"recovery.open": "打开冲突副本",
"recovery.toast": "检测到保存冲突,已为 {title} 保留一份冲突副本。",
"recovery.failedLease": "冲突副本被另一个运行时占用,未切换过去。",
"recovery.failedUnavailable": "无法打开冲突副本,当前标签仍停留在原会话。",
"recovery.banner": "当前会话是保存冲突副本。继续前建议与原会话对比。",
"recovery.reason": "原因:{reason}",
"recovery.digest": "摘要:{digest}",
"recovery.parent": "父会话:{parent}",
// 项目树
"projectTree.workspaceTitle": "项目",
"projectTree.pinnedTitle": "置顶",
@@ -686,6 +764,7 @@ export const zh: Record<DictKey, string> = {
"projectTree.renameProjectWorkbench": "重命名项目",
"projectTree.projectHistory": "查看项目历史",
"projectTree.justNow": "刚刚",
"projectTree.previously": "之前",
"projectTree.running": "输出中",
"projectTree.status.thinking": "思考中",
"projectTree.status.streaming": "输出中",
@@ -846,6 +925,7 @@ export const zh: Record<DictKey, string> = {
"settings.tab.capabilities": "MCP 与技能",
"settings.tab.mcp": "MCP 与工具",
"settings.tab.skills": "技能",
"settings.tab.plugins": "插件",
"settings.tab.memory": "记忆",
"settings.tab.hooks": "Hooks",
"settings.tab.shortcuts": "快捷键",
@@ -861,6 +941,7 @@ export const zh: Record<DictKey, string> = {
"settings.tabSub.capabilities": "服务器、工具与技能",
"settings.tabSub.mcp": "服务器与工具",
"settings.tabSub.skills": "来源与启用",
"settings.tabSub.plugins": "包与诊断",
"settings.tabSub.memory": "项目与全局",
"settings.tabSub.hooks": "Shell 自动化",
"settings.tabSub.shortcuts": "按键与帮助",
@@ -942,6 +1023,10 @@ export const zh: Record<DictKey, string> = {
"settings.subagentEffort": "子代理 effort",
"settings.subagentEffortDefault": "auto(模型服务默认)",
"settings.subagentHint": "作用于 task 和 runAs=subagent skills;工具调用参数或单个 skill 配置会覆盖它。",
"settings.subagentDepth": "子代理嵌套深度",
"settings.subagentDepthHint": "控制第一层子代理能否再派发一层 reviewer 或 worker 子代理。",
"settings.subagentDepthOne": "1 层",
"settings.subagentDepthTwo": "2 层",
"settings.defaultToolApprovalMode": "新会话默认审批",
"settings.defaultToolApprovalModeHint": "保存在全局设置中,仅用于新建会话;切换或恢复已有会话时不会覆盖其 Ask / Auto / Yolo 状态。",
"settings.defaultToolApprovalMode.ask": "询问",
@@ -964,7 +1049,7 @@ export const zh: Record<DictKey, string> = {
"settings.coldResumePrune.on": "开",
"settings.coldResumePrune.off": "关",
"settings.reasoningLanguage": "思考语言",
"settings.reasoningLanguageHint": "只影响可见思考过程。自动跟随对话语言,不额外注入语言指令。",
"settings.reasoningLanguageHint": "只影响可见思考过程。自动在原始提问明显为中文时锚定中文,引用内容不参与判断。",
"settings.reasoningLanguage.auto": "自动",
"settings.reasoningLanguage.zh": "中文",
"settings.reasoningLanguage.en": "English",
@@ -1071,11 +1156,76 @@ export const zh: Record<DictKey, string> = {
"settings.toggleOff": "关闭",
"settings.botSetup": "机器人入口",
"settings.botSetupHint": "先开启入口,再选择要连接的聊天渠道。",
"settings.botConnectPhoneTitle": "添加 IM Bot",
"settings.botConnectPhoneSubtitle": "QQ 手动配置,飞书、Lark 和微信扫码接入。",
"settings.botGateway": "机器人网关",
"settings.botGatewayHint": "桌面端统一管理 IM bot 的总开关、模型和运行参数。",
"settings.botGatewayHint": "桌面端统一管理 IM bot 的总开关和连接状态。",
"settings.botBrowserPreviewWarning": "当前是浏览器预览,只会保存 mock 设置,不会写入本机配置或启动 QQ/IM Bot gateway。请在桌面应用或 CLI 中配置后再到 QQ 客户端测试。",
"settings.botSimpleEyebrow": "傻瓜式配置",
"settings.botSimpleTitle": "移动端与聊天机器人",
"settings.botSimpleHint": "先用手机扫码远程控制当前工作区;需要长期从聊天工具访问时,再选择一个 Bot Channel 配置。",
"settings.botMobileRemoteEyebrow": "独立入口",
"settings.botMobileRemoteTitle": "手机远程控制",
"settings.botMobileRemoteHint": "扫码或复制链接,从移动端打开当前工作区的控制入口;它和 IM Bot 渠道管理分开配置。",
"settings.botMobileRemoteControlApi": "控制链接",
"settings.botMobileRemoteDisabled": "开启后生成二维码和链接",
"settings.botMobileRemoteEnableFirst": "开启后生成二维码",
"settings.botMobileRemoteReady": "等待手机连接",
"settings.botMobileRemoteWaiting": "手机扫码连接",
"settings.botMobileRemoteLoopbackNotice": "当前控制 API 仍是本机 loopback,只适合本机预览;跨设备 relay 接入后会沿用这个入口。",
"settings.botMobileRemoteCopyLink": "复制链接",
"settings.botMobileRemoteCopied": "已复制",
"settings.botMobileRemoteCopyFailed": "复制失败",
"settings.botChannelEntryTitle": "使用 Bot Channel",
"settings.botChannelEntryHint": "连接聊天 Bot,适合更长时间的移动端访问。",
"settings.botManageBots": "机器人管理",
"settings.botManageBotsHint": "左侧选择渠道;未配置时扫码或填写密钥,配置后在右侧管理这个机器人的运行设置。",
"settings.botBackToOverview": "返回入口",
"settings.botChannelOpenConfigured": "已配置,可继续修改设置。",
"settings.botChannelNeedsSetup": "未配置",
"settings.botChannelTabsLabel": "机器人渠道",
"settings.botChannelSetupTitle": "配置 {provider}",
"settings.botChannelSetupHint": "未配置前只需要完成凭据或扫码绑定;成功后会自动切换为具体设置。",
"settings.botSetupStatus": "机器人设置状态",
"settings.botSetupStatusConnected": "{online}/{total} 在线",
"settings.botSetupStatusAccessTrusted": "受信任名单 {count}",
"settings.botSetupStatusAccessEveryone": "所有人可用",
"settings.botSetupStatusAccessPerBot": "{configured}/{total} 个 Bot 已配置",
"settings.botMode": "机器人设置模式",
"settings.botModeSimple": "普通",
"settings.botModeAdvanced": "高级",
"settings.botShowAdvancedSettings": "高级设置",
"settings.botHideAdvancedSettings": "收起高级设置",
"settings.botAdvancedSettingsHint": "低频能力:路由、队列细项和自身消息过滤。",
"settings.botShowAddBot": "新增渠道",
"settings.botStepConnectTitle": "连接机器人",
"settings.botStepConnectHint": "QQ 手动填密钥;飞书、Lark、微信扫码即可接入。",
"settings.botStepConnectEmpty": "还没有连接",
"settings.botSharedAllowlistHint": "所有渠道共用第 2 步「谁可以使用」的名单与配对设置,改一处即可全部生效。",
"settings.botSharedAllowlistGo": "前往第 2 步编辑",
"settings.botGlobalAllowlist": "旧全局白名单",
"settings.botGlobalAllowlistHint": "兼容旧配置;新 Bot 推荐在各自详情里设置谁可以使用。",
"settings.botAccessShowAllPlatforms": "显示全部平台",
"settings.botAccessShowConnectedOnly": "只看已连接平台",
"settings.botListUsers": "用户",
"settings.botListGroups": "群",
"settings.botListApprovers": "审批人",
"settings.botListAdmins": "管理员",
"settings.botPlatformFeishuLark": "飞书 / Lark",
"settings.botQueueDropLabel": "队列满时",
"settings.botQueueDropHint": "队列达到上限后,如何处理放不下的消息。",
"settings.botPairingDetailHint": "配对开关在第 2 步「谁可以使用」里;这里只调整配对码有效期和待批数量。",
"settings.botSimpleAccessTitle": "谁可以使用",
"settings.botSimpleAccessTrustedSummary": "当前使用受信任名单,已填写 {count} 条成员或群。",
"settings.botSimpleAccessEveryoneSummary": "当前允许所有人使用,适合短时间测试。",
"settings.botAccessTrusted": "受信任名单",
"settings.botAccessTrustedHint": "只允许填写过的用户或群调用机器人。",
"settings.botAccessEveryone": "所有人可用",
"settings.botAccessEveryoneHint": "临时测试方便,但不建议长期打开。",
"settings.botAccessPairing": "私聊先配对",
"settings.botAccessPairingHint": "陌生私聊先发配对码,本机批准后再放行。",
"settings.botRoleAccess": "管理员与审批人",
"settings.botRoleAccessHint": "可选。普通用户不用填;需要远程审批或管理时再配置。",
"settings.botQueueModeSimple": "新消息怎么处理",
"settings.botQueueModeSimpleHint": "当机器人还在处理上一条消息时,选择下一条消息的处理方式。",
"settings.saveBotSettings": "保存机器人设置",
"settings.botEnableBot": "启用机器人",
"settings.botGatewayMasterSwitch": "总开关",
@@ -1086,8 +1236,48 @@ export const zh: Record<DictKey, string> = {
"settings.botModelHint": "留空时使用默认模型。",
"settings.botModelDefault": "使用默认模型",
"settings.botRuntime": "运行限制",
"settings.botRuntimeHint": "网关进程级限制,控制每次机器人任务最多走多远。",
"settings.botMaxSteps": "最大步数",
"settings.botDebounceMs": "合并窗口 ms",
"settings.botQueueCap": "队列上限",
"settings.botQueueMode.steer": "引导当前回合",
"settings.botQueueMode.followup": "追加跟进",
"settings.botQueueMode.collect": "收集等待",
"settings.botQueueMode.interrupt": "打断当前",
"settings.botQueueDrop.summarize": "汇总溢出",
"settings.botQueueDrop.old": "丢弃最旧",
"settings.botQueueDrop.new": "丢弃最新",
"settings.botIgnoreSelfMessages": "忽略自身回声",
"settings.botIgnoreSelfMessagesHint": "避免 gateway 再次处理由已配置 Bot 身份发出的消息。",
"settings.botSelfUserIds": "Bot 自身 ID",
"settings.botSelfUserIdsHint": "可选填写各平台 Bot 自己的 user/open ID,用于过滤回声消息。",
"settings.botPairing": "私聊配对",
"settings.botPairingTTL": "有效分钟",
"settings.botPairingMaxPending": "待批上限",
"settings.botControlApi": "本机控制 API",
"settings.botControlApiHint": "仅 loopback 的 HTTP API,可查状态和受控发送;需要从指定环境变量读取 Bearer token。",
"settings.botControlAddr": "控制地址",
"settings.botControlTokenEnv": "Token 变量",
"settings.botGatewayDefaults": "网关运行设置",
"settings.botGatewayDefaultsHint": "不用编辑 TOML,也能配置排队、配对和自身消息过滤。",
"settings.botRoutes": "聊天绑定到项目",
"settings.botRoutesHint": "把某个群、联系人或线程固定到指定工作目录、模型或审批方式。",
"settings.botRoutesEmpty": "还没有绑定规则。添加一条规则,把某个聊天、用户或线程绑定到项目或模型。",
"settings.botRouteTitle": "路由 {n}",
"settings.botAddRoute": "添加路由",
"settings.botRouteConnection": "来自哪个 Bot",
"settings.botRouteAny": "任意",
"settings.botRoutePlatform": "平台",
"settings.botRouteChatType": "聊天类型",
"settings.botRouteChatType.any": "任意",
"settings.botRouteChatType.dm": "私聊",
"settings.botRouteChatType.group": "群聊",
"settings.botRouteChatType.guild": "频道",
"settings.botRouteChatType.direct": "Direct",
"settings.botRouteChatType.thread": "Thread",
"settings.botRouteChatId": "群/聊天 ID",
"settings.botRouteUserId": "联系人 ID",
"settings.botRouteThreadId": "线程 ID",
"settings.botChannels": "连接渠道",
"settings.botChannelsHint": "选择渠道;QQ 手动配置,飞书、Lark 和微信扫码连接。",
"settings.botQQHint": "QQ 官方 Bot API v2。",
@@ -1142,8 +1332,7 @@ export const zh: Record<DictKey, string> = {
"settings.botInstallLarkHint": "扫码创建 Lark PersonalAgent。",
"settings.botInstallWeixinHint": "微信扫码登录并保存本地 token。",
"settings.botConnectedBots": "已连接 Bot",
"settings.botConnectedBotsSummary": "{online}/{total} 在线",
"settings.botConnectionsEmpty": "还没有连接的机器人渠道。",
"settings.botSelectBotHint": "选择左侧 Bot 进入配置。",
"settings.botConnectionColumnChannel": "渠道",
"settings.botConnectionColumnName": "机器人",
"settings.botConnectionColumnRemote": "远端 ID",
@@ -1161,16 +1350,17 @@ export const zh: Record<DictKey, string> = {
"settings.botConnectionConnected": "已连接",
"settings.botConnectionConfigured": "已配置",
"settings.botConnectionDisconnected": "未连接",
"settings.botManage": "高级设置",
"settings.botManage": "修改配置",
"settings.deleteBot": "删除机器人",
"settings.deleteBotShort": "删除",
"settings.confirmDeleteBot": "确认删除",
"settings.deleteBotHint": "从已连接 Bot 中移除这个机器人;不会自动清除已保存的密钥。",
"settings.botChannelModel": "模型",
"settings.botChannelModelHint": "留空时使用 Bot 默认模型。",
"settings.botChannelModelAuto": "自动",
"settings.botChannelModelHint": "这个机器人处理消息时使用的模型。",
"settings.botChannelModelAuto": "使用桌面当前模型",
"settings.botToolApprovalMode": "工具审批",
"settings.botToolApprovalModeHint": "控制该渠道是否在工具执行前请求确认;计划确认和 ask 问题仍会等待回复。",
"settings.botToolApprovalMode.inherit": "跟随默认",
"settings.botToolApprovalMode.inherit": "跟随机器人",
"settings.botToolApprovalMode.ask": "询问",
"settings.botToolApprovalMode.auto": "自动",
"settings.botToolApprovalMode.yolo": "YOLO",
@@ -1193,26 +1383,17 @@ export const zh: Record<DictKey, string> = {
"settings.botDiagnosticReportFailed": "发送诊断报告失败。",
"settings.botDiagnosticPrivacy": "仅包含已脱敏的连接元信息。",
"settings.botAccessControl": "访问控制",
"settings.botAccessControlHint": "默认使用白名单;临时测试可以改为允许所有人。",
"settings.botAccessMode": "谁可以使用",
"settings.botAccessAll": "所有人",
"settings.botAccessWhitelist": "仅白名单",
"settings.botEditAllowlist": "编辑白名单",
"settings.botAllowlist": "白名单",
"settings.botAllowlistHint": "限制哪些用户或群可以调用机器人。",
"settings.botAllowlistEnabled": "启用白名单",
"settings.botAllowAll": "允许所有人",
"settings.botAllowAllWarn": "开启后所有进入渠道的消息都可以调用机器人。",
"settings.botQQAccessRequired": "启用 QQ Bot 前,请先在高级设置里添加 QQ 用户/群白名单,或临时改为允许所有人。",
"settings.botAllowlistEntries": "用户与群",
"settings.botQQAccessRequired": "启用 QQ Bot 前,请先在第 2 步「谁可以使用」里添加 QQ 用户/群,或临时改为所有人可用。",
"settings.botQQUsers": "QQ 用户",
"settings.botQQGroups": "QQ群",
"settings.botFeishuUsers": "飞书用户",
"settings.botFeishuGroups": "飞书群",
"settings.botFeishuLarkUsers": "飞书 / Lark 用户",
"settings.botFeishuLarkGroups": "飞书 / Lark 群",
"settings.botWeixinUsers": "微信用户",
"settings.botWeixinGroups": "微信群",
"settings.botListPlaceholder": "每行一个 ID,也可用逗号分隔",
"settings.permissions": "权限",
"settings.writerMode": "写操作模式",
@@ -1224,17 +1405,23 @@ export const zh: Record<DictKey, string> = {
"settings.yoloHint": "Yolo批准会跳过所有工具权限审批(写文件、bash)。deny 规则仍会拦截,ask 问题和计划确认仍会等待你选择。仅运行时生效,重启后失效。",
"settings.addRule": "添加 {list} 规则…",
"settings.sandboxTitle": "沙箱与工作区",
"settings.sandboxBoundaryHint": "沙箱可写根只限制已批准的 Bash 和文件工具能写到哪里;它不会自动批准命令。若 Bash 沙箱为 enforce 但宿主机没有 OS 沙箱后端,shell 命令会被拒绝;只在可信工作区把 Bash 沙箱切到 off。",
"settings.reloadSessionConfig": "应用手动配置修改",
"settings.reloadSessionConfigHint": "设置页内修改会自动生效;此按钮用于应用手动编辑 config.toml / reasonix.toml 后的变更。",
"settings.shellInterpreter": "Shell 解释器",
"settings.shellAuto": "自动(优先 bash,Windows 无 bash 时用 PowerShell)",
"settings.shellAuto": "自动(bash 优先)",
"settings.shellBash": "bash",
"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": "工作区根目录",
"settings.workspaceDefault": "(默认:当前目录)",
"settings.effectiveWriteRoots": "实际可写根",
"settings.effectiveWriteRootsHint": "按当前工作区展开。设置页内修改会自动生效;手动编辑配置文件后可用上方按钮刷新当前会话。",
"settings.noEffectiveWriteRoots": "暂无可写根",
"settings.appearance": "外观",
"settings.theme": "主题",
"settings.themeStyle": "视觉风格",
@@ -1320,6 +1507,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": "微软雅黑",
@@ -1342,6 +1532,7 @@ export const zh: Record<DictKey, string> = {
"settings.pageDesc.bots": "配置 QQ、飞书、Lark 和微信机器人渠道,管理每个 Bot 的模型和运行参数。",
"settings.pageDesc.mcp": "管理 MCP 服务器连接和工具发现。",
"settings.pageDesc.skills": "浏览、启用和管理智能体技能与来源。",
"settings.pageDesc.plugins": "安装和管理插件包,用统一单元提供技能、hooks 与 MCP 服务器。",
"settings.pageDesc.memory": "管理 Reasonix 会长期参考的偏好、项目约定和指令文件。",
"settings.pageDesc.hooks": "配置围绕对话、工具调用、压缩和会话生命周期执行的 shell hooks。",
"settings.pageDesc.shortcuts": "自定义键盘快捷键,并让帮助表保持同步。",
@@ -1356,7 +1547,7 @@ export const zh: Record<DictKey, string> = {
"settings.customProviders": "自定义供应商",
"settings.customProvidersHint": "你自己添加的 OpenAI-compatible 或代理接入,可以编辑、删除并持久化模型列表。",
"settings.customProvidersEmpty": "还没有自定义供应商。常用服务优先使用上方内置接入。",
"settings.providerAccessEmptyHint": "点击“添加供应商”,选择 DeepSeek 或自定义 OpenAI-compatible 接入。",
"settings.providerAccessEmptyHint": "使用“推荐预设”可快速接入常用供应商,通常只需填写 API Key;使用“自定义供应商”可手动配置 OpenAI-compatible 或 Anthropic-compatible 端点。",
"settings.builtinProviderBadge": "内置",
"settings.customProviderBadge": "自定义",
"settings.configureProvider": "配置",
@@ -1368,24 +1559,77 @@ export const zh: Record<DictKey, string> = {
"settings.searchModels": "搜索模型...",
"settings.noMatchingModels": "没有匹配的模型",
"settings.addProvider.chooseTitle": "添加供应商",
"settings.addProvider.chooseHint": "选择官方预设,或添加一个自定义 OpenAI-compatible 接入。",
"settings.addProvider.officialChoice": "官方供应商",
"settings.addProvider.officialChoiceHint": "从 Reasonix 预设的 DeepSeek 接入中选择。",
"settings.addProvider.chooseHint": "选择推荐预设,或手动添加一个自定义供应商。",
"settings.addProvider.officialChoice": "推荐预设",
"settings.addProvider.officialChoiceHint": "选择 DeepSeek 或已适配的自定义供应商预设。",
"settings.addProvider.customChoice": "自定义供应商",
"settings.addProvider.customChoiceHint": "按 base_url、模型、密钥环境变量等参数手动配置。",
"settings.addProvider.officialTitle": "添加官方供应商",
"settings.addProvider.officialHint": "官方预设会写入固定端点和默认参数,只需要选择供应商并配置密钥。",
"settings.addProvider.officialHint": "推荐预设会填好端点、模型、视觉和推理默认值,通常只需要填 API Key。",
"settings.addProvider.customTitle": "添加自定义供应商",
"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。",
"settings.addProvider.preset.mimoApiDesc": "小米 MiMo 直连 API,适合 v2.5 按量付费模型。",
"settings.addProvider.preset.mimoAnthropicDesc": "小米 MiMo 直连 Anthropic-compatible 端点,自动使用 MiMo v2.5 模型。",
"settings.addProvider.preset.mimoTokenPlanCnDesc": "小米 MiMo token plan 中国区端点。",
"settings.addProvider.preset.mimoTokenPlanCnAnthropicDesc": "小米 MiMo token plan 中国区 Anthropic-compatible 端点。",
"settings.addProvider.preset.mimoTokenPlanSgpDesc": "小米 MiMo token plan 新加坡端点。",
"settings.addProvider.preset.mimoTokenPlanSgpAnthropicDesc": "小米 MiMo token plan 新加坡 Anthropic-compatible 端点。",
"settings.addProvider.preset.mimoTokenPlanAmsDesc": "小米 MiMo token plan 阿姆斯特丹端点。",
"settings.addProvider.preset.mimoTokenPlanAmsAnthropicDesc": "小米 MiMo token plan 阿姆斯特丹 Anthropic-compatible 端点。",
"settings.addProvider.preset.minimaxCnApiDesc": "MiniMax 国内 OpenAI-compatible M 系列 API 端点。",
"settings.addProvider.preset.minimaxGlobalApiDesc": "MiniMax 国际 OpenAI-compatible M 系列 API 端点。",
"settings.addProvider.preset.minimaxCnAnthropicDesc": "MiniMax 国内 Anthropic-compatible M 系列端点。",
"settings.addProvider.preset.minimaxGlobalAnthropicDesc": "MiniMax 国际 Anthropic-compatible 端点,自动使用 Bearer 认证。",
"settings.addProvider.preset.glmCnDesc": "智谱 GLM 国内 API,预置 GLM thinking 控制。",
"settings.addProvider.preset.zaiGlobalDesc": "Z.AI 国际 GLM API。",
"settings.addProvider.preset.glmCodingPlanCnDesc": "智谱 GLM 国内 coding plan 端点,默认 GLM-5.2。",
"settings.addProvider.preset.glmCodingPlanCnAnthropicDesc": "智谱 GLM 国内 Anthropic-compatible coding plan 端点,默认 GLM-5.2[1m]。",
"settings.addProvider.preset.zaiCodingPlanGlobalDesc": "Z.AI 国际 coding plan 端点,默认 GLM-5.2。",
"settings.addProvider.preset.zaiCodingPlanGlobalAnthropicDesc": "Z.AI 国际 Anthropic-compatible coding plan 端点,默认 GLM-5.2[1m]。",
"settings.addProvider.preset.opencodeGoDesc": "OpenCode Go 混合模型网关,内置每模型能力覆盖。",
"settings.addProvider.preset.opencodeGoAnthropicDesc": "OpenCode Go Anthropic 线路,预置 Qwen 与 MiniMax 模型。",
"settings.addProvider.preset.opencodeZenAnthropicDesc": "OpenCode Zen Anthropic 线路,预置 Claude 与 Qwen 模型。",
"settings.addProvider.preset.qwenCnDesc": "Qwen/DashScope 国内标准 API 端点。",
"settings.addProvider.preset.qwenGlobalDesc": "Qwen/DashScope 国际标准 API 端点。",
"settings.addProvider.preset.qwenCodingPlanCnDesc": "Qwen Coding Plan 国内 OpenAI-compatible 端点,默认 qwen3.7-plus。",
"settings.addProvider.preset.qwenCodingPlanCnAnthropicDesc": "Qwen Coding Plan 国内 Anthropic-compatible 端点,默认 qwen3.7-plus。",
"settings.addProvider.preset.qwenCodingPlanGlobalDesc": "Qwen Coding Plan 国际 OpenAI-compatible 端点,默认 qwen3.7-plus。",
"settings.addProvider.preset.qwenCodingPlanGlobalAnthropicDesc": "Qwen Coding Plan 国际 Anthropic-compatible 端点,默认 qwen3.7-plus。",
"settings.addProvider.preset.stepfunDesc": "阶跃星辰 StepFun coding plan OpenAI-compatible 端点,默认 step-3.7-flash。",
"settings.addProvider.preset.stepfunAnthropicDesc": "阶跃星辰 StepFun coding plan Anthropic-compatible 端点,默认 step-3.7-flash。",
"settings.addProvider.preset.novitaDesc": "NovitaAI 多模型网关,预置 Kimi、MiniMax、GLM、DeepSeek、Qwen。",
"settings.addProvider.preset.gmiDesc": "GMI Cloud 多模型直连网关,预置 GLM、DeepSeek、Kimi、Gemini、Claude、GPT。",
"settings.addProvider.preset.vercelAiGatewayDesc": "Vercel AI Gateway,走 Anthropic-compatible Messages API。",
"settings.addProvider.preset.huggingfaceDesc": "HuggingFace Inference Router,预置 Qwen 与 DeepSeek 模型。",
"settings.addProvider.preset.nvidiaDesc": "NVIDIA NIM 加速推理端点,预置 Nemotron 模型。",
"settings.addProvider.preset.kilocodeDesc": "Kilo Code 网关,预置 kilo/auto 自动路由模型。",
"settings.addProvider.preset.ollamaCloudDesc": "Ollama Cloud 托管端点,支持 none 到 max 的 reasoning effort。",
"settings.addProvider.addedBadge": "已添加",
"settings.addProvider.modifiedBadge": "已修改",
"settings.addProvider.nameConflictBadge": "同名占用",
"settings.addProvider.similarExistingBadge": "有相似配置",
"settings.addProvider.confirm": "添加供应商",
"settings.addProvider.alreadyAddedAction": "已添加",
"settings.addProvider.nameConflictAction": "先处理同名配置",
"settings.addProvider.viewPresetProvider": "查看配置",
"settings.addProvider.viewConflictProvider": "查看同名配置",
"settings.addProvider.resetPreset": "重置为预设",
"settings.addProvider.confirmResetPreset": "确认重置",
"settings.customProviderName": "自定义供应商名称",
"settings.customProviderNamePlaceholder": "",
"settings.providerNamePlaceholder": "供应商名称(如 deepseek-proxy)",
"settings.providerProtocol": "接入协议",
"settings.providerProtocolOpenAI": "OpenAI-compatible",
"settings.providerProtocolOpenAIHint": "适用于兼容 OpenAI API 的代理、聚合平台和自建服务。",
"settings.providerProtocolAnthropic": "Anthropic-compatible",
"settings.providerProtocolAnthropicHint": "适用于 Claude Messages API 风格的服务,例如部分 coding plan 端点。",
"settings.providerBaseUrlLabel": "API 地址",
"settings.providerChatUrlLabel": "完整请求 URL",
"settings.providerChatUrlPlaceholder": "例如 https://api.openai.com/v1/chat/completions",
@@ -1408,6 +1652,12 @@ export const zh: Record<DictKey, string> = {
"settings.providerHeaders": "额外请求头(可选)",
"settings.providerHeadersPlaceholder": "HTTP-Referer: https://example.com\nX-Title: Reasonix",
"settings.providerHeadersHint": "用于 OpenRouter 等要求站点来源的中转站。一行一个 Header: value;API Key 仍通过上方密钥保存。",
"settings.providerExtraBody": "额外请求体(可选)",
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
"settings.providerExtraBodyHint": "会合并到聊天请求体顶层 JSON。model、messages、tools、stream、thinking 等核心字段仍由 Reasonix 控制。",
"settings.providerExtraBodyError": "请输入不含 null 值的 JSON 对象,例如 { \"enable_thinking\": true }。",
"settings.providerAuthHeader": "使用 Authorization: Bearer 发送密钥",
"settings.providerAuthHeaderHint": "适用于 MiniMax Global、Vercel AI Gateway 等不使用 x-api-key 的 Anthropic-compatible 网关。",
"settings.fetchingModels": "刷新中…",
"settings.fetchModelsSuccess": "已获取 {n} 个模型",
"settings.fetchModelsManualFallbackAuth": "模型发现接口拒绝了密钥或权限。请检查 API Key;如果该服务不开放模型发现,也可以在下方手动填写模型 ID。",
@@ -1427,10 +1677,16 @@ export const zh: Record<DictKey, string> = {
"settings.newProviderDraftName": "新供应商",
"settings.reasoningProtocol": "模型能力模式",
"settings.reasoningProtocol.auto": "自动识别(推荐)",
"settings.reasoningProtocol.deepseek": "DeepSeek 思考",
"settings.reasoningProtocol.openai": "OpenAI reasoning",
"settings.reasoningProtocol.deepseek": "DeepSeek 思考(thinking + 深度)",
"settings.reasoningProtocol.openai": "OpenAI reasoning(reasoning_effort)",
"settings.reasoningProtocol.none": "普通聊天(不发送思考参数)",
"settings.reasoningProtocolHint": "默认自动识别。只有代理不兼容,或你确定模型需要特定 reasoning 格式时再切换。",
"settings.thinkingMode": "Thinking 覆盖",
"settings.thinkingMode.auto": "Auto(使用服务默认)",
"settings.thinkingMode.enabled": "Enabled(开启)",
"settings.thinkingMode.disabled": "Disabled(关闭)",
"settings.thinkingMode.adaptive": "Adaptive(自适应)",
"settings.thinkingModeHint": "服务商专用覆盖项。兼容网关可用 enabled/disabled;只有服务文档明确支持时才使用 adaptive。",
"settings.updateKey": "更新 {env}(全局保存)",
"settings.updateKeyAction": "更新密钥",
"settings.keyStatus": "密钥",
@@ -1545,6 +1801,8 @@ export const zh: Record<DictKey, string> = {
"msg.imageAttachment": "图片",
"msg.workspaceReference": "工作区引用",
"msg.folderReference": "文件夹 · 工作区引用",
"msg.pastedExpandTooltip": "展开粘贴文本",
"msg.pastedCollapseTooltip": "收起粘贴文本",
"turnActions.summary": "总结",
"turnActions.rewind": "回溯",
"transcript.jumpToBottom": "跳到底部",
@@ -1626,7 +1884,7 @@ export const zh: Record<DictKey, string> = {
"settings.telemetryLabel": "匿名启动统计",
"settings.telemetryHint": "启动时发送随机安装 ID、版本号和操作系统用于统计活跃安装量;若上次运行发生崩溃,则在本次启动补发该崩溃报告。绝不包含对话、密钥或文件内容。",
"settings.metricsLabel": "共享聚合质量指标",
"settings.metricsHint": "默认开启。发送匿名的轮次结束统计、设置偏好快照,以及 Memory v5 聚合桶,例如是否注入、大小桶、IR 数量桶和记忆图规模桶。包含用于 DAU 去重的随机安装 ID。Bucket 可能包含归一化后的自定义 Provider 名和模型名——绝不包含对话、提示词、密钥、路径、base URL、记忆正文、工具输出或文件内容。",
"settings.metricsHint": "默认开启。发送匿名的轮次结束统计、更新器错误类别、设置偏好快照,以及 Memory v5 聚合桶,例如是否注入、大小桶、IR 数量桶和记忆图规模桶。包含用于 DAU 去重的随机安装 ID。Bucket 可能包含归一化后的自定义 Provider 名和模型名——绝不包含对话、提示词、密钥、路径、base URL、记忆正文、工具输出或文件内容。",
"updater.currentVersion": "当前版本:{v}",
"updater.channelLabel": "更新渠道:{channel}",
"updater.checkButton": "检查更新",
File diff suppressed because it is too large. Load diff
+11
View File
@@ -25,7 +25,9 @@ require (
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/BurntSushi/toml v1.6.0 // 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
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
@@ -33,6 +35,7 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/labstack/echo/v4 v4.15.2 // indirect
github.com/labstack/gommon v0.5.0 // indirect
github.com/larksuite/oapi-sdk-go/v3 v3.9.5 // indirect
@@ -42,6 +45,7 @@ require (
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-pointer v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
@@ -49,6 +53,11 @@ require (
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
github.com/samber/lo v1.53.0 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/tree-sitter/go-tree-sitter v0.25.0 // indirect
github.com/tree-sitter/tree-sitter-javascript v0.25.0 // indirect
github.com/tree-sitter/tree-sitter-python v0.25.0 // indirect
github.com/tree-sitter/tree-sitter-rust v0.24.2 // indirect
github.com/tree-sitter/tree-sitter-typescript v0.23.2 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
@@ -57,6 +66,8 @@ require (
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/image v0.43.0 // indirect
golang.org/x/net v0.56.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
mvdan.cc/sh/v3 v3.13.1 // indirect
)
replace reasonix => ../
+49
View File
@@ -7,8 +7,12 @@ 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/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=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
@@ -18,10 +22,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
@@ -29,8 +37,14 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.15.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4=
github.com/labstack/echo/v4 v4.15.2/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI=
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
@@ -54,6 +68,8 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0=
github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
@@ -67,6 +83,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
@@ -79,6 +97,34 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/tree-sitter/go-tree-sitter v0.25.0 h1:sx6kcg8raRFCvc9BnXglke6axya12krCJF5xJ2sftRU=
github.com/tree-sitter/go-tree-sitter v0.25.0/go.mod h1:r77ig7BikoZhHrrsjAnv8RqGti5rtSyvDHPzgTPsUuU=
github.com/tree-sitter/tree-sitter-c v0.23.4 h1:nBPH3FV07DzAD7p0GfNvXM+Y7pNIoPenQWBpvM++t4c=
github.com/tree-sitter/tree-sitter-c v0.23.4/go.mod h1:MkI5dOiIpeN94LNjeCp8ljXN/953JCwAby4bClMr6bw=
github.com/tree-sitter/tree-sitter-cpp v0.23.4 h1:LaWZsiqQKvR65yHgKmnaqA+uz6tlDJTJFCyFIeZU/8w=
github.com/tree-sitter/tree-sitter-cpp v0.23.4/go.mod h1:doqNW64BriC7WBCQ1klf0KmJpdEvfxyXtoEybnBo6v8=
github.com/tree-sitter/tree-sitter-embedded-template v0.23.2 h1:nFkkH6Sbe56EXLmZBqHHcamTpmz3TId97I16EnGy4rg=
github.com/tree-sitter/tree-sitter-embedded-template v0.23.2/go.mod h1:HNPOhN0qF3hWluYLdxWs5WbzP/iE4aaRVPMsdxuzIaQ=
github.com/tree-sitter/tree-sitter-go v0.23.4 h1:yt5KMGnTHS+86pJmLIAZMWxukr8W7Ae1STPvQUuNROA=
github.com/tree-sitter/tree-sitter-go v0.23.4/go.mod h1:Jrx8QqYN0v7npv1fJRH1AznddllYiCMUChtVjxPK040=
github.com/tree-sitter/tree-sitter-html v0.23.2 h1:1UYDV+Yd05GGRhVnTcbP58GkKLSHHZwVaN+lBZV11Lc=
github.com/tree-sitter/tree-sitter-html v0.23.2/go.mod h1:gpUv/dG3Xl/eebqgeYeFMt+JLOY9cgFinb/Nw08a9og=
github.com/tree-sitter/tree-sitter-java v0.23.5 h1:J9YeMGMwXYlKSP3K4Us8CitC6hjtMjqpeOf2GGo6tig=
github.com/tree-sitter/tree-sitter-java v0.23.5/go.mod h1:NRKlI8+EznxA7t1Yt3xtraPk1Wzqh3GAIC46wxvc320=
github.com/tree-sitter/tree-sitter-javascript v0.25.0 h1:ZkWETb66/w8cc13yhfnNuHOLDQWl3BnKlH6f9AdR88c=
github.com/tree-sitter/tree-sitter-javascript v0.25.0/go.mod h1:lmGD1EJdCA+v0S1u2fFgepMg/opzSg/4pgFym2FPGAs=
github.com/tree-sitter/tree-sitter-json v0.24.8 h1:tV5rMkihgtiOe14a9LHfDY5kzTl5GNUYe6carZBn0fQ=
github.com/tree-sitter/tree-sitter-json v0.24.8/go.mod h1:F351KK0KGvCaYbZ5zxwx/gWWvZhIDl0eMtn+1r+gQbo=
github.com/tree-sitter/tree-sitter-php v0.23.11 h1:iHewsLNDmznh8kgGyfWfujsZxIz1YGbSd2ZTEM0ZiP8=
github.com/tree-sitter/tree-sitter-php v0.23.11/go.mod h1:T/kbfi+UcCywQfUNAJnGTN/fMSUjnwPXA8k4yoIks74=
github.com/tree-sitter/tree-sitter-python v0.25.0 h1:O6XD9v8U1LOcRc3cNj9nM7XufrtEBezE6VrpRrHZDf0=
github.com/tree-sitter/tree-sitter-python v0.25.0/go.mod h1:cpdthSy/Yoa28aJFBscFHlGiU+cnSiSh1kuDVtI8YeM=
github.com/tree-sitter/tree-sitter-ruby v0.23.1 h1:T/NKHUA+iVbHM440hFx+lzVOzS4dV6z8Qw8ai+72bYo=
github.com/tree-sitter/tree-sitter-ruby v0.23.1/go.mod h1:kUS4kCCQloFcdX6sdpr8p6r2rogbM6ZjTox5ZOQy8cA=
github.com/tree-sitter/tree-sitter-rust v0.24.2 h1:NL4nF67ib21RMzzfvkmXlVwe45vvhW10DVyO+D0z/W0=
github.com/tree-sitter/tree-sitter-rust v0.24.2/go.mod h1:hfeGWic9BAfgTrc7Xf6FaOAguCFJRo3RBbs7QJ6D7MI=
github.com/tree-sitter/tree-sitter-typescript v0.23.2 h1:/Odvphn18PniVixb9e97X0DbNVsU6Qocv9mfkyzdXwU=
github.com/tree-sitter/tree-sitter-typescript v0.23.2/go.mod h1:zjzMXT/Ulffel2xfOcAkQQkiAkmgnbtPGlFQw/5X4xA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
@@ -146,7 +192,10 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
+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)
}
}
+19
View File
@@ -88,6 +88,25 @@ func TestHistoryMessagesDoNotReplayMemoryCompilerContract(t *testing.T) {
assertNoHistoryMemoryContract(t, got[0].Content)
}
func TestHistoryMessagesStripActiveGoalFromVisibleUserContent(t *testing.T) {
raw := "<active-goal>\nship the approval redesign\n</active-goal>\n\ncontinue implementation"
msgs := []provider.Message{
{Role: provider.RoleUser, Content: raw},
{Role: provider.RoleAssistant, Content: "done"},
}
got := historyMessages(msgs, control.StripComposePrefixes)
if len(got) != 2 {
t.Fatalf("history length = %d, want 2: %+v", len(got), got)
}
if got[0].Content != "continue implementation" {
t.Fatalf("visible user content = %q, want active-goal stripped", got[0].Content)
}
if strings.Contains(got[0].Content, "<active-goal>") || strings.Contains(got[0].Content, "ship the approval redesign") {
t.Fatalf("active-goal leaked into visible history content: %+v", got[0])
}
}
func TestHistoryMessagesCarryCheckpointTurnsAcrossHiddenSyntheticUsers(t *testing.T) {
msgs := []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"testing"
"reasonix/internal/config"
"reasonix/internal/event"
"reasonix/internal/notify"
)
type desktopRecordSink struct {
events []event.Kind
}
func (s *desktopRecordSink) Emit(e event.Event) {
s.events = append(s.events, e.Kind)
}
type desktopRecordSender struct {
messages []notify.Message
}
func (s *desktopRecordSender) Send(m notify.Message) error {
s.messages = append(s.messages, m)
return nil
}
func TestDesktopControllerSinkSendsConfiguredSystemNotifications(t *testing.T) {
inner := &desktopRecordSink{}
sender := &desktopRecordSender{}
app := &App{notificationSender: sender}
sink := app.desktopControllerSink(inner, config.NotificationsConfig{
Enabled: true,
TurnDone: true,
ApprovalRequest: true,
AskRequest: true,
})
sink.Emit(event.Event{Kind: event.TurnDone})
if len(inner.events) != 1 || inner.events[0] != event.TurnDone {
t.Fatalf("forwarded events = %+v, want [TurnDone]", inner.events)
}
if len(sender.messages) != 1 {
t.Fatalf("notifications = %d, want 1", len(sender.messages))
}
if sender.messages[0].Body != "Turn finished" {
t.Fatalf("notification body = %q, want Turn finished", sender.messages[0].Body)
}
}
func TestDesktopControllerSinkSkipsSystemNotificationsWhenDisabled(t *testing.T) {
inner := &desktopRecordSink{}
sender := &desktopRecordSender{}
app := &App{notificationSender: sender}
sink := app.desktopControllerSink(inner, config.NotificationsConfig{
Enabled: false,
TurnDone: true,
})
sink.Emit(event.Event{Kind: event.TurnDone})
if len(inner.events) != 1 || inner.events[0] != event.TurnDone {
t.Fatalf("forwarded events = %+v, want [TurnDone]", inner.events)
}
if len(sender.messages) != 0 {
t.Fatalf("notifications = %d, want 0", len(sender.messages))
}
}
+246
View File
@@ -0,0 +1,246 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"reasonix/internal/config"
"reasonix/internal/installsource"
"reasonix/internal/pluginpkg"
)
type PluginView struct {
Name string `json:"name"`
Version string `json:"version,omitempty"`
Description string `json:"description,omitempty"`
Source string `json:"source,omitempty"`
Root string `json:"root"`
ManifestKind string `json:"manifestKind,omitempty"`
Enabled bool `json:"enabled"`
Skills int `json:"skills"`
Hooks int `json:"hooks"`
MCPServers int `json:"mcpServers"`
SkillDetails []PluginSkillView `json:"skillDetails,omitempty"`
HookDetails []PluginHookView `json:"hookDetails,omitempty"`
MCPServerDetails []PluginMCPServerView `json:"mcpServerDetails,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Error string `json:"error,omitempty"`
}
type PluginInstallOptions struct {
DryRun bool `json:"dryRun,omitempty"`
Link bool `json:"link,omitempty"`
Replace bool `json:"replace,omitempty"`
Name string `json:"name,omitempty"`
}
type PluginSkillView struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Path string `json:"path,omitempty"`
Invocation string `json:"invocation,omitempty"`
RunAs string `json:"runAs,omitempty"`
}
type PluginHookView struct {
Event string `json:"event"`
Match string `json:"match,omitempty"`
Command string `json:"command,omitempty"`
ContextFile string `json:"contextFile,omitempty"`
Description string `json:"description,omitempty"`
}
type PluginMCPServerView struct {
Name string `json:"name"`
Transport string `json:"transport,omitempty"`
Command string `json:"command,omitempty"`
URL string `json:"url,omitempty"`
}
func (a *App) Plugins() []PluginView {
st, err := pluginpkg.LoadState(config.ReasonixHomeDir())
if err != nil {
return []PluginView{{Error: err.Error()}}
}
out := make([]PluginView, 0, len(st.Plugins))
for _, p := range st.Plugins {
view := PluginView{
Name: p.Name,
Version: p.Version,
Description: p.Description,
Source: p.Source,
Root: pluginpkg.ResolveRoot(config.ReasonixHomeDir(), p.Root),
ManifestKind: p.ManifestKind,
Enabled: p.Enabled,
}
if pkg, warnings, err := pluginpkg.ParseDir(view.Root); err == nil {
applyPluginPackageDetails(&view, pkg, warnings)
} else {
view.Error = err.Error()
}
out = append(out, view)
}
return out
}
func applyPluginPackageDetails(view *PluginView, pkg pluginpkg.Package, warnings []string) {
view.Skills, view.Hooks, view.MCPServers = pkg.CapabilityCounts()
view.Warnings = warnings
inv := pkg.Inventory()
view.SkillDetails = make([]PluginSkillView, 0, len(inv.Skills))
for _, sk := range inv.Skills {
view.SkillDetails = append(view.SkillDetails, PluginSkillView{
Name: sk.Name,
Description: sk.Description,
Path: sk.Path,
Invocation: sk.Invocation,
RunAs: sk.RunAs,
})
}
view.HookDetails = make([]PluginHookView, 0, len(inv.Hooks))
for _, hook := range inv.Hooks {
view.HookDetails = append(view.HookDetails, PluginHookView{
Event: hook.Event,
Match: hook.Match,
Command: hook.Command,
ContextFile: hook.ContextFile,
Description: hook.Description,
})
}
view.MCPServerDetails = make([]PluginMCPServerView, 0, len(inv.MCPServers))
for _, server := range inv.MCPServers {
view.MCPServerDetails = append(view.MCPServerDetails, PluginMCPServerView{
Name: server.Name,
Transport: server.Transport,
Command: server.Command,
URL: server.URL,
})
}
}
func (a *App) PlanPluginInstall(source string, opts PluginInstallOptions) (string, error) {
opts.DryRun = true
return a.runPluginInstallSource(source, opts, false)
}
func (a *App) InstallPlugin(source string, opts PluginInstallOptions) (string, error) {
if err := a.ensureActiveTabRebuildAllowed("plugins"); err != nil {
return "", err
}
out, err := a.runPluginInstallSource(source, opts, true)
if err != nil {
return "", err
}
a.invalidateSkillRootsCache()
if rebuildErr := a.rebuild(); rebuildErr != nil {
if _, ok := a.deferredRebuildWarning("plugins", rebuildErr); ok {
return out, nil
}
return out, rebuildErr
}
return out, nil
}
func (a *App) RemovePlugin(name string) error {
if err := a.ensureActiveTabRebuildAllowed("plugins"); err != nil {
return err
}
raw, _ := json.Marshal(map[string]any{"op": "uninstall", "kind": "plugin", "name": strings.TrimSpace(name), "scope": "global"})
tl := installsource.NewTool(installsource.Options{
ProjectRoot: a.activeWorkspaceRoot(),
OnDisconnect: func(serverName string) bool {
tab := a.activeTab()
if tab == nil || tab.Ctrl == nil {
return false
}
removed, _ := tab.Ctrl.RemoveMCPServer(serverName)
return removed
},
})
if _, err := tl.Execute(context.Background(), raw); err != nil {
return err
}
a.invalidateSkillRootsCache()
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 {
if err := a.ensureActiveTabRebuildAllowed("plugins"); err != nil {
return err
}
if err := pluginpkg.SetEnabled(config.ReasonixHomeDir(), strings.TrimSpace(name), enabled); err != nil {
return err
}
a.invalidateSkillRootsCache()
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) {
name = strings.TrimSpace(name)
for _, p := range a.Plugins() {
if p.Name == name {
if strings.TrimSpace(p.Source) == "" {
return "", fmt.Errorf("plugin %q has no recorded source", name)
}
return a.InstallPlugin(p.Source, PluginInstallOptions{Name: name, Replace: true})
}
}
return "", fmt.Errorf("plugin %q is not installed", name)
}
func (a *App) PluginDoctor(name string) PluginView {
name = strings.TrimSpace(name)
for _, p := range a.Plugins() {
if p.Name != name {
continue
}
if p.Error != "" {
return p
}
if p.Root == "" {
p.Error = "missing plugin root"
return p
}
if _, err := os.Stat(p.Root); err != nil {
p.Error = err.Error()
return p
}
return p
}
return PluginView{Name: name, Error: "plugin is not installed"}
}
func (a *App) runPluginInstallSource(source string, opts PluginInstallOptions, apply bool) (string, error) {
mode := "copy"
if opts.Link {
mode = "link"
}
body := map[string]any{
"source": strings.TrimSpace(source),
"kind": "plugin",
"mode": mode,
"replace": opts.Replace,
"apply": apply && !opts.DryRun,
}
if strings.TrimSpace(opts.Name) != "" {
body["name"] = strings.TrimSpace(opts.Name)
}
raw, _ := json.Marshal(body)
tl := installsource.NewTool(installsource.Options{ProjectRoot: a.activeWorkspaceRoot()})
return tl.Execute(context.Background(), raw)
}
+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})
+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))
}
}
+171 -16
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,6 +205,14 @@ 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 {
return err
@@ -192,6 +225,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,11 +252,11 @@ 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)
@@ -247,11 +283,11 @@ func prepareSessionTrashTarget(dir, sessionPath, key string) (bool, 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 false, err
}
if discardable {
if removable {
return false, removeDesktopSessionArtifacts(sessionPath)
}
return false, fmt.Errorf("session already exists in trash: %s", key)
@@ -267,6 +303,29 @@ func prepareSessionTrashTarget(dir, sessionPath, key string) (bool, error) {
return true, nil
}
// 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
}
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) {
if agent.IsCleanupPending(sessionPath) {
return true, nil
@@ -291,6 +350,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
@@ -317,6 +390,13 @@ func trashSessionArtifactsBeforeMove(dir, sessionPath, key string, beforeMove fu
if !shouldMove {
return 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 := filepath.Join(sessionTrashPath(dir), key)
if err := os.MkdirAll(itemDir, 0o755); err != nil {
return err
@@ -332,6 +412,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 {
@@ -443,11 +526,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 +660,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 +733,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 +778,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)
@@ -853,6 +937,77 @@ 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
}
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(), "session already exists in trash") {
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)
}
}
+114
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)
}
@@ -731,12 +787,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 +824,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 +837,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 +1028,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")
+1212 -265
View File
File diff suppressed because it is too large. Load diff
+240 -1
View File
@@ -97,6 +97,16 @@ func TestProviderViewFromEntry_MigratesProviderWideVision(t *testing.T) {
}
}
func TestProviderViewFromEntryIncludesThinking(t *testing.T) {
view := providerViewFromEntry(config.ProviderEntry{
Name: "anthropic",
Thinking: "ADAPTIVE",
}, false, true)
if view.Thinking != "adaptive" {
t.Fatalf("ProviderView.Thinking = %q, want adaptive", view.Thinking)
}
}
func TestProviderViewFromEntryShowsKeySource(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("TEST_PROVIDER_KEY_SOURCE", "")
@@ -120,6 +130,43 @@ func TestProviderViewFromEntryShowsKeySource(t *testing.T) {
}
}
func TestSettingsExposesEffectiveSandboxWriteRoots(t *testing.T) {
home := isolateDesktopUserDirs(t)
project := robustTempDir(t)
cfg := config.LoadForEdit(config.UserConfigPath())
cfg.Sandbox.AllowWrite = []string{
"${HOME}/.m2",
"${HOME}/.m2/repository",
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
app := NewApp()
app.tabs = map[string]*WorkspaceTab{
"project": {ID: "project", Scope: "project", WorkspaceRoot: project, Ready: true},
}
app.activeTabID = "project"
got := app.Settings().Sandbox
if got.EffectiveWorkspaceRoot != project {
t.Fatalf("EffectiveWorkspaceRoot = %q, want %q", got.EffectiveWorkspaceRoot, project)
}
// Settings expose expanded configured roots; the writer confiner normalizes
// separators later when enforcing them.
want := []string{
project,
home + "/.m2",
home + "/.m2/repository",
}
if !reflect.DeepEqual(got.EffectiveWriteRoots, want) {
t.Fatalf("EffectiveWriteRoots = %v, want %v", got.EffectiveWriteRoots, want)
}
if !reflect.DeepEqual(got.AllowWrite, cfg.Sandbox.AllowWrite) {
t.Fatalf("AllowWrite = %v, want raw configured paths %v", got.AllowWrite, cfg.Sandbox.AllowWrite)
}
}
func TestProviderViewFromEntryExposesNoAuthAvailability(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("LOCAL_API_KEY", "")
@@ -382,6 +429,60 @@ func TestSaveProviderFiltersNonChatModels(t *testing.T) {
}
}
func TestSaveProviderPersistsThinkingOverride(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
if err := app.SaveProvider(ProviderView{
Name: "glm-proxy",
Kind: "openai",
BaseURL: "https://proxy.example.com/v1",
Models: []string{"glm-4.5-air"},
APIKeyEnv: "GLM_PROXY_API_KEY",
Thinking: "DISABLED",
}); err != nil {
t.Fatalf("SaveProvider: %v", err)
}
cfg := config.LoadForEdit(config.UserConfigPath())
got, ok := cfg.Provider("glm-proxy")
if !ok {
t.Fatal("saved provider not found")
}
if got.Thinking != "disabled" {
t.Fatalf("saved provider thinking = %q, want disabled", got.Thinking)
}
}
func TestSaveProviderPersistsAuthHeader(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
if err := app.SaveProvider(ProviderView{
Name: "minimax-global-anthropic",
Kind: "anthropic",
BaseURL: "https://api.minimax.io/anthropic",
Models: []string{"MiniMax-M3"},
APIKeyEnv: "MINIMAX_API_KEY",
AuthHeader: true,
}); err != nil {
t.Fatalf("SaveProvider: %v", err)
}
cfg := config.LoadForEdit(config.UserConfigPath())
got, ok := cfg.Provider("minimax-global-anthropic")
if !ok {
t.Fatal("saved provider not found")
}
if !got.AuthHeader {
t.Fatal("saved provider auth_header = false, want true")
}
view := providerViewFromEntry(*got, false, true)
if !view.AuthHeader {
t.Fatal("provider view authHeader = false, want true")
}
}
func TestSaveProviderPersistsCustomEndpointURLs(t *testing.T) {
isolateDesktopUserDirs(t)
@@ -443,6 +544,7 @@ func TestSaveProviderPreservesHiddenProviderFields(t *testing.T) {
Thinking: "adaptive",
Effort: "high",
VisionDetail: "low",
ExtraBody: map[string]any{"enable_thinking": true},
NoProxy: true,
}}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
@@ -463,6 +565,9 @@ func TestSaveProviderPreservesHiddenProviderFields(t *testing.T) {
if !found {
t.Fatalf("Settings providers missing custom: %+v", settings.Providers)
}
if view.ExtraBody["enable_thinking"] != true {
t.Fatalf("settings extra_body = %+v, want enable_thinking=true", view.ExtraBody)
}
if err := app.SaveProvider(view); err != nil {
t.Fatalf("SaveProvider: %v", err)
@@ -485,6 +590,9 @@ func TestSaveProviderPreservesHiddenProviderFields(t *testing.T) {
if got.VisionDetail != "low" {
t.Fatalf("vision_detail = %q, want low", got.VisionDetail)
}
if got.ExtraBody["enable_thinking"] != true {
t.Fatalf("extra_body = %+v, want enable_thinking=true", got.ExtraBody)
}
if !got.NoProxy {
t.Fatal("no_proxy = false, want preserved true")
}
@@ -721,7 +829,7 @@ func TestSetReasoningLanguageUpdatesLiveTabControllers(t *testing.T) {
}
userComposed := userCtrl.Compose("hi")
if !strings.Contains(userComposed, "Simplified Chinese") {
if !strings.Contains(userComposed, "简体中文") {
t.Fatalf("user-level tab Compose = %q, want zh reasoning language", userComposed)
}
projectComposed := projectCtrl.Compose("hi")
@@ -1109,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
@@ -122,7 +122,7 @@ api_key_env = "PROJECT_API_KEY"
if !got.Supported || got.Current != "auto" || got.Default != "high" {
t.Fatalf("EffortForTab model registry = %+v, want supported auto/high", got)
}
wantLevels := []string{"auto", "high", "max"}
wantLevels := []string{"auto", "disabled", "high", "max"}
if len(got.Levels) != len(wantLevels) {
t.Fatalf("levels = %v, want %v", got.Levels, wantLevels)
}
+1482 -311
View File
File diff suppressed because it is too large. Load diff
+236 -1
View File
@@ -9,6 +9,7 @@ import (
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
@@ -73,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")
@@ -181,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
@@ -292,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()
@@ -398,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()
@@ -453,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
@@ -679,6 +878,42 @@ func TestBuildTabControllerReusesOpenSessionPathRuntime(t *testing.T) {
}
}
func TestBuildTabControllerBlocksWhenSessionLeaseHeld(t *testing.T) {
isolateDesktopUserDirs(t)
dir := desktopSessionDir(globalTabWorkspaceRoot())
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := filepath.Join(dir, "leased.jsonl")
if err := os.WriteFile(path, nil, 0o644); err != nil {
t.Fatalf("write placeholder session: %v", err)
}
lease, err := agent.TryAcquireSessionLease(path)
if err != nil {
t.Fatalf("TryAcquireSessionLease: %v", err)
}
defer lease.Release()
app := NewApp()
tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "leased")
tab.SessionPath = path
tab.sink = &tabEventSink{tabID: "leased", app: app}
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
app.buildTabController(tab)
if tab.Ctrl != nil {
t.Fatalf("tab controller = %T, want nil when lease is held", tab.Ctrl)
}
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)
}
}
func TestOpenGlobalTabResolvesTopicToLatestSessionRuntime(t *testing.T) {
isolateDesktopUserDirs(t)
dir := desktopSessionDir(globalTabWorkspaceRoot())
+112
View File
@@ -1170,6 +1170,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)
@@ -1567,6 +1588,53 @@ func TestAutoTitleTopicStripsReasoningLanguagePrefix(t *testing.T) {
}
}
func TestAutoTitleTopicRefreshesOnThirdUserTurn(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topic, err := NewApp().CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
firstTurn := strings.Join([]string{
`{"role":"user","content":"帮我看看"}`,
`{"role":"assistant","content":"可以"}`,
}, "\n") + "\n"
if err := os.WriteFile(sessionPath, []byte(firstTurn), 0o644); err != nil {
t.Fatalf("write first session: %v", err)
}
title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath)
if !updated || title != "帮我看看" {
t.Fatalf("first auto title = %q updated=%v, want 帮我看看/true", title, updated)
}
thirdTurn := strings.Join([]string{
`{"role":"user","content":"帮我看看"}`,
`{"role":"assistant","content":"可以"}`,
`{"role":"user","content":"继续"}`,
`{"role":"assistant","content":"继续分析"}`,
`{"role":"user","content":"实现自动更新会话标题"}`,
`{"role":"assistant","content":"已实现"}`,
}, "\n") + "\n"
if err := os.WriteFile(sessionPath, []byte(thirdTurn), 0o644); err != nil {
t.Fatalf("write third session: %v", err)
}
title, updated = autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath)
if !updated || title != "实现自动更新会话标题" {
t.Fatalf("third-turn auto title = %q updated=%v, want 实现自动更新会话标题/true", title, updated)
}
if got := loadTopicTitle(projectRoot, topic.ID); got != "实现自动更新会话标题" {
t.Fatalf("stored title = %q, want 实现自动更新会话标题", got)
}
meta := loadTopicAutoTitleMeta(projectRoot)[topic.ID]
if meta.Stage != 3 {
t.Fatalf("auto title stage = %d, want 3", meta.Stage)
}
}
func TestAutoTitleDoesNotOverrideManualTopicTitle(t *testing.T) {
isolateDesktopUserDirs(t)
@@ -1592,6 +1660,50 @@ func TestAutoTitleDoesNotOverrideManualTopicTitle(t *testing.T) {
}
}
func TestAutoTitleDoesNotOverrideManualSessionTitle(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topic, err := NewApp().CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"讲讲这个代码库的架构"}`+"\n"), 0o644); err != nil {
t.Fatalf("write session: %v", err)
}
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{CustomTitle: "手动会话标题"}); err != nil {
t.Fatalf("save branch meta: %v", err)
}
if title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath); updated || title != "" {
t.Fatalf("manual session title should not auto-update, title=%q updated=%v", title, updated)
}
if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
t.Fatalf("stored title = %q, want default title", got)
}
}
func TestRenameTopicBlankKeepsManualTitleSource(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
if err := app.RenameTopic(topic.ID, " "); err != nil {
t.Fatalf("rename blank topic: %v", err)
}
if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
t.Fatalf("stored title = %q, want %q", got, defaultTopicTitle)
}
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
t.Fatalf("title source = %q, want manual", got)
}
}
func TestTrashTopicMovesRelatedSessionsToTrash(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 ""
}
+29 -19
View File
@@ -33,36 +33,38 @@ import (
// has no Wails dependency so the logic is unit-tested directly; updater_app.go is
// the thin Wails binding that wires these into App methods and progress events.
// Manifest endpoints — R2 CDN first (fast, especially in CN), GitHub releases as
// fallback. 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.
// 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.
const (
r2Base = "https://dl.reasonix.io"
ghReleasesBase = "https://github.com/esengine/reasonix/releases"
httpTimeout = 15 * time.Second
r2Base = "https://dl.reasonix.io"
releaseGatewayBase = "https://crash.reasonix.io/v1/desktop/releases"
downloadPageURL = "https://reasonix.io/#start"
httpTimeout = 15 * time.Second
)
// manifestEndpoints returns the primary (R2) then fallback (GitHub) manifest URLs
// for the running build's channel.
func manifestEndpoints() []string {
if channel == "canary" {
// Canary publishes only to R2 (no GitHub release), so there is no
// GitHub fallback for this channel.
return []string{r2Base + "/canary/latest.json"}
return []string{
r2Base + "/canary/latest.json",
releaseGatewayBase + "/canary/latest.json",
}
}
return []string{
r2Base + "/latest/latest.json",
ghReleasesBase + "/latest/download/latest.json",
releaseGatewayBase + "/stable/latest.json",
}
}
// downloadPage is the human-facing releases page shown when self-update is
// unavailable (macOS) or the manifest omits its own link.
func downloadPage() string {
if channel == "canary" {
return ghReleasesBase // lists pre-releases too
}
return ghReleasesBase + "/latest"
return downloadPageURL
}
// UpdateInfo is the CheckUpdate result that drives the frontend's update banner.
@@ -570,13 +572,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 ""
@@ -584,6 +583,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)
}
Loaded 100 of 429 files, more files were not shown because too many files have changed in this diff. Show more