Compare commits

...
339 Commits
Author SHA1 Message Date
SivanCola 7d68fb1a93 Merge pull request #6182 from SivanCola/fix/secret-output-redaction
Redact secret-bearing tool output / 脱敏工具输出中的密钥
2026-07-08 18:11:19 +08:00
SivanCola 5ea5593f40 style(doctor): satisfy staticcheck S1008 in event-log scan 2026-07-08 18:00:30 +08:00
SivanCola bdeeb644c4 fix(security): close review gaps in cleanup coverage, env filtering, config persistence, auth schemes
Four review findings on the redaction line, each pinned by a repro:

- doctor redact-sessions now scans every event-log record, not just the
  replayed view: a later replace event supersedes but does not erase
  earlier records, so a raw key could survive in a stale event while
  the current messages were already clean. Any record needing masking
  (or an undecodable tail, which may hold torn secret bytes) triggers
  the compacting rewrite; the clean replayed view is what survives.

- filter_subprocess_env now reaches every spawn path: environment
  probes set the filtered env even when the probe declares no extra
  variables, the bash and MCP stdio login-shell PATH probes stop
  inheriting the full environment, and the sweep covers ripgrep,
  project indexing, plugin-package git, attachment converters, shell
  viability probes, ccswitch sqlite, and OS notifiers. Deliberately
  exempt: the user's `!` passthrough shell (the documented escape
  hatch for credentialed commands), MCP-manager editor/open launchers
  (the user's own applications), and Windows kill/system utilities.

- RenderTOMLForScope now emits [secrets] for the user scope, so saving
  the config no longer drops the user's security toggles; the project
  scope still omits the section LoadForRoot would ignore.

- The key/value pattern captures Basic/Digest/Negotiate/NTLM/Token/Bot
  auth schemes alongside Bearer: an uncaptured scheme word was itself
  swallowed as the value, leaving the real credential in the clear
  right behind the [redacted] marker. The scheme is preserved in
  output and the replacement stays idempotent.
2026-07-08 17:52:20 +08:00
SivanCola 6c9671aa57 fix(doctor): decode session artifacts before redacting, not raw bytes
Masking the raw encoded bytes of JSONL/JSON artifacts corrupts them: a
quoted secret is stored as PASSWORD=\"...\" on disk, and the key/value
pattern's value group consumes the escape's backslash, so the rewritten
line terminates the JSON string early. The transcript stops decoding
(LoadSession hard-fails), an event log truncates at the broken record
and the next save compacts the loss in permanently — and the secret
itself was left in the clear outside the match. Already-redacted
healthy stores hit the same corruption on rescan, because the masked
form re-triggers the escape-eating match in its encoded shape.

Redact decoded values instead, per format:

- Transcripts, guardian sessions, and event logs go through
  agent.LoadSession + Session.Save: the save path re-runs
  RedactMessages on decoded content, folds the event log into a single
  redacted replace event, refreshes the anchor and event index, and
  records the revision under the same cross-process locks live
  sessions use — digests, the CAS ledger, and replay stay coherent,
  and reruns are no-ops because Redact is idempotent.
- Branch metadata masks its free-text fields (preview, titles, goal,
  recovery reason) through the typed LoadBranchMeta/SaveBranchMeta
  pair under LockSessionMetaPath.
- Goal state and job .json sidecars decode with UseNumber, mask every
  string in the tree, and re-encode; unparseable files are reported
  and left untouched instead of risked with a raw rewrite.
- Only plain-text job .log files keep raw-byte redaction.

Regression tests pin the two failure shapes: a quoted-secret historical
transcript must stay loadable with the secret masked, and a healthy
already-redacted store must come through byte-identical.
2026-07-08 17:20:15 +08:00
SivanCola 030a4d5363 Merge pull request #6183 from esengine/codex/notice-preview-real-page
Add real-page notice preview mock
2026-07-08 17:13:00 +08:00
SivanCola 9087238c7a Trigger PR CI 2026-07-08 17:06:58 +08:00
SivanCola 704b15f4d2 Merge pull request #6174 from esengine/codex/compact-chat-notices-i18n
fix(desktop): compact backend notices in chat
2026-07-08 15:31:31 +08:00
SivanCola 38ec4093fe fix(security): add historical session redaction cleanup 2026-07-08 14:35:03 +08:00
SivanCola 079293f0d1 Fix notice preview DOM nesting to match Transcript markup
Render notice-line__text as a div (details is flow content, invalid
inside span) and use the same details > div structure as NoticeCard so
the preview reflects the real notice markup.
2026-07-08 14:30:58 +08:00
SivanCola 4c5e5a9572 Preserve planner notice detail in history and fix notice DOM nesting
Planner-sourced notices recorded through recordPlannerDisplayEvent
dropped Detail, so the expandable diagnostics vanished after a reload
even though the live transcript showed them; persist Detail the same
way the main history path (app.go notice restore) already does, with a
focused regression test.

Also render notice-line__text as a div: <details> is flow content and
is not valid inside <span>; the element is a flex item so layout is
unchanged.
2026-07-08 14:29:21 +08:00
SivanCola a22eba4a86 Merge pull request #6168 from SivanCola/fix/hide-legacy-recovery-topics
Hide legacy recovery topics from desktop sidebar / 隐藏旧恢复分支侧栏话题
2026-07-08 14:25:35 +08:00
SivanCola 99c1539142 fix(security): gate env/path protections behind [secrets] config, fix PWD and clone bugs
Split the credential-protection layers by blast radius:

- Durable surfaces (session transcripts, job artifacts) always redact.
- Live tool output redaction stays default-on but honors [secrets]
  redact_tool_output for fixture-heavy repos.
- Subprocess env filtering and the sensitive-path read denylist become
  explicit opt-ins (filter_subprocess_env, protect_sensitive_files,
  both default off): stripping GH_TOKEN/NPM_TOKEN broke gh, HTTPS git
  push, and npm publish; hiding .env broke legitimate edit workflows.

[secrets] is user-global: project reasonix.toml values are discarded on
load so a cloned repo cannot disable redaction or flip on the
workflow-breaking protections. The captured global section deep-copies
its *bool so the project-file TOML decode cannot write through it.

Fix the PWD false positive: bare "pwd" now requires a leading [_-]
separator (DB_PWD masks, POSIX PWD/OLDPWD pass through), keeping every
subprocess's working-directory context intact.

Fix RedactMessage writing through shared ToolCalls/MemoryCitations
backing arrays: the save path hands in live session messages, and
in-place masking would silently rewrite model-visible history
mid-conversation and churn the prompt cache. Clone before masking.

Fix SaveRecoveryBranch bypassing redaction: recovery branches are
durable transcripts, and comparing a raw snapshot against the
already-redacted on-disk content would misread coverage as divergence
and fork a bogus recovery branch. Document the digest-consistency
argument (Redact is deterministic and idempotent, so load/save cycles
are byte-stable) and pin it with a regression test.
2026-07-08 13:42:25 +08:00
SivanCola 4524016932 Guard notice preview mock behind browser-only scenario param
Reuse browserMockScenarioParam so the notice preview panel cannot
activate inside the Wails shell (the helper returns empty when
window.runtime is present), matching the existing guidance-mock guard.
Drop the notice preview mock history: the preview panel replaces the
transcript, so that history was never rendered.
2026-07-08 12:32:06 +08:00
SivanCola 10e637bce6 feat(frontend): sweep recovery copies to trash from history
The trash view could already bulk-purge recovery copies, but copies still
sitting in the session list had to be trashed one by one — painful for
users who accumulated hundreds from the legacy conflict loop. The history
view now offers the same one-click sweep, moving every idle recovery copy
to the trash; copies that are current or open in a tab are skipped because
an adopted copy is someone's live conversation.

Also folds the redundant onPurgeRecoveryCopies prop into the view-aware
sweep handler (trash purges via onPurgeAll, history trashes via the new
onDeleteMany), and covers both flows plus the skip rule with a component
test that runs in the standard tsx harness.
2026-07-08 12:24:12 +08:00
SivanCola 0af75a1939 Add notice preview mock page 2026-07-08 12:15:03 +08:00
SivanCola 344895e496 fix(security): redact secret-bearing tool output 2026-07-08 12:13:50 +08:00
SivanCola 0c2efcd5ae Merge pull request #6176 from SivanCola/fix/tui-session-recovery-lease
Fix TUI session recovery lease tracking / 修复 TUI 会话恢复 lease 跟随
2026-07-08 12:08:39 +08:00
SivanCola 3cb37b5c9c fix(cli): move lease before rebuild binds the recovery path
The /model, /effort, and skills-refresh rebuild paths snapshot (which can
retarget the controller to a recovery branch), then hand prevPath to the
async buildController, whose AdoptHistory resumes that path for writing.
The lease only followed in the modelSwitchMsg handler after the build
returned, leaving a window where the rebuilt controller writes a recovery
path the TUI does not guard. Rebind the lease to prevPath before firing
the build, and abort the switch on refusal instead of binding an unguarded
writer. Matches the lease-before-bind order /resume and /switch already
follow.
2026-07-08 11:56:19 +08:00
SivanCola eb9e3e8e0f Merge pull request #6177 from esengine/codex/perf-lag-diagnostics
fix(frontend): Reduce startup performance lag false positives
2026-07-08 11:47:12 +08:00
SivanCola d0cc98d9d3 test(frontend): cover bridge timing breadcrumbs 2026-07-08 11:40:15 +08:00
SivanCola c3ef4bfc92 fix(cli): follow lease on completion listing and failed model switch
Branch listing during /switch tab-completion snapshots the session, which
can retarget the controller to a recovery branch without any switch
running; the lease now follows immediately instead of guarding the stale
original path. Likewise, when a model/effort/skill rebuild fails after the
pre-switch snapshot already retargeted the kept controller, the error
branch now re-points the lease at the recovery path instead of leaving the
stale binding until the next lease-following operation.
2026-07-08 11:39:29 +08:00
SivanCola 7cca6e9770 fix(frontend): reduce startup performance lag false positives 2026-07-08 11:33:40 +08:00
SivanCola 431dddb1ef fix(desktop): compact backend notices in chat 2026-07-08 11:30:26 +08:00
SivanCola 8178abc0bf fix(cli): keep TUI leases on recovered sessions 2026-07-08 11:17:04 +08:00
SivanCola 009a2359cb fix: surface recovery copies in history and trash 2026-07-08 11:16:24 +08:00
Sivan 972f052277 fix(desktop): hide legacy recovery topics 2026-07-08 10:27:42 +08:00
SivanCola f501f3dba0 Merge pull request #6164 from SivanCola/feature/composer-run-dock
Dock run state into the composer card and add prompt-card previews / 运行态内嵌输入卡与审批·提问卡预览行
2026-07-08 09:54:04 +08:00
Sivan 80cf5274bf test(frontend): mock canvas in settings snapshot test 2026-07-08 09:45:59 +08:00
SivanCola 4317a6176b Merge pull request #6165 from SivanCola/fix/bounded-slugs-and-tail-reads
Bound lossy slugs and tail-read append-only logs / 限长有损 slug 并尾读追加日志
2026-07-08 09:43:32 +08:00
SivanCola 3716462392 Fix workspace slug test on Windows 2026-07-08 09:33:44 +08:00
Sivan 2f4a6995f1 Keep --composer-height in logical space across all resize writers
Review found the strip compensation baked into the React render path
only: composerCardStyle wrote height+30px while the live drag updater
and drag-start measurement wrote raw logical heights to the same CSS
variable. Dragging a resized composer during a running turn briefly
shrank the card by the strip height (re-clipping the meta row) until
release re-added the compensation — a visible jump.

Make the variable single-meaning: --composer-height always carries the
user's logical height from every writer (render, live drag, keyboard),
and the strip reservation moves into a separate
--composer-run-strip-reserved variable consumed by a CSS calc on
.composer-card--resized. DOM-based fallback measurements subtract the
strip so auto-height cards convert to the same space.

Regression covered in composer-run-strip: drag start/release during a
running turn stays in logical-height space, the reservation survives
the drag, and the idle card releases it.
2026-07-08 03:54:57 +08:00
Sivan 1401a07a49 Bound the stats filename stem so the hash suffix cannot overflow
Review finding: statsPath's new "-<hash>" suffix pushed previously
writable long slugs (236-244 bytes, which fit as "<slug>.stats.json")
past the 255-byte filename component limit, so stats telemetry for
long server names regressed to ENAMETOOLONG on every write.

Budget the stem at 255 minus the suffix length before appending the
hash. Truncating the slug is safe because the hash of the raw name
carries the uniqueness.

Regression test: a 240-byte name produces a <=255-byte component,
records and reads back end-to-end, and two long names sharing the
truncated stem keep distinct files. Fails at 260 bytes on the previous
commit.
2026-07-08 03:54:51 +08:00
Sivan d39a7694d3 Give colliding plugin stats files per-server hashed paths
Review finding on the previous commit: the ownership check alone kept
colliding servers ("foo.bar"/"foo-bar") writing one shared file, so
interleaved startups alternately reset each other's window — neither
could accumulate maxSamples and a chronically slow server might never
auto-demote. Worse, a legacy nameless file's samples were still trusted
by Recommend, so one old slow file could demote the colliding healthy
server before its first write.

statsPath now embeds an FNV-1a hash of the raw server name, giving
every server its own file. The pre-hash path is read-only for
migration: a legacy file whose recorded Name matches is carried into
the hashed path on the next write; a nameless legacy file has no
provable owner, so it never feeds Recommend and never seeds a window.
Nameless files at the hashed path remain trusted — the hash pins them
to exactly one name.

Regression tests: interleaved colliding names accumulate independent
windows and demote independently; a named legacy file migrates with
history intact; a nameless legacy collision demotes nobody and is not
inherited.
2026-07-08 03:38:43 +08:00
Sivan fb1840f22d Preview each approval action's consequence on the tool approval card
The allow-once / allow-session / always-allow / deny pills carry real
semantic differences (scope, persistence) that the labels alone do not
explain. Reuse the ask card's quick-pick pattern: a consequence row
under the pills previews what the hovered or arrow-selected action will
do, with a native title fallback on each pill. The subject block stays
default-visible and untouched — what you approve must remain fully
inspectable.

The tool actions are now a descriptor list driving both the buttons and
the preview, replacing the hand-rolled branch JSX. Also map
exit_plan_mode to a friendly label ("Plan approval") — the composer run
strip previously showed the raw tool name while a plan was waiting.
2026-07-08 03:33:35 +08:00
Sivan 8652137e92 Add a focus-following detail preview row to the ask card
The multi-column grid truncates option descriptions to one line, and
narrower columns made the loss worse — a cut-off description is the
one piece of information the user needs before answering. Add a
quick-pick-style detail row pinned under the options: it previews the
full description of the hovered/focused option, falling back to the
latest selected option and then the first described option so it
never blanks. Each option also carries a native title tooltip as a
redundant fallback.

PromptShelf gains a `note` slot (between actions and quick actions)
and PromptAction gains `title`/`onHoverChange`; both are opt-in and
inert for existing callers (approval card, clear-context card).
2026-07-08 03:33:35 +08:00
Sivan 75c858e543 Compact the ask card: multi-column grid and inline submit
The ask card used full-width single-column option cards (42px each)
with full-width back/submit rows, consuming ~400px+ of viewport. This
was the compact shelf's grid being overridden by the ask-specific
1fr rule at higher specificity.

Switch to a responsive multi-column grid (auto-fill, minmax 200px)
with slightly shorter items (36px), raise the scroll cap to
min(220px, 30vh), and move back/submit into a right-aligned pill row
with a subtle border-top separator. Four options now render in two
rows at ~93px total; the overall card height drops from ~400px to
~230px.
2026-07-08 03:33:35 +08:00
Sivan 9c058ef04f Anchor run strip as in-flow row with resized-card compensation
The absolute-positioned tab overlapped the guidance shelf, context
cards, and pasted blocks when those sat above the card. Switch to an
in-flow row: auto-height cards grow naturally, and user-resized cards
get COMPOSER_RUN_STRIP_RESERVED (30px) added to --composer-height via
inline style while the strip is visible — so the meta row stays fully
visible and reverts when the turn ends.
2026-07-08 03:33:35 +08:00
Sivan a665c88192 Keep the approval bar usable while its own prompt is pending
The composer is disabled while a tool approval is pending, which also
disabled the ask/auto/yolo bar — making the controller's designed
mode-switch semantics unreachable from the UI: SetToolApprovalMode
drains pending approvals the relaxed posture allows (fresh-human tools
are never drained, approval.go drainLocked). Scope the disable so the
approval bar stays active exactly when the pending approval itself is
the reason: relaxing the mode now resolves drainable requests in place,
and for surviving fresh-human requests the approval card's
mode-switch hint explains that an explicit decision is still needed.

Verified live against the dev instance: a pending bash approval
auto-resolves when switching ask -> auto, the run strip returns from
waiting to streaming, and the composer stays disabled for
non-approval reasons (covered by composer-run-strip assertions).
2026-07-08 03:33:35 +08:00
Sivan fa646b75c9 Anchor run strip as zero-height tab on the composer card
The in-flow strip added ~30px to the card's content height, which a
user-resized card (fixed --composer-height, minimum 104px) could not
absorb: the meta row overflowed the card bottom and was clipped behind
the app status bar. Position the strip absolutely on the card's top
edge instead — it no longer participates in height math (resized cards
keep their exact height) and turn start/finish causes zero layout
shift. The strip is non-interactive (pointer-events: none) so the
resize handle underneath keeps working.
2026-07-08 03:33:35 +08:00
Sivan 2d489f5c5f Dock run state into the composer card
Replace the floating run-status pill with a run strip inside the
composer card, so starting or finishing a turn no longer shifts the
surrounding layout, and give stop a fixed home next to the send button
while a turn runs (send switches to a queue-guidance icon).

The strip is a small state machine: retry > waiting-approval >
waiting-ask > streaming. While a tool approval or ask question is
blocked on the user, the strip says so (with the pending tool label)
instead of ticking whimsical "working" seconds, and the card hands the
running accent off to the prompt card above it.

Accessibility: the per-second ticker was inside an aria-live region and
re-announced every second; it is now aria-hidden, with a separate
sr-only status region that announces only stable state transitions.

The approval card also gains a hint when the approval mode is relaxed
(ask -> auto/yolo) while a request is pending, since switching modes
does not auto-resolve the pending prompt.

Verified: tsc --noEmit, tsc -p tsconfig.test.json, check:css, new
composer-run-strip test (16 assertions), and the full frontend test
chain all pass.
2026-07-08 03:33:35 +08:00
merge-order-check 4098c4d98f Bound lossy slugs and tail-read append-only logs
Four related unbounded-growth/lossy-slug defects, all with old-data
compatibility preserved:

1. AutoResearch direction fingerprints: slugify truncates to 56 chars
   and drops non-ASCII runes, so directions sharing a long ASCII prefix
   (or differing only in CJK text) collapsed to one fingerprint and
   wrongly inflated StaleCount/PivotCount toward forced pivots on long
   tasks. directionFingerprint appends an FNV-1a hash of the normalized
   summary only when slugify was lossy; short ASCII summaries keep the
   bare slug so fingerprints recorded by older versions still match,
   and legacy directions_tried.json entries are matched by recomputing
   from their stored summary and migrated in place.

2. AutoResearch bounded reads: Findings/Heartbeats scanned the whole
   JSONL file then truncated, a per-turn cost that grows for the life
   of a task (Summary, runtime block, and LastHeartbeat all hit this
   every turn). Bounded requests now tail-read backward in 64KiB
   chunks; limit 0 keeps the full scan for accepted-evidence lookups.

3. Workspace path slugs (config.WorkspaceSlug, memory.StoreFor): deep
   workspace paths flattened to a single component exceeding the
   255-byte filename limit, breaking project sessions, MemoryCompiler
   state, and the auto-memory store with ENAMETOOLONG. Components are
   now bounded with a hash suffix; any path short enough to have ever
   worked is returned byte-identical, so existing directories resolve.

4. Memory name slugs and plugin stats: slug(m.Name)+".md" hit the same
   filename limit for names distilled from long titles (now bounded,
   short names unchanged); plugin startup-stats files share a lossy
   filename slug across servers ("foo.bar"/"foo-bar"), letting one
   server's slow samples demote another. Stats now record the owning
   server name — mismatched files are treated as no-history and taken
   over fresh; legacy files without the field are adopted on the next
   write without losing samples (additive field, no version bump).
2026-07-08 03:09:58 +08:00
SivanCola 07c65c2222 Merge pull request #6162 from SivanCola/feature/memory-v5-memory-candidates
Surface stable Memory v5 learnings as memory candidates in settings / 将 Memory v5 稳定学习成果提炼为设置页记忆候选
2026-07-08 02:10:11 +08:00
SivanCola 8357312237 Merge pull request #6158 from SivanCola/fix/project-readd-self-heal
fix(desktop): self-heal project re-add roots
2026-07-08 02:07:42 +08:00
merge-order-check 0403d1d1a4 Preserve generated candidate names through the accept path
Review finding: AcceptMemorySuggestion re-derived the name with
suggestionName(in.Name, ...), whose asciiSlug pass truncates to 56
chars and strips the uniqueness hash suffix. Two candidates sharing a
long ASCII prefix got distinct generated Name/ID but collapsed to the
same file at save time, so the later save overwrote the earlier one.

Accept now keeps a well-formed generated slug verbatim
(isWellFormedSlug: lowercase ASCII/digit runs separated by single
dashes, up to 128 chars — room for the hash suffix beyond asciiSlug's
56-char cap). Malformed or empty names still fall back to deriving
from the description as before, and memory.Store.Save's own slug pass
remains the final validator (it cleans but never truncates).

Regression test: two >56-char common-prefix compiler patterns accept
into two distinct memory files; fails with "saved memories = 1" before
the fix.
2026-07-08 01:56:31 +08:00
merge-order-check 824f232478 fix(desktop): replace remaining normalizeProjectRoot equality with sameProjectRoot
Five project-root comparisons still used
normalizeProjectRoot(a) == normalizeProjectRoot(b), which does not
fold case on Windows — so a re-add under a different case spelling
could miss the existing session/tab/migration match:

- topicSessionMatchMatchesTarget (session meta vs registry root)
- tabMatchesTopicTarget (tab reuse for same topic)
- tabInWorkspace (tab-in-workspace predicate)
- reconcileTabWithLiveBinding (controller root vs tab root guard)
- legacySessionScopeMatchesMigrationTarget (migration scope check)

Switch all five to sameProjectRoot and add a Windows-only regression
test that plants a session with the original-case root in its meta,
re-registers the project under a flipped-case root, and verifies
findTopicSessionForTarget still resolves the original session.
2026-07-08 01:44:35 +08:00
merge-order-check 14801d3118 test(memory): expect folded slug case on Windows
config.WorkspaceSlug now folds case on Windows so equivalent spellings
of one workspace share a slug; TestStoreForSlug asserted the unfolded
spelling and failed on the windows-latest leg.
2026-07-08 01:44:35 +08:00
merge-order-check 64253776a8 fix(config,desktop): fold Windows case in workspace slugs and sync tab root spellings
WorkspaceSlug fed the raw absolute path into the slug, so on Windows
C:\Foo and c:\foo produced two projects/<slug> state directories and
the same workspace's session list and memory-compiler state split.
Fold case there (matching agent.CanonicalSessionPath's key form) and
route internal/memory's duplicate slugify through it. Existing
mixed-case slug directories keep working: NTFS resolves directory
names case-insensitively, so the folded slug opens the same directory
and no migration is needed. Unix slugs are unchanged - case-different
paths there are genuinely different directories.

The frontend compares tab.workspaceRoot against tree node roots with
exact string equality, so a registry re-spell (addProject adopting the
caller's spelling) could strand open tabs on the old spelling and
break ownership highlighting and session filters. Sync open project
tabs to the registry's canonical spelling after every registry write
(registerProjectRoot / rename / color paths), persist the tab
snapshot, and emit project-tree:changed so the frontend refreshes -
keeping one string form as the single source of truth instead of
teaching the frontend platform-aware path semantics.

Also fold the startup-migration and recovery-GC seen maps
(desktopMCPMigrationRoots, recoverLegacyProjectSidebarRoots,
recoveryGCDirs) with projectRootKey so case-split roots are not
scanned or appended twice, and cover the tab re-spell sync with a
Windows-only regression test on the desktop-windows CI leg.
2026-07-08 01:44:35 +08:00
merge-order-check 2a3543adca fix(desktop): extend root equivalence to runtime lookups and lock the Windows contract
The registry-side matcher still left same-shape exact comparisons on
paths that cross spelling sources:

- fold case in topicSummaryKey (matching agent.CanonicalSessionPath's
  key form) so sidebar topic runtime status never splits across the
  tab, registry, and session-meta root spellings
- match roots with sameProjectRoot in blank-tab reuse, open-topic
  collection, session-binding workspace notices, topic prepend/repair,
  indexed blank-topic reuse, and ListWorkspaces' Current flag
- keep the global sidebar token out of path comparisons in
  normalizeSidebarOrder

The re-add regression tests spelled the equivalent root with
filepath.Join(root, "."), which Join cleans back to the identical
string, so they also passed on the unpatched base. Spell the dot
segment by hand, and add Windows-only case-folding tests (*OnWindows)
plus a windows-latest desktop CI leg to run them - the ubuntu leg can
never reach the EqualFold branch.
2026-07-08 01:44:35 +08:00
merge-order-check cd9023ecf8 fix(desktop): self-heal project re-add roots 2026-07-08 01:44:35 +08:00
merge-order-check 78133b1a26 Preserve backward-compatible names for ASCII-safe history candidates
For history-derived candidates whose asciiSlug is non-empty and shorter
than the 56-char truncation boundary, return the plain slug without a
hash suffix. This keeps names identical to the old format, so an
already-accepted memory under the old name is not duplicated after
upgrade.

Hash suffix is still always appended for:
- CJK-only statements (empty slug → collision guaranteed)
- Statements whose slug hit the 56-char truncation boundary
  (collision likely with long common-prefix inputs)
- Compiler candidates (inherently homogeneous: same tool + similar
  error prefix)

Compiler candidates retain their own compilerCandidateName() which
always appends hash, since pattern homogeneity makes slug collisions
the norm, not the exception.

New test: TestHistoryEnglishCandidateNameBackwardCompat asserts English
candidates produce the same Name as old code (no hash suffix).
2026-07-08 01:40:47 +08:00
merge-order-check 8b0492525a Unify stable name generation for all suggestion candidates
Same-shape sweep: history-derived memory candidates from
suggestMemories used ordinal-suffixed fallback names
("memory-candidate-1", ...) when asciiSlug returned empty for pure-CJK
statements. Two CJK statements with different content but identical
empty slugs would collide on Name/ID, causing the same overwrite and
frontend state cross-wire issue found in the compiler candidates.

Extract stableSuggestionName(source, fallback) into
memory_suggestions.go: it appends an FNV-1a hash of the full source
text to the asciiSlug base, keeping names unique per content and stable
across refreshes. Both suggestMemories and suggestCompilerMemories now
use this single helper; the standalone compilerCandidateName is removed.

Regression tests: pure-CJK history statements produce distinct
Name/ID and persist as separate memory files; IDs are stable across
suggestion refreshes.
2026-07-08 01:21:41 +08:00
merge-order-check 43b147e4f2 Make compiler candidate names unique with a pattern hash suffix
Review finding: candidate Name/ID came from asciiSlug of the pattern,
which drops non-ASCII runes and truncates to 56 chars. Patterns
differing only in CJK error text (or sharing a long English prefix)
collided: colliding IDs cross-wire the frontend's React keys and
accepted-state map, and colliding Names make Store.Save overwrite the
earlier accepted memory.

Append an FNV-1a hash of the full pattern to the slug. The hash is
derived from the pattern itself, so IDs stay stable across suggestion
refreshes and the accepted-state map remains valid.

Regression tests cover CJK-only-diff patterns producing distinct
Name/ID and two distinct persisted memories (fails against the
previous commit), and ID stability across refreshes.
2026-07-08 01:08:35 +08:00
SivanCola 30ecaf4354 Merge pull request #6160 from SivanCola/feature/memory-v5-outcome-signals
Upgrade Memory v5 outcome signals, injected split, and learnings export / 升级 Memory v5 成败信号、注入分桶统计与 learnings 导出
2026-07-08 01:06:35 +08:00
merge-order-check 9dce3d05fc Consume outcome ref on every turn and clear it on session switch
Review finding: the pending lastCompilerOutcome ref was only consumed
when the Memory v5 runtime was non-nil, so /memory-v5 off froze it.
After re-enabling, a corrective first input ("still broken") could
retroactively downgrade a success trace from many turns ago, violating
the one-shot contract and corrupting strategy counters.

Fix at the consumption semantics rather than the toggle: every
non-synthetic turn start now consumes the ref, runtime present or not,
so it can never outlive the turn that follows it. This also keeps the
correct behavior when off->on happens with no intervening turn.

Same-shape sweep found one more leak: SetSession (switch/resume) kept
the ref alive across conversations, letting the new session's first
corrective input revise the old session's trace. Session switches now
clear the ref. Both ref accesses are now guarded by memoryCompilerMu,
matching how the neighboring compiler fields are protected.

Regression tests cover the reviewer's off -> intervening turn -> on ->
corrective feedback scenario and the session-switch scenario; both
fail against the previous commit.
2026-07-08 00:48:27 +08:00
merge-order-check 8be5af9f6f Surface stable Memory v5 learnings as memory candidates in settings
Memory v5 already learns repeated failure patterns per workspace
(NoisyRefs), but they were buried in state.json. This bridges the
stable ones into the settings Memory page's existing suggestions flow:

- memorycompiler.StableNoisePatterns returns failure patterns that
  recurred across a minimum number of separate turns, ordered by
  frequency. Read-only over compiler state.
- suggestCompilerMemories converts them into project-typed memory
  candidates with provenance (reason + trace evidence), deduplicated
  against history-derived suggestions and existing saved memories, and
  appends them to MemorySuggestionsView.Memories.
- Candidates persist only through the regular AcceptMemorySuggestion
  confirmation flow; once accepted, the pattern stops being suggested.

No frontend changes needed: candidates render through the existing
suggestion card, which already shows reason and evidence. No
provider-visible behavior changes.
2026-07-08 00:29:15 +08:00
merge-order-check c9ea166b04 Upgrade Memory v5 outcome signals, injected split, and learnings export
- Treat user-cancelled turns as "aborted": still traced for
  observability, but excluded from strategy counters and mutation
  evaluation so cancellations stop poisoning success rates.
- Recognize verification-shaped bash commands (go test/vet, pnpm test,
  pytest, tsc, ...) as a stronger outcome signal than the last-tool
  heuristic: a failing final verification caps the turn at
  partial_success; a passing one confirms success unless a later tool
  errored after it.
- Retroactively downgrade a recorded success when the user's immediate
  follow-up reports the result wrong. One-shot per turn, matched only
  against the head of the message, and a no-op when the referenced
  trace is not in this runtime's trace log.
- Persist per-turn injection state on runtime/learning traces and split
  strategy counters into injected vs observed, so compact-mode lift is
  measurable instead of assumed.
- Add "/memory-v5 learnings" to the CLI TUI and the shared control
  slash surface: strategy success rates with the injected/observed
  split, repeated noise patterns, and recent improvements/findings.

No provider-visible behavior changes: the observe default, contract
format, tool schemas, and system prompt are untouched.
2026-07-08 00:07:14 +08:00
SivanCola 5992b2ef25 Merge pull request #6153 from SivanCola/fix/compact-tool-error-details
Compact tool error details / 折叠工具错误详情
2026-07-07 21:22:33 +08:00
merge-order-check bc556ce363 Merge main-v2 into fix/compact-tool-error-details 2026-07-07 21:15:57 +08:00
SivanCola b98d4277c5 Merge pull request #6143 from SivanCola/fix/recovery-banner-acknowledge
Quiet recovery-copy notifications / 移除冲突副本横幅、toast 与转写通知
2026-07-07 21:06:05 +08:00
merge-order-check c009c1bfdf fix(frontend): quiet background lifecycle notices 2026-07-07 20:57:04 +08:00
merge-order-check 7dd84c5d25 fix(frontend): quiet recovery lifecycle prompts 2026-07-07 20:40:11 +08:00
SivanCola 7ac91a41ec test(cli): avoid blocking node stdin in statusline test 2026-07-07 20:09:03 +08:00
SivanCola 53bd6bf901 test(cli): give windows statusline node eval more time 2026-07-07 20:02:24 +08:00
SivanCola d743a99ae8 fix(cli): let statusline runner repair raw node eval commands 2026-07-07 19:53:18 +08:00
SivanCola 3cfc430341 Merge remote-tracking branch 'origin/main-v2' into fix/recovery-banner-acknowledge
# Conflicts:
#	desktop/frontend/src/App.tsx
2026-07-07 19:44:03 +08:00
SivanCola b07876d2c2 fix(frontend): compact tool error details 2026-07-07 19:13:21 +08:00
SivanCola 4b764dae33 Merge pull request #6150 from SivanCola/fix/crlf-edit-diagnostics
Fix CRLF edit diagnostics / 修复 CRLF 编辑诊断
2026-07-07 18:59:26 +08:00
SivanCola 741f8e58d5 Merge pull request #6151 from SivanCola/fix/rebuilt-event-sink-ordering
fix(desktop): order runtime:rebuilt ahead of the rebuilt controller's events
2026-07-07 18:18:26 +08:00
SivanCola 54f11756af fix(desktop): order runtime:rebuilt ahead of the rebuilt controller's events
Review catch on #6146: runtime:rebuilt rode the App-level async event
queue while agent events (approval_request, ask_request) ride each tab
sink's own queue. Two independent queues drain on separate goroutines,
so nothing ordered the dedupe-key reset before the rebuilt controller's
first prompt — a prompt racing in ahead of the reset would still be
muted by the stale key.

Route the notice through the tab sink's queue instead: the rebuild
paths hand the SAME sink to the new controller (snap.sink for
model/effort/token switches, the freshly-swapped tab.sink for
clear-while-running), so FIFO on that one queue guarantees the reset
reaches the frontend before any subsequent agent event from the rebuilt
controller. The App-level queue remains only as a fallback when the
sink cannot deliver (nil sink or cleared webview context) — unordered,
but an unordered notice still beats none.

The Go test now captures through the sink's emitter and fails if any
notice leaks to the App-level queue; reverse-verified against the old
routing (all three switches flagged VIA-APP-QUEUE).
2026-07-07 18:11:35 +08:00
SivanCola c0d0235274 fix(edit): clarify CRLF edit diagnostics 2026-07-07 18:06:07 +08:00
SivanCola eb8f4b82ff Merge pull request #6146 from SivanCola/fix/chime-dedupe-runtime-rebuild
fix(desktop): re-arm the prompt chime after runtime rebuilds
2026-07-07 18:01:13 +08:00
SivanCola 7c437b2d9a fix(desktop): announce runtime:rebuilt so rebuilds re-arm the prompt chime
Review blocker on #6146: the onReady clearing never fires for the very
rebuilds the PR names as the reproduction. SetModelForTab,
SetEffortForTab, and SetTokenModeForTab swap the tab's controller and
return without emitting agent:ready, so "approve once -> switch model
-> next approval stays silent" was still broken. clearActiveSessionRuntime
(clear-while-running) has the same silent swap.

Emitting agent:ready from those paths would drag its reload semantics
along (useController re-hydrates on ready). Add a dedicated lightweight
runtime:rebuilt event instead, emitted with the tab id from all four
swap paths via notifyTabRuntimeRebuilt; the frontend subscribes
alongside onReady and clears that tab's chime keys.

Test-infra fallout: the new emissions ride asyncRuntimeEmitter, whose
nil-hook fallback called Wails runtime.EventsEmit directly — which
log.Fatals on the plain Background contexts desktop tests use, killing
the whole test binary from any emitting path. Route the fallback
through runtimeEventsEmitFallback and neutralize it once in TestMain;
per-instance runtimeEvents.emit hooks keep precedence, so event-
asserting tests are unaffected, and future tests cannot step on this
mine.

TestRuntimeRebuildsEmitRuntimeRebuiltForTab drives real model, effort,
and token-mode switches end-to-end and asserts each emits
runtime:rebuilt carrying the tab id; verified it fails when the
effort-path emit is removed.
2026-07-07 17:45:24 +08:00
SivanCola 17074c1ede Quiet recovery-copy notifications
Recovery copies are a data-protection outcome the backend already handles;
surfacing their lifecycle in the desktop UI has mostly produced "the app
is broken" reports (#6083 discussion, #6127) long after the underlying
false conflicts were fixed. Remove every recovery-lifecycle prompt from
the desktop:

- the "conflict copy" banner (keyed off the permanent
  BranchMeta.Recovered flag, it could never go away),
- the recovery-created toast and the recovery-failed toasts,
- the transcript notices for the whole recovery family (saved-copy,
  kept-current, adopted, adopted-covered), both live and when hydrating
  history — old transcripts stop replaying warnings after upgrade.

What remains: History and ProjectTree still badge copies for
identification; a recovery-lease failure still surfaces through the
ctrl.Snapshot() error path as an autosave-failure notice (that family
is not suppressed); the CLI keeps printing its warnings; the backend
fork/adopt/force-save protection is untouched.
2026-07-07 17:22:17 +08:00
SivanCola 089a3144fb fix(desktop): re-arm the prompt chime after runtime rebuilds
Review catch on #6142: approval/ask ids are per-controller counters
starting at "1" (approvalManager.nextID), and desktop model/effort/
settings switches rebuild the tab's controller — so the first prompt
after a rebuild reuses an id an earlier prompt on the same tab already
consumed, and the chime dedupe misread it as a replay and stayed
silent.

Clear the dedupe keys on agent:ready: a (re)build completion is exactly
the moment ids restart. A tab-scoped ready clears that tab's keys; the
tab-less ready that settings rebuilds emit clears everything —
over-clearing only re-chimes a replayed pending prompt (a desirable
reminder), while under-clearing mutes a live one.
2026-07-07 17:01:53 +08:00
SivanCola b2dae608ad Merge pull request #6142 from SivanCola/fix/background-prompt-attention-sound
fix(desktop): play attention sound for background prompts
2026-07-07 16:55:49 +08:00
SivanCola 65cded04a9 fix(desktop): drop AskCard mount chime, bound the dedupe set
Review follow-ups on the background prompt attention sound:

- AskCard still played the attention chime on mount, so every ask
  prompt on the active tab chimed TWICE after the global event-stream
  path landed (the PR removed the ApprovalModal side effect but missed
  AskCard). The chime now plays exclusively from the runtime event
  stream, keyed by prompt id.
- The "test" script was missing sound.test.ts (only test:all had it),
  so `pnpm test` skipped the new suite.
- The chime dedupe set grew without bound (one entry per prompt id for
  the app's lifetime). It now self-prunes past 512 entries, dropping
  the oldest half — replay dedupe only needs recently seen ids. Pinned
  by a 2000-prompt bounded-growth test that also asserts recent ids
  stay deduped after pruning.
2026-07-07 16:49:07 +08:00
SivanCola 2fbce2834a Merge pull request #6141 from SivanCola/fix/hook-node-eval-autorepair
Fix quoted node eval hook autorepair / 修复 node -e hook 引号自愈
2026-07-07 16:35:34 +08:00
SivanCola 9535e411e3 hook: document and pin the direct-exec boundary, harden quote trimming
Review follow-ups on the node -e autorepair:

- spawnCommand now direct-execs only commands this call actually
  repaired, plus (on Windows) recognized node stdin-hook evals where
  cmd /c mangles quoted JS. Well-formed POSIX commands keep sh -c
  verbatim. Behaviorally this matches the previous code — StaticFields
  already rejects runtime expansions, so only expansion-free commands
  ever hit direct exec, where the two vehicles are equivalent — but the
  boundary is now explicit, commented, and pinned by
  TestWellFormedNodeEvalKeepsShellSemantics ($VAR expansion must
  survive for a well-formed stdin-hook command).
- The quote-trim helpers now require the closing marker instead of
  prefix-only matching, so an unterminated candidate ("\"abc with no
  tail) cannot be "repaired" into an unintended script.
- Drop the unexplained runtime.Gosched() from the test mock provider:
  hook/cli/agent/control suites plus 5x statusline stress pass without
  it.
2026-07-07 16:29:26 +08:00
SivanCola 7eeb5c2125 Merge pull request #6140 from SivanCola/feature/claude-plugin-manifest-compat
Support Claude plugin manifest installs / 支持 Claude 插件清单安装
2026-07-07 16:12:34 +08:00
SivanCola 725e10e560 fix(hook): repair quoted node eval hooks 2026-07-07 16:02:39 +08:00
SivanCola c0100dd509 fix: scope prompt chime dedupe by tab 2026-07-07 16:02:08 +08:00
SivanCola 6a68799e18 fix(pluginpkg): discover conventional Claude skill dirs and warn on unmapped capabilities
Review findings on the Claude manifest support:

- A standard Claude plugin ships a metadata-only plugin.json and relies
  on Claude's auto-discovery of the conventional skills/ directory (packs
  in the wild also use .claude/skills/). Reading only the explicit
  `skills` field installed such packages as zero capabilities with no
  warning — "install succeeded" while nothing usable landed. Claude-kind
  parsing now falls back to the conventional directories when the
  manifest declares none, adopting a directory only if it actually holds
  a <name>/SKILL.md. Explicit declarations disable the fallback; codex
  parsing is untouched.
- Conventional Claude capabilities Reasonix does not map yet (commands/,
  agents/, hooks/hooks.json, .mcp.json) now surface as install warnings
  instead of being silently dropped.
- Negative test pins the includeCodexSessionStartHook boundary: a
  claude-kind package shipping hooks/session-start-codex must not get it
  registered as an executable SessionStart hook.
- Docs: retitle the compatibility section to "Codex & Claude", document
  the fallback, the warnings, and that marketplace.json indexes are out
  of scope.

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

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

Two changes make the contract real:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs #6027.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Rework the owner registry around identity:

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

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

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

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

A load-time repair (LoadSession normalizing dangling tool calls, empty
names, truncated args) still forces a real write even when digests
match, so the repair actually reaches disk instead of being skipped as
a no-op forever.
2026-07-04 23:18:12 +08:00
SivanCola 82e7ba1068 Merge pull request #5931 from SivanCola/feature/longcat-provider
Add LongCat provider presets / 新增 LongCat 供应商预设
2026-07-04 22:48:09 +08:00
SivanCola 24946f31c7 Merge pull request #5777 from SauronSkywalker/fix/desktop-zoom-slider-resize-coords
# fix(desktop): fix zoom slider capsule seam and correct frameless resize coordinates
2026-07-04 22:47:54 +08:00
Reasonix ContributorandSivanCola dfa0928859 fix(desktop): fix zoom slider capsule seam and correct frameless resize coordinates
Co-authored-by: SivanCola <[email protected]>
2026-07-04 22:42:08 +08:00
SivanCola 11fc7d1b33 Add LongCat provider presets 2026-07-04 22:39:34 +08:00
SivanCola 0a1839adc9 test(desktop): add faster desktop test lane 2026-07-04 22:31:10 +08:00
SivanCola c3c3cac1f9 Merge pull request #5926 from SivanCola/fix/desktop-provider-rebuild-lease
Fix provider-key rebuild lease handling / 修复 provider key 重建租约处理
2026-07-04 22:18:44 +08:00
SivanCola 9c06b33ffa Merge pull request #5925 from SivanCola/fix/desktop-hydration-lag-meta
Reduce desktop hydration metadata lag / 降低桌面端 hydrate 元数据卡顿
2026-07-04 22:07:34 +08:00
SivanCola 39270df554 Fix provider-key rebuild lease handling 2026-07-04 21:58:22 +08:00
SivanCola fd9f8027f4 Guard session baseline cloning 2026-07-04 21:52:20 +08:00
SivanCola d5949f79bb Fix desktop hydration lag metadata refresh 2026-07-04 21:52:15 +08:00
SivanCola 250b9f6f91 Merge pull request #5924 from SivanCola/fix/display-zoom-recovery
Fix display zoom recovery controls / 修复显示缩放恢复控件
2026-07-04 21:48:14 +08:00
SivanCola c454608baf fix(runtime): address lock review follow-ups 2026-07-04 21:43:29 +08:00
SivanCola b95698d03c fix(desktop): make display zoom recoverable 2026-07-04 21:33:33 +08:00
SivanCola 65118e2d9b Merge pull request #5921 from SivanCola/fix/desktop-topic-order-metadata
Stabilize desktop topic ordering metadata / 修复桌面会话排序元数据
2026-07-04 21:15:45 +08:00
SivanCola 62cbf89624 Fix session rewrite recovery loops 2026-07-04 21:13:46 +08:00
SivanCola 0bffc871da fix: stabilize desktop topic ordering metadata 2026-07-04 21:00:28 +08:00
SivanCola 65be8e9d0c fix(runtime): avoid snapshot lock inversions 2026-07-04 20:48:30 +08:00
SivanCola c0867f60a5 Merge pull request #5912 from SivanCola/fix/desktop-model-switch-lease
Fix desktop model switch lease recovery / 修复桌面端切模型会话租约恢复
2026-07-04 18:23:50 +08:00
SivanCola 020a1f1757 fix(desktop): satisfy model switch lease lint 2026-07-04 18:17:15 +08:00
SivanCola 02d8bf531f Merge pull request #5911 from SivanCola/fix/mac-main-thread-hang-watchdog
Detect macOS main-thread hangs / 检测 macOS 主线程卡死
2026-07-04 18:15:10 +08:00
SivanCola 76dc8a48bc fix(desktop): preserve model switch session leases 2026-07-04 18:07:09 +08:00
SivanCola 01c4e63e58 Reduce macOS hang watchdog false positives 2026-07-04 18:02:37 +08:00
SivanCola 6abb384aac Detect macOS main-thread hangs 2026-07-04 17:40:00 +08:00
GTC2080 c146456d8a fix(cli): repaint wide-character input on Windows 2026-07-01 20:15:05 +10:00
416 changed files with 43876 additions and 3064 deletions

No files matched your search

+26
View File
@@ -1,2 +1,28 @@
.gitattributes text eol=lf
.gitignore text eol=lf
.npmrc text eol=lf
*.astro text eol=lf
*.css text eol=lf
*.go text eol=lf
*.html text eol=lf
*.js text eol=lf
*.json text eol=lf
*.md text eol=lf
*.mjs text eol=lf
*.mod text eol=lf
*.nsi text eol=lf
*.plist text eol=lf
*.py text eol=lf
*.rs text eol=lf
*.sh text eol=lf
*.sql text eol=lf
*.sum text eol=lf
*.toml text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.txt text eol=lf
*.xml text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
.githooks/** text eol=lf
benchmarks/**/*.sh text eol=lf
+61
View File
@@ -48,6 +48,7 @@ jobs:
run: go build ./...
- name: test
if: runner.os != 'Windows'
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
# regression there silently tanks the cache hit rate the project is
@@ -55,6 +56,19 @@ jobs:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test ./...
- name: test
if: runner.os == 'Windows'
timeout-minutes: 10
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
# regression there silently tanks the cache hit rate the project is
# built around.
REASONIX_RELEASE_CACHE_GUARD: "1"
# Bound sandbox helper children in Windows tests so a failed OS-level
# launch cannot pin the Actions step after Go's package timeout fires.
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -p 4 -timeout=3m ./...
race:
runs-on: ubuntu-latest
steps:
@@ -149,6 +163,53 @@ jobs:
- name: test
run: go test ./...
# Desktop project-root matching is case-insensitive only on Windows
# (sameDesktopPath folds case when os.PathSeparator is '\'), so the
# regression tests for that contract are named *OnWindows and can never
# run on the ubuntu leg above.
desktop-windows:
runs-on: windows-latest
defaults:
run:
shell: bash
working-directory: desktop
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: pnpm/action-setup@v6
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend/pnpm-lock.yaml
- name: Install Wails CLI
run: |
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
go install github.com/wailsapp/wails/v2/cmd/[email protected]
# go:embed of frontend/dist needs a built frontend before the package
# compiles, same as the ubuntu desktop leg.
- name: Build frontend
run: |
wails generate module
pnpm --dir frontend install --frozen-lockfile
pnpm --dir frontend build
- name: test (Windows-only path semantics)
timeout-minutes: 10
run: go test . -run 'OnWindows$' -v
lint:
runs-on: ubuntu-latest
steps:
+67 -5
View File
@@ -9,7 +9,7 @@ name: Release desktop
# 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, then the crash worker
# release gateway; GitHub's repository-wide "latest" is reserved for the CLI line).
# release gateway; stable desktop releases own GitHub's repository-wide "latest").
#
# The same build/sign/manifest pipeline serves two channels. A `desktop-v*` tag
# (or manual stable dispatch) publishes a GitHub release and moves R2's latest/
@@ -269,8 +269,14 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
# Keep the repository homepage focused on the installable desktop app;
# the CLI release line is configured not to claim repository-wide latest.
args=(--title "Reasonix Desktop ${{ steps.ver.outputs.version }}" --generate-notes)
[ "${{ steps.ver.outputs.prerelease }}" = "true" ] && args+=(--prerelease)
if [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then
args+=(--prerelease --latest=false)
else
args+=(--latest)
fi
gh release create "${{ steps.ver.outputs.tag }}" dist/* "${args[@]}"
- name: Upload canary dist for mirror
@@ -367,6 +373,10 @@ jobs:
aws s3 cp assets/ "s3://${R2_BUCKET}/latest/" --recursive --endpoint-url "$ENDPOINT"
fi
# dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot
# protection), so smoke the mirrored objects over the authenticated S3 API
# instead of the public edge. This verifies the mirror landed; the public
# edge itself is not reachable from CI and is covered by end users' traffic.
- name: Smoke desktop release pointers
if: env.HAS_R2 == 'true'
env:
@@ -374,8 +384,11 @@ jobs:
VERSION: ${{ steps.ver.outputs.version }}
CHANNEL: ${{ steps.ver.outputs.channel }}
PRERELEASE: ${{ steps.ver.outputs.prerelease }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
f=assets/latest.json
jq -e --arg version "$VERSION" --arg prefix "https://dl.reasonix.io/${TAG}/" '
.version == $version and
@@ -384,7 +397,8 @@ jobs:
all(type == "string" and startswith($prefix) and (contains("/releases/latest/") | not)))
' "$f" >/dev/null
curl --retry 5 --retry-delay 2 -fsSL "https://dl.reasonix.io/${TAG}/latest.json" >/tmp/reasonix-desktop-tag-latest.json
aws s3 cp "s3://${R2_BUCKET}/${TAG}/latest.json" /tmp/reasonix-desktop-tag-latest.json --endpoint-url "$ENDPOINT"
jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-tag-latest.json >/dev/null
if [ "$CHANNEL" = "canary" ]; then
pointer="canary"
elif [ "$PRERELEASE" = "true" ]; then
@@ -393,14 +407,62 @@ jobs:
pointer="latest"
fi
if [ -n "$pointer" ]; then
curl --retry 5 --retry-delay 2 -fsSL "https://dl.reasonix.io/${pointer}/latest.json" >/tmp/reasonix-desktop-pointer-latest.json
aws s3 cp "s3://${R2_BUCKET}/${pointer}/latest.json" /tmp/reasonix-desktop-pointer-latest.json --endpoint-url "$ENDPOINT"
jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-pointer-latest.json >/dev/null
fi
jq -r '.platforms[] | .url, .sig' "$f" | while IFS= read -r asset; do
curl --retry 5 --retry-delay 2 -fsSI "$asset" >/dev/null
key="${asset#https://dl.reasonix.io/}"
aws s3api head-object --bucket "$R2_BUCKET" --key "$key" --endpoint-url "$ENDPOINT" >/dev/null
done
# Best-effort probe of the release gateway — the updater's second
# manifest source — over the same public edge and Go client UA end users
# hit. A 403 here is the known Cloudflare bot-protection gap (#6005:
# datacenter/proxy egress gets blocked before the worker runs) and must
# not fail the release until a WAF skip rule for /v1/desktop/releases/*
# lands; it is surfaced as a warning so the run shows whether the edge
# is open. Anything else unexpected (404, 5xx, wrong version) means the
# gateway route or pointer regressed and fails hard.
- name: Probe public release gateway
if: env.HAS_R2 == 'true'
env:
VERSION: ${{ steps.ver.outputs.version }}
CHANNEL: ${{ steps.ver.outputs.channel }}
PRERELEASE: ${{ steps.ver.outputs.prerelease }}
run: |
set -euo pipefail
if [ "$PRERELEASE" = "true" ]; then
echo "prerelease — no pointer moved; skipping gateway probe"
exit 0
fi
chan="stable"
[ "$CHANNEL" = "canary" ] && chan="canary"
url="https://crash.reasonix.io/v1/desktop/releases/${chan}/latest.json"
# curl already prints 000 for a transport failure; || true keeps -e
# from killing the step so the case below can route it.
code="$(curl -sS -A "Go-http-client/2.0" -o /tmp/gateway-latest.json -w '%{http_code}' "$url" || true)"
case "$code" in
200)
if jq -e --arg version "$VERSION" '.version == $version' /tmp/gateway-latest.json >/dev/null; then
echo "gateway serves $VERSION on $chan"
else
echo "::error::gateway responded 200 but serves $(jq -r '.version // "<none>"' /tmp/gateway-latest.json), want $VERSION — stale or wrong pointer"
exit 1
fi
;;
403)
echo "::warning::gateway returned 403 to CI egress — known bot-protection gap (#6005), not failing the release"
;;
000|"")
echo "::warning::gateway unreachable from CI (transport error), not failing the release"
;;
*)
echo "::error::gateway returned $code for $url — route or pointer regression"
exit 1
;;
esac
- name: Attach desktop manifest to matching CLI release
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' && steps.ver.outputs.prerelease != 'true'
env:
+30 -4
View File
@@ -6,16 +6,17 @@ name: Release npm
# own tag lets a prerelease ship to npm's `next` channel without touching the
# stable Homebrew cask (and lets a stable cask ship without forcing npm to leave
# its rc). Bump with:
# git tag npm-vX.Y.Z # stable -> publishes under `next`
# git tag npm-vX.Y.Z # stable -> publishes under `latest`
# git tag npm-vX.Y.Z-rc.N # prerelease -> publishes under `next`
# git push origin <tag>
#
# A manual workflow_dispatch can publish either an opt-in canary from the current
# ref or an approved stable/next version when tag creation is restricted. Canary
# publishes to the `canary` dist-tag (npm i reasonix@canary) and never moves
# `next`/`latest`. build.mjs decides the dist-tag: 0.x stable is `latest`; a
# `-canary.` build is `canary`; the 1.x line and rc prereleases are `next`. Promote
# a 1.x stable to the default install with `npm dist-tag add [email protected] latest`.
# `next`/`latest`. build.mjs decides the dist-tag: a `-canary.` build is `canary`,
# any other prerelease is `next`, and a stable version is `latest` (#5822 — the
# old "promote latest by hand" rule left it pinned at 0.53.2 and downgraded
# `npm update -g` users). The verify step below asserts the tag actually landed.
on:
push:
tags: ['npm-v*']
@@ -86,3 +87,28 @@ jobs:
- run: node npm/build.mjs "${{ steps.ver.outputs.arg }}" --publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# Assert the dist-tag landed where build.mjs aimed it. This is the guard
# that #5822 lacked: `latest` sat on 0.53.2 for months while stables went
# to `next`, and nothing noticed until users got downgraded. The registry
# applies tags asynchronously after publish, hence the poll.
- name: Verify dist-tags
run: |
set -euo pipefail
version="${{ steps.ver.outputs.arg }}"
version="${version#npm-}"; version="${version#v}"
case "$version" in
*-canary.*) tag=canary ;;
*-*) tag=next ;;
*) tag=latest ;;
esac
for attempt in 1 2 3 4 5 6; do
got="$(npm view reasonix dist-tags."$tag" 2>/dev/null || true)"
if [ "$got" = "$version" ]; then
echo "dist-tag $tag -> $got"
exit 0
fi
echo "dist-tag $tag -> ${got:-<unset>}, want $version (attempt $attempt)"
sleep 10
done
echo "::error::npm dist-tag '$tag' never landed on $version"
exit 1
+105 -2
View File
@@ -30,7 +30,9 @@ jobs:
name: archives + checksums + homebrew tap
needs: cache-guard
runs-on: ubuntu-latest
# CLI stable release (archives + GitHub release + Homebrew cask). Gated on the
# CLI stable release (archives + GitHub release + Homebrew cask). It does not
# claim GitHub's repository-wide Latest badge; that belongs to desktop so the
# repository homepage points users at the installable app. Gated on the
# `release` environment so only esengine can approve it going public.
environment: release
steps:
@@ -53,6 +55,9 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
case "$TAG" in
@@ -61,10 +66,108 @@ jobs:
exit 0
;;
esac
curl --retry 5 --retry-delay 2 -fsSL https://dl.reasonix.io/latest/latest.json -o latest.raw.json
if [ "$HAS_R2" != "true" ]; then
echo "R2 secrets not configured; skipping desktop manifest compatibility asset"
exit 0
fi
# dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot
# protection), so read the manifest over the authenticated S3 API instead
# of the public edge.
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
aws configure set region auto
aws s3 cp "s3://${R2_BUCKET}/latest/latest.json" latest.raw.json \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
jq '.download_page = "https://reasonix.io/#start"' latest.raw.json > latest.json
jq -e '
([.platforms[] | (.url, .sig)] |
all(type == "string" and startswith("https://dl.reasonix.io/") and (contains("/releases/latest/") | not)))
' latest.json >/dev/null
gh release upload "$TAG" latest.json --clobber
# The compatibility asset exists for pre-v1.16 desktop updaters that
# still poll GitHub's repository-wide latest URL. Desktop releases now
# own that Latest badge, but this check still exercises the public fallback
# path exactly the way those clients fetch it: anonymously, over the public
# edge, with a Go client UA. Unlike dl.reasonix.io (whose bot protection
# 403s Actions egress — see the R2 note above), GitHub serves its own
# runners, so this can hard-fail. #5826/#5858 shipped a broken update check
# for weeks precisely because nothing exercised the public path. Retries
# cover the release CDN propagating the freshly uploaded asset.
- name: Smoke public compatibility manifest
env:
TAG: ${{ github.ref_name }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
run: |
set -euo pipefail
case "$TAG" in
*-*)
echo "prerelease $TAG — no compatibility asset uploaded; skipping"
exit 0
;;
esac
if [ "$HAS_R2" != "true" ]; then
echo "R2 secrets not configured; no compatibility asset uploaded; skipping"
exit 0
fi
url="https://github.com/${{ github.repository }}/releases/latest/download/latest.json"
for attempt in 1 2 3 4 5 6; do
if curl -fsSL -A "Go-http-client/2.0" -o /tmp/compat-latest.json "$url"; then
jq -e '(.version | type == "string") and (.platforms | type == "object")' /tmp/compat-latest.json >/dev/null
echo "public compatibility manifest OK (desktop version $(jq -r .version /tmp/compat-latest.json))"
exit 0
fi
echo "attempt $attempt failed; retrying in 10s"
sleep 10
done
echo "::error::public compatibility manifest unreachable at $url"
exit 1
# A stable CLI release must never leave the npm line behind: v1.17.5
# shipped as binaries/Homebrew while npm `latest` still pointed at 0.53.2
# (#5822) — every `npm update -g` user was silently downgraded to a
# months-old version, and nothing noticed because the npm line
# (release-npm.yml, `npm-vX.Y.Z` tags) is triggered independently and the
# stable npm tag was simply never pushed. release-npm.yml's own verify
# step only guards runs that happen; this guard catches the run that
# DIDN'T.
#
# Two distinct states, two responses (the runs race: both workflows wait
# on the release environment independently, and npm dist-tags propagate
# asynchronously, so "tag pushed but latest not moved yet" is a NORMAL
# mid-release state, not a failure):
# - npm-v<version> tag missing -> hard fail. This is the #5822 gap:
# nobody pushed the npm release at all.
# - tag pushed, latest lagging -> poll briefly, then WARN and pass.
# The npm run may still be awaiting its own environment approval;
# release-npm.yml's verify step owns asserting the dist-tag lands.
- name: Check npm latest dist-tag freshness
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
case "$TAG" in
*-*)
echo "prerelease $TAG — npm latest does not move on prereleases; skipping"
exit 0
;;
esac
version="${TAG#v}"
if ! git ls-remote --exit-code origin "refs/tags/npm-v$version" >/dev/null; then
echo "::error::the npm-v$version tag was never pushed — the npm channel is being left behind and 'npm update -g' users will be downgraded to the old 'latest'. Push it: git tag npm-v$version ${TAG} && git push origin npm-v$version (or 'npm dist-tag add reasonix@$version latest' for an already-published version)."
exit 1
fi
for attempt in 1 2 3 4 5 6; do
got="$(npm view reasonix dist-tags.latest 2>/dev/null || true)"
if [ -n "$got" ]; then
newest="$(printf '%s\n%s\n' "$got" "$version" | sort -V | tail -1)"
if [ "$newest" = "$got" ]; then
echo "npm latest -> $got (>= $version) OK"
exit 0
fi
fi
echo "npm latest -> ${got:-<unreadable>}, want >= $version (attempt $attempt)"
sleep 10
done
echo "::warning::npm-v$version is pushed but npm 'latest' is still ${got:-<unreadable>} — the npm publish run is likely awaiting release-environment approval or still propagating. Approve/monitor the release-npm run; its verify step asserts the dist-tag lands."
exit 0
+1
View File
@@ -58,4 +58,5 @@ homebrew_casks:
release:
prerelease: auto
make_latest: false
name_template: "Reasonix CLI {{ .Tag }}"
+31
View File
@@ -63,6 +63,37 @@ make hooks # install git hooks (pre-push: go vet)
make cross # cross-compile for all 6 targets
```
### Isolated development environment
A source-built binary shares no on-disk state with a stable release when launched
with `REASONIX_HOME` set. This gives each build its own self-contained directory
tree — config, credentials, sessions, cache, skills, commands, hooks, and
desktop tab state — so the two builds never interfere:
**CLI**
```bash
REASONIX_HOME=/tmp/reasonix-dev go run ./cmd/reasonix
# or after building:
# REASONIX_HOME=/tmp/reasonix-dev ./bin/reasonix
```
**Desktop**
```bash
cd desktop && wails build
REASONIX_HOME=/tmp/reasonix-dev-isolated build/bin/reasonix-desktop
```
On Windows, use `$env:REASONIX_HOME` in PowerShell or `set REASONIX_HOME=` in
Command Prompt; the binary extension is `.exe`.
The directory is empty on first launch; the app behaves exactly like a fresh
install. Every subsequent write — config saves, credential storage, session
logs — stays under `REASONIX_HOME`. Legacy migration, OS-home convention
directory scanning, and all other fallback paths are skipped so no production
data leaks in or out.
### Cache-first review gate
Reasonix treats high prompt-cache hit rate as product behavior. Changes that
+10 -1
View File
@@ -2,7 +2,7 @@ VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
LDFLAGS := -s -w -X main.version=$(VERSION)
GOEXE := $(shell go env GOEXE)
.PHONY: build vet fmt test hooks cross clean
.PHONY: build vet fmt test desktop-test desktop-test-short desktop-test-times hooks cross clean
build:
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
@@ -17,6 +17,15 @@ fmt:
test:
go test ./...
desktop-test:
cd desktop && go test .
desktop-test-short:
cd desktop && go test -short .
desktop-test-times:
cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
hooks:
@git config core.hooksPath .githooks
@echo "installed: core.hooksPath -> .githooks (pre-push runs go vet)"
+2 -6
View File
@@ -33,12 +33,6 @@
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
</p>
<p align="center">
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/9e931d80-2050-4b10-902e-44970cc133ad.svg" alt="oosmetrics — Top 2 in Agents by velocity"/></a>
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/556d94b3-61b7-486b-baf2-888b9327deab.svg" alt="oosmetrics — Top 3 in LLMs by velocity"/></a>
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/0f457d4c-efca-4d15-ad2b-139691ff342c.svg" alt="oosmetrics — Top 3 in CLI by velocity"/></a>
</p>
<br/>
<h3 align="center">A DeepSeek-native AI coding agent for your terminal.</h3>
@@ -137,6 +131,8 @@ commands, `@` references, and two-model setup are all in the
from the desktop app, then use approvals, YOLO, and commands from IM.
- **[Spec](./docs/SPEC.md)** — engineering contract: architecture, registries,
data types, and roadmap.
- **[Task contracts & pause policy](./docs/TASK_CONTRACT.md)** — structure
complex requests with context, output boundaries, constraints, and when to ask.
- **[Tool contract](./docs/TOOL_CONTRACT.md)** — provider-visible built-in tool
names, read-only flags, and schema snapshot guard.
- **[Migrating from 0.x](./docs/MIGRATING.md)** — moving from the legacy
+1 -6
View File
@@ -33,12 +33,6 @@
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
</p>
<p align="center">
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/9e931d80-2050-4b10-902e-44970cc133ad.svg" alt="oosmetrics — Top 2 in Agents by velocity"/></a>
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/556d94b3-61b7-486b-baf2-888b9327deab.svg" alt="oosmetrics — Top 3 in LLMs by velocity"/></a>
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/0f457d4c-efca-4d15-ad2b-139691ff342c.svg" alt="oosmetrics — Top 3 in CLI by velocity"/></a>
</p>
<br/>
<h3 align="center">面向终端的 DeepSeek 原生 AI coding agent。</h3>
@@ -126,6 +120,7 @@ provider key 的运行时 fallback,但仍会作为当前 workspace 范围内
- **[机器人使用指南](./docs/BOT_GUIDE.zh-CN.md)** —— 桌面端连接飞书、Lark、微信
Bot,以及 IM 里的审批、YOLO 和命令交互。
- **[规格](./docs/SPEC.md)** —— 工程契约:架构、registry、数据类型与路线图。
- **[任务合约与暂停策略](./docs/TASK_CONTRACT.zh-CN.md)** —— 用背景、输出边界、约束和暂停条件组织复杂请求。
- **[工具合约](./docs/TOOL_CONTRACT.zh-CN.md)** —— provider 可见的内置工具名、
read-only 标记和 schema 快照保护。
- **[从 0.x 迁移](./docs/MIGRATING.md)** —— 从 legacy TypeScript 版本迁到 1.0 Go 重写版。
+24 -5
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,9 +119,10 @@ Desktop releases ride their own tag namespace, `desktop-v<semver>` (plain `v*`
tags are the CLI release). Pushing one triggers `.github/workflows/release-desktop.yml`,
which builds on a native runner per platform (Wails can't cross-compile a
CGO/WebKit binary), packages each artifact, signs it with minisign, generates a
`latest.json` manifest, publishes a GitHub release, 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.
`latest.json` manifest, publishes a GitHub release, marks the desktop release as
GitHub's repository-wide `Latest`, mirrors everything to R2, and attaches the
current desktop manifest to the matching CLI release for old clients that still
ask GitHub's repository-wide `latest` release for it.
The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
`libwebkit2gtk-4.1-0` at runtime — present by default on Ubuntu 22.04+, Fedora 40+.
@@ -115,8 +134,8 @@ 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:
GitHub's repository-wide `/releases/latest` shortcut, so updater behavior does
not depend on homepage badge semantics. Self-update behavior by platform:
- **Linux / Windows** — download, verify the minisign signature, then update in
place: Linux replaces the binary and relaunches; Windows runs the per-user NSIS
+1173 -391
View File
File diff suppressed because it is too large. Load diff
+12 -13
View File
@@ -93,13 +93,12 @@ func appWithTab(t *testing.T, path string) (*App, *WorkspaceTab) {
// nil sink ctx (no webview) must not disable persistence.
func TestTurnDonePersistsSession(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
_ = path
a, tab := appWithTab(t, path)
_, tab := appWithTab(t, path)
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, path, "remember this turn")
_ = a
waitForAutosaveIdle(t, tab)
}
// TestNonTurnDoneDoesNotPersist confirms only TurnDone triggers a save, so the
@@ -248,8 +247,8 @@ func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
if tab.SessionPath != recoveryPath {
t.Fatalf("tab session path = %q, want recovery path %q", tab.SessionPath, recoveryPath)
}
if tab.TopicID == "" || tab.TopicID == originalTopic {
t.Fatalf("tab topic ID = %q, want fresh recovery topic", tab.TopicID)
if tab.TopicID != originalTopic {
t.Fatalf("tab topic ID = %q, want original topic %q", tab.TopicID, originalTopic)
}
meta, ok, err := agent.LoadBranchMeta(recoveryPath)
if err != nil || !ok {
@@ -263,22 +262,22 @@ func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
t.Fatalf("tab recovery meta = %+v, want digest %q parent %q", tabMeta, meta.RecoveryDigest, meta.ParentID)
}
nodes := app.ListProjectTree()
found := false
foundOriginal := false
var walk func([]ProjectNode)
walk = func(list []ProjectNode) {
for _, node := range list {
if node.TopicID == tab.TopicID {
found = true
if !node.Recovered || node.RecoveryDigest != meta.RecoveryDigest || node.RecoveryParentID != string(meta.ParentID) {
t.Fatalf("project tree recovery node = %+v, want digest %q parent %q", node, meta.RecoveryDigest, meta.ParentID)
}
if node.Recovered {
t.Fatalf("project tree should hide recovery metadata, got node %+v", node)
}
if node.TopicID == originalTopic {
foundOriginal = true
}
walk(node.Children)
}
}
walk(nodes)
if !found {
t.Fatalf("project tree did not include recovery topic %q: %#v", tab.TopicID, nodes)
if !foundOriginal {
t.Fatalf("project tree did not include original topic %q: %#v", originalTopic, nodes)
}
}
+89
View File
@@ -168,6 +168,60 @@ func TestEnsureBlankTabReusesIndexedTopicWithEmptyStub(t *testing.T) {
}
}
func TestEnsureBlankTabStoresCreatedAt(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
before := time.Now().UnixMilli()
meta, err := app.EnsureBlankTab("global", "")
after := time.Now().UnixMilli()
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
createdAt := loadTopicCreatedAt("", meta.TopicID)
if createdAt < before || createdAt > after {
t.Fatalf("createdAt = %d, want between %d and %d", createdAt, before, after)
}
nodes := app.ListProjectTree()
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 {
t.Fatalf("project tree = %#v, want Global with one topic", nodes)
}
if got := nodes[0].Children[0].CreatedAt; got != createdAt {
t.Fatalf("project tree createdAt = %d, want %d", got, createdAt)
}
}
func TestEnsureBlankTabRepairsMissingCreatedAtForReusedTopic(t *testing.T) {
isolateDesktopUserDirs(t)
const topicID = "topic_20260704-104018_deadbeef"
if err := setTopicTitleWithSource("", topicID, defaultTopicTitle, topicTitleSourceAuto); err != nil {
t.Fatalf("set topic title: %v", err)
}
if err := prependTopicInProjectsFile("", topicID, false); err != nil {
t.Fatalf("prepend topic: %v", err)
}
if got := loadTopicCreatedAt("", topicID); got != 0 {
t.Fatalf("createdAt before reuse = %d, want 0", got)
}
app := NewApp()
meta, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if meta.TopicID != topicID {
t.Fatalf("EnsureBlankTab topic = %q, want reused topic %q", meta.TopicID, topicID)
}
expected := time.Date(2026, 7, 4, 10, 40, 18, 0, time.UTC).UnixMilli()
if got := loadTopicCreatedAt("", topicID); got != expected {
t.Fatalf("repaired createdAt = %d, want %d", got, expected)
}
}
// EnsureBlankTab reuses an already-open project-scoped blank tab.
func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
@@ -588,6 +642,41 @@ func TestEnsureBlankTabDoesNotReuseProjectTopicWithSession(t *testing.T) {
}
}
// EnsureBlankTab must not reuse a tombstoned topic: the reused ID would flow
// into ensureTopicIndexed, whose intentional prepend clears the delete
// tombstone and resurrects the topic the user removed.
func TestEnsureBlankTabDoesNotReuseTombstonedTopic(t *testing.T) {
isolateDesktopUserDirs(t)
// Race product on disk: deleted topic whose default title lingered in the
// global title map (title-only, absent from GlobalTopics, no sessions).
tombstonedID := "topic_tombstone_blank"
if err := setTopicTitle("", tombstonedID, defaultTopicTitle); err != nil {
t.Fatalf("set lingering title: %v", err)
}
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
return true, nil
}); err != nil {
t.Fatalf("seed tombstone: %v", err)
}
meta, err := NewApp().EnsureBlankTab("global", "")
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if meta.TopicID == tombstonedID {
t.Fatalf("EnsureBlankTab reused tombstoned topic %q", meta.TopicID)
}
f := loadProjectsFile()
if !containsDesktopString(f.DeletedTopics, tombstonedID) {
t.Fatalf("deletedTopics = %#v, tombstone must survive blank-tab creation", f.DeletedTopics)
}
if containsDesktopString(f.GlobalTopics, tombstonedID) {
t.Fatalf("globalTopics = %#v, tombstoned topic must not be re-indexed", f.GlobalTopics)
}
}
// NewSession skips the snapshot when the current tab has no real conversation content.
func TestNewSessionNoopsWhenCurrentTabIsBlank(t *testing.T) {
+1777 -10
View File
File diff suppressed because it is too large. Load diff
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"errors"
"testing"
"time"
)
func TestReportTabSnapshotErrorDebouncesAutosave(t *testing.T) {
app := NewApp()
tab := &WorkspaceTab{ID: "tab_warn", sink: &tabEventSink{tabID: "tab_warn", app: app}}
failure := errors.New("disk unhappy")
app.reportTabSnapshotError(tab, "autosave", failure)
tab.saveMu.Lock()
first := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if first.IsZero() {
t.Fatal("first autosave warning did not record its timestamp")
}
// Within the window: suppressed, timestamp untouched.
app.reportTabSnapshotError(tab, "autosave", failure)
tab.saveMu.Lock()
second := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if !second.Equal(first) {
t.Fatalf("suppressed warning moved the debounce timestamp: %v -> %v", first, second)
}
// Window expired: warns again.
tab.saveMu.Lock()
tab.lastAutosaveWarnAt = time.Now().Add(-autosaveWarnInterval - time.Second)
tab.saveMu.Unlock()
app.reportTabSnapshotError(tab, "autosave", failure)
tab.saveMu.Lock()
third := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if !third.After(first) {
t.Fatal("expired window did not re-arm the autosave warning")
}
// Explicit user actions are never debounced (state untouched).
app.reportTabSnapshotError(tab, "changing model", failure)
tab.saveMu.Lock()
fourth := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if !fourth.Equal(third) {
t.Fatal("action-save warning must not consume the autosave debounce window")
}
}
+9 -2
View File
@@ -138,12 +138,19 @@ func (a *App) StartBotConnectionInstall(provider, domain string) (BotInstallStar
func (a *App) PollBotConnectionInstall(installID string) (BotInstallPollResult, error) {
installID = strings.TrimSpace(installID)
// Copy the session under a.mu: overlapping polls of the same install can
// race the locked PollDomain upgrade below with unlocked field reads.
a.mu.RLock()
session := a.botInstalls[installID]
sessionPtr := a.botInstalls[installID]
var sessionCopy botInstallSession
if sessionPtr != nil {
sessionCopy = *sessionPtr
}
a.mu.RUnlock()
if session == nil {
if sessionPtr == nil {
return BotInstallPollResult{Error: "install session not found"}, nil
}
session := &sessionCopy
if time.Now().After(session.ExpireAt) {
a.deleteBotInstall(installID)
return BotInstallPollResult{Status: "expired", Error: "install session expired"}, nil
+50 -23
View File
@@ -23,10 +23,15 @@ type BotRuntimeStatusView struct {
}
type desktopBotRuntime struct {
mu sync.Mutex
cancel context.CancelFunc
gw *bot.BotGateway
status BotRuntimeStatusView
// lifecycleMu serializes start/stop transitions so two apply/stop calls
// can't race a gateway into existence. The slow work (gw.Stop teardown,
// gw.Start dials) runs while holding it but NOT r.mu, so status/send reads
// never block on a restart.
lifecycleMu sync.Mutex
mu sync.Mutex
cancel context.CancelFunc
gw *bot.BotGateway
status BotRuntimeStatusView
}
func newDesktopBotRuntime() *desktopBotRuntime {
@@ -65,8 +70,11 @@ func (a *App) refreshBotRuntimeAsync() {
}
func (a *App) refreshBotRuntime() {
// NewApp always pre-fills botRuntime; a nil here means a test-constructed
// App with no bot runtime, which must not lazily create one from a
// background goroutine (that would race a concurrent refresh).
if a.botRuntime == nil {
a.botRuntime = newDesktopBotRuntime()
return
}
cfg, err := a.loadDesktopBotConfig()
if err != nil {
@@ -77,7 +85,11 @@ func (a *App) refreshBotRuntime() {
}
func (a *App) loadDesktopBotConfig() (*config.Config, error) {
cfg, _, err := a.loadDesktopUserConfigForEdit()
// Read-only load feeding the bot runtime and connection diagnostics. It
// must load credentials: the runtime resolves app secrets and control
// tokens from the process env (AppSecretEnv, Control.TokenEnv), which the
// credential-free view load would leave unset on a fresh process.
cfg, _, err := a.loadDesktopUserConfigForViewWithCredentials()
if err != nil {
return nil, err
}
@@ -105,11 +117,11 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
parent = context.Background()
}
plan := desktopBotRuntimePlan(cfg)
r.mu.Lock()
defer r.mu.Unlock()
r.stopLocked()
r.lifecycleMu.Lock()
defer r.lifecycleMu.Unlock()
r.stopCurrent()
if !plan.Start {
r.status = BotRuntimeStatusView{Status: plan.Status, Message: plan.Message}
r.setStatus(BotRuntimeStatusView{Status: plan.Status, Message: plan.Message})
return nil
}
@@ -176,14 +188,14 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
bindings := botruntime.AdapterBindings(cfg, plan.Enabled, nil, logger)
if len(bindings) == 0 {
cancel()
r.status = BotRuntimeStatusView{Status: "stopped", Message: "no bot adapters configured"}
r.setStatus(BotRuntimeStatusView{Status: "stopped", Message: "no bot adapters configured"})
return nil
}
gw := bot.NewGatewayWithAdapterBindings(gwCfg, bindings, logger)
if err := gw.Start(ctx); err != nil {
cancel()
gw.Stop()
r.status = BotRuntimeStatusView{Status: "error", Message: err.Error(), Connections: gw.AdapterCount()}
r.setStatus(BotRuntimeStatusView{Status: "error", Message: err.Error(), Connections: gw.AdapterCount()})
return err
}
runningConnections := gw.AdapterCount()
@@ -194,6 +206,7 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
status = "degraded"
message = fmt.Sprintf("%d bot connection(s) running; %d failed to start: %s", runningConnections, len(startErrors), summarizeBotRuntimeErrors(startErrors))
}
r.mu.Lock()
r.cancel = cancel
r.gw = gw
r.status = BotRuntimeStatusView{
@@ -203,6 +216,7 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
Connections: runningConnections,
StartedAt: time.Now().UTC().Format(time.RFC3339),
}
r.mu.Unlock()
return nil
}
@@ -274,23 +288,36 @@ func desktopBotRuntimePlan(cfg *config.Config) botRuntimePlan {
}
func (r *desktopBotRuntime) stop(status, message string) {
r.mu.Lock()
defer r.mu.Unlock()
r.stopLocked()
r.status = BotRuntimeStatusView{Status: status, Message: message}
r.lifecycleMu.Lock()
defer r.lifecycleMu.Unlock()
r.stopCurrent()
r.setStatus(BotRuntimeStatusView{Status: status, Message: message})
}
func (r *desktopBotRuntime) stopLocked() {
if r.cancel != nil {
r.cancel()
r.cancel = nil
// stopCurrent detaches the running gateway under r.mu, then tears it down
// off-lock: gw.Stop() closes every session controller (up to the jobs teardown
// grace each) and must not stall status/send readers. Callers hold lifecycleMu.
func (r *desktopBotRuntime) stopCurrent() {
r.mu.Lock()
cancel := r.cancel
gw := r.gw
r.cancel = nil
r.gw = nil
r.mu.Unlock()
if cancel != nil {
cancel()
}
if r.gw != nil {
r.gw.Stop()
r.gw = nil
if gw != nil {
gw.Stop()
}
}
func (r *desktopBotRuntime) setStatus(status BotRuntimeStatusView) {
r.mu.Lock()
r.status = status
r.mu.Unlock()
}
func (r *desktopBotRuntime) snapshot() BotRuntimeStatusView {
r.mu.Lock()
defer r.mu.Unlock()
+26 -5
View File
@@ -302,7 +302,8 @@ status = "connected"
t.Fatalf("chdir project: %v", err)
}
got, err := NewApp().loadDesktopBotConfig()
app := NewApp()
got, err := app.loadDesktopBotConfig()
if err != nil {
t.Fatalf("load desktop bot config: %v", err)
}
@@ -310,6 +311,17 @@ status = "connected"
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
}
// The bot-runtime load is a pure read: the merge above stays in memory and
// the user config file is not rewritten.
preWrite := config.LoadForEdit(config.UserConfigPath())
if preWrite.Bot.Enabled || len(preWrite.Bot.Connections) != 0 {
t.Fatalf("read path persisted bot config = %+v, want disk untouched until a locked write", preWrite.Bot)
}
// The first locked write path performs the on-disk migration.
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
t.Fatalf("applyConfigOnly: %v", err)
}
persisted := config.LoadForEdit(config.UserConfigPath())
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
@@ -351,7 +363,8 @@ status = "connected"
t.Fatalf("chdir project: %v", err)
}
got, err := NewApp().loadDesktopBotConfig()
app := NewApp()
got, err := app.loadDesktopBotConfig()
if err != nil {
t.Fatalf("load desktop bot config: %v", err)
}
@@ -359,13 +372,21 @@ status = "connected"
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
}
// The bot-runtime load is a pure read: it serves the legacy config from
// memory and must not create the user config file.
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
t.Fatalf("read path must not create the user config, stat err = %v", err)
}
// The first locked write path creates the user config with the migrated
// bot settings (adopting the legacy config, ConfigVersion-bumped).
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
t.Fatalf("applyConfigOnly: %v", err)
}
persisted := config.LoadForEdit(config.UserConfigPath())
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
}
if persisted.DesktopTheme() == "dark" {
t.Fatal("legacy project desktop theme should not be persisted during bot-only migration")
}
}
func TestDesktopSettingsBotMigrationPersistsOnlyBotBeforeFirstEdit(t *testing.T) {
@@ -36,6 +36,7 @@ Unicode true
####
!include "wails_tools.nsh"
!include "FileFunc.nsh"
!include "LogicLib.nsh"
# The version information for this two must consist of 4 parts
VIProductVersion "${INFO_PRODUCTVERSION}.0"
@@ -76,6 +77,8 @@ ManifestDPIAware true
Name "${INFO_PRODUCTNAME}"
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
!define REASONIX_DEFAULT_INSTALLDIR "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}"
!define REASONIX_UPDATE_HELPER "reasonix-update-helper.exe"
!define REASONIX_UNLOCK_RETRIES 60
InstallDirRegKey HKCU "${UNINST_KEY}" "InstallLocation" # Reuse the previous install path on update; .onInit falls back to the default on first install.
InstallDir "${REASONIX_DEFAULT_INSTALLDIR}" # Per-user install location (no admin rights required).
ShowInstDetails show # This will always show the installation details.
@@ -130,14 +133,54 @@ fallback:
done:
FunctionEnd
Function reasonix.waitForExecutableUnlock
IfFileExists "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 done
StrCpy $0 0
retry:
ClearErrors
FileOpen $1 "$INSTDIR\${PRODUCT_EXECUTABLE}" a
IfErrors locked
FileClose $1
Goto done
locked:
IntOp $0 $0 + 1
IntCmp $0 ${REASONIX_UNLOCK_RETRIES} failed 0 0
Sleep 1000
Goto retry
failed:
IfSilent silent interactive
interactive:
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "Reasonix is still running. Close Reasonix, then click Retry to continue the installation." IDRETRY retry IDCANCEL abort
Goto retry
silent:
SetErrorLevel 1618
abort:
Abort "Reasonix is still running. Close Reasonix and run the installer again."
done:
FunctionEnd
Section
!insertmacro wails.setShellContext
!insertmacro wails.webview2runtime
Call reasonix.waitForExecutableUnlock
SetOutPath $INSTDIR
!insertmacro wails.files
!if /FileExists "${REASONIX_UPDATE_HELPER}"
File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
!else
!warning "${REASONIX_UPDATE_HELPER} was not found; Windows auto-update will fall back to installer-side waiting only."
!endif
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
@@ -155,6 +198,7 @@ Section "uninstall"
; Precision uninstall: delete main application files
Delete "$INSTDIR\${PRODUCT_EXECUTABLE}"
Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
+11
View File
@@ -0,0 +1,11 @@
package main
import "fmt"
func installerCommandLine(installer, dir string) string {
line := fmt.Sprintf(`"%s" /S`, installer)
if dir != "" {
line += " /D=" + dir
}
return line
}
+17
View File
@@ -0,0 +1,17 @@
package main
import (
"strings"
"testing"
)
func TestInstallerCommandLineIsSilentAndLeavesDFlagLast(t *testing.T) {
got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`)
want := `"C:\Temp\Reasonix Installer.exe" /S /D=D:\Tools\Reasonix App`
if got != want {
t.Fatalf("installerCommandLine = %q, want %q", got, want)
}
if !strings.HasSuffix(got, `/D=D:\Tools\Reasonix App`) {
t.Fatalf("/D= must be the final unquoted NSIS token, got %q", got)
}
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !windows
package main
import (
"fmt"
"os"
)
func main() {
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Windows desktop builds")
os.Exit(2)
}
+108
View File
@@ -0,0 +1,108 @@
//go:build windows
package main
import (
"flag"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"syscall"
"time"
"golang.org/x/sys/windows"
)
const parentExitTimeout = 2 * time.Minute
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
var parentPID uint
var installer, installDir, relaunch string
fs := flag.NewFlagSet("reasonix-update-helper", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
fs.UintVar(&parentPID, "parent-pid", 0, "Reasonix process id to wait for before installing")
fs.StringVar(&installer, "installer", "", "verified NSIS installer path")
fs.StringVar(&installDir, "install-dir", "", "Reasonix installation directory")
fs.StringVar(&relaunch, "relaunch", "", "Reasonix executable to start after the installer succeeds")
if err := fs.Parse(args); err != nil {
return 2
}
logger := newLogger()
if installer == "" {
logger.Print("missing --installer")
return 2
}
if parentPID != 0 {
if err := waitForProcessExit(uint32(parentPID), parentExitTimeout); err != nil {
logger.Printf("wait for parent process %d: %v", parentPID, err)
return 1
}
}
if err := runInstaller(installer, installDir); err != nil {
logger.Printf("run installer: %v", err)
return 1
}
if relaunch != "" {
if err := startRelaunch(relaunch, installDir); err != nil {
logger.Printf("relaunch: %v", err)
return 1
}
}
return 0
}
func newLogger() *log.Logger {
dir, err := os.UserCacheDir()
if err == nil {
dir = filepath.Join(dir, "Reasonix", "updates")
if err := os.MkdirAll(dir, 0o700); err == nil {
if f, err := os.OpenFile(filepath.Join(dir, "update-helper.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
return log.New(f, "", log.LstdFlags)
}
}
}
return log.New(os.Stderr, "", log.LstdFlags)
}
func waitForProcessExit(pid uint32, timeout time.Duration) error {
h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, pid)
if err != nil {
if err == windows.ERROR_INVALID_PARAMETER {
return nil
}
return err
}
defer windows.CloseHandle(h)
waitMS := uint32(timeout / time.Millisecond)
result, err := windows.WaitForSingleObject(h, waitMS)
if err != nil {
return err
}
switch result {
case windows.WAIT_OBJECT_0:
return nil
case uint32(windows.WAIT_TIMEOUT):
return fmt.Errorf("timed out after %s", timeout)
default:
return fmt.Errorf("unexpected wait result %d", result)
}
}
func runInstaller(installer, installDir string) error {
cmd := exec.Command(installer)
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(installer, installDir), HideWindow: true}
return cmd.Run()
}
func startRelaunch(relaunch, installDir string) error {
cmd := exec.Command(relaunch)
cmd.Dir = installDir
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
return cmd.Start()
}
+20 -3
View File
@@ -46,15 +46,32 @@ func writePendingCrash(site string, r any, stack []byte) {
report.Stack = sanitizeCrashText(stackText, maxCrashStackBytes)
report.TopFrame = topFrameFromStack(report.Stack)
report.Message = msg
_ = writePendingReport(report, true)
}
func writePendingReport(report crashReport, overwrite bool) bool {
body, err := json.Marshal(report)
if err != nil {
return
return false
}
path := pendingCrashPath()
if os.MkdirAll(filepath.Dir(path), 0o755) != nil {
return
return false
}
_ = os.WriteFile(path, body, 0o644)
if overwrite {
return os.WriteFile(path, body, 0o644) == nil
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return false
}
defer f.Close()
n, err := f.Write(body)
if err != nil || n != len(body) {
_ = os.Remove(path)
return false
}
return true
}
func (a *App) goSafe(site string, fn func()) {
+61
View File
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
)
@@ -67,6 +68,66 @@ func TestWritePendingCrashCaps(t *testing.T) {
}
}
func TestWritePendingReportCanAvoidOverwritingExistingCrash(t *testing.T) {
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
writePendingCrash("panic", "boom", []byte("stack"))
before, ok := readPending(t)
if !ok {
t.Fatal("expected initial pending crash")
}
hang := baseCrashReport("performance")
hang.Source = "native.watchdog"
hang.Label = "mac.main_thread.hang"
hang.Message = "hang"
if writePendingReport(hang, false) {
t.Fatal("writePendingReport overwrite=false should not replace existing report")
}
after, ok := readPending(t)
if !ok {
t.Fatal("expected pending crash after skipped write")
}
if after.Label != before.Label || after.Message != before.Message {
t.Fatalf("pending crash was overwritten: before=%+v after=%+v", before, after)
}
}
func TestWritePendingReportNonOverwriteAllowsOneConcurrentWriter(t *testing.T) {
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
const writers = 32
start := make(chan struct{})
var ready sync.WaitGroup
var done sync.WaitGroup
var successes atomic.Int32
for i := 0; i < writers; i++ {
ready.Add(1)
done.Add(1)
go func() {
defer done.Done()
report := baseCrashReport("performance")
report.Source = "native.watchdog"
report.Label = "mac.main_thread.hang"
report.Message = strings.Repeat("hang", 1024)
ready.Done()
<-start
if writePendingReport(report, false) {
successes.Add(1)
}
}()
}
ready.Wait()
close(start)
done.Wait()
if got := successes.Load(); got != 1 {
t.Fatalf("successful non-overwrite writers = %d, want 1", got)
}
if _, ok := readPending(t); !ok {
t.Fatal("expected one pending report")
}
}
func TestWritePendingCrashScrubsSensitiveText(t *testing.T) {
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
apiKey := "sk-proj-" + "abcdefghijklmnopqrstuvwxyz1234567890"
+384
View File
@@ -0,0 +1,384 @@
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"strings"
"sync"
"time"
"reasonix/internal/agent"
"reasonix/internal/control"
)
// deferredRebuildRetryInterval is how often the retry loop probes a held
// session lease. Package-level so tests can shorten it.
var deferredRebuildRetryInterval = 2 * time.Second
const deferredStartupBuildLabel = "__startup__"
// deferredRebuildState tracks tabs whose settings were saved to disk but whose
// runtime could not refresh, plus tabs whose initial startup failed, because
// the session lease was held by another Reasonix process. A single background
// loop probes the lease and replays the rebuild once the other side releases
// it. The loop only runs after enableDeferredRebuildRetry (the wails startup
// hook); tests that never call it get the pending bookkeeping without a
// background goroutine.
type deferredRebuildState struct {
mu sync.Mutex
pending map[string]string // tab ID -> setting label for notices
enabled bool
running bool
stopped bool
stop chan struct{}
}
// enableDeferredRebuildRetry arms the retry loop; called from startup.
func (a *App) enableDeferredRebuildRetry() {
d := &a.deferredRebuild
d.mu.Lock()
defer d.mu.Unlock()
d.enabled = true
a.startDeferredRebuildLoopLocked()
}
// startDeferredRebuildLoopLocked starts the loop when it is armed, idle, and
// has work. Callers must hold d.mu.
func (a *App) startDeferredRebuildLoopLocked() {
d := &a.deferredRebuild
if !d.enabled || d.running || d.stopped || len(d.pending) == 0 {
return
}
d.running = true
if d.stop == nil {
d.stop = make(chan struct{})
}
go a.deferredRebuildLoop(d.stop)
}
// scheduleDeferredRebuild records that tabID needs a runtime refresh for
// setting and starts the retry loop if it is not running yet. Repeated calls
// for the same tab collapse into one retry carrying the latest label.
func (a *App) scheduleDeferredRebuild(tabID, setting string) {
tabID = strings.TrimSpace(tabID)
if tabID == "" {
return
}
d := &a.deferredRebuild
d.mu.Lock()
defer d.mu.Unlock()
if d.stopped {
return
}
if d.pending == nil {
d.pending = map[string]string{}
}
d.pending[tabID] = setting
a.startDeferredRebuildLoopLocked()
}
func (a *App) scheduleDeferredStartupBuild(tabID string) {
a.scheduleDeferredRebuild(tabID, deferredStartupBuildLabel)
}
func isDeferredStartupBuild(setting string) bool {
return setting == deferredStartupBuildLabel
}
func (a *App) clearDeferredRebuild(tabID string) {
d := &a.deferredRebuild
d.mu.Lock()
delete(d.pending, tabID)
d.mu.Unlock()
}
func (a *App) deferredRebuildPending(tabID string) bool {
d := &a.deferredRebuild
d.mu.Lock()
defer d.mu.Unlock()
_, ok := d.pending[tabID]
return ok
}
// stopDeferredRebuildRetry permanently stops the retry loop; used on shutdown
// and by tests.
func (a *App) stopDeferredRebuildRetry() {
d := &a.deferredRebuild
d.mu.Lock()
defer d.mu.Unlock()
if d.stopped {
return
}
d.stopped = true
if d.stop != nil {
close(d.stop)
}
}
func (a *App) deferredRebuildLoop(stop <-chan struct{}) {
ticker := time.NewTicker(deferredRebuildRetryInterval)
defer ticker.Stop()
for {
select {
case <-stop:
d := &a.deferredRebuild
d.mu.Lock()
d.running = false
d.mu.Unlock()
return
case <-ticker.C:
}
if a.deferredRebuildTickDone() {
return
}
}
}
// deferredRebuildTickDone runs one retry pass and reports true when the loop
// should exit because nothing is pending anymore.
func (a *App) deferredRebuildTickDone() bool {
return a.deferredRebuildTick(true)
}
func (a *App) deferredRebuildTick(markIdle bool) bool {
d := &a.deferredRebuild
d.mu.Lock()
if d.stopped || len(d.pending) == 0 {
if markIdle {
d.running = false
}
d.mu.Unlock()
return true
}
pending := make(map[string]string, len(d.pending))
for id, setting := range d.pending {
pending[id] = setting
}
d.mu.Unlock()
for tabID, setting := range pending {
a.retryDeferredRebuild(tabID, setting)
}
return false
}
func (a *App) kickDeferredRebuildRetry() {
if a.ctx == nil {
return
}
a.goSafe("deferredRebuildKick", func() {
_ = a.deferredRebuildTick(false)
})
}
func (a *App) retryDeferredRebuild(tabID, setting string) {
if a.ctx == nil {
return
}
tab := a.tabByID(tabID)
if tab == nil || tab.ID != tabID {
// The tab is gone; nothing left to refresh.
a.clearDeferredRebuild(tabID)
return
}
if isDeferredStartupBuild(setting) {
a.retryDeferredStartupBuild(tabID, tab)
return
}
// Hold the rebuild mutex across probe + rebuild: the probe briefly acquires
// the session lease, and a concurrent manual rebuild's ensureSessionLease
// would see that probe as "held by another runtime" and spuriously defer.
a.runtimeRebuildMu.Lock()
defer a.runtimeRebuildMu.Unlock()
// rebuildSettingLocked refreshes the active tab only. Wait until the user
// is back on this tab so we refresh the runtime the pending setting was
// meant for, not whichever tab happens to be focused.
if a.activeTab() != tab {
return
}
ctrl := a.controllerForTab(tab)
if ctrl == nil {
// Mid-(re)build on another path (provider retarget, workspace repair);
// racing a second build+swap against it is what this loop must avoid.
return
}
if controllerHasActiveRuntimeWork(ctrl) {
return
}
if !a.deferredRebuildLeaseLooksFree(tab) {
return
}
err := a.rebuildSettingLocked(setting)
if err == nil {
// rebuildSettingLocked already cleared the pending entry for the tab it
// refreshed; just announce it.
a.noticeForTab(tabID, fmt.Sprintf("%s applied: session refreshed after the lease was released", setting))
return
}
if errors.Is(err, agent.ErrSessionLeaseHeld) {
return // grabbed back before we could rebuild; keep waiting
}
var busy *rebuildBusyError
if errors.As(err, &busy) {
return // a turn started meanwhile; retry once it finishes
}
// Anything else will not resolve by waiting; give up loudly instead of
// retrying forever.
a.clearDeferredRebuild(tabID)
slog.Warn("desktop: deferred settings rebuild failed", "setting", setting, "tab", tabID, "err", err)
a.warnForTab(tabID, fmt.Sprintf("%s was saved but the session could not refresh: %s", setting, err.Error()))
}
func (a *App) retryDeferredStartupBuild(tabID string, tab *WorkspaceTab) {
a.runtimeRebuildMu.Lock()
defer a.runtimeRebuildMu.Unlock()
if !a.tabHasRetryableStartupLeaseError(tab) {
a.clearDeferredRebuild(tabID)
return
}
if !a.deferredRebuildLeaseLooksFree(tab) {
return
}
err := a.rebuildStartupTabLocked(tab)
if err == nil {
a.clearDeferredRebuild(tabID)
return
}
if errors.Is(err, agent.ErrSessionLeaseHeld) {
return
}
a.clearDeferredRebuild(tabID)
slog.Warn("desktop: deferred session startup failed", "tab", tabID, "err", err)
}
func (a *App) tabHasRetryableStartupLeaseError(tab *WorkspaceTab) bool {
if tab == nil {
return false
}
a.mu.RLock()
defer a.mu.RUnlock()
return a.tabs[tab.ID] == tab && !tab.removed && tab.Ctrl == nil && tab.StartupErrLeaseHeld
}
func (a *App) rebuildStartupTabLocked(tab *WorkspaceTab) error {
buildCtx, cancel := context.WithCancel(a.bootContext())
a.mu.Lock()
if tab == nil || a.tabs[tab.ID] != tab || tab.removed {
a.mu.Unlock()
cancel()
return nil
}
if tab.Ctrl != nil {
a.mu.Unlock()
cancel()
return nil
}
if !tab.StartupErrLeaseHeld {
a.mu.Unlock()
cancel()
return nil
}
tab.buildGeneration++
generation := tab.buildGeneration
if tab.buildCancel != nil {
tab.buildCancel()
}
tab.buildCancel = cancel
tab.Ready = false
clearTabStartupError(tab)
tab.ActivityStatus = ""
if tab.sink == nil {
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
}
a.saveTabsLocked()
a.mu.Unlock()
a.buildTabControllerWithContext(tab, loadedTabSession{}, buildCtx, generation, cancel)
a.mu.RLock()
stillCurrent := false
var ctrl control.SessionAPI
startupErr := ""
leaseHeld := false
if tab != nil {
stillCurrent = a.tabs[tab.ID] == tab && !tab.removed
ctrl = tab.Ctrl
startupErr = tab.StartupErr
leaseHeld = tab.StartupErrLeaseHeld
}
a.mu.RUnlock()
if !stillCurrent || ctrl != nil {
return nil
}
if leaseHeld {
return agent.ErrSessionLeaseHeld
}
if strings.TrimSpace(startupErr) != "" {
return fmt.Errorf("session startup: %s", startupErr)
}
return fmt.Errorf("session startup: controller was not built")
}
func (a *App) tryRecoverStartupLeaseHeldTab(tab *WorkspaceTab) bool {
if a.ctx == nil || !a.tabHasRetryableStartupLeaseError(tab) {
return false
}
a.runtimeRebuildMu.Lock()
defer a.runtimeRebuildMu.Unlock()
if !a.tabHasRetryableStartupLeaseError(tab) {
return a.controllerForTab(tab) != nil
}
if !a.deferredRebuildLeaseLooksFree(tab) {
return false
}
err := a.rebuildStartupTabLocked(tab)
if err == nil {
a.clearDeferredRebuild(tab.ID)
return a.controllerForTab(tab) != nil
}
if errors.Is(err, agent.ErrSessionLeaseHeld) {
a.scheduleDeferredStartupBuild(tab.ID)
} else {
a.clearDeferredRebuild(tab.ID)
}
return false
}
// deferredRebuildLeaseLooksFree cheaply probes whether the tab's session lease
// could be acquired right now, without touching tab.sessionLease (only the
// serialized rebuild paths may mutate that). The probe path can lag the
// reconciled path the rebuild will use; a stale answer either re-defers on the
// next tick or lets the rebuild fail back into the pending set, so a mismatch
// only delays the retry.
func (a *App) deferredRebuildLeaseLooksFree(tab *WorkspaceTab) bool {
a.mu.RLock()
ctrl := tab.Ctrl
path := strings.TrimSpace(tab.SessionPath)
a.mu.RUnlock()
if ctrl != nil {
if p := strings.TrimSpace(ctrl.SessionPath()); p != "" {
path = p
}
}
if path == "" {
return true // nothing to probe; let the rebuild decide
}
lease, err := agent.TryAcquireSessionLease(sessionRuntimeKey(path))
if err != nil {
var leaseErr *agent.SessionLeaseError
if errors.As(err, &leaseErr) && leaseErr.Info != nil && leaseErr.Info.PID == os.Getpid() {
if host, _ := os.Hostname(); leaseErr.Info.Hostname == host {
// This process (usually this very tab) holds the probed path;
// the blocking lease is some other path. Attempt the rebuild
// and let its own lease checks decide.
return true
}
}
return !errors.Is(err, agent.ErrSessionLeaseHeld)
}
lease.Release()
return true
}
+2 -2
View File
@@ -10,9 +10,9 @@
"check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css",
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
"typecheck": "tsc --noEmit",
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-run-strip.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.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__/history-recovery-copies.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-run-strip.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.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__/history-recovery-copies.test.tsx && 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",
+136 -55
View File
@@ -35,16 +35,17 @@ 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, onSessionRecovered, onSessionRecoveryFailed } from "./lib/bridge";
import { localizedBackendNoticeText, useController, type Item, type LiveStream } from "./lib/useController";
import { app, onEvent, onProjectTreeChanged, onReady, onRuntimeRebuilt, onSessionRecovered } from "./lib/bridge";
import { generativeMusic, isGenerativeMusicEnabled } from "./lib/generative-music";
import { playSuccessChime } from "./lib/sound";
import { clearAttentionChimeKeys, playAttentionChime, playSuccessChime, shouldPlayAttentionChimeForEvent } from "./lib/sound";
import { Transcript } from "./components/Transcript";
import { Composer } from "./components/Composer";
import { TodoPanel } from "./components/TodoPanel";
import { ApprovalModal } from "./components/ApprovalModal";
import { ApprovalModal, approvalToolLabel } from "./components/ApprovalModal";
import { AskCard } from "./components/AskCard";
import { UndoRewindBanner } from "./components/UndoRewindBanner";
import { ClearContextCard } from "./components/ClearContextCard";
@@ -160,6 +161,83 @@ import { topicShortcutIndexFromEvent, useTopicShortcuts, type TopicShortcutEntry
import { composerDraftKeyForTab } from "./lib/composerDraftKey";
import logoWordmark from "./assets/logo-wordmark.svg";
function noticePreviewMockEnabled(): boolean {
const value = browserMockScenarioParam();
return value === "notice" || value === "notices" || value === "notice-preview";
}
function noticePreviewItems(): Item[] {
const notice = (index: number, level: "info" | "warn", text: string, detail: string): Item => ({
kind: "notice",
id: `notice-preview-${index}`,
level,
text: localizedBackendNoticeText(text),
detail,
});
return [
notice(0, "info", "Task status needs one more check; asking the assistant to finish or explain what is blocking it.", "final-answer readiness blocked: latest successful todo_write still has incomplete items: UI spinner in_progress, settings persistence pending"),
notice(1, "info", "No visible answer was produced; asking the assistant to respond again.", "empty final answer blocked: qwen3.7-plus returned no visible answer text (finish=stop, reasoning=2314 chars); retrying"),
notice(2, "info", "The assistant answered before taking action; asking it to use the required tools.", "executor handoff: assistant produced a proposal before running required repository commands; nudged to execute"),
notice(3, "info", "Tool round limit reached; asking the assistant to summarize progress.", "tool budget reached after 128 tool calls; requesting a progress summary before continuing"),
notice(4, "info", "The assistant is stuck retrying a blocked action; asking it to change approach.", "loop guard: repeated command failure matched the same stderr signature across 3 attempts"),
notice(5, "info", "Context is getting large; preserving cache until cleanup is needed.", "context window 82% full; deferred cleanup to preserve reusable prompt cache"),
notice(6, "info", "Context cleanup skipped for now.", "cleanup skipped: recent turn included unresolved user approval state"),
notice(7, "info", "Automatic context cleanup paused because the context window is too small.", "configured compact threshold exceeds current model context window; auto cleanup paused for this model"),
notice(8, "info", "Context was compacted without a generated summary.", "compaction completed after upstream summary generation returned empty content; retained transcript checkpoint"),
notice(9, "info", "Planning mode enabled for this multi-step task.", "auto plan detector matched implementation task with repository edits and verification steps"),
notice(10, "info", "Plan detection requested a plan.", "plan detector confidence=0.83; task includes multiple dependent UI/backend changes"),
notice(11, "info", "Plan detection was uncertain; using the fallback planner heuristic.", "plan detector confidence=0.48; fallback heuristic selected because files and tests are involved"),
notice(12, "info", "Goal is not ready to complete yet; continuing the remaining work.", "goal completion check found pending validation: desktop/frontend typecheck"),
notice(13, "info", "Goal still has unfinished task state; continuing the remaining work.", "active goal has open task state: implement preview, verify browser, report result"),
notice(14, "warn", "AutoResearch status update failed.", "autoresearch task completion update failed: write .reasonix/autoresearch/task-42/state/task_spec.json: permission denied"),
notice(15, "warn", "AutoResearch task marked blocked.", "autoresearch task blocked: task-42\nreason: missing accepted verification evidence after three turns"),
notice(16, "warn", "background export failed: needs attention", "background export failed: session archive upload returned 503 after 3 retries"),
notice(17, "warn", "Job artifact migration failed.", "artifact migration failed for job job_123: checksum mismatch while moving output.zip"),
notice(18, "warn", "Background job teardown timed out.", "job job_123 did not stop within 10s; process is still marked running by the supervisor"),
notice(19, "warn", "Some plan-mode tool settings were ignored.", "plan-mode tool settings ignored: unsupported tool allowlist entry \"browser.screenshot\""),
notice(20, "warn", "Some plan-mode command settings were ignored.", "plan-mode command settings ignored: invalid read-only prefix \"npm && test\""),
notice(21, "warn", "Config migration did not complete.", "config migration failed at providers.defaultModel: unknown provider reference \"old/deepseek\""),
notice(22, "warn", "Selected model is missing its API key.", "selected model deepseek/deepseek-v4-pro requires DEEPSEEK_API_KEY, but no key is configured"),
notice(23, "warn", "An MCP server failed to start.", "mcp server \"github\" failed to start: command not found: mcp-server-github"),
notice(24, "warn", "Some MCP servers failed to start; run /mcp for details.", "mcp startup failures: github(command not found), linear(authentication expired)"),
notice(25, "warn", "Guardian was disabled because its model was not found.", "guardian model \"glm-5-guard\" is not present in the configured provider catalog"),
notice(26, "warn", "Guardian was disabled because it could not start.", "guardian startup failed: provider returned 401 unauthorized"),
];
}
function NoticePreviewPanel({ detailsLabel }: { detailsLabel: string }) {
return (
<div
style={{
flex: "1 1 auto",
minHeight: 0,
overflow: "auto",
padding: "44px 24px 128px",
}}
>
<div style={{ maxWidth: 920, margin: "0 auto" }}>
{noticePreviewItems().map((item) => {
if (item.kind !== "notice") return null;
return (
<div key={item.id} className={`notice-line notice-line--${item.level}`} data-entrance="true">
<span className="notice-line__icon">{item.level === "warn" ? "⚠ " : "ℹ "}</span>
<div className="notice-line__text">
{item.text}
{item.detail ? (
<details className="notice-line__details">
<summary>{detailsLabel}</summary>
<div>{item.detail}</div>
</details>
) : null}
</div>
</div>
);
})}
</div>
</div>
);
}
const HistoryPanel = lazy(() => import("./components/HistoryPanel").then((module) => ({ default: module.HistoryPanel })));
const SettingsPanel = lazy(() => import("./components/SettingsPanel").then((module) => ({ default: module.SettingsPanel })));
@@ -853,6 +931,9 @@ function sessionItemsToMarkdown(title: string, items: Item[], live?: LiveStream)
break;
case "notice":
lines.push(`### ${item.level === "warn" ? "Warning" : "Notice"}`, "", item.text.trim(), "");
if (item.detail?.trim()) {
lines.push("Details:", "", item.detail.trim(), "");
}
break;
case "compaction":
lines.push("### Context Compaction", "");
@@ -1009,6 +1090,7 @@ export default function App() {
const setRightDockPreviewWidth = useLayoutStore((s) => s.setRightDockPreviewWidth);
const workspacePreviewActive = useLayoutStore((s) => s.workspacePreviewActive);
const setWorkspacePreviewActive = useLayoutStore((s) => s.setWorkspacePreviewActive);
const attentionChimeEvents = useRef(new Set<string>());
// Bump dockRefreshKey after each turn so WorkspacePanel/ContextPanel re-fetch
// workspace changes, git history, and session metadata after AI tool writes.
useEffect(() => {
@@ -1016,11 +1098,31 @@ export default function App() {
if (e.kind === "turn_done") {
setDockRefreshKey((v) => v + 1);
}
if (shouldPlayAttentionChimeForEvent(e, attentionChimeEvents.current)) {
playAttentionChime();
}
if (e.kind === "turn_done") {
if (!e.err) playSuccessChime();
}
});
return unsub;
// Runtime rebuilds (model/effort/settings switch) replace the controller,
// whose approval/ask ids restart from "1" — stale dedupe keys would mute
// the first prompt after a rebuild. agent:ready fires when a (re)build
// completes; clear that tab's keys (or all, for tab-less ready events).
const unsubReady = onReady((readyTabId) => {
clearAttentionChimeKeys(attentionChimeEvents.current, readyTabId);
});
// Model/effort/token-mode switches and clear-while-running replace the
// controller WITHOUT an agent:ready — they signal runtime:rebuilt instead
// (a ready here would trigger a full session reload the UI already did).
const unsubRebuilt = onRuntimeRebuilt((rebuiltTabId) => {
clearAttentionChimeKeys(attentionChimeEvents.current, rebuiltTabId);
});
return () => {
unsub();
unsubReady();
unsubRebuilt();
};
}, []);
const [workspacePanelResizing, setWorkspacePanelResizing] = useState(false);
@@ -1041,6 +1143,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);
@@ -2264,7 +2367,6 @@ export default function App() {
if (!rewindState) return transcriptItems;
return transcriptItems.slice(0, rewindState.boundaryIdx).filter((it) => it.kind !== "compaction");
}, [transcriptItems, rewindState]);
const latestGuidanceConsumed = useMemo(() => {
for (let i = state.items.length - 1; i >= 0; i--) {
const item = state.items[i];
@@ -2277,8 +2379,8 @@ export default function App() {
// send wrapper: commits any pending optimistic rewind before sending.
const commitThenSend = useCallback(async (displayText: string, submitText?: string) => {
if (activeTab?.readOnly) throw new Error("channel session is read-only");
if (!controllerReady) throw new Error("workspace is still starting");
if (activeTab?.readOnly) throw new Error(t("composer.readOnlyChannel"));
if (!controllerReady) throw new Error(t("composer.workspaceStarting"));
const rs = rewindStateRef.current;
if (rs) {
rewindStateRef.current = null;
@@ -2294,7 +2396,7 @@ export default function App() {
// Rewind failed: the Go conversation is intact. Do not send; the
// controller emits a notice with the reason.
setRewindState(null);
throw new Error("rewind failed");
throw new Error(t("rewind.failed"));
}
setRewindSignal((v) => v + 1);
if (rs.scope === "both") {
@@ -2304,7 +2406,7 @@ export default function App() {
}
}
await send(displayText, submitText);
}, [activeTab?.readOnly, controllerReady, send, rewind]);
}, [activeTab?.readOnly, controllerReady, send, rewind, t]);
const handleTranscriptPrompt = useCallback((text: string) => {
if (!controllerReady) return;
@@ -2546,7 +2648,7 @@ export default function App() {
} else {
throw new Error(scope === "global" && !session.topicId
? t("history.failedOpenSession")
: (session.topicId ? "Missing workspaceRoot" : t("history.failedOpenSession")));
: (session.topicId ? t("history.missingWorkspaceRoot") : t("history.failedOpenSession")));
}
if (!latest()) return;
seedActiveTabMeta(targetTab);
@@ -2591,36 +2693,10 @@ 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]);
useEffect(() => onSessionRecovered(() => {
setProjectRevision((value) => value + 1);
void refreshTabMetas();
}), [refreshTabMetas]);
const handleNewTab = useCallback(async () => {
closeTransientOverlays();
@@ -2745,6 +2821,19 @@ export default function App() {
},
[state.running, deleteSession, refreshHistoryView],
);
const onDeleteManySessions = useCallback(
async (paths: string[]) => {
if (state.running) return;
const uniquePaths = Array.from(new Set(paths));
for (const path of uniquePaths) {
// Best effort per path: one locked/missing file must not abandon the
// rest of the sweep. The final refresh reconciles whatever happened.
await deleteSession(path).catch(() => undefined);
}
await refreshHistoryView();
},
[state.running, deleteSession, refreshHistoryView],
);
const onRenameSession = useCallback(
async (path: string, title: string) => {
if (state.running) return;
@@ -2882,13 +2971,6 @@ 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>) => {
@@ -3440,13 +3522,6 @@ 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">
@@ -3458,6 +3533,8 @@ export default function App() {
onManageAllowlist={() => openBotAllowlistSettings(sidebarImDetailConnection.connectionId)}
onOpenSession={() => void openSidebarImConnectionSession(sidebarImDetailConnection)}
/>
) : noticePreviewMockEnabled() ? (
<NoticePreviewPanel detailsLabel={t("notice.details")} />
) : (
<Transcript
items={displayItems}
@@ -3533,6 +3610,7 @@ export default function App() {
onStop={() => {
cancel();
}}
toolApprovalMode={toolApprovalMode}
/>
)}
{state.ask && (
@@ -3584,6 +3662,8 @@ export default function App() {
turnStartAt={state.turnStartAt}
turnTokens={state.turnTokens}
retry={state.retry}
pendingApprovalLabel={state.approval ? approvalToolLabel(state.approval.tool, t) : null}
pendingAsk={state.ask != null}
transientDismissSignal={transientOverlayDismissSignal}
sessionKey={composerSessionKey}
fileRefRefreshKey={composerFileRefRefreshKey}
@@ -3729,6 +3809,7 @@ export default function App() {
onRestore={onRestoreTrashedSession}
onPurge={onPurgeTrashedSession}
onPurgeAll={onPurgeAllTrashedSessions}
onDeleteMany={onDeleteManySessions}
onClose={closeHistory}
/>
</Suspense>
@@ -54,7 +54,7 @@ async function waitFor(label: string, predicate: () => boolean, timeoutMs = 1000
ok(false, label);
}
function installDom() {
function installDom(language = "en-US") {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
@@ -62,6 +62,7 @@ function installDom() {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: language });
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
@@ -129,7 +130,7 @@ async function renderApproval(props: Partial<Parameters<typeof ApprovalModal>[0]
console.log("\napproval modal file references");
{
const dom = installDom();
const dom = installDom("en-US");
mockApp({
ListDir: async () => [{ name: "src", isDir: true }, { name: "README.md", isDir: false }],
SearchFileRefs: async () => [],
@@ -178,6 +179,86 @@ console.log("\napproval modal file references");
dom.window.close();
}
{
const dom = installDom("zh-CN");
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const { root } = await renderApproval({
approval: {
id: "sandbox-escape-approval-zh",
tool: "sandbox_escape",
subject: "run unconfined once: go test ./...",
reason: "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
},
});
const text = document.body.textContent ?? "";
ok(text.includes("仅本次不进沙箱运行:go test ./..."), "sandbox escape approval localizes subject in Chinese UI");
ok(text.includes("Windows 沙箱启动这条命令时失败"), "sandbox escape approval localizes English backend reason in Chinese UI");
ok(text.includes("允许一次"), "sandbox escape Chinese approval shows allow once");
ok(text.includes("本会话使用真实环境"), "sandbox escape Chinese approval shows session grant");
ok(text.includes("拒绝"), "sandbox escape Chinese approval shows deny");
ok(!text.includes("总是允许"), "sandbox escape Chinese approval hides persistent grant");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom("zh-CN");
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const { root } = await renderApproval({
approval: {
id: "memory-approval-zh",
tool: "remember",
subject: "Save/update memory \"prefers-vitest\" [user]: Preferred test framework | body: Use Vitest for frontend tests.",
},
});
const text = document.body.textContent ?? "";
ok(text.includes("保存记忆"), "remember approval localizes tool label in Chinese UI");
ok(text.includes("保存/更新记忆 \"prefers-vitest\" [user]"), "remember approval localizes subject prefix in Chinese UI");
ok(text.includes("正文: Use Vitest for frontend tests."), "remember approval localizes body label in Chinese UI");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom("zh-CN");
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const { root } = await renderApproval({
approval: {
id: "plan-mode-read-only-command-zh",
tool: "plan_mode_read_only_command",
subject: "Trust \"gh issue view\" as a read-only command prefix while planning\nCommand: gh issue view 5867 --json title",
reason: "This bash command is not in Reasonix's built-in read-only set. Confirm only if this exact prefix is read-only for planning and research. Auto/YOLO approval cannot answer this trust prompt.",
},
});
const text = document.body.textContent ?? "";
ok(text.includes("计划模式只读命令"), "plan-mode read-only command approval localizes tool label in Chinese UI");
ok(text.includes("在计划模式中信任 \"gh issue view\" 为只读命令前缀"), "plan-mode read-only command approval localizes subject in Chinese UI");
ok(text.includes("不在 Reasonix 内置只读集合中"), "plan-mode read-only command approval localizes reason in Chinese UI");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
mockApp({
@@ -231,6 +312,25 @@ console.log("\napproval modal file references");
);
ok(document.body.textContent?.includes("Hide") === true, "tool approval details start expanded");
// Consequence preview row: follows the keyboard-selected action by default,
// arrow navigation, and hover; each pill carries a native title fallback.
const consequence = () => document.querySelector(".approval-consequence__text")?.textContent ?? null;
eq(consequence(), "Allow this call only; the next one asks again.", "consequence row previews the selected action by default");
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
await flushTimers();
});
eq(consequence(), "Allow matching calls until this session ends; resets on restart.", "arrow navigation moves the consequence preview");
const pills = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[];
eq(pills[2]?.getAttribute("title"), "Save a persistent allow rule; future sessions stop asking too.", "action pills carry a native title fallback");
await act(async () => {
pills[3].dispatchEvent(new window.MouseEvent("mouseover", { bubbles: true }));
await flushTimers();
});
eq(consequence(), "Reject this call; the model sees the refusal and continues.", "hover overrides the consequence preview");
await act(async () => {
root.unmount();
});
@@ -260,8 +360,8 @@ console.log("\napproval modal file references");
ok(!text.includes("Always allow"), "fresh-human approval hides persistent grant");
eq(
Array.from(document.querySelectorAll(".prompt-shelf__actions button")).map((button) => button.textContent).join("|"),
"1Allow once|4Deny",
"fresh-human approval keeps allow/deny shortcut keys",
"1Allow once|2Deny",
"fresh-human approval keeps conventional allow/deny shortcut keys",
);
const allowButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("Allow once")) as HTMLButtonElement | undefined;
@@ -280,5 +380,108 @@ console.log("\napproval modal file references");
dom.window.close();
}
{
const dom = installDom();
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const answers: Array<[boolean, boolean, boolean]> = [];
const { root } = await renderApproval({
approval: {
id: "memory-approval-deny",
tool: "remember",
subject: "Save/update memory \"prefers-vitest\": Preferred test framework",
},
onAnswer: (allow, session, persist) => answers.push([allow, session, persist]),
});
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "2", bubbles: true, cancelable: true }));
await flushTimers();
});
eq(JSON.stringify(answers), JSON.stringify([[false, false, false]]), "fresh-human numeric 2 denies");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const answers: Array<{ allow: boolean; session: boolean; persist: boolean }> = [];
const { root } = await renderApproval({
approval: {
id: "sandbox-escape-approval",
tool: "sandbox_escape",
subject: "run unconfined once: go test ./...",
reason: "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
},
onAnswer: (allow, session, persist) => answers.push({ allow, session, persist }),
});
const text = document.body.textContent ?? "";
ok(text.includes("bash sandbox escape"), "sandbox escape approval uses a clear tool label");
ok(text.includes("Allow once"), "sandbox escape approval shows allow once");
ok(text.includes("Use real environment for this session"), "sandbox escape approval shows session grant");
ok(text.includes("Deny"), "sandbox escape approval shows deny");
ok(!text.includes("Always allow"), "sandbox escape approval hides persistent grant");
eq(
Array.from(document.querySelectorAll(".prompt-shelf__actions button")).map((button) => button.textContent).join("|"),
"1Allow once|2Use real environment for this session|3Deny",
"sandbox escape approval keeps conventional allow once/session/deny shortcut keys",
);
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }));
await flushTimers();
});
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
await flushTimers();
});
eq(JSON.stringify(answers), JSON.stringify([{ allow: true, session: true, persist: false }]), "sandbox escape Enter on selected session action grants session");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const answers: Array<{ allow: boolean; session: boolean; persist: boolean }> = [];
const { root } = await renderApproval({
approval: {
id: "sandbox-escape-deny-approval",
tool: "sandbox_escape",
subject: "run unconfined once: go test ./...",
reason: "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
},
onAnswer: (allow, session, persist) => answers.push({ allow, session, persist }),
});
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "3", bubbles: true, cancelable: true }));
await flushTimers();
});
eq(JSON.stringify(answers), JSON.stringify([{ allow: false, session: false, persist: false }]), "sandbox escape numeric 3 denies");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -111,6 +111,29 @@ console.log("\nask card layout");
eq(computed.overflowWrap, "anywhere", "long unspaced ask questions can break within the shelf");
ok(card.getAttribute("role") === "dialog", "ask prompt shelf keeps dialog semantics");
// Detail preview row: shows the first described option by default, follows
// hover, and each option carries a native-title fallback for truncation.
const detail = document.querySelector(".ask-shelf__detail") as HTMLElement | null;
if (!detail) throw new Error("ask detail preview row did not render");
eq(
detail.querySelector(".ask-shelf__detail-text")?.textContent,
ask.questions[0].options[0].description,
"detail row previews the first described option by default",
);
const optionButtons = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[];
eq(optionButtons[1]?.getAttribute("title"), ask.questions[0].options[1].description, "options carry a native title fallback");
await act(async () => {
optionButtons[1].dispatchEvent(new window.MouseEvent("mouseover", { bubbles: true }));
await flushTimers();
});
eq(
document.querySelector(".ask-shelf__detail-text")?.textContent,
ask.questions[0].options[1].description,
"detail row follows the hovered option",
);
await act(async () => {
root.unmount();
});
@@ -0,0 +1,3 @@
// Stand-in for bundler-handled asset imports (.svg) in tsx-run tests; see the
// resolve hook in history-recovery-copies.test.tsx.
export default "";
@@ -0,0 +1,85 @@
// Run: tsx src/__tests__/bridge-breadcrumbs.test.ts
import { snapshotBreadcrumbs } from "../lib/breadcrumbs";
import { app } from "../lib/bridge";
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 ensureWindow() {
if (typeof window === "undefined") {
(globalThis as Record<string, unknown>).window = {} as Window & typeof globalThis;
}
}
function newBreadcrumbMessages(since: number, cat: string): string[] {
return snapshotBreadcrumbs()
.slice(since)
.filter((crumb) => crumb.cat === cat)
.map((crumb) => crumb.msg);
}
console.log("\nbridge breadcrumbs");
const previousPerformance = (globalThis as { performance?: Performance }).performance;
let now = 0;
(globalThis as Record<string, unknown>).performance = { now: () => now };
ensureWindow();
const previousGo = window.go;
window.go = {
main: {
App: {
async IsMainWindowMaximised() {
now = 42;
return true;
},
async CheckUpdate() {
now = 125;
throw new Error("offline");
},
} as never,
},
};
const successStart = snapshotBreadcrumbs().length;
now = 10;
await app.IsMainWindowMaximised();
const successBridge = newBreadcrumbMessages(successStart, "bridge");
ok(
successBridge.includes("window IsMainWindowMaximised") &&
successBridge.includes("window IsMainWindowMaximised done ms=32"),
"records bridge start and completion timing breadcrumbs",
);
const errorStart = snapshotBreadcrumbs().length;
now = 100;
try {
await app.CheckUpdate();
} catch {
// Expected: this branch verifies the breadcrumb emitted by the proxy rejection path.
}
const errorBridge = newBreadcrumbMessages(errorStart, "bridge");
const bridgeErrors = newBreadcrumbMessages(errorStart, "bridge.error");
ok(errorBridge.includes("update CheckUpdate"), "records bridge start breadcrumb before failed calls");
ok(bridgeErrors.includes("CheckUpdate ms=25"), "records bridge failure timing breadcrumbs");
window.go = previousGo;
if (previousPerformance) {
(globalThis as Record<string, unknown>).performance = previousPerformance;
} else {
delete (globalThis as { performance?: Performance }).performance;
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -257,7 +257,7 @@ console.log("\ncomposer goal toggle");
turnStartAt: Date.now(),
});
const stopButton = document.querySelector(".composer-runstatus__stop") as HTMLButtonElement | null;
const stopButton = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null;
if (!stopButton) throw new Error("composer stop button did not render");
await act(async () => {
@@ -643,7 +643,7 @@ console.log("\ncomposer goal toggle");
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("running composer send button did not render");
eq(textarea.placeholder, "Add guidance to the queue...", "running composer explains queued guidance input");
eq(textarea.placeholder, "Running — type guidance, Enter adds it to the queue", "running composer explains queued guidance input");
ok(sendButton.classList.contains("composer__btn--steer"), "running composer marks send button as steer");
ok(sendButton.disabled === true, "running steer button stays disabled without input");
@@ -0,0 +1,327 @@
// Run: tsx src/__tests__/composer-run-strip.test.tsx
//
// The run state lives inside the composer card (no floating pill, no layout
// jump), stop has a fixed home next to send, and a pending approval/ask shifts
// the strip into a waiting state instead of a ticking "working" spinner.
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { Composer } from "../components/Composer";
import { LocaleProvider } from "../lib/i18n";
import { ToastProvider } from "../lib/toast";
import type { CollaborationMode, ToolApprovalMode, TokenMode } from "../lib/types";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) ok(true, label);
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
function flushTimers(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
function installDom() {
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.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.InputEvent = dom.window.InputEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
globalThis.MutationObserver = dom.window.MutationObserver;
globalThis.File = dom.window.File;
globalThis.FileReader = dom.window.FileReader;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
globalThis.ResizeObserver = TestResizeObserver;
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: () => ({
matches: true,
media: "(prefers-reduced-motion: reduce)",
onchange: null,
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
dispatchEvent: () => false,
}),
});
return dom;
}
async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}) {
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
const calls = { cancel: 0 };
let currentProps: Parameters<typeof Composer>[0] = {
running: false,
collaborationMode: "normal" as CollaborationMode,
toolApprovalMode: "ask" as ToolApprovalMode,
tokenMode: "full" as TokenMode,
goal: "",
cwd: "/repo",
modelLabel: "DeepSeek-R1",
onSend: () => {},
onCancel: () => {
calls.cancel += 1;
return undefined;
},
onCycleMode: () => {},
onSetMode: () => {},
onSetCollaborationMode: () => {},
onSetToolApprovalMode: () => {},
onToggleYoloApprovalMode: () => {},
onClearGoal: () => {},
onSwitchModel: () => {},
onSetEffort: () => {},
onSetTokenMode: () => {},
ready: true,
...props,
};
const paint = async (nextProps: Partial<Parameters<typeof Composer>[0]> = {}) => {
currentProps = { ...currentProps, ...nextProps };
await act(async () => {
root.render(
<LocaleProvider>
<ToastProvider>
<Composer {...currentProps} />
</ToastProvider>
</LocaleProvider>,
);
await flushTimers();
});
};
await paint();
return { root, calls, rerender: paint };
}
console.log("\ncomposer run strip");
// Idle: no strip, no stop button, plain send arrow.
{
const dom = installDom();
const { root } = await renderComposer();
eq(document.querySelector(".composer-run-strip"), null, "idle composer renders no run strip");
eq(document.querySelector(".composer__btn--stop"), null, "idle composer renders no stop button");
ok(document.querySelector(".composer__btn--send") !== null, "idle composer keeps the send button");
eq(document.querySelector(".composer-toolbar--status-only"), null, "floating status pill is gone");
await act(async () => {
root.unmount();
});
dom.window.close();
}
// Running: strip lives inside the card, ticker is aria-hidden, stop cancels.
{
const dom = installDom();
const { root, calls } = await renderComposer({ running: true, turnStartAt: Date.now() });
const strip = document.querySelector(".composer-card .composer-run-strip");
ok(strip !== null, "running strip renders inside the composer card");
const ticker = strip?.querySelector(".composer-run-strip__text");
eq(ticker?.getAttribute("aria-hidden"), "true", "ticking spinner text stays out of the accessibility tree");
const live = strip?.querySelector(".sr-only[role=\"status\"]");
eq(live?.textContent, "Reasonix is working", "live region announces the stable state text only");
ok(document.querySelector(".composer-card--running") !== null, "running card keeps its running modifier");
const stop = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null;
if (!stop) throw new Error("running composer stop button did not render");
await act(async () => {
stop.click();
await flushTimers();
});
eq(calls.cancel, 1, "stop button next to send cancels the turn");
await act(async () => {
root.unmount();
});
dom.window.close();
}
// Waiting on approval: strip switches to the waiting state and stops ticking.
{
const dom = installDom();
const { root, rerender } = await renderComposer({ running: true, turnStartAt: Date.now() });
await rerender({ pendingApprovalLabel: "Run command", disabled: true });
const strip = document.querySelector(".composer-run-strip");
ok(strip?.classList.contains("composer-run-strip--waiting") === true, "pending approval shifts the strip into waiting");
const text = strip?.querySelector(".composer-run-strip__text");
eq(text?.textContent, "Waiting for your approval — Run command", "waiting strip names the tool awaiting approval");
eq(text?.getAttribute("aria-hidden"), null, "waiting text is static and stays accessible");
eq(document.querySelector(".composer-card--running"), null, "waiting card hands the running accent off to the prompt card");
ok(document.querySelector(".composer-card--waiting") !== null, "waiting card takes the waiting modifier");
const modeButtons = [...document.querySelectorAll(".composer-modebar--approval .composer-modebar__item")] as HTMLButtonElement[];
ok(modeButtons.length === 3 && modeButtons.every((b) => !b.disabled), "approval bar stays usable while its own prompt disables the composer");
await rerender({ pendingApprovalLabel: null, pendingAsk: true });
eq(
document.querySelector(".composer-run-strip__text")?.textContent,
"Waiting for your answer",
"pending ask question shows the ask waiting state",
);
ok(
modeButtons.every((b) => b.disabled),
"approval bar stays disabled for non-approval reasons",
);
await rerender({ pendingAsk: false, disabled: false });
ok(
document.querySelector(".composer-run-strip__text")?.getAttribute("aria-hidden") === "true",
"resolving the prompt returns the strip to the ticking spinner",
);
await act(async () => {
root.unmount();
});
dom.window.close();
}
// Cancel restores queued guidance: stop means "stop acting", never "discard
// what I typed".
{
const dom = installDom();
const { root, calls } = await renderComposer({
running: true,
turnStartAt: Date.now(),
guidanceQueuePreviewItems: ["数到一半改用英文", "最后给出一句总结"],
});
ok(document.querySelector(".composer-guidance-shelf") !== null, "queued guidance renders in the shelf");
const stop = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null;
if (!stop) throw new Error("stop button did not render");
await act(async () => {
stop.click();
await flushTimers();
});
eq(calls.cancel, 1, "stop cancels the running turn");
const ta = document.querySelector("textarea") as HTMLTextAreaElement;
eq(ta.value, "数到一半改用英文\n最后给出一句总结", "stop folds unconsumed queued guidance back into the draft");
eq(document.querySelector(".composer-guidance-shelf"), null, "restored queue clears the shelf");
await act(async () => {
root.unmount();
});
dom.window.close();
}
// Waiting on the user pauses the ticker clock: elapsed time means model time.
{
const dom = installDom();
const start = Date.now() - 30000;
const { root, rerender } = await renderComposer({ running: true, turnStartAt: start });
await rerender({ pendingApprovalLabel: "Run command", disabled: true });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 2400));
});
await rerender({ pendingApprovalLabel: null, disabled: false });
const ticker = document.querySelector(".composer-run-strip__text")?.textContent ?? "";
ok(/ 30s| 31s/.test(ticker), `ticker excludes the time spent waiting for approval (got "${ticker}")`);
ok(!/ 32s| 33s/.test(ticker), "ticker does not count the ~2.4s approval wait as model time");
await act(async () => {
root.unmount();
});
dom.window.close();
}
// Resize consistency: --composer-height always carries the logical height in
// every writer (React render, live drag, keyboard), with the run strip's
// reservation isolated in a CSS calc — so dragging a resized composer during a
// running turn cannot flash-shrink the card.
{
const stylesSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../styles.css"), "utf8");
ok(
stylesSource.includes("calc(var(--composer-height) + var(--composer-run-strip-reserved, 0px))"),
"resized card height combines logical height and strip reservation in CSS",
);
const dom = installDom();
const { root, rerender } = await renderComposer({ running: true, turnStartAt: Date.now() });
const handle = document.querySelector(".composer-resize-handle") as HTMLButtonElement;
await act(async () => {
handle.focus();
handle.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Home", bubbles: true }));
await flushTimers();
});
const card = document.querySelector(".composer-card") as HTMLElement;
eq(card.style.getPropertyValue("--composer-height"), "104px", "render path writes the logical height, not a compensated one");
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "30px", "running card reserves the strip height via its own variable");
// Drag while running: the live writer stays in logical-height space.
await act(async () => {
handle.dispatchEvent(new window.MouseEvent("pointerdown", { bubbles: true, clientY: 300 }));
await flushTimers();
});
eq(card.style.getPropertyValue("--composer-height"), "104px", "drag start does not flash-shrink the running card");
await act(async () => {
document.dispatchEvent(new window.MouseEvent("pointermove", { bubbles: true, clientY: 280 }));
document.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, clientY: 280 }));
await flushTimers();
});
eq(card.style.getPropertyValue("--composer-height"), "124px", "drag release keeps the same logical-height space as the render path");
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "30px", "strip reservation survives the drag");
eq(handle.getAttribute("aria-valuenow"), "124", "separator reports the logical height");
await rerender({ running: false, turnStartAt: undefined });
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "0px", "idle card releases the strip reservation");
eq(card.style.getPropertyValue("--composer-height"), "124px", "idle card keeps the user's logical height");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -1,6 +1,6 @@
// Run: tsx src/__tests__/context-panel-breakdown.test.ts
import { cacheHitTone, contextBreakdown, contextCostDisplay, contextSourceRows, contextUsageRefreshKey, contextWindowStatus, formatCacheHitRate, formatMetricTokens } from "../components/ContextPanel";
import { cacheHitTone, contextBreakdown, contextCostDisplay, contextSessionCache, contextSourceRows, contextUsageRefreshKey, contextWindowStatus, formatCacheHitRate, formatMetricTokens } from "../components/ContextPanel";
import { currencySymbol, formatMoney, formatMoneyLocalized } from "../lib/money";
let passed = 0;
@@ -109,6 +109,59 @@ const infoCost = contextCostDisplay({
usage: { cost: 0, costUsd: 0, currency: "¥" },
});
eq(infoCost, { amount: 0.1759, currency: "$" }, "panel cost keeps the panel currency instead of state default");
const singleRequestOnly = contextCostDisplay({
info: { sessionCost: 0, sessionCurrency: "", sessionCostUsd: 0 },
sessionCost: 0,
sessionCurrency: "¥",
usage: { cost: 0.42, costUsd: 0.42, currency: "¥" },
});
eq(
singleRequestOnly,
{ amount: 0, currency: "¥" },
"a single request's cost never renders under the session-cost label",
);
const localAccumulated = contextCostDisplay({
info: { sessionCost: 0, sessionCurrency: "", sessionCostUsd: 0 },
sessionCost: 1.5,
sessionCurrency: "¥",
usage: { cost: 0.42, costUsd: 0.42, currency: "$" },
});
eq(localAccumulated, { amount: 1.5, currency: "¥" }, "locally accumulated session cost still renders");
console.log("\ncontext panel session cache scope");
eq(
contextSessionCache(
{ sessionCacheHitTokens: 900, sessionCacheMissTokens: 100 },
{ cacheHitTokens: 800, cacheMissTokens: 200 },
{ sessionCacheHitTokens: 700, sessionCacheMissTokens: 300 },
),
{ hit: 900, miss: 100 },
"all-sources panel telemetry beats executor-only wire counters",
);
eq(
contextSessionCache(
{ sessionCacheHitTokens: 0, sessionCacheMissTokens: 0 },
{ cacheHitTokens: 800, cacheMissTokens: 200 },
{ sessionCacheHitTokens: 700, sessionCacheMissTokens: 300 },
),
{ hit: 800, miss: 200 },
"ContextInfo telemetry is the second all-sources choice",
);
eq(
contextSessionCache(
{ sessionCacheHitTokens: 0, sessionCacheMissTokens: 0 },
{ cacheHitTokens: 0, cacheMissTokens: 0 },
{ sessionCacheHitTokens: 700, sessionCacheMissTokens: 300 },
),
{ hit: 700, miss: 300 },
"executor-only wire counters only bridge the pre-refresh gap",
);
eq(
contextSessionCache(null, undefined, undefined),
{ hit: 0, miss: 0 },
"no data renders as empty, not NaN",
);
eq(formatMoney(infoCost.amount, infoCost.currency, "dash"), "$0.1759", "USD panel cost renders with dollar sign");
eq(currencySymbol("楼"), "¥", "unexpected currency text does not leak into money values");
eq(currencySymbol("aud"), "AUD ", "unknown ISO currency codes stay readable");
@@ -5,6 +5,7 @@ import {
buildPerformancePayload,
formatPerformanceContext,
globalCrashReportReason,
installPerformancePressureMonitor,
normalizeCrashError,
parseReportedPerf,
performanceLabelForReason,
@@ -151,6 +152,42 @@ eq(shouldPromptForPerformanceLabel(false, 5 * 60_000, false), false, "respects t
eq(shouldPromptForPerformanceLabel(false, 11 * 60_000, true), false, "never prompts while the window is hidden");
eq(shouldPromptForPerformanceLabel(false, 11 * 60_000, false, false), false, "never prompts while unfocused");
{
let interval: (() => void) | undefined;
let now = 0;
let promptPainted = false;
const previousWindow = (globalThis as any).window;
const previousDocument = (globalThis as any).document;
const previousPerformance = (globalThis as any).performance;
const previousPerformanceObserver = (globalThis as any).PerformanceObserver;
(globalThis as any).performance = { now: () => now };
(globalThis as any).window = {
runtime: {},
setInterval: (cb: () => void) => {
interval = cb;
return 1;
},
};
(globalThis as any).document = {
visibilityState: "visible",
hasFocus: () => true,
addEventListener: () => {},
getElementById: () => {
promptPainted = true;
return null;
},
};
(globalThis as any).PerformanceObserver = undefined;
installPerformancePressureMonitor();
now = 26_000;
interval?.();
eq(promptPainted, false, "first post-grace event-loop tick primes without reporting startup backlog");
(globalThis as any).window = previousWindow;
(globalThis as any).document = previousDocument;
(globalThis as any).performance = previousPerformance;
(globalThis as any).PerformanceObserver = previousPerformanceObserver;
}
const reportedPerf = serializeReportedPerf(new Set(["performance.lag"]), "abc123");
eq([...parseReportedPerf(reportedPerf, "abc123")], ["performance.lag"], "round-trips reported labels for the same build");
eq([...parseReportedPerf(reportedPerf, "def456")], [], "re-surfaces reported labels on a new build");
@@ -0,0 +1,216 @@
// Run: tsx src/__tests__/history-recovery-copies.test.tsx
//
// Recovery-copy bulk actions in HistoryPanel: the history view sweeps idle
// copies into the trash (skipping current/open ones), the trash view purges
// them, and both flows keep normal sessions untouched.
import { JSDOM } from "jsdom";
import { registerHooks } from "node:module";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { SessionMeta } from "../lib/types";
// HistoryPanel transitively imports Welcome's SVG wordmark; tsx has no asset
// loader, so redirect .svg specifiers to an empty-string module stub, the way
// Vite would default-export a URL.
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier.endsWith(".svg")) {
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
}
return nextResolve(specifier, context);
},
});
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 (JSON.stringify(actual) === JSON.stringify(expected)) ok(true, label);
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
function flushTimers(ms = 0): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function installDom() {
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(dom.window.navigator, "language", { configurable: true, value: "en-US" });
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
globalThis.Event = dom.window.Event;
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);
globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window);
return dom;
}
const now = 1_750_000_000_000;
function session(overrides: Partial<SessionMeta> & { path: string }): SessionMeta {
return {
preview: "session preview",
turns: 3,
createdAt: now - 3_600_000,
lastActivityAt: now,
modTime: now,
current: false,
open: false,
...overrides,
};
}
async function renderPanel(props: Record<string, unknown>) {
const { HistoryPanel } = await import("../components/HistoryPanel");
const { LocaleProvider } = await import("../lib/i18n");
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(
<LocaleProvider>
<HistoryPanel
running={false}
onResume={() => {}}
onPreview={async () => []}
onDelete={() => {}}
onRename={() => {}}
onClose={() => {}}
{...props}
/>
</LocaleProvider>,
);
await flushTimers(30);
});
return root;
}
function findButton(text: string): HTMLButtonElement | undefined {
return Array.from(document.querySelectorAll("button")).find((b) => b.textContent?.trim() === text) as
| HTMLButtonElement
| undefined;
}
async function click(button: HTMLButtonElement) {
await act(async () => {
button.click();
await flushTimers(20);
});
}
console.log("\nhistory panel recovery-copy bulk actions");
// History view: the sweep button trashes idle recovery copies only.
{
const dom = installDom();
const deleted: string[][] = [];
const purged: string[][] = [];
const root = await renderPanel({
kind: "history",
sessions: [
session({ path: "/s/normal.jsonl" }),
session({ path: "/s/idle-recovery-0123456789abcdef.jsonl", recovered: true }),
session({ path: "/s/open-recovery-0123456789abcdef.jsonl", recovered: true, open: true }),
session({ path: "/s/current-recovery-0123456789abcdef.jsonl", recovered: true, current: true }),
],
onDeleteMany: (paths: string[]) => deleted.push(paths),
onPurgeAll: (paths: string[]) => purged.push(paths),
});
const sweep = findButton("Trash recovery copies");
ok(Boolean(sweep), "history view shows the trash-recovery-copies button");
if (sweep) {
await click(sweep); // arm
const confirm = findButton("Confirm trash copies");
ok(Boolean(confirm), "sweep arms into a confirm step");
if (confirm) await click(confirm);
}
eq(deleted, [["/s/idle-recovery-0123456789abcdef.jsonl"]], "sweep trashes only idle recovery copies");
eq(purged, [], "history sweep never purges");
await act(async () => {
root.unmount();
});
dom.window.close();
}
// History view: no idle copies → no sweep button.
{
const dom = installDom();
const root = await renderPanel({
kind: "history",
sessions: [
session({ path: "/s/normal.jsonl" }),
session({ path: "/s/current-recovery-0123456789abcdef.jsonl", recovered: true, current: true }),
],
onDeleteMany: () => {},
});
ok(!findButton("Trash recovery copies"), "history view hides the sweep button when every copy is live");
await act(async () => {
root.unmount();
});
dom.window.close();
}
// Trash view: clear copies purges recovery copies, empty trash stays separate.
{
const dom = installDom();
const purged: string[][] = [];
const root = await renderPanel({
kind: "trash",
sessions: [
session({ path: "/t/normal.jsonl", deletedAt: now }),
session({ path: "/t/a-recovery-0123456789abcdef.jsonl", deletedAt: now, recovered: true }),
session({ path: "/t/b-recovery-0123456789abcdef.jsonl", deletedAt: now, recovered: true }),
],
onRestore: () => {},
onPurge: () => {},
onPurgeAll: (paths: string[]) => purged.push(paths),
});
const clear = findButton("Clear copies");
ok(Boolean(clear), "trash view shows the clear-copies button");
if (clear) {
await click(clear); // arm
const confirm = findButton("Confirm clear copies");
ok(Boolean(confirm), "clear copies arms into a confirm step");
if (confirm) await click(confirm);
}
eq(
purged,
[["/t/a-recovery-0123456789abcdef.jsonl", "/t/b-recovery-0123456789abcdef.jsonl"]],
"clear copies purges every trashed recovery copy and nothing else",
);
await act(async () => {
root.unmount();
});
dom.window.close();
}
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
@@ -9,6 +9,7 @@ import {
projectTreeReadActivityKey,
projectTreeTopicHasUnreadActivity,
projectTreeShouldRenderTopicActions,
projectTreeTopicMetaLine,
} from "../components/ProjectTree";
import type { ProjectNode } from "../lib/types";
@@ -27,6 +28,12 @@ function eq(a: unknown, b: unknown, label: string) {
console.log("\nproject tree runtime sessions");
const testT = (key: string, vars?: Record<string, string | number>) => {
if (key === "history.turnOne") return `${vars?.n ?? 1} turn`;
if (key === "history.turnOther") return `${vars?.n ?? 0} turns`;
return key;
};
const tree: ProjectNode[] = [
{
key: "global_folder",
@@ -107,6 +114,29 @@ eq(
"regular project topic still opens by topic",
);
eq(
projectTreeTopicMetaLine({
key: "global_topic_missing_time",
kind: "global_topic",
label: "Old empty topic",
topicId: "missing-time",
}, testT),
"projectTree.previously",
"topic with no turns and no timestamps renders previous-time fallback meta",
);
eq(
projectTreeTopicMetaLine({
key: "global_topic_recent",
kind: "global_topic",
label: "Recent blank topic",
topicId: "recent",
createdAt: Date.now(),
}, testT),
"projectTree.justNow",
"topic with a real recent timestamp still renders just-now meta",
);
const completedTopic: ProjectNode = {
key: "topic_complete",
kind: "topic",
@@ -0,0 +1,210 @@
// Run: tsx src/__tests__/ready-event-meta-sync.test.tsx
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { AppBindings } from "../lib/bridge";
import { useController } from "../lib/useController";
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) {
ok(true, label);
} else {
ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 30; attempt += 1) {
await act(async () => {
await flushPromises();
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
function tabMeta(ready: boolean): TabMeta {
return {
id: "tab-ready",
scope: "project",
workspaceRoot: "/repo",
workspaceName: "repo",
workspacePath: "/repo",
gitBranch: "main",
topicId: "topic-ready",
topicTitle: "Ready race",
sessionPath: "/repo/sessions/ready.jsonl",
label: "model",
ready,
running: false,
mode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
active: true,
cwd: "/repo",
};
}
function meta(ready: boolean): Meta {
return {
label: "model",
ready,
eventChannel: "agent:event",
cwd: "/repo",
workspaceRoot: "/repo",
workspaceName: "repo",
workspacePath: "/repo",
gitBranch: "main",
autoApproveTools: false,
bypass: false,
collaborationMode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
goal: "",
goalStatus: "stopped",
};
}
console.log("\nready event meta sync");
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
const balance: BalanceInfo = { available: false, display: "" };
const jobs: JobView[] = [];
const checkpoints: CheckpointMeta[] = [];
const readyHandlers: Array<(tabId?: string) => void> = [];
const historyGate = deferred<HistoryMessage[]>();
let backendReady = false;
let listTabsCalls = 0;
let historyCalls = 0;
let metaCalls = 0;
window.runtime = {
EventsOn: (name: string, cb: (...data: unknown[]) => void) => {
if (name === "agent:ready") readyHandlers.push(cb as (tabId?: string) => void);
return () => {};
},
BrowserOpenURL: () => {},
};
window.go = {
main: {
App: {
ListTabs: async () => {
listTabsCalls += 1;
return [tabMeta(backendReady)];
},
MetaForTab: async () => {
metaCalls += 1;
return meta(backendReady);
},
ContextUsageForTab: async () => context,
EffortForTab: async () => effort,
BalanceForTab: async () => balance,
JobsForTab: async () => jobs,
CheckpointsForTab: async () => checkpoints,
HistoryForTab: async () => historyGate.promise,
HistoryPageForTab: async () => {
historyCalls += 1;
const messages = await historyGate.promise;
return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false };
},
HistoryCheckpointTurnsForTab: async () => [],
ReplayPendingPrompts: async () => {},
} as Partial<AppBindings> as AppBindings,
},
};
type Controller = ReturnType<typeof useController>;
let controller: Controller | undefined;
function Probe() {
controller = useController();
return null;
}
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(<Probe />);
await flushPromises();
});
await waitFor("initial not-ready metadata", () => controller?.activeTabId === "tab-ready" && controller.state.meta?.ready === false);
eq(historyCalls, 1, "startup begins one history hydration");
eq(metaCalls, 0, "startup history remains in flight before ancillary meta");
backendReady = true;
await act(async () => {
for (const handler of readyHandlers) handler("tab-ready");
await flushPromises();
});
await waitFor("ready metadata refreshed before history settles", () => controller?.state.meta?.ready === true);
eq(listTabsCalls >= 2, true, "ready event refreshes active tab metadata from ListTabs");
eq(historyCalls, 1, "ready event joins the in-flight hydrate instead of reloading history");
eq(metaCalls, 0, "ready event does not wait for ancillary MetaForTab before unlocking send");
await act(async () => {
historyGate.resolve([{ role: "user", content: "hello" }]);
await historyGate.promise;
await flushPromises();
});
await waitFor("history finishes", () => controller?.state.hydrating === false);
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello") ?? false, "history still hydrates after the ready metadata sync");
await act(async () => {
root.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,220 @@
// Run: tsx src/__tests__/ready-meta-reconcile.test.tsx
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { AppBindings } from "../lib/bridge";
import { useController } from "../lib/useController";
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) {
ok(true, label);
} else {
ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
function flushPromises(ms = 0): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 100; attempt += 1) {
await act(async () => {
await flushPromises(25);
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
function tabMeta(id: string, ready: boolean, active: boolean): TabMeta {
return {
id,
scope: "project",
workspaceRoot: "/repo",
workspaceName: "repo",
workspacePath: "/repo",
gitBranch: "main",
topicId: `topic-${id}`,
topicTitle: id,
sessionPath: `/repo/sessions/${id}.jsonl`,
label: "model",
ready,
running: false,
mode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
active,
cwd: "/repo",
};
}
function meta(tabId: string, ready: boolean): Meta {
return {
label: "model",
ready,
eventChannel: "agent:event",
cwd: "/repo",
workspaceRoot: "/repo",
workspaceName: "repo",
workspacePath: "/repo",
sessionPath: `/repo/sessions/${tabId}.jsonl`,
gitBranch: "main",
autoApproveTools: false,
bypass: false,
collaborationMode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
goal: "",
goalStatus: "stopped",
};
}
console.log("\nready meta reconcile");
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
const balance: BalanceInfo = { available: false, display: "" };
const jobs: JobView[] = [];
const checkpoints: CheckpointMeta[] = [];
const historyGate = deferred<HistoryMessage[]>();
let backendReady = false;
let listTabsCalls = 0;
let historyCalls = 0;
let metaCalls = 0;
let approvalModeCalls = 0;
const metaTabIds: string[] = [];
window.runtime = {
EventsOn: () => () => {},
BrowserOpenURL: () => {},
};
window.go = {
main: {
App: {
ListTabs: async () => {
listTabsCalls += 1;
return [
tabMeta("tab-ready", backendReady, true),
tabMeta("tab-inactive", true, false),
];
},
MetaForTab: async (tabId: string) => {
metaCalls += 1;
metaTabIds.push(tabId);
return meta(tabId, tabId === "tab-ready" ? backendReady : true);
},
ContextUsageForTab: async () => context,
EffortForTab: async () => effort,
BalanceForTab: async () => balance,
JobsForTab: async () => jobs,
CheckpointsForTab: async () => checkpoints,
HistoryForTab: async () => historyGate.promise,
HistoryPageForTab: async (tabId: string) => {
historyCalls += 1;
const messages = await historyGate.promise;
return {
messages,
startTurn: 0,
endTurn: messages.filter((message) => message.role === "user").length,
totalTurns: messages.filter((message) => message.role === "user").length,
hasOlder: false,
};
},
HistoryCheckpointTurnsForTab: async () => [],
ReplayPendingPrompts: async () => {},
SetToolApprovalModeForTab: async () => {
approvalModeCalls += 1;
},
} as Partial<AppBindings> as AppBindings,
},
};
type Controller = ReturnType<typeof useController>;
let controller: Controller | undefined;
function Probe() {
controller = useController();
return null;
}
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(<Probe />);
await flushPromises();
});
await waitFor("initial not-ready metadata", () => controller?.activeTabId === "tab-ready" && controller.state.meta?.ready === false);
eq(historyCalls, 1, "startup begins one active-tab history hydration");
eq(listTabsCalls, 1, "startup fetches the active tab once");
backendReady = true;
await waitFor("ready metadata is reconciled without a ready event", () => controller?.state.meta?.ready === true);
ok(metaCalls >= 1, "active tab metadata is polled after a missed ready event");
ok(metaTabIds.length > 0 && metaTabIds.every((tabId) => tabId === "tab-ready"), "ready polling is limited to the active tab");
eq(listTabsCalls, 1, "ready polling does not re-list or activate tabs");
eq(historyCalls, 1, "ready polling does not start another history hydration");
eq(approvalModeCalls, 0, "ready polling does not rely on approval-mode changes");
await act(async () => {
historyGate.resolve([{ role: "user", content: "hello" }]);
await historyGate.promise;
await flushPromises();
});
await waitFor("history finishes", () => controller?.state.hydrating === false);
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello") ?? false, "history still hydrates after ready reconciliation");
await act(async () => {
root.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,31 @@
// Run: tsx src/__tests__/recovery-banner-privacy.test.ts
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
let passed = 0;
let failed = 0;
function ok(cond: boolean, label: string) {
if (cond) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
const here = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
console.log("\nquiet recovery prompt privacy");
ok(!appSource.includes("banner--recovery"), "App does not render a persistent recovery banner");
ok(!appSource.includes("recoveryDigest"), "App does not expose recovery digest through recovery prompts");
ok(!appSource.includes("recoveryParentId"), "App does not expose parent session id through recovery prompts");
ok(!appSource.includes("recoveryBannerTitle"), "App does not build a detailed recovery tooltip");
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,62 @@
// Run: tsx src/__tests__/recovery-quiet-notifications.test.ts
//
// Recovery copies remain protected by the backend, but the desktop UI should
// not interrupt users with recovery lifecycle banners, toasts, or transcript
// notices.
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { en } from "../locales/en";
import { zh } from "../locales/zh";
import { zhTW } from "../locales/zh-TW";
let passed = 0;
let failed = 0;
function ok(cond: boolean, label: string) {
if (cond) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
const here = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
const controllerSource = readFileSync(resolve(here, "../lib/useController.ts"), "utf8");
console.log("\nquiet recovery notifications");
ok(!appSource.includes("banner--recovery"), "App does not render a recovery banner");
ok(!appSource.includes("recovery.toast"), "App does not show a recovery-created toast");
ok(!appSource.includes("onSessionRecoveryFailed"), "App does not show recovery failure toasts");
ok(!appSource.includes("AcknowledgeTabRecovery"), "App does not expose recovery acknowledgement controls");
ok(!appSource.includes("OpenTabRecoveryParent"), "App does not expose recovery compare controls");
ok(!appSource.includes("recovery.openOriginalFailed"), "App does not carry recovery compare failure text");
ok(controllerSource.includes("function quietTranscriptNoticeKey"), "controller centralizes quiet transcript notices");
ok(controllerSource.includes("if (quietTranscriptNoticeKey(rawText))"), "raw quiet notices are skipped before localization");
ok(controllerSource.includes("if (quietTranscriptNoticeKey(text))"), "localized quiet notices are skipped before rendering");
const removedPromptKeys = [
"recovery.open",
"recovery.toast",
"recovery.failedLease",
"recovery.failedUnavailable",
"recovery.banner",
"recovery.bannerCompare",
"recovery.bannerDismiss",
"recovery.openOriginalFailed",
];
for (const [name, dict] of [["en", en], ["zh", zh], ["zh-TW", zhTW]] as const) {
for (const key of removedPromptKeys) {
ok(!(key in dict), `${name} locale omits ${key}`);
}
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -8,6 +8,7 @@ import {
SettingsPanel,
formatProviderExtraBody,
parseProviderExtraBody,
providerExtraBodyParseError,
providerBaseURLFromChatURL,
providerChatURLPreview,
providerEditorEffectiveKind,
@@ -41,6 +42,19 @@ function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function installCanvasMock(win: Window) {
Object.defineProperty(win.HTMLCanvasElement.prototype, "getContext", {
configurable: true,
value(type: string) {
if (type !== "2d") return null;
return {
font: "",
measureText: () => ({ width: 0 }),
} as unknown as CanvasRenderingContext2D;
},
});
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 20; attempt += 1) {
await act(async () => {
@@ -146,11 +160,32 @@ try {
extraBodyRejected = true;
}
ok(extraBodyRejected, "extra body editor rejects non-object JSON");
const extraBodyTestT = ((key: string, vars?: Record<string, string | number>) => {
if (key === "settings.providerExtraBodyError") return "localized extra body fallback";
if (key === "settings.providerExtraBodyNull") return `${vars?.path} localized null`;
return key;
}) as any;
eq(
providerExtraBodyParseError(new SyntaxError("Unexpected token } in JSON"), extraBodyTestT),
"localized extra body fallback",
"extra body editor localizes JSON syntax errors",
);
try {
parseProviderExtraBody('{ "nested": { "value": null } }', extraBodyTestT);
ok(false, "extra body editor rejects localized null validation errors");
} catch (e) {
eq(
providerExtraBodyParseError(e, extraBodyTestT),
"extra_body.nested.value localized null",
"extra body editor keeps localized structured validation errors",
);
}
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
installCanvasMock(dom.window as unknown as Window);
(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;
@@ -165,6 +200,7 @@ globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
window.scrollTo = () => {};
localStorage.clear();
const settingsSnapshots = [baseSettings("standard"), baseSettings("compact")];
let settingsCalls = 0;
@@ -191,6 +227,7 @@ await act(async () => {
<LocaleProvider>
<SettingsPanel
initialTab="general"
desktopPlatform="linux"
onClose={() => {}}
onChanged={(settings?: SettingsView) => {
onChangedSettings = settings;
@@ -238,6 +275,7 @@ await act(async () => {
<LocaleProvider>
<SettingsPanel
initialTab="general"
desktopPlatform="linux"
onClose={() => {}}
onChanged={() => {}}
/>
@@ -266,6 +304,55 @@ await act(async () => {
retryRoot.unmount();
});
const zoomRootEl = document.createElement("div");
document.body.appendChild(zoomRootEl);
const zoomRoot = createRoot(zoomRootEl);
let persistedZoom = 0.5;
const savedZoomFactors: number[] = [];
window.go = {
main: {
App: {
Settings: async () => baseSettings("standard"),
GetDesktopZoomFactor: async () => persistedZoom,
SetDesktopZoomFactor: async (factor: number) => {
persistedZoom = factor;
savedZoomFactors.push(factor);
},
} as Partial<AppBindings> as AppBindings,
},
};
localStorage.setItem("reasonix-zoom-restart", "1");
await act(async () => {
zoomRoot.render(
<LocaleProvider>
<SettingsPanel
initialTab="appearance"
desktopPlatform="windows"
onClose={() => {}}
onChanged={() => {}}
/>
</LocaleProvider>,
);
await flushPromises();
});
await waitFor("persisted display zoom sync", () => document.querySelector(".zoom-slider__value")?.textContent?.trim() === "50%");
const resetZoomButton = document.querySelector("button[aria-label='Reset display zoom to 100%']") as HTMLButtonElement | null;
if (!resetZoomButton) throw new Error("display zoom reset button did not render");
await act(async () => {
resetZoomButton.click();
await flushPromises();
});
await waitFor("display zoom reset", () => document.querySelector(".zoom-slider__value")?.textContent?.trim() === "100%");
eq(savedZoomFactors.at(-1), 1, "display zoom reset writes the default zoom factor");
eq(localStorage.getItem("reasonix-zoom-restart"), "1", "display zoom reset updates the local restart zoom cache");
await act(async () => {
zoomRoot.unmount();
});
// Bots tab: direct four-channel bot manager.
const botsRootEl = document.createElement("div");
document.body.appendChild(botsRootEl);
@@ -301,7 +388,7 @@ window.go = {
await act(async () => {
botsRoot.render(
<LocaleProvider>
<SettingsPanel initialTab="bots" onClose={() => {}} onChanged={() => {}} />
<SettingsPanel initialTab="bots" desktopPlatform="linux" onClose={() => {}} onChanged={() => {}} />
</LocaleProvider>,
);
await flushPromises();
@@ -0,0 +1,64 @@
// Run: tsx src/__tests__/sound.test.ts
import { attentionChimeEventKey, clearAttentionChimeKeys, shouldPlayAttentionChimeForEvent } from "../lib/sound";
let passed = 0;
let failed = 0;
function eq(a: unknown, b: unknown, label: string) {
if (a === 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;
}
}
console.log("\nsound notifications");
{
eq(attentionChimeEventKey({ kind: "approval_request", tabId: "tab-a", approval: { id: "approval-1" } }), "approval:tab-a:approval-1", "approval request builds a tab-scoped chime key");
eq(attentionChimeEventKey({ kind: "ask_request", tabId: "tab-a", ask: { id: "ask-1" } }), "ask:tab-a:ask-1", "ask request builds a tab-scoped chime key");
eq(attentionChimeEventKey({ kind: "approval_request", approval: { id: "approval-1" } }), "approval::approval-1", "legacy approval events without a tab still build a stable key");
eq(attentionChimeEventKey({ kind: "turn_done" }), undefined, "non-attention events do not build chime keys");
}
{
const seen = new Set<string>();
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen), true, "first approval event plays");
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen), false, "replayed approval event for the same tab is deduped");
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-b", approval: { id: "1" } }, seen), true, "same approval id from another tab still plays");
eq(shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-a", ask: { id: "1" } }, seen), true, "ask id sharing an approval id still plays");
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request" }, seen), false, "malformed approval event does not play");
}
{
// The dedupe set stays bounded: after many unique prompts it self-prunes
// while still deduping recently seen ids.
const seen = new Set<string>();
for (let i = 0; i < 2000; i++) {
shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "t", approval: { id: String(i) } }, seen);
}
eq(seen.size <= 1024, true, "dedupe set stays bounded after 2000 unique prompts");
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "t", approval: { id: "1999" } }, seen), false, "most recent prompt id is still deduped after pruning");
}
{
// Runtime rebuild clears dedupe keys so reissued ids chime again.
const seen = new Set<string>();
shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen);
shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-b", ask: { id: "1" } }, seen);
clearAttentionChimeKeys(seen, "tab-a");
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen), true, "rebuilt tab's reissued approval id chimes again");
eq(shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-b", ask: { id: "1" } }, seen), false, "other tab's keys survive a scoped clear");
clearAttentionChimeKeys(seen);
eq(shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-b", ask: { id: "1" } }, seen), true, "tab-less ready clears every key");
}
if (failed) {
console.error(`sound notifications: ${failed} failed, ${passed} passed`);
process.exit(1);
}
console.log(`sound notifications: ${passed} passed`);
@@ -133,9 +133,12 @@ const tabD = tabMeta("tab-d");
const tabE = tabMeta("tab-e");
const tabF = tabMeta("tab-f");
const tabG = tabMeta("tab-g");
const tabH = tabMeta("tab-h");
let backendActiveId = "tab-a";
const historyB = deferred<HistoryMessage[]>();
const historyD = deferred<HistoryMessage[]>();
let metaH = deferred<Meta>();
let historyH = deferred<HistoryMessage[]>();
const contextDGate = deferred<ContextInfo>();
const setActiveBGate = deferred<void>();
const setActiveEGate = deferred<void>();
@@ -144,12 +147,15 @@ const submitTabCGate = deferred<void>();
const historyCalls: string[] = [];
const cancelCalls: string[] = [];
let contextDCalls = 0;
let metaHCalls = 0;
let holdNextContextForD = false;
let holdNextMetaForH = false;
let holdNextHistoryForH = false;
let setActiveCalls = 0;
let newSessionCalls = 0;
let failSetActiveFor = "";
const runningTabs = new Set<string>();
const tabsById = new Map([tabA, tabB, tabC, tabD, tabE, tabF, tabG].map((tab) => [tab.id, tab]));
const tabsById = new Map([tabA, tabB, tabC, tabD, tabE, tabF, tabG, tabH].map((tab) => [tab.id, tab]));
const eventHandlers: Array<(e: WireEvent) => void> = [];
const readyHandlers: Array<(tabId?: string) => void> = [];
@@ -172,7 +178,14 @@ window.go = {
main: {
App: {
ListTabs: async () => currentTabs(),
MetaForTab: async (tabID: string) => metaFor(tabsById.get(tabID) ?? tabA),
MetaForTab: async (tabID: string) => {
if (tabID === "tab-h" && holdNextMetaForH) {
metaHCalls += 1;
holdNextMetaForH = false;
return metaH.promise;
}
return metaFor(tabsById.get(tabID) ?? tabA);
},
ContextUsageForTab: async (tabID: string) => {
if (tabID === "tab-d" && holdNextContextForD) {
contextDCalls += 1;
@@ -191,6 +204,12 @@ window.go = {
if (tabID === "tab-b") return historyB.promise;
if (tabID === "tab-d") return historyD.promise;
if (tabID === "tab-e") return [userMessage("fork E")];
if (tabID === "tab-g") return [userMessage("history G")];
if (tabID === "tab-h" && holdNextHistoryForH) {
holdNextHistoryForH = false;
return historyH.promise;
}
if (tabID === "tab-h") return [userMessage("history H")];
return [userMessage("cached A")];
},
HistoryPageForTab: async (tabID: string) => {
@@ -198,9 +217,15 @@ window.go = {
return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false };
},
HistoryCheckpointTurnsForTab: async () => [],
OpenProjectTab: async () => {
backendActiveId = "tab-d";
return { ...(tabsById.get("tab-d") ?? tabD), active: true };
OpenProjectTab: async (workspaceRoot: string, topicId: string) => {
const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === workspaceRoot && tab.topicId === topicId) ?? tabD;
backendActiveId = target.id;
return { ...target, active: true };
},
ActivateTopic: async (_scope: string, workspaceRoot: string, topicId: string) => {
const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === workspaceRoot && tab.topicId === topicId) ?? tabG;
backendActiveId = target.id;
return { ...target, active: true };
},
NewSession: async () => {
newSessionCalls += 1;
@@ -509,6 +534,72 @@ await act(async () => {
});
eq(historyCalls.length, historyCallsBeforeReboundD + 1, "rebound topic reloads history when session path changes");
metaH = deferred<Meta>();
holdNextMetaForH = true;
const historyCallsBeforeSlowMeta = historyCalls.length;
await act(async () => {
await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
await flushPromises();
});
await waitFor("slow meta tab hydrates history", () =>
controller?.activeTabId === "tab-h" &&
controller.state.hydrating === false &&
(controller.state.items.some((item) => item.kind === "user" && item.text === "history H") ?? false) &&
metaHCalls === 1
);
eq(historyCalls.length, historyCallsBeforeSlowMeta + 1, "slow MetaForTab does not delay the history request");
eq(controller?.state.meta?.label, "model-tab-h", "slow MetaForTab leaves optimistic metadata visible while history hydrates");
await act(async () => {
metaH.resolve({ ...metaFor(tabH), label: "fresh-model-tab-h" });
await metaH.promise;
await flushPromises();
});
await waitFor("slow meta refresh applies", () => controller?.state.meta?.label === "fresh-model-tab-h");
metaH = deferred<Meta>();
const metaHCallsBeforeStale = metaHCalls;
holdNextMetaForH = true;
await act(async () => {
await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
await flushPromises();
});
await waitFor("slow meta pending before single-surface navigation", () => metaHCalls === metaHCallsBeforeStale + 1);
await act(async () => {
await controller?.activateTopic("project", tabG.workspaceRoot, tabG.topicId || "");
await flushPromises();
});
await waitFor("single-surface activation replaces visible tab", () =>
controller?.activeTabId === "tab-g" &&
(controller.state.items.some((item) => item.kind === "user" && item.text === "history G") ?? false)
);
await act(async () => {
metaH.resolve({ ...metaFor(tabH), label: "stale-model-tab-h" });
await metaH.promise;
await flushPromises();
});
eq(controller?.activeTabId, "tab-g", "late meta from a replaced tab does not switch the visible tab");
ok(controller?.state.meta?.label !== "stale-model-tab-h", "late meta from a replaced tab does not overwrite visible metadata");
historyH = deferred<HistoryMessage[]>();
holdNextHistoryForH = true;
await act(async () => {
await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
await flushPromises();
});
await waitFor("reopened tab-h treats stale late meta as discarded", () =>
controller?.activeTabId === "tab-h" &&
controller.state.hydrating === true &&
(controller.state.hydratePlaceholderItems?.some((item) => item.kind === "user" && item.text === "history G") ?? false)
);
await act(async () => {
historyH.resolve([userMessage("history H after stale meta")]);
await historyH.promise;
await flushPromises();
});
await waitFor("reopened tab-h finishes after stale meta discard", () =>
controller?.state.hydrating === false &&
(controller.state.items.some((item) => item.kind === "user" && item.text === "history H after stale meta") ?? false)
);
await act(async () => {
root.unmount();
});
@@ -0,0 +1,114 @@
// Run: tsx src/__tests__/tool-card-error-details.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { ToolCard } from "../components/ToolCard";
import { LocaleProvider } from "../lib/i18n";
import type { Item } from "../lib/useController";
type ToolItem = Extract<Item, { kind: "tool" }>;
let passed = 0;
let failed = 0;
function ok(value: unknown, 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 flushTimers(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function installDom() {
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.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
dom.window.matchMedia = () => ({
matches: true,
media: "(prefers-reduced-motion: reduce)",
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
});
return dom;
}
console.log("\ntool card error details");
{
const dom = installDom();
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
const error = "evidence 1: verification command \"Select-String -Path @(...long...)\" has no matching successful receipt — cite the command exactly as it ran in the session; commands that ran: [\"unique-command-tail\"] — pick the matching one and retry complete_step";
const item: ToolItem = {
kind: "tool",
id: "complete-step-error",
name: "complete_step",
args: "",
readOnly: true,
status: "error",
output: `error: ${error}`,
error,
durationMs: 62,
};
await act(async () => {
root.render(
React.createElement(LocaleProvider, null,
React.createElement(ToolCard, { item }),
),
);
await flushTimers();
});
eq(document.querySelectorAll(".code-block").length, 0, "duplicate error output is not rendered as a code block");
ok(document.querySelector(".tool__err-summary")?.textContent?.includes("verification command has no matching successful receipt"), "compact error summary is visible");
ok(!document.querySelector(".tool__err-details"), "full error details are hidden by default");
ok(!document.body.textContent?.includes("unique-command-tail"), "long receipt list is not visible before expansion");
const toggle = document.querySelector(".tool__err-toggle") as HTMLButtonElement | null;
if (!toggle) throw new Error("error details toggle did not render");
await act(async () => {
toggle.click();
await flushTimers();
});
ok(document.querySelector(".tool__err-details")?.textContent?.includes("unique-command-tail"), "full error details render after expansion");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -3,7 +3,7 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { buildStepGroups, buildTurnGroups, createWarmLayerState, questionTurnsById, warmColdPageForTurn, warmLayerForSession, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination } from "../lib/transcriptGrouping";
import { buildStepGroups, buildTurnGroups, createWarmLayerState, lastQuestionTurn, questionTurnsById, warmColdPageForTurn, warmLayerForSession, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination } from "../lib/transcriptGrouping";
import type { Item } from "../lib/useController";
let passed = 0;
@@ -105,6 +105,22 @@ console.log("\ntranscript grouping contract");
eq(backendTurns.get("u0"), 0, "uses backend checkpoint turn zero when present");
eq(backendTurns.get("u2"), 3, "uses non-contiguous backend checkpoint turn");
ok(!backendTurns.has("u1"), "does not mix visible ordinal fallback into authoritative checkpoint sessions");
eq(lastQuestionTurn([
{ id: "u0", text: "first", turn: 0 },
{ id: "u1", text: "second", turn: 1 },
], visibleTurns), 1, "last question turn follows visible ordinal fallback");
eq(lastQuestionTurn([
{ id: "u0", text: "first", turn: 0, checkpointTurn: 0 },
{ id: "u1", text: "live without server stamp yet", turn: 1 },
{ id: "u2", text: "after hidden synthetic", turn: 2, checkpointTurn: 3 },
], backendTurns), 3, "last question turn follows non-contiguous backend turn");
const pagedTurns = questionTurnsById([
{ id: "u-recent", text: "recent prompt", turn: 0, checkpointTurn: 1060 },
]);
eq(lastQuestionTurn([
{ id: "u-recent", text: "recent prompt", turn: 0, checkpointTurn: 1060 },
], pagedTurns), 1060, "last question turn supports paged history windows");
}
{
@@ -0,0 +1,51 @@
// Run: tsx src/__tests__/turn-action-copy.test.ts
import { appendTurnActionCopyText } from "../lib/turnActionCopy";
let passed = 0;
let failed = 0;
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n`);
failed += 1;
}
}
console.log("\nturn action copy");
eq(
appendTurnActionCopyText("", "final answer"),
"final answer",
"keeps single assistant output unchanged",
);
eq(
appendTurnActionCopyText("first assistant block", "second assistant block"),
"first assistant block\n\nsecond assistant block",
"accumulates multiple assistant outputs in one turn",
);
eq(
appendTurnActionCopyText("first assistant block\n", "second assistant block"),
"first assistant block\nsecond assistant block",
"does not add an extra blank line when the previous block already ends with newline",
);
eq(
appendTurnActionCopyText("first assistant block", "\nsecond assistant block"),
"first assistant block\nsecond assistant block",
"does not add an extra blank line when the next block starts with newline",
);
eq(
appendTurnActionCopyText("final answer", " \n\t"),
"final answer",
"ignores whitespace-only assistant fragments",
);
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -102,6 +102,7 @@ globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.windo
const eventHandlers: Array<(e: WireEvent) => void> = [];
let backendRunning = false;
let cancelCalls = 0;
let effortCalls = 0;
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
@@ -119,6 +120,10 @@ window.go = {
MetaForTab: async () => meta(),
ContextUsageForTab: async () => context,
EffortForTab: async () => effort,
SetEffortForTab: async () => {
effortCalls += 1;
throw new Error("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing effort");
},
BalanceForTab: async () => ({ available: false, display: "" }),
JobsForTab: async () => [],
CheckpointsForTab: async () => [],
@@ -178,6 +183,15 @@ eq(controller?.state.running, false, "cancel reconciliation clears the running s
eq(cancelCalls, 1, "CancelTab is called once");
eq(controller?.state.cancelRequested, false, "cancel reconciliation clears cancelRequested");
await act(async () => {
await controller?.setEffort("max");
await flushPromises();
});
const effortNotice = controller?.state.items.find((item) => item.kind === "notice" && item.text.includes("cannot change yet"));
eq(effortCalls, 1, "SetEffortForTab is called once");
ok(Boolean(effortNotice), "busy effort switch surfaces a non-failure warning notice");
await act(async () => {
root.unmount();
});
@@ -1,7 +1,7 @@
// Run: tsx src/__tests__/use-controller-meta.test.ts
import { foregroundRunningFromRuntimeMeta, initialState, metaFromTab, reducer, sameMeta, shouldReconcileStaleTurn } from "../lib/useController";
import type { Meta, TabMeta, WireUsage } from "../lib/types";
import { effortSwitchNoticeText, foregroundRunningFromRuntimeMeta, historyMessagesToItems, initialState, localizedBackendNoticeText, metaFromTab, modelSwitchNoticeText, reducer, sameMeta, shouldReconcileStaleTurn, tokenModeSwitchNoticeText } from "../lib/useController";
import type { HistoryMessage, Meta, TabMeta, WireUsage } from "../lib/types";
type LooseTabMeta = Omit<TabMeta, "toolApprovalMode"> & { toolApprovalMode?: TabMeta["toolApprovalMode"] | "" };
@@ -82,6 +82,203 @@ function usage(source: string): WireUsage {
console.log("\nuse controller meta");
{
eq(
modelSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing model"),
"The model cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
"model busy guard is localized",
);
eq(
modelSwitchNoticeText("this session is already open in another Reasonix window or still running in the background; close the other window or open a copy before changing model"),
"This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing models.",
"model lease conflict explains the safe path",
);
eq(
modelSwitchNoticeText("workspace is still starting"),
"This session is still starting. Try changing models again in a moment.",
"model startup race asks the user to retry later",
);
eq(
modelSwitchNoticeText('tab "tab-a" changed while switching model; retry'),
"The current session changed while switching models. Try once more.",
"model tab race asks the user to retry",
);
eq(
modelSwitchNoticeText('unknown model "missing"'),
'Unknown model "missing".',
"model unknown error is localized",
);
eq(
modelSwitchNoticeText('model "other/other-model" is not available because provider "other" is not added'),
'Model "other/other-model" is unavailable because provider "other" is not added.',
"model provider access error is localized",
);
}
{
eq(
effortSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing effort"),
"Reasoning effort cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
"effort busy guard is worded as temporary",
);
eq(
effortSwitchNoticeText("this session is already open in another Reasonix window or still running in the background; close the other window or open a copy before changing effort"),
"This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing effort.",
"effort lease conflict explains the safe path",
);
eq(
effortSwitchNoticeText("workspace is still starting"),
"This session is still starting. Try changing reasoning effort again in a moment.",
"effort startup race asks the user to retry later",
);
eq(
effortSwitchNoticeText('tab "tab-a" changed while switching effort; retry'),
"The current session changed while switching reasoning effort. Try once more.",
"effort tab race asks the user to retry",
);
eq(
effortSwitchNoticeText("unknown model \"missing\""),
"Reasoning effort switch failed: unknown model \"missing\"",
"effort true failure keeps the underlying error",
);
}
{
eq(
tokenModeSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing token mode"),
"Token saver cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
"token mode busy guard is localized",
);
eq(
tokenModeSwitchNoticeText('tab "tab-a" changed while switching token mode; retry'),
"The current session changed while switching token saver. Try once more.",
"token mode tab race asks the user to retry",
);
}
{
eq(
localizedBackendNoticeText("Session autosave failed: disk full"),
"Session autosave failed: disk full",
"backend autosave notice is localized through the active dictionary",
);
eq(
localizedBackendNoticeText("Session save failed before changing model: disk full"),
"Session save failed before changing models: disk full",
"backend save-before-action notice localizes the action",
);
eq(
localizedBackendNoticeText('model "old/model" is no longer available; switched to new/model'),
'Model "old/model" is no longer available; switched to new/model.',
"backend model fallback notice is localized",
);
eq(
localizedBackendNoticeText("session changed on disk; unsaved local transcript was saved as recovery branch 20260706-152144.863947300-longcat-openai-LongCat-2.0-119b7259f151-recovery-693ce51bcbcbaa9"),
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
"legacy recovery branch notice can be normalized without exposing internal branch id",
);
eq(
localizedBackendNoticeText("session changed on disk; unsaved local transcript was saved as a conflict copy"),
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
"recovery copy notice can be normalized",
);
eq(
localizedBackendNoticeText("session conflicts kept recurring; kept the transcript on the current recovery branch"),
"Repeated save conflicts were detected, so the current conflict copy was saved in place.",
"legacy repeated recovery conflict notice can be normalized",
);
eq(
localizedBackendNoticeText("repeated save conflicts were detected; saved the current conflict copy in place"),
"Repeated save conflicts were detected, so the current conflict copy was saved in place.",
"repeated recovery conflict notice can be normalized",
);
eq(
localizedBackendNoticeText("session changed on disk; adopted the newer transcript"),
"The session changed on disk, so Reasonix adopted the newer transcript.",
"adopted transcript notice can be normalized",
);
eq(
localizedBackendNoticeText("session changed on disk; adopted the newer transcript (local changes already covered)"),
"The session changed on disk, so Reasonix adopted the newer transcript; the local changes were already covered.",
"covered adopted transcript notice can be normalized",
);
eq(
localizedBackendNoticeText("The assistant answered before taking action; asking it to use the required tools."),
"The assistant answered before taking action; asking it to use the required tools.",
"canonical backend notice is routed through the locale dictionary",
);
eq(
localizedBackendNoticeText("background export failed: needs attention"),
"Background export needs attention.",
"dynamic background job notice is user-facing",
);
}
{
let s = reducer(initialState, {
type: "event",
e: { kind: "notice", level: "warn", text: "session conflicts kept recurring; kept the transcript on the current recovery branch" },
});
s = reducer(s, {
type: "event",
e: { kind: "notice", level: "warn", text: "repeated save conflicts were detected; saved the current conflict copy in place" },
});
const recoveryNotices = s.items.filter((item) => item.kind === "notice" && item.text.includes("current conflict copy"));
eq(recoveryNotices.length, 0, "recovery conflict notices stay silent in the live transcript");
eq(s.seq, 0, "silent recovery notices do not consume sequence ids");
s = reducer(s, { type: "event", e: { kind: "notice", level: "warn", text: "runtime notice" } });
s = reducer(s, { type: "event", e: { kind: "notice", level: "warn", text: "runtime notice" } });
const ordinaryNotices = s.items.filter((item) => item.kind === "notice" && item.text === "runtime notice");
eq(ordinaryNotices.length, 2, "ordinary repeated notices remain visible");
}
{
const quietLifecycleMessages = [
{ level: "info", text: "guardian enabled · model=guardian-test" },
{ level: "warn", text: "2 MCP server(s) failed to start: fs, browser — run /mcp for details" },
{ level: "warn", text: "mcp fs: stdio plugin \"fs\": command \"missing-fs\" not found on PATH" },
{ level: "info", text: "settings applied: session refreshed after the lease was released" },
{ level: "info", text: "plugin \"slowserver\" has been slow 3 startups in a row (last 30000ms, budget 1000ms); demoting to background startup this session" },
] as const;
let s = initialState;
for (const message of quietLifecycleMessages) {
s = reducer(s, { type: "event", e: { kind: "notice", level: message.level, text: message.text } });
}
eq(s.items.filter((item) => item.kind === "notice").length, 0, "background lifecycle notices stay silent in the live transcript");
eq(s.seq, 0, "silent lifecycle notices do not consume sequence ids");
const userActionFailure = reducer(s, { type: "event", e: { kind: "notice", level: "warn", text: "mcp connect: no configured MCP server named \"fs\"" } });
const visibleNotices = userActionFailure.items.filter((item) => item.kind === "notice");
eq(visibleNotices.length, 1, "user-triggered MCP failures remain visible");
eq(visibleNotices[0]?.kind === "notice" && visibleNotices[0].text, "mcp connect: no configured MCP server named \"fs\"", "visible MCP failure keeps its text");
}
{
const history: HistoryMessage[] = [
{ role: "notice", level: "warn", content: "session conflicts kept recurring; kept the transcript on the current recovery branch" },
{ role: "notice", level: "warn", content: "repeated save conflicts were detected; saved the current conflict copy in place" },
{ role: "notice", level: "info", content: "guardian enabled · model=guardian-test" },
{ role: "notice", level: "warn", content: "1 MCP server(s) failed to start: fs — run /mcp for details" },
{ role: "notice", level: "info", content: "settings applied: session refreshed after the lease was released" },
{ role: "user", content: "continue" },
];
const hydrated = historyMessagesToItems(history, "h");
const recoveryNotices = hydrated.items.filter((item) => item.kind === "notice" && item.text.includes("current conflict copy"));
const lifecycleNotices = hydrated.items.filter((item) => item.kind === "notice");
const users = hydrated.items.filter((item) => item.kind === "user");
eq(recoveryNotices.length, 0, "recovery conflict notices stay silent when hydrating history");
eq(lifecycleNotices.length, 0, "background lifecycle notices stay silent when hydrating history");
eq(users[0]?.kind === "user" && users[0].id, "h0", "silent history notices keep later item ids compact");
eq(hydrated.seq, 1, "silent history notices do not inflate the hydrated sequence");
}
{
const hydrated = historyMessagesToItems([{ role: "notice", level: "warn", content: "short notice", detail: "historical diagnostic" }], "h");
const notice = hydrated.items.find((item) => item.kind === "notice" && item.text === "short notice");
eq(notice?.kind === "notice" && notice.detail, "historical diagnostic", "history notices preserve expandable detail text");
}
{
eq(sameMeta(meta(), meta()), true, "identical meta is unchanged");
eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "plan" })), false, "collaboration mode changes invalidate meta equality");
@@ -211,6 +408,12 @@ console.log("\nuse controller meta");
eq(Boolean(notice), true, "turn_done checkpoint merge preserves runtime notices");
}
{
const s = reducer(initialState, { type: "event", e: { kind: "notice", level: "warn", text: "short notice", detail: "verbose diagnostic" } });
const notice = s.items.find((item) => item.kind === "notice" && item.text === "short notice");
eq(notice?.kind === "notice" && notice.detail, "verbose diagnostic", "runtime notices preserve expandable detail text");
}
{
let s = reducer(initialState, {
type: "history_page",
@@ -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);
+178 -30
View File
@@ -1,10 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import gsap from "gsap";
import { useT } from "../lib/i18n";
import type { ComposerInsertRequest, DirEntry, WireApproval } from "../lib/types";
import { useT, type Translator } from "../lib/i18n";
import type { ComposerInsertRequest, DirEntry, ToolApprovalMode, WireApproval } from "../lib/types";
import { PromptAction, PromptBadge, PromptHeaderAction, PromptShelf } from "./PromptShelf";
import { playAttentionChime } from "../lib/sound";
import { DUR_FAST } from "../lib/gsapAnimations";
import {
FileReferenceMenu,
@@ -28,7 +27,93 @@ function animateShelfExit(
}
function requiresFreshHumanApproval(tool: string): boolean {
return tool === "remember" || tool === "forget" || tool === "exit_plan_mode";
return tool === "remember" || tool === "forget" || tool === "exit_plan_mode" || tool === "sandbox_escape";
}
const APPROVAL_MODE_RANK: Record<ToolApprovalMode, number> = { ask: 0, auto: 1, yolo: 2 };
export function approvalToolLabel(tool: string, t: Translator): string {
switch (tool) {
case "bash":
return t("approval.toolLabelBash");
case "edit_file":
return t("approval.toolLabelEditFile");
case "write_file":
return t("approval.toolLabelWriteFile");
case "multi_edit":
return t("approval.toolLabelMultiEdit");
case "move_file":
return t("approval.toolLabelMoveFile");
case "web_fetch":
return t("approval.toolLabelWebFetch");
case "run_skill":
return t("approval.toolLabelRunSkill");
case "remember":
return t("approval.toolLabelRemember");
case "forget":
return t("approval.toolLabelForget");
case "sandbox_escape":
return t("approval.toolLabelSandboxEscape");
case "plan_mode_read_only_command":
return t("approval.toolLabelPlanModeReadOnly");
case "exit_plan_mode":
return t("approval.toolLabelExitPlan");
default:
return tool;
}
}
const sandboxEscapeEnglishSubjectFallback = "run shell command unconfined once";
const sandboxEscapeEnglishSubjectPrefix = "run unconfined once: ";
const planModeMcpEnglishSubject = /^MCP (.+) as read-only for planning and research$/;
const planModeBashEnglishSubject = /^Trust (.+) as a read-only command prefix while planning\r?\nCommand: ([\s\S]+)$/;
function localizeApprovalSubject(tool: string, subject: string, t: Translator): string {
const trimmed = subject.trim();
if (tool === "sandbox_escape") {
if (!trimmed || trimmed === sandboxEscapeEnglishSubjectFallback) return t("approval.sandboxEscapeSubjectFallback");
if (trimmed.startsWith(sandboxEscapeEnglishSubjectPrefix)) {
return `${t("approval.sandboxEscapeSubjectPrefix")}${trimmed.slice(sandboxEscapeEnglishSubjectPrefix.length)}`;
}
return trimmed;
}
if (tool === "remember") {
return trimmed
.replace(/^Save\/update memory/, t("approval.memorySaveUpdate"))
.replace(/\bbody: /g, `${t("approval.memoryBodyLabel")}: `);
}
if (tool === "forget" && trimmed.startsWith("Archive memory ")) {
return `${t("approval.memoryArchivePrefix")}${trimmed.slice("Archive memory ".length)}`;
}
const mcpTrust = trimmed.match(planModeMcpEnglishSubject);
if (mcpTrust) {
return t("approval.planModeMcpTrustSubject", { target: mcpTrust[1] ?? "" });
}
const bashTrust = trimmed.match(planModeBashEnglishSubject);
if (bashTrust) {
return t("approval.planModeBashTrustSubject", { prefix: bashTrust[1] ?? "", command: bashTrust[2] ?? "" });
}
return trimmed;
}
function localizeApprovalReason(tool: string, reason: string | undefined, t: Translator): string {
const trimmed = reason?.trim() ?? "";
if (tool !== "sandbox_escape") return trimmed;
if (trimmed.includes("could not wrap this command")) return t("approval.sandboxEscapeWrapReason");
if (trimmed.includes("failed while starting this command") || trimmed.includes("Run this command unconfined once?")) {
return t("approval.sandboxEscapeRuntimeReason");
}
return trimmed || t("approval.sandboxEscapeRuntimeReason");
}
function localizePlanModeApprovalReason(tool: string, reason: string, t: Translator): string {
if (tool === "plan_mode_read_only_command" && reason.includes("built-in read-only set")) {
return t("approval.planModeBashTrustReason");
}
if (reason.includes("external read-only hints need your confirmation")) {
return t("approval.planModeMcpTrustReason");
}
return reason;
}
export function ApprovalModal({
@@ -40,6 +125,7 @@ export function ApprovalModal({
cwd,
insertRequest,
onRevisionActiveChange,
toolApprovalMode,
}: {
approval: WireApproval;
onAnswer: (allow: boolean, session: boolean, persist: boolean) => void;
@@ -49,21 +135,35 @@ export function ApprovalModal({
cwd?: string;
insertRequest?: ComposerInsertRequest | null;
onRevisionActiveChange?: (active: boolean) => void;
toolApprovalMode?: ToolApprovalMode;
}) {
const t = useT();
const isPlanApproval = approval.tool === "exit_plan_mode";
const isPlanModeReadOnlyCommand = approval.tool === "plan_mode_read_only_command";
const toolLabel = isPlanModeReadOnlyCommand ? "bash" : approval.tool;
const toolLabel = approvalToolLabel(approval.tool, t);
const isFreshHumanApproval = requiresFreshHumanApproval(approval.tool);
const subject = approval.subject.trim();
const hasFreshSessionGrant = approval.tool === "sandbox_escape";
// Switching the approval segmented control to a more permissive mode does not
// resolve an already-pending request; say so on the card instead of leaving
// the user to wonder why the switch "did nothing".
const initialToolApprovalModeRef = useRef(toolApprovalMode);
const approvalModeRelaxed =
!isPlanApproval &&
toolApprovalMode !== undefined &&
initialToolApprovalModeRef.current !== undefined &&
APPROVAL_MODE_RANK[toolApprovalMode] > APPROVAL_MODE_RANK[initialToolApprovalModeRef.current];
const subject = localizeApprovalSubject(approval.tool, approval.subject, t);
const reason = localizePlanModeApprovalReason(approval.tool, localizeApprovalReason(approval.tool, approval.reason, t), t);
const subjectSummary = subject.split(/\r?\n/).find((line) => line.trim())?.trim() ?? "";
const toolMeta = approval.reason?.trim() || subjectSummary || approval.tool;
const hasToolDetails = Boolean(approval.reason || subject);
const toolMeta = reason || subjectSummary || approval.tool;
const hasToolDetails = Boolean(reason || subject);
const showToolDetailsByDefault = !isPlanApproval && hasToolDetails;
const [revisionOpen, setRevisionOpen] = useState(false);
const [revisionText, setRevisionText] = useState("");
const [detailsOpen, setDetailsOpen] = useState(() => showToolDetailsByDefault);
const [selectedIndex, setSelectedIndex] = useState(() => (isPlanApproval ? 1 : 0));
// Action index currently hovered/focused; the consequence preview row
// prefers it over the keyboard-selected action.
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
const cardRef = useRef<HTMLDivElement | null>(null);
const shelfRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
@@ -100,6 +200,9 @@ export function ApprovalModal({
const chooseToolAction = (key: string) => {
if (key === "1") answerWithExit(() => onAnswer(true, false, false));
else if (hasFreshSessionGrant && key === "2") answerWithExit(() => onAnswer(true, true, false));
else if (hasFreshSessionGrant && key === "3") answerWithExit(() => onAnswer(false, false, false));
else if (isFreshHumanApproval && key === "2") answerWithExit(() => onAnswer(false, false, false));
else if (isFreshHumanApproval && key === "4") answerWithExit(() => onAnswer(false, false, false));
else if (!isFreshHumanApproval && key === "2") answerWithExit(() => onAnswer(true, true, false));
else if (!isFreshHumanApproval && key === "3") answerWithExit(() => onAnswer(true, true, true));
@@ -107,16 +210,22 @@ export function ApprovalModal({
else if (key === "Escape") answerWithExit(onStop);
};
const selectedToolActionKey = (index: number) => {
if (!isFreshHumanApproval) return String(index + 1);
if (hasFreshSessionGrant) return index === 0 ? "1" : index === 1 ? "2" : "3";
return index === 0 ? "1" : "2";
};
useEffect(() => {
cardRef.current?.focus();
setRevisionOpen(false);
setRevisionText("");
setDetailsOpen(showToolDetailsByDefault);
setSelectedIndex(isPlanApproval ? 1 : 0);
playAttentionChime();
setHoverIndex(null);
}, [approval.id, isPlanApproval, showToolDetailsByDefault]);
const actionCount = isPlanApproval ? 3 : isFreshHumanApproval ? 2 : 4;
const actionCount = isPlanApproval ? 3 : isFreshHumanApproval ? (hasFreshSessionGrant ? 3 : 2) : 4;
const selectedIndexRef = useRef(selectedIndex);
selectedIndexRef.current = selectedIndex;
@@ -137,8 +246,7 @@ export function ApprovalModal({
event.preventDefault();
const key = String(selectedIndexRef.current + 1);
if (isPlanApproval) choosePlanAction(key);
else if (isFreshHumanApproval) chooseToolAction(selectedIndexRef.current === 0 ? "1" : "4");
else chooseToolAction(key);
else chooseToolAction(selectedToolActionKey(selectedIndexRef.current));
} else if (event.key === "1" || event.key === "2" || event.key === "3" || event.key === "4" || event.key === "Escape") {
event.preventDefault();
if (isPlanApproval) choosePlanAction(event.key);
@@ -147,7 +255,7 @@ export function ApprovalModal({
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [isPlanApproval, isFreshHumanApproval, onAnswer, onExitPlan, onStop, actionCount]);
}, [isPlanApproval, isFreshHumanApproval, hasFreshSessionGrant, onAnswer, onExitPlan, onStop, actionCount]);
useEffect(() => {
if (revisionOpen) {
@@ -293,6 +401,26 @@ export function ApprovalModal({
);
}
// Descriptor list mirrors the action buttons below; the consequence row
// previews what the hovered (or keyboard-selected) action will actually do,
// in the quick-pick style the ask card uses for option descriptions.
const toolActions: { key: string; label: string; desc: string; run: () => void }[] = [
{ key: "1", label: t("approval.allowOnce"), desc: t("approval.allowOnceDesc"), run: () => onAnswer(true, false, false) },
...(isFreshHumanApproval
? hasFreshSessionGrant
? [
{ key: "2", label: t("approval.allowSandboxEscapeSession"), desc: t("approval.allowSandboxEscapeSessionDesc"), run: () => onAnswer(true, true, false) },
{ key: "3", label: t("approval.deny"), desc: t("approval.denyDesc"), run: () => onAnswer(false, false, false) },
]
: [{ key: "2", label: t("approval.deny"), desc: t("approval.denyDesc"), run: () => onAnswer(false, false, false) }]
: [
{ key: "2", label: t("approval.allowRuleSession"), desc: t("approval.allowRuleSessionDesc"), run: () => onAnswer(true, true, false) },
{ key: "3", label: t("approval.allowRulePersistent"), desc: t("approval.allowRulePersistentDesc"), run: () => onAnswer(true, true, true) },
{ key: "4", label: t("approval.deny"), desc: t("approval.denyDesc"), run: () => onAnswer(false, false, false) },
]),
];
const previewAction = toolActions[hoverIndex ?? selectedIndex] ?? null;
return (
<div ref={shelfRef}>
<PromptShelf
@@ -316,26 +444,46 @@ export function ApprovalModal({
}
actions={
<>
<PromptAction keyLabel="1" label={t("approval.allowOnce")} onClick={() => answerWithExit(() => onAnswer(true, false, false))} selected={selectedIndex === 0} />
{isFreshHumanApproval ? (
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 1} />
) : (
<>
<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} />
</>
)}
{toolActions.map((action, index) => (
<PromptAction
key={action.key}
keyLabel={action.key}
label={action.label}
onClick={() => answerWithExit(action.run)}
selected={selectedIndex === index}
title={action.desc}
onHoverChange={(hovering) =>
setHoverIndex((current) => (hovering ? index : current === index ? null : current))
}
/>
))}
</>
}
note={
previewAction && (
<div className="approval-consequence">
<span className="approval-consequence__label">{previewAction.label}</span>
<span className="approval-consequence__text">{previewAction.desc}</span>
</div>
)
}
>
{detailsOpen && (
<div className="approval-details">
{approval.reason && <div className="approval-reason">{approval.reason}</div>}
{subject && (
<pre className="approval-subject">{subject}</pre>
{/* Guard the whole block: PromptShelf only renders its body when children
are truthy, and a fragment of two false branches would still count. */}
{(approvalModeRelaxed || detailsOpen) && (
<>
{approvalModeRelaxed && (
<div className="approval-mode-hint">{t("approval.modeSwitchPendingHint")}</div>
)}
</div>
{detailsOpen && (
<div className="approval-details">
{reason && <div className="approval-reason">{reason}</div>}
{subject && (
<pre className="approval-subject">{subject}</pre>
)}
</div>
)}
</>
)}
</PromptShelf>
</div>
+36 -2
View File
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useT } from "../lib/i18n";
import type { QuestionAnswer, WireAsk, WireAskQuestion } from "../lib/types";
import { PromptAction, PromptHeaderAction, PromptShelf } from "./PromptShelf";
import { playAttentionChime } from "../lib/sound";
// AskCard renders the `ask` tool as a compact prompt shelf near the composer. It
// walks multi-question asks one at a time; single-select answers advance
@@ -24,6 +23,8 @@ export function AskCard({
const [custom, setCustom] = useState<Record<string, string>>({});
const [customOpen, setCustomOpen] = useState(false);
const [active, setActive] = useState(0);
// Option label currently hovered/focused; drives the detail preview row.
const [hoverLabel, setHoverLabel] = useState<string | null>(null);
const shelfRef = useRef<HTMLDivElement | null>(null);
const customInputRef = useRef<HTMLInputElement | null>(null);
const advanceTimer = useRef<number | null>(null);
@@ -40,8 +41,11 @@ export function AskCard({
setCustom({});
setCustomOpen(false);
setActive(0);
setHoverLabel(null);
if (advanceTimer.current != null) window.clearTimeout(advanceTimer.current);
playAttentionChime();
// The attention chime plays from the global runtime event stream (App.tsx),
// keyed by prompt id — not here. A mount-time chime would double-fire for
// the active tab and stay silent for background tabs.
}, [ask.id]);
useEffect(() => {
@@ -73,6 +77,7 @@ export function AskCard({
useEffect(() => {
setCustomOpen(false);
setHoverLabel(null);
}, [active]);
useEffect(() => {
@@ -156,6 +161,23 @@ export function AskCard({
[active, custom, questions, sel],
);
// Grid cells truncate descriptions to one line; this row previews the full
// description of the hovered/focused option, falling back to the latest
// selected option and then the first described option so it never blanks.
const detailOption = useMemo(() => {
if (!q) return null;
const withDescription = (label?: string | null) =>
label ? q.options.find((option) => option.label === label && option.description) : undefined;
const hovered = withDescription(hoverLabel);
if (hovered) return hovered;
const selectedLabels = sel[q.id] ?? [];
for (let i = selectedLabels.length - 1; i >= 0; i -= 1) {
const chosen = withDescription(selectedLabels[i]);
if (chosen) return chosen;
}
return q.options.find((option) => option.description) ?? null;
}, [hoverLabel, q, sel]);
if (!q) return null;
return (
@@ -196,11 +218,23 @@ export function AskCard({
description={o.description}
onClick={() => toggle(q, o.label)}
selected={on}
title={o.description || undefined}
onHoverChange={(hovering) =>
setHoverLabel((current) => (hovering ? o.label : current === o.label ? null : current))
}
/>
);
})}
</>
}
note={
detailOption && (
<div className="ask-shelf__detail">
<span className="ask-shelf__detail-label">{detailOption.label}</span>
<span className="ask-shelf__detail-text">{detailOption.description}</span>
</div>
)
}
quickActions={
<>
{active > 0 && (
+115 -33
View File
@@ -51,6 +51,9 @@ const LONG_PASTE_MIN_CHARS = 2000;
const LONG_PASTE_MIN_LINES = 20;
const COMPOSER_MIN_HEIGHT = 104;
const COMPOSER_MAX_HEIGHT = 360;
// Height reserved for the in-card run strip while a turn runs; applied via a
// CSS calc so --composer-height always stays in "logical height" space.
const COMPOSER_RUN_STRIP_RESERVED = 30;
const COMPOSER_MAX_VIEWPORT_RATIO = 0.4;
const COMPOSER_AUTO_RESERVED_HEIGHT = 58;
const PROMPT_HISTORY_PREFETCH_REMAINING = 3;
@@ -255,6 +258,14 @@ function composerMaxHeight(): number {
return Math.max(COMPOSER_MIN_HEIGHT, Math.min(COMPOSER_MAX_HEIGHT, Math.floor(window.innerHeight * COMPOSER_MAX_VIEWPORT_RATIO)));
}
// The rendered card includes the run strip while a turn runs; subtract it to
// recover the user's logical height when measuring from the DOM.
function composerLogicalHeight(card: HTMLElement): number {
const strip = card.querySelector(".composer-run-strip");
const stripHeight = strip ? strip.getBoundingClientRect().height : 0;
return card.getBoundingClientRect().height - stripHeight;
}
function clampComposerHeight(height: number): number {
return Math.min(Math.max(Math.round(height), COMPOSER_MIN_HEIGHT), composerMaxHeight());
}
@@ -449,6 +460,8 @@ export function Composer({
turnStartAt,
turnTokens,
retry,
pendingApprovalLabel,
pendingAsk = false,
transientDismissSignal,
sessionKey,
fileRefRefreshKey,
@@ -492,6 +505,11 @@ export function Composer({
turnStartAt?: number;
turnTokens?: number;
retry?: { attempt: number; max: number };
// Resolved label of the tool waiting for approval (null/undefined when none);
// shifts the run strip into its waiting state so the ticking spinner does not
// claim the model is working while a prompt is blocked on the user.
pendingApprovalLabel?: string | null;
pendingAsk?: boolean;
transientDismissSignal?: number;
sessionKey?: string;
fileRefRefreshKey?: number | string;
@@ -1536,7 +1554,19 @@ export function Composer({
const handleCancel = () => {
const restored = onCancel();
if (goalModeOn && activeGoal) onClearGoal();
if (typeof restored === "string") setTextCaretEnd(restored);
// Queued guidance the model never consumed would otherwise vanish when the
// cancelled turn ends (the running→false effect clears the shelf). Fold it
// back into the draft: cancelling means "stop acting", not "discard what I
// typed" — the same contract onCancel already honors for un-sent text.
const queued = pendingGuidance.map((item) => item.text).filter((part) => part.trim() !== "");
if (queued.length === 0) {
if (typeof restored === "string") setTextCaretEnd(restored);
return;
}
setPendingGuidance([]);
setGuidanceExpanded(false);
const base = typeof restored === "string" ? restored : text;
setTextCaretEnd([base, ...queued].filter((part) => part.trim() !== "").join("\n"));
};
const pickCommand = (c: CommandInfo) => setTextCaretEnd("/" + c.name + " ");
@@ -1637,7 +1667,7 @@ export function Composer({
e.preventDefault();
const startY = e.clientY;
const startHeight = composerHeight ?? card.getBoundingClientRect().height;
const startHeight = composerHeight ?? composerLogicalHeight(card);
let nextHeight = clampComposerHeight(startHeight);
let moved = false;
card.style.setProperty("--composer-height", `${nextHeight}px`);
@@ -1675,7 +1705,7 @@ export function Composer({
const onComposerResizeKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
const card = composerCardRef.current;
const current = composerHeight ?? card?.getBoundingClientRect().height ?? COMPOSER_MIN_HEIGHT;
const current = composerHeight ?? (card ? composerLogicalHeight(card) : COMPOSER_MIN_HEIGHT);
const step = e.shiftKey ? 32 : 16;
let next: number | null = null;
if (e.key === "ArrowUp" || e.key === "PageUp") next = current + step;
@@ -2006,7 +2036,18 @@ export function Composer({
}
};
const composerCardStyle = composerHeight === null ? undefined : ({ "--composer-height": `${composerHeight}px` } as CSSProperties);
// When the run strip is visible inside a user-resized card, the card grows
// by the strip's reserved height so the meta row stays fully visible.
// --composer-height carries only the user's logical height; the reservation
// is a separate variable consumed by the CSS calc, so the live resize drag
// (which writes raw logical heights) stays consistent with this render path.
const showRunStrip = Boolean(retry || running);
const composerCardStyle = composerHeight === null
? undefined
: ({
"--composer-height": `${composerHeight}px`,
"--composer-run-strip-reserved": `${showRunStrip ? COMPOSER_RUN_STRIP_RESERVED : 0}px`,
} as CSSProperties);
const textareaStyle = composerHeight === null && textareaAutoHeight !== null
? ({ height: `${textareaAutoHeight}px`, overflowY: textareaAutoOverflow ? "auto" : "hidden" } as CSSProperties)
: undefined;
@@ -2054,17 +2095,45 @@ export function Composer({
requestAnimationFrame(() => taRef.current?.focus());
});
};
const runActivity = retry
// Run-strip state machine: retry > waiting-approval > waiting-ask > streaming.
// The waiting states replace the whimsical ticker — while a prompt is blocked
// on the user, the strip must say so instead of counting "working" seconds.
const waitingPrompt = pendingApprovalLabel ? "approval" : pendingAsk ? "ask" : null;
// The pending approval itself disables the composer; keep the approval bar
// usable in exactly that case so the mode can still be changed mid-prompt.
const approvalBarDisabled = Boolean(disabled) && !pendingApprovalLabel;
// Waiting on the user (approval/ask) is not model work: pause the ticker's
// clock while a prompt is blocked so the elapsed time means "model time",
// matching what the waiting strip promises. Retries keep counting — they are
// system time, not user time. Accumulated in state via the effect cleanup so
// leaving the waiting state re-renders with the corrected time immediately.
const [waitAccumMs, setWaitAccumMs] = useState(0);
useEffect(() => {
setWaitAccumMs(0);
}, [turnStartAt]);
useEffect(() => {
if (!waitingPrompt) return;
const since = Date.now();
return () => setWaitAccumMs((total) => total + (Date.now() - since));
}, [waitingPrompt]);
const runStateText = retry
? t("status.retrying", { attempt: retry.attempt, max: retry.max })
: running && turnStartAt
? (() => {
const elapsedMs = Math.max(0, now - turnStartAt);
const words = SPINNER_WORDS[locale];
const word = words[Math.floor(elapsedMs / 3000) % words.length];
const tok = turnTokens && turnTokens > 0 ? ` · ↓ ${fmtTokens(turnTokens)} ${t("status.tokens")}` : "";
return `${word}… ${fmtElapsed(elapsedMs)}${tok}`;
})()
: null;
: waitingPrompt === "approval"
? t("composer.runWaitingApproval", { tool: pendingApprovalLabel ?? "" })
: waitingPrompt === "ask"
? t("composer.runWaitingAsk")
: running
? t("composer.runAnnounceRunning")
: null;
const runTicker = !retry && !waitingPrompt && running && turnStartAt
? (() => {
const elapsedMs = Math.max(0, now - turnStartAt - waitAccumMs);
const words = SPINNER_WORDS[locale];
const word = words[Math.floor(elapsedMs / 3000) % words.length];
const tok = turnTokens && turnTokens > 0 ? ` · ↓ ${fmtTokens(turnTokens)} ${t("status.tokens")}` : "";
return `${word}… ${fmtElapsed(elapsedMs)}${tok}`;
})()
: null;
const submitEmpty = !text.trim() && attachments.length === 0 && workspaceRefs.length === 0;
const submitBlocked = submitting || pendingPaste > 0 || (submitEmpty && !(goalModeOn && !activeGoal)) || disabled || (!running && submitDisabled) || readOnly;
const submitTooltip = running ? t("composer.queueGuidance") : t("composer.send");
@@ -2322,20 +2391,6 @@ export function Composer({
/>
)
)}
{runActivity && (
<div className="composer-toolbar composer-toolbar--status-only">
<div className="composer-runstatus" role="status" aria-live="polite">
<span className="composer-runstatus__dot" />
<span className="composer-runstatus__text">{runActivity}</span>
<Tooltip label={t("composer.stop")}>
<button className="composer-runstatus__stop" type="button" onClick={handleCancel}>
<Square size={10} fill="currentColor" />
<span>{t("composer.stopShort")}</span>
</button>
</Tooltip>
</div>
</div>
)}
{pendingGuidance.length > 0 && (
<div className="composer-guidance-shelf" aria-label={t("composer.guidanceQueue")}>
<div className="composer-guidance-head">
@@ -2484,7 +2539,7 @@ export function Composer({
</div>
)}
<div
className={`composer-card${composerHeight !== null || composerResizing ? " composer-card--resized" : ""}${composerAutoExpanded ? " composer-card--autosized" : ""}${composerResizing ? " composer-card--resizing" : ""}${running ? " composer-card--running" : ""}`}
className={`composer-card${composerHeight !== null || composerResizing ? " composer-card--resized" : ""}${composerAutoExpanded ? " composer-card--autosized" : ""}${composerResizing ? " composer-card--resizing" : ""}${running ? (waitingPrompt ? " composer-card--waiting" : " composer-card--running") : ""}`}
ref={composerCardRef}
style={composerCardStyle}
>
@@ -2502,6 +2557,17 @@ export function Composer({
onKeyDown={onComposerResizeKeyDown}
onDoubleClick={resetComposerHeight}
/>
{runStateText && (
<div className={`composer-run-strip${waitingPrompt ? " composer-run-strip--waiting" : ""}`}>
<span className="composer-run-strip__dot" aria-hidden="true" />
{/* The ticker re-renders every second; keep it out of the accessibility
tree and announce only the stable state text via the live region. */}
<span className="composer-run-strip__text" aria-hidden={runTicker ? true : undefined}>
{runTicker ?? runStateText}
</span>
<span className="sr-only" role="status">{runStateText}</span>
</div>
)}
<div
className={`composer${dragOver ? " composer--dragover" : ""}${disabled || readOnly ? " composer--disabled" : ""}${shellModeActive ? " composer--shell" : ""}`}
onDrop={onDrop}
@@ -2543,6 +2609,18 @@ export function Composer({
{composerPrompt}
</span>
)}
{running && (
<Tooltip label={t("composer.stop")}>
<button
className="composer__btn composer__btn--stop"
type="button"
onClick={handleCancel}
aria-label={t("composer.stop")}
>
<Square size={12} fill="currentColor" />
</button>
</Tooltip>
)}
<Tooltip label={submitTooltip}>
<button
className={`composer__btn composer__btn--send${running ? " composer__btn--steer" : ""}`}
@@ -2550,7 +2628,7 @@ export function Composer({
disabled={submitBlocked}
aria-label={submitTooltip}
>
<ArrowUp size={16} />
{running ? <CornerDownRight size={16} /> : <ArrowUp size={16} />}
</button>
</Tooltip>
</div>
@@ -2634,13 +2712,17 @@ export function Composer({
)}
</div>
<div className="composer-meta__control composer-meta__control--approval">
{/* A pending tool approval disables the composer, but the approval
bar stays usable so mode changes remain possible mid-prompt;
the approval card explains that the pending request still needs
an explicit decision. */}
<div className="composer-modebar composer-modebar--approval" data-mode={toolApprovalMode} title={t("composer.accessMenuTitle")}>
<span className="composer-modebar__thumb" aria-hidden="true" />
<button
type="button"
className={`composer-modebar__item composer-modebar__item--ask${toolApprovalMode === "ask" ? " composer-modebar__item--active" : ""}`}
onClick={() => chooseApprovalMode("ask")}
disabled={disabled}
disabled={approvalBarDisabled}
aria-pressed={toolApprovalMode === "ask"}
title={t("composer.accessAskTitle")}
>
@@ -2651,7 +2733,7 @@ export function Composer({
type="button"
className={`composer-modebar__item composer-modebar__item--auto${toolApprovalMode === "auto" ? " composer-modebar__item--active" : ""}`}
onClick={() => chooseApprovalMode("auto")}
disabled={disabled}
disabled={approvalBarDisabled}
aria-pressed={toolApprovalMode === "auto"}
title={t("composer.accessAutoTitle")}
>
@@ -2662,7 +2744,7 @@ export function Composer({
type="button"
className={`composer-modebar__item composer-modebar__item--yolo${toolApprovalMode === "yolo" ? " composer-modebar__item--active" : ""}`}
onClick={() => chooseApprovalMode("yolo")}
disabled={disabled}
disabled={approvalBarDisabled}
aria-pressed={toolApprovalMode === "yolo"}
title={t("composer.accessYoloTitle")}
>
@@ -126,24 +126,40 @@ export function contextCostDisplay({
sessionCurrency?: string;
usage?: Pick<WireUsage, "cost" | "costUsd" | "currency">;
}): { amount: number; currency?: string } {
// Session-scoped sources only: this value renders under the 会话费用 label,
// and falling back to a single request's usage.cost silently displayed one
// turn's spend as the whole session's. usage now contributes currency only.
if (info?.sessionCost && info.sessionCost > 0) {
return { amount: info.sessionCost, currency: info.sessionCurrency || sessionCurrency || usage?.currency };
}
if (sessionCost && sessionCost > 0) {
return { amount: sessionCost, currency: sessionCurrency || info?.sessionCurrency || usage?.currency };
}
if (usage?.cost && usage.cost > 0) {
return { amount: usage.cost, currency: usage.currency || sessionCurrency || info?.sessionCurrency };
}
if (info?.sessionCostUsd && info.sessionCostUsd > 0) {
return { amount: info.sessionCostUsd, currency: info.sessionCurrency || sessionCurrency || usage?.currency };
}
if (usage?.costUsd && usage.costUsd > 0) {
return { amount: usage.costUsd, currency: usage.currency || sessionCurrency || info?.sessionCurrency };
}
return { amount: 0, currency: info?.sessionCurrency || sessionCurrency || usage?.currency };
}
// contextSessionCache picks the session-cumulative cache hit/miss pair for the
// panel's session average. All-sources telemetry (panel info, then ContextInfo)
// wins over the wire session counters, which the Go agent scopes to the
// executor only — the same preference StatusBar applies — and the pair always
// comes from a single source so the computed rate never mixes scopes.
export function contextSessionCache(
info?: Pick<ContextPanelInfo, "sessionCacheHitTokens" | "sessionCacheMissTokens"> | null,
context?: Pick<ContextInfo, "cacheHitTokens" | "cacheMissTokens">,
usage?: Pick<WireUsage, "sessionCacheHitTokens" | "sessionCacheMissTokens">,
): { hit: number; miss: number } {
const infoHit = info?.sessionCacheHitTokens ?? 0;
const infoMiss = info?.sessionCacheMissTokens ?? 0;
if (infoHit + infoMiss > 0) return { hit: infoHit, miss: infoMiss };
const ctxHit = context?.cacheHitTokens ?? 0;
const ctxMiss = context?.cacheMissTokens ?? 0;
if (ctxHit + ctxMiss > 0) return { hit: ctxHit, miss: ctxMiss };
return { hit: usage?.sessionCacheHitTokens ?? 0, miss: usage?.sessionCacheMissTokens ?? 0 };
}
interface ContextBreakdown {
promptTokens: number;
completionTokens: number;
@@ -354,11 +370,12 @@ export function ContextPanel({
? usage.totalTokens
: promptTokens + completionTokens;
const reasoningTokens = usage?.reasoningTokens ?? info?.reasoningTokens ?? 0;
// Session-cumulative values for the top summary.
// 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;
// Session-cumulative cache tokens for the top summary: all-sources telemetry
// first (matching the session cost and per-source rows in this panel — the
// wire session counters are executor-only), with the live counters bridging
// only a fresh session's first turn before the telemetry refresh. Hit and
// miss come as a pair from one source so the rate cannot mix scopes.
const { hit: sessionCacheHit, miss: sessionCacheMiss } = contextSessionCache(info, context, usage);
const totalTokensMetric = formatMetricTokens(totalTokens, locale);
const cost = contextCostDisplay({ info, sessionCost, sessionCurrency, usage });
const sourceUsageRows = contextSourceRows(info, sessionCurrency);
@@ -28,6 +28,7 @@ export function HistoryPanel({
onRestore,
onPurge,
onPurgeAll,
onDeleteMany,
onClose,
}: {
kind?: "history" | "trash";
@@ -40,6 +41,7 @@ export function HistoryPanel({
onRestore?: (path: string) => void;
onPurge?: (path: string) => void;
onPurgeAll?: (paths: string[]) => void;
onDeleteMany?: (paths: string[]) => void;
onClose: () => void;
}) {
const tr = useT();
@@ -56,7 +58,7 @@ export function HistoryPanel({
const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
const [blankMenuPoint, setBlankMenuPoint] = useState<ContextMenuPoint | null>(null);
const [menuConfirmTarget, setMenuConfirmTarget] = useState<
{ kind: "delete"; path: string } | { kind: "purge"; path: string } | { kind: "clear" } | null
{ kind: "delete"; path: string } | { kind: "purge"; path: string } | { kind: "clear" } | { kind: "clearRecovery" } | null
>(null);
const [preview, setPreview] = useState<{
path: string;
@@ -144,15 +146,33 @@ export function HistoryPanel({
return [s.title, s.preview, s.path, s.topicTitle, s.workspaceRoot].some((part) => (part ?? "").toLowerCase().includes(q));
});
}, [dateFilter, isTrash, query, scopeFilter, sessions, statusFilter]);
// Recovery copies are bulk-actionable in both views: trash purges them for
// good, history sweeps them into the trash. The history sweep skips copies
// that are current or open in a tab — an adopted copy is someone's live
// conversation. Counting from `sessions` (not the filtered list) keeps the
// sweep exhaustive even while a search or filter is active.
const recoveryCopyPaths = useMemo(
() => sessions.filter((s) => s.recovered && (isTrash || (!s.current && !s.open))).map((s) => s.path),
[isTrash, sessions],
);
const recoveryCopyCount = recoveryCopyPaths.length;
const displayedSessions = useMemo(
() =>
isTrash
? filteredSessions
: [...filteredSessions.filter((s) => !s.recovered), ...filteredSessions.filter((s) => s.recovered)],
[filteredSessions, isTrash],
);
// Sessions arrive newest-first; bucket consecutive ones under a day heading
// (Today / Yesterday / a date) while preserving that order.
const groups: { label: string; items: SessionMeta[] }[] = [];
for (const s of filteredSessions) {
const groups: { label: string; items: SessionMeta[]; recovered: boolean }[] = [];
for (const s of displayedSessions) {
const recovered = !isTrash && Boolean(s.recovered);
const label = dayLabel(sessionTimeForGrouping(s, isTrash));
const last = groups[groups.length - 1];
if (last && last.label === label) last.items.push(s);
else groups.push({ label, items: [s] });
if (last && last.label === label && last.recovered === recovered) last.items.push(s);
else groups.push({ label, items: [s], recovered });
}
useEffect(() => {
@@ -168,19 +188,19 @@ export function HistoryPanel({
useEffect(() => {
setEditing(null);
if (filteredSessions.length === 0) {
if (displayedSessions.length === 0) {
if (preview) setPreview(null);
return;
}
if (preview && filteredSessions.some((s) => s.path === preview.path)) return;
const first = filteredSessions.find((s) => !s.current) ?? filteredSessions[0];
if (preview && displayedSessions.some((s) => s.path === preview.path)) return;
const first = displayedSessions.find((s) => !s.current) ?? displayedSessions[0];
void loadPreview(first);
}, [filteredSessions, loadPreview, preview]);
}, [displayedSessions, loadPreview, preview]);
const previewItems = useMemo(() => previewMessagesToItems(preview?.messages ?? []), [preview?.messages]);
const selectedSession = useMemo(
() => (preview ? filteredSessions.find((s) => s.path === preview.path) ?? null : null),
[filteredSessions, preview],
() => (preview ? displayedSessions.find((s) => s.path === preview.path) ?? null : null),
[displayedSessions, preview],
);
const openSessionMenu = (event: ReactMouseEvent<HTMLElement>, s: SessionMeta) => {
event.preventDefault();
@@ -207,6 +227,13 @@ export function HistoryPanel({
setBlankMenuPoint(null);
setMenuConfirmTarget({ kind: "clear" });
};
const armClearRecoveryCopies = () => {
if (recoveryCopyCount === 0 || (!isTrash && running)) return;
setMenuSession(null);
setMenuPoint(null);
setBlankMenuPoint(null);
setMenuConfirmTarget({ kind: "clearRecovery" });
};
const closeHistoryMenus = () => {
setMenuSession(null);
setMenuPoint(null);
@@ -226,6 +253,12 @@ export function HistoryPanel({
closeHistoryMenus();
onPurgeAll?.(paths);
};
const clearRecoveryCopies = () => {
const paths = recoveryCopyPaths;
closeHistoryMenus();
if (isTrash) onPurgeAll?.(paths);
else onDeleteMany?.(paths);
};
const sessionMenuItems: ContextMenuItem[] = menuSession
? isTrash
? [
@@ -292,30 +325,52 @@ export function HistoryPanel({
]
: [];
const trashBlankMenuItems: ContextMenuItem[] =
menuConfirmTarget?.kind === "clear"
menuConfirmTarget?.kind === "clearRecovery"
? [
{
key: "clear-trash-confirm",
key: "clear-recovery-confirm",
icon: <Trash2 size={13} />,
label: tr("history.confirmClearTrash"),
label: tr("history.confirmClearRecoveryCopies"),
danger: true,
onSelect: clearTrash,
onSelect: clearRecoveryCopies,
},
]
: [
{
key: "clear-trash",
icon: <Trash2 size={13} />,
label: tr("history.clearTrashMenu"),
danger: true,
onSelect: () => setMenuConfirmTarget({ kind: "clear" }),
},
];
: menuConfirmTarget?.kind === "clear"
? [
{
key: "clear-trash-confirm",
icon: <Trash2 size={13} />,
label: tr("history.confirmClearTrash"),
danger: true,
onSelect: clearTrash,
},
]
: [
...(recoveryCopyCount > 0
? [
{
key: "clear-recovery",
icon: <Trash2 size={13} />,
label: tr("history.clearRecoveryCopiesMenu"),
danger: true,
onSelect: () => setMenuConfirmTarget({ kind: "clearRecovery" }),
} as ContextMenuItem,
]
: []),
{
key: "clear-trash",
icon: <Trash2 size={13} />,
label: tr("history.clearTrashMenu"),
danger: true,
onSelect: () => setMenuConfirmTarget({ kind: "clear" }),
},
];
const actionConfirmDelete =
selectedSession && menuConfirmTarget?.kind === "delete" && menuConfirmTarget.path === selectedSession.path;
const actionConfirmPurge =
selectedSession && menuConfirmTarget?.kind === "purge" && menuConfirmTarget.path === selectedSession.path;
const actionConfirmClearTrash = isTrash && menuConfirmTarget?.kind === "clear";
const actionConfirmClearRecovery = menuConfirmTarget?.kind === "clearRecovery";
const openSelected = () => {
if (!selectedSession || running || isTrash) return;
@@ -356,6 +411,18 @@ export function HistoryPanel({
{!isTrash && running && <div className="management-modal__summary history-modal__summary">{tr("history.readOnlyHint")}</div>}
</div>
<div className="management-modal__actions history-modal__actions">
{recoveryCopyCount > 0 && (
<button
className={`chip history-clear${actionConfirmClearRecovery ? " history-clear--confirm" : ""}`}
type="button"
disabled={!isTrash && running}
onClick={actionConfirmClearRecovery ? clearRecoveryCopies : armClearRecoveryCopies}
>
{isTrash
? tr(actionConfirmClearRecovery ? "history.confirmClearRecoveryCopies" : "history.clearRecoveryCopies")
: tr(actionConfirmClearRecovery ? "history.confirmTrashRecoveryCopies" : "history.trashRecoveryCopies")}
</button>
)}
{isTrash && sessions.length > 0 && (
<button
className={`chip history-clear${actionConfirmClearTrash ? " history-clear--confirm" : ""}`}
@@ -426,9 +493,9 @@ export function HistoryPanel({
<div className="mem-empty">{tr("history.noResults")}</div>
) : (
groups.map((g) => (
<section className="mem-section" key={g.label}>
<section className="mem-section" key={`${g.recovered ? "recovered" : "normal"}-${g.label}`}>
<div className="mem-section__title hist-group__title">
<span>{g.label}</span>
<span>{g.recovered ? `${tr("history.recoveryCopiesGroup")} · ${g.label}` : g.label}</span>
<span className="hist-group__count">{g.items.length}</span>
</div>
{g.items.map((s) => {
@@ -470,6 +537,7 @@ export function HistoryPanel({
{!isTrash && s.current && <span className="hist-item__badge hist-item__badge--current">{tr("history.current")}</span>}
{!isTrash && !s.current && s.open && <span className="hist-item__badge hist-item__badge--open">{tr("history.open")}</span>}
{isTrash && <span className="hist-item__badge hist-item__badge--deleted">{tr("history.deleted")}</span>}
{s.recovered && <span className="hist-item__badge">{tr("recovery.badge")}</span>}
{sessionLocation(s, tr) && <span className="hist-item__scope">{sessionLocation(s, tr)}</span>}
<span className="hist-item__metaspacer" />
<span className="hist-item__stat">{tr(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns })}</span>
@@ -557,6 +557,7 @@ export function TurnActions({
actionPending = false,
rewindDisabled = false,
hoverMenus = false,
isLastTurn = false,
}: {
text: string;
turn?: number;
@@ -567,6 +568,8 @@ export function TurnActions({
actionPending?: boolean;
rewindDisabled?: boolean;
hoverMenus?: boolean;
/** true when this is the last user turn — disables "summarize after" */
isLastTurn?: boolean;
}) {
const t = useT();
const [confirmScope, setConfirmScope] = useState<MessageActionScope | null>(null);
@@ -577,6 +580,9 @@ export function TurnActions({
if ((scope === "fork" || scope === "summ-from" || scope === "conversation") && !checkpoint.canConversation) {
return t("rewind.disabledNoBoundary");
}
if (scope === "summ-from" && isLastTurn) {
return t("rewind.disabledNoLater");
}
if (scope === "summ-upto") {
if (!checkpoint.canConversation) return t("rewind.disabledNoBoundary");
if ((turn ?? 0) <= 0) return t("rewind.disabledNoEarlier");
+17 -27
View File
@@ -123,16 +123,20 @@ function topicIsActive(node: ProjectNode, activeScope?: string, activeWorkspaceR
);
}
function topicMetaLine(node: ProjectNode, t: Translator, compact = false): string {
export function projectTreeTopicMetaLine(node: ProjectNode, t: Translator, compact = false): string {
const parts: string[] = [];
const turns = node.turns ?? 0;
if (turns > 0) parts.push(t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns }));
const activityAt = node.lastActivityAt || node.createdAt || 0;
if (activityAt) parts.push(topicActivityLabel(activityAt, t, compact));
if (parts.length === 0) parts.push(t("projectTree.justNow"));
if (parts.length === 0) parts.push(t("projectTree.previously"));
return parts.join(" · ");
}
function topicUnknownTimeLabel(node: ProjectNode, t: Translator): string {
return topicActivityAt(node) ? "" : t("projectTree.previously");
}
const topicStatusLabels: Record<ProjectTopicStatus, DictKey> = {
thinking: "projectTree.status.thinking",
streaming: "projectTree.status.streaming",
@@ -1050,8 +1054,10 @@ export function ProjectTree({
const filtered = tree
.map(filterNode)
.filter((node): node is ProjectNode => node !== null);
return compactTopics ? arrangeWorkbenchTree(filtered, workbenchOrganizeMode, workbenchSortMode) : filtered;
}, [compactTopics, query, tree, timeFilter, workbenchOrganizeMode, workbenchSortMode]);
if (compactTopics) return arrangeWorkbenchTree(filtered, workbenchOrganizeMode, workbenchSortMode);
if (creationTopics) return arrangeWorkbenchTree(filtered, "project", "updated");
return filtered;
}, [compactTopics, creationTopics, query, tree, timeFilter, workbenchOrganizeMode, workbenchSortMode]);
const workbenchTreeSections = useMemo<WorkbenchTreeSections>(() => {
if (!compactTopics) return { pinned: [], projects: visibleTree };
@@ -1126,11 +1132,12 @@ export function ProjectTree({
const accentStyle = projectAccentStyle(node.projectColor, scope === "global" ? "var(--project-tree-global-accent)" : undefined);
const active = topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath);
const label = (node.label || node.topicId || "Untitled").replace(/^●\s*/, "");
const conflictCopyLabel = isSessionNode && node.recovered ? t("recovery.badge") : "";
const activityAt = node.lastActivityAt || node.createdAt || 0;
const sideTimeVisible = compactTopics || creationTopics;
const timeLabel = sideTimeVisible && activityAt ? topicActivityLabel(activityAt, t, true) : "";
const timeLabel = sideTimeVisible ? (activityAt ? topicActivityLabel(activityAt, t, true) : topicUnknownTimeLabel(node, t)) : "";
const exactTimeLabel = sideTimeVisible && activityAt ? topicActivityDateLabel(activityAt) : "";
const meta = topicMetaLine(node, t, compactTopics);
const meta = projectTreeTopicMetaLine(node, t, compactTopics);
const status = topicStatus(node);
const statusLabel = topicStatusLabel(node, t);
const showStatusInSide = status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job";
@@ -1140,15 +1147,8 @@ export function ProjectTree({
const imSourceLabel = imSource?.label || "";
const imSourceTitle = imSourceLabel ? t("msg.fromIm", { source: imSourceLabel }) : "";
const imSourcePlatform = (imSource?.platform || "im").replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "im";
const recovered = Boolean(node.recovered);
const recoveryTitle = recovered
? [
t("recovery.branch"),
node.recoveryReason ? t("recovery.reason", { reason: node.recoveryReason }) : "",
node.recoveryDigest ? t("recovery.digest", { digest: node.recoveryDigest.slice(0, 12) }) : "",
].filter(Boolean).join(" · ")
: "";
const title = [label, recoveryTitle, imSourceTitle, statusLabel, meta, exactTimeLabel].filter(Boolean).join(" · ");
const conflictCopyTitle = isSessionNode && node.recovered ? t("recovery.branch") : "";
const title = [label, conflictCopyTitle, imSourceTitle, statusLabel, meta, exactTimeLabel].filter(Boolean).join(" · ");
const topicMenuOpen = !isSessionNode && menuTopic === topicId;
const pinned = Boolean(node.pinned);
const pinLabel = t(pinned ? "projectTree.unpinTopic" : "projectTree.pinTopic");
@@ -1225,7 +1225,7 @@ export function ProjectTree({
}
const row = (
<div
className={`project-tree__topic${scopeClass}${isSessionNode ? " project-tree__topic--session" : ""}${active ? " project-tree__topic--active" : ""}${node.running ? " project-tree__topic--running" : ""}${status ? ` project-tree__topic--status-${status}` : ""}${unread ? " project-tree__topic--unread" : ""}${!isSessionNode && pinned ? " project-tree__topic--pinned" : ""}${recovered ? " project-tree__topic--recovered" : ""}${topicMenuOpen ? " project-tree__topic--menu-open" : ""}${sideTimeVisible && (timeLabel || showStatusInSide) ? " project-tree__topic--with-side" : meta ? " project-tree__topic--has-meta" : ""}${imSource ? " project-tree__topic--im-source" : ""}${shortcutIndex > 0 ? " project-tree__topic--show-shortcut" : ""}`}
className={`project-tree__topic${scopeClass}${isSessionNode ? " project-tree__topic--session" : ""}${active ? " project-tree__topic--active" : ""}${node.running ? " project-tree__topic--running" : ""}${status ? ` project-tree__topic--status-${status}` : ""}${unread ? " project-tree__topic--unread" : ""}${!isSessionNode && pinned ? " project-tree__topic--pinned" : ""}${topicMenuOpen ? " project-tree__topic--menu-open" : ""}${sideTimeVisible && (timeLabel || showStatusInSide) ? " project-tree__topic--with-side" : meta ? " project-tree__topic--has-meta" : ""}${imSource ? " project-tree__topic--im-source" : ""}${shortcutIndex > 0 ? " project-tree__topic--show-shortcut" : ""}`}
style={accentStyle}
onContextMenu={isSessionNode ? undefined : openTopicMenu}
>
@@ -1267,7 +1267,7 @@ export function ProjectTree({
>
<span className="project-tree__topic-copy">
<span className="project-tree__topic-heading">
<span className="project-tree__topic-label">{label}</span>
<span className="project-tree__topic-label">{conflictCopyLabel ? `${label} · ${conflictCopyLabel}` : label}</span>
{imSource && (
<span
className={`project-tree__topic-im project-tree__topic-im--${imSourcePlatform}`}
@@ -1279,11 +1279,6 @@ export function ProjectTree({
</span>
)}
{!compactTopics && statusLabel && <span className={`project-tree__topic-status project-tree__topic-status--${status}`}>{statusLabel}</span>}
{recovered && (
<span className="project-tree__topic-recovery" title={recoveryTitle}>
{t("recovery.badge")}
</span>
)}
</span>
{!compactTopics && !creationTopics && meta && (
<span className="project-tree__topic-meta">
@@ -1307,11 +1302,6 @@ export function ProjectTree({
{meta}
</span>
)}
{compactTopics && recovered && (
<span className="sr-only">
{t("recovery.branch")}
</span>
)}
</button>
{unread && <span className="project-tree__topic-unread-dot" aria-hidden="true" />}
{projectTreeShouldRenderTopicActions(isSessionNode, compactTopics, unread) && (
@@ -10,6 +10,7 @@ export function PromptShelf({
actions,
children,
crumbs,
note,
quickActions,
headerActions,
barRef,
@@ -24,6 +25,9 @@ export function PromptShelf({
actions?: ReactNode;
children?: ReactNode;
crumbs?: ReactNode;
// Rendered between the actions grid and the quick actions; used for
// focus-following detail previews and similar footnotes.
note?: ReactNode;
quickActions?: ReactNode;
headerActions?: ReactNode;
barRef?: RefObject<HTMLDivElement | null>;
@@ -52,6 +56,7 @@ export function PromptShelf({
{crumbs}
{children && <div className="prompt-shelf__body">{children}</div>}
{actions && <div className="prompt-shelf__actions">{actions}</div>}
{note && <div className="prompt-shelf__footnote">{note}</div>}
{quickActions && <div className="prompt-shelf__quick-actions">{quickActions}</div>}
</div>
</div>
@@ -92,6 +97,8 @@ export function PromptAction({
description,
onClick,
ariaLabel,
title,
onHoverChange,
primary = false,
selected = false,
quiet = false,
@@ -102,6 +109,11 @@ export function PromptAction({
description?: ReactNode;
onClick: () => void;
ariaLabel?: string;
// Native tooltip fallback for truncated descriptions.
title?: string;
// Fires on mouse enter/focus (true) and mouse leave/blur (false) so the
// parent can drive a focus-following detail preview.
onHoverChange?: (hovering: boolean) => void;
primary?: boolean;
selected?: boolean;
quiet?: boolean;
@@ -121,6 +133,11 @@ export function PromptAction({
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel}
title={title}
onMouseEnter={onHoverChange ? () => onHoverChange(true) : undefined}
onMouseLeave={onHoverChange ? () => onHoverChange(false) : undefined}
onFocus={onHoverChange ? () => onHoverChange(true) : undefined}
onBlur={onHoverChange ? () => onHoverChange(false) : undefined}
>
{keyLabel && <span className="prompt-action__key">{keyLabel}</span>}
{hasCopy && (
+163 -62
View File
@@ -1,5 +1,5 @@
import { lazy, memo, Suspense, useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent, type ReactNode } from "react";
import { Bot as BotIcon, Check, CheckCircle2, ChevronDown, ChevronUp, Clipboard, GripVertical, KeyRound, Loader2, MessageCircle, Play, QrCode, RefreshCw, Send } from "lucide-react";
import { Bot as BotIcon, Check, CheckCircle2, ChevronDown, ChevronUp, Clipboard, GripVertical, KeyRound, Loader2, MessageCircle, Minus, Play, Plus, QrCode, RefreshCw, RotateCcw, Send } from "lucide-react";
import { asArray } from "../lib/array";
import { useDeferredClose } from "../lib/useMountTransition";
import { app } from "../lib/bridge";
@@ -17,7 +17,7 @@ import {
type ThemeStyle,
} from "../lib/theme";
import { TEXT_SIZES, applyTextSize, getTextSize, type TextSize } from "../lib/textSize";
import { snapZoom, zoomToPercent, saveRestartZoom, getRestartZoom, type ZoomLevel } from "../lib/dpiScale";
import { DEFAULT_ZOOM, MAX_ZOOM, MIN_ZOOM, ZOOM_STEP, snapZoom, zoomToPercent, saveRestartZoom, getRestartZoom, type ZoomLevel } from "../lib/dpiScale";
import {
applyFontFamily,
applyMonoFontFamily,
@@ -102,6 +102,7 @@ export function SettingsPanel({
const [tab, setTab] = useState<SettingsTab>(initialTab === "providers" ? "models" : initialTab ?? "general");
// Play the modal exit animation, then let the parent unmount us.
const { status, requestClose } = useDeferredClose(onClose, 240);
const zoomSaveSeq = useRef(0);
const reload = useCallback(async () => {
setLoadingSettings(true);
@@ -129,6 +130,24 @@ export function SettingsPanel({
setThemeState(nextTheme);
setThemeStyleState(nextStyle);
}, [s?.desktopTheme, s?.desktopThemeStyle]);
useEffect(() => {
if (desktopPlatform !== "windows") return;
let cancelled = false;
void (async () => {
try {
const persisted = await app.GetDesktopZoomFactor();
if (cancelled || typeof persisted !== "number" || !Number.isFinite(persisted)) return;
const snapped = snapZoom(persisted);
saveRestartZoom(snapped);
setZoomPct(zoomToPercent(snapped));
} catch {
// Older mocks or startup races can lack the binding; keep the local fallback.
}
})();
return () => {
cancelled = true;
};
}, [desktopPlatform]);
// apply runs a mutation, re-reads settings, and refreshes the topbar/model.
const apply = useCallback(async (fn: () => Promise<unknown>) => {
@@ -143,11 +162,11 @@ export function SettingsPanel({
setWarning(result.trim());
}
} catch (e) {
setErr(String((e as Error)?.message ?? e));
setErr(formatSettingsError(e, t));
} finally {
setBusy(false);
}
}, [reload, onChanged]);
}, [reload, onChanged, t]);
const backgroundApply = useCallback(async (fn: () => Promise<void>) => {
setErr(null);
setWarning(null);
@@ -156,9 +175,24 @@ export function SettingsPanel({
const next = await reload();
onChanged(next);
} catch (e) {
setErr(String((e as Error)?.message ?? e));
setErr(formatSettingsError(e, t));
}
}, [reload, onChanged]);
}, [reload, onChanged, t]);
const setRestartZoom = useCallback(async (zoom: ZoomLevel) => {
const snapped = snapZoom(zoom);
const seq = ++zoomSaveSeq.current;
setErr(null);
setWarning(null);
setZoomPct(zoomToPercent(snapped));
try {
await app.SetDesktopZoomFactor(snapped);
if (seq === zoomSaveSeq.current) saveRestartZoom(snapped);
} catch (e) {
if (seq !== zoomSaveSeq.current) return;
setErr(formatSettingsError(e, t));
setZoomPct(zoomToPercent(getRestartZoom()));
}
}, [t]);
// Close on Esc
useEffect(() => {
@@ -248,18 +282,7 @@ export function SettingsPanel({
applyTextSize(size);
setTextSizeState(size);
}}
onRestartZoom={async (zoom) => {
const snapped = snapZoom(zoom);
setErr(null);
setWarning(null);
try {
await app.SetDesktopZoomFactor(snapped);
saveRestartZoom(snapped);
setZoomPct(zoomToPercent(snapped));
} catch (e) {
setErr(String((e as Error)?.message ?? e));
}
}}
onRestartZoom={setRestartZoom}
onFontFamily={(font) => {
applyFontFamily(font);
setFontFamilyState(font);
@@ -746,17 +769,38 @@ function sortedJSONValue(value: unknown): unknown {
return value;
}
function validateProviderExtraBodyValue(value: unknown, path = "extra_body"): void {
function formatSettingsError(error: unknown, t: ReturnType<typeof useT>): string {
const msg = String((error as Error)?.message ?? error ?? "").trim();
const unknownModel = /^unknown model (.+)$/i.exec(msg);
if (unknownModel) return t("settings.errorUnknownModel", { model: unknownModel[1] });
const providerNotAdded = /^model (.+) is not available because provider (.+) is not added$/i.exec(msg);
if (providerNotAdded) return t("settings.errorModelProviderMissing", { model: providerNotAdded[1], provider: providerNotAdded[2] });
const providerNoKey = /^model (.+) is not available because provider (.+) has no key$/i.exec(msg);
if (providerNoKey) return t("settings.errorModelProviderNoKey", { model: providerNoKey[1], provider: providerNoKey[2] });
const removeAccessBusy = /^finish or cancel active work using (.+) before removing the provider access$/i.exec(msg);
if (removeAccessBusy) return t("settings.errorRemoveAccessBusy", { provider: removeAccessBusy[1] });
const deleteProviderBusy = /^finish or cancel active work using (.+) before deleting the provider$/i.exec(msg);
if (deleteProviderBusy) return t("settings.errorDeleteProviderBusy", { provider: deleteProviderBusy[1] });
const saveBeforeRemoveAccess = /^save current session before removing provider access: (.+)$/is.exec(msg);
if (saveBeforeRemoveAccess) return t("settings.errorSaveBeforeRemoveAccess", { err: saveBeforeRemoveAccess[1] });
const saveBeforeDeleteProvider = /^save current session before deleting provider: (.+)$/is.exec(msg);
if (saveBeforeDeleteProvider) return t("settings.errorSaveBeforeDeleteProvider", { err: saveBeforeDeleteProvider[1] });
const removeProviderUsed = /^remove provider: (.+) is used by open tabs and no other configured provider exists$/i.exec(msg);
if (removeProviderUsed) return t("settings.errorRemoveProviderNoFallback", { provider: removeProviderUsed[1] });
return msg || t("settings.errorUnknown");
}
function validateProviderExtraBodyValue(value: unknown, path = "extra_body", t?: ReturnType<typeof useT>): void {
if (value === null) {
throw new Error(`${path} cannot contain null`);
throw new Error(t ? t("settings.providerExtraBodyNull", { path }) : `${path} cannot contain null`);
}
if (Array.isArray(value)) {
value.forEach((item, index) => validateProviderExtraBodyValue(item, `${path}[${index}]`));
value.forEach((item, index) => validateProviderExtraBodyValue(item, `${path}[${index}]`, t));
return;
}
if (typeof value === "object") {
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
validateProviderExtraBodyValue(child, `${path}.${key}`);
validateProviderExtraBodyValue(child, `${path}.${key}`, t);
}
}
}
@@ -772,14 +816,14 @@ export function formatProviderExtraBody(extraBody: Record<string, unknown> | nul
return JSON.stringify(sortedJSONValue(cleaned), null, 2);
}
export function parseProviderExtraBody(raw: string): Record<string, unknown> {
export function parseProviderExtraBody(raw: string, t?: ReturnType<typeof useT>): Record<string, unknown> {
const trimmed = raw.trim();
if (!trimmed) return {};
const parsed = JSON.parse(trimmed) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("extra body must be a JSON object");
throw new Error(t ? t("settings.providerExtraBodyObjectRequired") : "extra body must be a JSON object");
}
validateProviderExtraBodyValue(parsed);
validateProviderExtraBodyValue(parsed, "extra_body", t);
const out: Record<string, unknown> = {};
for (const [rawKey, value] of Object.entries(parsed as Record<string, unknown>)) {
const key = rawKey.trim();
@@ -788,6 +832,12 @@ export function parseProviderExtraBody(raw: string): Record<string, unknown> {
return out;
}
export function providerExtraBodyParseError(error: unknown, t: ReturnType<typeof useT>): string {
if (error instanceof SyntaxError) return t("settings.providerExtraBodyError");
const message = String((error as Error)?.message ?? error ?? "").trim();
return message || t("settings.providerExtraBodyError");
}
function providerModelFetchFallbackMessage(error: unknown, t: ReturnType<typeof useT>): string {
const message = String((error as Error)?.message ?? error);
if (/\bstatus\s+(401|403)\b/i.test(message)) {
@@ -4479,7 +4529,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
setGroupModelDraft(group.id, null);
try {
await apply(async () => {
await app.SetProviderKey(apiKeyEnv, value);
await app.SaveProviderKey(apiKeyEnv, value);
try {
const fetched = await app.FetchProviderModels({ ...probe, apiKeyEnv });
if (fetched.length > 0) {
@@ -4585,7 +4635,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
setAdding(null);
}}
onResetPreset={(id) => apply(() => app.ResetProviderPresetAccess(id)).then(() => setAdding(null))}
onAddCustom={(pv) => apply(() => app.SaveProvider(pv)).then(() => setAdding(null))}
onAddCustom={(pv, key) => apply(() => key ? app.SaveProviderWithKey(pv, key) : app.SaveProvider(pv)).then(() => setAdding(null))}
/>
)}
{adding === null && groups.map((group) => (
@@ -4601,7 +4651,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
kinds={s.providerKinds}
onEdit={setEditing}
onCancelEdit={() => setEditing(null)}
onSave={(pv) => apply(() => app.SaveProvider(pv)).then(() => {
onSave={(pv, key) => apply(() => key ? app.SaveProviderWithKey(pv, key) : app.SaveProvider(pv)).then(() => {
setEditing(null);
setGroupModelDraft(group.id, null);
})}
@@ -4705,6 +4755,10 @@ function providerTemplateConflictProviderName(choice: ProviderTemplateChoice): s
function providerPresetDescription(preset: ProviderPresetView, t: ReturnType<typeof useT>): string {
switch (preset.id) {
case "longcat-openai":
return t("settings.addProvider.preset.longcatOpenAIDesc");
case "longcat-anthropic":
return t("settings.addProvider.preset.longcatAnthropicDesc");
case "kimi-cn":
return t("settings.addProvider.preset.kimiCnDesc");
case "kimi-global":
@@ -4811,7 +4865,7 @@ function AddProviderPanel({
onAddPreset: (id: string, key: string) => Promise<void>;
onViewPresetConflict: (providerName: string) => void;
onResetPreset: (id: string) => Promise<void>;
onAddCustom: (p: ProviderView) => void | Promise<void>;
onAddCustom: (p: ProviderView, key?: string) => void | Promise<void>;
}) {
const t = useT();
const templateChoices = useMemo<ProviderTemplateChoice[]>(() => [
@@ -5026,7 +5080,7 @@ function ProviderAccessCard({
kinds: string[];
onEdit: (name: string) => void;
onCancelEdit: () => void;
onSave: (p: ProviderView) => void | Promise<void>;
onSave: (p: ProviderView, key?: string) => void | Promise<void>;
onRefresh: () => void;
onToggleDraftModel: (model: string) => void;
onToggleDraftVision: (model: string) => void;
@@ -5517,7 +5571,7 @@ function ProviderEditor({
kinds: string[];
busy: boolean;
onCancel: () => void;
onSave: (p: ProviderView) => void;
onSave: (p: ProviderView, key?: string) => void | Promise<void>;
onSaveKey?: (apiKeyEnv: string, value: string) => Promise<void>;
onClearKey?: (apiKeyEnv: string) => Promise<void>;
}) {
@@ -5564,9 +5618,9 @@ function ProviderEditor({
const effectiveHeaders = parseProviderHeaders(headersDraft);
const extraBodyParse = useMemo(() => {
try {
return { value: parseProviderExtraBody(extraBodyDraft), error: "" };
} catch {
return { value: {}, error: t("settings.providerExtraBodyError") };
return { value: parseProviderExtraBody(extraBodyDraft, t), error: "" };
} catch (e) {
return { value: {}, error: providerExtraBodyParseError(e, t) };
}
}, [extraBodyDraft, t]);
const effectiveExtraBody = extraBodyParse.value;
@@ -5594,7 +5648,7 @@ function ProviderEditor({
try {
const effectiveApiKeyEnv = providerApiKeyEnvForSave(name, apiKeyEnv, keyDraft);
if (!apiKeyEnv.trim()) setApiKeyEnv(effectiveApiKeyEnv);
if (keyDraft.trim()) await app.SetProviderKey(effectiveApiKeyEnv, keyDraft.trim());
if (keyDraft.trim()) await app.SaveProviderKey(effectiveApiKeyEnv, keyDraft.trim());
const fetched = await app.FetchProviderModels({
name: name.trim() || t("settings.newProviderDraftName"),
builtIn: initial?.builtIn ?? false,
@@ -5642,11 +5696,12 @@ function ProviderEditor({
const save = async () => {
if (extraBodyInvalid) return;
setFetchStatus(null);
setFetchFallback(null);
const ms = parseProviderListInput(models);
const vms = parseProviderListInput(visionModels).filter((model) => ms.includes(model));
const effectiveApiKeyEnv = providerApiKeyEnvForSave(name, apiKeyEnv, keyDraft);
if (keyDraft.trim()) await app.SetProviderKey(effectiveApiKeyEnv, keyDraft.trim());
onSave({
const provider: ProviderView = {
name: name.trim(),
builtIn: initial?.builtIn ?? false,
added: initial?.added ?? true,
@@ -5672,7 +5727,12 @@ function ProviderEditor({
// NormalizeEffort would otherwise silently ignore an unsupported value.
defaultEffort: cleanedSupportedEfforts.length > 0 ? cleanDefaultEffort : "",
modelOverrides: initial?.modelOverrides ?? [],
});
};
try {
await onSave(provider, keyDraft.trim() || undefined);
} catch (e) {
setFetchFallback(String((e as Error)?.message ?? e));
}
};
if (builtIn) {
@@ -6185,7 +6245,7 @@ function HooksSection({ onChanged }: { onChanged: (settings?: SettingsView | nul
const parseHooksEditorJSON = (raw = jsonText): { hooks: HookConfigView[]; text: string } | null => {
try {
const hooks = parseHooksJSON(raw, view?.events ?? []);
const hooks = parseHooksJSON(raw, view?.events ?? [], t);
const text = formatHooksJSON(hooks, view?.events ?? []);
setJsonText(text);
setJsonError(null);
@@ -6376,7 +6436,7 @@ function formatHooksJSON(hooks: HookConfigView[], eventOrder: string[]): string
return JSON.stringify({ hooks: ordered }, null, 2);
}
function parseHooksJSON(raw: string, validEvents: string[]): HookConfigView[] {
function parseHooksJSON(raw: string, validEvents: string[], t: ReturnType<typeof useT>): HookConfigView[] {
const trimmed = raw.trim();
if (!trimmed) return [];
let parsed: unknown;
@@ -6386,21 +6446,21 @@ function parseHooksJSON(raw: string, validEvents: string[]): HookConfigView[] {
throw new Error(String((e as Error)?.message ?? e));
}
if (Array.isArray(parsed)) {
return parsed.map((item) => normalizeHookConfig(parseHookArrayItem(item, validEvents))).filter((h) => h.event);
return parsed.map((item) => normalizeHookConfig(parseHookArrayItem(item, validEvents, t))).filter((h) => h.event);
}
if (!parsed || typeof parsed !== "object") {
throw new Error("expected an object or array");
throw new Error(t("settings.hooksJsonExpectedObjectArray"));
}
const obj = parsed as Record<string, unknown>;
const hooksValue = obj.hooks && typeof obj.hooks === "object" && !Array.isArray(obj.hooks) ? obj.hooks : obj;
return flattenHooksMap(hooksValue as Record<string, unknown>, validEvents);
return flattenHooksMap(hooksValue as Record<string, unknown>, validEvents, t);
}
function parseHookArrayItem(item: unknown, validEvents: string[]): HookConfigView {
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("hook item must be an object");
function parseHookArrayItem(item: unknown, validEvents: string[], t: ReturnType<typeof useT>): HookConfigView {
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(t("settings.hooksJsonItemObject"));
const obj = item as Record<string, unknown>;
const event = stringField(obj, "event") || "PreToolUse";
if (validEvents.length > 0 && !validEvents.includes(event)) throw new Error(`unknown hook event ${event}`);
if (validEvents.length > 0 && !validEvents.includes(event)) throw new Error(t("settings.hooksJsonUnknownEvent", { event }));
return {
event,
match: stringField(obj, "match"),
@@ -6411,14 +6471,14 @@ function parseHookArrayItem(item: unknown, validEvents: string[]): HookConfigVie
};
}
function flattenHooksMap(hooks: Record<string, unknown>, validEvents: string[]): HookConfigView[] {
function flattenHooksMap(hooks: Record<string, unknown>, validEvents: string[], t: ReturnType<typeof useT>): HookConfigView[] {
const valid = new Set(validEvents);
const out: HookConfigView[] = [];
for (const [event, value] of Object.entries(hooks)) {
if (valid.size > 0 && !valid.has(event)) throw new Error(`unknown hook event ${event}`);
if (valid.size > 0 && !valid.has(event)) throw new Error(t("settings.hooksJsonUnknownEvent", { event }));
const items = Array.isArray(value) ? value : [value];
for (const item of items) {
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(`hook ${event} item must be an object`);
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(t("settings.hooksJsonEventItemObject", { event }));
const obj = item as Record<string, unknown>;
out.push(normalizeHookConfig({
event,
@@ -6582,6 +6642,15 @@ function AppearanceSection({
const themeOptions: Theme[] = ["auto", "light", "dark"];
const availableFontFamilies = useMemo(() => getAvailableFontFamilies(fontFamily), [fontFamily]);
const availableMonoFontFamilies = useMemo(() => getAvailableMonoFontFamilies(monoFontFamily), [monoFontFamily]);
const zoomMinPct = zoomToPercent(MIN_ZOOM);
const zoomMaxPct = zoomToPercent(MAX_ZOOM);
const zoomStepPct = Math.round(ZOOM_STEP * 100);
const zoomProgressPct = Math.min(100, Math.max(0, ((zoomPct - zoomMinPct) / (zoomMaxPct - zoomMinPct)) * 100));
const canDecreaseZoom = zoomPct > zoomMinPct;
const canIncreaseZoom = zoomPct < zoomMaxPct;
const setZoomPercent = (pct: number) => {
void onRestartZoom(pct / 100);
};
return (
<SettingsSection title={t("settings.appearance")}>
<SettingsField label={t("settings.theme")}>
@@ -6647,30 +6716,62 @@ function AppearanceSection({
{showDisplayZoom && (
<SettingsField label={t("settings.displayZoom")}>
<div className="zoom-slider-wrap">
<div className="zoom-slider__value">{zoomPct}%</div>
<div className="zoom-slider__head">
<div className="zoom-slider__value">{zoomPct}%</div>
<div className="zoom-stepper">
<button
type="button"
className="zoom-stepper__btn"
aria-label={t("settings.displayZoomDecrease")}
title={t("settings.displayZoomDecrease")}
disabled={!canDecreaseZoom}
onClick={() => setZoomPercent(zoomPct - zoomStepPct)}
>
<Minus size={13} aria-hidden="true" />
</button>
<button
type="button"
className="zoom-stepper__reset"
aria-label={t("settings.displayZoomReset")}
title={t("settings.displayZoomReset")}
disabled={zoomPct === zoomToPercent(DEFAULT_ZOOM)}
onClick={() => { void onRestartZoom(DEFAULT_ZOOM); }}
>
<RotateCcw size={12} aria-hidden="true" />
<span>100%</span>
</button>
<button
type="button"
className="zoom-stepper__btn"
aria-label={t("settings.displayZoomIncrease")}
title={t("settings.displayZoomIncrease")}
disabled={!canIncreaseZoom}
onClick={() => setZoomPercent(zoomPct + zoomStepPct)}
>
<Plus size={13} aria-hidden="true" />
</button>
</div>
</div>
<div className="zoom-slider-row">
<span className="zoom-slider__label">50%</span>
<span className="zoom-slider__label">{zoomMinPct}%</span>
<div className="slider-track">
<div className="slider-track__bg" />
<div
className="slider-track__fill"
style={{ width: `calc(${((zoomPct - 50) / 150) * 100}% + 15px)` }}
style={{ width: `calc(${zoomProgressPct}% + 15px)` }}
/>
<div className="slider-thumb" style={{ left: `${((zoomPct - 50) / 150) * 100}%` }}>
<div className="slider-thumb__left" />
<div className="slider-thumb__mid" />
<div className="slider-thumb__right" />
</div>
<div className="slider-thumb" style={{ left: `${zoomProgressPct}%` }} />
<input
aria-label={t("settings.displayZoom")}
type="range"
min={50}
max={200}
step={5}
min={zoomMinPct}
max={zoomMaxPct}
step={zoomStepPct}
value={zoomPct}
onChange={(e) => { void onRestartZoom(Number(e.target.value) / 100); }}
onChange={(e) => setZoomPercent(Number(e.target.value))}
/>
</div>
<span className="zoom-slider__label">200%</span>
<span className="zoom-slider__label">{zoomMaxPct}%</span>
</div>
</div>
</SettingsField>
+11 -6
View File
@@ -22,9 +22,10 @@ function nowRate(u?: WireUsage): string | null {
}
// avgRate is the SESSION-AGGREGATE cache-hit % — Σhit/Σ(hit+miss) across every
// turn — the steadier, cost-oriented number that matches the legacy dashboard.
// On a non-compacting DeepSeek session it trails nowRate (early cold-start turns
// drag the average down); it overtakes only when compaction craters single turns.
// turn — but scoped to the EXECUTOR agent only: the wire session counters come
// from the main agent and exclude subagent/planner/auxiliary requests. It is
// only the pre-first-refresh fallback; the authoritative all-sources number is
// contextAvgRate below, so the "session average" label reports one scope.
function avgRate(u?: WireUsage): string | null {
if (!u) return null;
const denom = u.sessionCacheHitTokens + u.sessionCacheMissTokens;
@@ -32,8 +33,10 @@ function avgRate(u?: WireUsage): string | null {
}
// contextAvgRate computes the session-aggregate cache-hit % from ContextInfo
// cache tokens (loaded from persisted telemetry on session resume). Used as a
// fallback when no live WireUsage is available yet.
// cache tokens — the tab telemetry that accumulates ALL request sources
// (executor, subagents, planner, auxiliary calls), refreshed at turn
// boundaries. Preferred over avgRate: it matches the 会话费用 tooltip's
// "includes main model, subagents and auxiliary calls" scope.
function contextAvgRate(ctx: ContextInfo): string | null {
const hit = ctx.cacheHitTokens ?? 0;
const miss = ctx.cacheMissTokens ?? 0;
@@ -186,7 +189,9 @@ export function StatusBar({
const compactNear = pct !== null && compactPct !== null && pct >= Math.max(0, compactPct - 10);
const compactReached = pct !== null && compactPct !== null && pct >= compactPct;
const nowPct = nowRate(usage);
const avgPct = avgRate(usage) ?? contextAvgRate(context);
// All-sources telemetry first; the executor-only live counters only bridge
// the gap before the first ContextInfo refresh of a fresh session.
const avgPct = contextAvgRate(context) ?? avgRate(usage);
const turnCostLabel = formatMoneyLocalized(turnCost, currency, { locale });
const costLabel = formatMoneyLocalized(cost, currency, { locale });
const displayWorkspacePath = (workspacePath || workspaceName || "").trim();
+76 -9
View File
@@ -1,5 +1,5 @@
import { memo, useEffect, useRef, useState, type ReactNode } from "react";
import { ChevronRight } from "lucide-react";
import { ChevronRight, Compass } from "lucide-react";
import { CodeViewer } from "./CodeViewer";
import { DiffView } from "./DiffView";
import { useT } from "../lib/i18n";
@@ -16,6 +16,8 @@ const SUBAGENT_TOOLS = new Set(["task", "run_skill", "explore", "research", "rev
/** Lines shown by default in a shell output block before the "show all" button. */
const SHELL_PREVIEW_LINES = 10;
const ERROR_SUMMARY_MAX_CHARS = 140;
const ERROR_DETAILS_THRESHOLD = 220;
function pretty(json: string): string {
try {
@@ -30,6 +32,41 @@ function formatToolDuration(ms?: number): string {
return `${Math.round(ms)} ms`;
}
function normalizeErrorText(text: string): string {
return text.replace(/\r\n/g, "\n").trim();
}
function withoutErrorPrefix(text: string): string {
return normalizeErrorText(text).replace(/^error:\s*/i, "");
}
function toolOutputDuplicatesError(output: string | undefined, error: string | undefined): boolean {
if (!output || !error) return false;
const normalizedOutput = normalizeErrorText(output);
const normalizedError = normalizeErrorText(error);
if (!normalizedOutput || !normalizedError) return false;
return normalizedOutput === normalizedError || withoutErrorPrefix(normalizedOutput) === withoutErrorPrefix(normalizedError);
}
function summarizeToolError(error: string, receiptMismatchText: string): string {
const text = withoutErrorPrefix(error);
if (!text) return "";
if (/has no matching successful receipt/i.test(text)) {
return receiptMismatchText;
}
const firstLine = text.split("\n")[0]?.trim() ?? "";
if (firstLine.length <= ERROR_SUMMARY_MAX_CHARS) return firstLine;
return `${firstLine.slice(0, ERROR_SUMMARY_MAX_CHARS - 1)}…`;
}
function errorNeedsDetails(error: string, summary: string): boolean {
const normalizedError = withoutErrorPrefix(error);
if (!normalizedError) return false;
return normalizedError.includes("\n") ||
normalizedError.length > ERROR_DETAILS_THRESHOLD ||
(summary !== "" && normalizedError !== summary);
}
/** Returns the first n lines of text and the total line count. */
function splitPreview(text: string, n: number): { preview: string; total: number; hasMore: boolean } {
const lines = text.split("\n");
@@ -60,12 +97,14 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
const openRef = useRef(open);
openRef.current = open;
const [showAll, setShowAll] = useState(false);
const [showErrorDetails, setShowErrorDetails] = useState(false);
// Lazy-load full tool data from the backend when the card is expanded and
// the in-memory copy was archived for memory efficiency.
const [fullData, setFullData] = useState<{ args: string; output?: string } | null>(null);
const archivedWithoutFullData = Boolean(item.dataArchived && !fullData);
const effectiveArgs = archivedWithoutFullData ? "" : fullData?.args ?? item.args;
const effectiveOutput = fullData?.output ?? item.output;
const displayOutput = toolOutputDuplicatesError(effectiveOutput, item.error) ? undefined : effectiveOutput;
const previewDiff = item.fileDiff?.diff ? item.fileDiff : undefined;
const diffs = previewDiff || archivedWithoutFullData ? [] : diffsFor(item.name, effectiveArgs);
const subject = fullData ? subjectOf(item.name, effectiveArgs) : item.subject || subjectOf(item.name, effectiveArgs);
@@ -78,12 +117,15 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
// else folds its args/output away by default. Open while running so the
// user sees progress; closed by default once settled.
const hasArchivedOnDemandBody = Boolean(item.dataArchived && tabId);
const hasArgsOrOutput = !previewDiff && diffs.length === 0 && (!!effectiveArgs || !!effectiveOutput || hasArchivedOnDemandBody);
const hasArgsOrOutput = !previewDiff && diffs.length === 0 && (!!effectiveArgs || !!displayOutput || hasArchivedOnDemandBody);
// Shell output: split into preview + "show all" toggle.
const shellOutput = item.isShell && effectiveOutput ? effectiveOutput : null;
const shellOutput = item.isShell && displayOutput ? displayOutput : null;
const shellPreview = shellOutput ? splitPreview(shellOutput, SHELL_PREVIEW_LINES) : null;
const hasBody = Boolean(previewDiff || diffs.length || hasNested || shellPreview || (!shellPreview && hasArgsOrOutput) || item.error);
const errorText = item.error ? normalizeErrorText(item.error) : "";
const errorSummary = errorText ? summarizeToolError(errorText, t("tool.errorReceiptMismatch")) : "";
const hasErrorDetails = errorText ? errorNeedsDetails(errorText, errorSummary) : false;
useEffect(() => {
if (!open || !item.dataArchived || fullData || !tabId) return;
let cancelled = false;
@@ -91,7 +133,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]);
@@ -111,7 +153,7 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
item.readOnly && !hasNested && item.status !== "error" && item.status !== "stopped";
const duration = item.status === "running" ? "" : formatToolDuration(item.durationMs);
const summary = item.status === "running" ? "" : item.summary || summarizeFileDiff(item.fileDiff) || (archivedWithoutFullData ? "" : summarize(item.name, effectiveArgs, effectiveOutput, item.error));
const summary = item.status === "running" ? "" : item.summary || summarizeFileDiff(item.fileDiff) || (item.error ? errorSummary : archivedWithoutFullData ? "" : summarize(item.name, effectiveArgs, displayOutput, item.error));
// GSAP-driven collapse/expand for tool body
const toolBodyRef = useRef<HTMLDivElement>(null);
@@ -127,7 +169,12 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
aria-expanded={hasBody ? open : undefined}
>
<span className="tool__label-group">
{hasNested && <span className="tool__nested-count">⊞{nested.length}</span>}
{hasNested && (
<span className="tool__nested-count" aria-label={`${nested.length} nested tool calls`}>
<Compass className="tool__nested-icon" size={14} strokeWidth={2} aria-hidden="true" />
<span>{nested.length}</span>
</span>
)}
{item.status === "error" && <span className="tool__status-icon tool__status-icon--err">✗</span>}
{item.status === "done" && <span className="tool__status-icon tool__status-icon--ok">✓</span>}
{item.status === "stopped" && <span className="tool__status-icon tool__status-icon--stopped">—</span>}
@@ -202,16 +249,36 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
{!shellPreview && hasArgsOrOutput && (
<>
{effectiveArgs && <CodeViewer value={pretty(effectiveArgs)} language="json" maxHeight={180} />}
{effectiveOutput && (
{displayOutput && (
<>
<CodeViewer value={effectiveOutput} maxHeight={280} />
<CodeViewer value={displayOutput} maxHeight={280} />
{item.truncated && <div className="tool__note">{t("tool.truncated")}</div>}
</>
)}
</>
)}
{item.error && <div className="tool__err">{item.error}</div>}
{errorText && (
<div className={`tool__err${hasErrorDetails ? " tool__err--compact" : ""}`}>
{hasErrorDetails ? (
<>
<div className="tool__err-summary">{errorSummary || t("tool.error")}</div>
<button
type="button"
className="tool__err-toggle"
onClick={() => setShowErrorDetails((value) => !value)}
aria-expanded={showErrorDetails}
>
<ChevronRight className={`tool__err-toggle-icon${showErrorDetails ? " tool__err-toggle-icon--open" : ""}`} size={12} aria-hidden="true" />
<span>{showErrorDetails ? t("tool.hideErrorDetails") : t("tool.showErrorDetails")}</span>
</button>
{showErrorDetails && <div className="tool__err-details">{errorText}</div>}
</>
) : (
errorText
)}
</div>
)}
</div>
</div>
);
+28 -9
View File
@@ -14,7 +14,8 @@ import { isReadOnlyTool } from "../lib/useController";
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
import { useEntranceAnimation } from "../lib/useEntranceAnimation";
import { useScrollManager } from "../lib/useScrollManager";
import { buildStepGroups, buildTurnGroups, compactQuestionText, createWarmLayerState, questionAnchorId, questionTurnsById, scrollVersion, warmColdPageForTurn, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination, warmUserPreview, type QuestionAnchor, type TurnGroup, type WarmLayerState } from "../lib/transcriptGrouping";
import { buildStepGroups, buildTurnGroups, compactQuestionText, createWarmLayerState, lastQuestionTurn, questionAnchorId, questionTurnsById, scrollVersion, warmColdPageForTurn, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination, warmUserPreview, type QuestionAnchor, type TurnGroup, type WarmLayerState } from "../lib/transcriptGrouping";
import { appendTurnActionCopyText } from "../lib/turnActionCopy";
type ToolItem = Extract<Item, { kind: "tool" }>;
type AssistantItem = Extract<Item, { kind: "assistant" }>;
@@ -314,6 +315,7 @@ export function Transcript({
}, [openAction]);
const userTurn = useMemo(() => questionTurnsById(questions), [questions]);
const lastTurn = useMemo(() => lastQuestionTurn(questions, userTurn), [questions, userTurn]);
const checkpointsByTurn = useMemo(() => new Map(checkpoints.map((checkpoint) => [checkpoint.turn, checkpoint])), [checkpoints]);
// ── JumpBar integration ───────────────────────────────────────────────────
@@ -390,6 +392,7 @@ export function Transcript({
actionPending={actionPending}
rewindDisabled={rewindDisabled}
hoverMenus={actionHoverMenus}
isLastTurn={turn === lastTurn}
onRewind={(targetTurn, scope) => {
onRewind?.(targetTurn, scope);
setOpenAction(null);
@@ -500,7 +503,7 @@ export function Transcript({
/>,
);
if (!it.streaming && it.text.trim() !== "") {
actionText = it.text;
actionText = appendTurnActionCopyText(actionText, it.text);
actionReady = true;
}
}
@@ -573,7 +576,7 @@ export function Transcript({
case "assistant":
out.push(<LiveAssistantMessage key={it.id} item={it as AssistantItem} defaultExpanded={false} creationMode={creationMode} />);
if (!it.streaming && it.text.trim() !== "") {
actionText = it.text;
actionText = appendTurnActionCopyText(actionText, it.text);
actionReady = true;
}
break;
@@ -584,7 +587,7 @@ export function Transcript({
out.push(<ToolCard key={it.id} item={it} subcalls={subcallsByParent.get(it.id)} tabId={tabId} />);
break;
case "phase": out.push(<PhaseCard key={it.id} text={it.text} />); break;
case "notice": out.push(<NoticeCard key={it.id} level={it.level} text={it.text} />); break;
case "notice": out.push(<NoticeCard key={it.id} level={it.level} text={it.text} detail={it.detail} />); break;
case "compaction": out.push(<CompactionCard key={it.id} item={it} />); break;
}
}
@@ -593,7 +596,7 @@ export function Transcript({
if (!running) pushTurnActions();
}
return out;
}, [hotStartIdx, items, openAction, actionPending, rewindDisabled, running, onEditPrompt, onRewind, subcallsByParent, userTurn, checkpointsByTurn, displayMode, stepGroups, tabId, actionHoverMenus, creationMode]);
}, [hotStartIdx, items, openAction, actionPending, rewindDisabled, running, onEditPrompt, onRewind, subcallsByParent, userTurn, checkpointsByTurn, displayMode, stepGroups, tabId, actionHoverMenus, creationMode, lastTurn]);
// ── Assemble rendered output ──────────────────────────────────────────────
// Warm/cold zone is a separate memo'd WarmZone component so streaming tokens
@@ -631,6 +634,7 @@ export function Transcript({
warmSubcalls={subcallsByParent}
warmUserTurn={userTurn}
warmCheckpoints={checkpointsByTurn}
warmLastTurn={lastTurn}
warmOpenAction={openAction}
warmActionPending={actionPending}
warmRewindDisabled={rewindDisabled}
@@ -686,6 +690,7 @@ const WarmZone = memo(function WarmZone({
warmSubcalls,
warmUserTurn,
warmCheckpoints,
warmLastTurn,
warmOpenAction,
warmActionPending,
warmRewindDisabled,
@@ -708,6 +713,7 @@ const WarmZone = memo(function WarmZone({
warmSubcalls: ReadonlyMap<string, ToolItem[]>;
warmUserTurn: ReadonlyMap<string, number>;
warmCheckpoints: ReadonlyMap<number, CheckpointMeta>;
warmLastTurn?: number;
warmOpenAction: OpenTurnAction | null;
warmActionPending: boolean;
warmRewindDisabled: boolean;
@@ -773,6 +779,7 @@ const WarmZone = memo(function WarmZone({
onEdit={warmOnEdit}
tabId={tabId}
creationMode={creationMode}
lastTurn={warmLastTurn}
/>
</WarmTurnCard>,
);
@@ -820,6 +827,7 @@ function WarmTurnItems({
onEdit,
tabId,
creationMode = false,
lastTurn,
}: {
startIdx: number;
endIdx: number;
@@ -836,6 +844,7 @@ function WarmTurnItems({
onEdit?: (turn: number, displayText: string, submitText?: string) => boolean | void | Promise<boolean | void>;
tabId?: string;
creationMode?: boolean;
lastTurn?: number;
}) {
const nodes: React.ReactNode[] = [];
let actionText = "";
@@ -856,6 +865,7 @@ function WarmTurnItems({
actionPending={actionPending}
rewindDisabled={rewindDisabled}
hoverMenus={actionHoverMenus}
isLastTurn={turn === lastTurn}
onRewind={(targetTurn, scope) => {
onRewind?.(targetTurn, scope);
setOpenAction(null);
@@ -926,7 +936,7 @@ function WarmTurnItems({
case "assistant": {
nodes.push(<AssistantMessage key={it.id} item={it} defaultExpanded={false} creationMode={creationMode} />);
if (!it.streaming && it.text.trim() !== "") {
actionText = it.text;
actionText = appendTurnActionCopyText(actionText, it.text);
actionReady = true;
}
break;
@@ -939,7 +949,7 @@ function WarmTurnItems({
break;
}
case "phase": nodes.push(<PhaseCard key={it.id} text={it.text} />); break;
case "notice": nodes.push(<NoticeCard key={it.id} level={it.level} text={it.text} />); break;
case "notice": nodes.push(<NoticeCard key={it.id} level={it.level} text={it.text} detail={it.detail} />); break;
case "compaction": nodes.push(<CompactionCard key={it.id} item={it} />); break;
}
}
@@ -1258,11 +1268,20 @@ function PhaseCard({ text }: { text: string }) {
return <div className="phase" data-entrance="true"><ProcessPhaseIcon size={12} /><span>{text}</span></div>;
}
function NoticeCard({ level, text }: { level: NoticeItem["level"]; text: string }) {
function NoticeCard({ level, text, detail }: { level: NoticeItem["level"]; text: string; detail?: string }) {
const t = useT();
return (
<div className={`notice-line notice-line--${level}`} data-entrance="true">
<span className="notice-line__icon">{level === "warn" ? "⚠ " : "ℹ "}</span>
<span className="notice-line__text">{text}</span>
<div className="notice-line__text">
{text}
{detail ? (
<details className="notice-line__details">
<summary>{t("notice.details")}</summary>
<div>{detail}</div>
</details>
) : null}
</div>
</div>
);
}
@@ -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],
);
+190 -15
View File
@@ -294,12 +294,14 @@ export interface AppBindings {
SetAutoPlan(mode: string): Promise<void>;
SetDefaultToolApprovalMode(mode: string): Promise<void>;
SaveProvider(p: ProviderView): Promise<void>;
SaveProviderWithKey(p: ProviderView, key: string): Promise<string>;
AddOfficialProviderAccess(kind: string, key: string): Promise<string>;
AddProviderPresetAccess(id: string, key: string): Promise<string>;
ResetProviderPresetAccess(id: string): Promise<void>;
FetchProviderModels(p: ProviderView): Promise<string[]>;
DeleteProvider(name: string): Promise<void>;
RemoveProviderAccess(name: string): Promise<void>;
SaveProviderKey(apiKeyEnv: string, value: string): Promise<string>;
SetProviderKey(apiKeyEnv: string, value: string): Promise<string>;
ClearProviderKey(apiKeyEnv: string): Promise<void>;
SetPermissionMode(mode: string): Promise<void>;
@@ -572,6 +574,16 @@ export function onFilesDropped(cb: (paths: string[]) => void): () => void {
// onReady subscribes to the agent:ready event fired when boot.Build completes.
// The frontend re-fetches Meta/Context/History when this lands.
// onRuntimeRebuilt fires when a tab's controller is replaced in place
// (model/effort/token-mode switch, clear-while-running). The rebuilt
// controller restarts prompt ids, so per-tab id-keyed state must reset.
export function onRuntimeRebuilt(cb: (tabId?: string) => void): () => void {
if (realApp() && typeof window !== "undefined" && window.runtime) {
return window.runtime.EventsOn("runtime:rebuilt", (tabId?: unknown) => cb(typeof tabId === "string" ? tabId : undefined));
}
return () => {};
}
export function onReady(cb: (tabId?: string) => void): () => void {
if (realApp() && typeof window !== "undefined" && window.runtime) {
return window.runtime.EventsOn("agent:ready", (tabId?: unknown) => cb(typeof tabId === "string" ? tabId : undefined));
@@ -612,7 +624,7 @@ function bridgeBreadcrumb(method: string): string {
return `model ${method}`;
if (/^(SetDesktop|SetCloseBehavior|SetDisplayMode|SetStatusBar|SetExpandThinking|SetAutoPlan|SetDefaultToolApprovalMode|SetMemoryCompilerEnabled|SetReasoningLanguage)/.test(method))
return `settings ${method}`;
if (/^(SaveProvider|AddOfficialProviderAccess|AddProviderPresetAccess|ResetProviderPresetAccess|RemoveProviderAccess|DeleteProvider|SetProviderKey|ClearProviderKey|FetchProviderModels|ConnectKey)/.test(method))
if (/^(SaveProvider|AddOfficialProviderAccess|AddProviderPresetAccess|ResetProviderPresetAccess|RemoveProviderAccess|DeleteProvider|SaveProviderKey|SetProviderKey|ClearProviderKey|FetchProviderModels|ConnectKey)/.test(method))
return `provider ${method}`;
if (/^(CheckUpdate|DownloadUpdate|InstallUpdate|ApplyUpdate|OpenDownloadPage)/.test(method)) return `update ${method}`;
if (/^(AddMCPServer|UpdateMCPServer|RemoveMCPServer|ReconnectMCPServer|ClearMCPServerAuthentication|TrustMCPServerTool|TrustMCPServerTools|UntrustMCPServerTool|SetMCPServer)/.test(method))
@@ -625,6 +637,11 @@ function bridgeBreadcrumb(method: string): string {
return "";
}
function elapsedMs(startedAt: number): number {
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
return Math.max(0, Math.round(now - startedAt));
}
export const app: AppBindings = new Proxy({} as AppBindings, {
get(_t, prop) {
const target = realApp() ?? getMock();
@@ -633,18 +650,26 @@ export const app: AppBindings = new Proxy({} as AppBindings, {
return (...args: unknown[]) => {
const method = String(prop);
const crumb = bridgeBreadcrumb(method);
const startedAt = crumb ? (typeof performance !== "undefined" ? performance.now() : Date.now()) : 0;
if (crumb) addBreadcrumb("bridge", crumb);
try {
const result = (v as (...a: unknown[]) => unknown).apply(target, args);
if (result && typeof (result as Promise<unknown>).then === "function") {
return (result as Promise<unknown>).catch((err) => {
if (crumb) addBreadcrumb("bridge.error", method);
throw err;
});
return (result as Promise<unknown>).then(
(value) => {
if (crumb) addBreadcrumb("bridge", `${crumb} done ms=${elapsedMs(startedAt)}`);
return value;
},
(err) => {
if (crumb) addBreadcrumb("bridge.error", `${method} ms=${elapsedMs(startedAt)}`);
throw err;
},
);
}
if (crumb) addBreadcrumb("bridge", `${crumb} done ms=${elapsedMs(startedAt)}`);
return result;
} catch (err) {
if (crumb) addBreadcrumb("bridge.error", method);
if (crumb) addBreadcrumb("bridge.error", `${method} ms=${elapsedMs(startedAt)}`);
throw err;
}
};
@@ -717,12 +742,14 @@ function browserPlatformOverride(): "darwin" | "windows" | "linux" | "" {
return value === "darwin" || value === "windows" || value === "linux" ? value : "";
}
function mockScenario(): "demo" | "fresh" | "running" | "guidance" {
function mockScenario(): "demo" | "fresh" | "running" | "guidance" | "sandbox_escape" | "notice" {
if (typeof window === "undefined") return "demo";
const value = new URLSearchParams(window.location.search).get("mock")?.trim().toLowerCase();
if (value === "fresh" || value === "empty" || value === "first-run") return "fresh";
if (value === "guidance" || value === "guide" || value === "steer") return "guidance";
if (value === "running" || value === "busy" || value === "streaming") return "running";
if (value === "sandbox_escape" || value === "sandbox-escape" || value === "sandboxescape") return "sandbox_escape";
if (value === "notice" || value === "notices" || value === "notice-preview") return "notice";
return "demo";
}
@@ -766,6 +793,7 @@ function mockPreset(id: string, label: string, description: string, keyEnv: stri
}
const mockKimiAPIModels = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"];
const mockLongCatModels = ["LongCat-2.0"];
const mockMiMoV25Models = ["mimo-v2.5-pro", "mimo-v2.5"];
const mockMiniMaxModels = ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed"];
const mockGLMAPIModels = ["glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7", "glm-4.7-flash", "glm-4.7-flashx", "glm-4.6", "glm-4.5", "glm-4.5-air", "glm-4.5-flash"];
@@ -784,6 +812,8 @@ const mockVercelModels = ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-
const mockOllamaCloudModels = ["glm-5.2", "kimi-k2.7-code", "deepseek-v4-pro", "deepseek-v4-flash", "minimax-m3", "nemotron-3-nano:30b", "qwen3-coder-next"];
const mockProviderPresetTemplates: MockProviderPresetTemplate[] = [
mockPreset("longcat-openai", "LongCat OpenAI", "LongCat Platform OpenAI-compatible endpoint for LongCat-2.0.", "LONGCAT_API_KEY", mockProviderTemplate({ name: "longcat-openai", kind: "openai", baseUrl: "https://api.longcat.chat/openai/v1", modelsUrl: "https://api.longcat.chat/openai/v1/models", models: mockLongCatModels, default: "LongCat-2.0", apiKeyEnv: "LONGCAT_API_KEY", contextWindow: 131072, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
mockPreset("longcat-anthropic", "LongCat Anthropic", "LongCat Platform Anthropic-compatible Messages endpoint for LongCat-2.0.", "LONGCAT_API_KEY", mockProviderTemplate({ name: "longcat-anthropic", kind: "anthropic", baseUrl: "https://api.longcat.chat/anthropic", modelsUrl: "https://api.longcat.chat/anthropic/v1/models", models: mockLongCatModels, default: "LongCat-2.0", apiKeyEnv: "LONGCAT_API_KEY", authHeader: true, contextWindow: 131072, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
mockPreset("kimi-cn", "Kimi CN API", "Moonshot Kimi China OpenAI-compatible API.", "KIMI_API_KEY", mockProviderTemplate({ name: "kimi-cn", kind: "openai", baseUrl: "https://api.moonshot.cn/v1", models: mockKimiAPIModels, visionModels: mockKimiAPIModels, default: "kimi-k2.7-code", apiKeyEnv: "KIMI_API_KEY", balanceUrl: "https://api.moonshot.cn/v1/users/me/balance", contextWindow: 262144, reasoningProtocol: "none" })),
mockPreset("kimi-global", "Kimi Global API", "Moonshot Kimi international OpenAI-compatible API.", "MOONSHOT_API_KEY", mockProviderTemplate({ name: "kimi-global", kind: "openai", baseUrl: "https://api.moonshot.ai/v1", models: mockKimiAPIModels, visionModels: mockKimiAPIModels, default: "kimi-k2.7-code", apiKeyEnv: "MOONSHOT_API_KEY", balanceUrl: "https://api.moonshot.ai/v1/users/me/balance", contextWindow: 262144, reasoningProtocol: "none" })),
mockPreset("kimi-coding-plan", "Kimi Coding Plan", "Kimi Coding Plan via its dedicated Anthropic-compatible endpoint.", "KIMI_CODING_API_KEY", mockProviderTemplate({ name: "kimi-coding-plan", kind: "anthropic", baseUrl: "https://api.kimi.com/coding/", models: ["kimi-for-coding"], visionModels: ["kimi-for-coding"], default: "kimi-for-coding", apiKeyEnv: "KIMI_CODING_API_KEY", headers: { "User-Agent": "claude-code/0.1.0" }, thinking: "adaptive", contextWindow: 262144 })),
@@ -814,8 +844,8 @@ const mockProviderPresetTemplates: MockProviderPresetTemplate[] = [
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("stepfun", "StepFun", "StepFun coding-plan OpenAI-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun", kind: "openai", baseUrl: "https://api.stepfun.com/step_plan/v1", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
mockPreset("stepfun-anthropic", "StepFun Anthropic", "StepFun coding-plan Anthropic-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun-anthropic", kind: "anthropic", baseUrl: "https://api.stepfun.com/step_plan", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", thinking: "adaptive", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
mockPreset("novita", "NovitaAI", "NovitaAI OpenAI-compatible multi-model gateway.", "NOVITA_API_KEY", mockProviderTemplate({ name: "novita", kind: "openai", baseUrl: "https://api.novita.ai/openai/v1", models: mockNovitaModels, default: "zai-org/glm-5.2", apiKeyEnv: "NOVITA_API_KEY" })),
mockPreset("gmi", "GMI Cloud", "GMI Cloud direct multi-model OpenAI-compatible gateway.", "GMI_API_KEY", mockProviderTemplate({ name: "gmi", kind: "openai", baseUrl: "https://api.gmi-serving.com/v1", models: mockGMIModels, default: "zai-org/GLM-5.2-FP8", apiKeyEnv: "GMI_API_KEY", headers: { "User-Agent": "Reasonix" } })),
mockPreset("vercel-ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway via Anthropic-compatible Messages API.", "AI_GATEWAY_API_KEY", mockProviderTemplate({ name: "vercel-ai-gateway", kind: "anthropic", baseUrl: "https://ai-gateway.vercel.sh", models: mockVercelModels, visionModels: ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-4.8", "openai/gpt-5.4", "openai/gpt-5.4-pro", "moonshotai/kimi-k2.7-code"], default: "anthropic/claude-sonnet-4.6", apiKeyEnv: "AI_GATEWAY_API_KEY", authHeader: true, contextWindow: 1000000 })),
@@ -826,7 +856,7 @@ const mockProviderPresetTemplates: MockProviderPresetTemplate[] = [
];
function mockProviderPresetViews(): ProviderPresetView[] {
return mockProviderPresetTemplates.map((template) => ({
return [...mockProviderPresetTemplates].sort((a, b) => mockProviderPresetDisplayRank(a.id) - mockProviderPresetDisplayRank(b.id)).map((template) => ({
id: template.id,
label: template.label,
description: template.description,
@@ -842,6 +872,14 @@ function mockProviderPresetViews(): ProviderPresetView[] {
}));
}
function mockProviderPresetDisplayRank(id: string): number {
if (id === "glm-cn" || id === "zai-global" || id.startsWith("glm-coding-plan-") || id.startsWith("zai-coding-plan-")) return 0;
if (id.startsWith("longcat-")) return 1;
if (id.startsWith("kimi-")) return 2;
if (id.startsWith("minimax-")) return 3;
return 4;
}
function cloneMockProviderTemplate(id: string, key: string): ProviderView | undefined {
const template = mockProviderPresetTemplates.find((candidate) => candidate.id === id);
if (!template) return undefined;
@@ -859,6 +897,8 @@ function makeMockApp(): AppBindings {
const freshMock = scenario === "fresh";
const guidanceMock = scenario === "guidance";
const runningMock = scenario === "running" || guidanceMock;
const sandboxEscapeMock = scenario === "sandbox_escape";
const noticePreviewMock = scenario === "notice";
const mockAttachmentDataURLs = new Map<string, string>();
let cancelled = false;
let pendingAskPreview = false;
@@ -867,6 +907,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.
@@ -966,7 +1007,7 @@ function makeMockApp(): AppBindings {
// Mutable so delete/rename are observable in browser dev.
const sessions: SessionMeta[] = [
{ path: "/mock/sessions/a.jsonl", preview: "fix the login bug in auth.go", turns: 12, createdAt: t0 - 2 * day, lastActivityAt: t0 - 3_600_000, modTime: t0 - 3_600_000, current: true, open: true },
{ path: "/mock/sessions/b.jsonl", preview: "refactor the payment module", turns: 5, createdAt: t0 - 3 * day, lastActivityAt: t0 - 6 * 3_600_000, modTime: t0 - 6 * 3_600_000, current: false, open: true },
{ path: "/mock/sessions/b-recovery-0123456789abcdef.jsonl", preview: "refactor the payment module", turns: 5, createdAt: t0 - 3 * day, lastActivityAt: t0 - 6 * 3_600_000, modTime: t0 - 6 * 3_600_000, current: false, open: true, recovered: true },
{ path: "/mock/sessions/c.jsonl", preview: "write the README and badges", turns: 8, createdAt: t0 - 4 * day, lastActivityAt: t0 - day - 3_600_000, modTime: t0 - day - 3_600_000, current: false, open: false },
{ path: "/mock/sessions/d.jsonl", preview: "explain the plugin host design", turns: 3, createdAt: t0 - 5 * day, lastActivityAt: t0 - 4 * day, modTime: t0 - 4 * day, current: false, open: false },
];
@@ -1017,6 +1058,7 @@ function makeMockApp(): AppBindings {
scope: "global",
topicId: "topic_product",
topicTitle: t("mock.trashGlobalProductTitle"),
recovered: true,
},
];
if (freshMock) {
@@ -1541,7 +1583,28 @@ function makeMockApp(): AppBindings {
setMockTabRunning(currentMockTurnTabId(), false);
emit({ kind: "turn_done" });
};
let mockTabs: TabMeta[] = freshMock ? [
let mockTabs: TabMeta[] = noticePreviewMock ? [
{
id: "tab_notice_preview",
scope: "project",
workspaceRoot: "~/projects/reasonix",
workspaceName: "reasonix",
workspacePath: "~/projects/reasonix",
gitBranch: "codex/compact-chat-notices-i18n",
topicId: "topic_notice_preview",
topicTitle: "Compact notice preview",
projectColor: "green",
label: "DeepSeek-R1",
ready: true,
running: false,
mode: "normal",
collaborationMode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
active: true,
cwd: "~/projects/reasonix",
},
] : freshMock ? [
{
id: "tab_global",
scope: "global",
@@ -1620,6 +1683,23 @@ function makeMockApp(): AppBindings {
cwd: "~/projects/joyquant-db",
},
];
if (sandboxEscapeMock) {
window.setTimeout(() => {
if (pendingApprovalPreview) return;
pendingApprovalPreview = true;
emitMockTurnStarted();
emit({ kind: "reasoning", text: t("mock.sandboxEscapeReasoning") });
emit({
kind: "approval_request",
approval: {
id: "mock-sandbox-escape-preview",
tool: "sandbox_escape",
subject: t("mock.sandboxEscapeSubject"),
reason: t("mock.sandboxEscapeReason"),
},
});
}, 800);
}
const mockModelCatalog = [
{ ref: "deepseek/deepseek-v4-flash", provider: "deepseek", model: "deepseek-v4-flash" },
{ ref: "deepseek/deepseek-v4-pro", provider: "deepseek", model: "deepseek-v4-pro" },
@@ -1716,6 +1796,26 @@ function makeMockApp(): AppBindings {
});
return;
}
if (
trimmedInput === "/sandbox-escape-preview" ||
trimmedInput === "sandbox escape preview" ||
trimmedInput === "sandbox_escape preview" ||
trimmedInput === "sandbox escape预览"
) {
pendingApprovalPreview = true;
await delay(250);
if (cancelled) return;
emit({
kind: "approval_request",
approval: {
id: "mock-sandbox-escape-preview",
tool: "sandbox_escape",
subject: t("mock.sandboxEscapeSubject"),
reason: t("mock.sandboxEscapeReason"),
},
});
return;
}
if (
trimmedInput === "/plan-approve-preview" ||
trimmedInput === "plan approve preview" ||
@@ -1830,6 +1930,67 @@ function makeMockApp(): AppBindings {
emitMockTurnDone();
return;
}
if (trimmedInput === "/nested-preview" || trimmedInput === "nested preview" || trimmedInput === "嵌套预览") {
const parentId = "mock-nested-explore";
await delay(180);
if (cancelled) return;
emit({
kind: "reasoning",
text: "我先快速探索相关文件,再整理这个工具行的视觉层级。",
});
emit({
kind: "message",
text: "",
reasoning: "我先快速探索相关文件,再整理这个工具行的视觉层级。",
});
emit({
kind: "tool_dispatch",
tool: {
id: parentId,
name: "explore",
args: JSON.stringify({ task: "在 Reasonix 前端中检查工具调用图标和嵌套调用展示" }),
readOnly: true,
profile: { model: "mock-reasonix", effort: "high" },
},
});
for (let i = 1; i <= 30; i += 1) {
if (cancelled) return;
const id = `mock-nested-${i}`;
const isSearch = i % 3 === 0;
const name = isSearch ? "grep" : "read_file";
const args = isSearch
? { pattern: i % 2 === 0 ? "tool__nested-count" : "explore", path: "desktop/frontend/src" }
: { path: `desktop/frontend/src/${i % 2 === 0 ? "components/ToolCard.tsx" : "styles.css"}`, offset: i * 10, limit: 40 };
emit({ kind: "tool_dispatch", tool: { id, name, args: JSON.stringify(args), readOnly: true, parentId } });
emit({
kind: "tool_result",
tool: {
id,
name,
readOnly: true,
output: isSearch ? "3 matches" : "read 40 lines",
durationMs: 24 + i,
},
});
await delay(18);
}
emit({
kind: "tool_result",
tool: {
id: parentId,
name: "explore",
readOnly: true,
output: "已读 20 个文件 · 搜索 10 个文件",
durationMs: 61510,
},
});
emit({
kind: "message",
text: "Mock nested tool preview complete. The explore row now shows the compass count marker.",
});
emitMockTurnDone();
return;
}
// Simulate the server's pre-first-token latency so the deferred user bubble
// and the "un-send on Esc before any reply" path are observable in browser
// dev. Bail if cancelled during the wait — nothing was streamed yet.
@@ -2958,6 +3119,14 @@ function makeMockApp(): AppBindings {
if (i >= 0) settings.providers[i] = p;
else settings.providers.push(p);
},
async SaveProviderWithKey(p: ProviderView, key: string) {
p.added = true;
p.keySet = Boolean(key.trim()) || p.keySet;
const i = settings.providers.findIndex((x) => x.name === p.name);
if (i >= 0) settings.providers[i] = p;
else settings.providers.push(p);
return "";
},
async AddOfficialProviderAccess(kind: string, key: string) {
const templates: Record<string, ProviderView> = {
deepseek: { name: "deepseek", builtIn: true, added: true, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: !!key.trim(), balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
@@ -3020,6 +3189,12 @@ function makeMockApp(): AppBindings {
if (p?.builtIn) p.added = false;
else settings.providers = settings.providers.filter((x) => x.name !== name);
},
async SaveProviderKey(apiKeyEnv: string, _value: string) {
settings.providers.forEach((p) => {
if (p.apiKeyEnv === apiKeyEnv) p.keySet = true;
});
return "";
},
async SetProviderKey(apiKeyEnv: string, _value: string) {
settings.providers.forEach((p) => {
if (p.apiKeyEnv === apiKeyEnv) p.keySet = true;
@@ -3172,11 +3347,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
+11 -1
View File
@@ -635,6 +635,7 @@ export function installPerformancePressureMonitor() {
const isFocused = () => typeof document === "undefined" || document.hasFocus?.() !== false;
let visibleSince = isHidden() ? Number.POSITIVE_INFINITY : startedAt;
let expected = performance.now() + 1000;
let eventLoopLagPrimed = false;
const pastGrace = () => performance.now() >= graceUntil;
const inspectLongTasks = () => {
@@ -651,6 +652,7 @@ export function installPerformancePressureMonitor() {
longTasks.length = 0;
lagSamples.length = 0;
expected = performance.now() + 1000;
eventLoopLagPrimed = false;
if (!isHidden()) visibleSince = performance.now();
});
}
@@ -673,9 +675,17 @@ export function installPerformancePressureMonitor() {
window.setInterval(() => {
const now = performance.now();
if (!pastGrace()) {
expected = now + 1000;
return;
}
if (!eventLoopLagPrimed) {
expected = now + 1000;
eventLoopLagPrimed = true;
return;
}
const lagMs = Math.max(0, now - expected);
expected = now + 1000;
if (!pastGrace()) return;
if (!shouldRecordEventLoopLagSample(isHidden(), now - visibleSince, isFocused())) return;
lagSamples.push(lagMs);
if (lagSamples.length > MAX_LAG_SAMPLES) lagSamples.shift();
+1 -1
View File
@@ -114,7 +114,7 @@ export function LocaleProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (typeof document === "undefined") return;
document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
document.documentElement.lang = locale === "zh" ? "zh-CN" : locale === "zh-TW" ? "zh-TW" : "en";
}, [locale]);
// setPref updates only the live UI; persistence is handled by desktop config.
+53
View File
@@ -156,3 +156,56 @@ export function playAttentionChime(): void {
void playWav(pref, 0.25, playSynthAttention);
}
}
export type AttentionChimeEvent = {
kind?: string;
tabId?: string;
approval?: { id?: string };
ask?: { id?: string };
};
export function attentionChimeEventKey(event: AttentionChimeEvent): string | undefined {
if (event.kind === "approval_request" && event.approval?.id) return `approval:${event.tabId ?? ""}:${event.approval.id}`;
if (event.kind === "ask_request" && event.ask?.id) return `ask:${event.tabId ?? ""}:${event.ask.id}`;
return undefined;
}
// attentionChimeSeenCap bounds the dedupe set. Prompt ids are unique per
// prompt, so the set only ever grows; past the cap the oldest half is dropped
// (insertion order) — replay dedupe only needs to cover recently replayed
// prompts, not the whole session history.
const attentionChimeSeenCap = 512;
// clearAttentionChimeKeys drops dedupe keys after a runtime rebuild. Approval
// and ask ids are per-controller counters starting at "1", so a rebuilt
// controller (model/effort/settings switch) reissues ids an earlier prompt on
// the same tab already used — without this, the first prompt after a rebuild
// is misread as a replay and stays silent. A ready event without a tab id
// (settings rebuilds emit tab-less ready) clears everything: over-clearing
// only re-chimes a replayed pending prompt, which is a desirable reminder,
// while under-clearing mutes a live prompt.
export function clearAttentionChimeKeys(seen: Set<string>, tabId?: string): void {
if (tabId === undefined || tabId === "") {
seen.clear();
return;
}
for (const key of [...seen]) {
if (key.startsWith(`approval:${tabId}:`) || key.startsWith(`ask:${tabId}:`)) {
seen.delete(key);
}
}
}
export function shouldPlayAttentionChimeForEvent(event: AttentionChimeEvent, seen: Set<string>): boolean {
const key = attentionChimeEventKey(event);
if (!key || seen.has(key)) return false;
if (seen.size >= attentionChimeSeenCap) {
let drop = seen.size - attentionChimeSeenCap / 2;
for (const k of seen) {
if (drop-- <= 0) break;
seen.delete(k);
}
}
seen.add(key);
return true;
}
@@ -74,6 +74,14 @@ export function questionTurnsById(questions: QuestionAnchor[]): Map<string, numb
return turns;
}
export function lastQuestionTurn(questions: readonly QuestionAnchor[], turns: ReadonlyMap<string, number>): number | undefined {
for (let i = questions.length - 1; i >= 0; i -= 1) {
const turn = turns.get(questions[i].id);
if (turn != null) return turn;
}
return undefined;
}
export function scrollVersion(items: Item[]): string {
return items
.map((it) => {
@@ -0,0 +1,6 @@
export function appendTurnActionCopyText(current: string, next: string): string {
if (next.trim() === "") return current;
if (current.trim() === "") return next;
if (current.endsWith("\n") || next.startsWith("\n")) return current + next;
return `${current}\n\n${next}`;
}
+4
View File
@@ -155,6 +155,7 @@ export interface MemoryCompilerStats {
export interface WireEvent {
kind: EventKind;
text?: string;
detail?: string;
reasoning?: string;
memoryCitations?: MemoryCitation[];
memoryCompiler?: MemoryCompilerStats;
@@ -324,6 +325,7 @@ export interface ChangedFileInfo {
export interface HistoryMessage {
role: string;
content: string;
detail?: string;
submitText?: string;
checkpointTurn?: number;
createdAt?: number;
@@ -413,6 +415,7 @@ export interface SessionMeta {
userId?: string;
threadId?: string;
sessionSource?: string;
recovered?: boolean; // conflict-recovery copy of another session
}
// SessionReference is a session selected via @ past:chats for context injection.
@@ -668,6 +671,7 @@ export interface ServerView {
tools: number;
prompts: number;
resources: number;
hasTools?: boolean;
error?: string;
toolList?: MCPToolView[];
trustedReadOnlyTools?: string[];
+390 -40
View File
@@ -10,7 +10,7 @@ import { app, onEvent, onReady } from "./bridge";
import { invalidateCache } from "./composerHistory";
import { formatGuardianAssessmentNotice } from "./guardianEvents";
import { createRafBatch } from "./rafBatch";
import { t } from "./i18n";
import { t, type DictKey } from "./i18n";
import { fileDiffFromWire, summarize, summarizeFileDiff, type ToolFileDiff } from "./tools";
import { modeHasAutoApproveTools, normalizeMode, normalizeToolApprovalMode } from "./types";
import type {
@@ -53,7 +53,7 @@ export type Item =
| { kind: "user"; id: string; text: string; submitText?: string; failed?: boolean; createdAt?: number; checkpointTurn?: number }
| { kind: "assistant"; id: string; text: string; reasoning: string; streaming: boolean; reasoningComplete?: boolean; memoryCitations?: MemoryCitation[] }
| { kind: "phase"; id: string; text: string }
| { kind: "notice"; id: string; level: "info" | "warn"; text: string }
| { kind: "notice"; id: string; level: "info" | "warn"; text: string; detail?: string }
| {
kind: "compaction";
id: string;
@@ -244,6 +244,8 @@ export function sameMeta(a?: Meta, b?: Meta): boolean {
const STALE_TURN_RECONCILE_MS = 30_000;
const CANCEL_RECONCILE_DELAYS_MS = [0, 100, 300, 1_000] as const;
const STARTUP_READY_META_RECONCILE_MS = 250;
const STARTUP_READY_META_RECONCILE_ATTEMPTS = 60;
export function shouldReconcileStaleTurn(
state: Pick<State, "running" | "turnActive"> | undefined,
@@ -379,7 +381,7 @@ export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: str
const positionalResults = positionalToolResults(messages);
const consumedPositionalToolIndexes = new Set(Array.from(positionalResults.values(), (result) => result.index));
const items: Item[] = [];
let items: Item[] = [];
let seq = startSeq;
const consumedToolIDs = new Set<string>();
for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
@@ -394,8 +396,9 @@ export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: str
}
if (m.role === "notice") {
if (m.content.trim() !== "") {
items.push({ kind: "notice", id: `${idPrefix}${seq}`, level: m.level === "warn" ? "warn" : "info", text: m.content });
seq++;
const next = appendNoticeItem(items, seq, `${idPrefix}${seq}`, m.level === "warn" ? "warn" : "info", m.content, m.detail);
items = next.items;
seq = next.seq;
}
continue;
}
@@ -725,7 +728,7 @@ function applyEvent(s: State, e: WireEvent): State {
return { ...s, usage, context: { ...s.context, used, sessionTokens }, turnTokens, turnTotalTokens, turnCost, sessionTokens, sessionCost, sessionCurrency };
}
case "notice":
return { ...s, running: s.turnActive ? s.running : false, seq: s.seq + 1, items: [...s.items, { kind: "notice", id: `n${s.seq}`, level: e.level ?? "info", text: e.text ?? "" }] };
return appendNoticeToState(s, e.level ?? "info", e.text ?? "", e.detail);
case "phase":
return { ...s, seq: s.seq + 1, items: [...s.items, { kind: "phase", id: `p${s.seq}`, text: e.text ?? "" }] };
case "compaction_started":
@@ -953,6 +956,278 @@ function errorMessage(err: unknown): string {
return String(err || "");
}
export function effortSwitchNoticeText(err: unknown): string {
return settingSwitchNoticeText(err, "effort", {
busy: "status.effortSwitchBusy",
leaseHeld: "status.effortSwitchLeaseHeld",
starting: "status.effortSwitchStarting",
startupFailed: "status.effortSwitchStartupFailed",
retry: "status.effortSwitchRetry",
failed: "status.effortSwitchFailed",
});
}
export function modelSwitchNoticeText(err: unknown): string {
const msg = errorMessage(err).trim() || "unknown error";
const unknownModel = /^unknown model (.+)$/i.exec(msg);
if (unknownModel) {
return t("status.modelSwitchUnknown", { model: unknownModel[1] });
}
const unavailable = /^model (.+) is not available because provider (.+) is not added$/i.exec(msg);
if (unavailable) {
return t("status.modelSwitchProviderUnavailable", { model: unavailable[1], provider: unavailable[2] });
}
return settingSwitchNoticeText(msg, "model", {
busy: "status.modelSwitchBusy",
leaseHeld: "status.modelSwitchLeaseHeld",
starting: "status.modelSwitchStarting",
startupFailed: "status.modelSwitchStartupFailed",
retry: "status.modelSwitchRetry",
failed: "status.modelSwitchFailed",
});
}
export function tokenModeSwitchNoticeText(err: unknown): string {
return settingSwitchNoticeText(err, "token mode", {
busy: "status.tokenModeSwitchBusy",
leaseHeld: "status.tokenModeSwitchLeaseHeld",
starting: "status.tokenModeSwitchStarting",
startupFailed: "status.tokenModeSwitchStartupFailed",
retry: "status.tokenModeSwitchRetry",
failed: "status.tokenModeSwitchFailed",
});
}
export function localizedBackendNoticeText(text: string): string {
const msg = text.trim();
const autosave = /^Session autosave failed: (.+)$/s.exec(msg);
if (autosave) {
return t("status.sessionAutosaveFailed", { err: autosave[1] });
}
const saveBefore = /^Session save failed before (.+?): (.+)$/s.exec(msg);
if (saveBefore) {
return t("status.sessionSaveFailedBefore", { action: localizedSessionAction(saveBefore[1]), err: saveBefore[2] });
}
const modelFallback = /^model (.+) is no longer available; switched to (.+)$/s.exec(msg);
if (modelFallback) {
return t("status.modelFallbackSwitched", { model: modelFallback[1], fallback: modelFallback[2] });
}
const backgroundJob = /^background (.+) failed: needs attention$/s.exec(msg);
if (backgroundJob) {
return t("notice.backgroundJobFailed", { kind: backgroundJob[1] });
}
const canonicalNoticeKey = backendNoticeKey(msg);
if (canonicalNoticeKey) {
return t(canonicalNoticeKey);
}
if (
/^session changed on disk; unsaved local transcript was saved as a conflict copy$/i.test(msg) ||
/^session changed on disk; unsaved local transcript was saved as recovery branch\b/i.test(msg)
) {
return t("recovery.noticeSavedCopy");
}
if (
/^repeated save conflicts were detected; saved the current conflict copy in place$/i.test(msg) ||
/^session conflicts kept recurring; kept the transcript on the current recovery branch$/i.test(msg)
) {
return t("recovery.noticeKeptCurrent");
}
if (/^session changed on disk; adopted the newer transcript \(local changes already covered\)$/i.test(msg)) {
return t("recovery.noticeAdoptedCovered");
}
if (/^session changed on disk; adopted the newer transcript$/i.test(msg)) {
return t("recovery.noticeAdopted");
}
return msg;
}
function backendNoticeKey(msg: string): DictKey | "" {
switch (msg) {
case "Task status needs one more check; asking the assistant to finish or explain what is blocking it.":
return "notice.finalReadiness";
case "No visible answer was produced; asking the assistant to respond again.":
return "notice.emptyFinal";
case "The assistant answered before taking action; asking it to use the required tools.":
return "notice.executorHandoff";
case "Tool round limit reached; asking the assistant to summarize progress.":
return "notice.toolBudget";
case "The assistant is stuck retrying a blocked action; asking it to change approach.":
return "notice.loopGuard";
case "Context is getting large; preserving cache until cleanup is needed.":
return "notice.contextLarge";
case "Context cleanup skipped for now.":
return "notice.contextCleanupSkipped";
case "Automatic context cleanup paused because the context window is too small.":
return "notice.contextCleanupPaused";
case "Context was compacted without a generated summary.":
return "notice.compactionNoSummary";
case "Planning mode enabled for this multi-step task.":
return "notice.autoPlanEnabled";
case "Plan detection requested a plan.":
return "notice.autoPlanRequested";
case "Plan detection was uncertain; using the fallback planner heuristic.":
return "notice.autoPlanFallback";
case "Goal is not ready to complete yet; continuing the remaining work.":
return "notice.goalNotReady";
case "Goal still has unfinished task state; continuing the remaining work.":
return "notice.goalUnfinished";
case "AutoResearch status update failed.":
return "notice.autoresearchStatusFailed";
case "AutoResearch task marked blocked.":
return "notice.autoresearchBlocked";
case "Job artifact migration failed.":
return "notice.jobArtifactMigrationFailed";
case "Background job teardown timed out.":
return "notice.jobTeardownTimeout";
case "Some plan-mode tool settings were ignored.":
return "notice.planModeToolSettingsIgnored";
case "Some plan-mode command settings were ignored.":
return "notice.planModeCommandSettingsIgnored";
case "Config migration did not complete.":
return "notice.configMigrationIncomplete";
case "Selected model is missing its API key.":
return "notice.modelMissingApiKey";
case "An MCP server failed to start.":
return "notice.mcpServerFailed";
case "Some MCP servers failed to start; run /mcp for details.":
return "notice.mcpServersFailed";
case "Guardian was disabled because its model was not found.":
return "notice.guardianModelMissing";
case "Guardian was disabled because it could not start.":
return "notice.guardianStartFailed";
default:
return "";
}
}
function recoveryNoticeDedupeKey(text: string): string {
const msg = text.trim();
if (
/^session changed on disk; unsaved local transcript was saved as a conflict copy$/i.test(msg) ||
/^session changed on disk; unsaved local transcript was saved as recovery branch\b/i.test(msg) ||
msg === t("recovery.noticeSavedCopy")
) {
return "recovery:saved-copy";
}
if (
/^repeated save conflicts were detected; saved the current conflict copy in place$/i.test(msg) ||
/^session conflicts kept recurring; kept the transcript on the current recovery branch$/i.test(msg) ||
msg === t("recovery.noticeKeptCurrent")
) {
return "recovery:kept-current";
}
if (
/^session changed on disk; adopted the newer transcript \(local changes already covered\)$/i.test(msg) ||
msg === t("recovery.noticeAdoptedCovered")
) {
return "recovery:adopted-covered";
}
if (
/^session changed on disk; adopted the newer transcript$/i.test(msg) ||
msg === t("recovery.noticeAdopted")
) {
return "recovery:adopted";
}
return "";
}
function quietTranscriptNoticeKey(text: string): string {
const recovery = recoveryNoticeDedupeKey(text);
if (recovery) return recovery;
const msg = text.trim();
if (/^guardian enabled · model=.+$/i.test(msg)) {
return "startup:guardian-enabled";
}
if (/^\d+ MCP server\(s\) failed to start: .+ \u2014 run \/mcp for details$/i.test(msg)) {
return "startup:mcp-failures";
}
const directMCPFailure = /^mcp\s+([A-Za-z0-9._-]+):\s+.+$/i.exec(msg);
if (directMCPFailure) {
const name = directMCPFailure[1].toLowerCase();
if (!["add", "auth", "config", "connect", "import", "mode", "remove"].includes(name)) {
return "startup:mcp-failure";
}
}
if (/^plugin ".+" has been slow \d+ startups in a row \(last \d+ms, budget \d+ms\); demoting to background startup this session$/i.test(msg)) {
return "startup:plugin-demote";
}
if (/^.+ applied: session refreshed after the lease was released$/i.test(msg)) {
return "settings:deferred-refresh-applied";
}
return "";
}
function appendNoticeItem(items: Item[], seq: number, id: string, level: "info" | "warn", rawText: string, detail?: string): { items: Item[]; seq: number } {
if (quietTranscriptNoticeKey(rawText)) {
return { items, seq };
}
const text = localizedBackendNoticeText(rawText);
if (quietTranscriptNoticeKey(text)) {
return { items, seq };
}
const trimmedDetail = detail?.trim();
return { items: [...items, { kind: "notice", id, level, text, ...(trimmedDetail ? { detail: trimmedDetail } : {}) }], seq: seq + 1 };
}
function appendNoticeToState(s: State, level: "info" | "warn", text: string, detail?: string): State {
const next = appendNoticeItem(s.items, s.seq, `n${s.seq}`, level, text, detail);
return { ...s, running: s.turnActive ? s.running : false, seq: next.seq, items: next.items };
}
function localizedSessionAction(action: string): string {
switch (action.trim()) {
case "changing model":
return t("status.actionChangingModel");
case "changing effort":
return t("status.actionChangingEffort");
case "changing token mode":
return t("status.actionChangingTokenMode");
case "rebuilding settings":
return t("status.actionRebuildingSettings");
case "switching sessions":
return t("status.actionSwitchingSessions");
case "switching tabs":
return t("status.actionSwitchingTabs");
case "autosave":
return t("status.actionAutosave");
default:
return action.trim() || t("status.actionCurrentSession");
}
}
function settingSwitchNoticeText(
err: unknown,
setting: "effort" | "model" | "token mode",
keys: {
busy: DictKey;
leaseHeld: DictKey;
starting: DictKey;
startupFailed: DictKey;
retry: DictKey;
failed: DictKey;
},
): string {
const msg = errorMessage(err).trim() || "unknown error";
const lower = msg.toLowerCase();
if (lower.includes("finish or cancel") && lower.includes(`before changing ${setting}`)) {
return t(keys.busy);
}
if (lower.includes("already open in another reasonix window") || lower.includes("session lease held")) {
return t(keys.leaseHeld);
}
if (lower.includes("workspace is still starting")) {
return t(keys.starting);
}
if (lower.startsWith("workspace failed to start")) {
return t(keys.startupFailed, { err: msg });
}
if (lower.includes(`changed while switching ${setting}`) || (lower.includes("tab ") && lower.includes("not found"))) {
return t(keys.retry);
}
return t(keys.failed, { err: msg });
}
async function refreshMetaForTab(tabId: string, dispatchTo: (tabId: string, action: Action) => void): Promise<void> {
try {
dispatchTo(tabId, { type: "meta", meta: await app.MetaForTab(tabId) });
@@ -963,6 +1238,16 @@ async function refreshMetaForTab(tabId: string, dispatchTo: (tabId: string, acti
}
}
async function refreshMetaOnlyForTab(tabId: string, dispatchTo: (tabId: string, action: Action) => void): Promise<Meta | undefined> {
try {
const meta = await app.MetaForTab(tabId);
dispatchTo(tabId, { type: "meta", meta });
return meta;
} catch {
return undefined;
}
}
export function replayPendingPromptsForActiveTab(activeTabId: string | undefined, replay: () => Promise<void> = () => app.ReplayPendingPrompts()): void {
if (!activeTabId) return;
void replay().catch(() => {});
@@ -984,6 +1269,8 @@ export function useController() {
const stateRef = useRef(activeState);
const backendActiveTabIdRef = useRef<string | undefined>(undefined);
const backendActivationPromises = useRef(new Map<string, Promise<boolean>>());
const readyMetaReconcileSeq = useRef(0);
const readyMetaReconcileActive = useRef<{ tabId: string; seq: number } | undefined>(undefined);
activeTabIdRef.current = activeTabId;
stateRef.current = activeState;
@@ -1073,23 +1360,31 @@ export function useController() {
if (reset && sessionLoadCurrent(tabId, seq)) dispatchTo(tabId, { type: "reset" });
const stillCurrent = () => sessionLoadCurrent(tabId, seq);
const requiresVisibleTab = reason === "startup" || reason === "switch-tab" || reason === "open-topic";
const stillVisible = () => !requiresVisibleTab || activeTabIdRef.current === tabId;
const noteFailure = (label: string, err: unknown) => {
addBreadcrumb("tab.hydrate", `${label} failed ${tabId}: ${errorMessage(err)}`);
};
const historyStartedAt = Date.now();
const loadTimed = async <T,>(label: string, load: () => Promise<T>): Promise<T | undefined> => {
const startedAt = Date.now();
addBreadcrumb("tab.hydrate", `${label} start ${reason} ${tabId}`);
try {
const value = await load();
addBreadcrumb("tab.hydrate", `${label} done ${reason} ${tabId} ms=${Date.now() - startedAt}`);
return value;
} catch (err) {
noteFailure(label, err);
return undefined;
}
};
// Phase 1: dispatch fast metadata immediately so the transcript can render
// without waiting for slower ancillary calls (e.g. BalanceForTab network).
const [meta, historyPage] = await Promise.all([
app.MetaForTab(tabId).catch((err) => { noteFailure("meta", err); return undefined; }),
skipHistory
? Promise.resolve(undefined)
: app.HistoryPageForTab(tabId, 0, HISTORY_PAGE_TURNS).catch((err) => { noteFailure("history", err); return undefined; }),
]);
const historyStartedAt = Date.now();
const historyPage = skipHistory
? undefined
: await loadTimed("history", () => app.HistoryPageForTab(tabId, 0, HISTORY_PAGE_TURNS));
if (!stillCurrent()) return;
if (meta !== undefined) dispatchTo(tabId, { type: "meta", meta });
if (!skipHistory && historyPage !== undefined) {
const messages = asArray(historyPage.messages);
dispatchTo(tabId, { type: "history_page", page: historyPage, mode: "replace" });
@@ -1120,21 +1415,20 @@ export function useController() {
// in a loading state.
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
if (!stillCurrent()) return;
if (activeTabIdRef.current !== tabId && (reason === "startup" || reason === "switch-tab" || reason === "open-topic")) {
if (!stillVisible()) {
addBreadcrumb("tab.hydrate", `ancillary skipped inactive ${reason} ${tabId}`);
return;
}
const meta = await loadTimed("meta", () => app.MetaForTab(tabId));
if (!stillCurrent()) return;
if (!stillVisible()) {
addBreadcrumb("tab.hydrate", `meta ignored inactive ${reason} ${tabId}`);
return;
}
if (meta !== undefined) dispatchTo(tabId, { type: "meta", meta });
const ancillaryStartedAt = Date.now();
const loadAncillary = async <T,>(label: string, load: () => Promise<T>): Promise<T | undefined> => {
const startedAt = Date.now();
try {
const value = await load();
addBreadcrumb("tab.hydrate", `ancillary ${label} ${reason} ${tabId} ms=${Date.now() - startedAt}`);
return value;
} catch (err) {
noteFailure(label, err);
return undefined;
}
return loadTimed(`ancillary ${label}`, load);
};
const [effort, jobs, context] = await Promise.all([
loadAncillary("effort", () => app.EffortForTab(tabId)),
@@ -1142,22 +1436,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); });
})();
@@ -1355,11 +1657,9 @@ export function useController() {
addBreadcrumb("tab.hydrate", `ready ignored ${readyTabId}`);
return;
}
if (activeId) {
void loadSessionDataForTab(activeId, false, "startup", { preserveCachedHistory: true });
return;
}
void syncActiveTabFromBackend(false, true);
// A ready event can race the initial hydrate. Refresh the tab metadata
// first so a stale ready=false snapshot does not keep the composer locked.
void syncActiveTabFromBackend(false, true, { preserveCachedHistory: true });
});
void syncActiveTabFromBackend(false, true);
@@ -1380,6 +1680,51 @@ export function useController() {
};
}, [dispatchTo, loadSessionDataForTab, refreshCheckpoints, syncActiveTabFromBackend]);
// If the startup ready event is missed, keep the composer lock in sync with
// the active tab's backend metadata without kicking off tab activation work.
useEffect(() => {
const tabId = activeTabId;
const meta = activeState.meta;
if (!tabId || !meta || meta.ready || meta.startupErr || activeState.backendActivationPending) {
readyMetaReconcileSeq.current += 1;
readyMetaReconcileActive.current = undefined;
return;
}
let cancelled = false;
let timer: number | undefined;
const seq = readyMetaReconcileSeq.current + 1;
readyMetaReconcileSeq.current = seq;
readyMetaReconcileActive.current = { tabId, seq };
const stillCurrent = () => {
const active = readyMetaReconcileActive.current;
return !cancelled && active?.tabId === tabId && active.seq === seq && activeTabIdRef.current === tabId;
};
const schedule = (attempt: number) => {
timer = window.setTimeout(() => {
void tick(attempt);
}, STARTUP_READY_META_RECONCILE_MS);
};
const tick = async (attempt: number) => {
if (!stillCurrent()) return;
const current = statesRef.current.get(tabId);
if (!current?.meta || current.meta.ready || current.meta.startupErr || current.backendActivationPending) return;
const nextMeta = await refreshMetaOnlyForTab(tabId, dispatchTo);
if (!stillCurrent()) return;
if (nextMeta?.ready || nextMeta?.startupErr || attempt + 1 >= STARTUP_READY_META_RECONCILE_ATTEMPTS) return;
schedule(attempt + 1);
};
schedule(0);
return () => {
cancelled = true;
if (timer !== undefined) window.clearTimeout(timer);
};
}, [activeTabId, activeState.meta?.ready, activeState.meta?.startupErr, activeState.backendActivationPending, dispatchTo]);
// Stale-turn watchdog: if the frontend thinks the agent is running but the
// turn stream has gone quiet, reconcile with the backend. This catches cases
// where the Wails event channel silently drops turn_done after the final
@@ -1412,7 +1757,7 @@ export function useController() {
}, [activeTabId]);
const sendToTab = useCallback(async (tabId: string, displayText: string, submitText = displayText, originalText?: string) => {
if (!tabId) throw new Error("workspace is still starting");
if (!tabId) throw new Error(t("composer.workspaceStarting"));
const seq = getOrCreateState(statesRef.current, tabId).seq;
const display = displayText.trim();
const submit = submitText.trim();
@@ -1438,7 +1783,7 @@ export function useController() {
return sendToTab(tabId, displayText, submitText);
}
return activeTabFromBackend().then((active) => {
if (!active?.id) throw new Error("workspace is still starting");
if (!active?.id) throw new Error(t("composer.workspaceStarting"));
setActiveTabId(active.id);
activeTabIdRef.current = active.id;
confirmBackendActiveTab(active.id);
@@ -1448,7 +1793,7 @@ export function useController() {
}, [activeTabFromBackend, activeTabId, confirmBackendActiveTab, dispatchRuntimeStatusForTab, sendToTab]);
const runShell = useCallback(async (command: string) => {
if (!activeTabId) throw new Error("workspace is still starting");
if (!activeTabId) throw new Error(t("composer.workspaceStarting"));
dispatchTo(activeTabId, { type: "user", text: `!${command}`, seq: getOrCreateState(statesRef.current, activeTabId).seq });
try {
await app.RunShellForTab(activeTabId, command);
@@ -1459,7 +1804,7 @@ export function useController() {
}, [activeTabId, dispatchTo]);
const steer = useCallback(async (text: string) => {
if (!activeTabId) throw new Error("workspace is still starting");
if (!activeTabId) throw new Error(t("composer.workspaceStarting"));
// No optimistic user bubble: rewind/fork map turns by counting user items,
// and a steer is not a backend turn — the Steer event's ↪ notice is the
// visible confirmation (#3660).
@@ -1697,7 +2042,7 @@ export function useController() {
try {
await app.SetModelForTab(activeTabId, name);
} catch (err) {
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: t("status.modelSwitchFailed", { err: errorMessage(err) }) });
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: modelSwitchNoticeText(err) });
return;
}
try {
@@ -1709,7 +2054,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) });
@@ -1722,7 +2072,7 @@ export function useController() {
try {
await app.SetTokenModeForTab(activeTabId, mode);
} catch (err) {
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: t("status.tokenModeSwitchFailed", { err: errorMessage(err) }) });
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: tokenModeSwitchNoticeText(err) });
return;
}
try {
@@ -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]);
}
+137 -13
View File
@@ -21,6 +21,7 @@ export const en = {
"common.busyHint": "Finish or stop the current turn first",
"common.loading": "Loading…",
"composer.readOnlyChannel": "Channel conversation is read-only",
"composer.workspaceStarting": "Workspace is still starting",
"app.splashSubtitle": "Agent Workspace",
// top bar
@@ -470,7 +471,10 @@ export const en = {
"composer.send": "Send (Enter)",
"composer.queueGuidance": "Add to guidance queue (Enter)",
"composer.steer": "Add guidance to current turn (Enter)",
"composer.steerPlaceholder": "Add guidance to the queue...",
"composer.steerPlaceholder": "Running — type guidance, Enter adds it to the queue",
"composer.runWaitingApproval": "Waiting for your approval — {tool}",
"composer.runWaitingAsk": "Waiting for your answer",
"composer.runAnnounceRunning": "Reasonix is working",
"composer.guidanceQueue": "Queued guidance",
"composer.guidanceCount": "Queued guidance {n}",
"composer.guidanceRemaining": "{n} more queued",
@@ -531,8 +535,37 @@ export const en = {
"modelSwitcher.searchPlaceholder": "Search models…",
"modelSwitcher.noMatches": "No matching models",
"status.noModels": "no switchable models",
"status.modelSwitchBusy": "The model cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
"status.modelSwitchLeaseHeld": "This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing models.",
"status.modelSwitchStarting": "This session is still starting. Try changing models again in a moment.",
"status.modelSwitchStartupFailed": "This session did not start, so the model cannot change: {err}",
"status.modelSwitchRetry": "The current session changed while switching models. Try once more.",
"status.modelSwitchUnknown": "Unknown model {model}.",
"status.modelSwitchProviderUnavailable": "Model {model} is unavailable because provider {provider} is not added.",
"status.modelSwitchFailed": "Model switch failed: {err}",
"status.modelFallbackSwitched": "Model {model} is no longer available; switched to {fallback}.",
"status.effortSwitchBusy": "Reasoning effort cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
"status.effortSwitchLeaseHeld": "This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing effort.",
"status.effortSwitchStarting": "This session is still starting. Try changing reasoning effort again in a moment.",
"status.effortSwitchStartupFailed": "This session did not start, so reasoning effort cannot change: {err}",
"status.effortSwitchRetry": "The current session changed while switching reasoning effort. Try once more.",
"status.effortSwitchFailed": "Reasoning effort switch failed: {err}",
"status.tokenModeSwitchBusy": "Token saver cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
"status.tokenModeSwitchLeaseHeld": "This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing token saver.",
"status.tokenModeSwitchStarting": "This session is still starting. Try changing token saver again in a moment.",
"status.tokenModeSwitchStartupFailed": "This session did not start, so token saver cannot change: {err}",
"status.tokenModeSwitchRetry": "The current session changed while switching token saver. Try once more.",
"status.tokenModeSwitchFailed": "Token saver switch failed: {err}",
"status.sessionAutosaveFailed": "Session autosave failed: {err}",
"status.sessionSaveFailedBefore": "Session save failed before {action}: {err}",
"status.actionChangingModel": "changing models",
"status.actionChangingEffort": "changing reasoning effort",
"status.actionChangingTokenMode": "changing token saver",
"status.actionRebuildingSettings": "refreshing settings",
"status.actionSwitchingSessions": "switching sessions",
"status.actionSwitchingTabs": "switching tabs",
"status.actionAutosave": "autosave",
"status.actionCurrentSession": "saving the current session",
"status.effort": "effort {level}",
"status.effortTitle": "Reasoning effort",
"status.effortAutoTitle": "Reasoning effort: auto (model default: {def})",
@@ -643,6 +676,13 @@ export const en = {
"approval.sendRevision": "Send update",
"approval.exitPlan": "Exit plan",
"approval.toolPending": "Allow tool call",
"approval.modeSwitchPendingHint": "Approval mode was relaxed — this pending request still needs your decision.",
"approval.toolLabelExitPlan": "Plan approval",
"approval.allowOnceDesc": "Allow this call only; the next one asks again.",
"approval.allowRuleSessionDesc": "Allow matching calls until this session ends; resets on restart.",
"approval.allowRulePersistentDesc": "Save a persistent allow rule; future sessions stop asking too.",
"approval.denyDesc": "Reject this call; the model sees the refusal and continues.",
"approval.allowSandboxEscapeSessionDesc": "Run follow-up commands in the real environment for the rest of this session.",
"approval.details": "Details",
"approval.hideDetails": "Hide",
"approval.deny": "Deny",
@@ -657,6 +697,29 @@ export const en = {
"approval.allowRulePersistent": "Always allow (save)",
"approval.allowEditsSession": "Allow edits for session",
"approval.allowFilePersistent": "Always allow this file (save)",
"approval.allowSandboxEscapeSession": "Use real environment for this session",
"approval.toolLabelBash": "bash",
"approval.toolLabelEditFile": "edit file",
"approval.toolLabelWriteFile": "write file",
"approval.toolLabelMultiEdit": "multi edit",
"approval.toolLabelMoveFile": "move file",
"approval.toolLabelWebFetch": "web fetch",
"approval.toolLabelRunSkill": "run skill",
"approval.toolLabelRemember": "remember",
"approval.toolLabelForget": "forget",
"approval.toolLabelSandboxEscape": "bash sandbox escape",
"approval.toolLabelPlanModeReadOnly": "plan-mode read-only command",
"approval.memorySaveUpdate": "Save/update memory",
"approval.memoryBodyLabel": "body",
"approval.memoryArchivePrefix": "Archive memory ",
"approval.planModeMcpTrustSubject": "MCP {target} as read-only for planning and research",
"approval.planModeMcpTrustReason": "This MCP tool reports read-only, but external read-only hints need your confirmation before plan mode can use them. Choose always allow to remember this trust for future planning and read-only research.",
"approval.planModeBashTrustSubject": "Trust {prefix} as a read-only command prefix while planning\nCommand: {command}",
"approval.planModeBashTrustReason": "This bash command is not in Reasonix's built-in read-only set. Confirm only if this exact prefix is read-only for planning and research. Auto/YOLO approval cannot answer this trust prompt.",
"approval.sandboxEscapeSubjectFallback": "run shell command unconfined once",
"approval.sandboxEscapeSubjectPrefix": "run unconfined once: ",
"approval.sandboxEscapeWrapReason": "Windows sandbox could not wrap this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
"approval.sandboxEscapeRuntimeReason": "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
// ask card
"ask.title": "Need your decision",
@@ -695,6 +758,12 @@ export const en = {
"history.clearTrash": "Empty trash",
"history.confirmClearTrash": "Confirm empty trash",
"history.clearTrashMenu": "Empty trash…",
"history.clearRecoveryCopies": "Clear copies",
"history.confirmClearRecoveryCopies": "Confirm clear copies",
"history.clearRecoveryCopiesMenu": "Clear recovery copies…",
"history.trashRecoveryCopies": "Trash recovery copies",
"history.confirmTrashRecoveryCopies": "Confirm trash copies",
"history.recoveryCopiesGroup": "Recovery copies",
"history.moveToTrash": "Move to trash",
"history.confirmMoveToTrash": "Confirm move to trash",
"history.restore": "Restore",
@@ -726,22 +795,19 @@ export const en = {
"history.failedOpenProject": "Cannot open session, \"{name}\" does not exist: {path}",
"history.failedOpenSession": "Cannot open session",
"history.failedOpenGlobal": "Cannot open global session",
"history.missingWorkspaceRoot": "Cannot open session because its workspace path is missing.",
"history.selectSession": "Select a session to preview.",
"history.trashSessionActions": "Trash session actions",
"history.historySessionActions": "History session actions",
"history.trashActions": "Trash actions",
// session recovery
"recovery.branch": "Recovery branch",
"recovery.badge": "Recovery",
"recovery.open": "Open recovery branch",
"recovery.toast": "Unsaved local changes were saved as {title}.",
"recovery.failedLease": "The recovery branch is in use by another runtime, so this tab did not switch to it.",
"recovery.failedUnavailable": "The recovery branch could not be opened, so this tab stayed on the original session.",
"recovery.banner": "This session was recovered from a save conflict. Compare it with the original before continuing.",
"recovery.reason": "Reason: {reason}",
"recovery.digest": "Digest: {digest}",
"recovery.parent": "Parent: {parent}",
"recovery.branch": "Conflict copy",
"recovery.badge": "Copy",
"recovery.noticeSavedCopy": "The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
"recovery.noticeKeptCurrent": "Repeated save conflicts were detected, so the current conflict copy was saved in place.",
"recovery.noticeAdopted": "The session changed on disk, so Reasonix adopted the newer transcript.",
"recovery.noticeAdoptedCovered": "The session changed on disk, so Reasonix adopted the newer transcript; the local changes were already covered.",
// project tree
"projectTree.workspaceTitle": "Projects",
@@ -757,6 +823,7 @@ export const en = {
"projectTree.renameProjectWorkbench": "Rename project",
"projectTree.projectHistory": "View project history",
"projectTree.justNow": "just now",
"projectTree.previously": "previously",
"projectTree.running": "responding",
"projectTree.status.thinking": "thinking",
"projectTree.status.streaming": "responding",
@@ -1396,7 +1463,7 @@ export const en = {
"settings.yoloHint": "YOLO-approves approval-gated tool calls (writers, bash). Deny rules still block, and ask questions plus plan approvals still wait for you. Runtime-only — resets on relaunch.",
"settings.addRule": "add {list} rule…",
"settings.sandboxTitle": "Sandbox & workspace",
"settings.sandboxBoundaryHint": "Sandbox write roots limit where approved Bash and file tools can write; they do not auto-approve commands. Use Permissions or approval mode for deploy-style commands.",
"settings.sandboxBoundaryHint": "Sandbox write roots limit where approved Bash and file tools can write; they do not auto-approve commands. If Bash sandbox is enforced but no OS backend is available, shell commands are refused; switch Bash sandbox off only for trusted workspaces.",
"settings.reloadSessionConfig": "Apply manual config changes",
"settings.reloadSessionConfigHint": "Changes made in Settings apply automatically; use this only after manually editing config.toml or reasonix.toml.",
"settings.shellInterpreter": "Shell interpreter",
@@ -1405,7 +1472,7 @@ export const en = {
"settings.shellPowershell": "PowerShell (Windows PowerShell)",
"settings.shellPwsh": "pwsh (PowerShell 7+)",
"settings.bashSandbox": "Bash sandbox",
"settings.bashEnforce": "enforce (jail bash)",
"settings.bashEnforce": "enforce (jail bash; fail closed)",
"settings.bashOff": "off (run unconfined)",
"settings.allowNetwork": "Allow network egress from sandboxed bash",
"settings.workspaceRoot": "Workspace root",
@@ -1498,6 +1565,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",
@@ -1532,6 +1602,15 @@ export const en = {
"settings.modelUsage": "Model usage",
"settings.modelUnavailable": "{ref} is not available. Pick a configured model or add provider access.",
"settings.modelNeedsKey": "{provider} needs an API key before this model can run.",
"settings.errorUnknown": "Operation failed.",
"settings.errorUnknownModel": "Unknown model {model}.",
"settings.errorModelProviderMissing": "Model {model} is unavailable because provider {provider} is not added.",
"settings.errorModelProviderNoKey": "Model {model} is unavailable because provider {provider} has no API key.",
"settings.errorRemoveAccessBusy": "Stop active work using {provider} before removing provider access.",
"settings.errorDeleteProviderBusy": "Stop active work using {provider} before deleting the provider.",
"settings.errorSaveBeforeRemoveAccess": "Could not save the current session before removing provider access: {err}",
"settings.errorSaveBeforeDeleteProvider": "Could not save the current session before deleting the provider: {err}",
"settings.errorRemoveProviderNoFallback": "Provider {provider} is used by open tabs, and no other configured provider is available.",
"settings.customProviders": "Custom providers",
"settings.customProvidersHint": "OpenAI-compatible or proxy providers you add yourself. These can be edited, deleted, and updated with fetched model lists.",
"settings.customProvidersEmpty": "No custom providers yet. Prefer the built-in access above for common services.",
@@ -1558,6 +1637,8 @@ export const en = {
"settings.addProvider.customHint": "Use this for proxies, aggregators, or other OpenAI-compatible services.",
"settings.addProvider.official.deepseek": "DeepSeek Official",
"settings.addProvider.official.deepseekDesc": "Official API key access with DeepSeek V4 Flash / Pro.",
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat Platform OpenAI-compatible endpoint with LongCat-2.0 default.",
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat Platform Anthropic-compatible Messages endpoint using Bearer auth.",
"settings.addProvider.preset.kimiCnDesc": "Kimi China API via Moonshot CN endpoint with K2 vision models.",
"settings.addProvider.preset.kimiGlobalDesc": "Kimi international API via Moonshot global endpoint with K2 vision models.",
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan via the dedicated Anthropic-compatible endpoint; not a CN/Global variant.",
@@ -1642,6 +1723,8 @@ export const en = {
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
"settings.providerExtraBodyHint": "Top-level JSON fields merged into the chat request body. Core fields such as model, messages, tools, stream, and thinking stay controlled by Reasonix.",
"settings.providerExtraBodyError": "Enter a JSON object without null values, for example { \"enable_thinking\": true }.",
"settings.providerExtraBodyNull": "{path} cannot contain null.",
"settings.providerExtraBodyObjectRequired": "Extra body must be a JSON object.",
"settings.providerAuthHeader": "Use Authorization: Bearer for this key",
"settings.providerAuthHeaderHint": "For Anthropic-compatible gateways such as MiniMax Global or Vercel AI Gateway that do not use x-api-key.",
"settings.fetchingModels": "Refreshing…",
@@ -1724,6 +1807,10 @@ export const en = {
"settings.hooksJsonApplied": "JSON validated. Save to persist it.",
"settings.hooksJsonFormatted": "JSON formatted. Save to persist it.",
"settings.hooksJsonInvalid": "Invalid JSON: {error}",
"settings.hooksJsonExpectedObjectArray": "Expected an object or array.",
"settings.hooksJsonItemObject": "Hook item must be an object.",
"settings.hooksJsonUnknownEvent": "Unknown hook event {event}.",
"settings.hooksJsonEventItemObject": "Hook {event} item must be an object.",
"settings.hooksJsonClipboardEmpty": "Clipboard is empty",
"settings.hooksJsonClipboardUnavailable": "Clipboard is unavailable; copy the textarea manually.",
"settings.hooksJsonPasteFailed": "Paste failed: {error}",
@@ -1798,6 +1885,34 @@ export const en = {
"transcript.processedDuration": "Processed {s}s",
"notice.info": "Notice",
"notice.warning": "Warning",
"notice.details": "Details",
"notice.finalReadiness": "Task status needs one more check; asking the assistant to finish or explain what is blocking it.",
"notice.emptyFinal": "No visible answer was produced; asking the assistant to respond again.",
"notice.executorHandoff": "The assistant answered before taking action; asking it to use the required tools.",
"notice.toolBudget": "Tool round limit reached; asking the assistant to summarize progress.",
"notice.loopGuard": "The assistant is stuck retrying a blocked action; asking it to change approach.",
"notice.contextLarge": "Context is getting large; preserving cache until cleanup is needed.",
"notice.contextCleanupSkipped": "Context cleanup skipped for now.",
"notice.contextCleanupPaused": "Automatic context cleanup paused because the context window is too small.",
"notice.compactionNoSummary": "Context was compacted without a generated summary.",
"notice.autoPlanEnabled": "Planning mode enabled for this multi-step task.",
"notice.autoPlanRequested": "Plan detection requested a plan.",
"notice.autoPlanFallback": "Plan detection was uncertain; using the fallback planner heuristic.",
"notice.goalNotReady": "Goal is not ready to complete yet; continuing the remaining work.",
"notice.goalUnfinished": "Goal still has unfinished task state; continuing the remaining work.",
"notice.autoresearchStatusFailed": "AutoResearch status update failed.",
"notice.autoresearchBlocked": "AutoResearch task marked blocked.",
"notice.backgroundJobFailed": "Background {kind} needs attention.",
"notice.jobArtifactMigrationFailed": "Job artifact migration failed.",
"notice.jobTeardownTimeout": "Background job teardown timed out.",
"notice.planModeToolSettingsIgnored": "Some plan-mode tool settings were ignored.",
"notice.planModeCommandSettingsIgnored": "Some plan-mode command settings were ignored.",
"notice.configMigrationIncomplete": "Config migration did not complete.",
"notice.modelMissingApiKey": "Selected model is missing its API key.",
"notice.mcpServerFailed": "An MCP server failed to start.",
"notice.mcpServersFailed": "Some MCP servers failed to start; run /mcp for details.",
"notice.guardianModelMissing": "Guardian was disabled because its model was not found.",
"notice.guardianStartFailed": "Guardian was disabled because it could not start.",
"questionNav.label": "Question navigation",
"questionNav.progress": "Question {current} / {total}",
"questionNav.jump": "Jump to question {n}",
@@ -1813,6 +1928,7 @@ export const en = {
"rewind.disabledNoBoundary": "Conversation boundary is unavailable for this message",
"rewind.disabledNoCode": "No code changes were captured after this message",
"rewind.disabledNoEarlier": "There is nothing before this message to summarize",
"rewind.disabledNoLater": "There is nothing after this message to summarize",
"rewind.filesChanged": "{count} files",
"rewind.turnFiles": "{count} this turn",
"rewind.both": "Rewind code and conversation",
@@ -1833,6 +1949,7 @@ export const en = {
"rewind.busyConversation": "Rewinding conversation…",
"rewind.busyCode": "Rewinding code…",
"rewind.busyBoth": "Rewinding code and conversation…",
"rewind.failed": "Rewind failed",
"undoRewind.turns": "Rolled back {n} turn(s)",
"undoRewind.filesRestored": "{n} file(s) restored",
"undoRewind.filesRemoved": "{n} file(s) removed",
@@ -1843,8 +1960,12 @@ export const en = {
// tool card summaries
"tool.stepOne": "{n} step",
"tool.stepOther": "{n} steps",
"tool.error": "error",
"tool.errorReceiptMismatch": "verification command has no matching successful receipt",
"tool.truncated": "output truncated",
"tool.showAllLines": "show all {n} lines",
"tool.showErrorDetails": "show error details",
"tool.hideErrorDetails": "hide error details",
"tool.readCount": "Read {n} files",
"tool.searchCount": "Search {n} files",
"tool.otherReadCount": "{n} read calls",
@@ -2015,6 +2136,9 @@ export const en = {
"mock.topicAi": "AI engineering best practices",
"mock.topicLab": "Exploratory experiments",
"mock.approvalSubject": "npm run build\n\nRun the build command to verify frontend artifacts and style packaging.",
"mock.sandboxEscapeReasoning": "Windows sandbox helper failed; requesting a one-time unconfined rerun approval.",
"mock.sandboxEscapeSubject": "run unconfined once: go test ./...",
"mock.sandboxEscapeReason": "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
"mock.askQ1Header": "Approach",
"mock.askQ1Prompt": "How do you want to handle the git pull conflicts?",
"mock.askQ1Opt1Label": "git stash then pull",
+137 -13
View File
@@ -22,6 +22,7 @@ export const zhTW: Record<DictKey, string> = {
"common.busyHint": "請先完成或停止當前回合",
"common.loading": "載入中…",
"composer.readOnlyChannel": "渠道會話唯讀",
"composer.workspaceStarting": "工作區還在啟動",
"app.splashSubtitle": "Agent Workspace",
// 頂欄
@@ -349,7 +350,10 @@ export const zhTW: Record<DictKey, string> = {
"composer.send": "傳送(Enter)",
"composer.queueGuidance": "加入引導佇列(Enter)",
"composer.steer": "追加到目前任務(Enter)",
"composer.steerPlaceholder": "輸入要加入佇列的引導…",
"composer.steerPlaceholder": "正在執行——輸入補充指示,Enter 加入佇列",
"composer.runWaitingApproval": "等待你核准 — {tool}",
"composer.runWaitingAsk": "等待你回答",
"composer.runAnnounceRunning": "正在執行",
"composer.guidanceQueue": "待處理引導",
"composer.guidanceCount": "待處理引導 {n}",
"composer.guidanceRemaining": "還有 {n} 條",
@@ -385,7 +389,31 @@ export const zhTW: Record<DictKey, string> = {
"modelSwitcher.searchPlaceholder": "搜尋模型…",
"modelSwitcher.noMatches": "沒有匹配的模型",
"status.noModels": "沒有可切換模型",
"status.modelSwitchBusy": "暫時不能切換模型。請先停止目前回答、處理待確認問題,或等待背景任務結束。",
"status.modelSwitchLeaseHeld": "這個會話正在另一個 Reasonix 視窗或背景執行。請關閉那個視窗、停止背景執行,或開啟副本後再切換模型。",
"status.modelSwitchStarting": "會話還在啟動,稍後再切換模型。",
"status.modelSwitchStartupFailed": "目前會話啟動失敗,無法切換模型:{err}",
"status.modelSwitchRetry": "目前會話已變更,請再試一次切換模型。",
"status.modelSwitchUnknown": "未知模型 {model}。",
"status.modelSwitchProviderUnavailable": "模型 {model} 不可用,因為提供商 {provider} 尚未添加。",
"status.modelSwitchFailed": "模型切換失敗:{err}",
"status.modelFallbackSwitched": "模型 {model} 已不可用,已切換到 {fallback}。",
"status.effortSwitchBusy": "暫時不能切換推理力度。請先停止目前回答、處理待確認問題,或等待背景任務結束。",
"status.effortSwitchLeaseHeld": "這個會話正在另一個 Reasonix 視窗或背景執行。請關閉那個視窗、停止背景執行,或開啟副本後再切換推理力度。",
"status.effortSwitchStarting": "會話還在啟動,稍後再切換推理力度。",
"status.effortSwitchStartupFailed": "目前會話啟動失敗,無法切換推理力度:{err}",
"status.effortSwitchRetry": "目前會話已變更,請再試一次切換推理力度。",
"status.effortSwitchFailed": "推理力度切換失敗:{err}",
"status.sessionAutosaveFailed": "會話自動儲存失敗:{err}",
"status.sessionSaveFailedBefore": "在{action}前儲存會話失敗:{err}",
"status.actionChangingModel": "切換模型",
"status.actionChangingEffort": "切換推理力度",
"status.actionChangingTokenMode": "切換省 token 模式",
"status.actionRebuildingSettings": "重新整理設定",
"status.actionSwitchingSessions": "切換會話",
"status.actionSwitchingTabs": "切換標籤頁",
"status.actionAutosave": "自動儲存",
"status.actionCurrentSession": "儲存目前會話",
"status.effort": "effort {level}",
"status.effortTitle": "推理力度",
"status.effortAutoTitle": "推理力度:auto(模型預設:{def})",
@@ -495,6 +523,13 @@ export const zhTW: Record<DictKey, string> = {
"approval.sendRevision": "傳送修改意見",
"approval.exitPlan": "退出計畫",
"approval.toolPending": "允許工具呼叫",
"approval.modeSwitchPendingHint": "核准檔位已放寬——這筆擱置的請求仍需你確認。",
"approval.toolLabelExitPlan": "計畫確認",
"approval.allowOnceDesc": "僅允許本次呼叫,下次仍會詢問。",
"approval.allowRuleSessionDesc": "本會話內符合的呼叫不再詢問,重啟後失效。",
"approval.allowRulePersistentDesc": "儲存為持久允許規則,之後的會話也不再詢問。",
"approval.denyDesc": "拒絕本次呼叫,模型會收到拒絕結果並繼續。",
"approval.allowSandboxEscapeSessionDesc": "本會話後續命令改在真實環境執行,跳過沙箱。",
"approval.details": "詳情",
"approval.hideDetails": "收起",
"approval.deny": "拒絕",
@@ -509,6 +544,29 @@ export const zhTW: Record<DictKey, string> = {
"approval.allowRulePersistent": "總是允許(儲存)",
"approval.allowEditsSession": "本會話允許編輯",
"approval.allowFilePersistent": "總是允許此檔案(儲存)",
"approval.allowSandboxEscapeSession": "本會話使用真實環境",
"approval.toolLabelBash": "bash",
"approval.toolLabelEditFile": "編輯檔案",
"approval.toolLabelWriteFile": "寫入檔案",
"approval.toolLabelMultiEdit": "批次編輯",
"approval.toolLabelMoveFile": "移動檔案",
"approval.toolLabelWebFetch": "讀取網頁",
"approval.toolLabelRunSkill": "執行技能",
"approval.toolLabelRemember": "儲存記憶",
"approval.toolLabelForget": "刪除記憶",
"approval.toolLabelSandboxEscape": "bash 沙箱降級核准",
"approval.toolLabelPlanModeReadOnly": "計劃模式唯讀命令",
"approval.memorySaveUpdate": "儲存/更新記憶",
"approval.memoryBodyLabel": "正文",
"approval.memoryArchivePrefix": "封存記憶 ",
"approval.planModeMcpTrustSubject": "將 MCP {target} 作為計劃/研究唯讀工具信任",
"approval.planModeMcpTrustReason": "這個 MCP 工具宣告自己是唯讀的,但外部唯讀提示需要你確認後,計劃模式才能使用。選擇總是允許可為後續計劃和唯讀研究記住這份信任。",
"approval.planModeBashTrustSubject": "在計劃模式中信任 {prefix} 為唯讀命令前綴\n命令:{command}",
"approval.planModeBashTrustReason": "這條 bash 命令不在 Reasonix 內建唯讀集合中。只有在確認這個精確前綴用於計劃和研究時是唯讀的,才應核准。自動/YOLO 核准不能回答這個信任提示。",
"approval.sandboxEscapeSubjectFallback": "僅本次不進沙箱執行 shell 命令",
"approval.sandboxEscapeSubjectPrefix": "僅本次不進沙箱執行:",
"approval.sandboxEscapeWrapReason": "Windows 沙箱無法包裝這條命令。是否僅本次不進 OS 沙箱執行?這只會對此命令繞過 OS 沙箱。",
"approval.sandboxEscapeRuntimeReason": "Windows 沙箱啟動這條命令時失敗。是否僅本次不進 OS 沙箱執行?這只會對此命令繞過 OS 沙箱。",
// 提問卡片
"ask.title": "需要你決定",
@@ -538,6 +596,12 @@ export const zhTW: Record<DictKey, string> = {
"history.clearTrash": "清空回收站",
"history.confirmClearTrash": "確認清空回收站",
"history.clearTrashMenu": "清空回收站…",
"history.clearRecoveryCopies": "清理副本",
"history.confirmClearRecoveryCopies": "確認清理副本",
"history.clearRecoveryCopiesMenu": "清理恢復副本…",
"history.trashRecoveryCopies": "副本移入回收站",
"history.confirmTrashRecoveryCopies": "確認移入回收站",
"history.recoveryCopiesGroup": "恢復副本",
"history.moveToTrash": "移到回收站",
"history.confirmMoveToTrash": "確認移入回收站",
"history.restore": "還原",
@@ -569,22 +633,19 @@ export const zhTW: Record<DictKey, string> = {
"history.failedOpenProject": "無法開啟會話,{name}不存在:{path}",
"history.failedOpenSession": "無法開啟會話",
"history.failedOpenGlobal": "無法開啟全域會話",
"history.missingWorkspaceRoot": "無法開啟會話:缺少工作區路徑。",
"history.selectSession": "選擇一個會話以預覽。",
"history.trashSessionActions": "回收站會話操作",
"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}",
"recovery.branch": "衝突副本",
"recovery.badge": "副本",
"recovery.noticeSavedCopy": "偵測到會話在磁碟上發生變化,未儲存的本機記錄已保留為衝突副本。",
"recovery.noticeKeptCurrent": "偵測到重複儲存衝突,目前衝突副本已繼續儲存。",
"recovery.noticeAdopted": "偵測到會話在磁碟上發生變化,已採用較新的記錄。",
"recovery.noticeAdoptedCovered": "偵測到會話在磁碟上發生變化,已採用較新的記錄;本機改動已包含其中。",
// 專案樹
"projectTree.workspaceTitle": "專案",
@@ -885,11 +946,11 @@ export const zhTW: Record<DictKey, string> = {
"settings.yoloHint": "所有工具呼叫(寫檔案、bash)自動執行、不再詢問。deny 規則仍會攔截。僅執行時生效,重啟後失效。",
"settings.addRule": "新增 {list} 規則…",
"settings.sandboxTitle": "沙箱與工作區",
"settings.sandboxBoundaryHint": "沙箱可寫根只限制已核准的 Bash 和檔案工具能寫到哪裡;它不會自動核准命令。mvn deploy 這類命令還需要權限規則或核准模式放行。",
"settings.sandboxBoundaryHint": "沙箱可寫根只限制已核准的 Bash 和檔案工具能寫到哪裡;它不會自動核准命令。若 Bash 沙箱為 enforce 但宿主機沒有 OS 沙箱後端,shell 命令會被拒絕;只在可信工作區把 Bash 沙箱切到 off。",
"settings.reloadSessionConfig": "套用手動設定修改",
"settings.reloadSessionConfigHint": "設定頁內修改會自動生效;此按鈕用於套用手動編輯 config.toml / reasonix.toml 後的變更。",
"settings.bashSandbox": "Bash 沙箱",
"settings.bashEnforce": "enforce(隔離 bash)",
"settings.bashEnforce": "enforce(隔離 bash;失敗即拒絕)",
"settings.bashOff": "off(不受限執行)",
"settings.allowNetwork": "允許沙箱內 bash 存取網路",
"settings.workspaceRoot": "工作區根目錄",
@@ -913,6 +974,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": "微軟正黑體",
@@ -941,6 +1005,15 @@ export const zhTW: Record<DictKey, string> = {
"settings.modelUsage": "使用模型",
"settings.modelUnavailable": "{ref} 不可用。請選擇已設定模型,或先新增模型服務接入。",
"settings.modelNeedsKey": "{provider} 需要設定 API key 後才能執行該模型。",
"settings.errorUnknown": "操作失敗。",
"settings.errorUnknownModel": "未知模型 {model}。",
"settings.errorModelProviderMissing": "模型 {model} 不可用,因為提供商 {provider} 尚未添加。",
"settings.errorModelProviderNoKey": "模型 {model} 不可用,因為提供商 {provider} 尚未設定 API Key。",
"settings.errorRemoveAccessBusy": "請先停止正在使用 {provider} 的任務,再移除模型服務接入。",
"settings.errorDeleteProviderBusy": "請先停止正在使用 {provider} 的任務,再刪除模型服務。",
"settings.errorSaveBeforeRemoveAccess": "移除模型服務接入前儲存目前會話失敗:{err}",
"settings.errorSaveBeforeDeleteProvider": "刪除模型服務前儲存目前會話失敗:{err}",
"settings.errorRemoveProviderNoFallback": "模型服務 {provider} 正被開啟的標籤頁使用,且沒有其他可用模型服務可切換。",
"settings.customProviders": "自訂供應商",
"settings.customProvidersHint": "你自己新增的 OpenAI-compatible 或代理接入,可以編輯、刪除並持久化模型列表。",
"settings.customProvidersEmpty": "還沒有自訂供應商。常用服務優先使用上方內建接入。",
@@ -967,6 +1040,8 @@ export const zhTW: Record<DictKey, string> = {
"settings.addProvider.customHint": "適合代理、聚合平台或其它 OpenAI-compatible 服務。",
"settings.addProvider.official.deepseek": "DeepSeek 官方",
"settings.addProvider.official.deepseekDesc": "官方 API Key 接入,可用 DeepSeek V4 Flash / Pro。",
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat 開放平台 OpenAI-compatible 端點,預設 LongCat-2.0。",
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat 開放平台 Anthropic-compatible Messages 端點,使用 Bearer 認證。",
"settings.addProvider.preset.kimiCnDesc": "Kimi 中國 API,走 Moonshot CN 端點,預置 K2 視覺模型。",
"settings.addProvider.preset.kimiGlobalDesc": "Kimi 國際 API,走 Moonshot global 端點,預置 K2 視覺模型。",
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan,走獨立 Anthropic-compatible 端點,不歸屬 CN/Global。",
@@ -1051,6 +1126,8 @@ export const zhTW: Record<DictKey, string> = {
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
"settings.providerExtraBodyHint": "會合併到聊天請求體頂層 JSON。model、messages、tools、stream、thinking 等核心欄位仍由 Reasonix 控制。",
"settings.providerExtraBodyError": "請輸入不含 null 值的 JSON 物件,例如 { \"enable_thinking\": true }。",
"settings.providerExtraBodyNull": "{path} 不能包含 null。",
"settings.providerExtraBodyObjectRequired": "額外請求體必須是 JSON 物件。",
"settings.providerAuthHeader": "使用 Authorization: Bearer 發送金鑰",
"settings.providerAuthHeaderHint": "適用於 MiniMax Global、Vercel AI Gateway 等不使用 x-api-key 的 Anthropic-compatible 閘道。",
"settings.fetchingModels": "重新整理中…",
@@ -1144,6 +1221,34 @@ export const zhTW: Record<DictKey, string> = {
"transcript.processedDuration": "已處理 {s}s",
"notice.info": "提示",
"notice.warning": "警告",
"notice.details": "詳情",
"notice.finalReadiness": "任務狀態還需要確認,已要求助手完成或說明阻塞原因。",
"notice.emptyFinal": "沒有產生可見回覆,已要求助手重新作答。",
"notice.executorHandoff": "助手尚未執行必要操作,已要求它使用相應工具。",
"notice.toolBudget": "工具呼叫輪數已達上限,已要求助手總結目前進度。",
"notice.loopGuard": "助手卡在重複受阻操作上,已要求它換一種處理方式。",
"notice.contextLarge": "上下文正在變大,暫時保留快取,等需要時再清理。",
"notice.contextCleanupSkipped": "本次暫未清理上下文。",
"notice.contextCleanupPaused": "自動上下文清理已暫停:目前上下文視窗太小。",
"notice.compactionNoSummary": "上下文已壓縮,但未產生摘要。",
"notice.autoPlanEnabled": "已為這個多步驟任務開啟規劃模式。",
"notice.autoPlanRequested": "任務判斷建議先制定計畫。",
"notice.autoPlanFallback": "規劃判斷不確定,已使用備用規則。",
"notice.goalNotReady": "目標暫未達到完成條件,繼續處理剩餘工作。",
"notice.goalUnfinished": "目標還有未完成的任務狀態,繼續處理。",
"notice.autoresearchStatusFailed": "AutoResearch 狀態更新失敗。",
"notice.autoresearchBlocked": "AutoResearch 任務已標記為阻塞。",
"notice.backgroundJobFailed": "背景 {kind} 需要處理。",
"notice.jobArtifactMigrationFailed": "任務產物遷移失敗。",
"notice.jobTeardownTimeout": "背景任務清理逾時。",
"notice.planModeToolSettingsIgnored": "部分規劃模式工具設定已被忽略。",
"notice.planModeCommandSettingsIgnored": "部分規劃模式指令設定已被忽略。",
"notice.configMigrationIncomplete": "設定遷移未完成。",
"notice.modelMissingApiKey": "目前模型缺少 API key。",
"notice.mcpServerFailed": "一個 MCP 服務啟動失敗。",
"notice.mcpServersFailed": "部分 MCP 服務啟動失敗,可執行 /mcp 查看詳情。",
"notice.guardianModelMissing": "Guardian 已停用:未找到對應模型。",
"notice.guardianStartFailed": "Guardian 啟動失敗,已停用。",
"questionNav.label": "問題導航",
"questionNav.progress": "問題 {current} / {total}",
"questionNav.jump": "跳轉到問題 {n}",
@@ -1159,6 +1264,7 @@ export const zhTW: Record<DictKey, string> = {
"rewind.disabledNoBoundary": "這條訊息的會話邊界不可用",
"rewind.disabledNoCode": "這條訊息之後沒有捕獲到程式碼變更",
"rewind.disabledNoEarlier": "這條訊息之前沒有可總結內容",
"rewind.disabledNoLater": "這條訊息之後沒有可總結內容",
"rewind.filesChanged": "{count} 個檔案",
"rewind.turnFiles": "本輪 {count} 個",
"rewind.both": "回滾程式碼和對話",
@@ -1179,6 +1285,7 @@ export const zhTW: Record<DictKey, string> = {
"rewind.busyConversation": "正在回滾對話…",
"rewind.busyCode": "正在回滾程式碼…",
"rewind.busyBoth": "正在回滾程式碼和對話…",
"rewind.failed": "回滾失敗",
"undoRewind.turns": "已回滾 {n} 輪對話",
"undoRewind.filesRestored": "還原了 {n} 個檔案",
"undoRewind.filesRemoved": "移除了 {n} 個檔案",
@@ -1189,8 +1296,12 @@ export const zhTW: Record<DictKey, string> = {
// 工具卡片摘要
"tool.stepOne": "{n} 步",
"tool.stepOther": "{n} 步",
"tool.error": "錯誤",
"tool.errorReceiptMismatch": "證據命令沒有匹配的成功執行記錄",
"tool.truncated": "輸出已截斷",
"tool.showAllLines": "顯示全部 {n} 行",
"tool.showErrorDetails": "顯示錯誤詳情",
"tool.hideErrorDetails": "隱藏錯誤詳情",
"tool.lineOne": "{n} 行",
"tool.lineOther": "{n} 行",
"tool.matchOne": "{n} 處匹配",
@@ -1305,6 +1416,9 @@ export const zhTW: Record<DictKey, string> = {
"mock.topicAi": "AI 工程化最佳實踐",
"mock.topicLab": "臨時探索與實驗",
"mock.approvalSubject": "npm run build\n\n需要執行建構命令來驗證前端產物和樣式打包是否正常。",
"mock.sandboxEscapeReasoning": "Windows 沙箱 helper 失敗,需要確認是否僅本次不進沙箱重跑。",
"mock.sandboxEscapeSubject": "僅本次不進沙箱執行:go test ./...",
"mock.sandboxEscapeReason": "Windows 沙箱啟動這條命令時失敗。是否僅本次不進 OS 沙箱執行?這只會對此命令繞過 OS 沙箱。",
"mock.askQ1Header": "處理方向",
"mock.askQ1Prompt": "git pull 的衝突你想怎麼處理?",
"mock.askQ1Opt1Label": "git stash 後 pull",
@@ -1476,6 +1590,11 @@ export const zhTW: Record<DictKey, string> = {
"status.costLabel": "會話費用",
"status.balanceLabel": "餘額",
"status.jobsLabel": "任務",
"status.tokenModeSwitchBusy": "暫時不能切換省 token 模式。請先停止目前回答、處理待確認問題,或等待背景任務結束。",
"status.tokenModeSwitchLeaseHeld": "這個會話正在另一個 Reasonix 視窗或背景執行。請關閉那個視窗、停止背景執行,或開啟副本後再切換省 token 模式。",
"status.tokenModeSwitchStarting": "會話還在啟動,稍後再切換省 token 模式。",
"status.tokenModeSwitchStartupFailed": "目前會話啟動失敗,無法切換省 token 模式:{err}",
"status.tokenModeSwitchRetry": "目前會話已變更,請再試一次切換省 token 模式。",
"status.tokenModeSwitchFailed": "省 token 模式切換失敗:{err}",
"status.modelTitle": "當前模型",
"clearContext.title": "清空當前上下文",
@@ -1486,6 +1605,7 @@ export const zhTW: Record<DictKey, string> = {
"clearContext.done": "已清空當前上下文",
"clearContext.failed": "清空當前上下文失敗",
"projectTree.justNow": "剛剛",
"projectTree.previously": "之前",
"projectTree.running": "輸出中",
"projectTree.status.thinking": "思考中",
"projectTree.status.streaming": "輸出中",
@@ -1954,6 +2074,10 @@ export const zhTW: Record<DictKey, string> = {
"settings.hooksJsonApplied": "JSON 已校驗,儲存後生效。",
"settings.hooksJsonFormatted": "JSON 已格式化,儲存後生效。",
"settings.hooksJsonInvalid": "JSON 無效:{error}",
"settings.hooksJsonExpectedObjectArray": "應為物件或陣列。",
"settings.hooksJsonItemObject": "Hook 項必須是物件。",
"settings.hooksJsonUnknownEvent": "未知 Hook 事件 {event}。",
"settings.hooksJsonEventItemObject": "Hook {event} 的項目必須是物件。",
"settings.hooksJsonClipboardEmpty": "剪貼簿為空",
"settings.hooksJsonClipboardUnavailable": "無法存取剪貼簿;可手動複製文字框內容。",
"settings.hooksJsonPasteFailed": "貼上失敗:{error}",
+137 -13
View File
@@ -22,6 +22,7 @@ export const zh: Record<DictKey, string> = {
"common.busyHint": "请先完成或停止当前回合",
"common.loading": "加载中…",
"composer.readOnlyChannel": "渠道会话只读",
"composer.workspaceStarting": "工作区还在启动",
"app.splashSubtitle": "Agent Workspace",
// 顶栏
@@ -471,7 +472,10 @@ export const zh: Record<DictKey, string> = {
"composer.send": "发送(Enter)",
"composer.queueGuidance": "加入引导队列(Enter)",
"composer.steer": "追加到当前任务(Enter)",
"composer.steerPlaceholder": "输入要加入队列的引导…",
"composer.steerPlaceholder": "正在运行——输入补充指示,回车加入队列",
"composer.runWaitingApproval": "等待你批准 — {tool}",
"composer.runWaitingAsk": "等待你回答",
"composer.runAnnounceRunning": "正在运行",
"composer.guidanceQueue": "待处理引导",
"composer.guidanceCount": "待处理引导 {n}",
"composer.guidanceRemaining": "还有 {n} 条",
@@ -532,8 +536,37 @@ export const zh: Record<DictKey, string> = {
"modelSwitcher.searchPlaceholder": "搜索模型…",
"modelSwitcher.noMatches": "没有匹配的模型",
"status.noModels": "没有可切换模型",
"status.modelSwitchBusy": "暂时不能切换模型。请先停止当前回答、处理待确认问题,或等待后台任务结束。",
"status.modelSwitchLeaseHeld": "这个会话正在另一个 Reasonix 窗口或后台运行。请关闭那个窗口、停止后台运行,或打开副本后再切换模型。",
"status.modelSwitchStarting": "会话还在启动,稍后再切换模型。",
"status.modelSwitchStartupFailed": "当前会话启动失败,无法切换模型:{err}",
"status.modelSwitchRetry": "当前会话已变化,请再试一次切换模型。",
"status.modelSwitchUnknown": "未知模型 {model}。",
"status.modelSwitchProviderUnavailable": "模型 {model} 不可用,因为提供商 {provider} 尚未添加。",
"status.modelSwitchFailed": "模型切换失败:{err}",
"status.modelFallbackSwitched": "模型 {model} 已不可用,已切换到 {fallback}。",
"status.effortSwitchBusy": "暂时不能切换推理力度。请先停止当前回答、处理待确认问题,或等待后台任务结束。",
"status.effortSwitchLeaseHeld": "这个会话正在另一个 Reasonix 窗口或后台运行。请关闭那个窗口、停止后台运行,或打开副本后再切换推理力度。",
"status.effortSwitchStarting": "会话还在启动,稍后再切换推理力度。",
"status.effortSwitchStartupFailed": "当前会话启动失败,无法切换推理力度:{err}",
"status.effortSwitchRetry": "当前会话已变化,请再试一次切换推理力度。",
"status.effortSwitchFailed": "推理力度切换失败:{err}",
"status.tokenModeSwitchBusy": "暂时不能切换省 token 模式。请先停止当前回答、处理待确认问题,或等待后台任务结束。",
"status.tokenModeSwitchLeaseHeld": "这个会话正在另一个 Reasonix 窗口或后台运行。请关闭那个窗口、停止后台运行,或打开副本后再切换省 token 模式。",
"status.tokenModeSwitchStarting": "会话还在启动,稍后再切换省 token 模式。",
"status.tokenModeSwitchStartupFailed": "当前会话启动失败,无法切换省 token 模式:{err}",
"status.tokenModeSwitchRetry": "当前会话已变化,请再试一次切换省 token 模式。",
"status.tokenModeSwitchFailed": "省 token 模式切换失败:{err}",
"status.sessionAutosaveFailed": "会话自动保存失败:{err}",
"status.sessionSaveFailedBefore": "在{action}前保存会话失败:{err}",
"status.actionChangingModel": "切换模型",
"status.actionChangingEffort": "切换推理力度",
"status.actionChangingTokenMode": "切换省 token 模式",
"status.actionRebuildingSettings": "刷新设置",
"status.actionSwitchingSessions": "切换会话",
"status.actionSwitchingTabs": "切换标签页",
"status.actionAutosave": "自动保存",
"status.actionCurrentSession": "保存当前会话",
"status.effort": "effort {level}",
"status.effortTitle": "推理力度",
"status.effortAutoTitle": "推理力度:auto(模型默认:{def})",
@@ -644,6 +677,13 @@ export const zh: Record<DictKey, string> = {
"approval.sendRevision": "发送修改意见",
"approval.exitPlan": "退出计划",
"approval.toolPending": "允许工具调用",
"approval.modeSwitchPendingHint": "审批档位已放宽——本次挂起的请求仍需你确认。",
"approval.toolLabelExitPlan": "计划确认",
"approval.allowOnceDesc": "仅允许本次调用,下次仍会询问。",
"approval.allowRuleSessionDesc": "本会话内匹配的调用不再询问,重启后失效。",
"approval.allowRulePersistentDesc": "保存为持久允许规则,之后的会话也不再询问。",
"approval.denyDesc": "拒绝本次调用,模型会收到拒绝结果并继续。",
"approval.allowSandboxEscapeSessionDesc": "本会话后续命令改在真实环境运行,跳过沙箱。",
"approval.details": "详情",
"approval.hideDetails": "收起",
"approval.deny": "拒绝",
@@ -658,6 +698,29 @@ export const zh: Record<DictKey, string> = {
"approval.allowRulePersistent": "总是允许(保存)",
"approval.allowEditsSession": "本会话允许编辑",
"approval.allowFilePersistent": "总是允许此文件(保存)",
"approval.allowSandboxEscapeSession": "本会话使用真实环境",
"approval.toolLabelBash": "bash",
"approval.toolLabelEditFile": "编辑文件",
"approval.toolLabelWriteFile": "写入文件",
"approval.toolLabelMultiEdit": "批量编辑",
"approval.toolLabelMoveFile": "移动文件",
"approval.toolLabelWebFetch": "读取网页",
"approval.toolLabelRunSkill": "运行技能",
"approval.toolLabelRemember": "保存记忆",
"approval.toolLabelForget": "删除记忆",
"approval.toolLabelSandboxEscape": "bash 沙箱降级审批",
"approval.toolLabelPlanModeReadOnly": "计划模式只读命令",
"approval.memorySaveUpdate": "保存/更新记忆",
"approval.memoryBodyLabel": "正文",
"approval.memoryArchivePrefix": "归档记忆 ",
"approval.planModeMcpTrustSubject": "将 MCP {target} 作为计划/研究只读工具信任",
"approval.planModeMcpTrustReason": "这个 MCP 工具声明自己是只读的,但外部只读提示需要你确认后,计划模式才能使用。选择总是允许可为后续计划和只读研究记住这份信任。",
"approval.planModeBashTrustSubject": "在计划模式中信任 {prefix} 为只读命令前缀\n命令:{command}",
"approval.planModeBashTrustReason": "这条 bash 命令不在 Reasonix 内置只读集合中。只有在确认这个精确前缀用于计划和研究时是只读的,才应批准。自动/YOLO 审批不能回答这个信任提示。",
"approval.sandboxEscapeSubjectFallback": "仅本次不进沙箱运行 shell 命令",
"approval.sandboxEscapeSubjectPrefix": "仅本次不进沙箱运行:",
"approval.sandboxEscapeWrapReason": "Windows 沙箱无法包装这条命令。是否仅本次不进 OS 沙箱运行?这会只对此命令绕过 OS 沙箱。",
"approval.sandboxEscapeRuntimeReason": "Windows 沙箱启动这条命令时失败。是否仅本次不进 OS 沙箱运行?这会只对此命令绕过 OS 沙箱。",
// 提问卡片
"ask.title": "需要你决定",
@@ -696,6 +759,12 @@ export const zh: Record<DictKey, string> = {
"history.clearTrash": "清空回收站",
"history.confirmClearTrash": "确认清空回收站",
"history.clearTrashMenu": "清空回收站…",
"history.clearRecoveryCopies": "清理副本",
"history.confirmClearRecoveryCopies": "确认清理副本",
"history.clearRecoveryCopiesMenu": "清理恢复副本…",
"history.trashRecoveryCopies": "副本移入回收站",
"history.confirmTrashRecoveryCopies": "确认移入回收站",
"history.recoveryCopiesGroup": "恢复副本",
"history.moveToTrash": "移到回收站",
"history.confirmMoveToTrash": "确认移入回收站",
"history.restore": "恢复",
@@ -727,22 +796,19 @@ export const zh: Record<DictKey, string> = {
"history.failedOpenProject": "无法打开会话,{name}不存在:{path}",
"history.failedOpenSession": "无法打开会话",
"history.failedOpenGlobal": "无法打开全局会话",
"history.missingWorkspaceRoot": "无法打开会话:缺少工作区路径。",
"history.selectSession": "选择一个会话以预览。",
"history.trashSessionActions": "回收站会话操作",
"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}",
"recovery.branch": "冲突副本",
"recovery.badge": "副本",
"recovery.noticeSavedCopy": "检测到会话在磁盘上发生变化,未保存的本地记录已保留为冲突副本。",
"recovery.noticeKeptCurrent": "检测到重复保存冲突,当前冲突副本已继续保存。",
"recovery.noticeAdopted": "检测到会话在磁盘上发生变化,已采用较新的记录。",
"recovery.noticeAdoptedCovered": "检测到会话在磁盘上发生变化,已采用较新的记录;本地改动已包含其中。",
// 项目树
"projectTree.workspaceTitle": "项目",
@@ -758,6 +824,7 @@ export const zh: Record<DictKey, string> = {
"projectTree.renameProjectWorkbench": "重命名项目",
"projectTree.projectHistory": "查看项目历史",
"projectTree.justNow": "刚刚",
"projectTree.previously": "之前",
"projectTree.running": "输出中",
"projectTree.status.thinking": "思考中",
"projectTree.status.streaming": "输出中",
@@ -1398,7 +1465,7 @@ export const zh: Record<DictKey, string> = {
"settings.yoloHint": "Yolo批准会跳过所有工具权限审批(写文件、bash)。deny 规则仍会拦截,ask 问题和计划确认仍会等待你选择。仅运行时生效,重启后失效。",
"settings.addRule": "添加 {list} 规则…",
"settings.sandboxTitle": "沙箱与工作区",
"settings.sandboxBoundaryHint": "沙箱可写根只限制已批准的 Bash 和文件工具能写到哪里;它不会自动批准命令。mvn deploy 这类命令还需要权限规则或审批模式放行。",
"settings.sandboxBoundaryHint": "沙箱可写根只限制已批准的 Bash 和文件工具能写到哪里;它不会自动批准命令。若 Bash 沙箱为 enforce 但宿主机没有 OS 沙箱后端,shell 命令会被拒绝;只在可信工作区把 Bash 沙箱切到 off。",
"settings.reloadSessionConfig": "应用手动配置修改",
"settings.reloadSessionConfigHint": "设置页内修改会自动生效;此按钮用于应用手动编辑 config.toml / reasonix.toml 后的变更。",
"settings.shellInterpreter": "Shell 解释器",
@@ -1407,7 +1474,7 @@ export const zh: Record<DictKey, string> = {
"settings.shellPowershell": "PowerShell(Windows PowerShell)",
"settings.shellPwsh": "pwsh(PowerShell 7+)",
"settings.bashSandbox": "Bash 沙箱",
"settings.bashEnforce": "enforce(隔离 bash)",
"settings.bashEnforce": "enforce(隔离 bash;失败即拒绝)",
"settings.bashOff": "off(不受限运行)",
"settings.allowNetwork": "允许沙箱内 bash 访问网络",
"settings.workspaceRoot": "工作区根目录",
@@ -1500,6 +1567,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": "微软雅黑",
@@ -1534,6 +1604,15 @@ export const zh: Record<DictKey, string> = {
"settings.modelUsage": "使用模型",
"settings.modelUnavailable": "{ref} 不可用。请选择已配置模型,或先添加模型服务接入。",
"settings.modelNeedsKey": "{provider} 需要配置 API key 后才能运行该模型。",
"settings.errorUnknown": "操作失败。",
"settings.errorUnknownModel": "未知模型 {model}。",
"settings.errorModelProviderMissing": "模型 {model} 不可用,因为提供商 {provider} 尚未添加。",
"settings.errorModelProviderNoKey": "模型 {model} 不可用,因为提供商 {provider} 尚未配置 API Key。",
"settings.errorRemoveAccessBusy": "请先停止正在使用 {provider} 的任务,再移除模型服务接入。",
"settings.errorDeleteProviderBusy": "请先停止正在使用 {provider} 的任务,再删除模型服务。",
"settings.errorSaveBeforeRemoveAccess": "移除模型服务接入前保存当前会话失败:{err}",
"settings.errorSaveBeforeDeleteProvider": "删除模型服务前保存当前会话失败:{err}",
"settings.errorRemoveProviderNoFallback": "模型服务 {provider} 正被打开的标签页使用,且没有其他可用模型服务可切换。",
"settings.customProviders": "自定义供应商",
"settings.customProvidersHint": "你自己添加的 OpenAI-compatible 或代理接入,可以编辑、删除并持久化模型列表。",
"settings.customProvidersEmpty": "还没有自定义供应商。常用服务优先使用上方内置接入。",
@@ -1560,6 +1639,8 @@ export const zh: Record<DictKey, string> = {
"settings.addProvider.customHint": "适合代理、聚合平台或其它 OpenAI-compatible 服务。",
"settings.addProvider.official.deepseek": "DeepSeek 官方",
"settings.addProvider.official.deepseekDesc": "官方 API Key 接入,可用 DeepSeek V4 Flash / Pro。",
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat 开放平台 OpenAI-compatible 端点,默认 LongCat-2.0。",
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat 开放平台 Anthropic-compatible Messages 端点,使用 Bearer 认证。",
"settings.addProvider.preset.kimiCnDesc": "Kimi 国内 API,走 Moonshot CN 端点,预置 K2 视觉模型。",
"settings.addProvider.preset.kimiGlobalDesc": "Kimi 国际 API,走 Moonshot global 端点,预置 K2 视觉模型。",
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan,走独立 Anthropic-compatible 端点,不归属 CN/Global。",
@@ -1644,6 +1725,8 @@ export const zh: Record<DictKey, string> = {
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
"settings.providerExtraBodyHint": "会合并到聊天请求体顶层 JSON。model、messages、tools、stream、thinking 等核心字段仍由 Reasonix 控制。",
"settings.providerExtraBodyError": "请输入不含 null 值的 JSON 对象,例如 { \"enable_thinking\": true }。",
"settings.providerExtraBodyNull": "{path} 不能包含 null。",
"settings.providerExtraBodyObjectRequired": "额外请求体必须是 JSON 对象。",
"settings.providerAuthHeader": "使用 Authorization: Bearer 发送密钥",
"settings.providerAuthHeaderHint": "适用于 MiniMax Global、Vercel AI Gateway 等不使用 x-api-key 的 Anthropic-compatible 网关。",
"settings.fetchingModels": "刷新中…",
@@ -1726,6 +1809,10 @@ export const zh: Record<DictKey, string> = {
"settings.hooksJsonApplied": "JSON 已校验,保存后生效。",
"settings.hooksJsonFormatted": "JSON 已格式化,保存后生效。",
"settings.hooksJsonInvalid": "JSON 无效:{error}",
"settings.hooksJsonExpectedObjectArray": "应为对象或数组。",
"settings.hooksJsonItemObject": "Hook 项必须是对象。",
"settings.hooksJsonUnknownEvent": "未知 Hook 事件 {event}。",
"settings.hooksJsonEventItemObject": "Hook {event} 的项目必须是对象。",
"settings.hooksJsonClipboardEmpty": "剪贴板为空",
"settings.hooksJsonClipboardUnavailable": "无法访问剪贴板;可手动复制文本框内容。",
"settings.hooksJsonPasteFailed": "粘贴失败:{error}",
@@ -1800,6 +1887,34 @@ export const zh: Record<DictKey, string> = {
"transcript.processedDuration": "已处理 {s}s",
"notice.info": "提示",
"notice.warning": "警告",
"notice.details": "详情",
"notice.finalReadiness": "任务状态还需要确认,已要求助手完成或说明阻塞原因。",
"notice.emptyFinal": "没有生成可见回复,已要求助手重新作答。",
"notice.executorHandoff": "助手尚未执行必要操作,已要求它使用相应工具。",
"notice.toolBudget": "工具调用轮数已达上限,已要求助手总结当前进展。",
"notice.loopGuard": "助手卡在重复受阻操作上,已要求它换一种处理方式。",
"notice.contextLarge": "上下文正在变大,暂时保留缓存,等需要时再清理。",
"notice.contextCleanupSkipped": "本次暂未清理上下文。",
"notice.contextCleanupPaused": "自动上下文清理已暂停:当前上下文窗口太小。",
"notice.compactionNoSummary": "上下文已压缩,但未生成摘要。",
"notice.autoPlanEnabled": "已为这个多步骤任务开启规划模式。",
"notice.autoPlanRequested": "任务判断建议先制定计划。",
"notice.autoPlanFallback": "规划判断不确定,已使用备用规则。",
"notice.goalNotReady": "目标暂未达到完成条件,继续处理剩余工作。",
"notice.goalUnfinished": "目标还有未完成的任务状态,继续处理。",
"notice.autoresearchStatusFailed": "AutoResearch 状态更新失败。",
"notice.autoresearchBlocked": "AutoResearch 任务已标记为阻塞。",
"notice.backgroundJobFailed": "后台 {kind} 需要处理。",
"notice.jobArtifactMigrationFailed": "任务产物迁移失败。",
"notice.jobTeardownTimeout": "后台任务清理超时。",
"notice.planModeToolSettingsIgnored": "部分规划模式工具设置已被忽略。",
"notice.planModeCommandSettingsIgnored": "部分规划模式命令设置已被忽略。",
"notice.configMigrationIncomplete": "配置迁移未完成。",
"notice.modelMissingApiKey": "当前模型缺少 API key。",
"notice.mcpServerFailed": "一个 MCP 服务启动失败。",
"notice.mcpServersFailed": "部分 MCP 服务启动失败,可运行 /mcp 查看详情。",
"notice.guardianModelMissing": "Guardian 已停用:未找到对应模型。",
"notice.guardianStartFailed": "Guardian 启动失败,已停用。",
"questionNav.label": "问题导航",
"questionNav.progress": "问题 {current} / {total}",
"questionNav.jump": "跳转到问题 {n}",
@@ -1815,6 +1930,7 @@ export const zh: Record<DictKey, string> = {
"rewind.disabledNoBoundary": "这条消息的会话边界不可用",
"rewind.disabledNoCode": "这条消息之后没有捕获到代码改动",
"rewind.disabledNoEarlier": "这条消息之前没有可总结内容",
"rewind.disabledNoLater": "这条消息之后没有可总结内容",
"rewind.filesChanged": "{count} 个文件",
"rewind.turnFiles": "本轮 {count} 个",
"rewind.both": "回滚代码和对话",
@@ -1835,6 +1951,7 @@ export const zh: Record<DictKey, string> = {
"rewind.busyConversation": "正在回滚对话…",
"rewind.busyCode": "正在回滚代码…",
"rewind.busyBoth": "正在回滚代码和对话…",
"rewind.failed": "回滚失败",
"undoRewind.turns": "已回滚 {n} 轮对话",
"undoRewind.filesRestored": "还原了 {n} 个文件",
"undoRewind.filesRemoved": "移除了 {n} 个文件",
@@ -1845,8 +1962,12 @@ export const zh: Record<DictKey, string> = {
// 工具卡片摘要
"tool.stepOne": "{n} 步",
"tool.stepOther": "{n} 步",
"tool.error": "错误",
"tool.errorReceiptMismatch": "证据命令没有匹配的成功执行记录",
"tool.truncated": "输出已截断",
"tool.showAllLines": "显示全部 {n} 行",
"tool.showErrorDetails": "显示错误详情",
"tool.hideErrorDetails": "隐藏错误详情",
"tool.readCount": "已读 {n} 个文件",
"tool.searchCount": "搜索 {n} 个文件",
"tool.otherReadCount": "{n} 个只读调用",
@@ -2017,6 +2138,9 @@ export const zh: Record<DictKey, string> = {
"mock.topicAi": "AI 工程化最佳实践",
"mock.topicLab": "临时探索与实验",
"mock.approvalSubject": "npm run build\n\n需要运行构建命令来验证前端产物和样式打包是否正常。",
"mock.sandboxEscapeReasoning": "Windows 沙箱 helper 失败,需要确认是否仅本次不进沙箱重跑。",
"mock.sandboxEscapeSubject": "仅本次不进沙箱运行:go test ./...",
"mock.sandboxEscapeReason": "Windows 沙箱启动这条命令时失败。是否仅本次不进 OS 沙箱运行?这会只对此命令绕过 OS 沙箱。",
"mock.askQ1Header": "处理方向",
"mock.askQ1Prompt": "git pull 的冲突你想怎么处理?",
"mock.askQ1Opt1Label": "git stash 后 pull",
+296 -227
View File
@@ -2665,27 +2665,6 @@ a[href] {
color: var(--ok);
border-bottom: 1px solid var(--border-soft);
}
.banner--recovery {
display: flex;
align-items: center;
gap: 8px;
background: color-mix(in srgb, var(--warn) 9%, var(--bg-elev));
color: color-mix(in srgb, var(--warn) 72%, var(--fg));
border-bottom: 1px solid color-mix(in srgb, var(--warn) 22%, var(--border-soft));
}
.banner__badge {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 6px;
border-radius: 5px;
background: color-mix(in srgb, var(--warn) 13%, transparent);
color: color-mix(in srgb, var(--warn) 84%, var(--fg));
font-size: 11px;
font-weight: 650;
line-height: 1;
}
.banner--update {
display: flex;
align-items: center;
@@ -3310,6 +3289,20 @@ a[href] {
min-width: 0;
word-break: break-word;
}
.notice-line__details {
margin-top: 4px;
color: var(--fg-faint);
font-size: 12px;
line-height: 1.45;
}
.notice-line__details summary {
cursor: pointer;
user-select: none;
}
.notice-line__details div {
margin-top: 4px;
white-space: pre-wrap;
}
/* ── ReadOnly batch (parent fold for read/search tools) ────────────── */
.readonly-batch {
@@ -5207,11 +5200,22 @@ body > .mermaid-diagram--fullscreen {
min-width: 0;
}
.tool__nested-count {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 34px;
font-family: var(--mono);
font-size: 11px;
color: var(--accent);
color: color-mix(in srgb, var(--fg-dim) 86%, var(--fg));
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.tool__nested-icon {
width: 14px;
height: 14px;
color: inherit;
flex: 0 0 auto;
}
.tool__status-icon {
font-size: 13px;
line-height: 1;
@@ -5272,6 +5276,46 @@ body > .mermaid-diagram--fullscreen {
font-size: 12px;
white-space: pre-wrap;
}
.tool__err--compact {
display: grid;
gap: 6px;
margin: 0 0 6px;
overflow-wrap: anywhere;
}
.tool__err-summary {
min-width: 0;
}
.tool__err-toggle {
justify-self: start;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
opacity: 0.82;
}
.tool__err-toggle:hover {
opacity: 1;
}
.tool__err-toggle-icon {
flex: 0 0 auto;
transition: transform 120ms ease;
}
.tool__err-toggle-icon--open {
transform: rotate(90deg);
}
.tool__err-details {
max-height: 240px;
overflow: auto;
padding-top: 6px;
border-top: 1px solid color-mix(in srgb, var(--err) 18%, transparent);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.tag {
font-size: 10px;
text-transform: uppercase;
@@ -5420,13 +5464,59 @@ body > .mermaid-diagram--fullscreen {
transition: border-color 0.12s;
}
.composer-card--resized {
height: var(--composer-height);
/* --composer-height is always the user's logical height; the run strip's
reservation lives in this calc so the live resize drag (which writes the
raw logical height) and the React render path can never disagree. */
height: calc(var(--composer-height) + var(--composer-run-strip-reserved, 0px));
display: flex;
flex-direction: column;
}
.composer-card:focus-within {
border-color: var(--fg-faint);
}
/* Run strip: an in-flow bar at the top of the card. For user-resized cards
(fixed --composer-height), the JS adds the strip's reserved height so the
meta row stays fully visible. For auto-height cards the card grows
naturally. */
.composer-run-strip {
display: flex;
align-items: center;
gap: 8px;
min-height: 24px;
padding: 4px 14px 0;
color: var(--accent);
font-family: var(--sans);
font-size: 12px;
}
.composer-card--waiting .composer-run-strip {
/* inherited from the card border tint; no extra rule needed */
}
.composer-run-strip__dot {
flex: 0 0 auto;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
animation: pulse 1.2s ease-in-out infinite;
}
.composer-run-strip__text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.composer-run-strip--waiting {
color: color-mix(in srgb, var(--warn) 74%, var(--fg));
}
.composer-run-strip--waiting .composer-run-strip__dot {
animation: none;
}
/* Attention handoff: while a prompt card is waiting on the user, the card
drops the accent running tint and takes a calm warn edge instead. */
.composer-card--waiting {
border-color: color-mix(in srgb, var(--warn) 30%, var(--border));
}
.composer-resize-handle {
--wails-draggable: no-drag;
appearance: none;
@@ -5680,6 +5770,22 @@ body > .mermaid-diagram--fullscreen {
cursor: default;
transform: none;
}
.composer__btn--stop {
margin-right: 6px;
border: 1px solid color-mix(in srgb, var(--err) 42%, transparent);
background: color-mix(in srgb, var(--err) 14%, var(--bg-elev-2));
color: var(--err);
}
.composer__btn--stop:hover,
.composer__btn--stop:focus-visible {
color: var(--fg);
background: color-mix(in srgb, var(--err) 38%, var(--bg-elev-2));
border-color: color-mix(in srgb, var(--err) 70%, var(--border));
}
.composer__btn--stop:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.composer-meta {
display: flex;
align-items: center;
@@ -7197,6 +7303,16 @@ body > .mermaid-diagram--fullscreen {
margin-top: 8px;
flex-wrap: wrap;
}
.approval-mode-hint {
margin: 0;
padding: 6px 10px;
border: 1px solid color-mix(in srgb, var(--warning, #f59e0b) 30%, var(--border-soft));
border-radius: 8px;
background: color-mix(in srgb, var(--warning, #f59e0b) 8%, var(--bg-soft));
color: color-mix(in srgb, var(--warning, #f59e0b) 62%, var(--fg));
font-size: 12px;
line-height: 1.45;
}
.approval-reason {
margin: 0;
padding: 8px 10px;
@@ -7378,14 +7494,14 @@ body > .mermaid-diagram--fullscreen {
}
.prompt-shelf--ask .prompt-shelf__actions {
display: grid;
grid-template-columns: 1fr;
max-height: 154px;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
max-height: min(220px, 30vh);
overflow: auto;
padding-right: 2px;
}
.prompt-shelf--ask .prompt-action {
width: 100%;
min-height: 42px;
min-height: 36px;
align-items: center;
border-radius: 8px;
padding: 5px 8px;
@@ -7401,6 +7517,69 @@ body > .mermaid-diagram--fullscreen {
text-overflow: ellipsis;
white-space: nowrap;
}
.prompt-shelf--ask .prompt-shelf__quick-actions {
justify-content: flex-end;
gap: 6px;
border-top: 1px solid var(--border-soft);
padding-top: 6px;
margin-top: 2px;
}
.prompt-shelf--ask .prompt-shelf__quick-actions > .prompt-action {
width: auto;
min-height: 28px;
padding: 4px 12px;
border-radius: 999px;
}
.prompt-shelf__footnote {
min-width: 0;
}
.ask-shelf__detail {
display: flex;
align-items: baseline;
gap: 8px;
padding: 0 2px;
color: var(--fg-dim);
font-size: 11.5px;
line-height: 1.45;
}
.ask-shelf__detail-label {
flex: 0 0 auto;
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg);
font-weight: 620;
}
.ask-shelf__detail-text {
min-width: 0;
overflow-wrap: anywhere;
}
/* Tool-approval consequence preview: same quick-pick pattern as the ask
detail row — pill buttons stay compact, the row spells out what the
hovered/selected action will do. */
.approval-consequence {
display: flex;
align-items: baseline;
gap: 8px;
padding: 0 2px;
color: var(--fg-dim);
font-size: 11.5px;
line-height: 1.45;
}
.approval-consequence__label {
flex: 0 0 auto;
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg);
font-weight: 620;
}
.approval-consequence__text {
min-width: 0;
overflow-wrap: anywhere;
}
.prompt-shelf--ask .ask-shelf__custom-row {
margin-top: 0;
}
@@ -14257,10 +14436,18 @@ body > .mermaid-diagram--fullscreen {
.zoom-slider-wrap {
display: flex;
flex-direction: column;
gap: 6px;
gap: 8px;
width: 100%;
}
.zoom-slider__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 26px;
}
.zoom-slider-row {
display: flex;
align-items: center;
@@ -14275,14 +14462,67 @@ body > .mermaid-diagram--fullscreen {
flex-shrink: 0;
}
/* 百分比值 — 居中在轨道上方 */
/* 百分比值,与右侧步进控件同排显示 */
.zoom-slider__value {
font-size: var(--font-control-small);
font-weight: 700;
color: var(--accent);
line-height: 1;
text-align: center;
width: 100%;
text-align: left;
min-width: 42px;
}
.zoom-stepper {
display: inline-flex;
align-items: center;
flex: none;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg);
}
.zoom-stepper__btn,
.zoom-stepper__reset {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 25px;
border: 0;
border-right: 1px solid var(--border);
background: transparent;
color: var(--fg-dim);
font: inherit;
font-size: var(--font-control-small);
line-height: 1;
}
.zoom-stepper__btn {
width: 28px;
}
.zoom-stepper__reset {
min-width: 58px;
padding: 0 8px;
font-weight: 650;
}
.zoom-stepper__btn:last-child {
border-right: 0;
}
.zoom-stepper__btn:not(:disabled):hover,
.zoom-stepper__reset:not(:disabled):hover {
background: var(--accent-soft);
color: var(--accent);
}
.zoom-stepper__btn:disabled,
.zoom-stepper__reset:disabled {
color: var(--fg-faint);
opacity: .55;
cursor: default;
}
/* 让 label 在 settings-field 网格中靠下对齐 */
@@ -14329,40 +14569,17 @@ body > .mermaid-diagram--fullscreen {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 28px;
height: 18px;
width: 26px;
height: 16px;
pointer-events: none;
z-index: var(--z-local-raised);
box-shadow: 0 0 0 1px var(--accent);
background: #fff;
border: 1px solid var(--accent);
border-radius: 10px;
box-sizing: border-box;
}
.slider-thumb__left {
position: absolute;
left: 0;
top: 0;
width: 9px;
height: 18px;
background: #fff;
border-radius: 9px 0 0 9px;
}
.slider-thumb__mid {
position: absolute;
left: 9px;
top: 0;
width: 10px;
height: 18px;
background: #fff;
}
.slider-thumb__right {
position: absolute;
right: 0;
top: 0;
width: 9px;
height: 18px;
background: #fff;
border-radius: 0 9px 9px 0;
}
/* slider-thumb is now a single element; child segments were removed. */
/* 透明 input range — 事件捕获 */
.slider-track input[type="range"] {
@@ -17242,21 +17459,6 @@ body > .mermaid-diagram--fullscreen {
padding-left: 18px;
}
/* ── composer run status ──────────────────────────────────────────────────── */
.composer-toolbar {
--wails-draggable: no-drag;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
max-width: var(--maxw);
margin: 0 auto 8px;
}
.composer-toolbar--status-only {
justify-content: flex-end;
}
.composer-guidance-shelf {
--wails-draggable: no-drag;
display: grid;
@@ -17413,79 +17615,6 @@ body > .mermaid-diagram--fullscreen {
}
}
.composer-runstatus {
--wails-draggable: no-drag;
flex: 0 1 auto;
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
height: 34px;
padding: 0 5px 0 11px;
border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border));
border-radius: 10px;
background: color-mix(in srgb, var(--accent) 9%, var(--bg-elev));
color: var(--accent);
font-family: var(--sans);
font-size: 12px;
box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 6%, transparent);
}
.composer-runstatus__dot {
flex: 0 0 auto;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
animation: pulse 1.2s ease-in-out infinite;
}
.composer-runstatus__text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.composer-runstatus__stop {
--wails-draggable: no-drag;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
height: 26px;
padding: 0 9px;
border: 1px solid color-mix(in srgb, var(--err) 42%, transparent);
border-radius: 7px;
background: color-mix(in srgb, var(--err) 14%, var(--bg-elev-2));
color: var(--err);
font: inherit;
font-weight: 620;
}
.composer-runstatus__stop:hover,
.composer-runstatus__stop:focus-visible {
color: var(--fg);
background: color-mix(in srgb, var(--err) 38%, var(--bg-elev-2));
border-color: color-mix(in srgb, var(--err) 70%, var(--border));
}
.composer-runstatus__stop:disabled {
cursor: not-allowed;
opacity: 0.48;
color: var(--fg-faint);
background: var(--button-bg);
border-color: var(--border-soft);
}
.composer-runstatus__stop:disabled:hover,
.composer-runstatus__stop:disabled:focus-visible {
color: var(--fg-faint);
background: var(--button-bg);
border-color: var(--border-soft);
}
.composer-context {
display: flex;
flex-wrap: wrap;
@@ -20475,26 +20604,6 @@ body > .mermaid-diagram--fullscreen {
color: color-mix(in srgb, var(--err) 84%, var(--fg));
}
.project-tree__topic--recovered {
color: color-mix(in srgb, var(--warn) 18%, var(--fg-dim));
}
.project-tree__topic-recovery {
flex: 0 0 auto;
height: 17px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 5px;
border-radius: 5px;
background: color-mix(in srgb, var(--warn) 12%, transparent);
color: color-mix(in srgb, var(--warn) 82%, var(--fg));
font-size: 10.5px;
font-weight: 650;
line-height: 1;
white-space: nowrap;
}
.project-tree__topic-meta {
max-width: 100%;
min-width: 0;
@@ -22738,10 +22847,6 @@ body > .mermaid-diagram--fullscreen {
padding: 5px 10px 10px 30px;
}
.composer-toolbar {
margin-bottom: 8px;
}
.composer-modebar {
border-radius: 8px;
background: color-mix(in srgb, var(--bg-elev-2) 72%, transparent);
@@ -26816,41 +26921,29 @@ body > .mermaid-diagram--fullscreen {
margin: 0 auto;
}
.app--creation .composer-toolbar,
:root[data-theme-style] .app--creation .composer-toolbar {
max-width: 880px;
margin: 0 auto 8px;
}
.app--creation .composer-toolbar--status-only,
:root[data-theme-style] .app--creation .composer-toolbar--status-only {
justify-content: center;
}
.app--creation .composer-runstatus,
:root[data-theme-style] .app--creation .composer-runstatus {
max-width: min(100%, 340px);
height: 28px;
gap: 7px;
padding: 0 4px 0 10px;
border-color: color-mix(in srgb, var(--accent) 22%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--bg-elev) 24%, transparent);
.app--creation .composer-run-strip,
:root[data-theme-style] .app--creation .composer-run-strip {
color: color-mix(in srgb, var(--accent) 88%, var(--fg-muted));
font-size: 11.5px;
box-shadow: none;
}
.app--creation .composer-runstatus__dot,
:root[data-theme-style] .app--creation .composer-runstatus__dot {
.app--creation .composer-run-strip__dot,
:root[data-theme-style] .app--creation .composer-run-strip__dot {
width: 5px;
height: 5px;
background: var(--accent);
box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 48%, transparent);
animation: creation-runstatus-dot-breathe 1.65s ease-in-out infinite;
animation: creation-run-strip-dot-breathe 1.65s ease-in-out infinite;
}
@keyframes creation-runstatus-dot-breathe {
.app--creation .composer-run-strip--waiting .composer-run-strip__dot,
:root[data-theme-style] .app--creation .composer-run-strip--waiting .composer-run-strip__dot {
animation: none;
background: currentColor;
box-shadow: none;
}
@keyframes creation-run-strip-dot-breathe {
0%, 100% {
opacity: 0.42;
box-shadow: 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent);
@@ -26862,46 +26955,22 @@ body > .mermaid-diagram--fullscreen {
}
@media (prefers-reduced-motion: reduce) {
.app--creation .composer-runstatus__dot,
:root[data-theme-style] .app--creation .composer-runstatus__dot {
.app--creation .composer-run-strip__dot,
:root[data-theme-style] .app--creation .composer-run-strip__dot {
animation: none;
opacity: 0.78;
}
}
.app--creation .composer-runstatus__text,
:root[data-theme-style] .app--creation .composer-runstatus__text {
.app--creation .composer-run-strip__text,
:root[data-theme-style] .app--creation .composer-run-strip__text {
color: color-mix(in srgb, var(--fg-muted) 72%, var(--accent));
font-weight: 540;
}
.app--creation .composer-runstatus__stop,
:root[data-theme-style] .app--creation .composer-runstatus__stop {
height: 22px;
gap: 4px;
padding: 0 8px;
border-color: color-mix(in srgb, var(--err) 24%, transparent);
border-radius: 999px;
background: transparent;
color: color-mix(in srgb, var(--err) 76%, var(--fg-muted));
font-size: 11.5px;
font-weight: 560;
}
.app--creation .composer-runstatus__stop svg,
:root[data-theme-style] .app--creation .composer-runstatus__stop svg {
width: 8px;
height: 8px;
opacity: 0.78;
}
.app--creation .composer-runstatus__stop:hover,
.app--creation .composer-runstatus__stop:focus-visible,
:root[data-theme-style] .app--creation .composer-runstatus__stop:hover,
:root[data-theme-style] .app--creation .composer-runstatus__stop:focus-visible {
border-color: color-mix(in srgb, var(--err) 42%, transparent);
background: color-mix(in srgb, var(--err) 9%, transparent);
color: color-mix(in srgb, var(--err) 90%, var(--fg));
.app--creation .composer-run-strip--waiting .composer-run-strip__text,
:root[data-theme-style] .app--creation .composer-run-strip--waiting .composer-run-strip__text {
color: color-mix(in srgb, var(--warn) 64%, var(--fg-muted));
}
.app--creation .composer-card,
+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)
}
}
+46 -4
View File
@@ -57,6 +57,7 @@ type heartbeatConfig struct {
type HeartbeatEngine struct {
mu sync.Mutex
tasks []HeartbeatTask
cfgMod time.Time // config-file mtime as of the engine's last read/write (external-edit probe)
pendingTopics map[string]heartbeatPendingTopic // in-memory retry/in-flight safety for NewConversationEachRun
done chan struct{}
running bool
@@ -99,6 +100,28 @@ func (e *HeartbeatEngine) loadTasks() []HeartbeatTask {
return cfg.Tasks
}
// noteConfigModLocked records the config file's current mtime so the tick
// external-edit probe does not re-read a file the engine itself just wrote.
func (e *HeartbeatEngine) noteConfigModLocked() {
if info, err := os.Stat(e.configPath()); err == nil {
e.cfgMod = info.ModTime()
}
}
// adoptExternalEditsLocked re-reads the config when its mtime moved since the
// engine last touched the file: heartbeat-tasks.json is documented as human-
// and AI-editable, so an external edit should be scheduled by the next tick
// rather than waiting for a manual Refresh or an app restart.
func (e *HeartbeatEngine) adoptExternalEditsLocked() {
info, err := os.Stat(e.configPath())
if err != nil || info.ModTime().Equal(e.cfgMod) {
return
}
e.cfgMod = info.ModTime()
e.tasks = e.loadTasks()
e.prunePendingTopicsLocked(e.tasks)
}
// saveTasks writes tasks to disk atomically.
func (e *HeartbeatEngine) saveTasks(tasks []HeartbeatTask) error {
if tasks == nil {
@@ -129,6 +152,7 @@ func (e *HeartbeatEngine) Start() {
return
}
e.tasks = e.loadTasks()
e.noteConfigModLocked()
e.running = true
go e.loop()
log.Printf("[heartbeat] engine started (%d tasks)", len(e.tasks))
@@ -160,10 +184,12 @@ func (e *HeartbeatEngine) loop() {
}
// tick checks every enabled task and runs those whose interval has elapsed.
// It merges results (topicId, lastRunAt) rather than replacing the full list,
// so concurrent HeartbeatSaveTasks edits are not lost.
// It first adopts any external edit to the config file (human/AI-editable),
// then merges results (topicId, lastRunAt) rather than replacing the full
// list, so concurrent HeartbeatSaveTasks edits are not lost.
func (e *HeartbeatEngine) tick() {
e.mu.Lock()
e.adoptExternalEditsLocked()
tasks := append([]HeartbeatTask(nil), e.tasks...)
e.mu.Unlock()
@@ -374,6 +400,7 @@ func (e *HeartbeatEngine) ReloadTasks() []HeartbeatTask {
e.mu.Lock()
defer e.mu.Unlock()
e.tasks = e.loadTasks()
e.noteConfigModLocked()
e.prunePendingTopicsLocked(e.tasks)
out := make([]HeartbeatTask, len(e.tasks))
copy(out, e.tasks)
@@ -386,7 +413,9 @@ func (e *HeartbeatEngine) ReplaceTasks(tasks []HeartbeatTask) error {
defer e.mu.Unlock()
e.tasks = tasks
e.prunePendingTopicsLocked(tasks)
return e.saveTasks(tasks)
err := e.saveTasks(tasks)
e.noteConfigModLocked()
return err
}
func (e *HeartbeatEngine) prunePendingTopicsLocked(tasks []HeartbeatTask) {
@@ -431,7 +460,18 @@ func (e *HeartbeatEngine) mergeRunUpdatesLocked(updates map[string]HeartbeatTask
if len(updates) == 0 {
return
}
tasks := append([]HeartbeatTask(nil), e.tasks...)
// Rebase onto the on-disk list before the full-list save: the config file
// is documented as human- and AI-editable, so an external edit may have
// landed after the in-memory snapshot this tick ran from. The engine owns
// only the run-state fields (TopicID, LastRunAt, CreatedAt backfill);
// task definitions added, edited, or deleted externally are adopted from
// disk, so the save below can never silently roll an external edit back.
tasks := e.loadTasks()
if tasks == nil {
// Missing or unreadable file: fall back to the in-memory list so the
// run state still lands (the historical behavior).
tasks = append([]HeartbeatTask(nil), e.tasks...)
}
for i := range tasks {
update, ok := updates[tasks[i].ID]
if !ok {
@@ -448,7 +488,9 @@ func (e *HeartbeatEngine) mergeRunUpdatesLocked(updates map[string]HeartbeatTask
}
}
e.tasks = tasks
e.prunePendingTopicsLocked(tasks)
_ = e.saveTasks(tasks)
e.noteConfigModLocked()
}
// parseInterval converts a string like "5m", "1h", "30s" to time.Duration.
+71
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"os"
"path/filepath"
"testing"
"time"
@@ -362,3 +363,73 @@ func TestHeartbeatInactiveOpenDoesNotChangeActiveTab(t *testing.T) {
t.Fatalf("inactive open meta = %+v, want heartbeat and inactive", meta)
}
}
func TestHeartbeatMergeRunUpdatesAdoptsExternalFileEdits(t *testing.T) {
isolateDesktopUserDirs(t)
engine := &HeartbeatEngine{
tasks: []HeartbeatTask{
{ID: "a", Title: "stale title", Prompt: "stale", Interval: "1h", Enabled: true},
},
}
// An external editor (the documented human/AI flow) rewrote the file after
// the engine's in-memory snapshot: task a was edited and task b was added.
external := []HeartbeatTask{
{ID: "a", Title: "edited externally", Prompt: "new prompt", Interval: "2h", Enabled: true},
{ID: "b", Title: "added externally", Prompt: "hello", Interval: "1h", Enabled: false},
}
if err := engine.saveTasks(external); err != nil {
t.Fatalf("seed external file: %v", err)
}
engine.mergeRunUpdatesLocked(map[string]HeartbeatTask{
"a": {ID: "a", TopicID: "topic-a", LastRunAt: 4242},
})
if len(engine.tasks) != 2 {
t.Fatalf("tasks len = %d, want 2 (external addition adopted): %+v", len(engine.tasks), engine.tasks)
}
got := engine.tasks[0]
if got.Title != "edited externally" || got.Prompt != "new prompt" || got.Interval != "2h" {
t.Fatalf("external edit was rolled back by the run-state save: %+v", got)
}
if got.TopicID != "topic-a" || got.LastRunAt != 4242 {
t.Fatalf("run state was not merged onto the disk copy: %+v", got)
}
// The full-list save must have preserved the externally added task on disk.
onDisk := engine.loadTasks()
if len(onDisk) != 2 || onDisk[1].ID != "b" || onDisk[1].Title != "added externally" {
t.Fatalf("externally added task was lost on save: %+v", onDisk)
}
}
func TestHeartbeatTickAdoptsExternalFileEdits(t *testing.T) {
isolateDesktopUserDirs(t)
engine := newHeartbeatEngine(nil)
if err := engine.saveTasks([]HeartbeatTask{{ID: "a", Title: "A", Interval: "1h", Enabled: false}}); err != nil {
t.Fatalf("seed file: %v", err)
}
engine.mu.Lock()
engine.tasks = engine.loadTasks()
engine.noteConfigModLocked()
engine.mu.Unlock()
// External edit lands after the engine last touched the file. Force the
// mtime forward so coarse filesystem timestamps cannot make this flaky.
if err := engine.saveTasks([]HeartbeatTask{
{ID: "a", Title: "A", Interval: "1h", Enabled: false},
{ID: "b", Title: "added externally", Interval: "1h", Enabled: false},
}); err != nil {
t.Fatalf("external edit: %v", err)
}
future := time.Now().Add(2 * time.Second)
if err := os.Chtimes(engine.configPath(), future, future); err != nil {
t.Fatalf("chtimes: %v", err)
}
engine.tick() // disabled tasks only: adoption runs, nothing executes
tasks := engine.ListTasks()
if len(tasks) != 2 || tasks[1].ID != "b" {
t.Fatalf("tick did not adopt the external edit: %+v", tasks)
}
}
+1
View File
@@ -69,6 +69,7 @@ func (a *App) SaveHooksSettingsForRoot(scope, projectRoot string, hooks []HookCo
if cmd == "" {
continue
}
cmd = hook.NormalizeCommand(cmd)
settings.Hooks[event] = append(settings.Hooks[event], hook.HookConfig{
Match: strings.TrimSpace(h.Match),
Command: cmd,
+22
View File
@@ -24,9 +24,21 @@ import (
// cmd/reasonix does — boot.Build resolves providers/tools from these registries.
_ "reasonix/internal/provider/anthropic"
_ "reasonix/internal/provider/openai"
"reasonix/internal/sandbox"
_ "reasonix/internal/tool/builtin"
)
// runWindowsSandboxHelperIfRequested reports whether argv (os.Args-shaped, so
// argv[0] is the program name) asks this process to act as the hidden Windows
// sandbox helper, and runs it when so. Split from main so tests can pin that
// the desktop binary keeps the helper route the sandbox wrapper depends on.
func runWindowsSandboxHelperIfRequested(argv []string) (int, bool) {
if len(argv) > 1 && argv[1] == sandbox.WindowsHelperCommand {
return sandbox.RunWindowsSandboxHelper(argv[2:], os.Stdin, os.Stdout, os.Stderr), true
}
return 0, false
}
// assets embeds the built frontend. `all:` so dotfiles (e.g. the dist .gitkeep
// that keeps this directive compilable before the first `pnpm build`) are
// included. A real run requires `pnpm build` (or `wails build`) to populate dist.
@@ -90,6 +102,16 @@ func linuxWebviewGpuPolicy(pattern string) linux.WebviewGpuPolicy {
}
func main() {
// The Windows bash sandbox relaunches the current executable as a hidden
// helper process. Dispatch it before any Wails or single-instance setup:
// otherwise every sandboxed command starts a second GUI instance that
// forwards to the running app and exits 0 with no output, so bash silently
// returns empty on Windows (#6051, #6067, #6072).
if code, ok := runWindowsSandboxHelperIfRequested(os.Args); ok {
os.Exit(code)
}
sandbox.RegisterHelperDispatch()
app := NewApp()
// Restore saved window size, or fall back to the default.
+7
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"os"
"path/filepath"
"testing"
@@ -23,6 +24,12 @@ func TestMain(m *testing.M) {
os.Setenv("REASONIX_STATE_HOME", dir+"/state")
os.Setenv("REASONIX_CACHE_HOME", dir+"/cache")
os.Setenv("AppData", dir)
// Neutralize the Wails runtime-event bridge for the whole test binary:
// outside a running Wails app, runtime.EventsEmit log.Fatals on the plain
// contexts tests use, killing the process from any emitting code path.
// Tests that assert on runtime events install their own capture through
// the per-instance runtimeEvents.emit hook, which takes precedence.
runtimeEventsEmitFallback = func(context.Context, string, ...interface{}) {}
code := m.Run()
os.RemoveAll(dir)
os.Exit(code)
+66 -2
View File
@@ -2,6 +2,7 @@ package main
import (
"fmt"
"hash/fnv"
"path/filepath"
"strings"
"time"
@@ -116,6 +117,9 @@ func (a *App) MemorySuggestionsForTab(tabID string) MemorySuggestionsView {
sessions := loadSuggestionSessions(sessionDir, suggestionSessionLimit)
view.Memories = suggestMemories(set, sessions)
// Stable Memory v5 execution learnings join the same candidate list and
// the same explicit-confirmation flow as history-derived suggestions.
view.Memories = append(view.Memories, suggestCompilerMemories(workspaceRoot, set, view.Memories)...)
view.Skills = suggestSkills(workspaceRoot, ctrl.AllSkills(), sessions)
return view
}
@@ -137,7 +141,7 @@ func (a *App) AcceptMemorySuggestionForTab(tabID string, in MemorySuggestion) (s
if desc == "" || body == "" {
return "", fmt.Errorf("memory suggestion requires description and body")
}
name := suggestionName(in.Name, desc, "memory-candidate")
name := acceptedSuggestionName(in.Name, desc)
return ctrl.SaveMemory(memory.Memory{
Name: name,
Title: oneLine(in.Title),
@@ -229,7 +233,7 @@ func suggestMemories(set *memory.Set, sessions []suggestionSession) []MemorySugg
continue
}
seen[key] = true
name := suggestionName("", statement, fmt.Sprintf("memory-candidate-%d", len(out)+1))
name := stableSuggestionName(statement, "memory-candidate")
title := suggestionTitle(statement, "Memory candidate")
typ := inferMemoryType(statement)
out = append(out, MemorySuggestion{
@@ -504,6 +508,66 @@ func suggestionName(given, source, fallback string) string {
return "candidate"
}
// acceptedSuggestionName preserves the candidate name generated at suggestion
// time. Re-running asciiSlug here would truncate back to 56 chars and strip
// the uniqueness hash suffix, re-colliding long common-prefix candidates at
// save time even though their generated Name/ID differed. A well-formed slug
// is kept verbatim (memory.Store.Save's own slug pass cleans but never
// truncates); anything else falls back to deriving from the description as
// before.
func acceptedSuggestionName(given, desc string) string {
if isWellFormedSlug(given) {
return given
}
return suggestionName("", desc, "memory-candidate")
}
// isWellFormedSlug reports whether s already matches asciiSlug's output shape
// (lowercase ASCII letters/digits separated by single dashes), possibly with a
// hash suffix beyond asciiSlug's 56-char cap.
func isWellFormedSlug(s string) bool {
if s == "" || len(s) > 128 || s[0] == '-' || s[len(s)-1] == '-' {
return false
}
prevDash := false
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
prevDash = false
case r == '-':
if prevDash {
return false
}
prevDash = true
default:
return false
}
}
return true
}
// stableSuggestionName returns a slug that is unique per source text and stable
// across suggestion refreshes. asciiSlug drops non-ASCII runes and truncates to
// 56 chars, so two CJK-only statements (or long English statements sharing a
// prefix) can collide — colliding Names make Store.Save overwrite the earlier
// memory, and colliding IDs cross-wire the frontend's accepted-state map.
//
// When the ASCII slug is short enough that truncation cannot have caused a
// collision, it is returned as-is for backward compatibility with old-version
// candidate names. The hash suffix is only appended when the slug fell back to
// the fallback (non-ASCII source) or when the slug hit the 56-char truncation
// boundary.
func stableSuggestionName(source, fallback string) string {
slug := asciiSlug(source)
if slug != "" && len(slug) < 56 {
return slug
}
base := suggestionName("", source, fallback)
h := fnv.New32a()
_, _ = h.Write([]byte(source))
return fmt.Sprintf("%s-%08x", base, h.Sum32())
}
func asciiSlug(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
var b strings.Builder
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"fmt"
"hash/fnv"
"reasonix/internal/config"
"reasonix/internal/memory"
"reasonix/internal/memorycompiler"
)
const (
// compilerSuggestionLimit bounds Memory v5 candidates separately from the
// history-derived ones so a busy history cannot crowd them out.
compilerSuggestionLimit = 3
// compilerSuggestionMinCount requires a pattern to recur across at least
// this many separate turns before it is stable enough to suggest.
compilerSuggestionMinCount = 2
)
// suggestCompilerMemories converts stable Memory v5 execution learnings into
// memory candidates for the settings Memory page. Read-only: candidates are
// only persisted through the regular AcceptMemorySuggestion confirmation flow.
func suggestCompilerMemories(workspaceRoot string, set *memory.Set, already []MemorySuggestion) []MemorySuggestion {
rt := memorycompiler.New(config.MemoryCompilerDir(workspaceRoot))
if rt == nil || set == nil {
return nil
}
existing := existingMemoryText(set)
seen := map[string]bool{}
for _, s := range already {
seen[normalizeSuggestionKey(s.Description)] = true
}
var out []MemorySuggestion
for _, p := range rt.StableNoisePatterns(compilerSuggestionMinCount, compilerSuggestionLimit*2) {
statement := compilerPatternStatement(p.Pattern)
key := normalizeSuggestionKey(statement)
if key == "" || seen[key] || existingCovers(existing, key) {
continue
}
seen[key] = true
name := compilerCandidateName(p.Pattern)
out = append(out, MemorySuggestion{
ID: "memory-" + name,
Name: name,
Title: suggestionTitle(statement, "Memory v5 pattern"),
Description: oneLine(statement),
Type: string(memory.TypeProject),
Body: compilerCandidateBody(statement, p.Count),
Reason: fmt.Sprintf("Memory v5 observed this failure pattern in %d separate turns", p.Count),
Evidence: []string{fmt.Sprintf("memory-v5 execution traces: %s (x%d)", truncateRunes(p.Pattern, 160), p.Count)},
})
if len(out) >= compilerSuggestionLimit {
break
}
}
return out
}
// compilerCandidateName always appends a hash: compiler patterns are
// inherently homogeneous (same tool + similar error prefix) so the ASCII
// slug alone cannot distinguish them even when it is non-empty.
func compilerCandidateName(pattern string) string {
base := suggestionName("", "memory-v5 "+pattern, "memory-v5-pattern")
h := fnv.New32a()
_, _ = h.Write([]byte(pattern))
return fmt.Sprintf("%s-%08x", base, h.Sum32())
}
func compilerPatternStatement(pattern string) string {
pattern = oneLine(pattern)
if pattern == "" {
return ""
}
return "Known repeated failure in this workspace: " + pattern + "."
}
func compilerCandidateBody(statement string, count int) string {
return statement + "\n\n" +
fmt.Sprintf("**Why:** Memory v5 recorded this failure pattern in %d separate execution traces for this workspace.\n", count) +
"**How to apply:** Address the known cause before retrying the same command; drop this memory once the failure stops reproducing.\n"
}
+240
View File
@@ -0,0 +1,240 @@
package main
import (
"context"
"strings"
"testing"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/memory"
"reasonix/internal/memorycompiler"
)
func seedCompilerFailures(t *testing.T, workspaceRoot string, turns int, errText string) {
t.Helper()
rt := memorycompiler.New(config.MemoryCompilerDir(workspaceRoot))
if rt == nil {
t.Fatal("memory compiler dir did not resolve under isolated dirs")
}
for i := 0; i < turns; i++ {
_, turn := rt.StartTurn(context.Background(), "fix a bug", nil)
turn.RecordToolResults([]memorycompiler.ToolRecord{
{Name: "bash", Error: errText},
{Name: "bash", Error: errText},
})
turn.Finish(nil)
}
}
func TestMemorySuggestionsIncludeCompilerCandidates(t *testing.T) {
isolateDesktopUserDirs(t)
userDir := t.TempDir()
cwd := t.TempDir()
sessionDir := t.TempDir()
store := memory.StoreFor(userDir, cwd)
seedCompilerFailures(t, cwd, 2, "cannot find module providing package foo")
app := NewApp()
app.setTestCtrl(control.New(control.Options{
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
SessionDir: sessionDir,
}), "test-model")
app.tabs["test"].WorkspaceRoot = cwd
view := app.MemorySuggestions()
var candidate MemorySuggestion
for _, item := range view.Memories {
if strings.HasPrefix(item.Name, "memory-v5-") {
candidate = item
break
}
}
if candidate.ID == "" {
t.Fatalf("memories = %+v, want a memory-v5 candidate", view.Memories)
}
if candidate.Type != string(memory.TypeProject) {
t.Fatalf("candidate type = %q, want project", candidate.Type)
}
if !strings.Contains(candidate.Description, "cannot find module") {
t.Fatalf("candidate description missing pattern: %q", candidate.Description)
}
if !strings.Contains(candidate.Reason, "Memory v5") {
t.Fatalf("candidate reason missing provenance: %q", candidate.Reason)
}
path, err := app.AcceptMemorySuggestion(candidate)
if err != nil {
t.Fatalf("AcceptMemorySuggestion: %v", err)
}
if path == "" {
t.Fatal("AcceptMemorySuggestion returned empty path")
}
saved := store.List()
if len(saved) != 1 || !strings.Contains(saved[0].Body, "cannot find module") {
t.Fatalf("saved memories = %+v, want compiler candidate body", saved)
}
// Once accepted, the same pattern is covered by an existing memory and
// must not be suggested again.
again := app.MemorySuggestions()
for _, item := range again.Memories {
if strings.HasPrefix(item.Name, "memory-v5-") {
t.Fatalf("accepted pattern suggested again: %+v", item)
}
}
}
func TestCompilerCandidatesRequireStablePattern(t *testing.T) {
isolateDesktopUserDirs(t)
userDir := t.TempDir()
cwd := t.TempDir()
sessionDir := t.TempDir()
store := memory.StoreFor(userDir, cwd)
// A pattern seen in only one turn is not stable enough to suggest.
seedCompilerFailures(t, cwd, 1, "cannot find module providing package foo")
app := NewApp()
app.setTestCtrl(control.New(control.Options{
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
SessionDir: sessionDir,
}), "test-model")
app.tabs["test"].WorkspaceRoot = cwd
view := app.MemorySuggestions()
for _, item := range view.Memories {
if strings.HasPrefix(item.Name, "memory-v5-") {
t.Fatalf("unstable pattern was suggested: %+v", item)
}
}
}
// TestCompilerCandidateNamesUniqueForSimilarPatterns covers the review
// finding: asciiSlug drops non-ASCII runes and truncates to 56 chars, so
// patterns differing only in CJK text (or sharing a long English prefix) used
// to collide on Name/ID — colliding IDs cross-wire the frontend's accepted
// state and colliding Names make Store.Save overwrite the earlier memory.
func TestCompilerCandidateNamesUniqueForSimilarPatterns(t *testing.T) {
isolateDesktopUserDirs(t)
userDir := t.TempDir()
cwd := t.TempDir()
sessionDir := t.TempDir()
store := memory.StoreFor(userDir, cwd)
// Same tool, errors differ only in CJK text: identical after asciiSlug.
seedCompilerFailures(t, cwd, 2, "编译失败:找不到模块甲")
seedCompilerFailures(t, cwd, 2, "编译失败:找不到模块乙")
app := NewApp()
app.setTestCtrl(control.New(control.Options{
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
SessionDir: sessionDir,
}), "test-model")
app.tabs["test"].WorkspaceRoot = cwd
view := app.MemorySuggestions()
var candidates []MemorySuggestion
for _, item := range view.Memories {
if strings.HasPrefix(item.Name, "memory-v5-") {
candidates = append(candidates, item)
}
}
if len(candidates) != 2 {
t.Fatalf("candidates = %+v, want both CJK-only-diff patterns", candidates)
}
if candidates[0].Name == candidates[1].Name || candidates[0].ID == candidates[1].ID {
t.Fatalf("similar patterns collided on Name/ID: %q vs %q", candidates[0].Name, candidates[1].Name)
}
// Accepting both must persist two distinct memories, not overwrite one.
for _, c := range candidates {
if _, err := app.AcceptMemorySuggestion(c); err != nil {
t.Fatalf("AcceptMemorySuggestion(%s): %v", c.Name, err)
}
}
saved := store.List()
if len(saved) != 2 {
t.Fatalf("saved memories = %d (%+v), want 2 distinct files", len(saved), saved)
}
}
// TestCompilerCandidateNamesStableAcrossRefreshes: the hash suffix must be
// derived from the pattern, not from ordering or randomness, so a refresh
// keeps the same ID and the frontend's accepted-state map stays valid.
func TestCompilerCandidateNamesStableAcrossRefreshes(t *testing.T) {
isolateDesktopUserDirs(t)
userDir := t.TempDir()
cwd := t.TempDir()
sessionDir := t.TempDir()
store := memory.StoreFor(userDir, cwd)
seedCompilerFailures(t, cwd, 2, "cannot find module providing package foo")
app := NewApp()
app.setTestCtrl(control.New(control.Options{
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
SessionDir: sessionDir,
}), "test-model")
app.tabs["test"].WorkspaceRoot = cwd
first := app.MemorySuggestions()
second := app.MemorySuggestions()
firstIDs := compilerCandidateIDs(first.Memories)
secondIDs := compilerCandidateIDs(second.Memories)
if len(firstIDs) == 0 || strings.Join(firstIDs, ",") != strings.Join(secondIDs, ",") {
t.Fatalf("candidate IDs changed across refreshes: %v vs %v", firstIDs, secondIDs)
}
}
func compilerCandidateIDs(memories []MemorySuggestion) []string {
var out []string
for _, item := range memories {
if strings.HasPrefix(item.Name, "memory-v5-") {
out = append(out, item.ID)
}
}
return out
}
// TestCompilerCandidateLongPrefixNamesPersistDistinctly reproduces the review
// finding: two patterns sharing a >56-char ASCII prefix get distinct generated
// Name/ID (hash suffix), but the accept path used to re-run asciiSlug, which
// truncates to 56 chars and strips the hash — so both saved to one file.
func TestCompilerCandidateLongPrefixNamesPersistDistinctly(t *testing.T) {
isolateDesktopUserDirs(t)
userDir := t.TempDir()
cwd := t.TempDir()
sessionDir := t.TempDir()
store := memory.StoreFor(userDir, cwd)
prefix := "cannot find module providing package example dot com slash very long prefix "
seedCompilerFailures(t, cwd, 2, prefix+"alpha")
seedCompilerFailures(t, cwd, 2, prefix+"beta")
app := NewApp()
app.setTestCtrl(control.New(control.Options{
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
SessionDir: sessionDir,
}), "test-model")
app.tabs["test"].WorkspaceRoot = cwd
view := app.MemorySuggestions()
var candidates []MemorySuggestion
for _, item := range view.Memories {
if strings.HasPrefix(item.Name, "memory-v5-") {
candidates = append(candidates, item)
}
}
if len(candidates) != 2 {
t.Fatalf("candidates = %+v, want both long-prefix patterns", candidates)
}
for _, c := range candidates {
if _, err := app.AcceptMemorySuggestion(c); err != nil {
t.Fatalf("AcceptMemorySuggestion(%s): %v", c.Name, err)
}
}
saved := store.List()
if len(saved) != 2 {
t.Fatalf("saved memories = %d, want 2 distinct files (accept path truncated the hash suffix)", len(saved))
}
if saved[0].Name == saved[1].Name {
t.Fatalf("saved names collided: %q", saved[0].Name)
}
}
+126
View File
@@ -210,3 +210,129 @@ func writeSuggestionSession(t *testing.T, dir, name string, messages ...provider
t.Fatalf("save session %s: %v", name, err)
}
}
// TestHistoryEnglishCandidateNameBackwardCompat: an English statement whose
// asciiSlug is short (<56 chars) must produce the same Name as old code
// (plain slug, no hash suffix), so an already-accepted memory under the old
// Name is not duplicated after upgrade.
func TestHistoryEnglishCandidateNameBackwardCompat(t *testing.T) {
isolateDesktopUserDirs(t)
userDir := t.TempDir()
cwd := t.TempDir()
sessionDir := t.TempDir()
store := memory.StoreFor(userDir, cwd)
writeSuggestionSession(t, sessionDir, "en.jsonl",
provider.Message{Role: provider.RoleUser, Content: "Always prefer English for code comments."},
provider.Message{Role: provider.RoleAssistant, Content: "Got it."},
)
app := NewApp()
app.setTestCtrl(control.New(control.Options{
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
SessionDir: sessionDir,
}), "test-model")
app.tabs["test"].WorkspaceRoot = cwd
view := app.MemorySuggestions()
if len(view.Memories) == 0 {
t.Fatalf("no candidates")
}
// Old code: suggestionName("", statement, "memory-candidate-1") = asciiSlug(statement)
oldName := asciiSlug("Always prefer English for code comments.")
if view.Memories[0].Name != oldName {
t.Fatalf("Name = %q, want old-compatible %q (no hash suffix for short ASCII slugs)", view.Memories[0].Name, oldName)
}
}
// TestHistoryMemoryCandidateNamesUniqueForCJK: two pure-CJK statements that
// differ in content but produce the same empty asciiSlug must still get
// distinct Name/ID. Without the hash suffix they would both fall back to
// "memory-candidate-<ordinal>" — but ordinals depend on iteration order and
// wouldn't survive refresh, and Store.Save overwrites by name.
func TestHistoryMemoryCandidateNamesUniqueForCJK(t *testing.T) {
isolateDesktopUserDirs(t)
userDir := t.TempDir()
cwd := t.TempDir()
sessionDir := t.TempDir()
store := memory.StoreFor(userDir, cwd)
// Two pure-CJK "always" statements that pass extractMemoryStatement but
// share the exact same empty asciiSlug.
writeSuggestionSession(t, sessionDir, "zh-a.jsonl",
provider.Message{Role: provider.RoleUser, Content: "以后始终使用甲方案处理合并冲突。"},
provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
)
writeSuggestionSession(t, sessionDir, "zh-b.jsonl",
provider.Message{Role: provider.RoleUser, Content: "以后始终使用乙方案处理部署回滚。"},
provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
)
app := NewApp()
app.setTestCtrl(control.New(control.Options{
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
SessionDir: sessionDir,
}), "test-model")
app.tabs["test"].WorkspaceRoot = cwd
view := app.MemorySuggestions()
if len(view.Memories) < 2 {
t.Fatalf("memories = %+v, want at least 2 CJK candidates", view.Memories)
}
names := map[string]bool{}
ids := map[string]bool{}
for _, m := range view.Memories {
if names[m.Name] {
t.Fatalf("duplicate Name %q among history candidates", m.Name)
}
if ids[m.ID] {
t.Fatalf("duplicate ID %q among history candidates", m.ID)
}
names[m.Name] = true
ids[m.ID] = true
}
// Accept both → two distinct persisted memories.
for _, c := range view.Memories {
if _, err := app.AcceptMemorySuggestion(c); err != nil {
t.Fatalf("AcceptMemorySuggestion(%s): %v", c.Name, err)
}
}
saved := store.List()
if len(saved) != len(view.Memories) {
t.Fatalf("saved %d memories, want %d (Name collision caused overwrite)", len(saved), len(view.Memories))
}
}
// TestHistoryMemoryCandidateNamesStableAcrossRefreshes: the hash suffix must
// be derived from the statement, not from iteration order or random state, so
// a refresh keeps the same ID and the frontend's accepted-state map stays valid.
func TestHistoryMemoryCandidateNamesStableAcrossRefreshes(t *testing.T) {
isolateDesktopUserDirs(t)
userDir := t.TempDir()
cwd := t.TempDir()
sessionDir := t.TempDir()
store := memory.StoreFor(userDir, cwd)
writeSuggestionSession(t, sessionDir, "pref.jsonl",
provider.Message{Role: provider.RoleUser, Content: "以后请始终用中文回复,除非我明确要求英文。"},
provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
)
app := NewApp()
app.setTestCtrl(control.New(control.Options{
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
SessionDir: sessionDir,
}), "test-model")
app.tabs["test"].WorkspaceRoot = cwd
first := app.MemorySuggestions()
second := app.MemorySuggestions()
if len(first.Memories) == 0 || len(first.Memories) != len(second.Memories) {
t.Fatalf("memories counts differ across refreshes: %d vs %d", len(first.Memories), len(second.Memories))
}
for i := range first.Memories {
if first.Memories[i].ID != second.Memories[i].ID || first.Memories[i].Name != second.Memories[i].Name {
t.Fatalf("refresh changed candidate #%d: %q/%q → %q/%q",
i, first.Memories[i].Name, first.Memories[i].ID,
second.Memories[i].Name, second.Memories[i].ID)
}
}
}
+1 -1
View File
@@ -309,7 +309,7 @@ func (m *metricsAggregator) observe(e event.Event) {
case event.MemoryCompilerStatsEvent:
m.observeMemoryCompilerStats(e.MemoryCompiler)
case event.Notice:
if strings.HasPrefix(e.Text, "empty final answer blocked") {
if e.Text == "No visible answer was produced; asking the assistant to respond again." || strings.HasPrefix(e.Detail, "empty final answer blocked") {
m.inc("empty_final", "total")
}
}
+1 -1
View File
@@ -34,7 +34,7 @@ func TestObserveClassifiesEvents(t *testing.T) {
StrategyCount: 3,
LearningCount: 6,
}},
{Kind: event.Notice, Text: "empty final answer blocked: model returned no visible answer text; retrying"},
{Kind: event.Notice, Text: "No visible answer was produced; asking the assistant to respond again.", Detail: "empty final answer blocked: model returned no visible answer text; retrying"},
{Kind: event.TurnDone, Err: errors.New("deepseek-flash: status 429: rate limited")},
{Kind: event.TurnDone},
}
+17 -2
View File
@@ -136,6 +136,9 @@ func (a *App) InstallPlugin(source string, opts PluginInstallOptions) (string, e
}
a.invalidateSkillRootsCache()
if rebuildErr := a.rebuild(); rebuildErr != nil {
if _, ok := a.deferredRebuildWarning("plugins", rebuildErr); ok {
return out, nil
}
return out, rebuildErr
}
return out, nil
@@ -161,7 +164,13 @@ func (a *App) RemovePlugin(name string) error {
return err
}
a.invalidateSkillRootsCache()
return a.rebuild()
if err := a.rebuild(); err != nil {
if _, ok := a.deferredRebuildWarning("plugins", err); ok {
return nil
}
return err
}
return nil
}
func (a *App) SetPluginEnabled(name string, enabled bool) error {
@@ -172,7 +181,13 @@ func (a *App) SetPluginEnabled(name string, enabled bool) error {
return err
}
a.invalidateSkillRootsCache()
return a.rebuild()
if err := a.rebuild(); err != nil {
if _, ok := a.deferredRebuildWarning("plugins", err); ok {
return nil
}
return err
}
return nil
}
func (a *App) UpdatePlugin(name string) (string, error) {
+480
View File
@@ -0,0 +1,480 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/event"
)
// TestSessionLeaseHelpersConcurrentAccess hammers the sessionLeaseMu helpers
// (ensure/take/adopt/release/key) from concurrent goroutines. Run with -race:
// any residual raw access to tab.sessionLease shows up as a data race, and the
// final acquire asserts no lease leaked through an interleaving.
func TestSessionLeaseHelpersConcurrentAccess(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := filepath.Join(dir, "lease-helper-hammer.jsonl")
key := sessionRuntimeKey(path)
tabA := &WorkspaceTab{ID: "a"}
tabB := &WorkspaceTab{ID: "b"}
const iterations = 300
var wg sync.WaitGroup
wg.Add(3)
go func() {
defer wg.Done()
for i := 0; i < iterations; i++ {
_ = tabA.ensureSessionLease(path)
_ = tabA.sessionLeaseRuntimeKey()
}
}()
go func() {
defer wg.Done()
for i := 0; i < iterations; i++ {
// The applyRuntimeTab transfer shape: move A's lease to B and back.
tabB.adoptSessionLease(tabA.takeSessionLease())
tabA.adoptSessionLease(tabB.takeSessionLease())
}
}()
go func() {
defer wg.Done()
for i := 0; i < iterations; i++ {
tabA.releaseSessionLease()
}
}()
wg.Wait()
tabA.releaseSessionLease()
tabB.releaseSessionLease()
lease, err := agent.TryAcquireSessionLease(key)
if err != nil {
t.Fatalf("lease leaked through concurrent helper interleavings: %v", err)
}
lease.Release()
}
// TestDetachRuntimeForReplacementTransfersLease asserts the detach clone takes
// lease ownership through the locked helpers and the visible tab keeps none.
func TestDetachRuntimeForReplacementTransfersLease(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := filepath.Join(dir, "detach-transfer.jsonl")
key := sessionRuntimeKey(path)
tab := &WorkspaceTab{ID: "tab", Scope: "global", SessionPath: path}
app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
if err := tab.ensureSessionLease(path); err != nil {
t.Fatalf("ensureSessionLease: %v", err)
}
t.Cleanup(func() {
tab.releaseSessionLease()
for _, d := range app.detachedSessions {
d.releaseSessionLease()
}
})
if !app.detachRuntimeForReplacement(tab) {
t.Fatal("detachRuntimeForReplacement failed for a live tab")
}
detached := app.detachedSessions[key]
if detached == nil {
t.Fatal("detached runtime was not registered")
}
if got := detached.sessionLeaseRuntimeKey(); got != key {
t.Fatalf("detached clone lease key = %q, want %q", got, key)
}
if got := tab.sessionLeaseRuntimeKey(); got != "" {
t.Fatalf("visible tab still holds lease key %q after detach", got)
}
}
// TestDetachRuntimeForReplacementSkipsRemovedTab: a tab that DeleteSession /
// CloseTab already unlinked must not be re-published into detachedSessions
// (the "session resurrects" class, #4384).
func TestDetachRuntimeForReplacementSkipsRemovedTab(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := filepath.Join(dir, "detach-removed.jsonl")
removed := &WorkspaceTab{ID: "removed", SessionPath: path, removed: true}
app := &App{tabs: map[string]*WorkspaceTab{"removed": removed}}
if app.detachRuntimeForReplacement(removed) {
t.Fatal("removed tab was detached into the background registry")
}
if len(app.detachedSessions) != 0 {
t.Fatal("removed tab left an entry in detachedSessions")
}
orphan := &WorkspaceTab{ID: "orphan", SessionPath: path}
if app.detachRuntimeForReplacement(orphan) {
t.Fatal("tab absent from a.tabs was detached into the background registry")
}
if len(app.detachedSessions) != 0 {
t.Fatal("orphan tab left an entry in detachedSessions")
}
}
// TestTabEventSinkEmitConcurrentRebind: Emit keeps running on the controller
// goroutine while detach/reattach rebinds the sink's tab routing. Run with
// -race; before setBinding existed the tabID write raced every Emit.
func TestTabEventSinkEmitConcurrentRebind(t *testing.T) {
sink := &tabEventSink{tabID: "before"}
const iterations = 500
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for i := 0; i < iterations; i++ {
sink.Emit(event.Event{Kind: event.Notice, Text: "hammer"})
}
}()
go func() {
defer wg.Done()
for i := 0; i < iterations; i++ {
sink.setBinding(fmt.Sprintf("tab-%d", i%2), nil)
sink.clearContext()
}
}()
wg.Wait()
if tabID, _ := sink.binding(); tabID == "" {
t.Fatal("sink lost its tab binding")
}
}
// TestTabBuildSupersededByRebindGeneration: a session rebind bumps
// buildGeneration to strand any in-flight async build, so its swap (and every
// mid-build field write) is rejected; synchronous rebuilds pass generation 0
// and rely on runtimeRebuildMu instead.
func TestTabBuildSupersededByRebindGeneration(t *testing.T) {
tab := &WorkspaceTab{ID: "tab"}
app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
app.mu.Lock()
tab.buildGeneration = 3
app.mu.Unlock()
if app.tabBuildSuperseded(tab, 3) {
t.Fatal("build with the current generation must not be superseded")
}
app.mu.Lock()
tab.buildGeneration++ // the rebind-side invalidation
app.mu.Unlock()
if !app.tabBuildSuperseded(tab, 3) {
t.Fatal("stale-generation build must be superseded after a rebind bump")
}
if app.tabBuildSuperseded(tab, 0) {
t.Fatal("synchronous rebuild (generation 0) must not be superseded by generation bumps")
}
app.mu.Lock()
tab.removed = true
app.mu.Unlock()
if !app.tabBuildSuperseded(tab, 0) {
t.Fatal("removed tab must supersede every build, including synchronous ones")
}
}
// TestReleaseSessionLeaseForKeyOnlyMatchesOwnKey: superseded builds may only
// release the lease bound to their own path; a mismatched key (the rebind's
// replacement session) must be left untouched.
func TestReleaseSessionLeaseForKeyOnlyMatchesOwnKey(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
pathA := filepath.Join(dir, "lease-key-a.jsonl")
pathB := filepath.Join(dir, "lease-key-b.jsonl")
tab := &WorkspaceTab{ID: "tab"}
t.Cleanup(tab.releaseSessionLease)
if err := tab.ensureSessionLease(pathB); err != nil {
t.Fatalf("ensureSessionLease: %v", err)
}
tab.releaseSessionLeaseForKey(sessionRuntimeKey(pathA))
if _, err := agent.TryAcquireSessionLease(sessionRuntimeKey(pathB)); err == nil {
t.Fatal("mismatched key released the tab's live lease")
}
tab.releaseSessionLeaseForKey(sessionRuntimeKey(pathB))
lease, err := agent.TryAcquireSessionLease(sessionRuntimeKey(pathB))
if err != nil {
t.Fatalf("matching key did not release the lease: %v", err)
}
lease.Release()
}
// TestAbandonSupersededBuildPreservesNewBuildOwnership reproduces the rebind
// interleaving from the #5968 review: a stale async build cleans up after a
// rebind's replacement build already published its own SharedHostKey and
// session lease on the same live tab. The stale build must drop only its own
// host reference and lease, never the tab's.
func TestAbandonSupersededBuildPreservesNewBuildOwnership(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
oldPath := filepath.Join(dir, "superseded-old.jsonl")
newPath := filepath.Join(dir, "superseded-new.jsonl")
newKey := sessionRuntimeKey(newPath)
app := &App{tabs: map[string]*WorkspaceTab{}}
// The stale build and the replacement build each hold one reference on
// the same workspace-root host (the common same-root rebind).
app.acquireSharedHost("root")
app.acquireSharedHost("root")
tab := &WorkspaceTab{ID: "tab", SharedHostKey: "root"}
app.tabs["tab"] = tab
t.Cleanup(tab.releaseSessionLease)
if err := tab.ensureSessionLease(newPath); err != nil {
t.Fatalf("ensureSessionLease(new): %v", err)
}
// Stale build abandons with ITS key material: the old lease path and the
// root key it acquired.
app.abandonSupersededBuild(tab, nil, "root", sessionRuntimeKey(oldPath))
app.mu.RLock()
hostKey := tab.SharedHostKey
app.mu.RUnlock()
if hostKey != "root" {
t.Fatalf("stale build cleared the tab's SharedHostKey: %q", hostKey)
}
app.sharedHostsMu.Lock()
entry := app.sharedHosts["root"]
refs := 0
if entry != nil {
refs = entry.refs
}
app.sharedHostsMu.Unlock()
if refs != 1 {
t.Fatalf("shared host refs = %d after stale-build cleanup, want 1 (the live build's reference)", refs)
}
if got := tab.sessionLeaseRuntimeKey(); got != newKey {
t.Fatalf("stale build released the replacement build's lease: key = %q, want %q", got, newKey)
}
// Removal shape: the abandoned build's own key still on the tab is the
// last reference and must be released.
app.abandonSupersededBuild(tab, nil, "", newKey)
lease, err := agent.TryAcquireSessionLease(newKey)
if err != nil {
t.Fatalf("matching-key abandon did not release the lease: %v", err)
}
lease.Release()
}
// TestRebindInvalidatesInFlightAsyncBuildBeforeSnapshot reproduces the #5968
// review scenario: an async startTabControllerBuild is stalled right after
// binding its session lease (holding its controller, lease, and shared-host
// reference), a rebind to a different session starts meanwhile, and the
// stalled build then tries to finish. The rebind must bump the generation
// BEFORE snapshotting tab.Ctrl, so the stale build can only fall into its
// superseded branches: the rebound controller must survive un-overwritten,
// the stale build's lease and host reference must be released, and the
// replacement build's ownership must stay intact.
func TestRebindInvalidatesInFlightAsyncBuildBeforeSnapshot(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
oldPath := filepath.Join(dir, "rebind-race-old.jsonl")
newPath := filepath.Join(dir, "rebind-race-new.jsonl")
writeHistoryTestSession(t, oldPath, "old prompt")
writeHistoryTestSession(t, newPath, "new prompt")
oldKey := sessionRuntimeKey(oldPath)
newKey := sessionRuntimeKey(newPath)
app := NewApp()
app.readyHook = func() {}
tab := &WorkspaceTab{
ID: "tab",
Scope: "global",
WorkspaceRoot: root,
SessionPath: oldPath,
sink: &tabEventSink{tabID: "tab", app: app},
disabledMCP: map[string]ServerView{},
}
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
t.Cleanup(func() {
app.mu.RLock()
ctrl := tab.Ctrl
app.mu.RUnlock()
if ctrl != nil {
ctrl.Close()
}
tab.releaseSessionLease()
})
// Stall the async build inside its lease bind: at that point it already
// holds its controller, its lease, and its shared-host reference.
stalled := make(chan struct{})
releaseHook := make(chan struct{})
var once sync.Once
sessionLeaseAcquireHookForTest = func() {
once.Do(func() {
close(stalled)
<-releaseHook
})
}
t.Cleanup(func() { sessionLeaseAcquireHookForTest = nil })
t.Cleanup(func() {
select {
case <-releaseHook:
default:
close(releaseHook)
}
})
// startTabControllerBuild only backgrounds the build when a Wails context
// exists; expand its goroutine branch by hand so a.ctx can stay nil (the
// nil-ctx emit guards are what every other build test relies on too).
buildCtx, cancel := context.WithCancel(context.Background())
app.mu.Lock()
tab.buildGeneration++
generation := tab.buildGeneration
tab.buildCancel = cancel
app.mu.Unlock()
buildDone := make(chan struct{})
go func() {
defer close(buildDone)
app.buildTabControllerWithContext(tab, loadedTabSession{}, buildCtx, generation, cancel)
}()
select {
case <-stalled:
case <-time.After(15 * time.Second):
t.Fatal("async build did not reach the lease bind")
}
loaded, err := agent.LoadSession(newPath)
if err != nil {
t.Fatalf("LoadSession(new): %v", err)
}
rebindErr := make(chan error, 1)
go func() {
rebindErr <- app.rebindTabToLoadedSessionPath(tab, newPath, loaded)
}()
// Wait until the rebind's validation section has invalidated the async
// build (startTabControllerBuild set generation 1; the rebind bumps to 2)
// while the async build is still stalled pre-swap.
deadline := time.Now().Add(15 * time.Second)
for {
app.mu.RLock()
generation := tab.buildGeneration
app.mu.RUnlock()
if generation >= 2 {
break
}
if time.Now().After(deadline) {
t.Fatal("rebind did not bump the build generation")
}
time.Sleep(time.Millisecond)
}
close(releaseHook)
select {
case err := <-rebindErr:
if err != nil {
t.Fatalf("rebindTabToLoadedSessionPath: %v", err)
}
case <-time.After(30 * time.Second):
t.Fatal("rebind did not finish after releasing the stalled build")
}
select {
case <-buildDone:
case <-time.After(30 * time.Second):
t.Fatal("stale async build did not finish its superseded cleanup")
}
app.mu.RLock()
builtCtrl := tab.Ctrl
sessionPath := tab.SessionPath
app.mu.RUnlock()
if builtCtrl == nil {
t.Fatal("rebind left the tab without a controller")
}
if got := builtCtrl.SessionPath(); got != newPath {
t.Fatalf("stale async build overwrote the rebound controller: session path = %q, want %q", got, newPath)
}
if sessionPath != newPath {
t.Fatalf("tab.SessionPath = %q, want %q", sessionPath, newPath)
}
if got := tab.sessionLeaseRuntimeKey(); got != newKey {
t.Fatalf("tab lease key = %q, want the rebound session %q", got, newKey)
}
// The stale build's lease must be gone (released by its superseded
// cleanup or replaced by the rebound build's adopt) — the old session
// must be acquirable again.
lease, err := agent.TryAcquireSessionLease(oldKey)
if err != nil {
t.Fatalf("stale build leaked its session lease: %v", err)
}
lease.Release()
// Exactly one shared-host reference may remain: the rebound build's. The
// stale build must have released its own.
app.sharedHostsMu.Lock()
refs := 0
for _, entry := range app.sharedHosts {
refs += entry.refs
}
app.sharedHostsMu.Unlock()
if refs != 1 {
t.Fatalf("shared host refs = %d after stale-build cleanup, want 1 (the rebound build's)", refs)
}
}
// TestMetaForTabConcurrentWithBuildSwap polls MetaForTab (the frontend's boot
// probe) while a fake build goroutine flips Ready/Label/StartupErr/model under
// a.mu — the write pattern of buildTabControllerWithContext. Run with -race.
func TestMetaForTabConcurrentWithBuildSwap(t *testing.T) {
isolateDesktopUserDirs(t)
tab := &WorkspaceTab{ID: "tab", Scope: "project", WorkspaceRoot: t.TempDir()}
app := &App{
tabs: map[string]*WorkspaceTab{"tab": tab},
tabOrder: []string{"tab"},
activeTabID: "tab",
}
const iterations = 100
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < iterations; i++ {
app.mu.Lock()
tab.Ready = !tab.Ready
tab.Label = fmt.Sprintf("model-%d", i)
tab.StartupErr = ""
tab.model = fmt.Sprintf("provider/m%d", i)
tab.goal = fmt.Sprintf("goal-%d", i)
tab.tokenMode = "full"
app.mu.Unlock()
}
}()
for i := 0; i < iterations; i++ {
meta := app.MetaForTab("tab")
if meta.EventChannel == "" {
t.Fatal("MetaForTab returned zero meta for a live tab")
}
}
<-done
}
+126
View File
@@ -0,0 +1,126 @@
package main
import (
"log/slog"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
)
// recoveryGCInterval bounds how often the background sweep repeats after the
// startup run. Recovery branches accumulate slowly (only on a save conflict),
// so a low frequency is plenty and keeps the disk scan off the hot path.
const recoveryGCInterval = 6 * time.Hour
// startRecoveryGC waits for tab restore to complete, runs one sweep, then
// repeats on an interval until the app context is cancelled. The wait is
// load-bearing: restoreOrBuildTabs populates a.tabs asynchronously, and a
// sweep against the pre-restore empty tab map would see every saved tab's
// session as closed — and DeleteSession's tab-list persistence would then
// overwrite desktop-tabs.json with that empty snapshot.
func (a *App) startRecoveryGC() {
a.goSafe("recoveryGC", func() {
select {
case <-a.tabsRestoredSignal():
case <-a.ctx.Done():
return
}
a.sweepReclaimableRecoveryBranches()
ticker := time.NewTicker(recoveryGCInterval)
defer ticker.Stop()
for {
select {
case <-a.ctx.Done():
return
case <-ticker.C:
a.sweepReclaimableRecoveryBranches()
}
}
})
}
// sweepReclaimableRecoveryBranches trashes conflict-recovery branches that
// preserve nothing unique (their content is covered by a still-present parent
// session), were never continued on, sat idle past the grace period, and are
// not held by any runtime. Trashing — never hard deletion — keeps every swept
// branch recoverable from the session trash. Returns how many were reclaimed.
func (a *App) sweepReclaimableRecoveryBranches() int {
return a.reclaimRecoveryBranchesIn(recoveryGCDirs(), time.Now())
}
func (a *App) reclaimRecoveryBranchesIn(dirs []string, now time.Time) int {
reclaimed := 0
for _, dir := range dirs {
reclaimable, err := agent.ReclaimableRecoveryBranches(dir, now, agent.RecoveryGCGracePeriod)
if err != nil {
slog.Warn("desktop: scan reclaimable recovery branches", "dir", dir, "err", err)
continue
}
for _, path := range reclaimable {
// Re-check liveness right before disposal: the scan is a snapshot,
// and the user may have opened the branch since. DeleteSession then
// runs the full removal path (removal guard, runtime unbinding,
// trash move), so even a miss here lands in the recoverable trash.
if agent.SessionLeaseHeld(path) || a.sessionOpenInAnyTab(path) {
continue
}
if err := a.DeleteSession(path); err != nil {
slog.Warn("desktop: trash reclaimed recovery branch", "path", path, "err", err)
continue
}
reclaimed++
}
}
if reclaimed > 0 {
slog.Info("desktop: moved redundant recovery branches to the session trash", "count", reclaimed)
a.emitProjectTreeChanged()
}
return reclaimed
}
// recoveryGCDirs returns every session directory the desktop lists sessions
// from: the global desktop and legacy shared dirs plus each saved project's
// session dirs, deduplicated.
func recoveryGCDirs() []string {
seen := map[string]bool{}
var dirs []string
add := func(dir string) {
key := projectRootKey(dir)
if dir == "" || seen[key] {
return
}
seen[key] = true
dirs = append(dirs, dir)
}
add(desktopSessionDir(globalWorkspaceRoot()))
add(config.SessionDir())
for _, project := range loadProjectsFile().Projects {
if root := normalizeProjectRoot(project.Root); root != "" {
add(desktopSessionDir(root))
add(config.ProjectSessionDir(root))
}
}
return dirs
}
// sessionOpenInAnyTab reports whether any tab's current session is path.
// Lease checks cover live runtimes; this additionally covers tabs that hold a
// session without a lease (read-only channel views).
func (a *App) sessionOpenInAnyTab(path string) bool {
key := sessionRuntimeKey(path)
if key == "" {
return false
}
a.mu.RLock()
defer a.mu.RUnlock()
for _, tab := range a.tabs {
if tab == nil {
continue
}
if sessionRuntimeKey(tab.currentSessionPath()) == key {
return true
}
}
return false
}
+155
View File
@@ -0,0 +1,155 @@
package main
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/provider"
)
// forkCoveredRecoveryBranch builds the reclaimable shape in dir: a conflict
// fork whose parent went on to contain everything the fork preserved.
func forkCoveredRecoveryBranch(t *testing.T, dir, name string) (parentPath, branchPath string) {
t.Helper()
parentPath = filepath.Join(dir, name+".jsonl")
disk := agent.NewSession("sys")
disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk " + name})
if err := disk.Save(parentPath); err != nil {
t.Fatalf("Save parent: %v", err)
}
stale := agent.NewSession("sys")
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
stale.Add(provider.Message{Role: provider.RoleUser, Content: "local " + name})
info, err := stale.SaveRecoveryBranch(agent.RecoveryBranchOptions{OriginalPath: parentPath})
if err != nil {
t.Fatalf("SaveRecoveryBranch: %v", err)
}
covering := agent.NewSession("")
covering.Messages = append([]provider.Message(nil), stale.Snapshot()...)
covering.Add(provider.Message{Role: provider.RoleAssistant, Content: "answered after recovery"})
if err := covering.Save(parentPath); err != nil {
t.Fatalf("Save covering parent: %v", err)
}
return parentPath, info.Path
}
func TestRecoveryGCTrashesCoveredForkAndKeepsParent(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
parentPath, branchPath := forkCoveredRecoveryBranch(t, dir, "session")
app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
if got := app.reclaimRecoveryBranchesIn([]string{dir}, time.Now().Add(48*time.Hour)); got != 1 {
t.Fatalf("reclaimed = %d, want 1", got)
}
if _, err := os.Stat(branchPath); !os.IsNotExist(err) {
t.Fatalf("reclaimed branch still present at %s (err=%v)", branchPath, err)
}
key := filepath.Base(branchPath)
trashPath := filepath.Join(dir, sessionTrashDir, key, key)
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("reclaimed branch should be in trash: %v", err)
}
if _, err := os.Stat(parentPath); err != nil {
t.Fatalf("parent session must be untouched: %v", err)
}
// A second sweep is a no-op: nothing left to reclaim.
if got := app.reclaimRecoveryBranchesIn([]string{dir}, time.Now().Add(48*time.Hour)); got != 0 {
t.Fatalf("second sweep reclaimed = %d, want 0", got)
}
}
func TestRecoveryGCSkipsBranchOpenInTab(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
_, branchPath := forkCoveredRecoveryBranch(t, dir, "open")
tab := &WorkspaceTab{ID: "tab", Scope: "global", SessionPath: branchPath, Ready: true}
app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
if got := app.reclaimRecoveryBranchesIn([]string{dir}, time.Now().Add(48*time.Hour)); got != 0 {
t.Fatalf("reclaimed = %d, want 0 while the branch is open in a tab", got)
}
if _, err := os.Stat(branchPath); err != nil {
t.Fatalf("open branch must be untouched: %v", err)
}
}
// TestRecoveryGCFirstSweepWaitsForTabRestore forces the startup race the
// review caught: a saved recovery tab exists in desktop-tabs.json but a.tabs
// has not been populated yet. The GC's first sweep must wait for the restore
// gate — sweeping early would judge the branch "not open in any tab" and
// DeleteSession would persist the pre-restore (empty) tab list over the
// user's saved one.
func TestRecoveryGCFirstSweepWaitsForTabRestore(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
_, branchPath := forkCoveredRecoveryBranch(t, dir, "startup")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
app := &App{
ctx: ctx,
tabs: map[string]*WorkspaceTab{},
tabsRestored: make(chan struct{}),
}
swept := make(chan int, 1)
go func() {
select {
case <-app.tabsRestoredSignal():
case <-ctx.Done():
swept <- -1
return
}
swept <- app.reclaimRecoveryBranchesIn([]string{dir}, time.Now().Add(48*time.Hour))
}()
// Gate still closed: the sweep must not have run — the branch is intact.
select {
case n := <-swept:
t.Fatalf("sweep ran before tab restore completed (reclaimed=%d)", n)
case <-time.After(100 * time.Millisecond):
}
if _, err := os.Stat(branchPath); err != nil {
t.Fatalf("branch touched before restore completed: %v", err)
}
// Restore lands the saved tab holding the branch, then opens the gate:
// the sweep runs and must skip the now-open branch.
tab := &WorkspaceTab{ID: "tab", Scope: "global", SessionPath: branchPath, Ready: true}
app.mu.Lock()
app.tabs["tab"] = tab
app.mu.Unlock()
app.markTabsRestored()
if n := <-swept; n != 0 {
t.Fatalf("post-restore sweep reclaimed = %d, want 0 (branch is open in a restored tab)", n)
}
if _, err := os.Stat(branchPath); err != nil {
t.Fatalf("restored tab's branch must be untouched: %v", err)
}
// markTabsRestored is idempotent (restore + recover paths may both fire).
app.markTabsRestored()
}
+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})
+144
View File
@@ -0,0 +1,144 @@
package main
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
)
// TestRuntimeRebuildsEmitRuntimeRebuiltForTab pins the frontend contract the
// prompt-chime dedupe depends on: every in-place controller replacement must
// announce itself. Model, effort, and token-mode switches rebuild the tab's
// controller WITHOUT an agent:ready — the rebuilt controller restarts its
// approval/ask id counter at "1", so without runtime:rebuilt the frontend's
// id-keyed chime dedupe mutes the first prompt after a switch.
func TestRuntimeRebuildsEmitRuntimeRebuiltForTab(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "old/old-model"
cfg.Desktop.ProviderAccess = []string{"old", "new"}
cfg.Providers = []config.ProviderEntry{
{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
{Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "deepseek-v4-pro", APIKeyEnv: "NEW_MODEL_KEY"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
sess := agent.NewSession("sys")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
path := filepath.Join(dir, "rebuild-events.jsonl")
ctrl := control.New(control.Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard})
app := NewApp()
app.ctx = context.Background()
// emitReady calls the Wails runtime directly; the ready hook keeps the
// workspace-reconcile path (which SetEffortForTab can take) off the real
// event bridge, which log.Fatals on a plain Background context.
app.readyHook = func() {}
var mu sync.Mutex
var rebuilt []string
// The App-level queue must stay silent: ordering against the tab's agent
// events only holds when the notice rides the tab sink's own queue, so a
// notice showing up here means the routing regressed to the fallback.
app.runtimeEvents.emit = func(_ context.Context, name string, _ ...interface{}) {
if name == "runtime:rebuilt" {
mu.Lock()
rebuilt = append(rebuilt, "VIA-APP-QUEUE")
mu.Unlock()
}
}
sinkEmit := func(_ context.Context, name string, payload ...interface{}) {
if name != "runtime:rebuilt" {
return
}
tabID := ""
if len(payload) > 0 {
tabID, _ = payload[0].(string)
}
mu.Lock()
rebuilt = append(rebuilt, tabID)
mu.Unlock()
}
tab := &WorkspaceTab{
ID: "tab_rebuild_events",
Scope: "global",
WorkspaceRoot: globalTabWorkspaceRoot(),
Ready: true,
model: "old/old-model",
Ctrl: ctrl,
sink: &tabEventSink{tabID: "tab_rebuild_events", app: app, ctx: context.Background()},
disabledMCP: map[string]ServerView{},
}
tab.sink.runtimeEvents.emit = sinkEmit
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
t.Cleanup(func() {
if tab.Ctrl != nil {
tab.Ctrl.Close()
}
})
waitCount := func(want int, step string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
mu.Lock()
n := len(rebuilt)
mu.Unlock()
if n >= want {
return
}
time.Sleep(10 * time.Millisecond)
}
mu.Lock()
defer mu.Unlock()
t.Fatalf("after %s: runtime:rebuilt events = %v, want %d", step, rebuilt, want)
}
if err := app.SetModelForTab(tab.ID, "new/deepseek-v4-pro"); err != nil {
t.Fatalf("SetModelForTab: %v", err)
}
waitCount(1, "model switch")
if err := app.SetEffortForTab(tab.ID, "high"); err != nil {
t.Fatalf("SetEffortForTab: %v", err)
}
waitCount(2, "effort switch")
if err := app.SetTokenModeForTab(tab.ID, "economy"); err != nil {
t.Fatalf("SetTokenModeForTab: %v", err)
}
waitCount(3, "token-mode switch")
mu.Lock()
defer mu.Unlock()
for i, id := range rebuilt {
if id == "VIA-APP-QUEUE" {
t.Fatalf("event %d took the App-level fallback queue; it must ride the tab sink queue so it orders before the rebuilt controller's agent events (full: %v)", i, rebuilt)
}
if id != tab.ID {
t.Fatalf("event %d carried tab id %q, want %q (full: %v)", i, id, tab.ID, rebuilt)
}
}
}
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"errors"
"log/slog"
"syscall"
)
// Sanitized errors surfaced when a destructive or restore session operation
// hits a real filesystem blocker. Like errSessionBusyElsewhere, they
// intentionally carry no path, so raw OS error text never reaches the UI.
var (
errSessionFileLocked = errors.New("a session file is temporarily locked by another program (often antivirus or sync tools) — wait a moment and retry")
errSessionFileAccessDenied = errors.New("access to a session file was denied — close programs that may be using it or check folder permissions, then retry")
errSessionDiskFull = errors.New("not enough disk space to finish the operation — free some space and retry")
)
// friendlySessionFileError rewrites raw OS-level filesystem errors from the
// session trash/restore/purge flows (e.g. a Windows sharing violation while a
// scanner holds a transcript) into the actionable, path-free errors above.
// The original error is logged so diagnostics keep the path and errno.
// Unrecognized errors — including already-sanitized ones like
// errSessionBusyElsewhere — pass through unchanged.
func friendlySessionFileError(err error) error {
if err == nil {
return nil
}
var errno syscall.Errno
if !errors.As(err, &errno) {
return err
}
var friendly error
switch {
case isFileInUseErrno(errno):
friendly = errSessionFileLocked
case isAccessDeniedErrno(errno):
friendly = errSessionFileAccessDenied
case isDiskFullErrno(errno):
friendly = errSessionDiskFull
default:
return err
}
slog.Warn("desktop: session file operation blocked", "err", err)
return friendly
}
Loaded 100 of 416 files, more files were not shown because too many files have changed in this diff. Show more