Compare commits

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

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

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

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

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

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

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

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

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

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

MAIL_FROM was [email protected], but only send.reasonix.io is a
verified Resend domain (the apex isn't). Resend rejects sends from an
unverified from-domain, so every verification/reset email would 403.
Send from the verified subdomain instead.
2026-07-01 15:13:54 +08:00
SivanCola c38b0801cc Merge pull request #5691 from SivanCola/fix/transient-blank-session-delete
Fix delete fallback blank sessions / 修复删除后的空白会话残留
2026-07-01 15:05:16 +08:00
SivanCola 8d42210d7c fix(desktop): discard unused transient blank sessions 2026-07-01 14:55:19 +08:00
SivanCola 191772931f fix(desktop): keep delete fallback blanks out of project tree 2026-07-01 14:27:47 +08:00
SivanCola 56a53237f4 Merge pull request #5675 from SivanCola/fix/legacy-sidebar-project-recovery
Recover legacy sidebar projects on upgrade / 升级时恢复旧版侧栏项目
2026-07-01 11:49:53 +08:00
SivanCola c045230307 fix(desktop): serialize project index updates 2026-07-01 11:41:06 +08:00
SivanCola 946255ce89 fix(desktop): recover legacy sidebar projects on upgrade 2026-07-01 11:27:46 +08:00
SivanCola 397605ab13 Merge pull request #5674 from SivanCola/fix/mcp-remove-clears-failure
Clear stale MCP failure after removal / 删除 MCP 后清理残留失败状态
2026-07-01 11:13:59 +08:00
SivanColaandcyq1017 a753893797 fix(desktop): clear failed MCP status after removal
Co-authored-by: cyq1017 <[email protected]>
2026-07-01 11:08:30 +08:00
SivanCola 23bf5dd464 Merge pull request #5657 from CVEngineer66/feature/autoresearch-runtime
Add host-managed AutoResearch runtime
2026-07-01 04:53:00 +08:00
SivanCola c600b12693 Merge pull request #5643 from SauronSkywalker/dpi-zoom-restart
feat(desktop): add custom DPI zoom via WebView2 ZoomFactor
2026-07-01 04:51:57 +08:00
SivanCola d2cf3addc7 Merge pull request #5662 from SivanCola/fix/math-rendering-pr-integration
Consolidate math rendering fixes / 整合数学渲染修复
2026-07-01 04:51:17 +08:00
SivanCola f1951c5d1f Harden AutoResearch task store paths
Co-authored-by: SivanCola <[email protected]>
2026-07-01 01:01:35 +08:00
SivanCola 82a9d6829e Consolidate math rendering fixes 2026-07-01 00:54:52 +08:00
SivanCola 7981c96d70 fix(desktop): keep zoom preference authoritative
Co-authored-by: SivanCola <[email protected]>
2026-07-01 00:50:51 +08:00
SivanCola ac1b2ad45d Merge pull request #5661 from SivanCola/fix/hide-windows-probe-consoles
Hide Windows startup probe consoles / 隐藏 Windows 启动探测终端弹窗
2026-07-01 00:49:18 +08:00
SivanCola f00bb1ea85 Fix AutoResearch readiness evidence loop 2026-07-01 00:22:09 +08:00
SivanCola 68b7ea6c07 fix(desktop): hide zoom setting outside Windows
Co-authored-by: SivanCola <[email protected]>
2026-07-01 00:12:19 +08:00
SivanCola d239e346f6 fix(environment): hide startup probe consoles on Windows 2026-07-01 00:09:17 +08:00
wufengfan 5527e8e805 Keep AutoResearch out of default tool surface 2026-06-30 23:51:13 +08:00
wufengfan cf7ee9e400 Remove AutoResearch frontend UI surfaces 2026-06-30 23:35:21 +08:00
wufengfan c580472167 Reuse AutoResearch task for repeated goal updates 2026-06-30 23:07:03 +08:00
wufengfan 462fd5e281 Refresh status metadata for AutoResearch notices 2026-06-30 23:01:39 +08:00
wufengfan 8ddad32b4b Allow AutoResearch status bar item in config 2026-06-30 22:53:31 +08:00
wufengfan 2bc0d2f010 Add host-managed AutoResearch runtime 2026-06-30 22:40:43 +08:00
SivanCola 97a6d617f2 Merge pull request #5648 from GTC2080/GTC/fix-5596-memory-compiler-self-feedback
fix(memorycompiler): suppress compiler feedback noise
2026-06-30 21:16:41 +08:00
SivanCola 13a86545d3 Complete memory compiler issue controls
Co-authored-by: SivanCola <[email protected]>
2026-06-30 21:06:36 +08:00
SivanCola 8d67305f88 Merge pull request #5646 from SivanCola/fix/desktop-session-workspace-binding
Fix desktop session workspace binding / 修复桌面会话工作区绑定
2026-06-30 20:54:29 +08:00
SivanCola 052fdc25ca fix(desktop): reconcile effort workspace binding 2026-06-30 20:48:04 +08:00
SivanCola 754c95c038 Merge pull request #5647 from Li-Charles-One/feat/creation-composer-polish
style(creation): 打磨创作输入框发送按钮与模式切换条
2026-06-30 20:25:31 +08:00
SivanCola 78686f68a3 Filter memory compiler feedback noisy refs
Co-authored-by: SivanCola <[email protected]>
2026-06-30 20:24:32 +08:00
Sivan 6d43964c5b fix(desktop): serialize workspace reconcile quick clicks 2026-06-30 20:23:46 +08:00
Sivan 4df899342b fix(desktop): guard steer and compact workspace binding 2026-06-30 20:14:10 +08:00
GTC2080 0a227840d1 fix(memorycompiler): suppress compiler feedback noise 2026-06-30 21:56:53 +10:00
Li-Charles-One 9c55693279 Polish creation composer controls 2026-06-30 19:33:55 +08:00
Sivan 82e6002d1d fix(desktop): reconcile session workspace bindings 2026-06-30 19:26:58 +08:00
SivanCola 8b1e5c8700 Merge pull request #5644 from SivanCola/fix/custom-provider-full-url
Support full custom provider request URLs / 支持自定义供应商完整请求 URL
2026-06-30 18:04:47 +08:00
SivanCola 21e1965d5a Merge pull request #5635 from SivanCola/fix/planner-executor-cache-breakdown-5488
Fix planner/executor cache telemetry breakdown / 修复规划器与执行器缓存明细
2026-06-30 18:01:43 +08:00
Sivan e7d6b0f5c6 docs: document custom provider full URLs 2026-06-30 17:55:25 +08:00
SivanCola f55a0c6f0d test(desktop): cover executor-only usage sources
Co-authored-by: SivanCola <[email protected]>
2026-06-30 17:51:34 +08:00
SivanCola 69305cbf5c ui(desktop): compact runtime metrics panel
Co-authored-by: SivanCola <[email protected]>
2026-06-30 17:44:18 +08:00
SivanCola 0b3f501db0 fix(provider): support full custom chat URLs 2026-06-30 17:41:52 +08:00
SivanCola 61e10c95fc ui(desktop): refine model usage breakdown
Co-authored-by: SivanCola <[email protected]>
2026-06-30 17:38:38 +08:00
SivanCola 40b32c421e Merge pull request #5641 from SivanCola/fix/custom-provider-openai-kind
Default custom providers to OpenAI / 自定义供应商默认使用 OpenAI 协议
2026-06-30 17:34:01 +08:00
SivanCola 5d6d8c8504 fix(desktop): default custom providers to OpenAI 2026-06-30 17:22:44 +08:00
SauronSkywalker af13bc76be feat(desktop): add custom DPI zoom via WebView2 ZoomFactor
- Add ZoomFactor support in windows.Options (main.go)
- Add zoom_factor.go for JSON persistence of zoom setting
- Add dpiScale.ts for frontend zoom state management
- Add slider UI in Settings → Appearance with capsule thumb
- Add SetDesktopZoomFactor / GetDesktopZoomFactor / RestartApplication bindings
- Add locale keys for zoom setting (en/zh/zh-TW)

Setting changes require app restart to take effect (WebView2 engine-level).
2026-06-30 17:17:27 +08:00
SivanCola e0bdcf70df Fix planner executor cache telemetry breakdown
Co-authored-by: SivanCola <[email protected]>
2026-06-30 16:31:16 +08:00
SivanCola ff379b4244 Merge pull request #5478 from ttmouse/fix/session-title-reset-5438
fix(desktop): restore topic title from session fallback when switching projects in creation mode (#5438)
2026-06-30 16:23:21 +08:00
SivanCola d60d0b0ae8 Merge pull request #5455 from ttmouse/fix/paste-multiline-5446
fix(composer): ensure pasted text is always captured via React state (#5446)
2026-06-30 16:18:59 +08:00
SivanCola e7334831e9 Merge pull request #5477 from ttmouse/fix/session-switch-flicker-5441
fix(tui): suppress ClearScreen flicker on session switch (#5441)
2026-06-30 16:11:24 +08:00
SivanCola d25edd2541 test(composer): cover short multiline text paste submit
Co-authored-by: SivanCola <[email protected]>
2026-06-30 15:51:36 +08:00
SivanandSivanCola 1f23350262 fix(desktop): preserve stored topic titles on project switch
Restore saved topic titles from session metadata or desktop session title sidecars when the topic title index is missing, while preserving manually defaulted topic titles. Also removes the unrelated OpenAI stream change from this PR diff.

Co-authored-by: SivanCola <[email protected]>
2026-06-30 15:50:40 +08:00
SivanandSivanCola 59fa0357fd Merge origin/main-v2 into session switch flicker fix
Resolve the clear transcript helper conflict and add focused regression coverage for session-switch ClearScreen suppression.

Co-authored-by: SivanCola <[email protected]>
2026-06-30 15:47:25 +08:00
SivanCola 6f15baefac Merge pull request #5535 from GTC2080/GTC/fix-5068-image-drop-navigation
fix(desktop): prevent file drops opening images
2026-06-30 15:36:01 +08:00
SivanCola 60c787e02e Merge pull request #5536 from GTC2080/GTC/fix-5533-transcript-final-fold
fix(desktop): keep final replies visible in compact transcript
2026-06-30 15:33:40 +08:00
SivanCola 185731e586 Merge pull request #5284 from qiukeren/addUpdateMessage
feat(i18n): add MB unit to download progress display
2026-06-30 15:30:39 +08:00
SivanCola dd896f9ea1 Merge pull request #5630 from SivanCola/fix/desktop-system-prompt-memory
Fix desktop system prompt refresh / 修复桌面 system prompt 刷新
2026-06-30 15:21:30 +08:00
SivanCola bddb8000d2 Merge pull request #5631 from SivanCola/fix/composer-responsive-controls
Improve composer responsive controls / 优化输入栏响应式控件
2026-06-30 15:19:59 +08:00
Sivan dad8195b3a fix(desktop): improve composer responsive controls 2026-06-30 15:11:49 +08:00
SivanCola 224321fbd0 fix(desktop): refresh system prompt across session rebuilds
Move the fresh-system-prompt resume helper into shared desktop code and apply it when model, effort, or token mode rebuilds carry existing history.

Refresh existing system messages when restoring pinned/topic sessions, while preserving legacy user-only transcripts as-is.

Add regression coverage for global AGENTS.md being picked up after a desktop model switch.
2026-06-30 15:07:06 +08:00
drafish 46e50ee5a5 fix(desktop): prevent empty placeholder session from overwriting real session
When a new desktop tab is created, createEmptySessionFile writes an
empty placeholder .jsonl file.  Later, buildTabControllerWithContext
loads it via LoadSession — which returns a non-nil &Session{} (zero
messages) — and Resume replaces the executor's real session (built by
boot.Build with the full system prompt) with the empty one.  The
subsequent snapshot writes only user/assistant/tool messages,
dropping the agent identity contract permanently.

Root cause: desktop/tabs.go loadPinnedTabSessionWithPreload treated
a 0-message session as a valid resume target.

Fix: treat an empty session the same as a missing file — return nil
so the caller keeps the live session built by boot.Build.

Adds a defensive guard in controller snapshot(): refuse to persist
a session that has content but no system message, logging a warning
so the identity-loss invariant is enforced regardless of code path.

Adds Session.HasSystemMessage() and focused tests.
2026-06-30 15:07:06 +08:00
SivanCola d98610825a Merge pull request #5628 from SivanCola/codex/fix-5627-tui-copy-docs
docs: correct TUI transcript selection guidance
2026-06-30 15:04:27 +08:00
SivanandVu Minh Sang d52fec85a3 docs: correct TUI transcript selection guidance
Co-authored-by: Vu Minh Sang <[email protected]>
2026-06-30 14:53:31 +08:00
SivanCola 046f575e2d Merge pull request #5620 from SivanCola/fix/desktop-runtime-safety-maintenance
Harden desktop topic IDs and tray probing / 加固桌面 topic ID 与托盘探测
2026-06-30 11:52:39 +08:00
SivanCola 06e6075d88 Merge pull request #5555 from yekern/fix/tui-cjk-cursor-shape
feat(cli): add configurable cursor_shape to fix CJK text input rendering
2026-06-30 11:35:37 +08:00
Sivan c675d754cc fix(desktop): harden topic ids and tray probing 2026-06-30 11:34:17 +08:00
SivanCola c8ccc01740 Merge pull request #5619 from SivanCola/fix/desktop-hydration-final
Fix desktop hydration storms / 修复桌面端会话同步风暴
2026-06-30 11:31:56 +08:00
SivanandSivanCola 5f6315c079 Document TUI cursor shape config
Add the new ui.cursor_shape option to the user guide, configuration paths docs, and configuration spec so the formal docs match the PR behavior.

Co-authored-by: SivanCola <[email protected]>
2026-06-30 11:26:05 +08:00
SivanCola b2fa320188 Merge pull request #5543 from yekern/fix/tui-transcript-perf
feat(cli): add /cls to clear screen without losing LLM context
2026-06-30 11:17:07 +08:00
SivanandGTC2080 7e4f600b03 fix(desktop): coalesce tab hydration storms
Builds on the in-flight hydration coalescing approach from #5580 while scoping ready events to the tab that finished building and keeping visible transcript hydration independent from slower ancillary calls.

Co-authored-by: GTC2080 <[email protected]>
2026-06-30 11:13:09 +08:00
SivanCola be6df9e2f5 Merge pull request #5572 from GTC2080/GTC/fix-5570-edit-original-jsonl
fix: preserve original prompt on message edit
2026-06-30 11:08:04 +08:00
SivanandSivanCola 5a6a93eced fix(cli): reset display state when clearing transcript
Reset transcript-owned shell output indexes when /cls or /clear clears the visible TUI buffer, so Ctrl+B cannot rewrite or panic on stale transcript slots. Document the clear-screen shortcut in the CLI guide.

Co-authored-by: SivanCola <[email protected]>
2026-06-30 11:06:10 +08:00
SivanandSivanCola 041585203b Preserve configured TUI cursor shape
Render ui.cursor_shape through config save paths and guard the textarea cursor-shape mapping with focused tests.

Co-authored-by: SivanCola <[email protected]>
2026-06-30 11:03:34 +08:00
SivanCola f3c536d2ff Merge pull request #5579 from GTC2080/GTC/fix-5575-preserve-waitdelay
fix(bash): preserve session launchers after WaitDelay
2026-06-30 10:46:09 +08:00
SivanCola e8f7c150b4 Merge pull request #5568 from nanami7777777/docs/add-qq-bot-guide
docs: add QQ Bot to bot guide
2026-06-30 10:45:13 +08:00
Sivan 2250de1ab9 docs: fix QQ bot guide accuracy 2026-06-30 10:34:27 +08:00
wufengfan 4d767f3db4 Add AutoResearch runtime design spec 2026-06-29 22:21:11 +08:00
GTC2080 192c53357a fix(bash): preserve session launchers after WaitDelay 2026-06-29 22:09:55 +10:00
nanami7777777 ab9951af9e docs: fix QQ Bot description - change from QR scan to manual config
QQ Bot (as of v1.13.1) does not support QR code scanning for
connection setup. It must be manually configured with App ID and
App Secret. This corrects the documentation to match the actual
implementation.

Fixes #5540
2026-06-29 18:56:35 +08:00
GTC2080 615d5a9981 fix: preserve original prompt on edits 2026-06-29 20:35:10 +10:00
SivanCola adf228b344 Merge pull request #5566 from esengine/feature/context-contract-maintenance
Add context maintenance and tool contract guards / 增加上下文维护与工具合约保护
2026-06-29 18:21:47 +08:00
Sivan d308d0de1b Move snip geometry onto tools and guard the contract
The snip strategy was a name-keyed switch in prune.go: a tool rename
silently dropped its result to the generic default, with nothing to
catch the desync. Move the geometry onto the tools themselves.

- Add tool.SnipHint value + optional tool.SnipHinter interface; the
  large-output built-ins (read_file, web_fetch, bash, grep, glob, ls,
  lsp_*) implement it with the same geometry the switch used.
- prune.go resolves the strategy through the registry (SnipHinter),
  falling back to a ReadOnly-tiered default: read-only output keeps a
  long head/short tail, side-effecting output keeps both ends evenly.
- TestEveryBuiltinDeclaresSnipStance forces every registered built-in to
  either implement SnipHinter or opt into the default explicitly, so a
  new or renamed tool fails CI instead of silently desyncing.
- Cover registry-resolved hints and the ReadOnly-tiered fallback.

go test ./internal/agent/... ./internal/tool/... ./internal/lsp/... ./internal/boot/...
go vet ./internal/agent/... ./internal/tool/... ./internal/lsp/...
2026-06-29 18:14:54 +08:00
SivanCola 9a69481e8d Harden startup environment probes 2026-06-29 17:18:59 +08:00
nanami7777777 40259bea22 docs: add QQ Bot to bot guide (fixes #5540) 2026-06-29 17:05:43 +08:00
SivanCola 2abd87e646 Cache startup environment probes 2026-06-29 16:56:40 +08:00
SivanCola 5401c3538b Add context maintenance and tool contract guards 2026-06-29 16:41:36 +08:00
SivanCola 0eb641c960 Merge pull request #5530 from cyq1017/codex/reasonix-5176-step-index
fix(agent): allow complete_step by todo index
2026-06-29 16:14:41 +08:00
SivanCola 9656efb5fe Merge pull request #5556 from morningtzh/fix/acp-mcp-headers-array
fix(acp): MCPServerSpec.Headers 兼容 ACP 官方 [{name,value}] 数组形状
2026-06-29 15:46:38 +08:00
SivanCola c3593566b3 Merge pull request #5554 from SivanCola/feature/mermaid-rendering-integration
Integrate Mermaid diagram rendering / 整合 Mermaid 图表渲染
2026-06-29 15:25:39 +08:00
MorningTZH 43cc7a315c fix(acp): accept official HTTPHeader[] shape for MCP server headers
ACP spec ships HTTP/SSE MCP `headers` as an array of {name,value} objects
(see https://agentclientprotocol.com). Reasonix's `MCPServerSpec.Headers`
declared the legacy `map[string]string` shape, so spec-compliant clients
like Paseo — which always emits `headers` as an array, even when empty —
got rejected at the very first `session/new`:

    json: cannot unmarshal array into Go struct field
    MCPServerSpec.mcpServers.headers of type map[string]string

The sibling `Env` field already solves this exact problem with a custom
`MCPEnv` type that tries the official array shape first and falls back to
the legacy map. Mirror that for headers via a new `MCPHeaders` type, and
extract the shared parse into `unmarshalNameValueMap` so both fields share
one implementation. Existing wire formats keep working unchanged.

Refs: #5553
2026-06-29 15:15:40 +08:00
Sivan 3556ffe771 Integrate Mermaid diagram rendering 2026-06-29 15:15:27 +08:00
maple 673996e9fc feat(cli): add configurable cursor_shape for TUI input
Add [ui] cursor_shape option (block|underline|bar) to let users choose
the textarea cursor shape. Defaults to underline to avoid CJK wide-char
rendering artifacts caused by Bubble Tea's real terminal block cursor
overlapping double-width characters during edit operations.

- UIConfig.CursorShape field + UICursorShape() normalizer
- cliCursorShape package var set from config at startup
- applyTextareaTheme() maps config value to tea.CursorShape
2026-06-29 14:38:21 +08:00
maple 5ea0ee26fc cli: add /cls command and Ctrl+L to clear TUI screen only
Add /cls slash command and Ctrl+L keyboard shortcut that clears the TUI
transcript display while preserving the LLM conversation context. Unlike
/clear and /new (which both reset the agent session), /cls only clears
the visual buffer.

- /cls command handler in runSlashCommand
- Ctrl+L key binding (idle-only, gated by m.state != tuiRunning)
- Tab completion and /help registration
- i18n messages for en/zh/zh-TW
2026-06-29 11:16:04 +08:00
GTC2080 cb494af294 fix(desktop): keep final text out of processed folds 2026-06-29 09:41:03 +10:00
GTC2080 b09d045d14 fix(desktop): prevent file drops opening images 2026-06-29 08:17:27 +10:00
ttmouse 01414ef99b fix(openai): deliver final stream error chunk reliably when ctx is cancelled
The stream-reconnect loop calls sendChunk(ctx, out, error) with the
caller's context after readStream returns context.Canceled.  Since
the context is already cancelled, sendChunk's second select races
<-ctx.Done() against out<-chunk (both ready), dropping the error
~50% of the time — the caller sees a nil error instead of
context.Canceled.

Fix: use context.Background() for the final non-reconnectable error
send, so the error chunk is always delivered to the reader.

Fixes TestStreamCancelDoesNotReconnect flaky failure under -race.
2026-06-29 03:07:14 +08:00
SivanCola aaaf863c11 Merge pull request #5532 from SivanCola/fix/theme-user-bubble-accent
Soften themed user message bubbles / 柔化主题用户消息气泡
2026-06-29 02:16:00 +08:00
SivanCola a0ce934d1e Soften themed user message bubbles 2026-06-29 02:02:07 +08:00
SivanCola 284e65d912 Merge pull request #5529 from SivanCola/fix/edit-file-fuzzy-old-string
Fix edit_file old_string drift matching / 修复 edit_file 匹配漂移
2026-06-29 01:38:07 +08:00
SivanCola 2a9c3f5ede fix(edit): tolerate whitespace drift in old_string matches 2026-06-29 01:26:50 +08:00
SivanCola d7f49d1f54 Merge pull request #5528 from cyq1017/codex/reasonix-5269-run-skill-subject
fix(desktop): show run_skill subject
2026-06-29 01:25:32 +08:00
cyq 555af11724 fix(agent): allow complete_step by todo index 2026-06-29 01:12:26 +08:00
SivanCola 633d602718 fix(desktop): keep run_skill subject in history
Preserve the same run_skill skill-name subject when completed tool calls are restored from archived history, where the frontend no longer has full args available to recompute it.\n\nRefs #4740.
2026-06-29 01:04:18 +08:00
SivanCola 02bd3d95b4 Merge pull request #5512 from SivanCola/fix/desktop-performance-5306
Fix desktop long-history lag and cancellation state / 修复桌面端长历史卡顿与取消状态
2026-06-29 00:44:09 +08:00
SivanCola 0da7fe8326 fix(desktop): align paged checkpoint refresh 2026-06-29 00:38:13 +08:00
SivanCola c9a358c779 fix(desktop): page history and preserve cancel context 2026-06-29 00:16:01 +08:00
cyq 0256e836ec fix(desktop): show run_skill subject 2026-06-29 00:12:19 +08:00
SivanCola 1c19a04833 Merge pull request #5500 from SauronSkywalker/feat/metrics-session-cumulative-tokens
feat(metrics): add session-cumulative input/在运行指标栏新增输入tokens(命中/未命中缓存)和输出tokens统计
2026-06-29 00:09:24 +08:00
SivanCola 1d30640be6 Show exact token metric values
Co-authored-by: SivanCola <[email protected]>
2026-06-28 23:56:22 +08:00
SivanCola 176e41c0ad Use wide cards for token metrics
Co-authored-by: SivanCola <[email protected]>
2026-06-28 23:52:05 +08:00
SivanCola cb52340db8 Merge pull request #5522 from SivanCola/fix/statusbar-configured-items-only
Restrict status bar to configured items / 限制状态栏仅显示配置项
2026-06-28 23:39:51 +08:00
SivanCola cf66ff24fa Fix status bar to honor configured items 2026-06-28 23:25:26 +08:00
SivanCola bca450eff0 Compact large token metric labels
Co-authored-by: SivanCola <[email protected]>
2026-06-28 23:25:23 +08:00
SivanCola 4bea752508 Merge pull request #5504 from SivanCola/fix/bot-session-delete-mapping
Clean up bot session mappings on delete / 删除 Bot 会话时清理映射
2026-06-28 23:14:27 +08:00
SivanCola 758a9e1fe1 Fix bot session deletion mapping cleanup 2026-06-28 23:06:25 +08:00
SivanCola 94166c3c08 Format context panel metrics fields
Co-authored-by: SivanCola <[email protected]>
2026-06-28 23:05:49 +08:00
SivanCola 68460194c4 Merge pull request #5498 from GTC2080/GTC/fix-5492-linux-tray-restore-path
fix(desktop): avoid background close without tray restore
2026-06-28 22:49:45 +08:00
SivanCola 832fe95906 Merge pull request #5501 from SivanCola/docs/memory-v5-llm-classification-env
Document Memory v5 classifier env / 补充 Memory v5 分类环境变量文档
2026-06-28 22:48:11 +08:00
SivanCola f7bfa62b7f fix(desktop): require ready tray before background close
Co-authored-by: SivanCola <[email protected]>
2026-06-28 22:41:49 +08:00
SivanCola fd9338fd61 docs: document Memory v5 LLM classifier env 2026-06-28 22:41:03 +08:00
SivanCola 0f24a334f6 Merge pull request #5493 from XTLine/fix/memory-v5-citation-noise
feat: improve Memory v5 task/chat classification with LLM classifier
2026-06-28 22:29:37 +08:00
SauronSkywalker d6d36a9c05 feat(metrics): add session-cumulative input/output token cards to overview panel
Add three new metric cards to the runtime metrics section:
- Input tokens (cache hit) -- session-cumulative cache hit prompt tokens
- Input tokens (cache miss) -- session-cumulative cache miss prompt tokens
- Output tokens -- session-cumulative completion tokens

Go backend changes:
- ContextPanelInfo struct: add SessionCacheHitTokens,
  SessionCacheMissTokens, SessionCompletionTokens fields
- ContextPanel() method: populate new fields from telemetry snapshot

Frontend changes:
- types.ts: add matching fields to ContextPanelInfo interface
- bridge.ts: update mock return values
- ContextPanel.tsx: use new info fields directly; add wide layout
- en.ts/zh.ts/zh-TW.ts: add three i18n translation keys
2026-06-28 22:23:58 +08:00
GTC2080 aae563c0b3 fix(desktop): avoid background close without tray restore 2026-06-29 00:19:39 +10:00
SivanCola 72e6e360bb test(agent): expand Memory v5 heuristic classifier coverage 2026-06-28 22:17:13 +08:00
SivanCola 715630d366 Merge pull request #5467 from ttmouse/fix/context-token-total-5283
fix(desktop): include completion tokens in context gauge total (#5283)
2026-06-28 22:07:16 +08:00
SivanCola 9d5d2a3c1c Merge pull request #5487 from ttmouse/fix/memory-v5-filter-evidence-5468
fix(memorycompiler): filter tool_result evidence from user-facing citations (#5468)
2026-06-28 22:01:14 +08:00
SivanCola 356037ae62 Add context token total regression coverage
Co-authored-by: SivanCola <[email protected]>
2026-06-28 21:58:41 +08:00
SivanCola fae1bf2c74 fix(agent): honor Memory v5 task classification 2026-06-28 21:50:19 +08:00
SivanCola ba611da664 test(memorycompiler): guard evidence citation filtering 2026-06-28 21:50:19 +08:00
XTLine 097a49d2aa test: update and add E2E tests for classifier behavior
Update TestRunDoesNotInjectMemoryCompilerContractForGreeting to expect
zero stats events (chat inputs now completely skip Memory v5).

Add two new E2E tests:
- TestClassifierSkipsMemoryV5ForChat: verifies chat skips Memory v5
- TestClassifierUsesMemoryV5ForTask: verifies tasks use Memory v5
2026-06-28 20:09:11 +08:00
XTLine bbc2d87404 feat: integrate classifier to gate Memory v5 for chat vs task
Update Agent to use TaskClassifier:
- Add classifier field and UseMemoryCompilerLLMClassification option
- Initialize classifier in New() (LLM or heuristic based on flag)
- Completely skip Memory v5 for chat inputs (not just injection)
- Clear cache on session boundary

Add REASONIX_MEMORY_COMPILER_LLM_CLASSIFICATION env var (default: false).

When disabled (default), uses heuristic classifier. When enabled, uses LLM
classifier with heuristic fallback on error/timeout.
2026-06-28 20:08:56 +08:00
XTLine d916d0c78d feat: add task/chat classifier infrastructure for Memory v5
Add TaskClassifier interface with two implementations:
- llmClassifier: uses provider with 2s timeout, fallback to heuristic
- heuristicClassifier: improved keyword matching + file reference detection

Add classificationCache with LRU eviction (100 entries max) and 5min TTL.

Includes comprehensive unit tests for both classifiers and cache behavior.
2026-06-28 20:08:14 +08:00
XTLine 76852fe134 fix: gate memory compiler prompt injection for chat 2026-06-28 17:57:26 +08:00
ttmouse 53dea11006 fix(memorycompiler): filter tool_result evidence from user-facing citations (#5468)
Tool result memory nodes (Influence == "evidence") represent transient
internal graph state like "bash succeeded" or "read_file succeeded".
These are noise when shown as "Memory v5 compiler" citations every turn.

The memory graph still records tool results for model-side learning and
constraint generation; only the user-facing citation display is filtered.

Fixes #5468
2026-06-28 17:51:58 +08:00
ttmouse 808e24ff2e fix: also reset controller planMode; only suppress ClearScreen on actual transcript rebuild 2026-06-28 17:42:40 +08:00
XTLine bc9b7f2abb fix: hide memory compiler citations in transcript 2026-06-28 16:45:07 +08:00
XTLine 59dc039d33 fix: throttle memory compiler prompt injection 2026-06-28 16:24:40 +08:00
ttmouse 0dd1923ab1 fix(desktop): restore topic title from session fallback when switching projects in creation mode (#5438)
In single-surface (creation/workbench) mode, switching between projects
removes the previous tab via keepOnlyVisibleTab. When the user switches
back, a new tab is created and the topic title is loaded from the
persisted topic-titles file. However, if the topic-title file has no
entry for this topic ID (e.g., due to path normalization differences
on Windows), the title would incorrectly show the default '新的会话'.

This fix adds a fallback: when the topic-title file has no entry and a
valid session path is known, extract the title from the session and
persist it back to the topic-titles file for consistency.
2026-06-28 13:55:47 +08:00
ttmouse df11ddd1b7 fix(composer): reset history nav + normalize CRLF in short paste path (#5446)
Address Codex review feedback:
- Reset prompt history navigation state in the short-paste branch
  so context-menu pastes don't corrupt prompt-history editing
- Normalize \r\n to \n for caret position calculation (Windows)
2026-06-28 13:30:40 +08:00
ttmouse 1e3ef27a31 fix(composer): ensure pasted text is always captured via React state (#5446)
The paste handler previously relied on the browser's default paste behavior
for short multi-line text (<20 lines), only preventing default for long
pastes that get folded into collapsible blocks. This creates a race condition
in React's controlled-input reconciliation: a concurrent re-render can
overwrite the textarea DOM value with stale state before onChange fires,
losing the pasted content entirely (WebView2 / Windows).

Fix: always preventDefault() in the paste handler and insert text through
React setText() — both for folded long pastes and raw short pastes — so
the value is always driven by React state, not the DOM.

Also switch submit() to read from textRef.current / attachmentsRef.current
/ workspaceRefsRef.current / sessionRefsRef.current instead of state
variables, providing a second defense layer against stale closures.
2026-06-28 13:26:37 +08:00
SivanCola b9d1ffc606 Merge pull request #5470 from esengine/fix/plan-revision-file-refs
Fix file references in plan revisions / 修复修改计划中的文件引用
2026-06-28 13:11:05 +08:00
SivanCola e4a72782c0 Fix file references in plan revisions 2026-06-28 12:33:03 +08:00
ttmouse c545e6ae26 fix(desktop): include completion tokens in context gauge total (#5283) 2026-06-28 12:10:46 +08:00
SivanCola 5efbb7384f Merge pull request #5454 from ttmouse/fix/bot-tool-approval-live-update
fix(bot): 修改连接工具审批时避免全量重启 BotGateway
2026-06-28 11:25:19 +08:00
SivanCola f54c763538 Fix live bot approval mode updates
Track active bot sessions by connection so live approval mode changes reach hashed session keys, propagate inherit selections to the running gateway, and keep the settings panel state in sync after the lightweight save path.

Co-authored-by: SivanCola <[email protected]>
2026-06-28 11:08:17 +08:00
SivanCola 427658bc86 Merge pull request #5453 from SivanCola/feature/external-folder-session-refs
Support session-scoped external folder refs / 支持会话级外部文件夹引用
2026-06-28 09:10:38 +08:00
SivanCola 8a8daad590 Merge main-v2 into external folder refs
# Conflicts:
#	internal/boot/boot.go
#	internal/tool/builtin/glob.go
#	internal/tool/builtin/grep.go
#	internal/tool/builtin/ls.go
#	internal/tool/builtin/readfile.go
#	internal/tool/builtin/workspace.go
2026-06-28 04:26:06 +08:00
SivanCola 1976a217eb feat: let read tools resolve external folder refs 2026-06-28 04:15:32 +08:00
SivanCola a81a46583f feat: clarify directory ref context 2026-06-28 04:01:47 +08:00
SivanCola af3d3805e5 feat: support external folder subpath refs 2026-06-28 03:52:57 +08:00
SivanCola 20e99f6913 Merge pull request #5452 from SivanCola/fix/memory-security-hardening
Fix memory and permission hardening regressions / 修复记忆与权限加固回归
2026-06-28 03:31:02 +08:00
SivanCola 35d2476974 Fix memory and permission hardening regressions 2026-06-28 03:22:28 +08:00
ttmouse 20ccf46d2b fix(bot): 修改连接工具审批时避免全量重启 BotGateway
## 问题

在 Settings → Bot → 连接详情中修改「工具审批」(ask/auto/yolo) 时,
SetBotSettings 会调用 refreshBotRuntimeAsync() 停掉整个 BotGateway
再重建,导致飞书 WebSocket 断连、已有 session 销毁。
此期间任何卡片动作回调都会失败,显示「目标回调服务当前未在线」。

## 修复

新增轻量路径 SetBotConnectionToolApprovalMode:

1. internal/bot/gateway.go — 新增 UpdateConnectionToolApprovalMode,
   直接更新内 ConnectionChannels 的 ToolApprovalMode,
   并通知该连接下所有活跃 session 的 controller 即时生效

2. desktop/bot_runtime_app.go — 新增 updateConnectionToolApprovalMode,
   委托给网关的原地更新方法

3. desktop/settings_app.go — 新增 SetBotConnectionToolApprovalMode API,
   只保存该字段到磁盘 + 原地更新运行中网关,不触发全量重启

4. desktop/frontend — 桥接声明、调用处改为新 API

附带修复:
- desktop/frontend/src/lib/types.ts: normalizeToolApprovalMode
  补充 fallbackMode === 'yolo' 分支(之前只有 auto)

关联 issue: #5424
2026-06-28 03:21:23 +08:00
SivanCola 6a9d1acb8c feat: support session-scoped external folder refs 2026-06-28 03:13:50 +08:00
SivanCola 3ee319d763 Merge pull request #5348 from myipanta/fix/4708-custom-provider-key-collision
fix: generate unique apiKeyEnv for non-ASCII custom provider names
2026-06-28 03:11:42 +08:00
SivanCola 4f1769a05f Unify custom provider key env derivation
Co-authored-by: SivanCola <[email protected]>
2026-06-28 03:04:16 +08:00
SivanCola c9df209e92 Merge pull request #5353 from liugh-dev/fix/backfill-tool-result-name
fix(provider): backfill Name in pairToolResults for existing tool res…
2026-06-28 02:59:10 +08:00
SivanCola aa5ff7bd4b Merge pull request #5359 from yizeyi18/sandbox-nonreading
feat(sandbox): add a `forbid_read` key
2026-06-28 02:56:13 +08:00
SivanCola a11719a643 Improve custom provider key env defaults
Co-authored-by: SivanCola <[email protected]>
2026-06-28 02:52:22 +08:00
SivanCola 759986a138 Merge pull request #5345 from ashishexee/fix/session-stats-telemetry-resume
fix(desktop): load session stats from telemetry on resume (#3450)
2026-06-28 02:51:57 +08:00
SivanCola 009017ed51 fix(provider): route missing tool result names through repair
Treat a tool result whose name does not match its paired tool call as malformed so SanitizeToolPairing takes the slow path and backfills the name before provider serialization.

Add regression coverage for saved sessions that carried a tool result with an empty name.

Co-authored-by: SivanCola <[email protected]>
2026-06-28 02:51:05 +08:00
SivanCola 995bcf432e Enforce forbid_read across reader tools
Co-authored-by: SivanCola <[email protected]>
2026-06-28 02:40:35 +08:00
SivanCola e8a8a4e120 Fix restored telemetry reset state 2026-06-28 02:37:22 +08:00
SivanCola 5e7cdac33e Merge pull request #5304 from cyq1017/codex/reasonix-5250-config-fallback
fix(config): add Windows home fallback for config discovery
2026-06-28 02:27:57 +08:00
SivanCola 534dcd85d6 Document forbid_read sandbox usage
Co-authored-by: SivanCola <[email protected]>
2026-06-28 02:25:08 +08:00
SivanCola e2b8f08a9c Merge pull request #5288 from YoungDan-hero/dev-6
fix(cli/mcp): prevent command injection via $EDITOR/$VISUAL
2026-06-28 02:22:11 +08:00
SivanCola 2f0cf143bc Merge pull request #5382 from cyq1017/codex/reasonix-5374-collab-mode
fix(desktop): preserve collaboration mode during session rebuild
2026-06-28 02:20:14 +08:00
SivanCola fb320db1f3 Support quoted MCP editor commands
Parse EDITOR/VISUAL values into argv with shell-style quoting and backslash escaping while still avoiding shell execution. This preserves common editor commands such as emacsclient -a '' and quoted editor paths without reintroducing command injection risk.

Co-authored-by: SivanCola <[email protected]>
2026-06-28 02:13:42 +08:00
SivanCola 59ff9af911 Merge pull request #5428 from ttmouse/fix/todo-auto-update-5339
fix(goal): 目标模式完成时待办列表未自动更新
2026-06-28 02:05:00 +08:00
SivanCola 0b12cf418a Merge pull request #5426 from 183965983/fix/paste-expand-interject-queue
fix(tui): expand paste placeholders before queuing interject during r…
2026-06-28 01:54:28 +08:00
SivanCola fde0c93879 test(tui): cover queued folded paste expansion 2026-06-28 01:46:53 +08:00
SivanCola d6a3cb7c38 fix(goal): restore completed todos on resume
Co-authored-by: SivanCola <[email protected]>
2026-06-28 01:46:18 +08:00
SivanCola ad05ecc9b7 Merge pull request #5442 from GTC2080/GTC/fix-topic-trash-placeholder
fix(desktop): include placeholder sessions in topic trash
2026-06-28 01:37:39 +08:00
SivanCola 76bc768da5 Merge pull request #5392 from cyq1017/codex/reasonix-5371-stdio-timeout
fix(plugin): make stdio call timeout configurable
2026-06-28 01:36:32 +08:00
SivanCola e5b6e08471 fix(boot): remove unused plugin spec helper
Remove the now-unused private wrapper left behind by the MCP timeout options path so golangci-lint passes on the PR merge ref.

Co-authored-by: SivanCola <[email protected]>
2026-06-28 01:30:57 +08:00
SivanCola ed818c1b05 feat(plugin): unify MCP call timeouts
Co-authored-by: SivanCola <[email protected]>
2026-06-28 01:09:47 +08:00
GTC2080 2eb11f8c70 fix(desktop): include placeholder sessions in topic trash 2026-06-28 02:34:42 +10:00
ttmouse 0993db1078 fix(goal): auto-complete remaining todos when goal finishes (#5339)
When the goal FSM transitions to completed (via proper completion or the
second [goal:complete] override in non-strict mode), force-complete any
remaining incomplete canonical todos and emit a synthetic todo_write
event so the frontend panel reflects the final state.

Bug: the todo panel stayed stuck showing the initial todo_write state
('0/5 进行中') even after the model text said '全部完成 ✅', because
no synthetic todo_write event was pushed to the frontend.

- Add completeRemainingGoalTodos() to turn_orchestrator.go
- Add goalCompleteNotice constant to goal.go
- Tests: end-to-end override, empty list, all-completed, mixed, empty-string status
2026-06-27 19:11:35 +08:00
jitzandClaude Opus 4.6 7c8d679c7c fix(tui): expand paste placeholders before queuing interject during reasoning
When multi-line text is pasted during model reasoning (tuiRunning), it
is folded to a "[Pasted text #N · M lines]" placeholder. Pressing Enter
queues this placeholder text directly into pendingInterject, and the
pastedBlocks are immediately cleared. When TurnDone fires, the raw
placeholder is sent to the model instead of the actual pasted content.

Expand paste blocks with expandPastedBlocks() before storing in the
interject queue, matching the normal send path at line 1172.

Fixes #5423.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-06-27 17:58:25 +08:00
YHH 3e824fc319 Merge pull request #5420 from esengine/feat/accounts
feat(accounts): email/password account service worker (id.reasonix.io)
2026-06-27 16:58:37 +08:00
esengine eb1500ef8f feat(accounts): add email/password account service worker
A new Cloudflare Worker (Hono + zod + D1) served at id.reasonix.io that
provides the self-owned account system for reasonix.io: email/password
registration with verification, login/logout, password reset, sessions,
and public profiles. Backend API only — no frontend pages yet.

- Layered structure: routes / db repositories / auth primitives / pluggable
  email / middleware / lib, kept separate from the internal crash dashboard.
- Sessions store sha256(pepper:token); the raw token lives only in the cookie.
- Registration is enumeration-safe; login and password reset return generic
  messages. Email tokens are single-use via UPDATE ... RETURNING.
- Schema reserves future growth: nullable password_hash for OAuth identities,
  and sessions.kind ('web'|'cli') for a later desktop/CLI device login.
- Email is pluggable: a console stub for local dev, Resend for production.
- PBKDF2-HMAC-SHA256 at 100k iterations (the Cloudflare Workers maximum).
2026-06-27 01:52:51 -07:00
SivanCola 8ceb23fb6e Merge pull request #5388 from ttmouse/pr/unify-topic-title
fix(desktop): 统一历史记录/搜索面板的对话标题与左侧边栏一致
2026-06-27 14:37:39 +08:00
cyq 23ea7aa2e5 Merge remote-tracking branch 'origin/main-v2' into codex/reasonix-5371-stdio-timeout
# Conflicts:
#	docs/SPEC.md
#	internal/config/mcpjson_test.go
#	internal/config/render.go
#	reasonix.example.toml
2026-06-27 14:14:08 +08:00
ttmouseandSivanCola fe0680a3ce fix(desktop): show session context in Cmd+K
Keep topic-backed session results titled by topic while surfacing the saved session title or preview as the visible hint.

Co-authored-by: SivanCola <[email protected]>
2026-06-27 12:52:31 +08:00
SivanCola aa1f951797 Merge origin/main-v2 into pr/unify-topic-title 2026-06-27 11:37:12 +08:00
ttmouseandSivanCola 63d12145bc fix(desktop): lock session title search contracts
Keep topic-backed Cmd+K entries displayed by topic title while preserving session titles and previews as searchable keywords.

Prefer explicit session renames in history before falling back to topic titles, and cover both contracts with a focused frontend test.

Co-authored-by: SivanCola <[email protected]>
2026-06-27 11:32:39 +08:00
SivanCola 57e80a942f Merge pull request #5409 from SivanCola/fix/desktop-session-hydration-mcp-cache
Fix desktop session hydration latency / 修复桌面会话加载延迟
2026-06-27 11:22:45 +08:00
SivanCola 80130b0aed Fix desktop session hydration latency 2026-06-27 11:10:20 +08:00
SivanCola 33b3a4a6bf Merge pull request #5401 from SivanCola/fix/mcp-read-only-trust-approval
Confirm MCP read-only trust on first use / 首次使用时确认 MCP 只读信任
2026-06-27 11:08:38 +08:00
YHH 0b6f5c9d0f Merge pull request #5407 from esengine/fix/plan-mode-readonly-bash-unify
fix(planmode): allow the full read-only bash command set while planning (#5341)
2026-06-27 09:58:04 +08:00
esengine 0b80deec92 fix(planmode): allow the full read-only bash command set while planning (#5341)
Plan mode let the model run only a short hard-coded bash allowlist (git
status/diff/log/show, ls, cat, …), so read-only git commands the user needs to
plan with — git rev-parse, describe, reflog, for-each-ref, cat-file, ls-tree,
plus go env, npm view, docker ps, kubectl get — were blocked even though the
permission auto-approve path already trusts them. Two independent read-only
command tables (planmode.safeBashCommands vs permission's readOnlyBash tables)
had drifted.

Unify them on one source of truth: a new internal/shellsafe package holds the
canonical read-only command/prefix tables + the shell-syntax guard. Both the
permission auto-approve path and the plan-mode gate classify command membership
against it, so they can't drift again.

Each consumer keeps its own ARGUMENT rigor on top — plan mode stays
intentionally stricter than auto-approve (it still refuses git --output, git
grep --open-files-in-pager, find -exec, go list -mod=mod, …, and any command
with shell operators / background execution). So this widens the read-only
command set without weakening either gate's write-capable-arg checks.

Tests: shellsafe classifier table (read-only vs write/shell-syntax);
TestPlanModeAllowsSharedReadOnlyBashSet (the #5341 git/tooling set is allowed,
write-capable + shell-syntax + write-args still blocked). go build/vet/test
./internal/... green (57 packages); existing permission/planmode tests
unchanged-pass.
2026-06-26 18:51:42 -07:00
YHH 70da2df7fc Merge pull request #5406 from esengine/fix/desktop-sink-context-stale-events
fix(desktop): clear queued events + lock ctx on sink teardown (#5352)
2026-06-27 09:23:58 +08:00
esengine 4fda67d03c fix(desktop): clear queued events + lock ctx on sink teardown (#5352)
P0-2 of the multi-session switch race: when a tab's runtime is detached for
replacement (single-surface session switch / controller replace / session
remove), the code wrote sink.ctx = nil DIRECTLY. That:

1. Data-races s.ctx — emitRuntimeEvent reads it through the locked s.context(),
   but these teardown sites wrote it without the mutex.
2. Leaves the asyncRuntimeEmitter's ALREADY-QUEUED events to flush. The
   detached/backgrounded controller's in-flight stream events then reach the
   frontend and land on the tab that has since been rebound to another session
   — the 'AI 不断输出,永不结束' on the now-visible session in #5352.

Route every sink context mutation through the locked accessors: teardown sites
use clearContext() (locked nil + runtimeEvents.Clear() to drain the queue),
re-attach sites use setContext(). emitRuntimeEvent already no-ops on a nil ctx,
so once cleared no new events enqueue either. This unifies all six bare ctx
writes onto the two accessors that were already used elsewhere (tabs.go:1839/
1976/2059), so there is one consistent teardown path.

Test: TestTabEventSinkClearContextStopsEmission — after clearContext the sink
reports a nil context and emitRuntimeEvent does not reach the runtime bridge.

Pairs with #5404 (serialize tab switches): #5404 stops concurrent switches from
racing the activation; this stops a superseded binding's queued events from
corrupting the new session. Desktop Go can't build locally (cgo); relies on the
CI desktop job.
2026-06-26 18:18:52 -07:00
YHH 950bba82a9 Merge pull request #5404 from esengine/fix/desktop-tab-switch-coalescing
fix(desktop): coalesce tab-bar switches through the navigation scheduler (#5352)
2026-06-27 09:05:05 +08:00
esengine 3fe39f7c47 fix(desktop): coalesce tab-bar switches through the navigation scheduler (#5352)
Topic / blank / sidebar-IM / resume-session navigation already runs through
the last-click-wins coalescing scheduler (openTopicCoalescing), but the tab-bar
click path (handleTabChange / handleTabsClose) called switchTab() directly,
fire-and-forget. Rapidly switching between two running sessions therefore ran
two switchTab() calls concurrently, racing the backend SetActiveTab /
confirmBackendActiveTab ordering — a primary trigger for #5352 (events and
hydration landing on the wrong session: '用户输入变了', 'AI 不断输出').

Route tab switches through the same enqueueNavigationRequest primitive (its own
refs) so they serialize and last-click-wins, exactly like the other navigation
entry points. switchTab's loadSessionDataForTab is already seq-guarded; this
serializes the backend activation around it. Unifies every navigation entry
point on one scheduler.

Test: rapid A→B→C tab switches run serially (A,C — middle coalesced) with no two
run()s overlapping (no concurrent SetActiveTab). tsc + the tab test suites green.

Follow-up (not in this PR, needs backend verification): wire events are routed
by tabId only (useController.ts:1156) with no session-generation guard, so a
detached/rebound controller's already-queued events can still land on the tab
(desktop/tabs.go detach sets sink.ctx=nil but does not clear the queued
emitter). A session-identity/epoch stamp on wire events + a frontend drop of
mismatched events would close the remaining stale-event window.
2026-06-26 17:59:12 -07:00
SivanCola 4204d61212 Keep MCP trust approvals fresh 2026-06-27 04:46:37 +08:00
SivanCola 45d8859075 Fix MCP read-only trust approvals
Confirm untrusted MCP read-only hints at plan-mode execution time and persist approved raw tool names back to the owning MCP config source.

Keep provider-visible MCP tool names and schemas stable while updating desktop, CLI, and docs language around pre-trusting read-only tools.
2026-06-27 04:24:08 +08:00
SivanCola 615544e2b4 Merge pull request #5272 from onenewcode/fix/5175-image-input-text-only-model
fix : image input gating by model capability
2026-06-27 03:58:37 +08:00
SivanCola 86aa67279e Merge pull request #5400 from SivanCola/fix/tui-scroll-interactions
Fix TUI transcript scroll interactions / 修复 TUI 对话滚动交互
2026-06-27 03:56:08 +08:00
SivanCola 6b788b43fb Merge latest main-v2 into image input gating PR 2026-06-27 03:53:46 +08:00
SivanCola 9008ab1a03 Merge pull request #5349 from liugh-dev/feat/copy-export-commands
feat(tui): add /copy and /export commands
2026-06-27 03:53:29 +08:00
SivanCola 5d7651430f Merge pull request #5347 from liugh-dev/fix/cli-paste-memory-compiler-source-event
fix(cli): folded paste content was invisible to the model
2026-06-27 03:49:40 +08:00
SivanCola 6641a92f11 fix(tui): harden transcript scroll interactions 2026-06-27 03:49:37 +08:00
SivanCola 7da9b0acd0 Allow text models to keep tool-readable images 2026-06-27 03:43:10 +08:00
SivanCola dde60a4246 fix(tui): skip empty session exports
Avoid writing markdown files when /export has no visible user or assistant content after filtering.
2026-06-27 03:43:03 +08:00
SivanCola f6b7b429d9 test(cli): wait for folded paste turn completion
Wait for the controller TurnDone event before reading the recording runner output. This gives the async send path a real happens-before edge under go test -race instead of relying on a fixed sleep.
2026-06-27 03:42:28 +08:00
SivanCola 63575c9e06 fix(tui): keep exported markdown display-safe
Strip referenced-context payloads from exported user messages and cover /copy and /export slash command behavior.
2026-06-27 03:33:07 +08:00
SivanCola d9dd4ab55c Merge pull request #5399 from SivanCola/fix/desktop-rewind-checkpoint-turns
Fix desktop rewind checkpoint targeting / 修复桌面端回溯 checkpoint 定位
2026-06-27 03:31:54 +08:00
SivanCola b03f7f6ebf Merge latest main-v2 into image input gating PR 2026-06-27 03:27:47 +08:00
SivanCola fef4c09ef7 fix(desktop): align rewind targets with checkpoints 2026-06-27 03:26:34 +08:00
SivanCola f6500d8782 Merge main-v2 into image input gating PR 2026-06-27 03:24:18 +08:00
SivanCola 5c9209b1b2 Merge pull request #5398 from SivanCola/fix/code-copy-button-stable
Fix code block copy button positioning / 修复代码块复制按钮定位
2026-06-27 03:22:44 +08:00
SivanCola 04abf2469d Merge pull request #5378 from yekern/fix/tui-restore-mouse-wheel
fix(tui): restore mouse wheel scrolling in transcript viewport
2026-06-27 03:22:37 +08:00
SivanCola 61ae3b77b9 Fix code block copy button positioning 2026-06-27 03:12:38 +08:00
SivanCola 85fd0a2417 Merge pull request #5396 from ttmouse/fix/heartbeat-new-conversation-btn
fix(heartbeat): 每次新建对话后更新 topicId 到最新
2026-06-27 02:59:21 +08:00
SivanCola 2ae7d211ab test(heartbeat): cover fresh topic persistence
Verify that NewConversationEachRun execution returns the newly created topic ID, submits the prompt, and records the in-flight pending topic so later task merges can persist the latest conversation.

Co-authored-by: SivanCola <[email protected]>
2026-06-27 02:54:50 +08:00
SivanCola 6e4e2ed348 Merge pull request #5397 from SivanCola/fix/desktop-navigation-coalescing
Fix desktop navigation coalescing / 修复桌面导航合并竞态
2026-06-27 02:42:51 +08:00
SivanCola 7754911ac1 fix(desktop): coalesce session navigation 2026-06-27 02:37:48 +08:00
SivanCola a015f7fea4 Merge pull request #5395 from SivanCola/fix/memory-v5-boundary-guardrails
Guard Memory v5 synthetic boundaries / 加固 Memory v5 合成边界守卫
2026-06-27 02:30:47 +08:00
SivanCola 1b6868ab92 fix(memory-v5): guard synthetic and replay boundaries 2026-06-27 02:20:26 +08:00
SivanCola 8fcf663b3a Merge pull request #5393 from SivanCola/fix/memory-v5-synthetic-turn-contract
Harden synthetic Memory v5 turns / 加固 Memory v5 合成轮次标记
2026-06-27 02:07:14 +08:00
SivanCola 96518c298c Merge pull request #5394 from SivanCola/feature/explicit-legacy-session-import
Import legacy sessions from explicit paths / 支持从指定旧目录导入历史会话
2026-06-27 02:05:05 +08:00
ttmouse 217e01126a fix(heartbeat): honor NewConversationEachRun flag when creating topics
The executeTask method now checks t.NewConversationEachRun before deciding
whether to create a new conversation or reuse the existing topicId.

Previously the field existed in the type and frontend but was never read
by the backend, making the toggle ineffective — it always reused the
same topicId even when 'new conversation each run' was selected.

New behavior:
  - NewConversationEachRun=true → always creates a new topic every run,
    and stores the new topicId back so the task list always points to
    the latest session.
  - NewConversationEachRun=false → reuses the existing topicId (same
    as before).
  - TopicID always stores the most recent conversation's ID, so if the
    user switches modes, the last conversation is still the target.

Also adds the missing struct fields ApprovalMode, TimeWindowStart, and
TimeWindowEnd that already exist in the frontend type but were never
persisted to disk because the Go struct didn't have fields for them.
2026-06-27 02:04:58 +08:00
SivanCola 08177734e3 Merge pull request #5355 from GTC2080/GTC/fix-5306-desktop-lag
fix(desktop): paginate restored transcript warm turns
2026-06-27 02:03:25 +08:00
SivanCola 6f059b850a feat(migration): import legacy sessions from explicit roots 2026-06-27 01:59:16 +08:00
SivanCola cef150d8fe fix(control): mark synthetic turns before Memory v5 2026-06-27 01:54:53 +08:00
GTC2080andSivanCola 5ccacbc5ee fix(desktop): reveal warm turns before jump navigation
Co-authored-by: SivanCola <[email protected]>
2026-06-27 01:52:35 +08:00
GTC2080andSivanCola 667451bcb2 fix(desktop): reset warm transcript pagination per session
Co-authored-by: SivanCola <[email protected]>
2026-06-27 01:37:20 +08:00
SivanCola 51a152d7cc Merge pull request #5391 from SivanCola/fix/approved-plan-execution-after-approval
Fix approved-plan and Memory v5 visible noise / 修复计划批准与 Memory v5 可见噪音
2026-06-27 01:35:49 +08:00
SivanCola 93451f819d fix(desktop): clear approved-plan and memory citation noise 2026-06-27 01:27:18 +08:00
cyq 4ecd3080f8 fix(plugin): make stdio call timeout configurable 2026-06-27 01:13:18 +08:00
YHH 84e2daf4f4 Merge pull request #5390 from esengine/fix/memory-v5-display-unwrap-nested
fix(memory-v5): render corrupted/accreted contracts cleanly on session switch (#5361)
2026-06-27 00:37:58 +08:00
esengine 4411b5d486 fix(memory-v5): render corrupted/accreted contracts cleanly on session switch (#5361)
When multiple goal/long tasks run and the user switches between conversations,
already-sent user messages showed a wall of raw <memory-compiler-execution>
JSON ('乱码') instead of the prompt text.

Root cause: the pre-fix goal loop (#5342, fixed in #5387) re-compiled the
model's echoed contract every turn, so a user message's source_event nested
another full <memory-compiler-execution> block, many levels deep on long
runs. The display unwrap (agent.unwrapMemoryCompilerExecution) only peeled a
fixed one/two levels and its non-greedy regex couldn't complete a
dangling/truncated block, so the reload-on-switch path surfaced raw contract
JSON. (#5387 stops NEW accretion; existing sessions still carry it.)

Backend: unwrap to a fixpoint (peel nested layers up to a bound) and, as a
final guard, drop any residual/dangling block from its opening tag onward so
raw contract JSON is never shown — the user's text precedes it.

Frontend: extract the inline Message.tsx strip into a tested
lib/memoryCompilerDisplay helper and harden it the same way (remove complete
blocks + cut dangling) as a display-boundary safety net.

Tests: backend reproduction with 6-level nesting + a truncated block (was
leaking, now clean); frontend tsx unit test for the strip helper.
go build/vet/test ./internal/... green (56); frontend tsc + tsx test green.
2026-06-26 09:33:06 -07:00
ttmouse 9ae6bf8ba3 fix(desktop): 回应 Codex review — 添加 Cmd+K keywords + 优先显示会话重命名标题 2026-06-27 00:28:12 +08:00
YHH 74b33fbfda Merge pull request #5387 from esengine/fix/memory-v5-synthetic-turn-loop
fix(memory-v5): stop synthetic turns from spinning the goal loop (#5342, #5329)
2026-06-26 23:59:17 +08:00
ttmouse ed9d9b91e1 fix(desktop): 统一历史记录/搜索面板的对话标题与左侧边栏一致
问题:HistoryPanel 中 sessionDisplayTitle 使用 s.title || s.preview
作为主标题(用户重命名或首条消息预览),而左侧边栏展示的是
node.label(即 topicTitle),两者完全不同。

修复:
- sessionDisplayTitle 优先使用 s.topicTitle,与左侧边栏统一
- sessionLocation 中去掉冗余的 s.topicTitle 显示(已作为主标题展示)
2026-06-26 23:55:22 +08:00
esengine 02e05b6d76 fix(memory-v5): stop synthetic turns from spinning the goal loop (#5342, #5329)
Two independent causes made the goal loop re-inject the same
memory_v5_execution_contract every turn and never terminate:

1. The synthetic goal-continuation turn ('Continue pursuing the active
   goal. …') was Memory v5-compiled like a real user turn. The model
   echoes the compiled contract, and the goal loop re-injects it, so the
   contract accretes and the loop never converges.

2. classifyStrategy / strategy preconditions matched 'ui' as a bare
   substring, so 'pursuing' (and 'build', 'quiet', …) misrouted unrelated
   turns to frontend-visual-verify.

Fix 1 is done at the layer that knows a turn is synthetic. The turn
orchestrator already routes every controller-injected message and owns the
canonical synthetic-message registry (IsSyntheticUserMessage /
syntheticPrefixes). It now marks synthetic turns with a new
agent.WithMemoryCompilerSkip(ctx) instead of supplying a compiler source,
and Agent.Run honours it. This covers ALL synthetic turns uniformly
(goal-loop continuation, plan-approved execution, future additions) rather
than special-casing one English prefix inside the agent.

Fix 2 adds strategyPreconditionMatches: preconditions of <=2 runes (e.g.
'ui') match only on whole-token boundaries; longer ones keep substring
semantics.

Tests: agent skips compilation when the turn is marked synthetic; the
orchestrator marks goal-continuation (not genuine user) turns; 'ui'
token-matches real frontend goals but not 'pursuing'; goal-continuation no
longer classifies as frontend. go build/vet/test ./internal/... green (56).
2026-06-26 08:52:09 -07:00
YHH 369bfda9af Merge pull request #5386 from esengine/refactor/memory-v5-p4-control-heuristic
refactor(memorycompiler): replace control-theory packages with a heuristic
2026-06-26 23:38:49 +08:00
esengine 34b5da781e refactor(memorycompiler): replace control-theory packages with a heuristic
The Memory v5 control subsystem — internal/controlplane, internal/equilibrium,
internal/controlsemantics (~1600 LOC of oscillation/consensus/convergence/
entropy/anti-centralization math) — reached the model-facing contract through
exactly one value: the per-turn exploration rate (clamped to [3,12]) fed to a
deterministic hash threshold in selectStrategy. Measured over a varied 30-turn
session, it changed the selected strategy on ~1/30 turns versus a constant
rate; it was an elaborate 'explore less when unstable' switch with no other
contract-visible effect (its rich telemetry only fed a closed observer loop).

Replace controlPolicyForState with a legible heuristic that derives the policy
from the same signals the control-theory consumed as inputs — oscillating
strategy history, recent failures/drift (unstable), sustained clean successes
(stable), and accumulated semantic shift — mapping them directly to
exploration rate, gain, and mode. The signal helpers (equilibriumStable/
Unstable/Oscillating, semanticShiftSignals, hasDrift) are kept; only the math
tower between them and the rate is removed.

Also drops the now-dead ControlReports history machinery (its only reader was
the control-plane decision call) and decouples the compression report's two
len(ControlReports) telemetry counts.

Behavior change (unlike the prior dead-code removals): the exploration rate is
now derived by the heuristic. The documented control-policy semantics are
preserved and asserted by the existing tests — stable widens exploration,
unstable/oscillating/semantic-shift damp it, gain raises/lowers the mutation
cooldown. go build ./..., go vet, go test ./internal/... green (56 packages).
Net -2220 lines.
2026-06-26 08:33:18 -07:00
YHH 8c7a32a361 Merge pull request #5383 from esengine/refactor/memory-v5-p2-drop-runtime-os
refactor(memorycompiler): drop the unused production-hardening runtime-OS
2026-06-26 23:12:23 +08:00
esengine 7f1c94372e refactor(memorycompiler): drop functions orphaned by runtime-OS removal
cloneNoisyRefs (used only by the removed production snapshot/restore path)
and the assertCausalEdge test helper (used only by a removed production
hardening test) are now unused. Caught by golangci-lint's unused linter,
which go vet/gofmt do not flag.
2026-06-26 08:08:09 -07:00
YHH f0a57255fc Merge pull request #5381 from esengine/refactor/memory-v5-p3-compact-contract
perf(memorycompiler): compact the per-turn execution contract
2026-06-26 23:06:09 +08:00
YHH 23cdda5e0b Merge pull request #5380 from esengine/refactor/memory-v5-p1-dead-code
refactor(system): remove dead internal/system architecture-lock package
2026-06-26 23:05:57 +08:00
esengine 2af3af95a3 refactor(memorycompiler): drop the unused production-hardening runtime-OS
The Memory v5 production-hardening apparatus — internal/runtime/{canary,
resource,sandbox,snapshot,rollback} plus ~24 glue funcs in runtime.go — ran
every turn to build a ProductionHardeningTrace: a budget 2-phase-commit
ledger, a canary behavior diff, atomic snapshots written to r.dir, and a
rollback evaluator. None of it was consumed. No UI, metric, event, or the
compiled contract reads ProductionHardening, and st.Production is never read
by buildIRWithPolicy / the model-facing contract path. The one thread out of
the loop (canary attribution -> PrimaryCauses on the causal-compression
report) was only stored in st.CompressionReports, which only the observer
loop reads — never the contract. A closed telemetry loop: a goroutine + CPU +
periodic disk writes every turn for data nothing acted on.

Remove the runtime-OS packages, the production-hardening glue, the
ProductionHardeningTrace / ProductionState / RollbackRecord types and their
trace/state fields, and decouple compressCausalEdges from hardening (dropping
the now-dead PrimaryCauses field it fed).

No model-behavior change: buildIRWithPolicy never read st.Production, so the
compiled contract is byte-identical. Verified with go build ./..., go vet,
and go test ./internal/... (65 packages green). Net -2445 lines.
2026-06-26 07:54:10 -07:00
cyq 28ff9a4887 Preserve collaboration mode during session rebuild 2026-06-26 22:29:31 +08:00
esengine 12c3aac637 perf(memorycompiler): compact the per-turn execution contract
The Memory v5 compiler replaces each user turn with a compiled
<memory-compiler-execution> contract. It re-serialized the entire
PlannerIR every turn: a prose ir_explanation that merely restated the
constraints and memory references, the ranked available_strategies
candidate table, and each strategy's rejected/score/exploration-rate
control-loop state. On short user turns that inflated the user message
~20-50x and grew unbounded as the candidate table and explanation
accreted, with no token-saving counterpart anywhere in the code.

Project the model-facing IR onto a bounded contractIR that drops only
the non-actionable fields. The kept fields (source_event, constraints,
memory_references, execution_steps, risk_notes, selected strategy +
reason/mode) are passed through verbatim from the canonical IR, so the
guidance the model receives is byte-identical to before — verified by an
offline A/B over a warmed-up multi-turn session: same strategy, same
constraints, same 5 memory references; only ir_explanation,
available_strategies and rejected drop out. Per-turn contract ~737->390
tokens and a 12-turn session ~8170->4312 (-47%), with the unbounded
growth removed.

The full IR is still recorded on the execution trace for learning;
only the prompt copy is slimmed. Display unwrapping is unaffected
(planner_ir.source_event is preserved).
2026-06-26 07:24:58 -07:00
esengine 0536f049ab refactor(system): remove dead internal/system architecture-lock package
internal/system (architecture_lock.go, contract.go, v6_isolation.go,
release_guard.go) had zero importers anywhere in the module, confirmed by
`go build ./...` passing unchanged after removal. It declared a frozen
SystemContract and an ArchitectureLock that forbade control-plane and
equilibrium changes, yet nothing ever consulted it — pure dead weight that
also actively discouraged the very architecture work it claimed to guard.

No behavior change: nothing referenced the package.
2026-06-26 06:55:06 -07:00
maple 8a34a55cfc fix(tui): restore mouse wheel scrolling in transcript viewport
Commit edacf1a8 (fix(tui): preserve native terminal selection) removed
v.MouseMode = tea.MouseModeCellMotion from the View() method. Without
mouse mode, terminals do not send MouseWheelMsg events; many terminals
translate wheel events to Up/Down arrow keys, which trigger input
history navigation (recallSubmittedInput) instead of viewport scrolling.

Restore MouseModeCellMotion on the alt-screen path only. The Termux
native-scrollback path (nativeScrollback=true) is unchanged because
mouse mode breaks soft-keyboard focus on Android.

The TUI already implements in-app text selection (left-drag to select,
right-click to copy, Ctrl+C to copy selection with visual highlight),
so the loss of native terminal selection is a conscious trade-off for
restoring essential wheel scrolling behavior.
2026-06-26 20:37:24 +08:00
yizeyi18 cb81cb5a6d Adjust darwin test from detecting /etc/ssh to /home/user/.ssh. 2026-06-26 15:41:25 +08:00
yizeyi18 ee975ed517 run go fmt 2026-06-26 15:35:47 +08:00
YI Zeping 55f259646f Merge branch 'main-v2' into sandbox-nonreading 2026-06-26 15:32:42 +08:00
yizeyi18 41f3780bd0 modify tests 2026-06-26 15:12:24 +08:00
GTC2080 bf432d905b fix(desktop): paginate restored transcript warm turns 2026-06-26 17:03:45 +10:00
liuguohua 07502b7f78 fix(provider): backfill Name in pairToolResults for existing tool results
pairToolResults already set Name on backfill (new) tool results but
left it empty when reusing an existing result.  If a saved session
carried a tool message without Name, SanitizeToolPairing would not
repair it, and DeepSeek's API rejects tool-role messages with a
missing 'name' field (HTTP 400).  GLM's endpoint does not validate
this, so the bug only surfaced after switching.

Now set r.Name = tc.Name on both the id-matched and positional
paths, matching the backfill behaviour.
2026-06-26 06:24:16 +00:00
liuguohua 10309ab4c4 feat(tui): add /copy and /export commands
- `/copy` opens a modal picker (↑/↓ Enter Esc) to select an assistant
  response from the current turn and copy it to the clipboard.
- `/export` saves the full session as a Markdown file, merging
  consecutive same-role messages and stripping thinking/tool calls.
- Both commands read `Content` only; `ReasoningContent`, `ToolCalls`,
  and system/tool messages are excluded.
- Add i18n strings for en, zh-Simplified, zh-Traditional.
2026-06-26 06:02:11 +00:00
jiangrenhang cb8c6b63b0 fix: generate unique apiKeyEnv for non-ASCII custom provider names
Previously, apiKeyEnvFromProviderName() returned 'CUSTOM_API_KEY' for any
provider name consisting entirely of non-ASCII characters (e.g. Chinese).
All such custom providers shared the same credential slot, so adding a new
one silently overwrote the previous provider's API key.

Now an FNV-1a hash suffix ensures each provider gets a unique slot like
CUSTOM_d39b9067_API_KEY.

Fixes #4708
2026-06-26 13:58:06 +08:00
liuguohuaandClaude 7aa41f537b fix(cli): folded paste content was invisible to the model
A folded multi-line paste ([Pasted text #N · M lines]) was never seen by
the model — only the placeholder label was. The CLI passed the folded
label (the un-expanded `line`) as the controller's `raw` value. The Memory
v5 compiler (enabled by default) takes `raw` as the source_event of its
execution contract, and that contract replaces the whole user turn — so
the model's only view of the paste became the label, with the actual
content lost.

The Desktop path already passes the expanded text as `raw`; only the CLI
passed the label. Pass the expanded content (sentLine / msg.display) as
`raw` in both the non-refs and @-refs submit paths, matching Desktop.
`raw` still excludes resolved @-reference payloads (per the existing
TestRunCompilesMemorySourceFromUnexpandedContext intent) — it now carries
the expanded paste block but not inline file contents.

Add TestPasteFoldExpandOnSubmit, which asserts the memory-compiler
source_event holds the expanded paste (it fails before the fix:
source_event is only "[Pasted text #1 · 11 lines]").

Co-Authored-By: Claude <[email protected]>
2026-06-26 05:47:41 +00:00
ashishexee baa8cbaa05 fix(desktop): load session stats from telemetry on resume (#3450) 2026-06-26 10:19:56 +05:30
SivanCola 5dac4f6d67 Merge pull request #5321 from SivanCola/fix/mcp-trust-controls
feat(desktop): add explicit MCP trust controls / feat(desktop): 添加显式 MCP 信任控制
2026-06-26 03:47:19 +08:00
SivanCola 0526a88dad Add cache-invariant regression for read-only trust
The MCP trust controls must change a tool's execution/approval flags
(ReadOnly / PlanModeUntrustedReadOnly) without altering the model-visible
tool name or input schema — otherwise every trust toggle would break the
stable prompt prefix and drop the prompt-cache hit rate.

TestReadOnlyTrustDoesNotChangeModelVisibleSchema starts the same mock MCP
tool with and without ReadOnlyModelToolNames trust and asserts the tool name
and Schema() bytes are byte-identical across both, while ReadOnly() flips from
false to true and the trusted tool is no longer plan-mode-untrusted.
2026-06-26 00:43:53 +08:00
SivanCola cc2019b5cc Merge pull request #5285 from YoungDan-hero/dev-5
fix(serve/auth): handle crypto/rand.Read error in signSession
2026-06-26 00:37:54 +08:00
SivanCola f2ea581a47 feat(desktop): add explicit MCP trust controls 2026-06-26 00:29:42 +08:00
SivanCola 0d54d0f374 Merge pull request #5307 from ttmouse/pr/strip-memory-compiler-upstream
fix: strip memory-compiler-execution block from user message display
2026-06-26 00:24:46 +08:00
SivanCola d363773727 Preserve preview text for compiled memory turns
The Memory v5 compiler replaces a user turn with a <memory-compiler-execution>
contract rather than prefixing it; the original prompt survives only in the
contract's source_event. Adding the tag to the prefix-stripper made
UserPreviewText / SessionPreviewFromMessages collapse such turns to an empty
string, so a session whose first prompt was compiled showed a blank
history/sidebar preview (#5307 review finding).

Unwrap the contract to its source_event instead of dropping it like a prepended
transient block. source_event under planner_ir is the user's unexpanded prompt
(no @reference file blocks), so it is the right preview text. Falls back to a
top-level source_event for older shapes, and to empty only when no source_event
is recoverable.

Tests: cover unwrap (block-only, language-blocks-first, top-level fallback,
unrecoverable, non-contract) and the end-to-end SessionPreviewFromMessages path
with a compiled first turn. Verified they fail against the drop-the-block
behavior and pass with the unwrap.

Co-authored-by: SivanCola <[email protected]>
2026-06-26 00:19:51 +08:00
SivanCola 3370155d13 Add regression for stripping memory-compiler-execution block
StripTransientUserBlocks and UserPreviewText had no direct test coverage,
so the memory-compiler-execution stripping this PR adds was an implicit,
fragile string contract. Add focused tests using the real block format
(<memory-compiler-execution>{json}</memory-compiler-execution>) the Memory
v5 compiler emits, covering the leading-block, language-blocks-first, and
block-only (compiled contract replaced the whole turn) cases, plus the
UserPreviewText path used for titles/previews.

Verified the tests fail against the pre-fix regex and pass with the fix.

Co-authored-by: SivanCola <[email protected]>
2026-06-26 00:12:06 +08:00
SivanCola 7056884afc Merge pull request #5303 from myipanta/fix/5286-interrupted-task-re-execution
fix(control): strip incomplete turn messages after user cancel (#5286)
2026-06-26 00:04:29 +08:00
SivanCola 65f18ea656 fix(control): reset todoState and flush transcript after cancel strip
After stripTurnMessagesAfter truncates the session transcript, two states
remained inconsistent:

1. Agent.todoState was not updated. Session.Replace only swaps the message
   slice; it does not call rebuildTodoState. So a cancelled turn that had a
   successful todo_write left the in_progress item in the canonical task list,
   visible via Controller.Todos(), goal readiness, and the task panel on the
   next turn. Fix: expose Agent.RebuildTodoState() and call it after Replace.

2. A mid-turn autosave may have already written the partial transcript to
   disk. snapshotActivityIfChanged skips the write when messageCount() returns
   to startMessages (which it does after the strip), so the stale file was
   never overwritten. A restart or session resume would reload the partial
   turn. Fix: call Session.Save(path) directly after the strip to force a
   flush, bypassing the HasContent() guard that snapshot() uses (so the clean
   state is written even when the strip leaves only a system message).

Tests: extend TestTurnOrchestratorCancelStripsIncompleteTurn to assert
Todos() is empty after cancel, and add
TestTurnOrchestratorCancelFlushesCleanTranscriptToDisk to verify the
on-disk transcript matches the pre-cancel message count.

Co-authored-by: SivanCola <[email protected]>
2026-06-25 23:58:22 +08:00
SivanCola 91395b769d Merge pull request #5313 from SivanCola/fix/desktop-topic-navigation-coalescing
Fix desktop topic navigation coalescing / 修复桌面话题导航合并竞态
2026-06-25 23:34:51 +08:00
SivanCola 2f606fad14 fix(desktop): coalesce topic navigation 2026-06-25 23:11:45 +08:00
SivanCola 5afceb9356 Merge pull request #5308 from leslie3747/main-v2
fix(bot): run turn off dispatch goroutine so approval replies arent deadlocked
2026-06-25 22:44:41 +08:00
SivanCola ca421bca5f Add regression guarding approval reply unblocks blocked turn
A turn that blocks inside RunTurn waiting for interactive approval must not
wedge the per-adapter dispatch loop. handleSlashCommand is the only caller of
ctrl.Approve, and it runs on the same dispatch goroutine that drives runTurn.
With runTurn inline, the loop could never deliver the /approve reply that
unblocks the turn (#4402, #4701, #4863).

The test drives the real dispatchLoop with a fake controller whose RunTurn
parks until Approve is called, then sends a normal turn followed by /approve.
It passes only when the turn runs off the dispatch goroutine; against the
inline implementation it fails because the approval reply is never delivered.
2026-06-25 22:35:29 +08:00
TUF-User 216da47c47 fix(bot): run turn off dispatch goroutine so approval replies arent deadlocked 2026-06-25 14:08:15 +00:00
ttmouse 326cfc6ce4 refactor: move memory compiler indicator to msg-meta as disabled-style icon
Replace the 🧠 emoji badge inside msg__body with a BrainCircuit lucide
icon in the msg-meta bar (between time and copy button). The icon uses
disabled styling (opacity 0.45, cursor: default, no hover effects, no
pointer events) so it looks purely decorative — not clickable.
2026-06-25 21:56:53 +08:00
SivanCola ec82916e33 Merge pull request #5301 from meteor614/fix/direct-osc52-clipboard
fix: use OSC 52 directly for clipboard instead of platform-tool-then-…
2026-06-25 21:42:18 +08:00
ttmouse 029b717ba3 refactor: replace interactive memory icon with pure display badge
Remove the clickable BrainCircuit button from msg-meta (which was
confusingly similar to copy/edit buttons). Instead show a subtle 🧠
emoji badge inside the message body area when Memory v5 compiler was
applied. The badge is purely visual — no interaction.
2026-06-25 21:35:10 +08:00
SivanCola 54922b9760 docs: clarify OSC 52 clipboard behavior
Clarify that OSC 52 clipboard copy depends on terminal support while avoiding remote-host platform clipboard writes in SSH/tmux flows.

Co-authored-by: SivanCola <[email protected]>
2026-06-25 21:27:53 +08:00
ttmouse 22317b8cdf feat: make memory compiler icon interactive — click to toggle raw text
The BrainCircuit icon in the user message meta bar is now a clickable
button. Clicking it toggles between the cleaned display text and the
original raw message text (with the <memory-compiler-execution> block
visible). The icon changes to accent color when raw view is active.

Updated locales (en/zh/zh-TW) with ShowRaw/HideRaw labels.
2026-06-25 21:26:46 +08:00
cyq d8c4f2f07a Fix Windows config discovery fallback 2026-06-25 21:14:20 +08:00
ttmouse f60ebbe231 feat: add Memory v5 compiler indicator icon to user message meta
When a user message had a <memory-compiler-execution> block that was
stripped from display, show a BrainCircuit icon in the message meta bar
(left of the copy button) to indicate that Memory v5 compiler was applied.

Added translation key msg.memoryCompilerApplied in all three locales.
2026-06-25 21:07:21 +08:00
ttmouse 28c6ae72f3 fix: strip memory-compiler-execution block from user message display
The Memory v5 compiler injects a <memory-compiler-execution> block into
user turn text for model-internal planning. This block is not user-facing
content and should be hidden from the transcript display.

Backend change: add memory-compiler-execution to the transient XML blocks
regex in StripTransientUserBlocks so it is stripped when resolving display
text from persisted session messages.

Frontend change: add stripMemoryCompilerExecution() in Message.tsx to
strip the block as a safety net if it reaches the component via any path.
2026-06-25 21:00:06 +08:00
jiangrenhang 66dadfc3a0 fix(control): strip incomplete turn messages after user cancel (#5286)
When the user presses Ctrl+C to cancel a turn, the agent's
incomplete assistant messages and tool results are already saved
to the session. On the next turn, the model sees leftover
in-progress todo items and partial tool calls and may re-execute
the interrupted work.

Fix: in turn_orchestrator.runTurnWithRawDisplay, when Run()
returns context.Canceled AND the cancel was user-initiated
(CancelRequested() == true), strip the session back to the
pre-turn message count so the next prompt starts clean.

The guard (CancelRequested) ensures internal context cancellations
(stream recovery, timeouts) are unaffected — only explicit user
cancels trigger the strip.
2026-06-25 20:08:46 +08:00
meteorchen 256a62fd40 fix: use OSC 52 directly for clipboard instead of platform-tool-then-fallback
Remove the atotto/clipboard dependency from copyToClipboard and the
two-tier strategy that tried the platform clipboard (xclip/wl-copy/pbcopy)
first and fell back to OSC 52 on failure.

Over SSH, the platform tool writes to the remote server's clipboard — the
fallback never triggered locally because clipboardWriteAll returned nil
success. OSC 52 works in all environments (local, SSH, tmux, modern
terminals) and is the correct single path.

- transcript.go: remove clipboardWriteAll var, simplify copyToClipboard
- transcript_test.go: replace two-tier tests with direct OSC 52 test
- chat_tui_test.go: remove clipboard stubs from Ctrl+C tests
2026-06-25 19:26:00 +08:00
Sivan d4aadbbda5 docs(serve/auth): correct signSession nonce-panic comment
The rand.Read failure comment overstated the risk as making the cookie
'trivially forgeable and bypass authentication'. verifySession still
requires the PBKDF2-derived sessKey to produce a valid HMAC, so an
all-zero nonce is not an auth bypass; it weakens session token
uniqueness/unpredictability. Reword to match the accurate PR description
and avoid misleading future security review.
2026-06-25 18:34:51 +08:00
SivanCola fb364bef87 Merge pull request #5115 from GTC2080/GTC/fix-desktop-command-wire-contract
Fix desktop command failure feedback / 修复桌面命令失败反馈
2026-06-25 18:23:21 +08:00
Sivan 9af091209e fix: surface desktop command failures 2026-06-25 18:10:39 +08:00
SivanCola dbdc2482b4 Merge pull request #5297 from SivanCola/fix/5287-planner-history-integration
fix(desktop): preserve Planner transcript across settings reload (integrates #5293 + #5295) / 修复设置重载丢失 Planner 历史(集成 #5293 + #5295)
2026-06-25 18:05:10 +08:00
SivanCola ba8ccc0085 Merge pull request #5119 from GTC2080/GTC/fix-desktop-visible-errors
Fix desktop visible error states / 修复桌面可见错误状态
2026-06-25 18:02:42 +08:00
Sivan e3a7ad9a73 Merge main-v2 into desktop visible error states 2026-06-25 17:57:23 +08:00
Deng Bushi 邓步实 1908 135abaaa9f fix(desktop): persist planner display history across reload 2026-06-25 17:56:38 +08:00
GTC2080 d7783747e7 fix(desktop): preserve transcript on settings reload 2026-06-25 17:56:38 +08:00
SivanCola 5a60b74c2a Merge pull request #5291 from SivanCola/fix/desktop-language-replies
fix(desktop): honor desktop language for replies / 修复桌面语言回复偏好
2026-06-25 17:45:53 +08:00
Sivan 4debe25ef4 fix(desktop): honor desktop language for replies 2026-06-25 16:36:20 +08:00
SivanCola e11741c13a Merge pull request #5124 from GTC2080/GTC/fix-desktop-p0-stability
Fix desktop session identity and cancel recovery
2026-06-25 16:18:38 +08:00
SivanCola e3a7d38f1e Merge pull request #5289 from SivanCola/fix/5128-signoff-recovery-integration
Integrate #5128 signoff recovery fixes / 整合 #5128 签退恢复修复
2026-06-25 16:18:12 +08:00
SivanCola 43f42a17eb Merge pull request #5290 from SivanCola/fix/creation-overview-integration
fix(desktop): stabilize creation overview dock / 修复创作概览右侧面板布局与重开渲染
2026-06-25 16:17:36 +08:00
Sivan c1f8fe5e17 Merge remote-tracking branch 'origin/main-v2' into GTC/fix-desktop-p0-stability
# Conflicts:
#	desktop/frontend/package.json
2026-06-25 16:13:53 +08:00
Sivan a5300f997b fix(desktop): settle cancel recovery and session rotation 2026-06-25 16:09:57 +08:00
Sivan 845f883d89 fix(evidence): drop stale attempted signoff helper 2026-06-25 16:08:55 +08:00
Sivan 5da004b09c fix(desktop): stabilize creation overview dock 2026-06-25 16:08:53 +08:00
Sivan a29db659e8 fix(agent): reconcile signoff recovery integration 2026-06-25 16:00:09 +08:00
Sivan 33f50d4bba fix(evidence): tighten failed signoff recovery
(cherry picked from commit e1c72dbe31)
2026-06-25 15:55:46 +08:00
GTC2080 1be1b579e1 fix(agent): recover todo completion after failed signoff
(cherry picked from commit 0d95872aba)
2026-06-25 15:55:36 +08:00
jiangrenhang 153f0d0cdb fix: prevent deadlock between complete_step and todo_write (#5128)
Two fixes for the circular gate that freezes the task list when a
complete_step is rejected on a technicality like command quoting:

1. Better error messages in verifyCommandFromSession: when a command
   citation doesn't match, the error now lists ALL commands from both
   the per-turn ledger and session history, formatted so the model can
   self-correct with the exact string.

2. Allow attempted (failed) complete_step to authorize todo_write
   completions: hasAttemptedCompleteStepForTodo accepts any
   complete_step receipt (not just successful), recognizing the model
   acted in good faith. A technical mismatch won't deadlock the list.

Tests updated: TestLedgerRequiresCompleteStepForNewCompletedTodos,
TestTodoWriteAllowsCompletionAfterAttemptedCompleteStep,
TestEvidenceFlowRejectsUncitedCommand (error message format).

(cherry picked from commit 7577ad0cd9)
2026-06-25 15:54:42 +08:00
SivanCola 6ddb595366 Merge pull request #5275 from GTC2080/GTC/fix-5229-linux-webkit-software-rendering
fix(desktop): disable Linux webview GPU without DRI
2026-06-25 15:28:35 +08:00
luffy 8596273b24 fix(cli/mcp): prevent command injection via $EDITOR/$VISUAL
mcpEditConfigLaunchCommand concatenated the raw $VISUAL/$EDITOR value
into a `sh -lc "<editor> <path>"` command string. Only the path was
shell-quoted, so an editor value like `vim; rm -rf ~` (or any
environment pollution) would execute arbitrary shell commands.

Replace the sh -lc construction with editorLaunchCmd, which expands
$VAR references and leading ~/~ prefixes without a shell, splits the
result into argv via strings.Fields, and invokes the binary directly
with exec.Command. This matches the safe pattern already used by the
terminal-editor fallback (exec.Command(bin, path)) in the same function.
Multi-token values such as `code --wait` are still honored.

Add tests covering: direct invocation (no shell), argument splitting,
$VAR expansion, tilde expansion, rejection of shell metacharacters,
and proof that tilde expansion cannot be abused by an injection payload.
2026-06-25 15:26:31 +08:00
SivanCola d9374a4015 Merge pull request #3695 from taibai233/feature/guardian-subagent
新增 Guardian Subagent
2026-06-25 15:21:47 +08:00
SivanCola 736ba3ad75 Merge pull request #5282 from manza7014/fix/stop-hook-cancelled-ctx
fix: Stop hook 使用 context.Background() 替代已取消的 ctx
2026-06-25 15:08:00 +08:00
Sivan 80d033fa80 guardian: port safety reviewer to current main 2026-06-25 15:00:15 +08:00
yongfeng.li c4a11bf379 fix: gofmt turn_orchestrator_test.go 2026-06-25 14:58:35 +08:00
yongfeng.li 7abf3e72fb test: verify Stop hook receives non-cancelled context on cancellation
Add TestTurnOrchestratorStopHookCancelledContext to prove the Stop hook
spawner receives a usable (non-cancelled) context even when the turn
context is cancelled before runTurnWithRawDisplay returns.

This guards the fix in turn_orchestrator.go:47 and controller.go:1118.
2026-06-25 14:54:39 +08:00
Sivan e50755589b test: cover Stop hook cancellation contexts 2026-06-25 14:53:07 +08:00
luffy 2495efc23e fix(serve/auth): handle crypto/rand.Read error in signSession
signSession() silently discarded the error from rand.Read, leaving the
session nonce all-zero on failure. While this does not directly bypass
authentication (forging a cookie still requires the PBKDF2-derived
sessKey), it weakens the unpredictability of password-mode session
cookies and is an explicit anti-pattern inconsistent with the same
file's generateToken() and sessionKeyForPasswordHash(), both of which
panic on rand.Read failure.

Now panics with a descriptive message, matching the existing convention.
2026-06-25 14:48:27 +08:00
yongfeng.li 89d4c20a0a fix: also use context.Background() for Stop hook in Controller.Run path
Reviewer SivanCola pointed out that the synchronous/headless
Controller.Run(ctx, ...) path had the same issue: a cancelled caller
context would prevent the Stop hook command from executing.

Apply the same context.Background() fix to controller.go:1118.
2026-06-25 14:46:42 +08:00
qiukeren 8d5c0cbc53 feat(i18n): add MB unit to download progress display
Show 'MB' after the numeric values in the updater's downloading status text across all three locales (en, zh, zh-TW).
2026-06-25 14:43:29 +08:00
SivanCola 3167eaa54f Merge pull request #5273 from SivanCola/fix/mode-toggle-flap-pr
Preserve auto approval mode across partial tab snapshots / 保持自动审批模式不被快照降级
2026-06-25 14:39:48 +08:00
Sivan 8eeb379910 fix(frontend): tolerate missing generated wails bindings 2026-06-25 14:33:15 +08:00
yongfeng.li 506a5f48db fix: use context.Background() for Stop hook on cancellation
When the user cancels a turn (Stop/Esc), Controller.Cancel() cancels the
turn context before the deferred Stop hook runs, causing
exec.CommandContext(cancelledCtx, ...) to immediately fail with
context.Canceled. Since Stop is a non-blocking cleanup hook, use
context.Background() instead so the user-configured command executes
regardless of cancellation.

Closes #5281
2026-06-25 14:32:57 +08:00
Sivan 15552a3c1d fix(desktop): use global defaults for new sessions 2026-06-25 14:27:59 +08:00
GTC2080 6a0b5a94a6 fix(desktop): disable Linux webview GPU without DRI 2026-06-25 14:39:49 +10:00
Sivan 5d7f726096 fix(frontend): preserve auto mode in browser mock 2026-06-25 12:22:18 +08:00
Sivan ec81fd702e fix(frontend): preserve auto approval mode across partial tab snapshots 2026-06-25 12:10:14 +08:00
SivanCola 6bbb66efe7 Merge pull request #5262 from lifu963/pr/subagent-cancel-contract
Fix subagent cancellation contract
2026-06-25 11:55:17 +08:00
SivanCola 105ff37131 Fix canceled closed stream handling 2026-06-25 11:41:51 +08:00
SivanCola a430ea8900 Merge pull request #5248 from ttmouse/pr/fix-chat-switch-flash
fix: 消除侧边栏切换对话时 Welcome 闪现
2026-06-25 11:19:28 +08:00
onenewcode 1b2a465923 gate image input by model capability 2026-06-25 10:59:54 +08:00
Sivan 29577e091e Merge remote-tracking branch 'origin/main-v2' into HEAD
# Conflicts:
#	desktop/frontend/src/App.tsx
2026-06-25 10:59:29 +08:00
Sivan c103c2cd05 Address hydration bot review findings 2026-06-25 10:57:32 +08:00
Sivan 06a440de75 Separate hydration placeholders from transcript state 2026-06-25 10:49:55 +08:00
SivanCola 0416e312ff Merge pull request #5244 from ttmouse/fix/cmd-shortcuts-allow-input-content
输入框有内容时 Cmd+1..9 快捷键仍可切换对话
2026-06-25 10:45:59 +08:00
lifu963 3818c6cafc Merge origin/main-v2 into subagent cancellation PR 2026-06-25 10:43:26 +08:00
Sivan 2ba4ce8821 test: cover topic shortcuts inside editable fields
Add regression coverage for Cmd/Ctrl+1..9 while focus stays in input and textarea elements.
2026-06-25 10:40:46 +08:00
SivanCola b193d426b3 Merge pull request #5217 from esengine/feature/memory-v5-compiler
Add Memory v5 execution compiler / 新增 Memory v5 执行记忆编译器
2026-06-25 04:06:40 +08:00
SivanCola 5b78ad1889 Merge main-v2 into Memory v5 compiler PR 2026-06-25 04:01:51 +08:00
SivanCola 75a32c5331 Add CLI Memory v5 toggle commands 2026-06-25 03:50:22 +08:00
SivanCola 1b04e2ff72 Report Memory v5 in CLI run metrics 2026-06-25 03:35:08 +08:00
SivanCola a93440ff3e Document Memory v5 usage and metrics 2026-06-25 03:27:43 +08:00
SivanCola 54215902fd Adapt Memory v5 metrics for frontend event flow 2026-06-25 03:16:53 +08:00
SivanCola 2f2d0194c6 Report Memory v5 aggregate usage metrics 2026-06-25 03:08:54 +08:00
SivanCola 5dd5efce96 Fix Memory v5 bot review regressions 2026-06-25 02:40:35 +08:00
SivanCola ce3c5103fd Merge pull request #5246 from lifu963/pr/plan-mode-policy
Centralize plan mode policy
2026-06-25 02:30:20 +08:00
SivanCola b1326ef41e Fix plan mode review regressions
Honor shell quoting when screening plan-mode bash commands, allow explicitly declared MCP servers to connect in token economy plan mode, and strip the legacy plan marker from restored display text.

Co-authored-by: SivanCola <[email protected]>
2026-06-25 02:20:21 +08:00
SivanColaandClaude Opus 4.8 5ae787efd2 Fix four Memory v5 review issues (no-tool outcome, goal context, reservation, weight cap)
Addresses a self-review of #5217; all four are reproduced by focused tests.

1. outcomeFor: a turn that finished with no error and no tool calls returned
   partial_success, which updateStrategy then learned as a strategy *failure*,
   poisoning scores once Memory v5 is on by default. A no-tool successful turn is
   now "success".

2. StartTurn: the goal was classified from the controller's injected "Referenced
   context:" preamble plus <file>/<dir>/<resource>/<image> blocks, so
   summarizeGoal and strategy matching keyed off file contents. Goal
   classification now strips that prefix via stripReferencedContext. SourceEvent
   intentionally keeps the full input — when the compiled contract replaces the
   user turn, source_event is the model's only view of the referenced files.

3. hardeningTraceForStartWithCoordinator: an empty / un-useful IR reserved the
   entire MaxToolCalls, so concurrent tabs on one workspace starved each other's
   reservations through the shared coordinator. It now reserves only the IR's
   steps (zero for an empty IR).

4. consensus.capDominantWeights: the cap used the pre-cap total, so [100,1]
   capped to 0.55*101 still left the node at ~98% of the post-cap total. It now
   bounds each node to maxNodeShare of the post-cap total, iterating since
   capping one node lowers the total for the rest.

Adds regression tests for all four (including the no-tool turn path the earlier
hardening-fix tests didn't cover) and the first unit tests for capDominantWeights.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-25 02:01:22 +08:00
SivanCola 948cd3e176 Merge pull request #5265 from esengine/fix/cli-i18n-test-isolation
Fix two locale/load-dependent test-isolation bugs (cli i18n, bash login-shell probe)
2026-06-25 01:57:35 +08:00
SivanColaandClaude Opus 4.8 e3b4d2a1a9 Distrust MCP readOnlyHint in plan mode (review fix)
The moderate fail-closed gate trusted any ReadOnly()==true tool, but an MCP
remoteTool's ReadOnly() can be true purely from a server-reported readOnlyHint —
an untrusted signal. Such a tool could enter plan mode (and read-only research
sub-agents) without a plan_mode_allowed_tools declaration.

- Add tool.PlanModeUntrustedReadOnly; plugin remoteTool/lazyTool implement it,
  returning true only when ReadOnly() came from a server readOnlyHint, not from a
  first-party Spec.ReadOnlyToolNames override (cache format unchanged).
- planmode.Call gains Untrusted; Decide no longer trusts an untrusted ReadOnly()
  on its fast path — such tools are gated like writers and must be declared in
  plan_mode_allowed_tools. Built-ins and first-party overrides stay trusted.
- ReadOnlySubagentToolRegistry / FilterReadOnlyRegistry exclude untrusted
  read-only tools from read-only research sub-agents and the planner registry.
- Tests across planmode/plugin/agent; docs corrected (the earlier "plugin/MCP
  contractually non-read-only" claim was wrong).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-25 01:56:45 +08:00
SivanColaandClaude Opus 4.8 a8c91c4c11 Fix TestBashMergesLoginShellPath flaking under full-suite load
The test spawned a real login shell to probe its PATH, but the probe
(defaultBashShellPATH) runs up to three interactive-login shells with a 2s
timeout each. Under the CPU load of `go test ./...` the probe timed out and
returned an empty PATH, so the test failed with command-not-found — only in
the full suite, never in isolation or in the builtin package alone.

Inject a deterministic login-shell PATH via the bashShellPATH function var
(which exists for exactly this kind of seam) so the test no longer spawns a
real shell. Add TestParseShellPATH and TestMergePathLists to cover the probe's
parsing/merging directly — these pure functions had no unit test, so coverage
increases rather than drops.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-25 01:14:58 +08:00
SivanColaandClaude Opus 4.8 0456f97f64 Fix cli tests leaking host locale into global i18n state
The cli test binary had no deterministic UI language. Production code
(cli.Run) calls i18n.DetectLanguage("") which resolves the host locale from
the environment and installs it as the global i18n.M. Tests that exercise the
CLI entry point (acp_test.go's Run(["--acp"]), cli_test.go's Run(["version"])
etc.) leave M set to the host language without restoring it. On a zh_CN dev
machine this flips M to Chinese, so every later test that asserts English UI
strings fails — but only when the whole package runs, not in isolation:

  TestIngestSeparatesReasoningFromAnswer
  TestVerboseReasoningInsertsTextUnderSummary
  TestToolWorkingLineThenClears
  TestClearCommandRequiresConfirmationAndDiscardsSession
  TestIngestEventRoutesByKind

CI never caught it because CI runs in an English locale.

Pin a deterministic English environment in cli's TestMain so the suite is
independent of the host locale. Tests that need another language still set it
explicitly via i18n.DetectLanguage(lang) with their own cleanup.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-25 01:14:57 +08:00
SivanCola 5873ebee69 Merge pull request #5064 from Zhuocheng-Lang/fix/nvidia-wayland-explicit-sync
fix(desktop): apply NVIDIA Wayland explicit-sync workaround for WebKitGTK
2026-06-25 01:12:59 +08:00
SivanColaandClaude Opus 4.8 905977386e Harden plan-mode gate with moderate fail-closed boundary
Plan mode previously trusted any ReadOnly()==true tool while planning, so
a pseudo-read-only tool (e.g. complete_step) could slip through unless it
was manually denylisted, with no test pinning the built-in roster.

- Add tri-state PlanSafety + tool.PlanModeClassifier so tools self-report
  their plan-mode stance; complete_step reports unsafe, read_only_task /
  read_only_skill report safe.
- Make Decide fail-closed for non-read-only tools (plugin/MCP, writers)
  while still trusting in-process ReadOnly() tools; enforce PlanSafe =>
  ReadOnly. plan_mode_allowed_tools is the escape valve for read-only
  MCP/plugin tools.
- Add reconcile_test.go: walks tool.Builtins() and fails if any built-in
  lacks an explicit plan-mode stance (forcing function against drift).
- Add marker round-trip test pinning the model-facing Marker to the gate.
- Document the boundary in SPEC/GUIDE/GUIDE.zh-CN/MIGRATING.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-25 01:04:18 +08:00
SivanColaandClaude Opus 4.8 237bdbddfb Fix Memory v5 hardening gate that disabled compiler and demoted successes
The production-hardening layer used hardcoded budgets to both gate contract
injection and rewrite execution outcomes after the fact, which broke the
compiler in normal multi-turn use:

- Bug 1: StartTurn stopped injecting the compiled contract once learned
  memory nodes reached the GC cap (which equalled the hardening memory-node
  budget) and never recovered — about 10 turns in practice.
- Bug 2: a genuinely successful turn with more tool calls than the budget was
  rewritten to partial_success in the persisted trace.

Fixes:
- StartTurn no longer gates the cache-safe contract on hardening.Allowed. The
  contract is plain input text and the real execution that follows is still
  bounded by tool permissions.
- applyProductionHardening no longer rewrites tr.Outcome; the budget/canary
  verdict is recorded as an advisory observation only.
- Remove the per-turn sandbox replay + RunIsolated goroutine from the hot path
  (StartTurn and writeTraceAndLearn) — they produced trace metadata that never
  reached the model yet cost a goroutine + CPU on every turn.
- Decouple the memory-node budget (2000) from the graph GC cap (300) so a
  fully-populated graph no longer permanently trips the verdict.
- Unify canary.DefaultPolicy() (was a 10% canary) with the runtime's
  full-production seed, removing the mismatch.

Add TestMemoryV5SurvivesLongRun (80 turns, 25 tool calls each) as a regression
guard and rewrite two tests that asserted the old buggy behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-25 00:38:44 +08:00
SivanColaandSivanCola a0dcd1f90e add read-only skill delegation
Co-authored-by: SivanCola <[email protected]>
2026-06-24 23:38:57 +08:00
SivanCola d4499008a7 Merge pull request #5257 from SivanCola/fix/session-trash-consolidation
Fix session trash fallback conflicts / 修复会话回收站与回退会话冲突
2026-06-24 23:35:07 +08:00
SivanCola d75a9d8d52 block source connector in read-only subagents
Exclude connect_tool_source from read_only_task child registries so token economy source loading cannot reopen writer-capable or installer tool surfaces. Extend the economy plan-mode test to execute read_only_task and verify the child request stays within the read-only boundary.

Co-authored-by: SivanCola <[email protected]>
2026-06-24 23:24:24 +08:00
SivanCola b3130ec1fd Remove unused topic index helper 2026-06-24 23:23:13 +08:00
lifu963 ec0cbda3a9 Merge subagent cancellation contract implementation 2026-06-24 23:20:41 +08:00
Sivan be7c7efb4b Freeze Memory v5 stable release boundary 2026-06-24 23:16:05 +08:00
SivanCola f3cdb9d3e1 Fix blank session trash lifecycle 2026-06-24 23:14:52 +08:00
SivanCola d87ddb54b7 add read-only plan-mode subagent
Add read_only_task as an ephemeral subagent that exposes only read-only research tools plus plan-mode safe foreground bash. Wire it into token economy source loading and document the plan-mode boundary.

Co-authored-by: SivanCola <[email protected]>
2026-06-24 23:11:43 +08:00
Sivan 89b9a296a1 Add Memory v6 layer collapse analyzer 2026-06-24 23:00:40 +08:00
lifu963 dcc82866c7 Preserve completed parallel results on cancel
Drain already-finished child results before marking remaining parallel_tasks work cancelled so partial aggregates do not lose completed output during cancellation races.
2026-06-24 22:59:29 +08:00
lifu963 1ba4dbd26a Fix provider chunk sends on cancellation
Make OpenAI-compatible and Anthropic stream producers stop blocking when the agent abandons chunk reads while still delivering cancellation errors to active readers.
2026-06-24 22:58:38 +08:00
lifu963 9d3be13f83 Fix parallel subagent cancellation aggregation
Return a cancellation-aware aggregate for parallel_tasks so completed children remain visible while unfinished work is marked cancelled or skipped instead of reported as fully completed.
2026-06-24 22:56:01 +08:00
lifu963 0c96d3c863 Test subagent entrypoint cancellation
Cover task and runAs=subagent skill cancellation wiring so future changes keep parent cancellation observable through both entrypoints.
2026-06-24 22:52:39 +08:00
lifu963 cda3954506 Fix stream cancellation on stuck providers
Return promptly when the provider stream stops producing chunks so parent turns and subagents are not held by a non-cooperative backend.
2026-06-24 22:51:31 +08:00
Sivan 1349b9d8ab Guard Memory v5 predictive bias 2026-06-24 22:45:59 +08:00
SivanCola 9dc723c9f6 Fix session trash fallback conflicts 2026-06-24 22:41:37 +08:00
Sivan 4b54f5e414 Bridge Memory v5 predictive advisories 2026-06-24 22:35:40 +08:00
SivanCola a135b0ca81 Merge pull request #5160 from GTC2080/GTC/fix-tui-mouse-cell-motion
fix(tui): preserve native terminal selection
2026-06-24 22:31:05 +08:00
SivanCola 234e624e18 Fix collapsed shell hint mouse copy
Co-authored-by: SivanCola <[email protected]>
2026-06-24 22:22:51 +08:00
Sivan d4b97d543f Add Memory v5 early warning observer 2026-06-24 22:10:41 +08:00
SivanCola bd6ec239c9 Merge pull request #5243 from ttmouse/feat/heartbeat-new-session
feat: 心跳任务新增「每次新建对话」选项
2026-06-24 22:07:46 +08:00
SivanCola 1b910f84a3 Fix heartbeat fresh conversation overlap
Keep fresh-conversation heartbeat topics as in-flight guards until their controllers are idle, restore editor scrolling, and cover pending-topic cleanup.

Co-authored-by: SivanCola <[email protected]>
2026-06-24 22:04:01 +08:00
Sivan 6d79fe9086 Decouple Memory v5 observer loop 2026-06-24 21:50:07 +08:00
Sivan dde3588065 Monitor Memory v5 causal signal dynamics 2026-06-24 21:32:17 +08:00
Sivan 77cf4c83b8 Correct Memory v5 compression bias 2026-06-24 21:21:34 +08:00
Sivan f6aca44764 Compress Memory v5 causal traces 2026-06-24 20:58:27 +08:00
ttmouse b469f9e707 fix: 修复 Codex review 第2轮反馈
1. resumeSession 路径也去掉 if (messages.length) 守卫
2. 新增 3 条原则沉淀到 pr-review 技能:
   - clear-placeholder-after-reset
   - always-dispatch-completing-action
   - fast-path-before-slow-path

同步更新 SKILL.md 原则计数 56→59
2026-06-24 20:45:54 +08:00
ttmouse 8b1b01e351 fix: 修复 Codex review 指出的 3 个问题
1. P1: clearSession 后 reset 保留旧 items,需 dispatch 空 history
   清除占位(dispatchTo(tabId, {type: 'history', messages: []}))
2. P1: 空 HistoryForTab 跳过 dispatch 导致旧 items 残留,
   去掉 if (messages.length) 守卫,始终 dispatch
3. P2: BalanceForTab 有网络调用,同一批 Promise.all 阻塞了
   history 渲染。拆为两阶段:先 dispatch meta+history(快),
   再 batch dispatch 其余(checkpoints/context/balance/effort/jobs)
2026-06-24 20:42:26 +08:00
ttmouse 8c2512cc1d fix: 经典模式下 openTopicSession/openProjectTab/openGlobalTab 也缺 prevItems 迁移
这三个函数在单画布模式(classic)下用于打开新标签页,
但缺少 prevItems 迁移逻辑。当 reset=true 时,新标签页的
items 为[],reset 保留不了占位内容,导致 Welcome 闪现。
修复:与 activateTopic 同样的 prevItems 保存+迁移逻辑。
2026-06-24 20:24:16 +08:00
Sivan b9f6442085 Harden Memory v5.9 causal safety 2026-06-24 20:17:43 +08:00
ttmouse 2940ad690e perf: onResumeSession 同样改为 fire-and-forget refreshTabMetas 2026-06-24 19:49:16 +08:00
ttmouse 3967a7160e perf: refreshTabMetas 改为 fire-and-forget,Transcript 不阻塞于侧边栏刷新
handleOpenTopic 和 onResumeSession 中 await refreshTabMetas()
阻塞了 Transcript 的 revealSignal 触发,导致用户感知延迟。
改为 void refreshTabMetas() 后,setTranscriptRevealSignal
立即执行,Transcript 可以在数据加载完成后就刷新,不必等待
侧边栏/顶部栏的 IPC 完成。
2026-06-24 19:49:10 +08:00
ttmouse b8637e133b Revert "perf: 合并 9 次 IPC 为单次 HydrateTopic 调用,切换会话快 3x"
This reverts commit 528c997cd2.
2026-06-24 19:48:19 +08:00
ttmouse 528c997cd2 perf: 合并 9 次 IPC 为单次 HydrateTopic 调用,切换会话快 3x
之前每次切换会话需要 9 次 IPC 往返:
  ActivateTopic → MetaForTab → HistoryForTab → CheckpointsForTab
  → ContextUsageForTab → BalanceForTab → EffortForTab → JobsForTab
  → ListTabs (refreshTabMetas)

每次 IPC 有 ~2-5ms 的序列化+传输开销,累积延迟明显。

现在 Go 后端新增 HydrateTopic 方法,一次返回全部数据:
1. tabs.go: 新增 HydrateTopicData 结构体 + HydrateTopic 方法
2. useController.ts: activateTopic 改用 app.HydrateTopic,
   一次性 dispatch 所有数据(无 reset、无 loadSessionDataForTab)
3. App.tsx: handleOpenTopic/onResumeSession 中 refreshTabMetas
   改为 fire-and-forget(不再 await)
4. types.ts + bridge.ts: 新增 HydrateTopicData 类型定义和绑定
2026-06-24 19:46:02 +08:00
ttmouse 0ec3d67eeb 允许输入框有内容时 Cmd+1..9 快捷键仍可切换对话
移除 topicShortcutIndexFromEvent 中的 isEditableTarget 守卫,
使 Cmd/Ctrl+1..9 在焦点位于输入框/文本区时也能触发对话切换。
这些是应用级导航快捷键,不是编辑器标准键(如 Cmd+C/V/Z),
移除守卫不会影响文本编辑能力。行为与 CodeX 一致。
2026-06-24 19:28:00 +08:00
lifu963 48f06c4276 assert economy source blocking behavior directly 2026-06-24 19:20:13 +08:00
ttmouse 0d884e0b79 fix: CR 反馈 — executeTask 重试保护 + 移除 min-height
根据 Codex Review (P2) 修复:

1. 【P2】executeTask: NewConversationEachRun 模式下用 pendingTopics
   内存 map 追踪临时 topicID,失败重试时复用同一 topic 而非
   反复新建;成功提交后清理,下次执行新建
   (评论: 'Reuse the pending topic when a run is retried')

2. 【P2】executeTask: 对于 NewConversationEachRun,现有 topic
   的 controller busy check 通过 openTab + 等待 controller 完成,
   防止重叠执行
   (评论: 'Prevent overlapping fresh-conversation runs')

3. 【P2】移除 .heartbeat-modal 的 min-height:600px,让模态框
   在小窗口(480px)下也能正常显示,由 max-height: min(780px,92vh)
   控制最大高度
   (评论: 'Avoid forcing a 600px modal in 480px windows')

未采纳:
- 【P2】'Keep the edit form scrollable' — TaskEditor 直接渲染在
  .heartbeat-modal 下,但 textarea 已有 max-height:180px 限制,
  总内容高度在模态框范围内,不会溢出裁剪
2026-06-24 19:20:11 +08:00
ttmouse a40aaa7614 fix: 切换标签页时迁移旧 items 作占位,消除 Welcome 闪现
activateTopic 返回新 tab ID 时,旧状态被全部清除,新状态
从 initialState 创建(items=[])。之前的 reset 保留 items
策略对空数组无效。

现在在 loadSessionDataForTab 的 reset 发生之前,将当前激活
标签页的 items 显式写入新标签页的状态。reset 保留 items 时
就能保留这些占位内容,用户看到的仍是旧对话,直到新历史到达。
2026-06-24 19:15:17 +08:00
lifu963 6f25d1ecb2 cover plan mode economy source blocking 2026-06-24 19:09:56 +08:00
lifu963 605c481fad block subagent skill tools explicitly in plan mode 2026-06-24 19:09:28 +08:00
lifu963 f4f9965855 document plan mode allowed tools migration 2026-06-24 19:05:39 +08:00
SivanCola 10bdacf625 Merge pull request #5241 from esengine/fix/opaque-script-error-reporting
Improve opaque script error reporting / 改进不透明脚本错误上报
2026-06-24 19:03:26 +08:00
ttmouse 24a2d9f47c feat: 心跳任务新增「每次新建对话」选项
在心跳任务编辑器中增加「对话模式」选项,可选择:
- 复用同一对话(默认,原有行为)
- 每次执行新建对话(每次触发时创建新 topic)

Go 后端:
- HeartbeatTask 新增 NewConversationEachRun 字段
- executeTask 中当该字段为 true 时清除 topicID 强制创建新对话
- mergeRunUpdatesLocked 不为 newConversationEachRun 任务持久化 topicId

前端:
- 编辑面板新增 toggle 选择器(复用 set-seg 模式)
- 提示词 textarea 自动增高(min-height 80px → max-height 180px,超出内部滚动)
- 底部操作栏移除分隔线,与表单融为一体
- 移除列表页底部提示条(heartbeat-hint)
- 弹窗高度自适应,任务卡片间距缩小至 4px
- 项目选择器选中态取消蓝色高亮

i18n:中/英/繁三语文案同步

修复审查发现:
- autoGrowPrompt 改为 useLayoutEffect 避免高度闪烁
- handleAdd/startNew 初始化补上 newConversationEachRun: false
2026-06-24 19:01:49 +08:00
Sivan c9e7a3659b Harden Memory v5.9 production runtime 2026-06-24 19:00:00 +08:00
SivanCola 74868106f0 Preserve manual crash severity overrides 2026-06-24 18:58:11 +08:00
SivanCola a1cd445a78 Merge pull request #5238 from ttmouse/pr/doubleclick-rename-topic
左侧边栏双击对话名称可重命名
2026-06-24 18:56:49 +08:00
Sivan 56a334f44d Fix project tree double-click routing 2026-06-24 18:50:11 +08:00
lifu963 fa010d90ce block plan mode bash process controls 2026-06-24 18:47:18 +08:00
SivanCola 3401643586 Improve opaque script error crash reporting 2026-06-24 18:42:53 +08:00
lifu963 7cfd4238c5 cover actual plan mode writer tool names 2026-06-24 18:39:23 +08:00
lifu963 bf54e8c45c document plan mode override tightening 2026-06-24 18:38:27 +08:00
Sivan ac95619904 Add Memory v5.9 production hardening 2026-06-24 18:35:57 +08:00
lifu963 8cef3524a0 apply plan mode policy to economy sources 2026-06-24 18:35:41 +08:00
lifu963 d18cab405a align plan mode marker with policy 2026-06-24 18:34:41 +08:00
lifu963 fd38efb48d centralize agent plan mode gate 2026-06-24 18:33:59 +08:00
lifu963 f3470b2e4f add plan mode policy tracer 2026-06-24 18:32:13 +08:00
SivanCola 671dd22232 Merge pull request #5153 from GTC2080/GTC/fix-5097-new-session-history
fix(desktop): keep project history topics out of blank reuse
2026-06-24 18:21:16 +08:00
Sivan e4a7f4399a Add Memory v5.8 typed control semantics 2026-06-24 18:19:25 +08:00
ttmouse 29715aeeff fix: 批量分发 loadSessionDataForTab 结果,消除级联重渲染
loadSessionDataForTab 中有 7 个并行 IPC 调用(MetaForTab,
HistoryForTab, CheckpointsForTab, ContextUsageForTab, BalanceForTab,
EffortForTab, JobsForTab),之前每个通过独立的 .then() 链 dispatch,
导致每次 IPC 完成都触发一次 bump() → React 重渲染,共产生 7+ 次
视觉刷新,用户感受到两次以上闪烁。

改为 Promise.all 收集所有结果后,在同一个微任务中批量 dispatch 所有
数据(history、meta、checkpoints、context、balance、effort、jobs
再加 hydrate_done),React 18 自动合并到一次渲染中完成。
2026-06-24 17:55:14 +08:00
SivanCola 112618ce41 Merge pull request #5230 from ttmouse/pr/fix-new-conversation-modes
fix: 工作台/创作模式下新建对话显示旧对话的问题
2026-06-24 17:44:40 +08:00
ttmouse df9f4d932d fix: 消除切换会话时 Welcome 闪现
问题根因:activateTopic 调用 loadSessionDataForTab(reset=true) 时,
reset 先清空 items(设为 []),Transcript 检测到 empty 后立即渲染
Welcome,随后异步 history 到达才填充真实会话内容,导致一闪而过。

修复方案(两处改动):
1. useController.ts reducer: reset 时保留旧 items 不立即清空,
   切换期间旧对话内容留在界面上作为占位,新 history 到达后替换。
2. Transcript.tsx + App.tsx: 添加 hydrating prop,加载期间不显示
   Welcome;仅在真正空且非加载态时才显示新建会话界面。
2026-06-24 17:44:27 +08:00
Sivan eb067be473 Add startup active-tab race regression 2026-06-24 17:38:13 +08:00
Sivan 03d9638d89 Add Memory v5.7 global equilibrium 2026-06-24 17:38:07 +08:00
SivanCola ed9b285bcb Merge pull request #5233 from SivanCola/fix/desktop-workspace-plan-integration
Integrate desktop workspace and plan persistence fixes / 整合工作区与计划模式修复
2026-06-24 17:31:40 +08:00
ttmouse 025c095484 fix: 组件卸载时清理 clickTimerRef 避免空回调 2026-06-24 17:29:18 +08:00
SivanCola 8026a8c685 Address desktop integration review findings 2026-06-24 17:23:47 +08:00
SivanCola b99ffc8e01 Merge pull request #5213 from ttmouse/pr/fix/composer-max-width-4465
fix: 关闭侧栏后输入框宽度撑满窗口 + jump-bar/滚动条不影响历史消息对齐
2026-06-24 17:21:32 +08:00
ttmouse 49034f6ab8 fix: 双击改名时拦截第一下点击的导航跳转
用 clickTimerRef 延迟 200ms 执行 onOpenTopic:
双击时第二下点击清除定时器并提前返回,彻底避免双击过程中
的导航/滚动到顶部问题;单击延迟 200ms 后正常打开话题。
2026-06-24 17:14:41 +08:00
Sivan 4b2eb60264 Add Memory v5.6 distributed control plane 2026-06-24 17:13:31 +08:00
Sivan 81bde42d3e Fix compact composer controls at capped width 2026-06-24 17:12:06 +08:00
ttmouse 40a2ee3bdf fix: 经典模式编辑态保留行高,创作模式双击不改位置
1. 编辑态 div 添加 project-tree__topic--has-meta 类名,
   匹配正常行高(min-height: 34px vs 48px),避免行高突变
2. 创作模式下 onClick 检查 event.detail > 1 时跳过导航,
   双击改名不会触发位置切换
2026-06-24 17:11:57 +08:00
e6115f6743 Integrate desktop workspace and plan persistence fixes
Combine the workspace tree reveal fix from PR #5219 with the useful desktop and config fixes from PR #5220, while keeping plan restore intent scoped per tab.

Co-authored-by: lifu963 <[email protected]>
Co-authored-by: yzz521 <[email protected]>
2026-06-24 17:09:06 +08:00
ttmouse c32c421644 feat: 双击重命名时自动选中输入框全部文本 2026-06-24 17:08:13 +08:00
SivanCola 65feb79e0c Merge pull request #5199 from lifu963/pr/subagent-continue-autofork
Simplify subagent continuation reuse
2026-06-24 17:06:01 +08:00
ttmouse af0ca59cc3 style: 精简重命名输入框样式,用 align-self: stretch 撑满行高
将 .project-tree__topic-input 的固定 height: 26px 改为
align-self: stretch,让输入框自然撑满 flex 容器的高度(匹配
选中态的 min-height: 34px),消除白色输入框与蓝灰选中背景之间的
视觉重叠和文字位移。
2026-06-24 17:04:37 +08:00
Sivan 604b73c057 fix(agent): handle legacy subagent fork refs 2026-06-24 16:59:42 +08:00
Sivan c8140dce10 Add Memory v5.5 control hierarchy 2026-06-24 16:56:33 +08:00
SivanCola f450f61113 Merge pull request #5223 from SivanCola/fix/default-collapsed-workspace-panel
Default workspace panel to collapsed / 默认收起工作区面板
2026-06-24 16:45:18 +08:00
SivanCola 2b5f68bd17 Merge pull request #5125 from qiukeren/main-v2
fix(cli): show current version in upgrade success message
2026-06-24 16:44:23 +08:00
Sivan d106710082 Add Memory v5.4 control stability 2026-06-24 16:42:12 +08:00
ttmouse 9606bebe3e fix: 移除输入框激活时的 1px 上移,避免焦点切换轻微位移
:root[data-theme-style] .composer-card:focus-within 的
transform: translateY(-1px) 导致焦点切换时输入框位移 1px。
2026-06-24 16:40:13 +08:00
Sivan f3a638cd8a Add upgrade success message regression 2026-06-24 16:37:43 +08:00
Sivan 1167d6ef15 Add Memory v5.3 equilibrium controls 2026-06-24 16:26:26 +08:00
Sivan 14fb425e94 Add Memory v5 turn citations 2026-06-24 16:15:37 +08:00
lifu963andCursor 6e4a756531 test(agent): cover background ancestor continuation
Co-authored-by: Cursor <[email protected]>
2026-06-24 16:14:19 +08:00
lifu963andCursor d3a697f9e4 test(agent): cover background subagent references
Co-authored-by: Cursor <[email protected]>
2026-06-24 16:14:19 +08:00
lifu963andCursor d138d8bcc7 feat(agent): simplify subagent continuation interface
Co-authored-by: Cursor <[email protected]>
2026-06-24 16:14:19 +08:00
lifu963andCursor 1be16fc92c feat(agent): resolve subagent continuations through lineage
Co-authored-by: Cursor <[email protected]>
2026-06-24 16:14:19 +08:00
lifu963andCursor 238cdebcb3 test(agent): trace ancestor subagent continuation
Co-authored-by: Cursor <[email protected]>
2026-06-24 16:14:19 +08:00
Sivan ab33bad6bd Default workspace panel to collapsed 2026-06-24 16:01:43 +08:00
Sivan fa292b0f34 Add Memory v5.2 stability layer 2026-06-24 15:51:18 +08:00
Sivan 59bb5e8f81 Stabilize Memory v5 learning loop 2026-06-24 15:39:58 +08:00
ttmouse b2b0b99f03 fix: scrollbar-gutter: stable both-edges 防止滚动条导致内容偏移
overflow-y: overlay 在 WebKit 新版已被移除,回退到 auto 时滚动条
出现会导致历史消息内容偏移。使用 scrollbar-gutter: stable both-edges
在两侧对称预留空间,滚动条出现/消失不影响内容居中对齐。
2026-06-24 15:13:08 +08:00
ttmouse 8882478ade fix: 将 jump-bar 移出 transcript,使用绝对定位避免布局偏移
QuestionJumpBar 原为 .transcript 的子元素,其 sticky + margin-right: -48px
导致历史消息内容偏移。改为放置在 .transcript-shell 下、.transcript 外部,
使用 position: absolute 定位在右侧中部,完全不参与 transcript 子元素布局,
与 codex/upstream-sync-20260613 分支结构一致。
2026-06-24 15:10:22 +08:00
SivanCola 5968a44f96 Satisfy Memory v5 lint check 2026-06-24 15:09:41 +08:00
SivanCola 76c56778a6 Fix Memory v5 runtime review issues 2026-06-24 15:06:09 +08:00
SivanCola 199442e40f Bind Memory v5 compiler to execution runtime 2026-06-24 15:00:00 +08:00
ttmouse eebc3ee919 fix: 工作台/创作模式下新建对话显示旧对话的问题
工作台和创作模式使用 ensureBlankSurface(单 surface 布局)创建新对话时,
与 mount/onReady 时 fire-and-forget 调用的 syncActiveTabFromBackend
存在竞态条件:后者异步返回后会用旧的 backend tab 覆盖新创建的空白 tab。

修复:给 syncActiveTabFromBackend 增加 guard 参数,mount/onReady 的
fire-and-forget 调用传入 guard=true,在异步获取 backend 结果后检查
activeTabIdRef.current 是否已被用户操作改变,若是则跳过覆盖。
其他同步调用(closeTab、switchTab 等)不传 guard,行为不变。
2026-06-24 14:49:18 +08:00
ttmouse bc89a87cba fix: jump-bar 改用绝对定位,不占用 transcript 布局空间
.transcript > .jump-bar 原使用 margin-left: auto + margin-right: -48px
强制右移,导致 transcript 子元素偏移。改用 position: absolute 定位
在右下角,与 codex/upstream-sync-20260613 分支方案一致,不影响
其他子元素的居中对齐。
2026-06-24 14:46:29 +08:00
SivanCola 0acc680a2c Add Memory v5 execution compiler 2026-06-24 14:46:20 +08:00
ttmouse c987c8cc74 fix: 关闭侧栏后输入框宽度撑满窗口,设置固定最大宽度居中布局
1. :root[data-theme-style] .composer-wrap: max-width: none → var(--maxw)
   工作台模式下输入框不再铺满窗口,与消息历史共享 --maxw: 760px 对齐

2. .transcript: overflow-y: auto + overlay fallback + scrollbar-gutter: auto
   滚动条悬浮不占位,避免历史消息因滚动条出现而偏左移位

参考 codex/upstream-sync-20260613 分支的对齐方案。

Fixes #4465
2026-06-24 14:32:25 +08:00
ttmouse 4f23e75c2b feat: 左侧边栏双击对话名称可重命名
在 ProjectTree 的 topic 主按钮上添加 onDoubleClick 处理,双击对话
主题名称时调用已有的 startRenameTopic 逻辑,触发内联重命名输入框。
对 session 节点(运行时会话)跳过,保持与右键菜单重命名行为一致。
2026-06-24 14:09:38 +08:00
SivanCola 483d536214 Merge pull request #5209 from SivanCola/fix/session-composer-draft
Fix session-scoped composer drafts / 修复会话级输入草稿隔离
2026-06-24 13:35:36 +08:00
Sivan aaa65796e1 fix(desktop): keep composer draft async work scoped 2026-06-24 13:29:10 +08:00
SivanCola 60a203456c Merge pull request #5208 from ttmouse/fix/progress-panel-collapse-state
fix: 进度面板折叠状态持久化到 localStorage,移除自动弹窗
2026-06-24 13:21:31 +08:00
ttmouse 584492567d fix: TestSessionWrongSignature flaky test — 签名首 nibble 可能已是 0,翻转成空操作
随机签名 hex 串的首个 nibble 有 1/16 概率天然是 0,
此时 0 + sig[1:] 与原 sig 相同,verifySession 返回 true。
修复:若首字符已是 0,翻转为 f 保证不同。
2026-06-24 12:40:45 +08:00
Sivan 030e780fe9 fix(desktop): isolate composer drafts by session 2026-06-24 12:33:47 +08:00
SivanCola 8f489b5022 Merge pull request #4950 from ttmouse/pr/heartbeat-approval-mode
feat(heartbeat): 自动化任务新增权限等级选择
2026-06-24 12:30:38 +08:00
ttmouse da7b4bd128 fix: 进度面板折叠状态持久化到 localStorage,移除自动弹窗
Issue: #4920

- 添加 loadOpenState/saveOpenState 读写 localStorage('todoPanel:open')
- 移除 todoKey/allDone 触发的自动展开 useEffect
- 用户折叠/展开状态跨会话保持,不再被新任务覆盖
2026-06-24 12:27:49 +08:00
Sivan 009c4af0c9 Fix heartbeat schedule edge cases 2026-06-24 12:26:57 +08:00
SivanCola 2d254a08e4 Merge pull request #5205 from SivanCola/fix/desktop-workspace-resize-integration
Integrate desktop workspace split and resize drag fixes / 集成桌面文件区分栏与拖拽优化
2026-06-24 12:17:23 +08:00
Sivan d8ce905c16 Preserve workspace split during file browsing 2026-06-24 12:11:56 +08:00
Sivan ce79c6d354 Merge remote-tracking branch 'origin/main-v2' into HEAD
# Conflicts:
#	desktop/frontend/package.json
2026-06-24 12:02:01 +08:00
Sivan 1a113589b8 Fix live workspace dock resizing 2026-06-24 12:01:20 +08:00
Sivan b31b711884 Fix heartbeat approval and window scheduling 2026-06-24 11:58:22 +08:00
Sivan 0889e925a4 Fix workspace preview split edge cases 2026-06-24 11:43:56 +08:00
Sivan cdf7aabde2 Merge latest main-v2 into desktop integration 2026-06-24 11:27:06 +08:00
YHH 90a3c2892c Merge pull request #5203 from esengine/refactor/split-config-god-file
refactor(config): split the 3120-line config.go god file by concern
2026-06-24 11:27:00 +08:00
SivanCola b79c3e4fbf Merge pull request #5109 from lifu963/pr/controller-turn-orchestration
Extract controller turn orchestration
2026-06-24 11:24:27 +08:00
Sivan ad640fd41f Merge PR #5171 resize drag performance
# Conflicts:
#	desktop/frontend/package.json
#	desktop/frontend/src/components/WorkspacePanel.tsx
2026-06-24 11:22:16 +08:00
esengine 656ff9b2ca refactor(config): split the 3120-line config.go god file by concern
config.go mixed the config schema with the entire load pipeline, the DeepSeek/Mimo pricing tables, and all OS path resolution. Move three cohesive, self-contained concerns into their own files in the same package (pure code motion, no API or behaviour change):

- pricing.go  — DeepSeek/Mimo pricing tables + official-pricing reset/compare helpers
- load.go     — Load/LoadForRoot + the TOML merge and legacy-normalisation pipeline
- paths.go    — user-config / home / session / cache / convention dir resolution

config.go drops from ~3142 to ~1455 lines, leaving the config schema, Default(), and the core resolvers/Validate. The remaining sub-config types stay together as the cohesive config schema — deliberately not split further to avoid fragmenting their cohesion.

Verified: gofmt clean, go vet clean, go build ./..., and go test ./internal/config (English locale). config.go's diff is pure deletion (0 insertions).
2026-06-23 20:22:10 -07:00
Sivan c60c33f5be Merge PR #5157 workspace split fix 2026-06-24 11:20:06 +08:00
Sivan a05af40bc4 Fix final resize drag flush 2026-06-24 10:59:09 +08:00
YHH ece0d4e073 Merge pull request #5195 from esengine/refactor/control-session-path-helpers
refactor(control): collapse per-surface session-path wiring into Controller helpers
2026-06-24 10:15:03 +08:00
esengine c87781577f refactor(control): collapse per-surface session-path wiring into Controller helpers
Two snippets of session auto-save path logic were copy-pasted across every surface that builds a controller: the 'fresh session' path-stamp branch (CLI chat, CLI serve, bot) and the model/effort-switch carry (CLI, serve, ACP) — the latter carrying the #2807 orphaned-duplicate fix in three separate places.

Add EnsureSessionPath() and AdoptHistory(msgs, path) to Controller as the single home for both, and migrate the six call sites. The bot's local ensureControllerSessionPath helper and its now-unused agent import are removed.

No behaviour change; verified with gofmt, go build ./..., and go test for the affected packages (control, cli, serve, acp, bot).
2026-06-23 19:10:02 -07:00
lifu963andCursor 83144f4013 chore(desktop): fix resize test whitespace
Co-authored-by: Cursor <[email protected]>
2026-06-23 21:46:16 +08:00
lifu963andCursor ab5e0652a9 test(desktop): include resize drag coverage
Co-authored-by: Cursor <[email protected]>
2026-06-23 20:33:49 +08:00
lifu963andCursor 27356b6a73 fix(desktop): batch composer resizing
Co-authored-by: Cursor <[email protected]>
2026-06-23 20:31:49 +08:00
lifu963andCursor 9fe85032d4 fix(desktop): batch panel splitter resizing
Co-authored-by: Cursor <[email protected]>
2026-06-23 20:29:39 +08:00
lifu963andCursor 4bed5f2233 fix(desktop): batch root splitter resizing
Co-authored-by: Cursor <[email protected]>
2026-06-23 20:28:27 +08:00
lifu963andCursor a3047b38d5 test(desktop): add rAF resize updater
Co-authored-by: Cursor <[email protected]>
2026-06-23 20:26:15 +08:00
GTC2080 edacf1a8c2 fix(tui): preserve native terminal selection 2026-06-23 19:50:36 +10:00
SivanCola 9c27591edf Merge pull request #5146 from qiaone/serve-next
serve: authentication, goal mode, live todos, and mobile UX improvements
2026-06-23 17:24:42 +08:00
lifu963andCursor 9280fa1771 fix(desktop): recompute default files split after preview resize
Co-authored-by: Cursor <[email protected]>
2026-06-23 17:22:15 +08:00
lifu963andCursor ec370d75b9 fix(desktop): preserve code preview viewport padding
Co-authored-by: Cursor <[email protected]>
2026-06-23 17:09:18 +08:00
lifu963andCursor 624cb84f9e fix(desktop): account for file tree rail in split sizing
Co-authored-by: Cursor <[email protected]>
2026-06-23 17:09:13 +08:00
Sivan 95407074ef Address serve auth CodeQL findings 2026-06-23 16:53:42 +08:00
GTC2080 acb19f4eeb fix(desktop): avoid reusing project topics with history 2026-06-23 18:52:20 +10:00
YHH 13f7c041de Merge pull request #5152 from esengine/refactor/frontend-overlays-store
refactor(desktop): move overlay/modal state into a zustand store
2026-06-23 16:52:07 +08:00
lifu963andCursor 057856b3fb fix(desktop): keep file tree rail visible in split
Co-authored-by: Cursor <[email protected]>
2026-06-23 16:52:04 +08:00
lifu963andCursor 07d34a3d25 fix(desktop): restore file tree toggle rail
Co-authored-by: Cursor <[email protected]>
2026-06-23 16:48:50 +08:00
esengine 7ee1d00223 refactor(desktop): move overlay/modal state into a zustand store
Third slice of the frontend state-layer modernization. Moves the 12 transient
overlay/modal atoms out of App.tsx useState into a new src/store/overlays.ts,
read via selectors: settingsTarget/settingsFocus, paletteOpen/paletteSessions,
shortcutsOpen, heartbeatOpen, topicExportOpen, sidebarSearchOpen, the
sidebar-search-focus and dismiss-all coordination signals, startupSplashVisible,
and needsOnboarding.

Setters mirror Dispatch<SetStateAction<T>>, so call sites are drop-in —
including functional updaters like setPaletteOpen(c => ...) and the signal bumps
setSidebarSearchFocusSignal(s => s + 1). Behavior is unchanged: nothing here is
persisted, and every atom keeps its prior initial value (the splash still probes
sessionStorage via shouldShowStartupSplash, now from the store initializer).

Also extracts the SetStateAction resolver shared by the layout and overlays
stores into src/store/setState.ts (applySetState), and points layout.ts at it
to drop the duplicate.

App.tsx is down to 33 useState (from 54 at the start of this track).

Verified: tsc (main + test configs), pnpm build, full frontend test suite.
2026-06-23 01:47:15 -07:00
YHH 1f347727c7 Merge pull request #5151 from esengine/refactor/frontend-layout-flags
refactor(desktop): move right-dock layout config into the zustand store
2026-06-23 16:39:38 +08:00
lifu963andCursor 2f916b7e76 fix(desktop): restore flexible files dock resizing
Co-authored-by: Cursor <[email protected]>
2026-06-23 16:38:47 +08:00
esengine f7049182ec refactor(desktop): move right-dock layout config into the zustand store
Continues the layout-store slice. Moves the four non-persisted layout
configuration flags — workspacePanelOpen, workspacePanelMaximized,
workspacePreviewActive, and rightDockMode (plus its RightDockMode type) —
out of App.tsx useState into src/store/layout.ts, read via selectors.

These describe the right dock's layout and are read across the render tree
(rightDockMode alone has 17 sites, one flag is already prop-drilled to
AppChrome), so a shared store removes real coupling. Their setters mirror
React's Dispatch<SetStateAction<T>>, so existing call sites — including the
functional toggle setWorkspacePanelMaximized(v => !v) — are drop-in.

The purely view-local interaction ephemera (resize-drag flags, button-press
animation flags, measured footerHeight, viewportWidth) deliberately STAY as
App-local useState: they have no cross-component readers, so promoting them
to shared state would be a regression, not a cleanup.

Behavior is unchanged: the flags are not persisted and initialize to the
same defaults the useState calls used.

Verified: tsc (main + test configs), pnpm build, full frontend test suite.
2026-06-23 01:34:08 -07:00
YHH b99886d001 Merge pull request #5150 from esengine/refactor/frontend-layout-store
refactor(desktop): extract layout geometry state into a zustand store
2026-06-23 16:33:12 +08:00
Sivan e8447a7a77 Document serve web frontend usage 2026-06-23 16:33:08 +08:00
esengine f0cdc2892a refactor(desktop): extract layout geometry state into a zustand store
App.tsx held the desktop shell's sidebar/right-dock geometry (the collapse
flag plus three widths) as local useState, alongside the geometry constants,
clamps, and localStorage load/save helpers — part of why App.tsx is a
3.3k-line component with 50+ useState and no state layer at all.

Move the layout-geometry domain into a focused src/store/layout.ts: a zustand
store for the four atoms plus the constants/clamps/persistence helpers that
belong with it. App.tsx now reads each atom via a selector (only that slice
re-renders) and imports the shared geometry helpers.

Behavior is byte-identical: the store's setters are pure (state only) and
callers keep invoking the same save* helpers where they did before, so the
localStorage schema and write timing are unchanged.

First slice of the frontend state-layer modernization; introduces zustand as
the chosen state library.

Verified: tsc (main + test configs), pnpm build, full frontend test suite.
2026-06-23 01:25:56 -07:00
Sivan 1f390dd0e8 Harden serve authentication configuration 2026-06-23 16:22:39 +08:00
lifu963andCursor 9efaffd28c fix(desktop): keep code preview scrolling local
Co-authored-by: Cursor <[email protected]>
2026-06-23 16:16:25 +08:00
lifu963andCursor 34cd2b99d2 fix(desktop): stabilize files split view
Co-authored-by: Cursor <[email protected]>
2026-06-23 16:14:33 +08:00
qiaone 2129bb0569 serve: ignore project default_model, use user global config
Serve mode always reads default_model from the user's global config,
ignoring any project-level override. This keeps the model choice
consistent across projects. CLI/TUI/desktop are unaffected.
2026-06-23 15:54:37 +08:00
qiaone 7720361e39 serve: add live todo panel above composer
- Add Todos() to Status interface and Controller implementation
- Add GET /todos endpoint returning canonical task list
- Add todo panel CSS, HTML, JS to index.html
- Track todo_write events from SSE, fetch /todos on reconnect
- Auto-collapse when all done, dismiss on click, reset on new turn
2026-06-23 15:54:37 +08:00
qiaone 7e92a3cc3a serve: 添加 goal 模式支持
- 新增 POST /goal 端点,支持设置/清除目标(设置时自动关闭 plan 模式)
- 工具栏新增 Goal 按钮(靶心图标),三态切换:进入草稿→退出草稿→清除活跃目标
- 活跃目标时在输入框上方显示 goal-chip,点击可清除
- goal 草稿模式下 placeholder 切换为描述你的目标,发送时自动拼 /goal 前缀
- 手动输入 /goal 时自动退出草稿模式,避免重复拼前缀
- Esc / Shift+Tab 可退出 goal 草稿模式;goal 运行时状态栏显示 Active goal
- 添加 /goal 到斜杠命令列表,中英文 i18n 完整覆盖
- status 轮询同步 goal/goalStatus 状态,SSE 重连时刷新
2026-06-23 15:52:14 +08:00
qiaone ced56ef72d feat(serve): mobile sidebar toggle + fix balance display
- Add hamburger menu button for narrow screens (<768px): sidebar slides in with overlay, closes on Esc/backdrop click
- Fix balance always showing --: Balance.Display() is a Go method not a JSON field; now explicitly includes display in /status response
- Add balance diagnostic log at serve startup
2026-06-23 15:52:14 +08:00
qiaone 535993a271 serve: add token and password authentication for HTTP frontend
Add authentication to 'reasonix serve' so it can safely bind to
non-localhost addresses. Two modes:

- Token mode (--auth token): a pre-shared token in the URL query
  string sets a HttpOnly cookie; share the link with collaborators.
- Password mode (--auth password): a login page (bcrypt) with
  HMAC-signed session cookies, no username needed, persistent login.

Design priorities: simplicity, security, backward compatibility.
Default is still --auth none (no authentication).

Security highlights:
- Constant-time token comparison (crypto/subtle)
- bcrypt cost 12 for password hashing
- HMAC-SHA256 session tokens with expiry + cryptographic nonce
- HttpOnly / Secure / SameSite=Lax cookies
- Per-IP rate limiting on /login (5 attempts/minute)
- Periodic cleanup of rate-limit state
- X-Forwarded-* headers only trusted when behind_proxy is set
- /login path bypasses CSRF guard (form POST, not JSON)
- Error messages are intentionally generic (no oracle)

Files:
- internal/serve/auth.go (new): authGate middleware, session
  management, login handler, rate limiter
- internal/serve/auth_test.go (new): 28 tests covering token,
  password, session HMAC, rate limiting, IP extraction
- internal/serve/login.html (new): dark-themed login page
- internal/config/config.go: add ServeConfig struct
- internal/serve/serve.go: integrate auth into middleware chain
- internal/cli/cli.go: --auth, --token, --password, --hash-password,
  --behind-proxy flags
- i18n: updated serve help text (en, zh, zh-TW)
- reasonix.example.toml: [serve] section
2026-06-23 15:52:13 +08:00
SivanCola 0cfc3f021d Merge pull request #5141 from SivanCola/codex/fix-tab-build-context
Preserve tab plugin context after boot / 保留标签页插件上下文
2026-06-23 15:09:53 +08:00
SivanCola 6279485ad7 fix(desktop): preserve tab plugin context after boot 2026-06-23 15:01:54 +08:00
YHH 8e9d1c41fd fix(bot): close the getOrCreateSession check-create race (#5140)
getOrCreateSession released gw.mu between the "does this key exist?" check and
the registration of the freshly built controller, with boot.Build (a slow,
MCP-connecting operation) in between. Two near-simultaneous opening messages
for the same chat could both miss the cache, both build a controller, and the
second registration would overwrite the first in gw.controllers — orphaning
(leaking) the first controller's jobs and plugin host.

Re-check under the second lock: if another goroutine already registered a
session for the key, close the controller we just built (the same teardown the
eviction path uses) and reuse the existing one. The first writer wins.

Build + vet clean; -race bot suites and full go test ./... stay green. The race
is structural (TOCTOU around an un-injectable boot.Build), so it's fixed by the
standard double-check rather than a new unit test.
2026-06-23 15:00:12 +08:00
YHH 9349ea6916 refactor(bot): wire feishu onto the connloop primitives (#5139)
Replace feishu's hand-rolled reconnect loop with RunWithRetry, completing the
connloop adoption across all three adapters (#5136, #5137).

The attempt keeps the SDK's blocking client.Start in an off-loop goroutine so
a ctx cancellation closes the client immediately rather than waiting for Start
to notice — behavior identical to before. What changes is the outer reconnect
wait: the manual `time.NewTimer(5s)` becomes RunWithRetry's cancellation-aware
exponential backoff (1s→30s). The SDK's own WithAutoReconnect still handles
in-connection blips; RunWithRetry is the outer "SDK fully exited" fallback.

-race bot suites and full go test ./... stay green.
2026-06-23 14:54:56 +08:00
YHH c13a3d1054 refactor(bot): wire qq and weixin onto the connloop primitives (#5137)
Replace the two adapters' hand-rolled connection loops with the shared
primitives from #5136.

qq: gatewayLoop's `for { check ctx; getToken; connectGateway; sleep 5s on
error }` becomes a RunWithRetry call whose attempt is getToken+connectGateway
(already a blocking, connection-lifetime call). This swaps the fixed,
ctx-ignoring `time.Sleep(5s)` for a cancellation-aware exponential backoff
(1s→30s) — so Stop() now takes effect promptly instead of blocking out the
remaining delay, and a flapping gateway no longer hard-reconnects every 5s.

weixin: pollLoop is a polling adapter, not persistent-connection, so it keeps
its loop but swaps its three time-based waits (2s startup, 5s error, 500ms
idle) for SleepCtx — gaining prompt cancellation without changing the delays
or the poll semantics.

Behavior-preserving except for the intended backoff/cancellation improvements.
-race bot suites and full go test ./... stay green.
2026-06-23 14:49:11 +08:00
YHH 806a3df631 feat(bot): add shared connection-lifecycle primitives (connloop) (#5136)
The three IM adapters (qq/feishu/weixin) each hand-roll the same "loop
forever, attempt a connection, wait a fixed 5s on failure" shape. Two of
them (qq, weixin) wait with time.Sleep, which ignores ctx — so a Stop()
stays blocked for the remaining delay and the backoff is uncancellable.
None of the three backs off exponentially.

This adds the shared primitives the adapters will delegate to (no existing
adapter is changed yet — that's the follow-up PRs):

- SleepCtx(ctx, d): a cancellation-aware sleep; returns false if ctx is
  cancelled mid-wait. Replaces the raw time.Sleep in the adapter loops so
  Stop() takes effect promptly.
- RunWithRetry(ctx, log, name, cfg, attempt): drives a persistent-connection
  adapter (qq/feishu) — runs attempt (one connection lifetime, blocking),
  then reconnects after a cancellation-aware exponential backoff (1s→30s,
  reset after 60s healthy), until ctx is cancelled.

Fully unit-tested (cancellation, retry count, backoff cap, defaults) and
race-clean. Pure addition; the full suite stays green.
2026-06-23 14:43:12 +08:00
SivanCola 948b2b45d2 Merge pull request #5101 from lifu963/pr/event-wire-contract
统一事件 wire 契约
2026-06-23 14:41:25 +08:00
SivanCola f3525db383 Merge pull request #5133 from SivanCola/fix/single-surface-review-followup
Harden single-surface tab pruning / 加固单界面会话剪枝
2026-06-23 14:34:25 +08:00
GTC2080 1466e44233 fix: surface desktop error states 2026-06-23 16:29:48 +10:00
SivanCola 99c09ae7e3 fix(desktop): harden single-surface tab pruning 2026-06-23 14:29:25 +08:00
Sivan 0ef88e9064 Handle retrying events in serve UI 2026-06-23 14:27:42 +08:00
GTC2080 5fcae79fa0 fix: stabilize desktop session and cancel flows 2026-06-23 16:27:20 +10:00
YHH e17f31b76e refactor(cli): split the paste/image input layer out of chat_tui.go (#5130)
chat_tui.go is the cli's largest god-file (3920 lines). Move its
self-contained paste & image-attachment concern — folding long pasted text
into a deletable [Pasted text #N] token, turning dragged/pasted images and
file paths into @references, and the clipboard commands behind them — into a
new chat_tui_paste.go.

This is a file-level separation of concerns, not a struct change: the
composer state (pastedBlocks / nextPasteID / pendingPastes) stays on chatTUI;
the functions (mostly free, with minimal model coupling) move verbatim. The
two non-paste methods that were interleaved in the region (chooserTyping,
growInputToFit) stay put.

chat_tui.go: 3920 -> 3659 lines; new chat_tui_paste.go is 280. Behavior is
unchanged — same package, same identifiers, cross-file calls resolve as
before; the now-unused net/url, path/filepath and clipboard imports drop from
chat_tui.go. The cli suite and full go test ./... stay green.
2026-06-23 14:21:27 +08:00
SivanCola 3e520a9b2d Merge pull request #5126 from GTC2080/GTC/fix-command-palette-esc-close
Fix command palette escape close button / 修复命令面板 Esc 关闭按钮
2026-06-23 14:15:13 +08:00
SivanCola 0987f47623 Merge pull request #5129 from SivanCola/fix/single-surface-tab-persistence
Fix single-surface tab persistence / 修复单界面布局隐藏会话持久化
2026-06-23 14:09:50 +08:00
SivanCola bf159d0b56 fix(desktop): prune hidden tabs in single-surface layouts 2026-06-23 14:05:01 +08:00
YHH b16dba2dbb refactor(control): extract skillSet from Controller (#5127)
Group the session's discovered-skill fields out of the Controller god object
into a skillSet value type — the skills slice of the Capabilities concern,
alongside the mcpManager extracted in the prior step.

skillSet owns the enabled subset, the full set (including config-disabled
ones), and the optional reloadable stores that supersede the
construction-time snapshots. It is read-only (no lock): skills are discovered
at boot and never mutated in place, so unlike the stateful managers it just
consolidates four related fields and their lookup logic behind list /
listAll / byName / discovered.

- New internal/control/skill.go holds the type.
- Controller drops skills/allSkills/skillStore/allSkillStore for a single
  `skills skillSet`; Skills/AllSkills delegate, RunSkill resolves via
  byName (the redundant skillByName method is removed), and the /skills
  listing keeps reading the construction-time snapshot via discovered() so
  its output is unchanged. The public SessionAPI surface is unchanged.

controller.go: 3358 -> 3346 lines, 41 -> 38 fields. Behavior-preserving;
the -race suite and full go test ./... stay green.
2026-06-23 13:46:18 +08:00
GTC2080 3bafeb7bbb fix: make command palette escape key clickable 2026-06-23 15:32:51 +10:00
YHH 8c6a425e78 refactor(control): extract mcpManager from Controller (#5123)
Carve the live tool/plugin surface out of the Controller god object into an
mcpManager collaborator, following the goalMachine/approvalManager pattern.

mcpManager owns the MCP plugin Host (live server connections), the tool
registry the executor reads each turn, and the session-scoped context a
hot-added stdio server binds its subprocess to — behind its own lock, off
c.mu. Following the approvalManager precedent, only the live plumbing moves;
the Controller keeps the config-facing orchestration (persisting
reasonix.toml on add/remove, building specs from entries).

This also closes a latent race: the lazy `host = plugin.NewHost()` creation
was previously an unsynchronized field write; it now happens under the
manager's lock (the host pointer, once set, never reverts to nil).

- New internal/control/mcp.go holds the manager (connectSpec / disconnect /
  removeToolPrefix / suspendToolPrefix / registerTool / serverNames /
  hasServer / prompts / failures / readResource / hostRef).
- Controller drops the host/reg/pluginCtx fields for a single `mcp
  mcpManager`; AddMCPServer/RemoveMCPServer/Disconnect/Import/ReloadCommands/
  Host and the input/refs/slash readers delegate. The dead containsString
  helper goes with hasServer. The public SessionAPI surface is unchanged.

controller.go: 3417 -> 3358 lines, 43 -> 41 fields. Behavior-preserving;
the -race MCP tests and the full suite stay green.
2026-06-23 13:30:17 +08:00
qiukeren d519a7620e fix(cli): show current version in upgrade success message
The upgrade success format was only printing the latest version
instead of both current and latest versions. Fixed by passing the
correct arguments to the format string.
2026-06-23 13:26:02 +08:00
SivanCola edc500849d Merge pull request #5114 from esengine/fix/nonblocking-session-switch
Make desktop session switching non-blocking / 桌面会话切换改为非阻塞
2026-06-23 13:24:56 +08:00
SivanCola 158eeffcde Merge pull request #5117 from SivanCola/fix/provider-credential-followups
Fix provider credential follow-ups / 修复 provider 凭据后续问题
2026-06-23 13:24:13 +08:00
Sivan 12b64da913 Remove unused desktop credentials path 2026-06-23 13:11:33 +08:00
Sivan b53310f214 Guard session switch races 2026-06-23 13:07:36 +08:00
Sivan 1d564bd4d5 Scope project dotenv expansion 2026-06-23 13:06:08 +08:00
lifu963andCursor 0028c4883f Cover checkpoint ordering through public behavior
Avoid coupling the orchestrator test to Controller checkpoint internals so it survives the checkpoint manager extraction.

Co-authored-by: Cursor <[email protected]>
2026-06-23 12:57:02 +08:00
lifu963andCursor da5b171fc4 Cover checkpoint ordering in turn orchestration
Add a focused test proving the orchestrator records checkpoint boundaries before the user message and still snapshots activity.

Co-authored-by: Cursor <[email protected]>
2026-06-23 12:55:27 +08:00
lifu963andCursor 92b6b4ae4e Cover reference display separation in turn orchestration
Add a focused regression test proving resolved @file context reaches the model while the display recorder keeps the visible prompt.

Co-authored-by: Cursor <[email protected]>
2026-06-23 12:55:27 +08:00
lifu963andCursor e72e5020ed Cover foreground turn concurrency semantics
Lock down the existing sync and async already-running behavior so orchestrator extraction cannot interleave session writes.

Co-authored-by: Cursor <[email protected]>
2026-06-23 12:55:27 +08:00
lifu963andCursor ff55d5c0e3 Move foreground turn lifecycle into orchestrator
Delegate controller turn execution to the orchestrator so plan approval and goal continuation share one focused lifecycle boundary.

Co-authored-by: Cursor <[email protected]>
2026-06-23 12:55:27 +08:00
lifu963andCursor 4a46b81b9a Introduce turn orchestrator boundary
Add a minimal foreground-turn orchestrator seam with a tracer test so follow-up lifecycle extraction has a focused target.

Co-authored-by: Cursor <[email protected]>
2026-06-23 12:55:27 +08:00
Sivan 2163c0889b Remove unused credential migration wrapper 2026-06-23 12:31:09 +08:00
YHH 656862d775 refactor(control): extract checkpointManager and rename cpRoot (#5116)
Carve the snapshot-based rewind bookkeeping out of the Controller god
object into a checkpointManager collaborator, following the
goalMachine/approvalManager pattern, and disentangle the overloaded cpRoot
field.

checkpointManager owns the per-session checkpoint store, the monotonic turn
counter, and the conversation-rewind boundary map behind its own lock —
moving that state off c.mu, so a boundary read for a rewind/fork no longer
contends on the run-state lock. Every store call does its disk I/O off the
lock. Following the approvalManager precedent, only the bookkeeping moves;
the Controller keeps the rewind/fork/summarize orchestration (truncating the
session, restoring code, emitting events) that needs its collaborators.

cpRoot was never checkpoint-specific — it is the workspace root used for
@-ref resolution, "!" shell cwd, custom-command discovery and the restore
guard. Rename it to workspaceRoot so the remaining field reads true.

- New internal/control/checkpoint.go holds the manager.
- Controller drops cp/cpRoot/cpTurn/cpBound for workspaceRoot +
  checkpoints; rebind/begin/Rewind/Fork/summarize/CheckpointHasBoundary
  delegate the state ops. The public SessionAPI surface is unchanged.

controller.go: 3455 -> 3417 lines, 45 -> 43 fields. Behavior-preserving;
the -race rewind/fork/concurrency tests and the full suite stay green.
2026-06-23 12:30:30 +08:00
Sivan 2dd6fc6ddf Fix provider credential follow-ups 2026-06-23 12:27:23 +08:00
Sivan e6fc472145 Fix nonblocking desktop session switching 2026-06-23 12:17:58 +08:00
YHH b093abac8f refactor(control): extract memoryManager from Controller (#5113)
Carve the memory subsystem out of the Controller god object into a strict
leaf collaborator, following the goalMachine/approvalManager pattern.

memoryManager owns the loaded snapshot, the pending turn-tail notes queue,
and write serialization behind its own locks (mu for the snapshot pointer
and pending queue, writeMu to serialize the write+reload off mu) — entirely
off c.mu, so a memory-panel save never stalls an approval or status poll.

- New internal/control/memory.go holds the manager and all memory logic.
- Controller drops the mem/memMu/pendingMemory fields for a single
  `memory memoryManager`; QuickAdd/SaveDoc/SaveMemory/ForgetMemory/
  QueueMemory/Memory become one-line delegations. The public SessionAPI
  surface is unchanged, so no frontend is touched.
- Compose drains pending notes via the manager; the /memory listing reads
  the snapshot through it.

controller.go: 3561 -> 3455 lines, 47 -> 45 fields. Behavior-preserving;
the -race memory concurrency test and the full suite stay green.
2026-06-23 12:17:43 +08:00
SivanCola a2911e81e0 Merge pull request #5110 from esengine/fix/provider-global-env
Use global .env for provider credentials / 使用全局 .env 管理 provider 密钥
2026-06-23 12:00:12 +08:00
Sivan bc4e826a2b Close legacy keyring connections on Unix 2026-06-23 11:44:36 +08:00
SivanandBrunswickTabby 7e7c7eda09 Use global env for provider credentials
Co-authored-by: BrunswickTabby <[email protected]>
2026-06-23 11:38:06 +08:00
SivanCola 5bac71fd7b Merge pull request #5106 from esengine/fix/long-stream-interruptions
Fix interrupted stream recovery / 修复长任务断流恢复
2026-06-23 11:12:02 +08:00
Sivan 6de3bcbab3 Fix recovery for interrupted model streams 2026-06-23 11:04:53 +08:00
lifu963andCursor 5eec1c8733 Update desktop event wire types
Describe the shared wire fields for MCP surface readiness and cache diagnostics so frontend types match backend event JSON.

Co-authored-by: Cursor <[email protected]>
2026-06-23 10:15:13 +08:00
lifu963andCursor 126acdfa38 Use shared event wire in desktop
Keep desktop tab routing local while reusing the shared event wire contract for the core JSON payload sent to Wails.

Co-authored-by: Cursor <[email protected]>
2026-06-23 10:12:49 +08:00
lifu963andCursor 83cf4dcc4d Use shared event wire in serve
Route SSE event broadcasting through the shared wire contract so retrying and future event kinds use the same JSON mapping as other frontends.

Co-authored-by: Cursor <[email protected]>
2026-06-23 10:10:27 +08:00
lifu963andCursor f3c2695179 Cover event wire payload mapping
Move the observable payload contract into shared eventwire tests so tool, usage, interaction, and lifecycle events keep stable JSON fields.

Co-authored-by: Cursor <[email protected]>
2026-06-23 10:08:39 +08:00
lifu963andCursor 46b78a7dfe Add shared event wire kind coverage
Introduce the shared event wire package with retrying coverage and a mechanical kind boundary so future event kinds cannot silently miss a wire name.

Co-authored-by: Cursor <[email protected]>
2026-06-23 10:06:39 +08:00
SivanCola 7aa97d31ad Merge pull request #5084 from SivanCola/fix/post-merge-bot-findings
Fix post-merge bot review findings / 修复合并后 bot review 问题
2026-06-23 01:49:11 +08:00
SivanCola cb66b4895c Merge pull request #5076 from GTC2080/GTC/fix-short-choice-planner-context
Fix short choice replies in dual-model context / 修复双模型短选项回复上下文
2026-06-23 01:48:09 +08:00
SivanCola 71068eab91 Fix post-merge bot review findings 2026-06-23 01:27:40 +08:00
SivanCola 6ad1df07ff fix: preserve planning for short work requests
Co-authored-by: SivanCola <[email protected]>
2026-06-23 01:23:06 +08:00
SivanCola d26572c27c Merge pull request #5077 from Ninot1Quyi/fix/skill-root-dedupe
fix(desktop): dedupe skill roots in settings
2026-06-23 01:09:45 +08:00
SivanCola a64a6a8aa3 Merge pull request #5000 from jbts6/main-v2
feat: incremental project config writes (surgical section replacement)
2026-06-23 01:09:00 +08:00
SivanCola 76c8a07e57 fix(desktop): keep deduped skill roots active
Treat any already-rendered canonical skill root as active when deciding whether a configured user path needs an inactive placeholder. This prevents configured project convention roots from being shown twice after duplicate root merging.

Tested: cd desktop && go test . -run 'TestSkillRootsView(DedupesConfiguredConventionRoot|DedupesConfiguredProjectConventionRoot|CountsProjectSkills|MarksEnvConfiguredCustomRoot|OmitsExcludedConventionRoot)|Test(AddSkillPathRestoresConventionRootWithoutCustomPath|RemoveSkillPathPseudoDeletesConventionRoot)' -count=1

Tested: git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-06-23 01:04:41 +08:00
SivanCola 0173f3a913 Handle plugin removal in project config saves
Ensure incremental project saves can remove stale plugin entries when the runtime config no longer contains project plugins. This prevents lazy MCP uninstall/remove flows from leaving old [[plugins]] tables behind.

Co-authored-by: SivanCola <[email protected]>
2026-06-23 01:04:08 +08:00
SivanCola f10a456613 Merge pull request #5082 from SivanCola/fix/integrate-ask-user-decisions
Integrate ask user-decision fixes / 整合用户选择 ask 修复
2026-06-23 01:02:17 +08:00
SivanColaandGTC2080 658bc7ce46 Integrate ask user decision fixes
Co-authored-by: GTC2080 <[email protected]>
2026-06-23 00:55:15 +08:00
SivanCola dcf6e19126 Merge pull request #4983 from paradoxSCH/fix/desktop-cli-session-topic-migration
fix(desktop): surface CLI sessions in sidebar
2026-06-23 00:44:56 +08:00
SivanCola b61e4a77bd Fix project config incremental writes
Preserve top-level project config deltas, replace array-table sub-sections as part of their owning entries, and avoid writing merged user settings back into project configs.

Co-authored-by: SivanCola <[email protected]>
2026-06-23 00:43:44 +08:00
SivanCola 2a7852f86a fix(desktop): rescan migrated CLI session dirs
Invalidate the topic migration marker when session files change so CLI-created no-topic sessions added after the first migration pass still surface in the sidebar.

Co-authored-by: SivanCola <[email protected]>
2026-06-23 00:33:59 +08:00
SivanCola 05cf7a8b53 Merge pull request #4981 from ttmouse/pr/session-deletion-perf
perf: 优化对话删除流程 — 前端本地移除 + 锁外写磁盘
2026-06-23 00:33:52 +08:00
quyi 51eea606e7 fix(desktop): dedupe skill roots in settings
Configured convention skill roots can be discovered twice: once as a custom path and once as a built-in convention root. Collapse duplicate canonical paths in the desktop settings view while preserving configured metadata and skill counts.

Constraint: Keep skill discovery priority unchanged; fix only desktop source rendering.
Rejected: Deduping internal skill Store roots | would alter discovery behavior beyond the UI bug.
Confidence: high
Scope-risk: narrow
Directive: Keep duplicate-root handling in the view layer unless product behavior requires changing discovery semantics.
Tested: cd desktop && go test ./...
Tested: go test ./...
Tested: gofmt -l .
Tested: git diff --check
Not-tested: manual Wails desktop UI smoke
2026-06-23 00:29:13 +08:00
GTC2080 07b176ac6d fix: route short choice replies through executor context 2026-06-23 02:27:25 +10:00
SivanCola ce54916648 Merge pull request #5072 from SivanCola/fix/desktop-rewind-plan-prompt-integration
Consolidate desktop rewind and plan prompt recovery / 整合桌面回溯与计划确认恢复
2026-06-23 00:08:32 +08:00
SivanCola 962396cf7e Merge pull request #5073 from SivanCola/feature/desktop-mcp-headers-integration
Support remote MCP headers in desktop / 桌面端支持远程 MCP 请求头
2026-06-23 00:06:51 +08:00
SivanCola ddbbb37d36 Merge pull request #5069 from lifu963/pr/acp-background-job-rebuild-risk
修复 ACP 配置切换取消后台任务
2026-06-23 00:05:01 +08:00
dengbushi e19fbdcc00 fix(desktop): support remote MCP headers 2026-06-23 00:01:52 +08:00
SivanCola 87b40dfe87 fix(desktop): consolidate rewind and plan prompt recovery 2026-06-23 00:00:25 +08:00
SivanCola 6a84a4d3b9 Merge pull request #5065 from Li-Charles-One/feat/creation-tool-card-scrollbar-error
style(creation): 精简工具卡片错误框与代码块滚动条
2026-06-22 23:52:47 +08:00
SivanCola 61ec6a4b75 Use typed ACP config active-work errors
Replace message substring matching with a private typed error marker while preserving the underlying JSON-RPC error code and message.

Co-authored-by: SivanCola <[email protected]>
2026-06-22 23:52:20 +08:00
lifu963andCursor f60c0a74da Cover ACP config retry after background jobs stop
Lock the observable retry path so a rejected config switch can succeed after the session's background jobs finish.

Co-authored-by: Cursor <[email protected]>
2026-06-22 23:17:42 +08:00
lifu963andCursor 33304cbccd Reject ACP config switches during pending prompts
Avoid queueing model or config rebuilds while an ACP session is blocked on user interaction, and cover the legacy set_model path.

Co-authored-by: Cursor <[email protected]>
2026-06-22 23:04:03 +08:00
lifu963andCursor 23280359a0 Discard queued ACP config switches blocked by jobs
When a queued switch cannot rebuild after a turn, report the failure, restore the visible config state, and require a new client request instead of keeping it armed.

Co-authored-by: Cursor <[email protected]>
2026-06-22 23:02:32 +08:00
lifu963andCursor 4997a0f2e1 Reject ACP config switches during background jobs
Prevent idle ACP config rebuilds from releasing a controller that still owns running background jobs.

Co-authored-by: Cursor <[email protected]>
2026-06-22 23:00:01 +08:00
Li-Charles-One 917e036e65 style(creation): 精简工具卡片错误框与代码块滚动条
仅限 .app--creation 作用域,classic/workbench 不受影响:

- 工具错误信息 .tool__err 去掉红底块、清左缩进、左对齐,降低视觉干扰

- 工具体代码块 .tool__body .code 底部横滚动条隐藏滑道、只留 4px 细滑块

滚动条需先把 scrollbar-width 重置为 auto 以抵消全局 *{scrollbar-width:thin},

否则 WebView2 下 ::-webkit-scrollbar 伪元素被忽略;写法对齐已有的 .transcript 滚动条。
2026-06-22 21:51:44 +08:00
Zhuocheng-Lang 11b6c52bf8 fix(desktop): apply NVIDIA Wayland explicit-sync workaround for WebKitGTK
On KDE Plasma Wayland with NVIDIA GPUs, the WebKitGTK webview crashes at
startup (Error 71: Protocol error) due to an upstream WebKit explicit-sync
bug (WebKit #280210, #317089). ANGLE advertises wp_linux_drm_syncobj but
fails to set an acquire point before committing the buffer, violating the
Wayland protocol.

Set __NV_DISABLE_EXPLICIT_SYNC=1 automatically when all three conditions
are true: Wayland session detected, NVIDIA GPU present (/sys/module/nvidia),
and the user hasn't already set the variable. This preserves GPU acceleration
(unlike WEBKIT_DISABLE_DMABUF_RENDERER=1) and keeps the native Wayland
session (unlike GDK_BACKEND=x11).

Closes #4700.
2026-06-22 21:32:56 +08:00
SivanCola f2a475a2a5 Merge pull request #5044 from SivanCola/feature/crash-stats-default-30d
Default crash stats to 30d / 默认崩溃统计为 30 天
2026-06-22 18:29:56 +08:00
Sivan 05d2d209a8 Default crash stats window to 30d 2026-06-22 18:26:17 +08:00
SivanCola b9fc0a51ea Merge pull request #5042 from SivanCola/fix/session-trash-sidecars
Fix session trash sidecars / 修复会话回收站旁路文件
2026-06-22 18:19:55 +08:00
SivanCola b11f474de1 Merge pull request #5041 from SivanCola/feature/crash-stats-navigation
Improve crash stats navigation / 改进崩溃统计导航
2026-06-22 18:18:59 +08:00
Sivan 9eff550330 Improve crash stats navigation 2026-06-22 18:12:20 +08:00
Sivan 47166a3792 fix(desktop): keep session sidecars with trash 2026-06-22 18:12:03 +08:00
SivanCola 287d0804b9 Merge pull request #5034 from SivanCola/fix/transcript-reveal-signal-i18n
Fix transcript reveal signal and jump label / 修复正文 reveal 信号与跳底部文案
2026-06-22 17:32:51 +08:00
YHH 82f89adb5a ci(crash): auto-deploy crash worker on merge to main-v2 (#5036)
Manual wrangler deploy was the only path to ship crash.reasonix.io
changes; gate deploys on workers/crash-report changes so merging a PR
publishes the worker without a maintainer holding the Cloudflare token.
Needs the CLOUDFLARE_API_TOKEN repo secret.
2026-06-22 17:29:23 +08:00
Sivan 48f7af9bbb Fix transcript reveal signal and jump label 2026-06-22 17:01:37 +08:00
SivanCola 7eb4f2e650 Merge pull request #5029 from SivanCola/fix/transcript-scroll-bottom-reliability
Fix transcript bottom pinning / 修复对话滚动到底部定位
2026-06-22 16:47:49 +08:00
Sivan f7a47d4a6d fix(scroll): keep transcripts pinned on reveal 2026-06-22 16:38:38 +08:00
SivanCola 4fbc385e2e Merge pull request #5023 from SivanCola/fix/path-ref-containment
Fix path containment for image file references / 修复图片文件引用路径边界
2026-06-22 15:58:41 +08:00
Sivan 1e7a4f10f3 fix(control): clarify unscoped image refs 2026-06-22 15:52:13 +08:00
SivanCola bf3168e012 Merge pull request #5024 from esengine/chore/consolidate-dependabot-updates
Consolidate Dependabot dependency updates / 合并 Dependabot 依赖升级
2026-06-22 15:48:56 +08:00
Sivan b71d11cf5a chore(deps): consolidate frontend and Go updates 2026-06-22 15:44:08 +08:00
Sivan d68ed3b8a6 fix(control): contain image file references 2026-06-22 15:39:56 +08:00
dependabot[bot] d3dd9189cb chore(deps): bump actions/checkout from 6 to 7 in the actions group
Bumps the actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-06-22 15:38:42 +08:00
dependabot[bot] 2392e4936f chore(deps): bump astro from 6.4.6 to 6.4.8 in /site in the npm group
Bumps the npm group in /site with 1 update: [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro).


Updates `astro` from 6.4.6 to 6.4.8
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/[email protected]/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/[email protected]/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-version: 6.4.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-06-22 15:38:42 +08:00
SivanCola b535111280 Merge pull request #5020 from eghrhegpe/pr-fix-openai-omitempty
fix(openai): add omitempty to chatFunction description/parameters
2026-06-22 15:32:02 +08:00
Sivan 559b71d772 test(openai): cover omitting empty tool fields 2026-06-22 15:22:42 +08:00
eghrhegpe e36dd55905 fix(openai): add omitempty to chatFunction description/parameters to fix strict gateway rejection 2026-06-22 15:15:48 +08:00
SivanCola 8dbb673f51 Merge pull request #5018 from SivanCola/fix/model-switcher-compact-label
Compact model switcher trigger / 精简模型切换按钮
2026-06-22 14:55:18 +08:00
Sivan d692e25ea4 fix(desktop): keep model switcher trigger compact 2026-06-22 14:49:59 +08:00
YHHanddb 4da0a184c3 feat: add /reload-cmd command for hot-reloading custom slash commands without restart (#5016)
Adds a /reload-cmd command that rescans .md command files on disk and
hot-swaps the slash_command tool in the Registry plus the Controller's
internal commands slice — no MCP restart, no hook rerun, no Controller rebuild.

Implementation approach (lightweight hot-reload):
- Controller.commands changed from []command.Command to atomic.Pointer
  for lock-free thread safety
- Controller.ReloadCommands() rescans disk via command.Load(), rebuilds
  the slash_command tool with current skills preserved, and atomically
  swaps both the Registry entry and the commands slice
- Three dispatch paths: CLI TUI (runSlashCommand), Desktop/HTTP
  (managementNotice), and Desktop App bridge
- i18n support for zh/zh_tw/en locales
- All test suites pass

Closes #4840

Co-Authored-By: Claude <[email protected]>

# Conflicts:
#	internal/control/controller.go

Co-authored-by: db <[email protected]>
2026-06-22 14:20:18 +08:00
SurfireandSurfire bb4dbca0e0 fix(control): attach workspace images to vision turns (#4737)
Co-authored-by: Surfire <[email protected]>
2026-06-22 14:08:03 +08:00
eghrhegpe c29a39cd9a feat(agent): add grace round when tool-call budget is exhausted (#4869)
* feat(agent): add grace round when tool-call budget is exhausted

* fix: restore HasSuccessfulTodoProgressReceipt guard in finalReadinessCheck
2026-06-22 14:07:00 +08:00
liaoyl830andliaoyl830 94817d4c9d fix(control): strip referenced context prefix for title generation (#4954) (#4998)
When the user @-references files or resources, the controller injects a
"Referenced context:" preamble followed by XML reference blocks
(<file>, <dir>, <resource>, <image>) before the user's actual input.
This caused the session title to display as "Referenced context..."
instead of the user's actual message.

Add StripReferencedContextPrefix() to remove the preamble and reference
blocks, then apply it in topicTitleFromSession() so the title matches
what the user typed.

Fixes: #4954

Co-authored-by: liaoyl830 <[email protected]>
2026-06-22 12:08:34 +08:00
cyq eb9200f5a5 fix(plugin): persist lazy MCP schema cache (#4878) 2026-06-22 12:08:29 +08:00
SivanCola 043e618342 Merge pull request #4999 from liaoyl830/fix-4956-trash-session
fix(desktop): add copy+remove fallback for session trash move on Windows
2026-06-22 09:47:32 +08:00
SivanCola f1532d55dc Merge pull request #4974 from Li-Charles-One/feat/creation-tool-flow
style(desktop): add Creation tool flow visuals
2026-06-22 09:46:25 +08:00
SivanCola f853ac3c02 Scope Creation status block rendering 2026-06-22 09:32:16 +08:00
SivanCola d3c5f671f5 Fix session trash fallback symlink handling
Preserve rename semantics in the copy+remove fallback by using lstat-based dispatch and recreating symlinks instead of dereferencing them.

Add a regression test for symlinks inside copied session artifact directories.

Co-authored-by: SivanCola <[email protected]>
2026-06-22 09:29:45 +08:00
SivanCola 051239b633 Merge pull request #5005 from SivanCola/feature/desktop-shortcuts-integration
Integrate desktop shortcut improvements / 整合桌面快捷键改进
2026-06-22 02:08:26 +08:00
SivanCola faacd02423 Document desktop shortcut updates 2026-06-22 02:04:38 +08:00
SivanCola 1b15d6a25c Merge pull request #4985 from ttmouse/pr/fix-panel-spacing-header-bg
fix(ui): 修复右侧面板分隔线间距和对话头部浅色模式背景
2026-06-22 02:01:39 +08:00
SivanCola 28325fce0e Fix integrated desktop shortcut conflicts 2026-06-22 01:55:55 +08:00
ttmouse 037cabee13 fix: Cmd+K 命令面板需按两次才能打开 + 焦点和选中状态修复
- App.tsx: setPaletteOpen 回调 return current → return !current,修复首次按键无效
- CommandPalette.tsx: requestAnimationFrame → useLayoutEffect + callback ref,修复 input 焦点丢失
- CommandPalette.tsx: active 初始值 0 → -1,修复打开面板时已有选中项
2026-06-22 01:51:17 +08:00
ttmouse 7ab2f40411 feat: 互换 ⌘B 快捷键 — ⌘B 切换侧边栏,⌘⇧B 切换 Shell
- 将 ⌘B (Ctrl+B) 从 shell.toggle 改为 sidebar.toggle
- 将 ⌘⇧B (Ctrl+Shift+B) 从 sidebar.toggle 改为 shell.toggle
- 新增 sidebar.toggle 快捷键注册到 App 组件
- 更新 en/zh/zh-TW 国际化文本
2026-06-22 01:51:17 +08:00
ttmouse ba7b19e26a fix: 移除 capture-phase 监听器,恢复 Cmd+↑/↓ 原生滚动行为 2026-06-22 01:51:17 +08:00
ttmouse 50313c8d15 fix(sidebar): PR review 修复 — 折叠子话题跳过编号 + 移除 Cmd+0 + 输入框内显示标记
1. 折叠的文件夹内子话题不再占用快捷键编号(renderNode 增加 isVisible
   参数,折叠时传 false)
2. 移除 Cmd+0 / topic.goto.0,仅保留 Cmd+1-9
3. 输入框内按 Cmd 也显示快捷键标记(移除 editable element 检查)
2026-06-22 01:51:16 +08:00
ttmouse 1b253a0ee2 fix(sidebar): 创作模式下快捷键标记显示时隐藏时间标签
CSS 仅对 .sidebar--workbench 生效,创作模式(.app--creation)下
topic-side 时间标签未被隐藏。补充 .app--creation 选择器。
2026-06-22 01:51:16 +08:00
ttmouse 13a5bfdd6c feat(sidebar): Cmd+1-10 快捷键接入设置面板
将 topic.goto.1-0 注册到 SHORTCUT_DEFINITIONS,设置面板中展示
「转到聊天 1-10」及对应键位(⌘1-⌘0)。configurable: false,
不可自定义绑定。

- keyboardShortcuts.ts: 新增 10 个 topic.goto.* action 定义
- SettingsPanel: 支持 configurable === false 时禁用录制按钮
- en/zh/zh-TW: 添加快捷键标签和描述文案
2026-06-22 01:51:16 +08:00
ttmouse 05f2c32388 fix(sidebar): 输入框内也允许 Cmd+1-10 跳转话题
移除 editable element 焦点检查,Cmd+1-10 在任何焦点状态下均可触发。
Cmd+数字在 textarea 中本身无默认行为,不会干扰输入。
2026-06-22 01:51:16 +08:00
ttmouse b977698273 fix(sidebar): Cmd+1-10 快捷键不再等待标记显示即可触发
移除 showTopicBadges 对导航逻辑的 gate,Cmd+1-10 在 Cmd 按下的
瞬间即可触发跳转,无需等待 250ms 标记出现。标记显示保持延迟,
仅作为新用户发现功能的视觉辅助。
2026-06-22 01:51:16 +08:00
ttmouse 83ceb82290 fix(sidebar): PR review 修复 — 类型复用、编辑器焦点保护、死代码清理
- 复用 TopicShortcutEntry 类型替代 App.tsx/ProjectTree.tsx 中的内联类型
- Cmd+1-10 handler 增加 editable element 检查,编辑器内不拦截
- 移除已废弃的 flattenVisibleTopics / MAX_SHORTCUT_TOPICS / 无用 import
2026-06-22 01:51:16 +08:00
ttmouse 5026ccdb67 fix(sidebar): 快捷键导航顺序与侧边栏实际渲染顺序对齐
之前用 BFS 遍历项目树来确定快捷键对应的对话,没有考虑搜索筛选、
时间筛选、排序、置顶、折叠等状态,导致快捷键和展示对不上。

改为 ProjectTree 在渲染时收集实际可见话题列表,通过
onVisibleTopicsChange 回调上报给 App.tsx,Cmd+1-10 直接从该列表
取对应项进行导航,保证快捷键顺序与视觉顺序完全一致。
2026-06-22 01:51:16 +08:00
ttmouse c9c1e2de92 feat(sidebar): Cmd+1-10 快捷键跳转侧边栏对话
长按 Cmd/Ctrl 键后,侧边栏对话列表右侧会显示对应的快捷键标记(⌘1-⌘0),
松开后消失。按住期间按下对应数字键即可快速跳转到该对话。

- 新增 useTopicShortcuts hook:检测 Cmd/Ctrl 长按并控制标记显示
- ProjectTree:接受 showShortcutBadges prop,渲染快捷键标记
- App.tsx:注册 Cmd+1-10 全局快捷键,调用 ListProjectTree 获取对话列表
- styles.css:快捷键标记样式(圆角矩形,淡色背景)
2026-06-22 01:51:16 +08:00
SivanCola 27b5880041 chore: remove style trailing whitespace 2026-06-22 01:44:48 +08:00
jbts6 2419ca27ea feat: incremental project config writes (surgical section replacement)
SaveTo now writes only delta sections/fields for project-level reasonix.toml,
preserving existing file content. User configs (full template) unchanged.

- RenderTOMLProjectDelta: generates only non-default sections/fields
- replaceTOMLSection: handles both [section] and [[section]] formats
- saveProjectIncremental merges delta into existing file
2026-06-21 22:40:46 +08:00
liaoyl830 3208664815 fix(desktop): add copy+remove fallback for session trash move (#4956)
On Windows, os.Rename can fail when moving session directories to trash
if file handles are briefly held open (antivirus, indexing, etc.). This
caused 'move to trash' to fail for sessions with conversation history.

Add a fallback path: when os.Rename fails with a cross-device or
file-busy error, fall back to copy-then-remove. This handles:
- Cross-device moves (EXDEV)
- Windows sharing violations (ERROR_SHARING_VIOLATION)

The fallback recursively copies the directory tree, then removes the
source. A brief sleep (10ms) after close ensures Windows has time to
release file handles before removal.

Fixes: #4956
2026-06-21 21:27:17 +08:00
ttmouse ff45677e29 test: 同步 topicbar background 断言到 var(--bg-elev)
PR #4985 将 topicbar 的 background 从 var(--chat-bg) 改为 var(--bg-elev),
app-chrome-tabs.test.ts 中的 contract 测试需要同步更新断言值。
2026-06-21 19:19:10 +08:00
ttmouse a2072126d8 fix(ui): 修复右侧面板分隔线间距和对话头部浅色模式背景
- grid 从 4 列改为 3 列,去掉独立的 resizer 列
- resizer 与 dock 同列(grid-column:3),justify-self:start 紧贴列左侧
  dock 的 border-left:1px solid var(--border-soft) 作为视觉分割线,
  resizer 1px 宽叠在上面,hover 变 accent 蓝色
- ::before 左右各 3px 做不可见拖拽热区,::after 隐藏
- topicbar 所有 background 规则统一为 var(--bg-elev),
  包括基础 / override / data-theme-style / chrome-hidden 四种情况,
  浅色模式下 = #ffffff 纯白
2026-06-21 18:31:13 +08:00
paradoxSCH 01613306d0 fix(desktop): surface CLI sessions in project tree 2026-06-21 18:23:25 +08:00
ttmouse a9111d1174 fix: deleteSession 失败时不从历史面板移除
codex review: 当 app.DeleteSession 因验证/snapshot/I/O 失败时,
deleteSession hook 之前的 .catch(() => {}) 吞掉了错误,
导致本地 filter 仍移除了条目,但后端未实际删除。

修复:
1. useController.ts: 去掉 deleteSession 的 .catch(() => {}),
   让错误正常传播
2. App.tsx: onDeleteSession 用 try/catch 包裹,仅在成功后
   执行本地 state 过滤,失败时保持历史面板不变
2026-06-21 17:41:31 +08:00
ttmouse 8bdc4d35b9 perf: 优化对话删除流程 — 前端本地移除 + 锁外写磁盘
优化点:

1. 前端 onDeleteSession: 删除后不调用 listSessions() 全量重拉,
   改为从当前 state 中直接 filter 掉被删除的会话,省掉一次 Wails
   IPC 调用 + 后端全量扫描所有 .jsonl 文件的开销。

2. removeSessionRuntimeBindings / removeTopicRuntimeBindings:
   将 saveTabsLocked() 拆分为锁内收集 + 锁外写入,磁盘 I/O
   (序列化 JSON + 写文件) 不再在持有 a.mu 大锁期间进行,
   减少对其他并发操作的阻塞。
2026-06-21 17:22:08 +08:00
SivanCola 9ada141766 Merge pull request #4977 from SivanCola/fix/user-level-auto-plan
Make auto-plan user-level only / 自动计划仅按用户级配置生效
2026-06-21 17:18:03 +08:00
Li-Charles-One 6e7f27a73d style(desktop): make Creation settings panel less transparent 2026-06-21 17:16:58 +08:00
SivanCola 2db7acf6c8 fix: rebuild auto-plan classifier on enable 2026-06-21 17:09:58 +08:00
SivanCola 881b2f2f26 Merge pull request #4979 from SivanCola/feature/user-global-step-limits
Make agent step limits user-global / Agent 轮数上限仅按用户全局配置生效
2026-06-21 17:01:13 +08:00
SivanCola 01d9b1730e fix: make auto-plan a user-level setting 2026-06-21 16:58:56 +08:00
SivanCola 62180a99d6 Make agent step limits user-global 2026-06-21 16:54:58 +08:00
SivanCola 79af368a51 Merge pull request #4976 from SivanCola/fix/cancel-batch-results-and-parallel-stop
Preserve cancelled batch results / 保留取消批次结果
2026-06-21 16:39:30 +08:00
SivanCola f9461704d9 fix(agent): preserve cancelled batch results
Persist tool results from cancelled batches before returning, and stop launching new read-only tools once a parallel batch observes cancellation.
2026-06-21 16:33:25 +08:00
SivanCola 803a707f1a Merge pull request #4975 from SivanCola/fix/tui-cancel-escape-integration
Make running turns escapable after cancel / 取消后保持运行中任务可退出
2026-06-21 16:25:16 +08:00
53d515bb36 fix: make running turns escapable after cancel
Combine the TUI escape hatch from PR #4900 with the agent tool-batch cancellation path from PR #4924. The TUI now surfaces cancel-pending state and lets a second Ctrl+C quit, while the agent stops scheduling remaining tools once cancellation is observed.

Co-authored-by: GTC2080 <[email protected]>

Co-authored-by: liaoyl830 <[email protected]>
2026-06-21 16:16:45 +08:00
Li-Charles-One 916457fd26 style(desktop): lighten creation markdown code blocks 2026-06-21 15:55:20 +08:00
Li-Charles-One e8e87e819d style(desktop): refine creation markdown flow 2026-06-21 15:43:59 +08:00
Li-Charles-One b8bd45c9ad style(desktop): group creation tool calls 2026-06-21 15:06:58 +08:00
SivanCola bfe398cc89 Merge pull request #4957 from linze0721/fix/daf48ba5-00c6-4ab1-8150-148d9363260f
兼容ps7
2026-06-21 15:00:33 +08:00
SivanCola 50ff43d8cd merge main-v2 into pwsh compatibility PR 2026-06-21 14:53:34 +08:00
Li-Charles-One c486e3d2ee style(desktop): refine creation tool flow 2026-06-21 14:45:33 +08:00
SivanCola 32addaecfb fix(shell): describe pwsh chaining support 2026-06-21 13:12:41 +08:00
SivanCola 9e56c32768 Merge pull request #4959 from linze0721/fix/b56315f8-ca45-49e0-b42a-8cb97e40e434
会话级的 todo 列表被持久化保存了,即使清空也会被 relaod。
2026-06-21 13:02:10 +08:00
SivanCola 02b25d8bc2 fix(todo): keep archived todo args bounded after clear
Co-authored-by: SivanCola <[email protected]>
2026-06-21 12:48:03 +08:00
SivanCola 027e7dc083 Merge pull request #4961 from SivanCola/fix/mcp-background-autostart
Remove lazy MCP startup mode / 移除 MCP lazy 启动模式
2026-06-21 12:25:40 +08:00
SivanCola 315c95b366 fix(mcp): address startup retry review feedback 2026-06-21 10:33:38 +08:00
SivanCola e5a48d9c77 fix(mcp): add retry all for available servers 2026-06-21 10:20:15 +08:00
SivanCola 7ba4295565 fix(mcp): remove lazy startup mode 2026-06-21 10:09:33 +08:00
Berserk Agent dffb6973cc fix(todo): preserve cleared todo state on reload 2026-06-21 01:19:45 +00:00
Berserk Agent 3569f3a417 fix(sandbox): detect standard pwsh install path 2026-06-21 01:17:14 +00:00
ttmouse b0bc394b03 style: gofmt 格式化 heartbeat.go 和 write_heartbeat/main.go 2026-06-21 03:08:40 +08:00
ttmouse f899bdfc0c fix(heartbeat): 所有 interval 访问加 null 防御
所有 task.interval / draft.interval / prev.interval 访问改为
(task.interval || '') 形式,防止 null/undefined 时 .match()
.includes() .replace() 崩溃。
2026-06-21 02:56:45 +08:00
ttmouse cd08ab4fa8 fix(heartbeat): 防御 interval 为空时 .match() 崩溃
所有 draft.interval.match() 调用改为 (draft.interval || '').match(),
防止 interval 意外为 null/undefined 时抛出异常。
2026-06-21 02:53:36 +08:00
ttmouse c8ce403f8f fix(heartbeat): 切换周期/间隔模式不再污染 interval 值
用 useRef 保存切换前的原始 interval,切回间隔时恢复原值,
避免点一下周期再切回去后 interval 被改成 cycle 格式。
2026-06-21 02:50:00 +08:00
ttmouse eb6d6f4b32 fix(heartbeat): 任务卡片周几列表简化
每周/每两周任务卡片只显示'每周'/'每两周' + 时间,
不列出具体星期几,卡片信息保持简洁。
2026-06-21 02:46:11 +08:00
ttmouse 74c8359bae feat(heartbeat): 任务列表时间展示中文友好
- 简单间隔: '30m' → '每30分钟', '1h' → '每1小时'
- 每日: '24h|daily@22:00' → '每天 22:00'
- 每周: '168h|weekly:mon,wed,fri@09:00' → '每周 周一/三/五 09:00'
- 每两周: '336h|biweekly:mon@09:00' → '每两周 周一 09:00'
- 每月: '720h|monthly:15@09:00' → '每月 15日 09:00'
- 每年: '8760h|yearly:1-1@09:00' → '每年 1月1日 09:00'
2026-06-21 02:44:15 +08:00
ttmouse 0e72bc79dc fix(heartbeat): 解决 Codex review 提出的 P1/P2 问题
P1: 将 approvalMode 设置在 tab 层面而非直接操作 controller,
    使用 SetToolApprovalModeForTab 在打开 tab 后立即设置,
    controller 构建时自动继承,避免 drain 待审批的风险。

P2: normalizeHeartbeatApprovalMode 加 trim/lowercase 处理,
    防止人工编辑 JSON 时大小写/空格导致默认 fallback 到 yolo。
2026-06-21 02:41:50 +08:00
ttmouse b51f31b5c0 fix(heartbeat): 编辑时间隔模式初始化修复
- 给 TaskEditor 加 key={editing.id} 确保切换任务时组件重建
- 给 freqType 初始化加防御性空值检查
2026-06-21 02:35:24 +08:00
ttmouse 4707413f86 refactor(heartbeat): 简化代码
- ALL_WEEKDAYS 改为 WEEKDAYS.map(w => w.key) 派生
- 提取 hasWeekdays 变量消除 3 处重复判断
- 时间窗口内联样式搬到 heartbeat.css
- normalizeHeartbeatApprovalMode 调一次存变量复用
2026-06-21 02:21:08 +08:00
ttmouse 6da94c9498 fix(heartbeat): 新建任务默认 approvalMode=yolo,后端运行后补齐
- handleAdd / startNew 路径显式设置 approvalMode: yolo
- executeTask 中标准化后写回 t.ApprovalMode,确保首次运行后 JSON 有正确值
2026-06-21 02:16:10 +08:00
ttmouse 200cc9895a feat(heartbeat): 按间隔模式新增时间窗口限制
后端:
- HeartbeatTask 新增 TimeWindowStart/End 字段
- heartbeatWithinTimeWindow 判断当前时间是否在窗口内
- heartbeatTaskDueAt 在间隔模式下检查时间窗口

前端:
- 间隔模式下新增起止时间选择器(09:00 — 17:00)
- timeWindowStart/timeWindowEnd 类型定义
- 新增 heartbeat.timeWindow i18n 键
2026-06-21 01:53:51 +08:00
ttmouse dcf1ff80c6 fix(heartbeat): 周选择器移至时间右侧,同行显示 2026-06-21 01:46:31 +08:00
ttmouse d5b084e328 fix(heartbeat): 调整周选择器位置和样式
- 将周一到周日选择器移到时间输入框之后
- 样式改用 set-seg / set-seg__btn 以匹配权限等级选择器风格
2026-06-21 01:33:11 +08:00
ttmouse 0398bf0d13 feat(heartbeat): 周期编辑器中增加周一到周日激活日选择器
- 每日/每周/每两周模式均显示周一到周日按钮,默认全部选中
- 点击可切换选中/非选中状态
- 当每日模式取消部分日期时,自动切换为 weekly 时间格式存储
- 后端已支持 weekly 按日调度,无需改动
2026-06-21 01:23:04 +08:00
SivanCola 91fe06db61 Merge pull request #4949 from SivanCola/fix/remove-mimo-builtins
Remove MiMo built-in guidance / 移除 MiMo 内置引导
2026-06-21 01:19:51 +08:00
SivanCola 85ff986569 Merge pull request #4951 from SivanCola/fix/codebase-memory-mcp-auto-index
Support codebase-memory MCP auto-indexing / 支持 codebase-memory MCP 自动索引
2026-06-21 01:16:52 +08:00
SivanCola d9e453a1be fix(config): migrate MiMo built-ins to custom providers 2026-06-21 01:10:49 +08:00
ttmouse ad1b457b8e chore(heartbeat): write_heartbeat 工具同步新增 ApprovalMode 字段 2026-06-21 01:06:39 +08:00
ttmouse 0ef1321e83 fix(heartbeat): 收紧 approvalMode 类型精度
- heartbeart.types.ts: approvalMode 使用联合类型替代 string
- HeartbeatPanel.tsx: normalizeMode 签名同步收紧
2026-06-21 00:59:41 +08:00
ttmouse cfd92861e2 fix(heartbeat): 权限等级选择器文本走国际化
- 新增 en.ts / zh.ts / zh-TW.ts 中 approval mode 相关 i18n 键
- HeartbeatPanel 中 label、tooltip、hint 使用 t() 替换硬编码中文
2026-06-21 00:58:21 +08:00
SivanCola 45367085bf Support codebase-memory MCP auto-indexing 2026-06-21 00:20:39 +08:00
ttmouse a40b10020c feat(heartbeat): 自动化任务新增权限等级选择
自动化任务创建时支持选择权限等级(Ask/Auto/YOLO),默认 YOLO 模式以避免任务执行时因权限授权导致暂停。

- backend: HeartbeatTask 新增 ApprovalMode 字段,executeTask 时应用到 controller
- frontend: TaskEditor 新增权限等级选择器,使用 set-seg 分段按钮
- types: HeartbeatTask 接口新增 approvalMode 字段
- css: 新增 .heartbeat-editor__mode-hint 样式
2026-06-21 00:10:38 +08:00
SivanCola 49c14762b7 Merge pull request #4942 from SivanCola/fix/sidebar-resilience-integration
Integrate desktop sidebar runtime fixes / 整合桌面侧边栏运行态修复
2026-06-20 23:57:34 +08:00
SivanCola fea81e3aa5 Address desktop sidebar bot review feedback 2026-06-20 23:52:50 +08:00
SivanCola 62a8c3f79e Merge pull request #4938 from SivanCola/fix/pe-handoff-planner-routing
Fix PE handoff and planner routing / 修复 PE 交接与规划路由
2026-06-20 23:38:10 +08:00
e89419af24 Integrate desktop sidebar runtime fixes
Combine the sidebar/session resilience work from related PRs into one integration change.

- Preserve session runtime teardown before trash moves and CloseTab snapshot ordering.
- Reset planner sessions on desktop tab activation changes.
- Add bounded sidebar sidecar reads and safe ListProjectTree fan-out without caching runtime ProjectNode state.
- Keep REASONIX_DEV single-instance bypass while allowing path-scoped release/dev locks.

Co-authored-by: ttmouse <[email protected]>
Co-authored-by: GTC2080 <[email protected]>
Co-authored-by: liaoyl830 <[email protected]>
Co-authored-by: myipanta <[email protected]>
2026-06-20 23:36:03 +08:00
SivanCola b54e8dcaa3 Address PE handoff bot findings 2026-06-20 23:27:29 +08:00
YHH 5d1ad2ae8c Merge pull request #4939 from esengine/refactor/port-desktop
refactor(desktop): migrate the App to the SessionAPI port
2026-06-20 23:11:29 +08:00
esengine dbb6f83acd refactor(desktop): migrate the App to the SessionAPI port
Final frontend on the driving port (after bot #4931, serve+acp #4932, cli #4935).
With this, all five frontends depend on control.SessionAPI instead of the
concrete *control.Controller — the inbound boundary is complete.

The desktop App held the controller as *control.Controller across ~35 sites
(WorkspaceTab.Ctrl, activeCtrl/ctrlByTabID, and many per-ctrl helpers). All now
take control.SessionAPI. cli already forced the port to completeness, so desktop
needed no new methods — every one of its 69 controller calls is already in the
port. The controller is still BUILT concrete (boot.Build) and stored through the
interface; the dedupe maps become map[control.SessionAPI]bool (interface keys are
comparable). Test helpers that accept a controller widen to the interface too;
the constructor helpers keep returning the concrete type.

desktop can't be built locally (cgo -arch toolchain issue), so this was verified
by manual audit — no concrete type assertions/comparisons, no port-bypassing
consumers (serve/acp), all 69 methods in SessionAPI — backed by control's
compile-time SessionAPI assertion. CI's desktop job is the gate.

Next: with all frontends on the port, generate the desktop bridge.ts from
SessionAPI instead of hand-mirroring it.
2026-06-20 08:06:35 -07:00
SivanCola 582f31fd67 Fix PE handoff and planner routing 2026-06-20 23:05:08 +08:00
YHH c202f97035 Merge pull request #4935 from esengine/refactor/port-cli
refactor(control): migrate the cli TUI to the SessionAPI port
2026-06-20 22:44:19 +08:00
esengine 9ae96d8225 refactor(control): migrate the cli TUI to the SessionAPI port
Fourth and largest frontend on the driving port (after bot #4931, serve+acp
#4932). The TUI used ~70 controller methods across every domain, so it validated
the port's completeness — it only surfaced two missing surfaces, now added:

- Input sub-port: Compose, ComposeSynthetic, ResolveRefs, HasRefs
- Settings sub-port: SetReasoningLanguage, SetDisplayRecorder

SessionAPI now composes all eleven sub-ports (asserted against *Controller).

chatTUI.ctrl, modelSwitchMsg.ctrl/oldCtrl, and oldControllers[] are now
control.SessionAPI; the leaf helpers narrow further (fetchBalance → Status,
mcpConnected/recordMCPModePluginFailure → Capabilities). The model-switch
plumbing that builds controllers (setup/setupQuiet, the buildController closure,
acpFactory) stays concrete — construction returns *Controller, which is stored
through the interface. cli never calls InheritLifecycleFrom, so no concrete
escape hatch was needed. No behavior change.

Only desktop remains; once it's on the port the bridge.ts hand-mirror can be
generated from SessionAPI.

Verified: gofmt, go build/vet ./..., go test ./internal/cli + -race ./internal/control.
2026-06-20 07:39:54 -07:00
YHH 2909ef120f Merge pull request #4932 from esengine/refactor/port-serve-acp
refactor(control): migrate serve and acp to the SessionAPI port
2026-06-20 22:30:41 +08:00
esengine 1280c0f759 refactor(control): migrate serve and acp to the SessionAPI port
Continues the driving-port migration (after bot, #4931). Adds the remaining
sub-ports and the full composition, then moves the next two frontends off the
concrete *control.Controller.

port.go: add Goals, SessionHistory, MemoryControl, Capabilities, Status, and
SessionPersistence sub-ports, plus SessionAPI = the composition of all nine.
Compile-time assertions prove *Controller satisfies each.

- serve uses ~37 controller methods spanning every domain, so it depends on the
  full control.SessionAPI (Server.ctrl field, New param, ctl() return). switchModel
  still builds a concrete controller via boot.Build and stores it through the
  interface.
- acp uses ~17 methods across five domains; its acpSession.ctrl and
  availableCommandsFor now take a narrow acpController interface (Lifecycle +
  TurnControl + Approvals + Capabilities + SessionPersistence). The Factory still
  returns the concrete type at construction. InheritLifecycleFrom reads another
  controller's private state, so it stays a concrete construction-wiring call
  (not part of the port) behind a guarded type assertion at its one call site.

Two frontends remain (desktop, cli); once migrated the desktop bridge.ts mirror
can be generated from the port. No behavior change.

Verified: gofmt, go build/vet ./..., go test -race ./internal/control + serve/acp/bot.
2026-06-20 07:25:45 -07:00
YHH 48e5b99067 Merge pull request #4931 from esengine/refactor/control-driving-port
refactor(control): introduce the SessionAPI driving port; migrate bot to it
2026-06-20 22:15:57 +08:00
esengine 3e625b91d9 refactor(control): introduce the SessionAPI driving port; migrate bot to it
Five frontends (cli/desktop/bot/acp/serve) each couple to the concrete
*control.Controller and its ~99 exported methods — there is no inbound boundary,
which is why everything piles into the Controller god object: it's the only thing
a frontend can reach.

Start a segregated driving port in internal/control/port.go: Lifecycle,
TurnControl, and Approvals sub-interfaces, with compile-time assertions that
*Controller satisfies each. A frontend depends only on the sub-ports it uses
(interface segregation) rather than the whole surface. The sub-ports double as
the spec for Controller's own future decomposition — the port leads, the splits
follow.

First adapter migrated: the bot gateway. It used 12 controller methods spanning
exactly those three sub-ports; its renderSink/sessionState fields, locals, and
helpers now take a narrow botController interface (Lifecycle+TurnControl+
Approvals) instead of *control.Controller, so the bot can no longer reach goals,
checkpoints, or memory. boot.Build still returns the concrete type at
construction; it's stored through the interface. No behavior change.

Remaining frontends (serve/acp/desktop/cli) migrate in follow-ups; the full
SessionAPI composition accretes as their sub-ports are added.

Verified: gofmt, go build/vet ./..., go test ./internal/control + ./internal/bot/...
2026-06-20 07:11:19 -07:00
YHH d02457ee78 Merge pull request #4928 from esengine/refactor/store-session-sidecar-authority
refactor(store): add a store package owning the session-sidecar layout
2026-06-20 22:00:46 +08:00
esengine 98a57ded7b refactor(store): add a store package owning the session-sidecar layout
Start of a persistence layer. Five packages each independently derived a
session's sidecar paths off the .jsonl path — agent (.meta, .cleanup-pending.json),
control (.ckpt, .goal-state.json), jobs (.jobs) — and acp re-spelled .meta and
.ckpt inline a second time. A layout change meant hunting across packages.

internal/store is now the single authority for that layout: SessionMeta /
SessionGoalState / SessionCheckpointDir / SessionJobsDir / SessionCleanupPending.
It is a strict leaf (stdlib only — verified it depends on no other internal
package), so anything can import it without an import cycle.

The seven derivation sites now delegate to store; paths resolve byte-identically
(locked by a layout test), so this is pure structure — no behavior change, no
migration. The exported names (agent.BranchMetaPath, jobs.ArtifactDir, …) stay as
thin wrappers so their many callers are untouched.

Next slices (not here): store takes over root/dir resolution with config
delegating (P2), then desktop drops its divergent ~/.config/reasonix root and
adopts the store root with a one-time migration (P3).

Verified: gofmt, go build/vet ./..., go test across store/agent/jobs/control/acp
plus downstream serve/cli/boot/history/checkpoint.
2026-06-20 06:56:15 -07:00
YHH bc8249c307 Merge pull request #4922 from esengine/refactor/control-approval-manager
refactor(control): extract approval/ask bookkeeping into an approvalManager
2026-06-20 21:32:26 +08:00
esengine 726036bd15 refactor(control): extract approval/ask bookkeeping into an approvalManager
The approval subsystem was ~9 fields plus two mutexes (the catch-all c.mu and
promptMu) interleaved into Controller. Unlike the goal FSM, approval blocks on
user input and has side effects (events, hooks, the executor gate), so it can't
be a pure leaf — extract the bookkeeping, keep the orchestration.

approvalManager (internal/control/approval.go) owns the prompt maps, session
grants, the id counter, the runtime posture (ask/auto/yolo), the approval
timeout, and the just-approved-plan window, behind its own mu + promptMu — all
off c.mu. It exposes the decision helpers and map primitives (preApproved /
register / resolve / grantSession / drain / snapshot / setMode / clearAll …).
The Controller keeps the thin I/O orchestration: requestApproval/Ask still emit
the events, fire the hooks, and rebuild the executor gate.

Net effect: all approval bookkeeping moves off c.mu, Controller sheds 9 fields
and a mutex, and the manager is a strict leaf (no callbacks into Controller, no
lock inversion). Public API (Approve/AnswerQuestion/SetMode/SetToolApprovalMode/
SetAutoApproveTools/Bypass/AutoApproveTools/EnableInteractiveApproval/
ReplayPendingPrompts) is unchanged, so bot/cli/serve/desktop callers are
unaffected. Also drops the write-only autoApproveTools field and the now-redundant
bypass pre-check in gateApprover (requestApproval already short-circuits it).

controller.go: 3712 -> 3506 lines. Verified with go build/vet ./... and
go test ./internal/control/ -race plus downstream bot/serve/cli tests.
2026-06-20 06:28:01 -07:00
YHH c23d40f74c Merge pull request #4918 from esengine/refactor/control-goal-machine
refactor(control): extract goal FSM into a goalMachine collaborator
2026-06-20 21:10:33 +08:00
YHH 7409342b70 Merge pull request #4919 from esengine/chore/drop-dead-inspect-and-npm-lock
chore: remove unused inspect package and redundant npm lockfile
2026-06-20 21:09:12 +08:00
esengine dbaea84333 refactor(control): extract goal FSM into a goalMachine collaborator
The goal state machine was ~13 fields plus its FSM logic interleaved
directly into Controller and protected by the catch-all c.mu. Move it into
a dedicated goalMachine collaborator (internal/control/goal.go) with its own
mutex, kept as a strict leaf: its methods never call back into Controller, so
c.mu and the goal lock can't invert.

The FSM is now pure — advance() takes already-gathered inputs (the parsed
marker, the executor's todo snapshot and readiness, whether a tool ran) and
returns what to persist plus a notice. This also moves the executor calls
(CanonicalTodoState/GoalReadinessFailure) out of the lock critical section,
removing the disk/executor work that previously ran under c.mu.

Controller drops 13 goal fields and the goal write mutex; the goal accessors
become thin delegations. Public API (Goal/GoalStatus/SetGoal/
SetGoalWithResearchMode/ClearGoal/GoalStrict/ShortGoalForNotice) is unchanged,
so cli/desktop/acp callers are unaffected. Behavior-preserving; plan mode is a
separate concern and stays on Controller.

controller.go: 4054 -> 3712 lines. Verified with go build/vet ./... and
go test ./internal/control/ -race (incl. the goal concurrency test).
2026-06-20 06:06:21 -07:00
esengine bb06f5b4b6 chore: remove unused inspect package and redundant npm lockfile
internal/inspect was a read-only capability-projection layer that nothing
ever imported — zero non-test references across the main module and the desktop
module since it was first added. Its own doc comment claims the CLI /mcp listing
and a desktop settings panel consume it, but neither does. Drop the orphan.

desktop/frontend used pnpm (wails.json frontend:install runs pnpm install) yet
also tracked a stale package-lock.json. Remove it and gitignore it so the npm
lockfile can't drift back in alongside pnpm-lock.yaml.
2026-06-20 05:59:16 -07:00
YHH be67a498ad Merge pull request #4897 from esengine/perf/desktop-gate-topic-migration-marker
perf(desktop): gate legacy topic migration behind a per-dir marker
2026-06-20 13:45:17 +08:00
esengine ad3d742aeb perf(desktop): gate legacy topic migration behind a per-dir marker
migrateLegacySessionsIntoGlobalTopics ran on EVERY ListProjectTree (every
sidebar render) and every buildTabController, taking a global lock and doing a
full ListSessionOrder (ReadDir + N x LoadBranchMeta) only to find — in steady
state — that every session already has a TopicID and there is nothing to
migrate. Each render re-paid the discovery cost under a global lock to do
nothing.

Gate it behind a per-dir one-shot marker (.topics-migrated), matching the
marker-gated passes in internal/agent/migrate.go. Once a dir's pass completes
with nothing deferred, the marker short-circuits future calls before the scan.
New sessions are born with a TopicID, so no fresh legacy files appear after the
pass.

Zero-regression safety: the marker is stamped only when nothing was deferred
— an empty legacy session (turns==0, could gain content later) or a transient
read/write error leaves the dir unmarked, so the gate never permanently hides a
session that should still be migrated. Callers are unaffected: in steady state
the function already returned nil, so returning nil early preserves behavior.

Tests: the marker is written after a complete pass and gates a later re-scan;
an empty legacy session defers marking. Existing migration + idempotency tests
still pass. Desktop can't build locally (cgo) — verified by the CI desktop job.
2026-06-19 22:34:35 -07:00
YHH 6d404d8009 Merge pull request #4891 from esengine/perf/goal-state-write-off-controller-lock
perf(control): write goal state off the controller lock
2026-06-20 12:45:44 +08:00
esengine 341f7209cd perf(control): write goal state off the controller lock
saveGoalState built the goal-state JSON (including the executor's todo
snapshot) AND wrote it to disk (MkdirAll + WriteFile) all under c.mu. It runs
after every goal turn (advanceGoalAfterTurn) and on SetGoal/ClearGoal/GoalStrict/
stopGoal, so the same c.mu that requestApproval / RuntimeStatus / Running take
was held across a filesystem write between every goal-loop turn.

Split it: buildGoalStateLocked reads state + marshals under c.mu (cheap, no
disk) and returns the path + bytes; writeGoalState does the MkdirAll + WriteFile
OFF c.mu, serialized by a new goalWriteMu. Each caller now captures the snapshot
under the lock and writes after releasing it. Writes stay synchronous in the
calling goroutine, so persistence still completes before the method returns. The
persisted result is unchanged — advanceGoalAfterTurn's redundant pre-clear save
in the complete path (always overwritten by the final save) is dropped.

First stage of the goal-FSM extraction; moving the goal* fields into a
dedicated collaborator is a separate follow-up.

Tests: goal_test.go (11) + the persistence test pass under -race; added a
concurrency test (8 writers + concurrent c.mu readers) proving no data race and
that goalWriteMu keeps the on-disk file from being torn.
2026-06-19 21:40:26 -07:00
yizeyi18 5584350ff0 Sandbox: add forbid_read key to regular reads to unsupposed positions. 2026-06-13 19:50:18 +08:00
581 changed files with 82793 additions and 15650 deletions

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
cache-impact:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.base.sha }}
+6 -6
View File
@@ -21,7 +21,7 @@ jobs:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
@@ -58,7 +58,7 @@ jobs:
race:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
@@ -79,7 +79,7 @@ jobs:
run:
working-directory: desktop
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
@@ -152,7 +152,7 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
@@ -169,7 +169,7 @@ jobs:
runs-on: ubuntu-latest
continue-on-error: true # informational — stdlib vulns need a Go patch release
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
@@ -185,7 +185,7 @@ jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- language: actions
build-mode: none
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
@@ -0,0 +1,49 @@
name: Deploy accounts worker
on:
push:
branches: [main-v2]
paths:
- 'workers/accounts/**'
- '.github/workflows/deploy-accounts-worker.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-accounts-worker
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/accounts/pnpm-lock.yaml
- name: Deploy
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
pnpm install --frozen-lockfile
npx wrangler deploy
- name: Sync RESEND_API_KEY to the worker
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
run: |
if [ -n "$RESEND_API_KEY" ]; then
printf '%s' "$RESEND_API_KEY" | npx wrangler secret put RESEND_API_KEY
else
echo "RESEND_API_KEY not set in repo secrets; skipping (worker stays in stub email mode)."
fi
+34
View File
@@ -0,0 +1,34 @@
name: Deploy crash worker
on:
push:
branches: [main-v2]
paths:
- 'workers/crash-report/**'
- '.github/workflows/deploy-crash-worker.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-crash-worker
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
cache-dependency-path: workers/crash-report/package-lock.json
- name: Deploy
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npm ci
npx wrangler deploy
+38
View File
@@ -0,0 +1,38 @@
name: Deploy forum worker
on:
push:
branches: [main-v2]
paths:
- 'workers/forum/**'
- '.github/workflows/deploy-forum-worker.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-forum-worker
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/forum/pnpm-lock.yaml
- name: Deploy
working-directory: workers/forum
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
pnpm install --frozen-lockfile
npx wrangler deploy
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
# Default-branch checkout: this is where the harness (cmd/e2ebench), the
# suite, and a run --metrics-capable agent live.
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: '22'
+4 -4
View File
@@ -42,7 +42,7 @@ jobs:
name: cache hit guard
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
@@ -77,7 +77,7 @@ jobs:
run:
shell: bash # desktop-build.sh is bash; windows runners default to pwsh otherwise
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
@@ -229,7 +229,7 @@ jobs:
# approve); canary uses the open `canary` environment so maintainers self-serve.
environment: ${{ (github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') && 'canary' || 'release' }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
@@ -292,7 +292,7 @@ jobs:
env:
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Resolve version
id: ver
+2 -2
View File
@@ -42,7 +42,7 @@ jobs:
name: cache hit guard
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
@@ -58,7 +58,7 @@ jobs:
# canary dispatches run free.
environment: ${{ github.event_name == 'workflow_dispatch' && inputs.channel == 'canary' && 'canary' || 'release' }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
name: cache hit guard
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
@@ -34,7 +34,7 @@ jobs:
# `release` environment so only esengine can approve it going public.
environment: release
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-go@v6
+9
View File
@@ -4,6 +4,15 @@ All notable changes to the Go line (Reasonix 1.0+) are recorded here. The legacy
`0.x` TypeScript history lives on the [`v1`](https://github.com/esengine/DeepSeek-Reasonix/tree/v1)
branch.
## Unreleased
### Changed
- Agent runtime defaults now leave both executor and dedicated planner tool-call
rounds unlimited (`max_steps = 0`, `planner_max_steps = 0`). Step limits now
come from the user/global config only; project `reasonix.toml` does not
override them.
## [1.0.0] — 2026-06-03
First stable release — a **ground-up rewrite in Go**. Not an upgrade of the `0.x`
+16 -8
View File
@@ -55,11 +55,14 @@
- **Config-driven.** Providers, the agent, enabled tools, and plugins are all
declared in `reasonix.toml`. No hardcoded models.
- **Multi-model & composable.** DeepSeek (flash/pro) and MiMo ship as presets;
any OpenAI-compatible endpoint is a config entry, not new code. Optionally run
- **Multi-model & composable.** DeepSeek ships as a preset; any
OpenAI-compatible endpoint is a config entry, not new code. Optionally run
two models together (executor + planner) in separate, cache-stable sessions.
- **Plugin-driven.** External tools run as subprocesses over stdio JSON-RPC
(MCP-compatible). Built-in tools self-register at compile time.
- **Cache-aware context maintenance.** Startup injects a small stable environment
summary, stale tool output is snipped/pruned before summary compaction, and the
built-in tool schema contract is documented for regression review.
- **Zero-friction distribution.** `CGO_ENABLED=0` single binary; cross-compile
to six targets with one command. The only dependency is a TOML parser.
@@ -90,10 +93,10 @@ make cross # -> dist/ (darwin|linux|windows × amd64|arm64)
```sh
reasonix setup # config wizard → ./reasonix.toml
export DEEPSEEK_API_KEY=sk-... # or let setup save it to the credential store
export DEEPSEEK_API_KEY=sk-... # or let setup save it to Reasonix home .env
reasonix # then run /init to generate AGENTS.md (project memory)
reasonix run "implement the TODOs in main.go"
reasonix run --model mimo-pro "add unit tests for this function"
reasonix run --model deepseek-pro "add unit tests for this function"
echo "explain this code" | reasonix run
```
@@ -116,10 +119,13 @@ Resolution order is **flag > `./reasonix.toml` > the user config file >
built-in defaults**; starting with **Reasonix v1.8.1**, the user file lives at
`~/.reasonix/config.toml` on macOS/Linux and
`%AppData%\reasonix\config.toml` on Windows. See
**[Configuration paths](./docs/CONFIG_PATHS.md)** for migration details. Secrets come from the environment via `api_key_env`, are
never written to config files, and new keys default to the OS credential store
with a Reasonix-owned file fallback. Project `.env` files are read as a
compatibility override, but Reasonix does not write new keys there. Permissions, the sandbox, plugins (MCP), slash
**[Configuration paths](./docs/CONFIG_PATHS.md)** for migration details and the
full `config.toml` / `.env` structure. Provider entries name secrets with
`api_key_env`; the secret values themselves live in Reasonix's global
`<Reasonix home>/.env`, shared by CLI and desktop. Project `.env` files are not
provider-key runtime fallbacks, but still feed workspace-scoped, non-provider
`${VAR}` expansion for MCP/plugin settings without importing Reasonix control
variables. Permissions, the sandbox, plugins (MCP), slash
commands, `@` references, and two-model setup are all in the
**[Guide](./docs/GUIDE.md)**.
@@ -131,6 +137,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.
- **[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
TypeScript releases to the 1.0 Go rewrite.
- **[Checkpoints & rewind](./docs/CHECKPOINTS.md)** — the snapshot-based edit
+11 -7
View File
@@ -53,10 +53,12 @@
- **配置驱动**:provider、agent、启用的工具、插件全部在 `reasonix.toml` 中声明,
内核无硬编码模型。
- **多模型 · 可组合**:DeepSeek(flash/pro)与 MiMo 作为预设内置;任何 OpenAI 兼容
- **多模型 · 可组合**:DeepSeek 作为预设内置;任何 OpenAI 兼容
端点都只是一条配置。可选让两个模型协同(执行器 + 规划器),各自独立、缓存稳定的 session。
- **插件驱动**:外部工具以子进程形式运行,通过 stdio JSON-RPC 通信(MCP 兼容);
内置工具在编译期自注册。
- **缓存友好的上下文维护**:启动时注入稳定的环境摘要;旧工具输出会先 snip/prune,
再进入摘要 compaction;内置工具 schema 合约有文档和回归测试保护。
- **零摩擦分发**:`CGO_ENABLED=0` 单二进制;一条命令交叉编译到六个目标平台。
唯一依赖是一个 TOML 解析库。
@@ -86,10 +88,10 @@ make cross # -> dist/(darwin|linux|windows × amd64|arm64)
```sh
reasonix setup # 配置向导 → ./reasonix.toml
export DEEPSEEK_API_KEY=sk-... # 也可以让 setup 保存到凭据存储
export DEEPSEEK_API_KEY=sk-... # 也可以让 setup 保存到 Reasonix 全局 .env
reasonix # 然后在会话里运行 /init 生成 AGENTS.md(项目记忆)
reasonix run "把 main.go 里的 TODO 实现掉"
reasonix run --model mimo-pro "给这个函数补单元测试"
reasonix run --model deepseek-pro "给这个函数补单元测试"
echo "解释这段代码" | reasonix run
```
@@ -111,10 +113,10 @@ api_key_env = "DEEPSEEK_API_KEY"
优先级为 **flag > `./reasonix.toml` > 用户配置文件 > 内置默认值**;从
**Reasonix v1.8.1** 开始,用户配置位于 macOS/Linux 的 `~/.reasonix/config.toml`,
Windows 为 `%AppData%\reasonix\config.toml`。迁移细节见
**[配置路径](./docs/CONFIG_PATHS.zh-CN.md)**。
密钥经环境变量通过 `api_key_env` 注入,绝不写入配置文件;新密钥默认优先保存到系统凭据存储,
不可用时才 fallback 到 Reasonix 管理的凭据文件。项目 `.env` 只作为兼容覆盖读取,
Reasonix 不会把新密钥写入项目 `.env`。权限、沙盒、插件(MCP)、
**[配置路径](./docs/CONFIG_PATHS.zh-CN.md)**,其中也说明了全局 `config.toml`
和 `.env` 的完整结构。Provider 通过 `api_key_env` 命名密钥,真实密钥值保存在
CLI 与桌面端共用的 Reasonix 全局 `<Reasonix home>/.env`;项目 `.env` 不再作为
provider key 的运行时 fallback,但仍会作为当前 workspace 范围内的 MCP/plugin 非 provider `${VAR}` 展开来源,不导入 Reasonix 控制变量。权限、沙盒、插件(MCP)、
斜杠命令、`@` 引用与双模型设置,全部在 **[指南](./docs/GUIDE.zh-CN.md)** 里。
## 文档
@@ -124,6 +126,8 @@ Reasonix 不会把新密钥写入项目 `.env`。权限、沙盒、插件(MCP)
- **[机器人使用指南](./docs/BOT_GUIDE.zh-CN.md)** —— 桌面端连接飞书、Lark、微信
Bot,以及 IM 里的审批、YOLO 和命令交互。
- **[规格](./docs/SPEC.md)** —— 工程契约:架构、registry、数据类型与路线图。
- **[工具合约](./docs/TOOL_CONTRACT.zh-CN.md)** —— provider 可见的内置工具名、
read-only 标记和 schema 快照保护。
- **[从 0.x 迁移](./docs/MIGRATING.md)** —— 从 legacy TypeScript 版本迁到 1.0 Go 重写版。
- **[Checkpoints 与 rewind](./docs/CHECKPOINTS.md)** —— 基于快照的编辑安全网
(Esc-Esc / `/rewind`)。
+137
View File
@@ -0,0 +1,137 @@
# Security Policy
## Supported Versions
Reasonix security fixes are prioritized for the currently developed Go rewrite
and the current 1.x release line.
| Version or branch | Security support |
| --- | --- |
| `main-v2` / 1.x releases | Supported |
| `v1` / 0.x legacy branch | Critical fixes only, where practical |
| Older releases, forks, or modified builds | Not covered unless the issue is reproducible upstream |
If you are unsure whether a version is affected, report against the newest
released 1.x version and include the exact version or commit you tested.
## Reporting a Vulnerability
Please report security issues privately. Do not open a public issue with exploit
details, secrets, crash dumps, or proof-of-concept payloads.
Preferred reporting path:
1. Use GitHub private vulnerability reporting for this repository, if available.
2. If private reporting is not available to you, open a minimal public issue
asking for a private maintainer contact path. Do not include exploit details
in that issue.
Please include:
- Affected Reasonix version, commit, operating system, and installation method.
- The feature or surface involved, such as CLI, desktop app, HTTP `serve`, bot
gateway, MCP plugin, built-in tool, updater, or configuration loading.
- Clear reproduction steps using dummy credentials and non-sensitive files.
- The expected impact, such as secret disclosure, arbitrary file access,
command execution, sandbox escape, authentication bypass, or supply-chain risk.
- Any relevant logs with API keys, tokens, local paths, and personal data
redacted.
Do not send real provider API keys, bot credentials, OAuth tokens, private
workspace files, or third-party user data.
## Security Boundaries
Reasonix is a local coding agent. Many features intentionally operate on the
user's local machine and workspace, including file reads, file writes, shell
commands, MCP plugins, language servers, bot sessions, and model-provider
requests. A finding is security-relevant when it crosses a supported boundary or
bypasses an explicit guard.
Supported boundaries include:
- Workspace confinement for file operations that are documented or implemented
as workspace-scoped.
- Permission checks for tool calls, shell commands, file writes, and approvals.
- Sandbox behavior for built-in shell execution where the platform supports it.
- Secret handling for provider keys, bot credentials, OAuth tokens, plugin
headers, and credential-store fallback files.
- HTTP `serve` protections for the unauthenticated local server, including
localhost binding assumptions, JSON-only state-changing requests, and CORS
restrictions.
- Desktop and bot session isolation, including per-workspace session metadata
and configured bot allowlists.
- Updater, install, and release verification paths.
The following are normally treated as trusted local/operator-controlled inputs
unless another bug lets an untrusted actor supply them:
- CLI arguments and text typed directly by the local user.
- Project configuration files intentionally loaded from the current workspace.
- Explicit `@path` references supplied by the local user to attach local files.
- MCP servers, language servers, hooks, and slash commands installed or enabled
by the local user.
- Provider base URLs and model names configured by the local user.
The following can be security issues when reachable by an untrusted actor or
when they bypass the intended boundary:
- Reading or writing files outside the configured workspace without explicit
local-user intent.
- Following symlinks or path traversal to escape workspace confinement.
- Running shell commands or external tools without the required permission gate.
- Leaking credentials, environment variables, prompt history, local files, or bot
messages to logs, model providers, MCP servers, crash reports, or telemetry.
- Allowing a website to drive the local HTTP server through CSRF, CORS, or
content-type bypasses.
- Letting a bot user outside the configured allowlist submit prompts, approve
tools, or access a project workspace.
- Trusting unverified update artifacts, plugin definitions, or downloaded
binaries.
## `@` File References
Reasonix supports `@path` references so users can include local files and images
in a prompt. This is intentional local functionality, but implementations must
preserve these invariants:
- In workspace-scoped sessions, relative and absolute paths must resolve under
the active workspace root before file content is read or attached.
- Path traversal such as `..` must not escape the workspace root.
- Symlinks must not be usable to bypass the intended workspace boundary.
- Unscoped local CLI compatibility must not be exposed to remote, bot, or
browser-controlled inputs unless an equivalent workspace boundary is applied.
- File content should be size-limited and binary content should not be dumped as
prompt text.
Static analysis alerts about path expressions should be triaged against these
rules: user-controlled path data is expected, but the access must either stay
inside the configured workspace or be clearly limited to trusted local CLI use.
## Out of Scope
The following reports are usually out of scope unless they demonstrate a bypass
of one of the boundaries above:
- A local user intentionally asks Reasonix to read, edit, or send their own
files to a configured model provider.
- A local user installs or enables a malicious MCP server, hook, slash command,
language server, or shell command and then grants it permission.
- A configured model provider, proxy, or MCP server receives data the user
intentionally sent to it.
- Denial-of-service issues that only affect the local user's own session and do
not corrupt files, leak secrets, or bypass permissions.
- Issues requiring administrator/root access on the user's machine before
interacting with Reasonix.
- Vulnerabilities in third-party services, models, proxies, or plugins that are
not caused by Reasonix behavior.
## Coordinated Disclosure
This is a community-maintained project. Maintainers will make a best-effort
assessment, ask follow-up questions when needed, and coordinate fixes before
public disclosure for confirmed vulnerabilities.
Please give maintainers reasonable time to investigate and release a fix before
publishing exploit details. If you plan to disclose on a timeline, include that
timeline in your initial report.
+22 -5
View File
@@ -183,11 +183,18 @@ the **native** webview per OS, so the rough edges are platform-specific. What's
handled here, and what to reach for if a target misbehaves:
- **Linux / WebKitGTK** is the one real pain point — rendering varies by distro &
GPU driver. `main.go` sets `WebviewGpuPolicy: OnDemand` to avoid blank/flickering
webviews from forced compositing. If artifacts persist, launch with
`WEBKIT_DISABLE_COMPOSITING_MODE=1`. Test on at least one GTK target before
release; the CSS deliberately avoids `backdrop-filter`/blur (slow & inconsistent
there).
GPU driver. `main.go` keeps `WebviewGpuPolicy: OnDemand` when a DRI render node
is usable, and falls back to `Never` for xrdp/headless/software-rendered sessions
that cannot access `/dev/dri`. If artifacts persist, launch with
`WEBKIT_DISABLE_COMPOSITING_MODE=1`. Test on at least one GTK target before release;
the CSS deliberately avoids `backdrop-filter`/blur (slow & inconsistent there).
- **Wayland + NVIDIA**: On KDE Plasma Wayland with NVIDIA GPUs, WebKitGTK can
crash at startup (`Error 71: Protocol error`) due to an upstream WebKit
explicit-sync bug (WebKit #280210, #317089, NVIDIA/egl-wayland #179).
Reasonix automatically sets `__NV_DISABLE_EXPLICIT_SYNC=1` when it detects
Wayland + NVIDIA GPU. To opt out, set `__NV_DISABLE_EXPLICIT_SYNC=0`.
Alternative fallbacks: `WEBKIT_DISABLE_DMABUF_RENDERER=1` (poor performance)
or `GDK_BACKEND=x11` (forces XWayland).
- **Windows / WebView2** — `Theme: SystemDefault` follows the OS light/dark
setting; the installer embeds the WebView2 bootstrapper. Canary builds disable
WebView2 GPU acceleration by default to smoke-test blank-window reports; set
@@ -240,3 +247,13 @@ Settings > Updates > "Share aggregate quality metrics", or by setting
`metrics = false` under `[desktop]`. These metrics are anonymous signal/bucket
counts and preference buckets; they never include conversations, prompts, keys,
paths, base URLs, or file contents.
When Memory v5 is enabled, the same aggregate metrics pipeline may include only
content-free count/size buckets such as injection on/off, compiled-token bucket,
IR-overhead bucket, memory-reference count, constraint/risk/step counts, and
memory-graph size buckets. It never uploads memory text, tool outputs, prompts,
file paths, IDs, keys, base URLs, or file contents. The Memory v5 runtime itself
is controlled from Settings > General > "Memory v5" and shares the user/global
`agent.memory_compiler.enabled` setting with the CLI/TUI and `reasonix serve`;
CLI users can also run `/memory-v5 off|observe|compact|on|status` in a session
or `reasonix config memory-v5 off|observe|compact|on|status` from a shell.
+1928 -315
View File
File diff suppressed because it is too large. Load diff
+216 -2
View File
@@ -32,7 +32,7 @@ func controllerWithContent(t *testing.T, path string) *control.Controller {
sess.Add(provider.Message{Role: provider.RoleUser, Content: "remember this turn"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "acknowledged"})
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
return control.New(control.Options{Executor: ag, SessionPath: path, Sink: event.Discard})
return control.New(control.Options{Executor: ag, SessionDir: filepath.Dir(path), SessionPath: path, Sink: event.Discard})
}
func waitForFile(t *testing.T, path, want string) {
@@ -49,7 +49,12 @@ func waitForFile(t *testing.T, path, want string) {
func waitForAutosaveIdle(t *testing.T, tab *WorkspaceTab) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
waitForAutosaveIdleWithin(t, tab, 2*time.Second)
}
func waitForAutosaveIdleWithin(t *testing.T, tab *WorkspaceTab, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
tab.saveMu.Lock()
idle := !tab.saving && !tab.saveAgain
@@ -132,6 +137,91 @@ func TestScheduleSnapshotCoalesces(t *testing.T) {
waitForAutosaveIdle(t, tab)
}
func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, tab := appWithTab(t, path)
_ = a
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForAutosaveIdleWithin(t, tab, 5*time.Second)
tab.saveMu.Lock()
failures := tab.saveFailures
tab.saveMu.Unlock()
if failures == 0 {
t.Fatal("autosave failure should be recorded and retried")
}
if info, err := os.Stat(path); err != nil || !info.IsDir() {
t.Fatalf("blocked session path should still be the directory, info=%v err=%v", info, err)
}
if err := os.Remove(path); err != nil {
t.Fatalf("remove blocked dir: %v", err)
}
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, path, "remember this turn")
waitForAutosaveIdle(t, tab)
tab.saveMu.Lock()
failures = tab.saveFailures
tab.saveMu.Unlock()
if failures != 0 {
t.Fatalf("autosave failures after recovery = %d, want 0", failures)
}
}
func TestSetActiveTabBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, _ := appWithTab(t, path)
a.tabs["target_tab"] = &WorkspaceTab{
ID: "target_tab",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
a.tabOrder = []string{"test_tab", "target_tab"}
err := a.SetActiveTab("target_tab")
if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") {
t.Fatalf("SetActiveTab error = %v, want persistence failure", err)
}
if a.activeTabID != "test_tab" {
t.Fatalf("active tab = %q, want original tab after failed save", a.activeTabID)
}
}
func TestRebindSessionBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, tab := appWithTab(t, path)
target := filepath.Join(t.TempDir(), "target.jsonl")
sess := agent.NewSession("system")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "target prompt"})
if err := sess.Save(target); err != nil {
t.Fatalf("save target: %v", err)
}
loaded, err := agent.LoadSession(target)
if err != nil {
t.Fatalf("load target: %v", err)
}
err = a.rebindTabToLoadedSessionPath(tab, target, loaded)
if err == nil || !strings.Contains(err.Error(), "save current session before switching sessions") {
t.Fatalf("rebind error = %v, want persistence failure", err)
}
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
t.Fatalf("tab controller/path changed after failed save: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
}
}
// TestCloseTabNoResurrectionFromAutosave is the regression test for #4384.
// It proves that after CloseTab returns, the per-turn autosave goroutine can no
// longer write the session file — even when it is in flight at the moment the
@@ -187,6 +277,34 @@ func TestCloseTabNoResurrectionFromAutosave(t *testing.T) {
}
}
func TestCloseTabBlocksWhenSessionCannotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, tab := appWithTab(t, path)
survivor := &WorkspaceTab{
ID: "survivor_tab",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
a.tabs[survivor.ID] = survivor
a.tabOrder = []string{tab.ID, survivor.ID}
err := a.CloseTab(tab.ID)
if err == nil || !strings.Contains(err.Error(), "save current session before closing tab") {
t.Fatalf("CloseTab error = %v, want persistence failure", err)
}
if _, ok := a.tabs[tab.ID]; !ok {
t.Fatal("tab was removed even though its session could not be saved")
}
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
t.Fatalf("tab controller/path changed after failed close: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
}
}
// TestCloseTabSurvivorKeepsAutosave ensures the survivor tab is untouched: the
// closing/drain logic is per-tab and must not leak to other tabs.
func TestCloseTabSurvivorKeepsAutosave(t *testing.T) {
@@ -221,3 +339,99 @@ func TestCloseTabSurvivorKeepsAutosave(t *testing.T) {
t.Fatal("survivor tab was marked closing — closing flag leaked across tabs")
}
}
func TestDeleteSessionClearsRemovedRuntimeSessionPath(t *testing.T) {
isolateDesktopUserDirs(t)
dir := t.TempDir()
path := filepath.Join(dir, "delete-open.jsonl")
ctrl := controllerWithContent(t, path)
tab := &WorkspaceTab{
ID: "delete_open",
Scope: "global",
Ready: true,
Ctrl: ctrl,
disabledMCP: map[string]ServerView{},
}
app := &App{
tabs: map[string]*WorkspaceTab{"delete_open": tab},
activeTabID: "delete_open",
}
if err := ctrl.Snapshot(); err != nil {
t.Fatalf("snapshot: %v", err)
}
if err := app.DeleteSession(path); err != nil {
t.Fatalf("DeleteSession: %v", err)
}
if got := ctrl.SessionPath(); got != "" {
t.Fatalf("removed controller session path = %q, want empty before trash move can race Windows file locks", got)
}
trashPath := filepath.Join(dir, sessionTrashDir, "delete-open.jsonl", "delete-open.jsonl")
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("session should be in trash: %v", err)
}
}
func TestTrashTopicClearsRemovedRuntimeSessionPath(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_clear_removed_runtime"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, "Clear removed runtime"); err != nil {
t.Fatalf("set topic title: %v", err)
}
dir := t.TempDir()
path := filepath.Join(dir, "trash-open-topic.jsonl")
ctrl := controllerWithContent(t, path)
if err := ctrl.Snapshot(); err != nil {
t.Fatalf("snapshot: %v", err)
}
if err := agent.SaveBranchMeta(path, agent.BranchMeta{
CreatedAt: time.Now().Add(-time.Minute),
UpdatedAt: time.Now(),
Scope: "project",
WorkspaceRoot: projectRoot,
TopicID: topicID,
TopicTitle: "Clear removed runtime",
}); err != nil {
t.Fatalf("save branch meta: %v", err)
}
tab := &WorkspaceTab{
ID: "trash_open",
Scope: "project",
WorkspaceRoot: projectRoot,
TopicID: topicID,
TopicTitle: "Clear removed runtime",
Ready: true,
Ctrl: ctrl,
disabledMCP: map[string]ServerView{},
}
survivor := &WorkspaceTab{
ID: "survivor",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
app := &App{
tabs: map[string]*WorkspaceTab{"trash_open": tab, "survivor": survivor},
tabOrder: []string{"trash_open", "survivor"},
activeTabID: "trash_open",
}
if err := app.TrashTopic(topicID); err != nil {
t.Fatalf("TrashTopic: %v", err)
}
if got := ctrl.SessionPath(); got != "" {
t.Fatalf("removed topic controller session path = %q, want empty before trash move can race Windows file locks", got)
}
trashPath := filepath.Join(dir, sessionTrashDir, "trash-open-topic.jsonl", "trash-open-topic.jsonl")
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("topic session should be in trash: %v", err)
}
}
+219
View File
@@ -4,8 +4,10 @@ import (
"os"
"path/filepath"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
@@ -67,6 +69,12 @@ func TestEnsureBlankTabReusesExistingBlankTab(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if first.SessionPath == "" {
t.Fatal("EnsureBlankTab should pre-create a session path for immediate deletion")
}
if _, err := os.Stat(first.SessionPath); err != nil {
t.Fatalf("pre-created blank session should exist: %v", err)
}
second, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatal(err)
@@ -79,6 +87,85 @@ func TestEnsureBlankTabReusesExistingBlankTab(t *testing.T) {
}
}
func TestEnsureBlankTabReusesPrecreatedBlankBeforeControllerReady(t *testing.T) {
isolateDesktopUserDirs(t)
globalRoot := globalWorkspaceRoot()
if err := os.MkdirAll(globalRoot, 0o755); err != nil {
t.Fatal(err)
}
sessionPath := agent.NewSessionPath(desktopSessionDir(globalRoot), "")
if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
t.Fatal(err)
}
app := NewApp()
topic, err := app.CreateTopic("global", "", "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
app.tabs["blank"] = &WorkspaceTab{
ID: "blank",
Scope: "global",
WorkspaceRoot: globalRoot,
TopicID: topic.ID,
TopicTitle: defaultTopicTitle,
SessionPath: sessionPath,
disabledMCP: map[string]ServerView{},
}
app.tabOrder = []string{"blank"}
app.activeTabID = "blank"
meta, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if meta.ID != "blank" {
t.Fatalf("EnsureBlankTab created duplicate blank tab %q, want existing pre-created blank", meta.ID)
}
}
func TestEnsureBlankTabReusesIndexedTopicWithEmptyStub(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
topic, err := app.CreateTopic("global", "", "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
globalRoot := globalWorkspaceRoot()
dir := desktopSessionDir(globalRoot)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
stubPath := filepath.Join(dir, "empty-stub.jsonl")
if err := os.WriteFile(stubPath, nil, 0o644); err != nil {
t.Fatalf("write empty stub: %v", err)
}
now := time.Now()
if err := agent.SaveBranchMetaPreserveUpdated(stubPath, agent.BranchMeta{
CreatedAt: now.Add(-time.Minute),
UpdatedAt: now,
Scope: "global",
WorkspaceRoot: globalRoot,
TopicID: topic.ID,
TopicTitle: defaultTopicTitle,
}); err != nil {
t.Fatalf("save branch meta: %v", err)
}
meta, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if meta.TopicID != topic.ID {
t.Fatalf("EnsureBlankTab topic = %q, want reused empty topic %q", meta.TopicID, topic.ID)
}
}
// EnsureBlankTab reuses an already-open project-scoped blank tab.
func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
@@ -254,6 +341,36 @@ func TestEnsureBlankTabOpensExistingProjectSidebarBlankTopic(t *testing.T) {
}
}
func TestEnsureBlankTabDoesNotReuseProjectTopicWithSession(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := robustTempDir(t)
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("CreateTopic: %v", err)
}
dir := desktopSessionDir(projectRoot)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
existingPath := writeTopicSession(t, dir, "existing.jsonl", topic.ID, defaultTopicTitle, projectRoot)
if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
t.Fatalf("precondition topic session = %q, want %q", got, existingPath)
}
meta, err := app.EnsureBlankTab("project", projectRoot)
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if meta.TopicID == topic.ID {
t.Fatalf("EnsureBlankTab reused topic %q even though it already has session %q", topic.ID, existingPath)
}
if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
t.Fatalf("existing topic session changed = %q, want %q", got, existingPath)
}
}
// NewSession skips the snapshot when the current tab has no real conversation content.
func TestNewSessionNoopsWhenCurrentTabIsBlank(t *testing.T) {
@@ -272,3 +389,105 @@ func TestNewSessionNoopsWhenCurrentTabIsBlank(t *testing.T) {
t.Fatalf("blank NewSession changed session path = %q, want %q", got, path)
}
}
func TestNewSessionUsesFreshTopicIdentity(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
oldTopicID := "topic_old"
oldTopicTitle := "Old topic"
oldPath := writeTopicSessionWithPrompt(t, dir, "old.jsonl", oldTopicID, oldTopicTitle, projectRoot, "old prompt", time.Now().Add(-time.Hour))
sess := &agent.Session{}
sess.Replace([]provider.Message{{Role: provider.RoleUser, Content: "old prompt"}})
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, SessionPath: oldPath, Sink: event.Discard})
app := NewApp()
app.setTestCtrl(ctrl, "model-a")
tab := app.tabs["test"]
tab.Scope = "project"
tab.WorkspaceRoot = projectRoot
tab.TopicID = oldTopicID
tab.TopicTitle = oldTopicTitle
tab.SessionPath = oldPath
app.projectTreeChangedHook = func() {}
if err := app.NewSession(); err != nil {
t.Fatalf("NewSession: %v", err)
}
if got := tab.TopicID; got == "" || got == oldTopicID {
t.Fatalf("new session topic ID = %q, want fresh ID distinct from %q", got, oldTopicID)
}
if got := tab.TopicTitle; got != defaultTopicTitle {
t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
}
newPath := ctrl.SessionPath()
if newPath == "" || filepath.Clean(newPath) == filepath.Clean(oldPath) {
t.Fatalf("new session path = %q, want fresh path distinct from %q", newPath, oldPath)
}
if err := os.WriteFile(newPath, []byte(`{"role":"user","content":"new prompt"}`+"\n"), 0o644); err != nil {
t.Fatalf("write new session: %v", err)
}
if !app.maybeAutoTitleTopic(tab) {
t.Fatalf("new session should auto-title its fresh topic")
}
oldMeta, ok, err := agent.LoadBranchMeta(oldPath)
if err != nil || !ok {
t.Fatalf("load old meta: ok=%v err=%v", ok, err)
}
if oldMeta.TopicID != oldTopicID || oldMeta.TopicTitle != oldTopicTitle {
t.Fatalf("old session meta changed after new session auto-title: %+v", oldMeta)
}
newMeta, ok, err := agent.LoadBranchMeta(newPath)
if err != nil || !ok {
t.Fatalf("load new meta: ok=%v err=%v", ok, err)
}
if newMeta.TopicID != tab.TopicID || newMeta.TopicTitle != "new prompt" {
t.Fatalf("new session meta = %+v, want topic %q titled new prompt", newMeta, tab.TopicID)
}
}
func TestNewSessionKeepsFreshRuntimeWhenTopicRepairFails(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := agent.NewSessionPath(dir, "model-a")
ctrl := controllerWithContent(t, path)
app := NewApp()
app.projectTreeChangedHook = func() {}
app.setTestCtrl(ctrl, "model-a")
tab := app.tabs["test"]
tab.TopicID = "topic_old"
tab.TopicTitle = "Old topic"
// Block desktopConfigDir-backed topic-index writes without affecting the
// session directory, which exercises the post-NewSession repair failure path.
if err := os.MkdirAll(filepath.Dir(desktopConfigDir()), 0o755); err != nil {
t.Fatalf("mkdir desktop config parent: %v", err)
}
if err := os.WriteFile(desktopConfigDir(), []byte("not-a-directory"), 0o644); err != nil {
t.Fatalf("block desktop config dir: %v", err)
}
if err := app.NewSession(); err != nil {
t.Fatalf("NewSession should keep the fresh runtime even when topic repair fails: %v", err)
}
if got := tab.TopicID; got == "" || got == "topic_old" {
t.Fatalf("new session topic ID = %q, want fresh ID distinct from the old topic", got)
}
if got := tab.TopicTitle; got != defaultTopicTitle {
t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
}
if got := ctrl.SessionPath(); got == "" || filepath.Clean(got) == filepath.Clean(path) {
t.Fatalf("new session path = %q, want a fresh path distinct from %q", got, path)
}
}
+1497 -68
View File
File diff suppressed because it is too large. Load diff
+200
View File
@@ -1,11 +1,17 @@
package main
import (
"context"
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
)
const desktopTinyPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
@@ -73,6 +79,51 @@ func TestSavePastedImageUsesActiveWorkspaceRoot(t *testing.T) {
}
}
func TestSavePastedImageUsesPinnedSessionOwnerBeforeStaleWorkspaceRoot(t *testing.T) {
isolateDesktopUserDirs(t)
orig, _ := os.Getwd()
defer os.Chdir(orig)
launchRoot := t.TempDir()
projectA := t.TempDir()
projectB := t.TempDir()
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
sessionDirA := desktopSessionDir(projectA)
if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
t.Fatalf("mkdir project A sessions: %v", err)
}
sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_attach_owner", "Attach owner", projectA, "project A prompt", time.Now())
if err := os.Chdir(launchRoot); err != nil {
t.Fatal(err)
}
app := &App{
tabs: map[string]*WorkspaceTab{
"project": {ID: "project", Scope: "project", WorkspaceRoot: projectB, SessionPath: sessionPathA},
},
activeTabID: "project",
}
got, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
if err != nil {
t.Fatalf("SavePastedImage: %v", err)
}
if _, err := os.Stat(filepath.Join(projectA, filepath.FromSlash(got))); err != nil {
t.Fatalf("pasted image should be saved under pinned session owner project A: %v", err)
}
if _, err := os.Stat(filepath.Join(projectB, filepath.FromSlash(got))); !os.IsNotExist(err) {
t.Fatalf("pasted image should not be saved under stale project B, stat err=%v", err)
}
if gotRoot := normalizeProjectRoot(app.tabs["project"].WorkspaceRoot); gotRoot != normalizeProjectRoot(projectA) {
t.Fatalf("tab workspace root = %q, want project A %q", gotRoot, normalizeProjectRoot(projectA))
}
}
func TestAttachDroppedUsesActiveWorkspaceRoot(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
@@ -225,3 +276,152 @@ func TestAttachDroppedImageStoresThumbnail(t *testing.T) {
t.Fatalf("preview = %q, want png data URL", got.PreviewURL)
}
}
func TestAttachDroppedOutsideWorkspaceDirRegistersWorkspaceRef(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
workspace := t.TempDir()
outside := filepath.Join(t.TempDir(), "Folder With Spaces")
if err := os.MkdirAll(filepath.Join(outside, "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(outside, "sub", "notes.txt"), []byte("notes"), 0o644); err != nil {
t.Fatal(err)
}
expectedOutside := outside
if resolved, err := filepath.EvalSymlinks(outside); err == nil {
expectedOutside = resolved
}
expectedDisplayPath := filepath.ToSlash(expectedOutside)
if err := os.Chdir(workspace); err != nil {
t.Fatal(err)
}
ctrl := control.New(control.Options{WorkspaceRoot: workspace})
app := &App{
tabs: map[string]*WorkspaceTab{
"project": {ID: "project", WorkspaceRoot: workspace, Ctrl: ctrl},
},
activeTabID: "project",
}
got, err := app.AttachDropped(outside)
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
if got.Kind != "workspace" || !got.IsDir {
t.Fatalf("got %+v, want workspace directory ref", got)
}
if !strings.HasPrefix(got.Path, "__reasonix_external_folder/") || strings.ContainsAny(got.Path, " \t\r\n") {
t.Fatalf("external folder path token = %q, want whitespace-free external token", got.Path)
}
if got.DisplayPath != expectedDisplayPath {
t.Fatalf("display path = %q, want %q", got.DisplayPath, expectedDisplayPath)
}
block, errs := ctrl.ResolveScopedRefs(context.Background(), "inspect @"+got.Path+"/")
if len(errs) != 0 {
t.Fatalf("ResolveScopedRefs errors = %v", errs)
}
if !strings.Contains(block, `<dir path="`+expectedDisplayPath+`">`) ||
!strings.Contains(block, "sub/") ||
!strings.Contains(block, "sub/notes.txt") {
t.Fatalf("external dropped folder should resolve as dir context:\n%s", block)
}
}
func TestAttachDroppedOutsideWorkspaceDirRegistersAfterPinnedOwnerRebuild(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "TEST_MODEL_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "test/test-model"
cfg.Desktop.ProviderAccess = []string{"test"}
cfg.Providers = []config.ProviderEntry{
{Name: "test", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "test-model", APIKeyEnv: "TEST_MODEL_KEY"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
orig, _ := os.Getwd()
defer os.Chdir(orig)
projectA := t.TempDir()
projectB := t.TempDir()
outside := filepath.Join(t.TempDir(), "External")
if err := os.MkdirAll(filepath.Join(outside, "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(outside, "sub", "notes.txt"), []byte("notes"), 0o644); err != nil {
t.Fatal(err)
}
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
sessionDirA := desktopSessionDir(projectA)
sessionDirB := desktopSessionDir(projectB)
if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
t.Fatalf("mkdir project A sessions: %v", err)
}
if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
t.Fatalf("mkdir project B sessions: %v", err)
}
sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_external_ref", "External ref", projectA, "project A prompt", time.Now())
sessionPathB := filepath.Join(sessionDirB, "wrong.jsonl")
oldCtrl := control.New(control.Options{
SessionDir: sessionDirB,
SessionPath: sessionPathB,
WorkspaceRoot: projectB,
Sink: event.Discard,
})
app := NewApp()
app.readyHook = func() {}
tab := &WorkspaceTab{
ID: "project",
Scope: "project",
WorkspaceRoot: projectB,
TopicID: "topic_external_ref",
TopicTitle: "External ref",
SessionPath: sessionPathA,
Ready: true,
model: "test/test-model",
Ctrl: oldCtrl,
sink: &tabEventSink{tabID: "project", app: app},
disabledMCP: map[string]ServerView{},
}
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()
}
})
got, err := app.AttachDropped(outside)
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
if tab.Ctrl == oldCtrl {
t.Fatal("stale controller was reused for external folder ref")
}
if gotRoot := normalizeProjectRoot(tab.Ctrl.WorkspaceRoot()); gotRoot != normalizeProjectRoot(projectA) {
t.Fatalf("controller workspace root = %q, want project A %q", gotRoot, normalizeProjectRoot(projectA))
}
resolver, ok := tab.Ctrl.(interface {
ResolveScopedRefs(context.Context, string) (string, []string)
})
if !ok {
t.Fatalf("rebuilt controller does not resolve scoped refs: %T", tab.Ctrl)
}
block, errs := resolver.ResolveScopedRefs(context.Background(), "inspect @"+got.Path+"/")
if len(errs) != 0 {
t.Fatalf("ResolveScopedRefs errors = %v", errs)
}
if !strings.Contains(block, "sub/notes.txt") {
t.Fatalf("external dropped folder should resolve on rebuilt controller:\n%s", block)
}
}
+179
View File
@@ -0,0 +1,179 @@
package main
import (
"context"
"log"
"strings"
"sync"
"time"
"reasonix/internal/bot"
"reasonix/internal/event"
)
const botForwardSendTimeout = 30 * time.Second
const botForwardQueueSize = 64
// ── Forward target ──────────────────────────────────────────────────────────
// botForwardTarget identifies one remote chat to send forwarded events to.
type botForwardTarget struct {
ConnID string
Domain string
ChatID string
ChatType bot.ChatType
}
// ── Event forwarder ─────────────────────────────────────────────────────────
// botEventForwarder implements event.Sink and forwards relevant events to
// connected bot channels through the desktopBotRuntime. It is attached to a
// tabEventSink when a heartbeat task should push AI output to IM channels.
//
// It accumulates Text events and sends them as complete messages on TurnDone
// (and occasionally during generation when the buffer grows large enough), so
// the remote side sees progressive streaming output rather than one big blob.
type botEventForwarder struct {
runtime *desktopBotRuntime
targets []botForwardTarget
mu sync.Mutex
buf strings.Builder
queueMu sync.Mutex
queue chan string
closed bool
closeOnce sync.Once
}
// newBotEventForwarder creates a forwarder that sends to all given targets.
// runtime may be nil — Emit calls are then no-ops.
func newBotEventForwarder(runtime *desktopBotRuntime, targets []botForwardTarget) *botEventForwarder {
f := &botEventForwarder{
runtime: runtime,
targets: targets,
queue: make(chan string, botForwardQueueSize),
}
go f.run()
return f
}
// Emit implements event.Sink. It forwards text and lifecycle events to the
// connected bot channels; reasoning, tool dispatch, and other internal events
// are dropped to avoid noisy IM output.
func (f *botEventForwarder) Emit(e event.Event) {
if f.runtime == nil || len(f.targets) == 0 {
return
}
switch e.Kind {
case event.TurnStarted:
f.mu.Lock()
f.buf.Reset()
f.mu.Unlock()
case event.Text:
f.mu.Lock()
f.buf.WriteString(e.Text)
size := f.buf.Len()
f.mu.Unlock()
// Flush opportunistically when the buffer crosses a threshold, so long
// streams (e.g. "tell me three jokes") produce multiple messages.
if size >= 400 {
f.flush()
}
case event.TurnDone:
f.flush()
f.Close()
case event.ApprovalRequest:
// The heartbeat turn belongs to the desktop tab controller, not the bot
// gateway session, so remote /approve replies cannot satisfy this ID.
text := "⚠️ 需要在 Reasonix 桌面端批准操作: " + e.Approval.Tool + " — " + e.Approval.Subject
text += "\n请回到桌面窗口处理。"
f.sendToAll(text)
case event.AskRequest:
var qb strings.Builder
qb.WriteString("❓ 需要在 Reasonix 桌面端回答问题:\n")
for i, q := range e.Ask.Questions {
if i > 0 {
qb.WriteString("\n")
}
qb.WriteString(q.Prompt)
}
qb.WriteString("\n请回到桌面窗口处理。")
f.sendToAll(qb.String())
case event.Notice:
if e.Level == event.LevelWarn {
f.sendToAll("⚠️ " + e.Text)
}
case event.CompactionStarted:
f.sendToAll("🔄 正在压缩上下文...")
}
}
// flush sends the accumulated buffer as one message per target channel.
func (f *botEventForwarder) flush() {
f.mu.Lock()
text := strings.TrimSpace(f.buf.String())
if text == "" {
f.mu.Unlock()
return
}
f.buf.Reset()
f.mu.Unlock()
f.sendToAll(text)
}
// sendToAll dispatches text to every target channel. Errors are logged and
// non-fatal; a failed target does not block other targets.
func (f *botEventForwarder) sendToAll(text string) {
text = strings.TrimSpace(text)
if f.runtime == nil || len(f.targets) == 0 || text == "" {
return
}
f.queueMu.Lock()
defer f.queueMu.Unlock()
if f.closed {
return
}
select {
case f.queue <- text:
default:
log.Printf("[bot-forward] send queue full; dropping message for %d target(s)", len(f.targets))
}
}
func (f *botEventForwarder) run() {
for text := range f.queue {
f.sendToAllNow(text)
}
}
func (f *botEventForwarder) sendToAllNow(text string) {
for _, tgt := range f.targets {
ctx, cancel := context.WithTimeout(context.Background(), botForwardSendTimeout)
_, err := f.runtime.SendToAdapter(ctx, tgt.ConnID, tgt.Domain, bot.OutboundMessage{
ChatID: tgt.ChatID,
ChatType: tgt.ChatType,
Text: text,
})
cancel()
if err != nil {
log.Printf("[bot-forward] send to %s/%s failed: %v", tgt.ConnID, tgt.ChatType, err)
}
}
}
func (f *botEventForwarder) Close() {
f.closeOnce.Do(func() {
f.flush()
f.queueMu.Lock()
f.closed = true
close(f.queue)
f.queueMu.Unlock()
})
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"context"
"io"
"log/slog"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/bot"
"reasonix/internal/event"
)
type desktopForwardTestAdapter struct {
platform bot.Platform
name string
messages chan bot.InboundMessage
entered chan struct{}
release chan struct{}
sent chan bot.OutboundMessage
once sync.Once
}
func newDesktopForwardTestAdapter() *desktopForwardTestAdapter {
return &desktopForwardTestAdapter{
platform: bot.PlatformFeishu,
name: "forward-test",
messages: make(chan bot.InboundMessage),
entered: make(chan struct{}),
release: make(chan struct{}),
sent: make(chan bot.OutboundMessage, 8),
}
}
func (a *desktopForwardTestAdapter) Platform() bot.Platform { return a.platform }
func (a *desktopForwardTestAdapter) Name() string { return a.name }
func (a *desktopForwardTestAdapter) Start(context.Context) error {
return nil
}
func (a *desktopForwardTestAdapter) Stop() error { return nil }
func (a *desktopForwardTestAdapter) SendTyping(context.Context, string) error {
return nil
}
func (a *desktopForwardTestAdapter) Messages() <-chan bot.InboundMessage {
return a.messages
}
func (a *desktopForwardTestAdapter) Send(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
a.once.Do(func() { close(a.entered) })
select {
case <-a.release:
case <-ctx.Done():
return bot.SendResult{}, ctx.Err()
}
a.sent <- msg
return bot.SendResult{MessageID: "sent"}, nil
}
func newDesktopForwardTestRuntime(adapter bot.Adapter) *desktopBotRuntime {
gw := bot.NewGatewayWithAdapterBindings(bot.GatewayConfig{}, []bot.AdapterBinding{{
ID: "feishu-lark",
Domain: "lark",
Platform: bot.PlatformFeishu,
Adapter: adapter,
}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
return &desktopBotRuntime{gw: gw}
}
func TestBotEventForwarderDoesNotBlockEventEmissionOnSlowSend(t *testing.T) {
adapter := newDesktopForwardTestAdapter()
forwarder := newBotEventForwarder(newDesktopForwardTestRuntime(adapter), []botForwardTarget{{
ConnID: "feishu-lark",
Domain: "lark",
ChatID: "oc-group-1",
ChatType: bot.ChatGroup,
}})
done := make(chan struct{})
go func() {
forwarder.Emit(event.Event{Kind: event.Text, Text: strings.Repeat("x", 400)})
close(done)
}()
select {
case <-done:
case <-time.After(500 * time.Millisecond):
t.Fatal("Emit blocked behind slow bot send")
}
select {
case <-adapter.entered:
case <-time.After(500 * time.Millisecond):
t.Fatal("adapter send did not start")
}
forwarder.Close()
close(adapter.release)
select {
case <-adapter.sent:
case <-time.After(500 * time.Millisecond):
t.Fatal("queued bot message was not sent after adapter release")
}
}
func TestBotEventForwarderApprovalNoticeDoesNotExposeReplyID(t *testing.T) {
adapter := newDesktopForwardTestAdapter()
close(adapter.release)
forwarder := newBotEventForwarder(newDesktopForwardTestRuntime(adapter), []botForwardTarget{{
ConnID: "feishu-lark",
Domain: "lark",
ChatID: "oc-group-1",
ChatType: bot.ChatGroup,
}})
forwarder.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
ID: "approval-1",
Tool: "shell",
Subject: "run command",
}})
forwarder.Close()
select {
case msg := <-adapter.sent:
if strings.Contains(msg.Text, "approval-1") || strings.Contains(msg.Text, "/approve") {
t.Fatalf("approval notice exposed unusable reply routing: %q", msg.Text)
}
if !strings.Contains(msg.Text, "桌面") {
t.Fatalf("approval notice = %q, want desktop guidance", msg.Text)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("approval notice was not sent")
}
}
+76
View File
@@ -242,3 +242,79 @@ func (r *desktopBotRuntime) snapshot() BotRuntimeStatusView {
defer r.mu.Unlock()
return r.status
}
// updateConnectionToolApprovalMode updates a connection's tool approval mode
// on the running gateway without restarting. Returns true if updated, false if
// the gateway is not running or the connection is unknown.
func (r *desktopBotRuntime) updateConnectionToolApprovalMode(connID, mode string) bool {
r.mu.Lock()
defer r.mu.Unlock()
if r.gw == nil {
return false
}
mode = normalizeBotConnectionToolApprovalMode(mode)
// Update ConnectionChannels in the internal GatewayConfig so new sessions
// pick up the mode. Existing sessions are updated by the gateway directly.
r.gw.UpdateConnectionToolApprovalMode(connID, mode)
return true
}
// SendToAdapter sends a message through the running gateway's adapter
// identified by connID. Returns an error if the gateway is not running
// or no matching adapter is found.
func (r *desktopBotRuntime) SendToAdapter(ctx context.Context, connID, domain string, msg bot.OutboundMessage) (bot.SendResult, error) {
r.mu.Lock()
gw := r.gw
r.mu.Unlock()
if gw == nil {
return bot.SendResult{}, nil // gateway not running — silent no-op
}
return gw.SendToAdapter(ctx, connID, domain, msg)
}
// Running returns true if the bot gateway is currently active.
func (r *desktopBotRuntime) Running() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.gw != nil
}
// ForwardTargets returns the list of bot forward targets derived from the
// current config's bot connections and their session mappings. Each mapping
// produces one target (connID + chatID + chatType) for event forwarding.
func (r *desktopBotRuntime) ForwardTargets(cfg *config.Config) []botForwardTarget {
if cfg == nil {
return nil
}
var targets []botForwardTarget
seen := make(map[botForwardTarget]bool)
for _, conn := range cfg.Bot.Connections {
if !conn.Enabled {
continue
}
connID := botruntime.ConnectionRuntimeID(conn)
domain := strings.TrimSpace(conn.Domain)
for _, sm := range conn.SessionMappings {
remoteID := strings.TrimSpace(sm.RemoteID)
if remoteID == "" {
continue
}
chatType := bot.ChatDM
if sm.ChatType != "" {
chatType = bot.ChatType(sm.ChatType)
}
target := botForwardTarget{
ConnID: connID,
Domain: domain,
ChatID: remoteID,
ChatType: chatType,
}
if seen[target] {
continue
}
seen[target] = true
targets = append(targets, target)
}
}
return targets
}
+31
View File
@@ -63,6 +63,37 @@ func TestDesktopBotRuntimePlanStopsWhenBotDisabled(t *testing.T) {
}
}
func TestDesktopBotRuntimeForwardTargetsDeduplicatesMappedChats(t *testing.T) {
cfg := config.Default()
cfg.Bot.Connections = []config.BotConnectionConfig{{
ID: "feishu-lark",
Provider: "feishu",
Domain: "lark",
Enabled: true,
SessionMappings: []config.BotConnectionSessionMapping{
{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-1"},
{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-2"},
{RemoteID: "oc-dm-1", ChatType: string(bot.ChatDM), UserID: "ou-user-1"},
},
}}
targets := newDesktopBotRuntime().ForwardTargets(cfg)
if len(targets) != 2 {
t.Fatalf("targets = %+v, want one group target plus one dm target", targets)
}
seen := map[string]bool{}
for _, target := range targets {
key := target.ConnID + "|" + target.Domain + "|" + target.ChatID + "|" + string(target.ChatType)
if seen[key] {
t.Fatalf("duplicate target %q in %+v", key, targets)
}
seen[key] = true
}
if !seen["feishu-lark|lark|oc-group-1|group"] || !seen["feishu-lark|lark|oc-dm-1|dm"] {
t.Fatalf("targets = %+v, want group and dm targets", targets)
}
}
func TestDesktopBotRuntimeConfigUsesUserBotSettings(t *testing.T) {
isolateDesktopUserDirs(t)
+83
View File
@@ -25,6 +25,28 @@ func seedCheckpoint(t *testing.T, ckptDir string, c checkpoint.Checkpoint) {
}
}
func assertCheckpointFilesEncodeAsArray(t *testing.T, metas []CheckpointMeta) {
t.Helper()
raw, err := json.Marshal(metas)
if err != nil {
t.Fatal(err)
}
var payload []struct {
Files json.RawMessage `json:"files"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatal(err)
}
for i, item := range payload {
if string(item.Files) == "null" {
t.Fatalf("checkpoint %d files encoded as null; frontend expects []", i)
}
if len(item.Files) == 0 || item.Files[0] != '[' {
t.Fatalf("checkpoint %d files encoded as %s, want JSON array", i, item.Files)
}
}
}
// TestCheckpointsCanCodePropagatesToEarlierTurns covers #3438: RestoreCode(turn)
// reverts files touched in that turn or any later one, so a turn with no file
// changes of its own can still rewind code when a later turn changed files. The
@@ -68,4 +90,65 @@ func TestCheckpointsCanCodePropagatesToEarlierTurns(t *testing.T) {
if got[2] {
t.Error("turn 2 is after the last file-bearing turn, should NOT allow code rewind")
}
if metas[0].TurnFileCount != 0 {
t.Fatalf("turn 0 file count = %d, want 0 for this turn", metas[0].TurnFileCount)
}
if metas[1].TurnFileCount != 1 {
t.Fatalf("turn 1 file count = %d, want 1 for this turn", metas[1].TurnFileCount)
}
if len(metas[0].Files) != 1 || metas[0].Files[0] != "a.txt" {
t.Fatalf("turn 0 cumulative files = %#v, want [a.txt]", metas[0].Files)
}
if metas[0].FileCount != 1 || metas[0].FilesTruncated {
t.Fatalf("turn 0 file summary = count %d truncated %v, want count 1 truncated false", metas[0].FileCount, metas[0].FilesTruncated)
}
if len(metas[2].Files) != 0 {
t.Fatalf("turn 2 cumulative files = %#v, want empty", metas[2].Files)
}
assertCheckpointFilesEncodeAsArray(t, metas)
}
func TestCheckpointsForTabLimitsCumulativeFilePreview(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "s.jsonl")
ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
t.Fatal(err)
}
content := "old"
files := make([]checkpoint.FileSnap, 0, checkpointFilePreviewLimit+5)
for i := 0; i < checkpointFilePreviewLimit+5; i++ {
files = append(files, checkpoint.FileSnap{Path: "file-" + strconv.Itoa(1000+i) + ".txt", Content: &content})
}
now := time.Now()
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 0, Time: now, Prompt: "before edits", MsgIndex: 0})
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 1, Time: now, Prompt: "edit many files", MsgIndex: 2, Files: files})
ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
ctrl.SetSessionPath(sessionPath)
app := &App{}
app.setTestCtrl(ctrl, "test")
metas := app.CheckpointsForTab("test")
if len(metas) != 2 {
t.Fatalf("checkpoints = %d, want 2", len(metas))
}
if metas[0].FileCount != checkpointFilePreviewLimit+5 {
t.Fatalf("turn 0 cumulative file count = %d, want %d", metas[0].FileCount, checkpointFilePreviewLimit+5)
}
if len(metas[0].Files) != checkpointFilePreviewLimit {
t.Fatalf("turn 0 preview files = %d, want %d", len(metas[0].Files), checkpointFilePreviewLimit)
}
if !metas[0].FilesTruncated {
t.Fatal("turn 0 should mark file preview as truncated")
}
if metas[0].TurnFileCount != 0 {
t.Fatalf("turn 0 file count = %d, want 0 for this turn", metas[0].TurnFileCount)
}
if metas[1].TurnFileCount != checkpointFilePreviewLimit+5 {
t.Fatalf("turn 1 file count = %d, want %d", metas[1].TurnFileCount, checkpointFilePreviewLimit+5)
}
assertCheckpointFilesEncodeAsArray(t, metas)
}
+2 -70
View File
@@ -9,21 +9,8 @@ import (
"reasonix/internal/fileutil"
)
// credentialsPath is the file fallback for the reasonix-owned global credential
// store. The settings panel writes through config.SetCredential instead, so
// systems with a usable keyring do not need to store secrets in this file.
func credentialsPath() string {
if p := config.UserCredentialsPath(); p != "" {
return p
}
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".env")
}
return ".env"
}
// upsertDotEnv stores KEY=value in the configured global credential store and
// applies it to the running process so a rebuild picks it up without a restart.
// upsertDotEnv stores KEY=value in Reasonix's global .env and applies it to the
// running process so a rebuild picks it up without a restart.
func upsertDotEnv(key, value string) error {
_, err := config.SetCredential(key, value)
return err
@@ -144,58 +131,3 @@ func removeEnvFile(path, key string) error {
}
return os.Unsetenv(key)
}
// promoteProviderKeysToCredentials copies any configured provider api_key_env that
// currently resolves (from a project .env, ~/.env, or the OS env) into the global
// credential store when it isn't there yet, so a key set for one workspace follows
// the user across every project. Promoted keys are then stripped from ~/.env so the
// credential store is the single source of truth; a project's own .env is
// user-owned and left untouched.
func promoteProviderKeysToCredentials(cfg *config.Config) {
for _, p := range cfg.Providers {
env := strings.TrimSpace(p.APIKeyEnv)
if env == "" || config.CredentialStored(env) {
continue
}
val := os.Getenv(env)
if val == "" {
continue
}
if _, err := config.SetCredential(env, val); err != nil {
continue
}
removeHomeEnvKey(env)
}
}
// removeHomeEnvKey deletes a single KEY=value assignment from ~/.env (the legacy
// fallback the old migration wrote to), leaving every other line intact. No-op when
// ~/.env is absent or the credentials store resolves to ~/.env itself.
func removeHomeEnvKey(key string) {
home, err := os.UserHomeDir()
if err != nil {
return
}
path := filepath.Join(home, ".env")
if sameConfigPath(path, credentialsPath()) {
return
}
data, err := os.ReadFile(path)
if err != nil {
return
}
var kept []string
removed := false
for _, raw := range strings.Split(string(data), "\n") {
check := strings.TrimPrefix(strings.TrimSpace(raw), "export ")
if k, _, ok := strings.Cut(check, "="); ok && strings.TrimSpace(k) == key {
removed = true
continue
}
kept = append(kept, raw)
}
if !removed {
return
}
_ = os.WriteFile(path, []byte(strings.Join(kept, "\n")), 0o600)
}
+7 -49
View File
@@ -65,63 +65,21 @@ func TestRemoveEnvFileDeletesKeyAndUnsetsProcessEnv(t *testing.T) {
}
}
// TestPromoteProviderKeysLiftsProjectKeyAndStripsHomeEnv proves a provider key
// that resolves only from a project .env / ~/.env is copied into the global
// credentials store, removed from ~/.env, and that unrelated env vars are ignored.
func TestPromoteProviderKeysLiftsProjectKeyAndStripsHomeEnv(t *testing.T) {
func TestLegacyHomeEnvProviderKeyIsNotPromoted(t *testing.T) {
home := isolateDesktopUserDirs(t)
homeEnv := filepath.Join(home, ".env")
if err := os.WriteFile(homeEnv, []byte("DEEPSEEK_API_KEY=sk-test\nNPM_TOKEN=secret\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("DEEPSEEK_API_KEY", "sk-test")
t.Setenv("NPM_TOKEN", "secret")
promoteProviderKeysToCredentials(config.Default())
cred, err := os.ReadFile(config.UserCredentialsPath())
if err != nil {
t.Fatalf("credentials not written: %v", err)
if _, err := config.LoadForRoot(t.TempDir()); err != nil {
t.Fatalf("LoadForRoot: %v", err)
}
if !strings.Contains(string(cred), "DEEPSEEK_API_KEY=sk-test") {
t.Errorf("provider key not promoted to credentials:\n%s", cred)
if data, err := os.ReadFile(config.UserCredentialsPath()); err == nil && strings.Contains(string(data), "DEEPSEEK_API_KEY") {
t.Errorf("legacy ~/.env provider key must not be imported:\n%s", data)
}
if strings.Contains(string(cred), "NPM_TOKEN") {
t.Errorf("non-provider env var must not be promoted:\n%s", cred)
}
rest, _ := os.ReadFile(homeEnv)
if strings.Contains(string(rest), "DEEPSEEK_API_KEY") {
t.Errorf("promoted key must be stripped from ~/.env:\n%s", rest)
}
if !strings.Contains(string(rest), "NPM_TOKEN=secret") {
t.Errorf("unrelated ~/.env line must survive:\n%s", rest)
}
}
// TestPromoteProviderKeysLeavesExistingCredentialsKey proves promotion never
// overwrites a key already in the credentials store and leaves ~/.env untouched.
func TestPromoteProviderKeysLeavesExistingCredentialsKey(t *testing.T) {
home := isolateDesktopUserDirs(t)
if err := os.MkdirAll(filepath.Dir(config.UserCredentialsPath()), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(config.UserCredentialsPath(), []byte("DEEPSEEK_API_KEY=sk-global\n"), 0o600); err != nil {
t.Fatal(err)
}
homeEnv := filepath.Join(home, ".env")
if err := os.WriteFile(homeEnv, []byte("DEEPSEEK_API_KEY=sk-stale\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("DEEPSEEK_API_KEY", "sk-global")
promoteProviderKeysToCredentials(config.Default())
cred, _ := os.ReadFile(config.UserCredentialsPath())
if !strings.Contains(string(cred), "DEEPSEEK_API_KEY=sk-global") {
t.Errorf("existing credentials key was changed:\n%s", cred)
}
if data, err := os.Stat(homeEnv); err != nil || data.Size() == 0 {
t.Errorf("~/.env should be untouched when key already global, err=%v", err)
if !strings.Contains(string(rest), "DEEPSEEK_API_KEY=sk-test") || !strings.Contains(string(rest), "NPM_TOKEN=secret") {
t.Errorf("legacy ~/.env should be left untouched:\n%s", rest)
}
}
+3
View File
@@ -13,3 +13,6 @@ wailsjs
# Wails dev install-checksum cache
package.json.md5
# This project uses pnpm (see wails.json `frontend:install`); reject the npm lockfile.
package-lock.json
-4662
View File
File diff suppressed because it is too large. Load diff
+10 -7
View File
@@ -10,31 +10,34 @@
"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__/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__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/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__/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__/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",
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
"test: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__/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__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/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__/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__/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"
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
},
"dependencies": {
"@gsap/react": "^2.1.2",
"@tanstack/react-virtual": "^3.14.2",
"@tanstack/react-virtual": "^3.14.3",
"gsap": "^3.15.0",
"highlight.js": "^11.10.0",
"katex": "^0.17.0",
"lucide-react": "^1.18.0",
"lucide-react": "^1.21.0",
"mermaid": "^11.16.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0"
"remark-math": "^6.0.0",
"svg-pan-zoom": "^3.6.2",
"zustand": "^5.0.14"
},
"devDependencies": {
"@types/jsdom": "^28.0.3",
"@types/node": "^25.9.3",
"@types/node": "^26.0.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"@vitejs/plugin-react": "^6.0.2",
"jsdom": "^29.1.1",
"terser": "^5.48.0",
"tsx": "^4.22.4",
+865 -359
View File
File diff suppressed because it is too large. Load diff
@@ -16,7 +16,16 @@ const transpiled = ts.transpileModule(source, {
}).outputText;
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled).toString("base64")}`;
const { shouldShowTodoPanel, todoDismissalKey } = await import(moduleUrl);
const {
dismissedTodoKeyForScope,
scopedTodoBatchKey,
scopedTodoDismissalKey,
shouldOpenTodoPanelByDefault,
shouldShowTodoPanel,
todoBatchKey,
todoDismissalKey,
todoPanelScope,
} = await import(moduleUrl);
const completedTodos = [
{ content: "Inspect the report", status: "completed" },
@@ -42,6 +51,66 @@ assert.equal(
false,
"a user dismissal still hides that exact todo list",
);
const completedKey = todoDismissalKey(completedTodos);
const dismissedBySession = new Set([scopedTodoDismissalKey("session:a", completedKey)]);
assert.equal(
shouldShowTodoPanel(completedKey, dismissedTodoKeyForScope("session:a", dismissedBySession, completedKey), completedTodos),
false,
"a completed todo dismissal hides the list in the session where it was closed",
);
assert.equal(
shouldShowTodoPanel(completedKey, dismissedTodoKeyForScope("session:b", dismissedBySession, completedKey), completedTodos),
true,
"a completed todo dismissal in one session does not hide another session's list",
);
dismissedBySession.add(scopedTodoDismissalKey("session:b", completedKey));
assert.equal(
shouldShowTodoPanel(completedKey, dismissedTodoKeyForScope("session:a", dismissedBySession, completedKey), completedTodos),
false,
"closing another session's todo does not forget the first session dismissal",
);
assert.equal(
todoPanelScope({
activeTabId: "tab-a",
activeTab: {
id: "tab-a",
scope: "project",
workspaceRoot: "/repo",
topicId: "topic-a",
sessionPath: "/sessions/a.jsonl",
},
}),
"session:/sessions/a.jsonl",
"a restored history session uses its session path as the todo panel scope",
);
assert.equal(
todoPanelScope({
activeTabId: "tab-b",
activeTab: {
id: "tab-a",
scope: "project",
workspaceRoot: "/repo",
topicId: "topic-a",
sessionPath: "/sessions/a.jsonl",
},
eventChannel: "desktop-events",
}),
"tab:tab-b",
"a stale active-tab fallback must not scope dismissal to the previous session",
);
assert.equal(
todoPanelScope({
activeTabId: "tab-c",
activeTab: {
id: "tab-c",
scope: "project",
workspaceRoot: "/repo",
topicId: "topic-a",
},
}),
"tab:tab-c",
"an unsaved topic session uses its tab id so sibling sessions do not share todo state",
);
assert.equal(shouldShowTodoPanel(null, null, completedTodos), false, "no canonical todo item means no panel");
assert.equal(shouldShowTodoPanel("todo-empty", null, []), false, "empty todo lists do not render a panel");
@@ -61,6 +130,36 @@ assert.notEqual(
todoDismissalKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }]),
"real progress produces a fresh dismissal key",
);
assert.equal(
todoBatchKey(activeTodos),
todoBatchKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }]),
"status progress stays in the same todo batch",
);
assert.equal(
scopedTodoBatchKey("session:a", todoBatchKey(activeTodos)),
scopedTodoBatchKey("session:a", todoBatchKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }])),
"status progress keeps the same scoped open-state key",
);
assert.notEqual(
scopedTodoBatchKey("session:a", todoBatchKey(activeTodos)),
scopedTodoBatchKey("session:b", todoBatchKey(activeTodos)),
"the same task list in a different session gets isolated open state",
);
assert.notEqual(
todoBatchKey(activeTodos),
todoBatchKey([{ content: "Run a different task", status: "in_progress" }]),
"new task content creates a new todo batch",
);
assert.equal(
shouldOpenTodoPanelByDefault(activeTodos),
true,
"new incomplete todo batches open by default",
);
assert.equal(
shouldOpenTodoPanelByDefault(completedTodos),
false,
"completed todo batches collapse by default",
);
const iterations = 200_000;
const started = performance.now();
File diff suppressed because it is too large. Load diff
@@ -7,6 +7,11 @@ import { fileURLToPath } from "node:url";
const testDir = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
const appChromeSource = readFileSync(resolve(testDir, "../components/AppChrome.tsx"), "utf8");
const commandPaletteSource = readFileSync(resolve(testDir, "../components/CommandPalette.tsx"), "utf8");
const projectTreeSource = readFileSync(resolve(testDir, "../components/ProjectTree.tsx"), "utf8");
const topicShortcutsSource = readFileSync(resolve(testDir, "../lib/topicShortcuts.ts"), "utf8");
const transcriptSource = readFileSync(resolve(testDir, "../components/Transcript.tsx"), "utf8");
const layoutStoreSource = readFileSync(resolve(testDir, "../store/layout.ts"), "utf8");
const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
let passed = 0;
@@ -64,6 +69,12 @@ ok(
"AppChrome markup includes classic tab strip containers",
);
ok(
/const WORKSPACE_PANEL_DEFAULT_OPEN = false;/.test(layoutStoreSource) &&
/workspacePanelOpen:\s*WORKSPACE_PANEL_DEFAULT_OPEN/.test(layoutStoreSource),
"right dock starts collapsed on launch",
);
ok(
finalDeclaration(".app-chrome__tab-strip", "overflow") === "hidden",
"AppChrome tab strip clips tabs to the available chrome width",
@@ -110,7 +121,7 @@ ok(
);
ok(
/\{!workbenchChromeHidden && \(/.test(appSource),
/\{!appChromeHidden && \(/.test(appSource),
"workbench skips rendering the top AppChrome row",
);
@@ -119,6 +130,104 @@ ok(
"workbench keeps chrome controls in the topic bar",
);
ok(
/const \[transcriptRevealSignal, setTranscriptRevealSignal\] = useState\(0\);/.test(appSource) &&
/revealActiveSignal=\{tabRevealSignal\}/.test(appSource) &&
/revealSignal=\{transcriptRevealSignal\}/.test(appSource),
"transcript bottom reveal is decoupled from tab-strip reveal",
);
const tabsReorderBlock = appSource.match(/const handleTabsReorder = useCallback\([\s\S]*?\n \}, \[refreshTabMetas, reorderTabs\]\);/)?.[0] ?? "";
ok(
/setTabRevealSignal/.test(tabsReorderBlock) && !/setTranscriptRevealSignal/.test(tabsReorderBlock),
"tab reordering refreshes the tab strip without snapping the transcript",
);
ok(
/aria-label=\{t\("transcript\.jumpToBottom"\)\}/.test(transcriptSource) &&
/title=\{t\("transcript\.jumpToBottom"\)\}/.test(transcriptSource),
"jump-to-bottom affordance uses localized transcript text",
);
ok(
/setActive\(items\.length > 0 \? 0 : -1\)/.test(commandPaletteSource),
"command palette highlights the first item when opened with an empty query",
);
ok(
/topicShortcutIndexFromEvent\(event, desktopPlatform\)/.test(appSource) &&
/useTopicShortcuts\(!sidebarCollapsed, desktopPlatform\)/.test(appSource),
"topic shortcuts use the resolved desktop platform",
);
ok(
/topicShortcutLabel\(shortcutIndex, shortcutPlatform\)/.test(projectTreeSource),
"topic shortcut badges render the platform-specific modifier",
);
ok(
/if \(!enabled\) hideBadges\(\);/.test(topicShortcutsSource) &&
/if \(heldRef\.current\) hideBadges\(\);/.test(topicShortcutsSource) &&
/window\.removeEventListener\("blur", onBlur\);\s*hideBadges\(\);/.test(topicShortcutsSource),
"topic shortcut badge state is cleared when disabled, interrupted, or cleaned up",
);
ok(
/const \[rewindCommitting, setRewindCommitting\] = useState\(false\);/.test(appSource) &&
/rewindStateRef\.current = null;/.test(appSource) &&
/setRewindCommitting\(true\);/.test(appSource),
"committing optimistic rewind clears undo state before awaiting the backend",
);
ok(
/const controllerReady = state\.meta\?\.ready === true && !state\.backendActivationPending;/.test(appSource) &&
/if \(!controllerReady\) return;\s*void commitThenSend\(text\)\.catch/.test(appSource) &&
/onPrompt=\{handleTranscriptPrompt\}/.test(appSource) &&
/submitDisabled=\{!controllerReady\}/.test(appSource),
"welcome prompts and composer submit share the controller readiness gate",
);
ok(
/const transcriptHydrating = state\.hydrating && !state\.hydrateHistoryLoaded;/.test(appSource) &&
/hydrating=\{transcriptHydrating\}/.test(appSource),
"Welcome is suppressed only until transcript history has loaded",
);
const navigationBlock = appSource.match(/const runNavigationRequest = useCallback\([\s\S]*?\n \}, \[[^\]]*singleSurfaceLayout[^\]]*\]\);/)?.[0] ?? "";
ok(
/const navigationRunningRef = useRef\(false\);/.test(appSource) &&
/const navigationPendingRef = useRef<PendingDesktopNavigationRequest \| null>\(null\);/.test(appSource) &&
/const runNavigationRequest = useCallback\(async \(request: PendingDesktopNavigationRequest\)/.test(appSource) &&
/const latest = \(\) => request\.seq === navigationSeqRef\.current;/.test(appSource) &&
/return activateTopic\(scope, workspaceRoot, topicId/.test(appSource) &&
/return openTopicSession\(scope, workspaceRoot, topicId/.test(appSource) &&
/return openGlobalTab\(topicId\)/.test(appSource) &&
/return openProjectTab\(workspaceRoot, topicId\)/.test(appSource) &&
/enqueueNavigationRequest\([\s\S]*runningRef: navigationRunningRef, pendingRef: navigationPendingRef/.test(appSource) &&
!/openTopicQueueRef\.current\.catch\(\(\) => \{\}\)\.then/.test(appSource) &&
/const refreshLatestTabMetas = async \(\): Promise<TabMeta\[]> => \{[\s\S]*if \(latest\(\)\) setTabMetas\(tabs\);/.test(navigationBlock) &&
/if \(!latest\(\)\) return;[\s\S]*seedActiveTabMeta\(openedTab\);[\s\S]*void refreshLatestTabMetas\(\);/.test(navigationBlock),
"desktop navigation coalesces pending requests, ignores stale results, and seeds active tab metadata before background refresh",
);
ok(
/return enqueueNavigation\(\{ kind: "topic", scope, workspaceRoot, topicId, sessionPath \}\);/.test(appSource) &&
/enqueueNavigation\(\{ kind: "blank", scope, workspaceRoot: scope === "project" \? workspaceRoot : "" \}\)/.test(appSource) &&
/return enqueueNavigation\(\{ kind: "sidebar-im", connection \}\);/.test(appSource) &&
/return enqueueNavigation\(\{ kind: "resume-session", session \}\);/.test(appSource),
"topic, blank, IM, and history navigation all use the shared coalescing path",
);
ok(
!/await resumeSession\(session\.path, targetTab\.id\);/.test(navigationBlock),
"history navigation does not re-resume a session that OpenTopicSession already pinned",
);
ok(
/<HeartbeatPanel[\s\S]*onOpenTopic=\{\(scope, workspaceRoot, topicId\) => \{[\s\S]*void handleOpenTopic\(scope, workspaceRoot, topicId\);[\s\S]*\}\}/.test(appSource),
"heartbeat topic navigation uses the guarded open-topic path",
);
for (const selector of [
".app--darwin .app-chrome--tabs",
":root[data-theme-style] .app--darwin .app-chrome--tabs",
@@ -143,6 +252,30 @@ for (const selector of [
);
}
for (const selector of [
".app--windows-frameless .app-chrome--native-tabs",
":root[data-theme-style] .app--windows-frameless .app-chrome--native-tabs",
]) {
const paddingRight = finalDeclaration(selector, "padding-right") ?? "";
ok(
finalDeclaration(selector, "--windows-frameless-titlebar-tools-offset") === "var(--windows-window-controls-safe)" &&
paddingRight.includes("--windows-frameless-titlebar-tools-offset") &&
paddingRight.includes("--chrome-panel-control-size") &&
!paddingRight.includes("--chrome-right-toggle-offset"),
`${selector} keeps titlebar tools fixed beside the Windows controls`,
);
}
for (const selector of [
".app--windows-frameless .app-chrome--native-tabs .app-chrome__panel-toggle--right",
":root[data-theme-style] .app--windows-frameless .app-chrome--native-tabs .app-chrome__panel-toggle--right",
]) {
ok(
finalDeclaration(selector, "right") === "calc(var(--windows-frameless-titlebar-tools-offset) + 8px)",
`${selector} stays fixed outside the Windows window controls`,
);
}
for (const selector of [
".layout--workbench-chrome-hidden",
":root[data-theme-style] .layout--workbench-chrome-hidden",
@@ -193,8 +326,8 @@ for (const selector of [
}
ok(
finalDeclaration(":root[data-theme-style] .layout--workbench-chrome-hidden .topicbar", "background") === "var(--chat-bg)",
"workbench topic bar uses a flat chat background",
finalDeclaration(":root[data-theme-style] .layout--workbench-chrome-hidden .topicbar", "background") === "var(--bg-elev)",
"workbench topic bar uses elevated background for light-mode white",
);
for (const selector of [
@@ -0,0 +1,229 @@
// Run: tsx src/__tests__/approval-modal-file-reference.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { ApprovalModal } from "../components/ApprovalModal";
import { LocaleProvider } from "../lib/i18n";
import type { AppBindings } from "../lib/bridge";
import type { WireApproval } 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(ms = 0): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitFor(label: string, predicate: () => boolean, timeoutMs = 1000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (predicate()) return;
await act(async () => {
await flushTimers(20);
});
}
ok(false, label);
}
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.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
globalThis.Event = dom.window.Event;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.InputEvent = dom.window.InputEvent;
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);
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
return dom;
}
function mockApp(methods: Partial<AppBindings>) {
window.go = {
main: {
App: {
...methods,
} as Partial<AppBindings> as AppBindings,
},
};
}
async function renderApproval(props: Partial<Parameters<typeof ApprovalModal>[0]> = {}) {
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
const revisions: string[] = [];
const activeStates: boolean[] = [];
const approval: WireApproval = {
id: "plan-approval",
tool: "exit_plan_mode",
subject: "Plan ready",
};
let currentProps: Parameters<typeof ApprovalModal>[0] = {
approval,
cwd: "/repo",
onAnswer: () => undefined,
onRevisePlan: (text) => revisions.push(text),
onExitPlan: () => undefined,
onStop: () => undefined,
onRevisionActiveChange: (active) => activeStates.push(active),
...props,
};
const paint = async (nextProps: Partial<Parameters<typeof ApprovalModal>[0]> = {}) => {
currentProps = { ...currentProps, ...nextProps };
await act(async () => {
root.render(
<LocaleProvider>
<ApprovalModal {...currentProps} />
</LocaleProvider>,
);
await flushTimers();
});
};
await paint();
return { root, revisions, activeStates, rerender: paint };
}
console.log("\napproval modal file references");
{
const dom = installDom();
mockApp({
ListDir: async () => [{ name: "src", isDir: true }, { name: "README.md", isDir: false }],
SearchFileRefs: async () => [],
});
const { root, revisions, rerender } = await renderApproval();
const reviseButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("Revise plan")) as HTMLButtonElement | undefined;
if (!reviseButton) throw new Error("revise button did not render");
await act(async () => {
reviseButton.click();
await flushTimers();
});
const textarea = document.querySelector(".plan-revision__input") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("plan revision textarea did not render");
await rerender({ insertRequest: { id: 1, text: "please inspect @" } });
await waitFor("plan revision @ text opens file suggestions", () => document.body.textContent?.includes("README.md") === true);
ok(document.body.textContent?.includes("README.md") === true, "plan revision @ text opens file suggestions");
const readmeButton = Array.from(document.querySelectorAll(".slashmenu__item")).find((button) => button.textContent?.includes("README.md")) as HTMLButtonElement | undefined;
if (!readmeButton) throw new Error("README file suggestion did not render");
await act(async () => {
readmeButton.dispatchEvent(new window.MouseEvent("mousedown", { bubbles: true, cancelable: true }));
await flushTimers();
});
eq(textarea.value, "please inspect @README.md ", "file suggestion completes inline in the plan revision");
const sendButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("Send update")) as HTMLButtonElement | undefined;
if (!sendButton) throw new Error("send revision button did not render");
await act(async () => {
sendButton.click();
await flushTimers(220);
});
eq(revisions.join(","), "please inspect @README.md", "submitted plan revision keeps the selected file reference");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const { root, activeStates, rerender } = await renderApproval();
const reviseButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("Revise plan")) as HTMLButtonElement | undefined;
if (!reviseButton) throw new Error("revise button did not render");
await act(async () => {
reviseButton.click();
await flushTimers();
});
const textarea = document.querySelector(".plan-revision__input") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("plan revision textarea did not render");
await rerender({ insertRequest: { id: 2, text: "@src/main.go" } });
eq(textarea.value, "@src/main.go", "workspace add-reference insert request targets the plan revision input");
ok(activeStates.includes(true), "plan revision reports itself as the active workspace insertion target");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const { root } = await renderApproval({
approval: {
id: "tool-approval",
tool: "bash",
subject: "npm run build\n\nRun the build command to verify frontend artifacts.",
},
});
const subject = document.querySelector(".approval-subject");
ok(subject != null, "tool approval shows its full subject by default");
eq(
subject?.textContent,
"npm run build\n\nRun the build command to verify frontend artifacts.",
"default-open tool approval keeps the complete subject visible",
);
ok(document.body.textContent?.includes("Hide") === true, "tool approval details start expanded");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,121 @@
// Run: tsx src/__tests__/ask-card-layout.test.ts
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 { AskCard } from "../components/AskCard";
import { LocaleProvider } from "../lib/i18n";
import type { QuestionAnswer, WireAsk } from "../lib/types";
const testDir = dirname(fileURLToPath(import.meta.url));
const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8");
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));
}
function installDom() {
const dom = new JSDOM("<!doctype html><html><head></head><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.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 style = document.createElement("style");
style.textContent = styles;
document.head.appendChild(style);
return dom;
}
console.log("\nask card layout");
{
const dom = installDom();
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
const answers: QuestionAnswer[][] = [];
const ask: WireAsk = {
id: "ask-superpowers-decision",
questions: [
{
id: "decision",
header: "Review",
prompt: "baoguanPutArchive needs a user-owned decision: fully align archive logic, or only repair the current compiler error?",
options: [
{ label: "Full alignment", description: "Reuse the archive flow and keep behavior consistent." },
{ label: "Minimal repair", description: "Touch only the failing path and keep the patch smaller." },
],
},
],
};
await act(async () => {
root.render(
React.createElement(LocaleProvider, null,
React.createElement(AskCard, {
ask,
onAnswer: (_id: string, next: QuestionAnswer[]) => answers.push(next),
onDismiss: () => undefined,
onStop: () => undefined,
}),
),
);
await flushTimers();
});
const card = document.querySelector(".prompt-shelf__card") as HTMLElement | null;
const meta = document.querySelector(".prompt-shelf__meta") as HTMLElement | null;
if (!card || !meta) throw new Error("ask prompt shelf did not render");
eq(meta.textContent, ask.questions[0].prompt, "ask question text remains complete in the prompt shelf");
const computed = window.getComputedStyle(meta);
eq(computed.whiteSpace, "normal", "ask question can wrap instead of staying on one line");
eq(computed.overflow, "visible", "ask question is not clipped by the prompt shelf");
eq(computed.textOverflow, "clip", "ask question does not render as an ellipsis-only preview");
eq(computed.overflowWrap, "anywhere", "long unspaced ask questions can break within the shelf");
ok(card.getAttribute("role") === "dialog", "ask prompt shelf keeps dialog semantics");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -26,6 +26,10 @@ function entry(name: string, isDir = false): DirEntry {
return { name, isDir };
}
function externalEntry(path: string, displayName: string): DirEntry {
return { name: displayName, path, displayName, isDir: false };
}
console.log("\nat-matches filter");
// 1. Nested file surfaces when fragment matches an intermediate segment.
@@ -92,5 +96,26 @@ console.log("\nat-matches filter");
);
}
// 6. External-folder search results can match against a friendly display name
// even when the submitted path is an opaque session token.
{
const entries: DirEntry[] = [];
const searchEntries = [externalEntry("__reasonix_external_folder/abc/Folder/src/outside.txt", "Folder With Spaces/src/outside.txt")];
const got = filterAtMatches(entries, searchEntries, "spaces");
eq(
got.map((e) => e.path),
["__reasonix_external_folder/abc/Folder/src/outside.txt"],
"external display name participates in filtering while preserving token path",
);
}
// 7. Dedup uses the submitted path when present.
{
const entries = [externalEntry("__reasonix_external_folder/abc/Folder/src/outside.txt", "outside.txt")];
const searchEntries = [externalEntry("__reasonix_external_folder/abc/Folder/src/outside.txt", "Folder With Spaces/src/outside.txt")];
const got = filterAtMatches(entries, searchEntries, "outside");
eq(got.length, 1, "external entries with the same submitted path dedup to one");
}
console.log(`\n${passed} passed, ${failed} failed\n`);
process.exit(failed === 0 ? 0 : 1);
@@ -1,6 +1,6 @@
// Run: tsx src/__tests__/bridge-drag-rejection.test.ts
import { isWailsNonFileDragError, isWailsNonFileDragErrorEvent } from "../lib/bridge";
import { isTransientWailsIPCError, isWailsNonFileDragError, isWailsNonFileDragErrorEvent } from "../lib/bridge";
let passed = 0;
let failed = 0;
@@ -58,6 +58,21 @@ eq(
false,
"keeps ErrorEvent invalid argument visible without a recent native file drag",
);
eq(
isTransientWailsIPCError(new DOMException("Failed to execute 'send' on 'WebSocket': Still in CONNECTING state.", "InvalidStateError")),
true,
"suppresses Wails IPC calls made before the websocket is open",
);
eq(
isTransientWailsIPCError(new TypeError("Cannot read properties of null (reading 'send')")),
true,
"suppresses Wails IPC calls made after the websocket is torn down",
);
eq(
isTransientWailsIPCError(new Error("backend returned an application error")),
false,
"keeps ordinary bridge failures visible",
);
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,42 @@
// Run: tsx src/__tests__/bridge-mock-approval-mode.test.ts
import { mockToolApprovalModeAfterModeChange } from "../lib/bridge";
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("\nbridge mock approval mode");
eq(
mockToolApprovalModeAfterModeChange("auto", "plan"),
"auto",
"legacy plan switch keeps explicit auto approval mode",
);
eq(
mockToolApprovalModeAfterModeChange("auto", "normal"),
"auto",
"legacy normal switch keeps explicit auto approval mode",
);
eq(
mockToolApprovalModeAfterModeChange("ask", "yolo"),
"yolo",
"legacy yolo switch still enables yolo approval mode",
);
eq(
mockToolApprovalModeAfterModeChange("yolo", "plan"),
"ask",
"legacy non-yolo switch clears yolo back to ask",
);
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -1,5 +1,12 @@
import { mcpServerLifecycleActions } from "../lib/mcpServerLifecycle";
import type { ServerView } from "../lib/types";
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { MCPServersSettingsPage } from "../components/CapabilitiesPanel";
import type { AppBindings } from "../lib/bridge";
import { LocaleProvider } from "../lib/i18n";
import { mcpServerLifecycleActions, mcpServerRetryableFromAvailableList } from "../lib/mcpServerLifecycle";
import type { Meta, ServerView, TabMeta } from "../lib/types";
function ok(value: unknown, message: string) {
if (!value) throw new Error(message);
@@ -21,16 +28,208 @@ function server(status: ServerView["status"]): ServerView {
const initializing = mcpServerLifecycleActions(server("initializing"));
ok(initializing.enabled, "initializing server should still be treated as enabled");
ok(initializing.showRetryInRow, "initializing server row should expose a retry/reset action");
ok(initializing.canReconnect, "initializing server details should expose reconnect");
ok(!initializing.showRetryInRow, "initializing server should not expose retry until it fails");
ok(!initializing.canReconnect, "initializing server should not expose reconnect while already connecting");
ok(!initializing.canConnectNow, "initializing server should not use the deferred connect-now action");
const connected = mcpServerLifecycleActions(server("connected"));
ok(!connected.showRetryInRow, "connected server row should keep the toggle UI");
ok(connected.canReconnect, "connected server details should expose reconnect");
const deferred = mcpServerLifecycleActions(server("deferred"));
ok(deferred.canConnectNow, "deferred server should expose connect now");
ok(!deferred.canReconnect, "deferred server should not show reconnect separately");
const manuallyConnected = mcpServerLifecycleActions({ ...server("connected"), autoStart: false, startIntent: "off", runtimeState: "ready" });
ok(manuallyConnected.enabled, "connected manual server should still render as enabled");
ok(!manuallyConnected.canConnectNow, "connected manual server should not expose connect-now");
ok(manuallyConnected.canReconnect, "connected manual server should expose reconnect");
const automaticIdle = mcpServerLifecycleActions({ ...server("deferred"), startIntent: "automatic" });
ok(!automaticIdle.canConnectNow, "automatic idle server should not look like a manual connector");
ok(!automaticIdle.canReconnect, "automatic idle server should wait for background connection or failure");
const failed = mcpServerLifecycleActions({ ...server("failed"), runtimeState: "issue" });
ok(failed.showRetryInRow, "failed server row should expose retry");
ok(mcpServerRetryableFromAvailableList(server("initializing")), "connecting server should be included in available-list retry all");
ok(mcpServerRetryableFromAvailableList({ ...server("deferred"), startIntent: "automatic" }), "automatic idle server should be included in available-list retry all");
ok(!mcpServerRetryableFromAvailableList(server("connected")), "connected server should be excluded from available-list retry all");
ok(!mcpServerRetryableFromAvailableList({ ...server("disabled"), startIntent: "off" }), "disabled server should be excluded from available-list retry all");
ok(!mcpServerRetryableFromAvailableList({ ...server("failed"), runtimeState: "issue" }), "failed server is handled by the failure banner retry all");
function flush(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 20; attempt += 1) {
await act(async () => {
await flush();
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
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.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);
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: () => ({
matches: true,
media: "(prefers-reduced-motion: reduce)",
onchange: null,
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
dispatchEvent: () => false,
}),
});
return dom;
}
function findButton(label: string): HTMLButtonElement | undefined {
return Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === label) as HTMLButtonElement | undefined;
}
console.log("capabilities panel MCP actions");
{
const dom = installDom();
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
const meta: Meta = { label: "test", ready: true, eventChannel: "test-channel", cwd: "/tmp/reasonix-test", workspaceRoot: "/tmp/reasonix-test" };
const tabs: TabMeta[] = [{
id: "tab-1",
scope: "project",
workspaceRoot: "/tmp/reasonix-test",
workspaceName: "reasonix-test",
topicId: "topic-1",
topicTitle: "Test",
label: "Test",
ready: true,
running: false,
mode: "normal",
toolApprovalMode: "auto",
active: true,
cwd: "/tmp/reasonix-test",
}];
let trustCalls = 0;
let bulkTrustCalls = 0;
let untrustCalls = 0;
let servers: ServerView[] = [{
name: "github",
transport: "stdio",
status: "connected",
configured: true,
autoStart: true,
tools: 2,
prompts: 0,
resources: 0,
toolList: [
{ name: "issue_read", description: "Read issues.", readOnlyHint: true },
{ name: "issue_write", description: "Write issues." },
],
trustedReadOnlyTools: [],
}];
window.go = {
main: {
App: {
Meta: async () => meta,
ListTabs: async () => tabs,
MCPServers: async () => servers.map((s) => ({
...s,
toolList: s.toolList?.map((tool) => ({ ...tool })),
trustedReadOnlyTools: [...(s.trustedReadOnlyTools ?? [])],
})),
TrustMCPServerTool: async (name: string, toolName: string) => {
trustCalls += 1;
servers = servers.map((s) => s.name === name ? { ...s, trustedReadOnlyTools: [...(s.trustedReadOnlyTools ?? []), toolName] } : s);
},
TrustMCPServerTools: async (name: string, toolNames: string[]) => {
bulkTrustCalls += 1;
servers = servers.map((s) => {
if (s.name !== name) return s;
const trusted = Array.from(new Set([...(s.trustedReadOnlyTools ?? []), ...toolNames]));
return { ...s, trustedReadOnlyTools: trusted };
});
},
UntrustMCPServerTool: async (name: string, toolName: string) => {
untrustCalls += 1;
servers = servers.map((s) => s.name === name ? { ...s, trustedReadOnlyTools: (s.trustedReadOnlyTools ?? []).filter((tool) => tool !== toolName) } : s);
},
} as Partial<AppBindings> as AppBindings,
},
};
await act(async () => {
root.render(React.createElement(LocaleProvider, null, React.createElement(MCPServersSettingsPage)));
await flush();
});
await waitFor("github server row", () => Boolean(document.querySelector(".cap-row__name")?.textContent?.includes("github")));
const disclosure = document.querySelector<HTMLButtonElement>(".cap-disclosure");
if (!disclosure) throw new Error("missing MCP disclosure button");
await act(async () => {
disclosure.click();
await flush();
});
const trustReadOnly = findButton("Pre-trust read-only (1)");
if (!trustReadOnly) throw new Error("missing bulk Pre-trust read-only button");
await act(async () => {
trustReadOnly.click();
await flush();
});
await waitFor("bulk trusted tool", () => servers[0]?.trustedReadOnlyTools?.includes("issue_read") ?? false);
const viewTools = findButton("View tools");
if (!viewTools) throw new Error("missing View tools button");
await act(async () => {
viewTools.click();
await flush();
});
await waitFor("trusted badge", () => Boolean(document.querySelector(".cap-tool-trust")?.textContent?.includes("Trusted")));
const untrust = findButton("Untrust");
if (!untrust) throw new Error("missing Untrust button");
await act(async () => {
untrust.click();
await flush();
});
await waitFor("untrusted tool", () => !(servers[0]?.trustedReadOnlyTools?.includes("issue_read") ?? false));
await waitFor("Pre-trust button", () => Boolean(findButton("Pre-trust")));
const trust = findButton("Pre-trust");
if (!trust) throw new Error("missing Pre-trust button");
await act(async () => {
trust.click();
await flush();
});
await waitFor("trusted badge", () => Boolean(document.querySelector(".cap-tool-trust")?.textContent?.includes("Trusted")));
ok(bulkTrustCalls === 1, "clicking Pre-trust read-only invokes the MCP bulk trust action once");
ok(untrustCalls === 1, "clicking Untrust invokes the MCP untrust action once");
ok(trustCalls === 1, "clicking Trust invokes the MCP trust action once");
ok(servers[0]?.trustedReadOnlyTools?.includes("issue_read") ?? false, "trusted raw tool name is added to the server snapshot");
await act(async () => {
root.unmount();
});
dom.window.close();
}
@@ -0,0 +1,86 @@
// Run: tsx src/__tests__/code-block-copy-position.test.tsx
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { JSDOM } from "jsdom";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import HljsCode from "../components/editors/HljsCode";
import { LocaleProvider } from "../lib/i18n";
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 notOk(value: unknown, label: string) {
ok(!value, label);
}
function renderCodeBlock() {
return renderToStaticMarkup(
createElement(
LocaleProvider,
null,
createElement(HljsCode, {
value: "const veryLongLine = 'x'.repeat(240);",
language: "ts",
maxHeight: 120,
}),
),
);
}
function selectorPresent(css: string, selector: string): boolean {
return css.includes(selector);
}
console.log("\ncode block copy position");
const markup = renderCodeBlock();
const dom = new JSDOM(markup);
const document = dom.window.document;
const wrap = document.querySelector(".code-block__wrap");
const pre = document.querySelector("pre.code.hljs");
const copy = document.querySelector(".code-block__copy");
ok(wrap, "HljsCode renders a positioned copy wrapper");
ok(pre, "HljsCode renders the highlighted pre");
ok(copy, "HljsCode renders the copy button");
ok(pre?.parentElement === wrap, "scrollable pre is inside the wrapper");
ok(copy?.parentElement === wrap, "copy button is a wrapper child");
notOk(pre?.contains(copy ?? null), "copy button is outside the scrollable pre");
ok(copy?.previousElementSibling === pre, "copy button follows pre as a sibling");
const stylesPath = fileURLToPath(new URL("../styles.css", import.meta.url));
const css = readFileSync(stylesPath, "utf8");
ok(selectorPresent(css, ".code-block__wrap:hover .code-block__copy"), "hover reveal targets the wrapper");
notOk(selectorPresent(css, ".code:hover .code-block__copy"), "hover reveal no longer depends on pre descendants");
ok(
selectorPresent(css, ".app--creation .msg--assistant .md .code:not([data-lang]) + .code-block__copy"),
"creation unlabelled code button styles target sibling copy buttons",
);
notOk(
selectorPresent(css, ".app--creation .msg--assistant .md .code:not([data-lang]) .code-block__copy"),
"creation unlabelled code styles do not target descendants inside pre",
);
ok(
selectorPresent(css, '.app--creation .msg--assistant .md .code[data-lang="bash"] + .code-block__copy'),
"creation shell code button styles target sibling copy buttons",
);
notOk(
selectorPresent(css, '.app--creation .msg--assistant .md .code[data-lang="bash"] .code-block__copy'),
"creation shell code styles do not target descendants inside pre",
);
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,138 @@
// Run: tsx src/__tests__/command-palette-interactions.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { CommandPalette, type PaletteItem } from "../components/CommandPalette";
import { LocaleProvider } from "../lib/i18n";
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;
}
}
async function flush() {
await 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.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.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
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 renderPalette() {
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
const calls = { close: 0, run: 0 };
const items: PaletteItem[] = [{
id: "cmd-new",
group: "Commands",
title: "New session",
run: () => {
calls.run += 1;
},
}];
await act(async () => {
root.render(
<LocaleProvider>
<CommandPalette
open
onClose={() => {
calls.close += 1;
}}
items={items}
placeholder="Search"
emptyText="No results"
/>
</LocaleProvider>,
);
await flush();
});
return { root, calls };
}
console.log("\ncommand palette interactions");
{
const dom = installDom();
const { root, calls } = await renderPalette();
const esc = document.querySelector<HTMLButtonElement>(".palette__esc");
ok(esc instanceof HTMLButtonElement, "header esc keycap is a real close button");
await act(async () => {
esc?.click();
await flush();
});
ok(calls.close === 1, "clicking header esc closes the command palette");
ok(calls.run === 0, "clicking header esc does not run the highlighted command");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const { root, calls } = await renderPalette();
const esc = document.querySelector<HTMLButtonElement>(".palette__esc");
if (!esc) throw new Error("missing header esc close button");
await act(async () => {
esc.focus();
esc.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
esc.click();
await flush();
});
ok(calls.close === 1, "Enter on focused header esc closes the command palette");
ok(calls.run === 0, "Enter on focused header esc does not run the highlighted command");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -4,9 +4,10 @@ import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { Composer } from "../components/Composer";
import { Composer, composerPickFileEntry } from "../components/Composer";
import { LocaleProvider } from "../lib/i18n";
import { ToastProvider } from "../lib/toast";
import type { AppBindings } from "../lib/bridge";
import type { CollaborationMode, ToolApprovalMode, TokenMode } from "../lib/types";
let passed = 0;
@@ -56,6 +57,8 @@ function installDom() {
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);
@@ -84,9 +87,15 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
const root = createRoot(rootEl);
const calls: {
send: string[];
submit: (string | undefined)[];
cancel: number;
clearGoal: number;
setCollaborationMode: CollaborationMode[];
} = {
send: [],
submit: [],
cancel: 0,
clearGoal: 0,
setCollaborationMode: [],
};
let currentProps: Parameters<typeof Composer>[0] = {
@@ -97,14 +106,22 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
goal: "",
cwd: "/repo",
modelLabel: "DeepSeek-R1",
onSend: (displayText) => calls.send.push(displayText),
onCancel: () => undefined,
onSend: (displayText, submitText) => {
calls.send.push(displayText);
calls.submit.push(submitText);
},
onCancel: () => {
calls.cancel += 1;
return undefined;
},
onCycleMode: () => {},
onSetMode: () => {},
onSetCollaborationMode: (mode) => calls.setCollaborationMode.push(mode),
onSetToolApprovalMode: () => {},
onToggleYoloApprovalMode: () => {},
onClearGoal: () => {},
onClearGoal: () => {
calls.clearGoal += 1;
},
onSwitchModel: () => {},
onSetEffort: () => {},
onSetTokenMode: () => {},
@@ -128,6 +145,61 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
return { root, calls, rerender: paint };
}
function mockApp(methods: Partial<AppBindings>) {
window.go = {
main: {
App: {
Commands: async () => [],
Models: async () => [],
ModelsForTab: async () => [],
...methods,
} as Partial<AppBindings> as AppBindings,
},
};
}
function dispatchPasteFile(textarea: HTMLTextAreaElement, file: File) {
const event = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(event, "clipboardData", {
configurable: true,
value: {
files: [file],
items: [],
types: ["Files"],
getData: () => "",
},
});
textarea.dispatchEvent(event);
}
function nativeFileDropEvent(): Event {
const drop = new window.Event("drop", { bubbles: true, cancelable: true });
Object.defineProperty(drop, "dataTransfer", {
configurable: true,
value: {
types: ["Files"],
files: [{}],
items: [
{
kind: "file",
webkitGetAsEntry: () => ({ isFile: true }),
},
],
},
});
return drop;
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 20; attempt += 1) {
await act(async () => {
await flushTimers();
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
console.log("\ncomposer goal toggle");
{
@@ -166,5 +238,506 @@ console.log("\ncomposer goal toggle");
dom.window.close();
}
{
const dom = installDom();
const { root, calls } = await renderComposer({
running: true,
collaborationMode: "goal",
goal: "finish the migration",
turnStartAt: Date.now(),
});
const stopButton = document.querySelector(".composer-runstatus__stop") as HTMLButtonElement | null;
if (!stopButton) throw new Error("composer stop button did not render");
await act(async () => {
stopButton.click();
await flushTimers();
});
eq(calls.cancel, 1, "goal-mode stop cancels the running turn");
eq(calls.clearGoal, 1, "goal-mode stop clears the active goal");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
mockApp({
SavePastedFile: async () => {
throw new Error("/Users/example/private.pdf: permission denied");
},
});
const { root, rerender } = await renderComposer();
await rerender({ insertRequest: { id: 2, text: "keep this draft", mode: "replace" } });
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("composer textarea did not render");
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("composer send button did not render");
await act(async () => {
dispatchPasteFile(textarea, new File(["hello"], "notes.txt", { type: "text/plain" }));
await flushTimers();
});
await waitFor("pasted file failure toast", () => document.body.textContent?.includes("File attach failed") === true);
ok(document.body.textContent?.includes("File attach failed") === true, "SavePastedFile rejection shows a visible error");
eq(textarea.value, "keep this draft", "failed pasted file attach preserves composer text");
ok(sendButton.disabled === false, "failed pasted file attach clears the pending state");
ok(document.body.textContent?.includes("/Users/example") === false, "pasted file failure toast does not expose the local path");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
mockApp({
SavePastedFile: async () => ".reasonix/attachments/notes.txt",
});
const { root } = await renderComposer();
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("composer textarea did not render");
await act(async () => {
dispatchPasteFile(textarea, new File(["hello"], "notes.txt", { type: "text/plain" }));
await flushTimers();
});
await waitFor("pasted file attachment", () => document.body.textContent?.includes("notes.txt") === true);
ok(document.body.textContent?.includes("notes.txt") === true, "successful pasted file attach still renders the attachment");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
let droppedCallback: ((x: number, y: number, paths: string[]) => void) | undefined;
window.runtime = {
EventsOn: () => () => {},
BrowserOpenURL: () => {},
OnFileDrop: (cb) => {
droppedCallback = cb;
},
OnFileDropOff: () => {},
};
mockApp({
AttachDropped: async () => {
throw new Error("/Users/example/secret.pdf: permission denied");
},
});
const { root, rerender } = await renderComposer();
await rerender({ insertRequest: { id: 3, text: "drop draft", mode: "replace" } });
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("composer textarea did not render");
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("composer send button did not render");
if (!droppedCallback) throw new Error("native file drop handler did not register");
await act(async () => {
droppedCallback?.(0, 0, ["/Users/example/secret.pdf"]);
await flushTimers();
});
await waitFor("dropped file failure toast", () => document.body.textContent?.includes("Dropped file attach failed") === true);
ok(document.body.textContent?.includes("Dropped file attach failed") === true, "AttachDropped rejection shows a visible error");
eq(textarea.value, "drop draft", "failed dropped file attach preserves composer text");
ok(sendButton.disabled === false, "failed dropped file attach clears the pending state");
ok(document.body.textContent?.includes("/Users/example") === false, "dropped file failure toast does not expose the local path");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
let droppedCallback: ((x: number, y: number, paths: string[]) => void) | undefined;
window.runtime = {
EventsOn: () => () => {},
BrowserOpenURL: () => {},
OnFileDrop: (cb) => {
droppedCallback = cb;
},
OnFileDropOff: () => {},
};
mockApp({
AttachDropped: async () => ({
kind: "attachment",
path: ".reasonix/attachments/report.pdf",
}),
});
const { root } = await renderComposer();
if (!droppedCallback) throw new Error("native file drop handler did not register");
await act(async () => {
droppedCallback?.(0, 0, ["/Users/example/report.pdf"]);
await flushTimers();
});
await waitFor("dropped file attachment", () => document.body.textContent?.includes("report.pdf") === true);
ok(document.body.textContent?.includes("report.pdf") === true, "successful dropped file attach still renders the attachment");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
let droppedCallback: ((x: number, y: number, paths: string[]) => void) | undefined;
window.runtime = {
EventsOn: () => () => {},
BrowserOpenURL: () => {},
OnFileDrop: (cb) => {
droppedCallback = cb;
},
OnFileDropOff: () => {},
};
mockApp({
AttachDropped: async () => ({
kind: "workspace",
path: "__reasonix_external_folder/mock/Folder-With-Spaces",
isDir: true,
displayPath: "/Users/example/Folder With Spaces",
}),
});
const { root, calls, rerender } = await renderComposer();
await rerender({ insertRequest: { id: 4, text: "inspect", mode: "replace" } });
if (!droppedCallback) throw new Error("native file drop handler did not register");
await act(async () => {
droppedCallback?.(0, 0, ["/Users/example/Folder With Spaces"]);
await flushTimers();
});
await waitFor("dropped external folder chip", () => document.body.textContent?.includes("Folder With Spaces/") === true);
ok(document.body.textContent?.includes("Folder With Spaces/") === true, "dropped external folder renders as a folder context chip");
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("composer send button did not render");
await act(async () => {
sendButton.click();
await flushTimers();
});
eq(calls.send.join(","), "inspect @/Users/example/Folder With Spaces/", "external folder display text uses the real folder path");
eq(calls.submit.join(","), "inspect @__reasonix_external_folder/mock/Folder-With-Spaces/", "external folder submit text uses the session ref token");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const externalToken = "__reasonix_external_folder/mock/Folder-With-Spaces/src/outside.txt";
const externalDisplayPath = "/Users/example/Folder With Spaces/src/outside.txt";
const picked = composerPickFileEntry("ask @outside", "outside", "", {
name: "src/outside.txt",
path: externalToken,
isDir: false,
displayName: "Folder With Spaces/src/outside.txt",
displayPath: externalDisplayPath,
});
eq(picked.text, "ask ", "external search selection removes the token fragment from the draft");
eq(picked.workspaceRef?.path, externalToken, "external search selection submits the session ref token");
eq(picked.workspaceRef?.displayPath, externalDisplayPath, "external search selection keeps the real display path");
const localFile = composerPickFileEntry("ask @src/mai", "src/mai", "src/", { name: "main.go", isDir: false });
eq(localFile.text, "ask @src/main.go ", "local file selection still completes inline text");
const localDir = composerPickFileEntry("ask @sr", "sr", "", { name: "src", isDir: true });
eq(localDir.text, "ask @src/", "local dir selection still keeps the menu-open slash");
}
{
const dom = installDom();
const { root: dropNavRoot } = await renderComposer();
const composer = document.querySelector(".composer") as HTMLElement | null;
if (!composer) throw new Error("composer did not render");
const drop = nativeFileDropEvent();
await act(async () => {
composer.dispatchEvent(drop);
await flushTimers();
});
ok(drop.defaultPrevented, "native file drop prevents browser image navigation");
await act(async () => {
dropNavRoot.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const { root: dropWrapRoot } = await renderComposer();
const wrap = document.querySelector(".composer-wrap") as HTMLElement | null;
if (!wrap) throw new Error("composer wrap did not render");
const drop = nativeFileDropEvent();
await act(async () => {
wrap.dispatchEvent(drop);
await flushTimers();
});
ok(drop.defaultPrevented, "outer native file drop target prevents browser image navigation");
await act(async () => {
dropWrapRoot.unmount();
});
dom.window.close();
}
{
const dom = installDom();
let rejectSubmit: (err: Error) => void = () => {};
const rejectedSubmit = new Promise<void>((_, reject) => {
rejectSubmit = reject;
});
rejectedSubmit.catch(() => {});
const { root, calls, rerender } = await renderComposer({
onSend: (displayText) => {
calls.send.push(displayText);
return rejectedSubmit;
},
});
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("composer textarea did not render");
await rerender({ insertRequest: { id: 2, text: "keep this draft", mode: "replace" } });
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("composer send button did not render");
await act(async () => {
sendButton.click();
rejectSubmit(new Error("workspace is still starting"));
await flushTimers();
});
eq(calls.send.join(","), "keep this draft", "rejected submit attempts the send once");
eq(textarea.value, "keep this draft", "rejected submit preserves the composer draft");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const { root, calls, rerender } = await renderComposer({
onSend: (displayText) => {
calls.send.push(displayText);
return Promise.resolve();
},
});
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("composer textarea did not render");
await rerender({ insertRequest: { id: 3, text: "send this draft", mode: "replace" } });
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("composer send button did not render");
await act(async () => {
sendButton.click();
await flushTimers();
});
eq(calls.send.join(","), "send this draft", "successful submit attempts the send once");
eq(textarea.value, "", "successful submit clears the composer draft");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const { root, rerender } = await renderComposer({
running: true,
guidanceQueuePreviewItems: ["confirm the send lifecycle", "keep steer protocol unchanged", "add a hanging submit regression"],
});
let guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
eq(guidanceItems.length, 2, "running guidance preview shows a compact queue preview");
ok(guidanceItems[0]?.textContent?.includes("confirm the send lifecycle") === true, "guidance preview shows the first seeded item");
ok(guidanceItems[1]?.textContent?.includes("keep steer protocol unchanged") === true, "guidance preview shows the second seeded item");
eq(document.querySelectorAll(".composer-guidance-item__guide").length, 2, "guidance preview exposes a guide action for each visible item");
let guidanceMore = document.querySelector(".composer-guidance-more") as HTMLButtonElement | null;
ok(guidanceMore?.textContent?.includes("1 more queued") === true, "guidance preview summarizes overflow items");
eq(guidanceMore?.getAttribute("aria-expanded"), "false", "guidance overflow starts collapsed");
if (!guidanceMore) throw new Error("guidance overflow button did not render");
await act(async () => {
guidanceMore.click();
await flushTimers();
});
guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
eq(guidanceItems.length, 3, "guidance overflow expands the remaining queued items");
ok(guidanceItems[2]?.textContent?.includes("add a hanging submit regression") === true, "expanded guidance preview shows the hidden item");
guidanceMore = document.querySelector(".composer-guidance-more") as HTMLButtonElement | null;
ok(guidanceMore?.textContent?.includes("Collapse") === true, "expanded guidance overflow can be collapsed");
eq(guidanceMore?.getAttribute("aria-expanded"), "true", "guidance overflow reports expanded state");
if (!guidanceMore) throw new Error("guidance collapse button did not render");
await act(async () => {
guidanceMore.click();
await flushTimers();
});
guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
eq(guidanceItems.length, 2, "guidance overflow collapses back to the compact preview");
await rerender({ guidanceQueuePreviewItems: ["only the latest preview seed"] });
guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
eq(guidanceItems.length, 1, "guidance preview refreshes when the seed changes");
ok(guidanceItems[0]?.textContent?.includes("only the latest preview seed") === true, "guidance preview renders the refreshed seed");
await rerender({ running: false });
ok(document.querySelector(".composer-guidance-item") === null, "guidance preview clears when the mock turn stops");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const { root, calls, rerender } = await renderComposer({
running: true,
onSend: (displayText, submitText) => {
calls.send.push(displayText);
calls.submit.push(submitText);
},
});
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("composer textarea did not render");
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("running composer send button did not render");
eq(textarea.placeholder, "Add guidance to the queue...", "running composer explains queued guidance input");
ok(sendButton.classList.contains("composer__btn--steer"), "running composer marks send button as steer");
ok(sendButton.disabled === true, "running steer button stays disabled without input");
await rerender({ insertRequest: { id: 4, text: "keep the files small", mode: "replace" } });
ok(sendButton.disabled === false, "running steer button enables after text input");
await act(async () => {
sendButton.click();
await flushTimers();
});
eq(calls.send.join(","), "", "running composer queues guidance without sending it immediately");
eq(textarea.value, "", "queued running guidance clears the composer draft");
const guidanceItem = document.querySelector(".composer-guidance-item") as HTMLElement | null;
if (!guidanceItem) throw new Error("running guidance chip did not render");
ok(guidanceItem.textContent?.includes("keep the files small") === true, "running guidance chip shows queued text");
ok(document.querySelector(".composer-guidance-head")?.textContent?.includes("Queued guidance 1") === true, "running guidance shelf shows queued count");
const guideButton = guidanceItem.querySelector(".composer-guidance-item__guide") as HTMLButtonElement | null;
if (!guideButton) throw new Error("running guidance guide button did not render");
await act(async () => {
guideButton.click();
await flushTimers();
});
eq(calls.send.join(","), "keep the files small", "queued guidance sends through onSend when guided");
ok(document.querySelector(".composer-guidance-item") === null, "queued guidance clears after being guided");
await rerender({ insertRequest: { id: 5, text: "prefer the smaller diff", mode: "replace" } });
await act(async () => {
sendButton.click();
await flushTimers();
});
const dismissibleGuidanceItem = document.querySelector(".composer-guidance-item") as HTMLElement | null;
if (!dismissibleGuidanceItem) throw new Error("dismissible guidance chip did not render");
const dismissButton = dismissibleGuidanceItem.querySelector(".composer-guidance-item__action") as HTMLButtonElement | null;
if (!dismissButton) throw new Error("running guidance dismiss button did not render");
await act(async () => {
dismissButton.click();
await flushTimers();
});
ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip can be dismissed");
await rerender({ insertRequest: { id: 6, text: "prefer the smaller diff", mode: "replace" } });
await act(async () => {
sendButton.click();
await flushTimers();
});
ok(document.querySelector(".composer-guidance-item") !== null, "running guidance chip renders again after another queued item");
await rerender({ guidanceConsumedKey: "s1", guidanceConsumedText: "prefer the smaller diff" });
ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip clears when steer is consumed");
await rerender({ insertRequest: { id: 7, text: "then stop showing the chip", mode: "replace" } });
await act(async () => {
sendButton.click();
await flushTimers();
});
ok(document.querySelector(".composer-guidance-item") !== null, "running guidance chip renders before turn stop");
await rerender({ running: false });
ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip clears when the turn stops");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const { root, calls, rerender } = await renderComposer({
running: true,
submitDisabled: true,
onSend: (displayText) => {
calls.send.push(displayText);
},
});
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("running composer send button did not render");
await rerender({ insertRequest: { id: 7, text: "steer while activating", mode: "replace" } });
ok(sendButton.disabled === false, "running guidance queue ignores controller submitDisabled");
await act(async () => {
sendButton.click();
await flushTimers();
});
eq(calls.send.join(","), "", "running guidance queues while controllerReady is false");
const guideButton = document.querySelector(".composer-guidance-item__guide") as HTMLButtonElement | null;
if (!guideButton) throw new Error("running guidance guide button did not render");
await act(async () => {
guideButton.click();
await flushTimers();
});
eq(calls.send.join(","), "steer while activating", "queued guidance can be guided while controllerReady is false");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,259 @@
// Run: tsx src/__tests__/composer-image-capability.test.tsx
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, TokenMode, ToolApprovalMode } 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));
}
async function waitFor(check: () => boolean, attempts = 10): Promise<void> {
for (let i = 0; i < attempts; i++) {
if (check()) return;
await act(async () => {
await flushTimers();
});
}
}
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.File = dom.window.File;
globalThis.FileReader = dom.window.FileReader;
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
globalThis.MutationObserver = dom.window.MutationObserver;
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;
}
function installBridgeApp(methods: Record<string, unknown>) {
(window as unknown as { go: { main: { App: Record<string, unknown> } } }).go = {
main: {
App: {
Commands: async () => [],
Models: async () => [],
ModelsForTab: async () => [],
...methods,
},
},
};
}
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);
let currentProps: Parameters<typeof Composer>[0] = {
running: false,
collaborationMode: "normal",
toolApprovalMode: "ask" as ToolApprovalMode,
tokenMode: "full" as TokenMode,
goal: "",
cwd: "/repo",
modelLabel: "DeepSeek-R1",
imageInputEnabled: true,
tabId: "single-surface-tab",
sessionKey: "session:project:/repo:topic-a:session-a",
onSend: () => {},
onCancel: () => undefined,
onCycleMode: () => {},
onSetMode: () => {},
onSetCollaborationMode: (_mode: CollaborationMode) => {},
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, rerender: paint };
}
function textarea(): HTMLTextAreaElement {
const node = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!node) throw new Error("composer textarea did not render");
return node;
}
function sendButton(): HTMLButtonElement {
const node = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!node) throw new Error("send button did not render");
return node;
}
function contextItemCount(): number {
return document.querySelectorAll(".composer-context__item").length;
}
function toastText(): string {
const items = Array.from(document.querySelectorAll(".toast__text"));
return (items.at(-1)?.textContent ?? "").trim();
}
function imagePasteEvent(file: File): Event {
const event = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(event, "clipboardData", {
configurable: true,
value: {
files: [file],
items: [],
types: [file.type],
getData: () => "",
},
});
return event;
}
console.log("\ncomposer image capability");
{
const dom = installDom();
let saveCalls = 0;
installBridgeApp({
SavePastedImage: async () => {
saveCalls += 1;
return ".reasonix/attachments/mock.png";
},
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
});
const { root } = await renderComposer({ imageInputEnabled: false });
const file = new File(["img"], "photo.png", { type: "image/png", lastModified: 1 });
await act(async () => {
textarea().dispatchEvent(imagePasteEvent(file));
await flushTimers();
await flushTimers();
});
await waitFor(() => contextItemCount() === 1);
eq(saveCalls, 1, "text-only model stores pasted image attachments as tool-readable refs");
eq(contextItemCount(), 1, "text-only model keeps the pasted image attachment in the draft");
eq(toastText(), "", "text-only image attach does not warn before send");
eq(document.querySelector(".composer__prompt") === null, true, "image attach warning does not render inside the composer layout");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const sent: Array<{ display: string; submit?: string }> = [];
installBridgeApp({
SavePastedImage: async () => ".reasonix/attachments/mock.png",
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
});
const { root, rerender } = await renderComposer({
imageInputEnabled: true,
onSend: (display, submit) => sent.push({ display, submit }),
});
const file = new File(["img"], "photo.png", { type: "image/png", lastModified: 1 });
await act(async () => {
textarea().dispatchEvent(imagePasteEvent(file));
await flushTimers();
await flushTimers();
});
await waitFor(() => contextItemCount() === 1);
eq(contextItemCount(), 1, "vision-capable model keeps the pasted image attachment");
await rerender({ imageInputEnabled: false, insertRequest: { id: 1, text: "describe this image", mode: "insert" } });
eq(toastText(), "", "model switch alone does not show a warning toast");
await act(async () => {
sendButton().click();
await flushTimers();
});
eq(sent.length, 1, "switching to a text-only model still sends the image ref for tool use");
ok(toastText().includes("will not receive images directly"), "text-only send warns about direct image input without blocking");
eq(document.querySelector(".composer__prompt") === null, true, "image-input warning does not render inside the composer layout");
ok(sent[0]?.submit?.includes("@.reasonix/attachments/mock.png") === true, "submitted text retains the local image attachment ref");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -7,10 +7,19 @@ import {
hydrateComposerProfileFromMeta,
hydrateComposerProfilesFromTabs,
patchComposerProfile,
pruneUserPlanModeIntents,
resolvePlanRestoreTabId,
shouldRestoreUserPlanMode,
shouldRestoreUserPlanModeForProfile,
updateUserPlanModeIntent,
type ComposerProfilesByTab,
type UserPlanModeIntents,
} from "../lib/composerProfile";
import type { Meta, TabMeta } from "../lib/types";
type LooseTabMeta = Omit<TabMeta, "toolApprovalMode"> & { toolApprovalMode?: TabMeta["toolApprovalMode"] | "" };
type LooseMeta = Omit<Meta, "toolApprovalMode"> & { toolApprovalMode?: Meta["toolApprovalMode"] | "" };
let passed = 0;
let failed = 0;
@@ -24,7 +33,7 @@ function eq(a: unknown, b: unknown, label: string) {
}
}
function tab(overrides: Partial<TabMeta> = {}): TabMeta {
function tab(overrides: Partial<LooseTabMeta> = {}): TabMeta {
return {
id: "tab-1",
scope: "project",
@@ -44,10 +53,10 @@ function tab(overrides: Partial<TabMeta> = {}): TabMeta {
active: true,
cwd: "/repo",
...overrides,
};
} as TabMeta;
}
function meta(overrides: Partial<Meta> = {}): Meta {
function meta(overrides: Partial<LooseMeta> = {}): Meta {
return {
label: "DeepSeek-R1",
ready: true,
@@ -61,7 +70,7 @@ function meta(overrides: Partial<Meta> = {}): Meta {
goal: "",
goalStatus: "stopped",
...overrides,
};
} as Meta;
}
console.log("\ncomposer profile");
@@ -138,5 +147,59 @@ console.log("\ncomposer profile");
eq(Boolean(profiles["tab-2"]), false, "tab hydration removes profiles for closed tabs");
}
{
let profiles: ComposerProfilesByTab = {};
profiles = hydrateComposerProfilesFromTabs(profiles, [tab({ toolApprovalMode: "auto" })]);
profiles = hydrateComposerProfilesFromTabs(profiles, [tab({ toolApprovalMode: "" })]);
eq(profiles["tab-1"].toolApprovalMode, "auto", "blank tab payload does not demote explicit auto approval mode to ask");
profiles = hydrateComposerProfileFromMeta(profiles, "tab-1", meta({ toolApprovalMode: "" }));
eq(profiles["tab-1"].toolApprovalMode, "auto", "blank meta payload does not demote explicit auto approval mode to ask");
}
{
let intents: UserPlanModeIntents = {};
intents = updateUserPlanModeIntent(intents, "tab-1", true);
intents = updateUserPlanModeIntent(intents, "tab-2", false);
eq(shouldRestoreUserPlanMode(intents, "tab-1"), true, "manual plan intent restores only the tab that enabled it");
eq(shouldRestoreUserPlanMode(intents, "tab-2"), false, "normal tabs do not inherit another tab's plan intent");
intents = updateUserPlanModeIntent(intents, "tab-1", false);
eq(shouldRestoreUserPlanMode(intents, "tab-1"), false, "manual normal mode clears plan restore intent");
}
{
let intents: UserPlanModeIntents = {};
intents = updateUserPlanModeIntent(intents, "tab-1", true);
intents = updateUserPlanModeIntent(intents, "tab-2", true);
intents = pruneUserPlanModeIntents(intents, ["tab-2"]);
eq(shouldRestoreUserPlanMode(intents, "tab-1"), false, "closed tabs lose plan restore intent");
eq(shouldRestoreUserPlanMode(intents, "tab-2"), true, "open tabs keep plan restore intent");
}
{
eq(resolvePlanRestoreTabId("finished-tab", "active-tab"), "finished-tab", "turn_done plan restore uses the event tab when present");
eq(resolvePlanRestoreTabId(undefined, "active-tab"), "active-tab", "turn_done plan restore falls back to the active tab for legacy events");
}
{
let intents: UserPlanModeIntents = {};
intents = updateUserPlanModeIntent(intents, "tab-1", true);
eq(
shouldRestoreUserPlanModeForProfile(intents, "tab-1", { goal: "research task" }),
false,
"active goals block remembered plan restoration",
);
eq(
shouldRestoreUserPlanModeForProfile(intents, "tab-1", { goal: "" }),
true,
"empty goals still allow remembered plan restoration",
);
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,311 @@
// Run: tsx src/__tests__/composer-session-draft.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { Composer } from "../components/Composer";
import { composerDraftKeyForTab } from "../lib/composerDraftKey";
import { LocaleProvider } from "../lib/i18n";
import { ToastProvider } from "../lib/toast";
import type { CollaborationMode, TokenMode, ToolApprovalMode } 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));
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((next) => {
resolve = next;
});
return { promise, resolve };
}
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.File = dom.window.File;
globalThis.FileReader = dom.window.FileReader;
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
globalThis.MutationObserver = dom.window.MutationObserver;
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;
}
function installBridgeApp(methods: Record<string, unknown>) {
(window as unknown as { go: { main: { App: Record<string, unknown> } } }).go = {
main: {
App: {
Commands: async () => [],
Models: async () => [],
ModelsForTab: async () => [],
...methods,
},
},
};
}
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);
let currentProps: Parameters<typeof Composer>[0] = {
running: false,
collaborationMode: "normal",
toolApprovalMode: "ask" as ToolApprovalMode,
tokenMode: "full" as TokenMode,
goal: "",
cwd: "/repo",
modelLabel: "DeepSeek-R1",
tabId: "single-surface-tab",
sessionKey: "session:project:/repo:topic-a:session-a",
onSend: () => {},
onCancel: () => undefined,
onCycleMode: () => {},
onSetMode: () => {},
onSetCollaborationMode: (_mode: CollaborationMode) => {},
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, rerender: paint };
}
function textarea(): HTMLTextAreaElement {
const node = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!node) throw new Error("composer textarea did not render");
return node;
}
function sendButton(): HTMLButtonElement {
const node = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!node) throw new Error("composer send button did not render");
return node;
}
function contextItemCount(): number {
return document.querySelectorAll(".composer-context__item").length;
}
function textPasteEvent(text: string): Event {
const event = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(event, "clipboardData", {
configurable: true,
value: {
files: [],
items: [],
types: ["text/plain"],
getData: (kind: string) => (kind === "text" || kind === "text/plain" ? text : ""),
},
});
return event;
}
console.log("\ncomposer session draft");
{
const withoutPath = composerDraftKeyForTab({
id: "tab-a",
scope: "project",
workspaceRoot: "/repo",
topicId: "topic-a",
sessionPath: "",
}, "tab-a");
const withPath = composerDraftKeyForTab({
id: "tab-a",
scope: "project",
workspaceRoot: "/repo",
topicId: "topic-a",
sessionPath: "/repo/.reasonix/sessions/topic-a.jsonl",
}, "tab-a");
eq(withPath, withoutPath, "topic draft key stays stable when session path appears");
}
{
const dom = installDom();
const { root, rerender } = await renderComposer();
await rerender({ insertRequest: { id: 1, text: "draft for A", mode: "replace" } });
await rerender({ insertRequest: { id: 2, text: "@/repo/src/app.ts", mode: "insert" } });
eq(textarea().value, "draft for A", "session A text is visible before switching");
eq(contextItemCount(), 1, "session A workspace ref is visible before switching");
await rerender({ sessionKey: "session:project:/repo:topic-b:session-b" });
eq(textarea().value, "", "session B does not inherit session A text");
eq(contextItemCount(), 0, "session B does not inherit session A context refs");
await rerender({ insertRequest: { id: 3, text: "draft for B", mode: "replace" } });
eq(textarea().value, "draft for B", "session B keeps its own text draft");
await rerender({ sessionKey: "session:project:/repo:topic-a:session-a" });
eq(textarea().value, "draft for A", "session A text is restored when switching back");
eq(contextItemCount(), 1, "session A context refs are restored when switching back");
await rerender({ sessionKey: "session:project:/repo:topic-b:session-b" });
eq(textarea().value, "draft for B", "session B text is restored independently");
eq(contextItemCount(), 0, "session B context refs stay isolated");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const sent: Array<{ display: string; submit?: string }> = [];
const { root } = await renderComposer({
onSend: (display, submit) => {
sent.push({ display, submit });
},
});
const rawPaste = "error: failed to compile\r\nat loader.ts:10\r\nat run.ts:22";
const normalizedPaste = "error: failed to compile\nat loader.ts:10\nat run.ts:22";
const event = textPasteEvent(rawPaste);
await act(async () => {
const input = textarea();
input.selectionStart = input.selectionEnd = 0;
input.dispatchEvent(event);
await flushTimers();
});
eq(event.defaultPrevented, true, "short text paste is handled by React state");
eq(textarea().value, normalizedPaste, "short multiline paste is visible in the composer");
await act(async () => {
sendButton().click();
await flushTimers();
});
eq(sent.length, 1, "short multiline paste submits once");
eq(sent[0]?.display, normalizedPaste, "short multiline paste is preserved in display text");
eq(sent[0]?.submit, normalizedPaste, "short multiline paste is preserved in submit text");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const saveStarted = deferred<void>();
const savePastedFile = deferred<string>();
installBridgeApp({
SavePastedFile: async () => {
saveStarted.resolve();
return savePastedFile.promise;
},
});
const { root, rerender } = await renderComposer();
const file = new File(["draft attachment"], "draft.txt", { type: "text/plain", lastModified: 1 });
const event = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(event, "clipboardData", {
configurable: true,
value: {
files: [file],
items: [],
types: [],
getData: () => "",
},
});
await act(async () => {
textarea().dispatchEvent(event);
await saveStarted.promise;
});
await rerender({ sessionKey: "session:project:/repo:topic-b:session-b" });
await act(async () => {
savePastedFile.resolve("/tmp/reasonix/draft.txt");
await flushTimers();
});
eq(contextItemCount(), 0, "async attachment does not land in the switched-to session");
await rerender({ sessionKey: "session:project:/repo:topic-a:session-a" });
eq(contextItemCount(), 1, "async attachment returns to the source session draft");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -1,6 +1,6 @@
// Run: tsx src/__tests__/context-panel-breakdown.test.ts
import { contextBreakdown, contextCostDisplay, formatCacheHitRate } from "../components/ContextPanel";
import { cacheHitTone, contextBreakdown, contextCostDisplay, contextSourceRows, contextWindowStatus, formatCacheHitRate, formatMetricTokens } from "../components/ContextPanel";
import { currencySymbol, formatMoney, formatMoneyLocalized } from "../lib/money";
let passed = 0;
@@ -49,7 +49,24 @@ eq(
42_124,
"legend values sum to used context tokens",
);
eq(Math.round(mock.otherPct), 33, "donut endpoint follows used/window percent");
eq(Math.round(mock.otherPct), 33, "usage endpoint follows used/window percent");
const issue5283 = contextBreakdown(6888, 1_000_000, 6840, 48, 48);
eq(
{
promptTokens: issue5283.promptTokens,
completionTokens: issue5283.completionTokens,
reasoningTokens: issue5283.reasoningTokens,
otherTokens: issue5283.otherTokens,
},
{
promptTokens: 6840,
completionTokens: 0,
reasoningTokens: 48,
otherTokens: 0,
},
"prompt tokens are not scaled down when used context includes completion tokens",
);
const oversized = contextBreakdown(61_000, 1_000_000, 1_622_277, 12_049, 3_217);
eq(
@@ -73,9 +90,16 @@ eq(
reasoningPct: 0,
otherPct: 0,
},
"unknown context window keeps donut segments empty",
"unknown context window keeps usage segments empty",
);
console.log("\ncontext window status");
eq(contextWindowStatus(33, 80), { tone: "good", key: "context.windowStatusHealthy" }, "low usage stays healthy");
eq(contextWindowStatus(72, 80), { tone: "notice", key: "context.windowStatusWatch" }, "usage near compact threshold warns early");
eq(contextWindowStatus(80, 80), { tone: "warn", key: "context.windowStatusPastCompact" }, "compact threshold reached takes warning tone");
eq(contextWindowStatus(91, 80), { tone: "warn", key: "context.windowStatusNearLimit" }, "near hard limit overrides compact status");
console.log("\ncontext panel cost");
const infoCost = contextCostDisplay({
@@ -101,6 +125,121 @@ console.log("\ncontext panel cache rate");
eq(formatCacheHitRate(99_950, 50), "99.95%", "cache hit rate preserves two decimal places");
eq(formatCacheHitRate(0, 10_000), "0.00%", "cache hit rate shows zero when usage data exists");
eq(formatCacheHitRate(0, 0), "-", "cache hit rate stays empty before usage data exists");
eq(cacheHitTone(8700, 1300), "good", "healthy cache hit rate uses positive tone");
eq(cacheHitTone(6000, 4000), "notice", "mid cache hit rate uses notice tone");
eq(cacheHitTone(5999, 4001), "warn", "low cache hit rate uses warning tone");
eq(cacheHitTone(0, 0), undefined, "missing cache data stays uncolored");
console.log("\ncontext panel source rows");
const sourceRows = contextSourceRows({
usedTokens: 0,
windowTokens: 0,
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
reasoningTokens: 0,
cacheHitTokens: 0,
cacheMissTokens: 0,
sessionCacheHitTokens: 0,
sessionCacheMissTokens: 0,
sessionCompletionTokens: 0,
readFiles: [],
changedFiles: [],
sources: {
planner: {
promptTokens: 200,
completionTokens: 20,
totalTokens: 220,
reasoningTokens: 0,
cacheHitTokens: 0,
cacheMissTokens: 0,
requestCount: 1,
},
executor: {
promptTokens: 1000,
completionTokens: 120,
totalTokens: 1120,
reasoningTokens: 0,
cacheHitTokens: 700,
cacheMissTokens: 300,
requestCount: 2,
sessionCost: 0.42,
sessionCurrency: "¥",
},
},
}, "¥");
eq(sourceRows.map((row) => row.source), ["executor", "planner"], "source rows keep executor before planner");
eq(
{
input: sourceRows[0].promptTokens,
output: sourceRows[0].completionTokens,
hit: sourceRows[0].cacheHitTokens,
miss: sourceRows[0].cacheMissTokens,
requests: sourceRows[0].requests,
},
{ input: 1000, output: 120, hit: 700, miss: 300, requests: 2 },
"executor source row exposes input, output, cache hit, cache miss, and request count",
);
eq(sourceRows[1].requests, 1, "planner source row remains visible without cache metadata");
eq(sourceRows[1].cacheHitTokens + sourceRows[1].cacheMissTokens, 0, "planner source preserves absent cache metadata as empty");
const executorOnlyRows = contextSourceRows({
usedTokens: 0,
windowTokens: 0,
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
reasoningTokens: 0,
cacheHitTokens: 0,
cacheMissTokens: 0,
sessionCacheHitTokens: 0,
sessionCacheMissTokens: 0,
sessionCompletionTokens: 0,
readFiles: [],
changedFiles: [],
sources: {
planner: {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
reasoningTokens: 0,
cacheHitTokens: 0,
cacheMissTokens: 0,
requestCount: 0,
},
executor: {
promptTokens: 4000,
completionTokens: 800,
totalTokens: 4800,
reasoningTokens: 0,
cacheHitTokens: 2500,
cacheMissTokens: 500,
requestCount: 3,
},
subagent: {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
reasoningTokens: 0,
cacheHitTokens: 0,
cacheMissTokens: 0,
requestCount: 0,
},
},
}, "¥");
eq(executorOnlyRows.map((row) => row.source), ["executor"], "source rows omit unused planner and subagent entries");
console.log("\ncontext panel metric token labels");
const exactMetric = formatMetricTokens(999_999, "en");
eq(exactMetric.display, "999,999", "sub-million metric tokens keep exact comma formatting");
eq(exactMetric.exact, "999,999", "sub-million exact metric title matches the display");
const largeMetric = formatMetricTokens(123_456_789, "en");
eq(largeMetric.display, "123,456,789", "large metric tokens keep exact comma formatting");
eq(largeMetric.exact, "123,456,789", "large metric exact title matches the display");
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -4,6 +4,7 @@ import {
buildCrashPayload,
buildPerformancePayload,
formatPerformanceContext,
globalCrashReportReason,
normalizeCrashError,
parseReportedPerf,
performanceLabelForReason,
@@ -80,6 +81,25 @@ eq(
false,
"checks ErrorEvent.error when ErrorEvent.message is a wrapper",
);
eq(
globalCrashReportReason({
defaultPrevented: false,
message: "Script error.",
filename: "wails://wails/assets/index-abc123.js",
lineno: 42,
colno: 7,
}),
"Script error.\nfilename=wails://wails/assets/index-abc123.js lineno=42 colno=7",
"adds script location to opaque window.error messages",
);
eq(
globalCrashReportReason({
defaultPrevented: false,
message: "Script error.",
}),
"Script error.",
"keeps opaque script errors bare when WebView provides no location",
);
const perf: PerformanceSnapshot = {
reason: "event loop lag 1300ms",
@@ -0,0 +1,91 @@
// Run: tsx src/__tests__/heartbeat-next-run.test.ts
import { heartbeatBuildCycleInterval, heartbeatNextRunAt } from "../custom/features/heartbeat/HeartbeatPanel";
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;
}
}
function localMs(year: number, month: number, day: number, hour: number, minute: number): number {
return new Date(year, month - 1, day, hour, minute, 0, 0).getTime();
}
console.log("\nheartbeat next run");
eq(
heartbeatNextRunAt(
{ interval: "30m", lastRunAt: localMs(2026, 6, 18, 16, 30) },
localMs(2026, 6, 18, 17, 20),
),
localMs(2026, 6, 18, 17, 0),
"plain interval stays due after elapsed",
);
eq(
heartbeatNextRunAt(
{ interval: "30m", lastRunAt: localMs(2026, 6, 18, 16, 30), timeWindowStart: "09:00", timeWindowEnd: "17:00" },
localMs(2026, 6, 18, 17, 20),
),
localMs(2026, 6, 19, 9, 0),
"time window defers elapsed interval to next opening",
);
eq(
heartbeatNextRunAt(
{ interval: "30m", lastRunAt: localMs(2026, 6, 18, 16, 0), timeWindowStart: "09:00", timeWindowEnd: "17:00" },
localMs(2026, 6, 18, 16, 10),
),
localMs(2026, 6, 18, 16, 30),
"time window keeps next run inside the open window",
);
eq(
heartbeatNextRunAt(
{ interval: "30m", lastRunAt: localMs(2026, 6, 18, 21, 50), timeWindowStart: "22:00", timeWindowEnd: "06:00" },
localMs(2026, 6, 18, 22, 10),
),
localMs(2026, 6, 18, 22, 20),
"cross-midnight window keeps due time in the open window",
);
eq(
heartbeatNextRunAt(
{ interval: "30m", lastRunAt: localMs(2026, 6, 18, 11, 30), timeWindowStart: "22:00", timeWindowEnd: "06:00" },
localMs(2026, 6, 18, 12, 10),
),
localMs(2026, 6, 18, 22, 0),
"cross-midnight window waits for today's opening from midday",
);
eq(
heartbeatNextRunAt(
{ interval: "24h|daily@20:00", lastRunAt: localMs(2026, 6, 18, 20, 0), timeWindowStart: "09:00", timeWindowEnd: "17:00" },
localMs(2026, 6, 19, 19, 0),
),
localMs(2026, 6, 19, 20, 0),
"cycle next run ignores stale interval time windows",
);
eq(
heartbeatBuildCycleInterval("daily", [], "09:00"),
"24h|weekly:mon@09:00",
"empty daily day selection does not save as every day",
);
eq(
heartbeatBuildCycleInterval("weekly", [], "09:00"),
"168h|weekly:mon@09:00",
"weekly default uses one weekday",
);
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -29,6 +29,25 @@ const userTimeItems = historyMessagesToItems([
eq(userTimeItems[0]?.kind === "user" && userTimeItems[0].createdAt, undefined, "history users without createdAt keep no timestamp");
eq(userTimeItems[1]?.kind === "user" && userTimeItems[1].createdAt, 1_718_000_000_000, "history users preserve createdAt when present");
const checkpointTurnItems = historyMessagesToItems([
{ role: "user", content: "first", checkpointTurn: 0 },
{ role: "assistant", content: "ok" },
{ role: "user", content: "second", checkpointTurn: 3 },
] as HistoryMessage[], "c").items.filter((item) => item.kind === "user");
eq(checkpointTurnItems[0]?.kind === "user" && checkpointTurnItems[0].checkpointTurn, 0, "history users preserve checkpoint turn zero");
eq(checkpointTurnItems[1]?.kind === "user" && checkpointTurnItems[1].checkpointTurn, 3, "history users preserve non-contiguous backend checkpoint turns");
const citedAssistant = historyMessagesToItems([
{
role: "assistant",
content: "done",
memoryCitations: [{ id: "mem-1", source: "MEMORY.md", lineStart: 116, lineEnd: 123, note: "workflow" }],
},
] as HistoryMessage[], "m").items.filter((item) => item.kind === "assistant");
eq(citedAssistant[0]?.kind === "assistant" && citedAssistant[0].memoryCitations?.length, 1, "assistant history preserves memory citations");
eq(citedAssistant[0]?.kind === "assistant" && citedAssistant[0].memoryCitations?.[0]?.source, "MEMORY.md", "assistant memory citation source is preserved");
eq(citedAssistant[0]?.kind === "assistant" && citedAssistant[0].memoryCitations?.[0]?.lineEnd, 123, "assistant memory citation line range is preserved");
const lowercaseError = toolItems([
{
role: "assistant",
@@ -1,5 +1,7 @@
// Run: tsx src/__tests__/keyboard-shortcuts.test.ts
import { JSDOM } from "jsdom";
import {
defaultShortcutCombo,
formatShortcutCombo,
@@ -9,6 +11,7 @@ import {
shortcutConflict,
type ShortcutPlatform,
} from "../lib/keyboardShortcuts";
import { topicShortcutIndexFromEvent, topicShortcutLabel } from "../lib/topicShortcuts";
let passed = 0;
let failed = 0;
@@ -23,13 +26,31 @@ function eq(a: unknown, b: unknown, label: string) {
}
}
function event(key: string, modifiers: { ctrlKey?: boolean; metaKey?: boolean } = {}) {
function event(key: string, modifiers: { ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean; shiftKey?: boolean; defaultPrevented?: boolean } = {}) {
return {
key,
ctrlKey: modifiers.ctrlKey ?? false,
metaKey: modifiers.metaKey ?? false,
altKey: false,
shiftKey: false,
altKey: modifiers.altKey ?? false,
shiftKey: modifiers.shiftKey ?? false,
defaultPrevented: modifiers.defaultPrevented ?? false,
target: null,
};
}
function eventWithTarget(
key: string,
target: EventTarget | null,
modifiers: { ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean; shiftKey?: boolean; defaultPrevented?: boolean } = {},
) {
return {
key,
ctrlKey: modifiers.ctrlKey ?? false,
metaKey: modifiers.metaKey ?? false,
altKey: modifiers.altKey ?? false,
shiftKey: modifiers.shiftKey ?? false,
defaultPrevented: modifiers.defaultPrevented ?? false,
target,
};
}
@@ -44,7 +65,7 @@ eq(isCloseTabShortcut(event("w", { ctrlKey: true }), "linux"), true, "Ctrl+W clo
for (const platform of ["darwin", "windows", "linux"] satisfies ShortcutPlatform[]) {
eq(isCloseTabShortcut(event("k", { ctrlKey: true, metaKey: true }), platform), false, `${platform} ignores non-W keys`);
eq(isCloseTabShortcut(event("w"), platform), false, `${platform} requires the platform modifier`);
eq(isCloseTabShortcut(event("w"), platform), false, `${platform} requires the platform modifier`);
}
eq(matchesShortcut(event("k", { metaKey: true }), "commandPalette.open", "darwin"), true, "Cmd+K opens the palette on macOS");
@@ -56,6 +77,25 @@ eq(JSON.stringify(formatShortcutComboParts(defaultShortcutCombo("settings.open",
eq(formatShortcutCombo(defaultShortcutCombo("settings.open", "windows"), "windows"), "Ctrl+,", "formats Windows settings shortcut");
eq(JSON.stringify(formatShortcutComboParts(defaultShortcutCombo("settings.open", "windows"), "windows")), JSON.stringify(["Ctrl", ","]), "splits Windows settings shortcut for display");
eq(shortcutConflict("settings.open", defaultShortcutCombo("commandPalette.open", "darwin"), "darwin")?.action, "commandPalette.open", "detects shortcut conflicts");
eq(topicShortcutIndexFromEvent(event("1", { metaKey: true }), "darwin"), 0, "Cmd+1 maps to the first topic shortcut on macOS");
eq(topicShortcutIndexFromEvent(event("1", { ctrlKey: true }), "darwin"), null, "Ctrl+1 is not a topic shortcut on macOS");
eq(topicShortcutIndexFromEvent(event("9", { ctrlKey: true }), "windows"), 8, "Ctrl+9 maps to the ninth topic shortcut on Windows");
eq(topicShortcutIndexFromEvent(event("9", { metaKey: true }), "windows"), null, "Meta+9 is not a topic shortcut on Windows");
eq(topicShortcutIndexFromEvent(event("1", { ctrlKey: true, shiftKey: true }), "linux"), null, "topic shortcuts reject extra modifiers");
eq(topicShortcutIndexFromEvent(event("0", { metaKey: true }), "darwin"), null, "Cmd+0 is not a topic shortcut");
eq(topicShortcutIndexFromEvent(event("1", { metaKey: true, defaultPrevented: true }), "darwin"), null, "topic shortcuts yield to already-handled custom shortcuts");
{
const dom = new JSDOM("<!doctype html><html><body><input /><textarea></textarea></body></html>");
const previousHTMLElement = globalThis.HTMLElement;
globalThis.HTMLElement = dom.window.HTMLElement;
const input = dom.window.document.querySelector("input");
const textarea = dom.window.document.querySelector("textarea");
eq(topicShortcutIndexFromEvent(eventWithTarget("1", input, { metaKey: true }), "darwin"), 0, "Cmd+1 still works when an input has focus");
eq(topicShortcutIndexFromEvent(eventWithTarget("9", textarea, { ctrlKey: true }), "windows"), 8, "Ctrl+9 still works when a textarea has focus");
globalThis.HTMLElement = previousHTMLElement;
}
eq(topicShortcutLabel(1, "darwin"), "⌘1", "topic badge uses the macOS command glyph");
eq(topicShortcutLabel(1, "windows"), "Ctrl+1", "topic badge uses the Windows control modifier");
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -133,6 +133,17 @@ check("$\\frac{a}{b}$", () => isLikelyInlineMath("\\frac{a}{b}") === true);
check("$f(x)$", () => isLikelyInlineMath("f(x)") === true);
check("$x+1$", () => isLikelyInlineMath("x+1") === true);
console.log("\nisLikelyInlineMath — classifier gaps from PR #4543");
check("$\\tfrac12$", () => isLikelyInlineMath("\\tfrac12") === true);
check("$\\sqrt2$", () => isLikelyInlineMath("\\sqrt2") === true);
check("$SO(3,1)$", () => isLikelyInlineMath("SO(3,1)") === true);
check("$SU(2)$", () => isLikelyInlineMath("SU(2)") === true);
check("$GL(n)$", () => isLikelyInlineMath("GL(n)") === true);
check("$K = -iJ$", () => isLikelyInlineMath("K = -iJ") === true);
check("$p = +\\alpha$", () => isLikelyInlineMath("p = +\\alpha") === true);
check("$+$", () => isLikelyInlineMath("+") === true);
check("$=$", () => isLikelyInlineMath("=") === true);
console.log("\nisLikelyInlineMath — currency/link (NOT math)");
check("$5", () => isLikelyInlineMath("5") === false);
check("$10", () => isLikelyInlineMath("10") === false);
@@ -153,6 +164,15 @@ check("uppercase $I$ → math (math name in non-English prose)", () => isLikelyI
check("uppercase $A$ → math", () => isLikelyInlineMath("A") === true);
check("uppercase $V$ → math", () => isLikelyInlineMath("V") === true);
console.log("\nisLikelyInlineMath — primed letters and bracketed labels");
check("$S'$ → math (primed letter)", () => isLikelyInlineMath("S'") === true);
check("$y''$ → math (double prime)", () => isLikelyInlineMath("y''") === true);
check("$f'(x)$ → math (primed function)", () => isLikelyInlineMath("f'(x)") === true);
check("$\\psi'$ → math (Greek with prime)", () => isLikelyInlineMath("\\psi'") === true);
check("$[56]$ → math (irrep label)", () => isLikelyInlineMath("[56]") === true);
check("$[56,0^+]$ → math", () => isLikelyInlineMath("[56,0^+]") === true);
check("$[\\mathbf{56}]$ → math", () => isLikelyInlineMath("[\\mathbf{56}]") === true);
console.log("\nisLikelyInlineMath — minimal LaTeX patterns (regression)");
// LLMs frequently emit minimal LaTeX in math contexts that the older
// classifier rejected as currency / word tokens. These tests pin down the
@@ -170,6 +190,8 @@ check("comma-separated $A, B$ → math (ordered pair)", () => isLikelyInlineMath
check("comma-separated $1, 2, 3$ → math (sequence)", () => isLikelyInlineMath("1, 2, 3") === true);
check("comma-separated $\\alpha, \\beta$ → math (Greek pair)", () => isLikelyInlineMath("\\alpha, \\beta") === true);
check("parens-wrapped $(A, B)$ inner → math", () => isLikelyInlineMath("(A, B)") === true);
check("cycle notation $(12)$ → math", () => isLikelyInlineMath("(12)") === true);
check("cycle notation $(12)(34)$ → math", () => isLikelyInlineMath("(12)(34)") === true);
check("$S$ (set name) → math", () => isLikelyInlineMath("S") === true);
check("$S$ with surrounding prose (regression)", () => {
return normalizeMath("$S$ 非空\n$S$ 有上界") === "$S$ 非空\n$S$ 有上界";
@@ -217,7 +239,7 @@ check("\\|x\\| renders as double bars", () => {
console.log("\nnormalizeMath — LLM delimiter conversion");
eq(normalizeMath("\\(x^2\\)"), "$x^2$", "\\(…\\) → $…$");
eq(normalizeMath("\\[E=mc^2\\]"), "$$E=mc^2$$", "\\[…\\] → $$…$$");
eq(normalizeMath("\\[E=mc^2\\]"), "$$\nE=mc^2\n$$", "\\[…\\] → $$…$$");
eq(normalizeMath("\\\\[4pt]"), "\\\\[4pt]", "\\\\[ line-break spacing protected");
console.log("\nnormalizeMath — \\slashed conversion (regression)");
@@ -235,19 +257,17 @@ console.log("\nnormalizeMath — inline $$ glued to prose (regression)");
// line before any $$ preceded by a letter/closing bracket/etc.
check("inline $$ after prose", () => {
const out = normalizeMath("decomposes as$$\n\\mathbf{6}.$$");
return /^decomposes as\n\n\$\$/.test(out) && out.includes("\\mathbf{6}");
return /^decomposes as\n\$\$/.test(out) && out.includes("\\mathbf{6}");
});
check("inline $$ after closing bracket", () => {
const out = normalizeMath("(octet)$$ \\mathbf{56}.$$");
return out.startsWith("(octet)\n\n$$");
return out.startsWith("(octet)\n$$");
});
check("inline $$ after closing brace (\\end{...}$$)", () => {
// A model that writes `\end{array}$$` or `\frac{a}{b}$$` on one line
// has the same micromark-fence problem as the comma case. The
// closing brace is the most common end-of-content marker in LaTeX
// math, so the repair-regex character class includes it.
// A display equation ending with }$$ must be extracted as a unit.
// The closing $$ must not be split off, or the equation body is emptied.
const out = normalizeMath("$$\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}$$");
return out.includes("\\end{pmatrix},\n\n$$") || out.includes("\\end{pmatrix}\n\n$$");
return out.includes("\\end{pmatrix},\n$$") || out.includes("\\end{pmatrix}\n$$");
});
check("inline $$ after comma on same line as content", () => {
// User-reported (2026-06-12, soft-pion chat): the model wrote the
@@ -261,25 +281,31 @@ check("inline $$ after comma on same line as content", () => {
// as math, which then fails to render with "Can't use function '$'
// in math mode" on the stray $ inside the equation body.
const out = normalizeMath("…D(q^2),$$\nwith $P=…$");
return out.includes("D(q^2),\n\n$$");
return out.includes("D(q^2),\n$$");
});
check("well-formed $$ already on own line is normalised consistently", () => {
// Whether the model writes `decomposes as$$\n\mathbf{6}.$$` or
// `decomposes as\n\n$$\n\mathbf{6}.$$`, both must produce the same
// remark-math-parseable form: opening $$ on its own line, body, blank
// line, closing $$ on its own line.
// `decomposes as\n\n$$\n\mathbf{6}.$$`, both must produce valid
// remark-math-parseable form: opening $$ on its own line, body, closing
// $$ on its own line.
const inline = normalizeMath("decomposes as$$\n\\mathbf{6}.$$");
const block = normalizeMath("decomposes as\n\n$$\n\\mathbf{6}.$$");
const expected = "decomposes as\n\n$$\n\\mathbf{6}.\n\n$$";
return inline === expected && block === expected;
const valid = (s: string) => /\n\$\$\n/.test(s) && /\n\$\$/.test(s) && s.includes("\\mathbf{6}");
return valid(inline) && valid(block);
});
check("\\[…\\] → $$…$$ still works (no spurious blank line)", () => {
return normalizeMath("\\[E=mc^2\\]") === "$$E=mc^2$$";
return normalizeMath("\\[E=mc^2\\]") === "$$\nE=mc^2\n$$";
});
check("digit before $$ is NOT a prose boundary (preserves c^2$$)", () => {
const out = normalizeMath("c^2$$ x $$");
return out === "c^2$$ x $$";
});
eq(normalizeMath("intro$$x+1"), "intro\n$$\nx+1", "orphan opening $$ is not duplicated");
eq(
normalizeMath("first$$a$$ middle $$b$$ end"),
"first\n$$\na\n$$\n middle \n$$\nb\n$$\n end",
"multiple display blocks on one line are all normalised",
);
console.log("\nnormalizeMath — non-math dollar filtering");
eq(normalizeMath("costs $1$ today"), "costs &#36;1&#36; today", "$1$ not math");
@@ -409,6 +435,10 @@ const e2e: Array<[string, string]> = [
["$\\frac{1}{\\sqrt{2}}\\|uud\\rangle$", "ket in fraction with \\|"],
["$\\|x\\|$", "norm \\|x\\| → double bar (regression)"],
["$\\langle\\psi\\|$", "bra closer \\| → single bar (regression)"],
["$S'$", "primed letter S'"],
["$f'(x)$", "primed function call"],
["$[56]$", "bracketed irrep label"],
["$[56,0^+]$", "bracketed irrep label with charge"],
];
for (const [src, label] of e2e) {
check(`${label}: ${src}`, () => katexOf(normalizeMath(src), false));
@@ -458,6 +488,19 @@ check("real inline math $x^2$ still renders as KaTeX", () => {
const html = renderHtml("the value $x^2$ here");
return html.includes("katex");
});
check("blockquote display math does not swallow following inline math", () => {
const html = renderHtml("> theorem\n> $$E=mc^2$$\n> after $x$");
return html.includes("katex-display")
&& !html.includes("katex-error")
&& html.includes(">x</mi>");
});
check("multi-line blockquote display math strips quote markers from the formula", () => {
const html = renderHtml("> theorem\n> $$\n> E=mc^2\n> $$\n> after $x$");
return html.includes("katex-display")
&& !html.includes("katex-error")
&& !html.includes("&gt; E")
&& html.includes(">x</mi>");
});
// ── Young diagram / tableau macros ─────────────────────────────────────────────
// `\yng` (ytableau) and `\young` (youngtab) are common in physics —
@@ -486,7 +529,7 @@ check("\\yng inside \\(...\\) does not get double-wrapped", () => {
});
check("\\yng inside \\[...\\] stays display math without triple dollars", () => {
const out = normalizeMath("\\[\\yng(2,1)\\]");
return out.startsWith("$$\\begin{array}{l}")
return out.startsWith("$$\n\\begin{array}{l}")
&& out.endsWith("$$")
&& !out.includes("$$$");
});
@@ -505,7 +548,7 @@ check("digit-starting inline math with \\young does not get nested wrappers", ()
});
check("display math ending in digit closes before following bare \\yng", () => {
const out = normalizeMath("$$x^2$$ \\yng(1)");
return out === "$$x^2$$ $\\begin{array}{l}\\square\\end{array}$";
return out === "$$\nx^2\n$$\n $\\begin{array}{l}\\square\\end{array}$";
});
check("bare \\yng after inline math is separated from adjacent dollars", () => {
const out = normalizeMath("$x$\\yng(1)");
@@ -0,0 +1,39 @@
// Run: tsx src/__tests__/memory-citation-visibility.test.ts
import { visibleTranscriptMemoryCitations } from "../lib/memoryCitationVisibility";
import type { MemoryCitation } from "../lib/types";
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("\nmemory citation visibility");
const compilerCitations: MemoryCitation[] = [
{ kind: "compiler_reference", source: "Memory v5", note: "evidence: bash succeeded" },
{ kind: "constraint", source: "Memory v5", note: "must_use: verify source" },
{ kind: "risk_note", source: "Memory v5", note: "risk: avoid failed strategy" },
];
eq(visibleTranscriptMemoryCitations(compilerCitations).length, 0, "compiler-only citations stay out of the transcript");
const regularCitation: MemoryCitation = { id: "mem-1", source: "MEMORY.md", lineStart: 12, note: "workflow" };
eq(visibleTranscriptMemoryCitations([regularCitation])[0]?.source, "MEMORY.md", "regular memory citations remain visible");
const mixed = visibleTranscriptMemoryCitations([...compilerCitations, regularCitation]);
eq(mixed.length, 1, "mixed citations keep only user-facing memory references");
eq(mixed[0]?.id, "mem-1", "mixed citations preserve the regular memory citation");
const compilerWithOtherSource: MemoryCitation = { kind: "compiler_reference", source: "MEMORY.md", note: "explicit memory file reference" };
eq(visibleTranscriptMemoryCitations([compilerWithOtherSource]).length, 1, "non-Memory-v5 compiler kind is not hidden by kind alone");
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,44 @@
// Run: tsx src/__tests__/memory-compiler-display.test.ts
//
// Display-boundary safety net for #5361: a corrupted/accreted Memory v5 contract
// from the pre-fix goal loop (#5342) must never render as raw JSON "乱码".
import { stripMemoryCompilerExecution } from "../lib/memoryCompilerDisplay";
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 contract = (sourceEvent: string) =>
"<memory-compiler-execution>\n" +
JSON.stringify({ type: "memory_v5_execution_contract", planner_ir: { version: 5, source_event: sourceEvent } }) +
"\n</memory-compiler-execution>";
console.log("\nmemory compiler display strip");
ok(stripMemoryCompilerExecution("fix the login bug") === "fix the login bug", "leaves plain user text untouched");
const complete = stripMemoryCompilerExecution(contract("do the thing"));
ok(!complete.includes("memory-compiler-execution"), "removes a complete contract block");
const withText = stripMemoryCompilerExecution("hello\n" + contract("hello"));
ok(withText.includes("hello") && !withText.includes("<memory-compiler-execution>"), "removes a complete block after user text");
const partial = 'keep this\n<memory-compiler-execution>\n{"planner_ir":{"source_event":"keep this",' + "x".repeat(40);
const cut = stripMemoryCompilerExecution(partial);
ok(
cut.includes("keep this") && !cut.includes("<memory-compiler-execution>") && !cut.includes("planner_ir"),
"cuts a dangling/truncated block instead of leaking raw JSON",
);
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,248 @@
// Run: tsx src/__tests__/mermaid-rendering.test.tsx
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 MermaidDiagram from "../components/MermaidDiagram";
import {
__setMermaidPanZoomFactoryForTest,
__setMermaidRenderAdapterForTest,
isOpenableMermaidHref,
isSafeMermaidHref,
sanitizeMermaidSvg,
} from "../components/MermaidDiagram";
import { LocaleProvider } from "../lib/i18n";
const testDir = dirname(fileURLToPath(import.meta.url));
const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8");
const markdownRendererSource = readFileSync(resolve(testDir, "../components/MarkdownRenderer.tsx"), "utf8");
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));
}
async function waitFor(label: string, predicate: () => boolean) {
for (let i = 0; i < 30; i += 1) {
if (predicate()) return;
await act(async () => {
await flushTimers();
});
}
ok(false, label);
}
function installDom() {
const dom = new JSDOM("<!doctype html><html><head></head><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.SVGElement = dom.window.SVGElement;
globalThis.Event = dom.window.Event;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.DOMParser = dom.window.DOMParser;
globalThis.XMLSerializer = dom.window.XMLSerializer;
globalThis.MutationObserver = dom.window.MutationObserver;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
Object.defineProperty(dom.window, "matchMedia", {
configurable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
}),
});
globalThis.matchMedia = dom.window.matchMedia;
const style = document.createElement("style");
style.textContent = styles;
document.head.appendChild(style);
return dom;
}
function parseSvg(svg: string): Document {
return new DOMParser().parseFromString(svg, "image/svg+xml");
}
console.log("\nmermaid rendering");
{
ok(
markdownRendererSource.includes('lazy(() => import("./MermaidDiagram"))'),
"MarkdownRenderer lazy-loads the Mermaid renderer",
);
ok(
markdownRendererSource.includes('lang === "mermaid"'),
"MarkdownRenderer routes mermaid fenced code blocks to the Mermaid renderer",
);
}
{
const dom = installDom();
const dirtySvg = `
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" onload="steal()">
<script>alert(1)</script>
<a id="safe" href="https://example.com/diagram"><text>safe</text></a>
<a id="unsafe" href="javascript:alert(1)"><text>bad</text></a>
<a id="unsafe-xlink" xlink:href="data:text/html,boom"><text>bad</text></a>
<g onclick="steal()"><text>node</text></g>
</svg>`;
const sanitized = sanitizeMermaidSvg(dirtySvg);
const doc = parseSvg(sanitized);
ok(!doc.documentElement.hasAttribute("onload"), "sanitizer strips event attributes from the root SVG");
ok(!doc.querySelector("script"), "sanitizer removes script nodes");
ok(doc.querySelector("#safe")?.getAttribute("href") === "https://example.com/diagram", "sanitizer keeps safe external links");
ok(!doc.querySelector("#unsafe")?.hasAttribute("href"), "sanitizer removes javascript links");
ok(!doc.querySelector("#unsafe-xlink")?.hasAttribute("xlink:href"), "sanitizer removes data xlink links");
ok(!doc.querySelector("g")?.hasAttribute("onclick"), "sanitizer strips event attributes from child nodes");
ok(isSafeMermaidHref("https://example.com/a"), "https Mermaid links are safe");
ok(isSafeMermaidHref("mailto:[email protected]"), "mailto Mermaid links are safe");
ok(!isSafeMermaidHref("file:///tmp/private"), "file Mermaid links are not safe");
ok(!isOpenableMermaidHref("#internal"), "fragment Mermaid links are not opened externally");
dom.window.close();
}
{
const dom = installDom();
const openedUrls: string[] = [];
dom.window.open = ((url: string | URL | undefined) => {
if (url) openedUrls.push(String(url));
return null;
}) as Window["open"];
const renders: Array<{ definition: string; theme: string }> = [];
const panZoomCalls: string[] = [];
__setMermaidRenderAdapterForTest(async (_svgId, definition, theme, signal) => {
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
renders.push({ definition, theme });
return `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 80" onload="steal()">
<script>alert(1)</script>
<a id="safe-link" href="https://example.com/diagram"><text>Open</text></a>
<a id="unsafe-link" href="vbscript:msgbox(1)"><text>Blocked</text></a>
<g class="node"><text>Rendered Mermaid</text></g>
</svg>`;
});
__setMermaidPanZoomFactoryForTest(() => ({
destroy: () => { panZoomCalls.push("destroy"); },
resize: () => { panZoomCalls.push("resize"); },
fit: () => { panZoomCalls.push("fit"); },
center: () => { panZoomCalls.push("center"); },
zoomIn: () => { panZoomCalls.push("zoomIn"); },
zoomOut: () => { panZoomCalls.push("zoomOut"); },
reset: () => { panZoomCalls.push("reset"); },
}));
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(
<LocaleProvider>
<div className="chat-pane">
<MermaidDiagram definition={"graph TD\nA-->B"} />
</div>
</LocaleProvider>,
);
await flushTimers();
});
await waitFor("Mermaid preview SVG rendered in DOM", () => Boolean(document.querySelector(".mermaid-diagram__preview svg")));
ok(document.querySelector(".mermaid-diagram__toolbar"), "Mermaid renderer shows its toolbar");
eq(renders.length, 1, "Mermaid renderer calls the render adapter once");
eq(renders[0]?.definition, "graph TD\nA-->B", "Mermaid renderer passes the diagram definition to Mermaid");
ok(document.querySelector("#safe-link"), "safe SVG link remains in the rendered DOM");
ok(!document.querySelector("#unsafe-link")?.hasAttribute("href"), "unsafe SVG link href is stripped in the rendered DOM");
ok(!document.querySelector(".mermaid-diagram__preview svg")?.hasAttribute("onload"), "rendered SVG root event handler is stripped");
ok(!document.querySelector(".mermaid-diagram__preview script"), "rendered SVG script nodes are removed");
await waitFor("pan zoom instance initialized", () => panZoomCalls.includes("fit") && panZoomCalls.includes("center"));
const zoomIn = document.querySelector<HTMLButtonElement>('button[aria-label="Zoom in"]');
const zoomOut = document.querySelector<HTMLButtonElement>('button[aria-label="Zoom out"]');
const reset = document.querySelector<HTMLButtonElement>('button[aria-label="Reset zoom"]');
zoomIn?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
zoomOut?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
reset?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
ok(panZoomCalls.includes("zoomIn"), "zoom in button calls the pan zoom instance");
ok(panZoomCalls.includes("zoomOut"), "zoom out button calls the pan zoom instance");
ok(panZoomCalls.includes("reset"), "reset zoom button calls the pan zoom instance");
document.querySelector("#safe-link text")?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
eq(openedUrls[0], "https://example.com/diagram", "SVG links open through the external browser bridge");
document.querySelector("#unsafe-link text")?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
eq(openedUrls.length, 1, "unsafe SVG links do not open externally");
await act(async () => {
document.querySelector<HTMLButtonElement>('button[aria-label="Show diagram source"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
await flushTimers();
});
ok(document.querySelector(".mermaid-diagram__code")?.textContent?.includes("graph TD"), "source tab shows the Mermaid definition");
await act(async () => {
document.querySelector<HTMLButtonElement>('button[aria-label="Open fullscreen"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
await flushTimers();
});
ok(document.querySelector(".chat-pane > .mermaid-diagram--fullscreen"), "fullscreen diagram portals into the chat pane");
await act(async () => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
await flushTimers();
});
ok(!document.querySelector(".chat-pane > .mermaid-diagram--fullscreen"), "Escape closes the Mermaid fullscreen portal");
await act(async () => {
root.unmount();
});
__setMermaidRenderAdapterForTest(null);
__setMermaidPanZoomFactoryForTest(undefined);
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -3,7 +3,7 @@
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { useController } from "../lib/useController";
import { initialState, reducer, useController, type Item } from "../lib/useController";
import type { AppBindings } from "../lib/bridge";
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
@@ -96,6 +96,29 @@ function meta(): Meta {
console.log("\nnew session load race");
const resetSourceItems: Item[] = [{ kind: "user", id: "old-user", text: "old prompt" }];
const resetPlaceholderItems: Item[] = [{ kind: "user", id: "placeholder-user", text: "placeholder prompt" }];
const resetState = reducer(
{
...initialState,
items: resetSourceItems,
hydrating: true,
hydrateReason: "open-topic",
hydratePlaceholderItems: resetPlaceholderItems,
},
{ type: "reset" },
);
eq(resetState.items.length, 0, "reset clears real transcript items");
eq(resetState.hydratePlaceholderItems?.length, 1, "reset preserves hydration placeholder separately");
const emptyHistoryState = reducer(resetState, { type: "history", messages: [] });
eq(emptyHistoryState.items.length, 0, "empty history keeps the real transcript empty");
eq(emptyHistoryState.hydrateHistoryLoaded, true, "empty history marks transcript hydration loaded");
eq(emptyHistoryState.hydratePlaceholderItems?.length ?? 0, 0, "empty history clears hydration placeholder items");
const hydrateDoneState = reducer(emptyHistoryState, { type: "hydrate_done" });
eq(Boolean(hydrateDoneState.hydrateHistoryLoaded), false, "hydrate_done clears the history-loaded marker");
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
@@ -137,6 +160,11 @@ window.go = {
JobsForTab: async () => jobs,
CheckpointsForTab: async () => checkpoints,
HistoryForTab: async () => staleHistory.promise,
HistoryPageForTab: async () => {
const messages = await staleHistory.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 () => {},
NewSession: async () => {
newSessionCalls += 1;
@@ -181,6 +209,54 @@ eq(controller?.state.items.length, 0, "stale history load cannot repopulate a ne
await act(async () => {
root.unmount();
});
const guardedStartupTabs = deferred<TabMeta[]>();
let ensureBlankSurfaceCalls = 0;
window.go.main.App = {
ListTabs: async () => guardedStartupTabs.promise,
MetaForTab: async () => meta(),
ContextUsageForTab: async () => context,
EffortForTab: async () => effort,
BalanceForTab: async () => balance,
JobsForTab: async () => jobs,
CheckpointsForTab: async () => checkpoints,
HistoryForTab: async () => [],
HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
HistoryCheckpointTurnsForTab: async () => [],
ReplayPendingPrompts: async () => {},
EnsureBlankSurface: async () => {
ensureBlankSurfaceCalls += 1;
return tabMeta({ id: "tab-new", topicId: "topic-new", topicTitle: "New session" });
},
} as Partial<AppBindings> as AppBindings;
controller = undefined;
const guardRoot = createRoot(rootEl);
await act(async () => {
guardRoot.render(<Probe />);
await flushPromises();
});
await act(async () => {
await controller?.ensureBlankSurface("project", "/repo");
await flushPromises();
});
eq(ensureBlankSurfaceCalls, 1, "EnsureBlankSurface is called once");
eq(controller?.activeTabId, "tab-new", "blank surface becomes active before startup sync resolves");
await act(async () => {
guardedStartupTabs.resolve([tabMeta({ id: "tab-old", topicId: "topic-old", topicTitle: "Old session" })]);
await guardedStartupTabs.promise;
await flushPromises();
});
eq(controller?.activeTabId, "tab-new", "guarded startup sync cannot restore an older active tab");
await act(async () => {
guardRoot.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
@@ -0,0 +1,227 @@
// Run: tsx src/__tests__/open-topic-coalescing.test.tsx
import { JSDOM } from "jsdom";
import React, { act, useCallback, useRef } from "react";
import { createRoot } from "react-dom/client";
import { enqueueNavigationRequest, enqueueOpenTopicRequest, type PendingOpenTopicRequest } from "../lib/openTopicCoalescing";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`);
}
function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function 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 };
}
console.log("\nopen topic coalescing");
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 gates = new Map<string, ReturnType<typeof deferred<void>>>();
const calls: string[] = [];
const updates: string[] = [];
const toasts: string[] = [];
let refreshes = 0;
let openTopic!: (topicId: string) => Promise<void>;
function gateFor(topicId: string) {
let gate = gates.get(topicId);
if (!gate) {
gate = deferred<void>();
gates.set(topicId, gate);
}
return gate;
}
function resetRecords() {
calls.length = 0;
updates.length = 0;
toasts.length = 0;
refreshes = 0;
}
function Harness() {
const seqRef = useRef(0);
const runningRef = useRef(false);
const pendingRef = useRef<PendingOpenTopicRequest | null>(null);
const run = useCallback(async (request: PendingOpenTopicRequest) => {
calls.push(request.topicId);
try {
await gateFor(request.topicId).promise;
if (request.topicId.includes("fail")) throw new Error(request.topicId);
if (request.seq !== seqRef.current) return;
updates.push(request.topicId);
refreshes += 1;
} catch {
if (request.seq !== seqRef.current) return;
toasts.push(request.topicId);
refreshes += 1;
}
}, []);
openTopic = (topicId: string) => enqueueOpenTopicRequest(
{ seqRef, runningRef, pendingRef },
{ scope: "global", workspaceRoot: "", topicId },
run,
);
return null;
}
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(<Harness />);
await flushPromises();
});
let bResolved = false;
let cResolved = false;
let dResolved = false;
const pA = openTopic("A");
await act(async () => {
await flushPromises();
});
const pB = openTopic("B").then(() => { bResolved = true; });
const pC = openTopic("C").then(() => { cResolved = true; });
const pD = openTopic("D").then(() => { dResolved = true; });
await act(async () => {
await flushPromises();
});
eq(calls.join(","), "A", "only the running request starts while newer requests coalesce");
ok(bResolved && cResolved, "superseded pending requests resolve immediately");
eq(dResolved, false, "latest pending request waits until it runs");
await act(async () => {
gateFor("A").resolve();
await pA;
await flushPromises();
});
eq(calls.join(","), "A,D", "after the running request finishes, only the latest pending request runs");
eq(updates.join(","), "", "stale running request does not update UI");
await act(async () => {
gateFor("D").resolve();
await pD;
await flushPromises();
});
eq(updates.join(","), "D", "latest coalesced request updates UI");
eq(refreshes, 1, "only the latest successful request refreshes metadata");
await pB;
await pC;
resetRecords();
let oldFailRejected = false;
let newFailRejected = false;
const pOldFail = openTopic("old-fail").catch(() => { oldFailRejected = true; });
await act(async () => {
await flushPromises();
});
const pNewFail = openTopic("new-fail").catch(() => { newFailRejected = true; });
await act(async () => {
await flushPromises();
gateFor("old-fail").resolve();
await pOldFail;
await flushPromises();
});
eq(calls.join(","), "old-fail,new-fail", "latest pending request starts after an older failing request");
eq(toasts.join(","), "", "stale failure does not show a toast");
eq(oldFailRejected, false, "stale failure promise does not reject");
await act(async () => {
gateFor("new-fail").resolve();
await pNewFail;
await flushPromises();
});
eq(toasts.join(","), "new-fail", "latest failure shows a toast");
eq(newFailRejected, false, "latest failure promise does not reject");
eq(refreshes, 1, "latest failure refreshes metadata once");
await act(async () => {
root.unmount();
});
dom.window.close();
// Tab-bar switches (App.handleTabChange) route through the same scheduler so
// rapidly clicking between two running sessions can't run switchTab()
// concurrently — concurrent switches race the backend SetActiveTab ordering and
// land events on the wrong session (#5352). This guards serialization (no two
// run()s overlap) + last-click-wins.
{
const refs = { seqRef: { current: 0 }, runningRef: { current: false }, pendingRef: { current: null as any } };
let active = 0;
let maxConcurrent = 0;
const ran: string[] = [];
const gates = new Map<string, ReturnType<typeof deferred<void>>>();
const gate = (id: string) => {
if (!gates.has(id)) gates.set(id, deferred<void>());
return gates.get(id)!;
};
const switchTab = (req: { tabId: string }) =>
enqueueNavigationRequest(refs, { tabId: req.tabId }, async (r) => {
active += 1;
maxConcurrent = Math.max(maxConcurrent, active);
await gate(r.tabId).promise;
ran.push(r.tabId);
active -= 1;
});
const pA = switchTab({ tabId: "A" }); // starts running
const pB = switchTab({ tabId: "B" }); // coalesced away (superseded while A runs)
const pC = switchTab({ tabId: "C" }); // latest pending
gate("A").resolve();
await pA;
await flushPromises();
gate("C").resolve();
await pC;
await pB;
await flushPromises();
eq(ran.join(","), "A,C", "tab switches serialize: only the running + latest run, middle coalesces");
eq(maxConcurrent, 1, "no two tab switches run concurrently (no backend SetActiveTab race)");
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -5,6 +5,10 @@ import {
defaultExpandedProjectTreeKeys,
activeSessionAncestorKeys,
projectTreeTopicOpenRequest,
projectTreeShouldSuppressOpenForRename,
projectTreeReadActivityKey,
projectTreeTopicHasUnreadActivity,
projectTreeShouldRenderTopicActions,
} from "../components/ProjectTree";
import type { ProjectNode } from "../lib/types";
@@ -103,6 +107,85 @@ eq(
"regular project topic still opens by topic",
);
const completedTopic: ProjectNode = {
key: "topic_complete",
kind: "topic",
label: "Completed",
root: "/repo",
topicId: "topic-complete",
lastActivityAt: 2000,
};
const completedTopicKey = projectTreeReadActivityKey(completedTopic) ?? "";
eq(
projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 1000 }, "project", "/repo", "other-topic"),
true,
"completed inactive topic with newer activity shows unread attention",
);
eq(
projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 2000 }, "project", "/repo", "other-topic"),
false,
"completed topic stops showing unread attention once opened at its latest activity",
);
eq(
projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 1000 }, "project", "/repo", "topic-complete"),
false,
"active topic does not show unread attention",
);
eq(
projectTreeTopicHasUnreadActivity({ ...completedTopic, status: "streaming", running: true }, { [completedTopicKey]: 1000 }, "project", "/repo", "other-topic"),
false,
"running topic keeps runtime status instead of completed-unread attention",
);
eq(
projectTreeShouldRenderTopicActions(false, true, false),
true,
"read workbench topic renders hover actions",
);
eq(
projectTreeShouldRenderTopicActions(false, true, true),
false,
"unread workbench topic omits hover actions from the keyboard tab order",
);
eq(
projectTreeShouldRenderTopicActions(true, true, false),
false,
"runtime session rows do not render topic hover actions",
);
eq(
projectTreeShouldSuppressOpenForRename(
{ rowKey: "topic-a", canRename: true },
{ rowKey: "topic-a", canRename: true },
),
true,
"second click on the same renameable topic suppresses open for inline rename",
);
eq(
projectTreeShouldSuppressOpenForRename(
{ rowKey: "session-a", canRename: false },
{ rowKey: "session-a", canRename: false },
),
false,
"runtime session double-click still allows the session row to open",
);
eq(
projectTreeShouldSuppressOpenForRename(
{ rowKey: "topic-a", canRename: true },
{ rowKey: "topic-b", canRename: true },
),
false,
"quickly clicking a different topic still opens the new target",
);
eq(
projectTreeFolderDisclosure(false, true),
{
@@ -1,6 +1,7 @@
// Run: tsx src/__tests__/provider-model-refresh.test.ts
import {
apiKeyEnvFromProviderName,
inferredVisionModels,
isLikelyChatModel,
isLikelyVisionModel,
@@ -139,6 +140,21 @@ eq(
"preserves an explicitly configured key env",
);
eq(
[
apiKeyEnvFromProviderName("商汤"),
apiKeyEnvFromProviderName("通义千问"),
],
["CUSTOM_d39b9067_API_KEY", "CUSTOM_e995c4c9_API_KEY"],
"generates distinct stable key envs for non-ASCII provider names",
);
eq(
providerApiKeyEnvForSave("商汤", "CUSTOM_API_KEY", "sk-test"),
"CUSTOM_API_KEY",
"preserves an explicitly configured legacy custom key env",
);
eq(
[
providerRequiresKey({ apiKeyEnv: "" }),
@@ -0,0 +1,105 @@
// Run: tsx src/__tests__/resize-drag.test.ts
import { createRafResizeUpdater } from "../lib/resizeDrag";
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("\nresizeDrag");
const frames: FrameRequestCallback[] = [];
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
frames.push(callback);
return frames.length;
}) as typeof requestAnimationFrame;
globalThis.cancelAnimationFrame = ((id: number) => {
frames[id - 1] = () => undefined;
}) as typeof cancelAnimationFrame;
try {
const applied: Array<[string, string]> = [];
const attrs: Record<string, string> = {};
const updater = createRafResizeUpdater({
target: {
style: {
setProperty(name: string, value: string) {
applied.push([name, value]);
},
},
},
separator: {
setAttribute(name: string, value: string) {
attrs[name] = value;
},
},
cssVar: "--pane-width",
});
updater.schedule(101.2);
updater.schedule(148.7);
eq(applied.length, 0, "live resize waits for animation frame");
eq(frames.length, 1, "multiple pointer moves share one animation frame");
frames[0](0);
eq(applied.length, 1, "one frame applies one live resize");
eq(applied[0]?.[0], "--pane-width", "live resize writes the configured CSS variable");
eq(applied[0]?.[1], "149px", "live resize applies the latest rounded pixel value");
eq(attrs["aria-valuenow"], "149", "live resize keeps separator ARIA in sync");
frames.length = 0;
const flushed: Array<[string, string]> = [];
const flushUpdater = createRafResizeUpdater({
target: {
style: {
setProperty(name: string, value: string) {
flushed.push([name, value]);
},
},
},
cssVar: "--pane-width",
});
flushUpdater.schedule(600);
frames[0](0);
flushUpdater.schedule(420);
eq(flushed[flushed.length - 1]?.[1], "600px", "pending final resize waits for animation frame before flush");
flushUpdater.flush();
eq(flushed[flushed.length - 1]?.[1], "420px", "flush applies the final resize before React state commits");
frames.length = 0;
const appliedValues: number[] = [];
const callbackUpdater = createRafResizeUpdater({
target: {
style: {
setProperty() {
// CSS write is covered above; this block checks the synchronized callback contract.
},
},
},
cssVar: "--pane-width",
onApply(value) {
appliedValues.push(value);
},
});
callbackUpdater.schedule(279.7);
callbackUpdater.schedule(300.2);
eq(appliedValues.length, 0, "live resize callback waits for animation frame");
frames[0](0);
eq(appliedValues.join(","), "300", "live resize callback receives the applied rounded value");
} finally {
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -1,5 +1,8 @@
// Run: tsx src/__tests__/send-failed.test.ts
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { initialState, reducer, replayPendingPromptsForActiveTab } from "../lib/useController";
import type { WireEvent } from "../lib/types";
@@ -35,6 +38,41 @@ const confirmed = reducer(sent, { type: "event", e: { kind: "text", text: "hi" }
eq(confirmed.items.filter((it) => it.kind === "user").length, 1, "first backend event confirms without duplicating");
eq(confirmed.pendingUser, undefined, "confirmation clears the pending marker");
const memoryStatsEvent = {
kind: "memory_compiler_stats",
memoryCompiler: {
injected: true,
usefulIR: true,
compiledTokens: 640,
irOverheadTokens: 120,
memoryReferences: 2,
constraints: 1,
riskNotes: 0,
executionSteps: 3,
totalNodes: 18,
highSignalNodes: 4,
toolResultNodes: 6,
decisionNodes: 2,
strategyCount: 5,
learningCount: 3,
},
} as WireEvent;
const statsOnly = reducer(sent, { type: "event", e: memoryStatsEvent });
eq(statsOnly, sent, "memory compiler stats do not confirm or mutate the visible turn");
const startedThenStats = reducer(reducer(sent, { type: "event", e: { kind: "turn_started" } as WireEvent }), { type: "event", e: memoryStatsEvent });
eq(startedThenStats.items.length, 2, "memory compiler stats do not add transcript items after turn start");
const compilerCitationMessage = {
kind: "message",
memoryCitations: [{ kind: "compiler_reference", source: "Memory v5", note: "evidence: bash succeeded" }],
} as WireEvent;
const citationOnlyFinal = reducer(reducer(sent, { type: "event", e: { kind: "turn_started" } as WireEvent }), { type: "event", e: compilerCitationMessage });
eq(citationOnlyFinal.items.length, 1, "memory compiler citations alone do not leave an empty assistant bubble");
eq(citationOnlyFinal.items.some((it) => it.kind === "assistant"), false, "memory compiler citations alone stay hidden from the transcript");
const textThenCitationFinal = reducer(reducer(startedThenStats, { type: "event", e: { kind: "text", text: "done" } as WireEvent }), { type: "event", e: compilerCitationMessage });
const citedAssistant = textThenCitationFinal.items.find((it) => it.kind === "assistant");
eq(citedAssistant?.kind === "assistant" && citedAssistant.text, "done", "memory compiler citations preserve existing assistant text");
eq(citedAssistant?.kind === "assistant" && citedAssistant.memoryCitations?.length, 1, "memory compiler citations attach to real assistant content");
const failedState = reducer(sent, { type: "send_failed", error: "Send failed: bridge unavailable" });
const failedBubble = failedState.items.find((it) => it.kind === "user");
eq(failedBubble?.kind === "user" && failedBubble.failed, true, "send_failed marks the bubble failed");
@@ -44,13 +82,67 @@ eq(notice.kind === "notice" && notice.level, "warn", "the notice is a warning");
eq(failedState.running, false, "send_failed stops the running indicator");
eq(failedState.pendingUser, undefined, "send_failed clears the pending marker");
const shellSent = reducer({ ...initialState }, { type: "user", text: "!ls", seq: 0 });
const shellFailed = reducer(shellSent, { type: "send_failed", error: "Command failed: workspace is still starting" });
const shellNotice = shellFailed.items[shellFailed.items.length - 1];
eq(shellNotice.kind, "notice", "rejected shell command appends a visible notice");
eq(shellNotice.kind === "notice" && shellNotice.text.includes("workspace is still starting"), true, "shell rejection notice includes the backend error");
const lateFailure = reducer(confirmed, { type: "send_failed", error: "Send failed: late" });
eq(lateFailure, confirmed, "send_failed after backend confirmation is a no-op");
const beforeMcpReady = { ...initialState };
const mcpReady = reducer(beforeMcpReady, { type: "event", e: { kind: "mcp_surface_ready" } as WireEvent });
eq(mcpReady, beforeMcpReady, "mcp_surface_ready is accepted as a deliberate no-op");
const pendingMcpReady = reducer(sent, { type: "event", e: { kind: "mcp_surface_ready" } as WireEvent });
eq(pendingMcpReady, sent, "mcp_surface_ready does not confirm a pending submit");
const failedAfterMcpReady = reducer(pendingMcpReady, { type: "send_failed", error: "Send failed: bridge unavailable" });
const failedAfterMcpReadyBubble = failedAfterMcpReady.items.find((it) => it.kind === "user");
eq(
failedAfterMcpReadyBubble?.kind === "user" && failedAfterMcpReadyBubble.failed,
true,
"send_failed still marks a pending submit after mcp readiness",
);
const here = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
const typesSource = readFileSync(resolve(here, "../lib/types.ts"), "utf8");
const controllerSource = readFileSync(resolve(here, "../lib/useController.ts"), "utf8");
eq(typesSource.includes('"mcp_surface_ready"'), true, "TypeScript EventKind declares mcp_surface_ready");
eq(controllerSource.includes('e.kind === "memory_compiler_stats" || e.kind === "mcp_surface_ready"'), true, "reducer handles mcp_surface_ready before optimistic confirmation");
eq(
/state\.approval!\.tool === "exit_plan_mode" && allow\) await applyCollaborationMode\("normal"\);/.test(appSource),
true,
"plan approval clears the remembered plan restore intent before execution",
);
eq(
!/exit_plan_mode[\s\S]{0,240}rememberUserIntent:\s*false/.test(appSource),
true,
"plan approval must not preserve stale plan restore intent",
);
eq(
!appSource.includes("rememberUserIntent"),
true,
"collaboration mode changes always reconcile the remembered plan restore intent",
);
const unsent = reducer(sent, { type: "unsend" });
eq(unsent.pendingUser, undefined, "unsend clears the pending marker");
eq(unsent.discardTurn, true, "unsend discards the in-flight turn");
const planApprovalFirst = reducer(
{ ...initialState },
{ type: "event", e: { kind: "approval_request", approval: { id: "plan-1", tool: "exit_plan_mode", subject: "Approve plan" } } as WireEvent },
);
const planTurnDoneAfter = reducer(planApprovalFirst, { type: "event", e: { kind: "turn_done" } as WireEvent });
eq(
planTurnDoneAfter.approval?.id,
"plan-1",
"turn_done preserves out-of-order plan approval",
);
eq(planTurnDoneAfter.running, true, "preserved plan approval keeps the tab running");
eq(planTurnDoneAfter.pendingPrompt, true, "preserved plan approval keeps the prompt gate active");
let replayCalls = 0;
replayPendingPromptsForActiveTab(undefined, () => {
replayCalls += 1;
@@ -0,0 +1,113 @@
// Run: tsx src/__tests__/session-title-contract.test.ts
import {
historySessionDisplayTitle,
paletteSessionDisplayTitle,
paletteSessionHint,
paletteSessionKeywords,
} from "../lib/session";
import type { SessionMeta } from "../lib/types";
let passed = 0;
let failed = 0;
function eq<T>(actual: T, expected: T, label: string) {
if (Object.is(actual, expected)) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n expected: ${String(expected)}\n actual: ${String(actual)}\n`);
failed += 1;
}
}
function deepEq<T>(actual: T, expected: T, label: string) {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson === expectedJson) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n expected: ${expectedJson}\n actual: ${actualJson}\n`);
failed += 1;
}
}
function session(overrides: Partial<SessionMeta> = {}): SessionMeta {
return {
path: "/sessions/topic-backed.jsonl",
preview: "first saved prompt preview",
title: "Renamed saved session",
turns: 3,
createdAt: 100,
lastActivityAt: 200,
modTime: 200,
current: false,
open: false,
workspaceRoot: "/work/project-alpha",
topicId: "topic-1",
topicTitle: "Shared topic",
...overrides,
};
}
console.log("\nsession title contracts");
{
const item = session();
eq(
paletteSessionDisplayTitle(item, "Untitled"),
"Shared topic",
"Cmd+K displays the topic title for topic-backed sessions",
);
deepEq(
paletteSessionKeywords(item),
["Renamed saved session", "first saved prompt preview"],
"Cmd+K still searches the session rename and preview text",
);
eq(
paletteSessionHint(item),
"Renamed saved session · /work/project-alpha",
"Cmd+K shows the session rename in the hint so hidden search hits are visible",
);
}
{
const item = session();
eq(
historySessionDisplayTitle(item, "Untitled"),
"Renamed saved session",
"History displays an explicit session rename before the topic title",
);
}
{
const item = session({ title: " " });
eq(
historySessionDisplayTitle(item, "Untitled"),
"Shared topic",
"Unrenamed topic-backed history sessions fall back to topic title",
);
deepEq(
paletteSessionKeywords(item),
["first saved prompt preview"],
"Blank session titles are not added as Cmd+K keywords",
);
eq(
paletteSessionHint(item),
"first saved prompt preview · /work/project-alpha",
"Cmd+K falls back to preview text in the hint when there is no custom session title",
);
}
{
const item = session({ title: "Shared topic", preview: "first saved prompt preview" });
eq(
paletteSessionHint(item),
"first saved prompt preview · /work/project-alpha",
"Cmd+K skips duplicate title hints that repeat the visible topic title",
);
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -4,7 +4,12 @@ import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { SettingsPanel } from "../components/SettingsPanel";
import {
SettingsPanel,
providerBaseURLFromChatURL,
providerChatURLPreview,
providerEditorEffectiveKind,
} from "../components/SettingsPanel";
import { LocaleProvider } from "../lib/i18n";
import type { AppBindings } from "../lib/bridge";
import type { SettingsView } from "../lib/types";
@@ -34,6 +39,16 @@ function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 20; attempt += 1) {
await act(async () => {
await flushPromises();
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
function baseSettings(displayMode: "standard" | "compact" = "standard"): SettingsView {
return {
defaultModel: "",
@@ -46,7 +61,7 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
permissions: { mode: "ask", allow: [], ask: [], deny: [] },
sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], shell: "auto" },
network: { proxyMode: "auto", proxyUrl: "", noProxy: "", proxy: { type: "socks5", server: "", port: 0, username: "", password: "" } },
agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 12, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" },
agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" },
bot: {
enabled: false,
model: "",
@@ -67,9 +82,11 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
displayMode,
statusBarStyle: "text",
statusBarItems: ["model", "workspace", "git_branch", "cache", "balance"],
defaultToolApprovalMode: "ask",
checkUpdates: true,
telemetry: true,
metrics: true,
memoryCompilerEnabled: true,
configPath: "/tmp/reasonix/config.toml",
providerKinds: [],
autoApproveTools: false,
@@ -79,6 +96,12 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
console.log("\nsettings refresh snapshot");
eq(providerEditorEffectiveKind(true, "anthropic", ["anthropic", "openai"]), "openai", "new custom providers ignore sorted providerKinds and default to OpenAI");
eq(providerEditorEffectiveKind(false, "anthropic", ["anthropic", "openai"]), "anthropic", "existing providers preserve their stored kind");
eq(providerChatURLPreview("https://proxy.example.com/v1", "", false), "https://proxy.example.com/v1/chat/completions", "base URL mode previews chat completions URL");
eq(providerChatURLPreview("", "https://proxy.example.com/custom/chat", true), "https://proxy.example.com/custom/chat", "full URL mode previews configured URL");
eq(providerBaseURLFromChatURL("https://proxy.example.com/v1/chat/completions"), "https://proxy.example.com/v1", "chat URL derives base URL for model discovery");
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
@@ -148,6 +171,55 @@ ok(onChangedSettings?.displayMode === "compact", "onChanged receives the post-sa
await act(async () => {
root.unmount();
});
const retryRootEl = document.createElement("div");
document.body.appendChild(retryRootEl);
const retryRoot = createRoot(retryRootEl);
let failingSettingsCalls = 0;
window.go = {
main: {
App: {
Settings: async () => {
failingSettingsCalls += 1;
if (failingSettingsCalls === 1) throw new Error("/Users/example/.reasonix/settings.toml: permission denied");
return baseSettings("standard");
},
} as Partial<AppBindings> as AppBindings,
},
};
await act(async () => {
retryRoot.render(
<LocaleProvider>
<SettingsPanel
initialTab="general"
onClose={() => {}}
onChanged={() => {}}
/>
</LocaleProvider>,
);
await flushPromises();
});
await waitFor("settings load failure", () => Boolean(document.querySelector(".banner--error")));
ok(document.body.textContent?.includes("Settings could not be loaded.") === true, "failed initial settings load shows a visible error");
ok(document.body.textContent?.includes("Loading…") === false, "failed initial settings load stops showing the loading state");
const retryButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Retry") as HTMLButtonElement | undefined;
if (!retryButton) throw new Error("settings retry button did not render");
await act(async () => {
retryButton.click();
await flushPromises();
});
await waitFor("settings retry success", () => Boolean(Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Compact")));
eq(failingSettingsCalls, 2, "settings retry calls Settings again");
ok(document.body.textContent?.includes("Settings could not be loaded.") === false, "settings retry clears the load error");
await act(async () => {
retryRoot.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
@@ -25,8 +25,6 @@ function renderStatusBar(props: Partial<Parameters<typeof StatusBar>[0]> = {}):
<StatusBar
context={{ used: 0, window: 0, sessionTokens: 0 }}
running={false}
collaborationMode="normal"
toolApprovalMode="ask"
{...props}
/>
</LocaleProvider>,
@@ -80,5 +78,16 @@ console.log("\nstatus bar workspace");
ok(html.indexOf("feature/meta") < html.indexOf("workspace/repo"), "workspace items follow configured order");
}
{
const html = renderStatusBar({ items: ["model"] });
ok(!html.includes("YOLO"), "status bar renders only configured status items, not mode indicators");
ok(!html.includes("后台作业") && !html.includes("Background jobs"), "status bar omits non-configurable job indicators");
}
{
const defaultItems = DEFAULT_STATUS_BAR_ITEMS as readonly string[];
ok(!defaultItems.includes("autoresearch"), "autoresearch is not a configurable status bar UI item");
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,419 @@
// Run: tsx src/__tests__/tab-switch-hydration.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, WireEvent } 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(id: string, overrides: Partial<TabMeta> = {}): TabMeta {
const workspaceRoot = `/repo/${id}`;
return {
id,
scope: "project",
workspaceRoot,
workspaceName: id,
workspacePath: workspaceRoot,
gitBranch: "main",
topicId: `topic-${id}`,
topicTitle: id,
sessionPath: `${workspaceRoot}/sessions/${id}.jsonl`,
label: `model-${id}`,
ready: true,
running: false,
mode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
active: false,
cwd: workspaceRoot,
...overrides,
};
}
function metaFor(tab: TabMeta): Meta {
return {
label: tab.label,
ready: tab.ready,
startupErr: tab.startupErr,
eventChannel: "agent:event",
cwd: tab.cwd || tab.workspaceRoot,
workspaceRoot: tab.workspaceRoot,
workspaceName: tab.workspaceName,
workspacePath: tab.workspacePath,
gitBranch: tab.gitBranch,
autoApproveTools: false,
bypass: false,
collaborationMode: tab.collaborationMode ?? "normal",
toolApprovalMode: tab.toolApprovalMode ?? "ask",
tokenMode: tab.tokenMode ?? "full",
goal: "",
goalStatus: "stopped",
};
}
function userMessage(content: string): HistoryMessage {
return { role: "user", content };
}
console.log("\ntab switch hydration");
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: 12, window: 100, sessionTokens: 12 };
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
const balance: BalanceInfo = { available: false, display: "" };
const jobs: JobView[] = [];
const checkpoints: CheckpointMeta[] = [];
const tabA = tabMeta("tab-a", { active: true });
const tabB = tabMeta("tab-b");
const tabC = tabMeta("tab-c");
const tabD = tabMeta("tab-d");
let backendActiveId = "tab-a";
const historyB = deferred<HistoryMessage[]>();
const historyD = deferred<HistoryMessage[]>();
const contextDGate = deferred<ContextInfo>();
const setActiveBGate = deferred<void>();
const submitTabCGate = deferred<void>();
const historyCalls: string[] = [];
let contextDCalls = 0;
let holdNextContextForD = false;
let setActiveCalls = 0;
let newSessionCalls = 0;
let failSetActiveFor = "";
const runningTabs = new Set<string>();
const tabsById = new Map([tabA, tabB, tabC, tabD].map((tab) => [tab.id, tab]));
const eventHandlers: Array<(e: WireEvent) => void> = [];
const readyHandlers: Array<(tabId?: string) => void> = [];
function currentTabs(): TabMeta[] {
return [tabA, tabB, tabC, tabD].map((tab) => ({ ...tab, active: tab.id === backendActiveId, running: runningTabs.has(tab.id) }));
}
window.runtime = {
EventsOn: (name: string, cb: (...data: unknown[]) => void) => {
if (name === "agent:event") eventHandlers.push(cb as (e: WireEvent) => void);
if (name === "agent:ready") readyHandlers.push(cb as (tabId?: string) => void);
return () => {};
},
BrowserOpenURL: () => {},
};
window.go = {
main: {
App: {
ListTabs: async () => currentTabs(),
MetaForTab: async (tabID: string) => metaFor(tabsById.get(tabID) ?? tabA),
ContextUsageForTab: async (tabID: string) => {
if (tabID === "tab-d" && holdNextContextForD) {
contextDCalls += 1;
holdNextContextForD = false;
return contextDGate.promise;
}
if (tabID === "tab-d") contextDCalls += 1;
return context;
},
EffortForTab: async () => effort,
BalanceForTab: async () => balance,
JobsForTab: async () => jobs,
CheckpointsForTab: async () => checkpoints,
HistoryForTab: async (tabID: string) => {
historyCalls.push(tabID);
if (tabID === "tab-b") return historyB.promise;
if (tabID === "tab-d") return historyD.promise;
return [userMessage("cached A")];
},
HistoryPageForTab: async (tabID: string) => {
const messages = await window.go.main.App.HistoryForTab(tabID);
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 };
},
NewSession: async () => {
newSessionCalls += 1;
},
ReplayPendingPrompts: async () => {},
SetActiveTab: async (tabID: string) => {
setActiveCalls += 1;
if (tabID === "tab-b") await setActiveBGate.promise;
if (tabID === failSetActiveFor) throw new Error("persist failed");
backendActiveId = tabID;
},
SubmitToTab: async (tabID: string) => {
runningTabs.add(tabID);
if (tabID === "tab-c") await submitTabCGate.promise;
},
SubmitDisplayToTab: async (tabID: string) => {
runningTabs.add(tabID);
},
} 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 active tab", () => controller?.activeTabId === "tab-a" && controller.state.items.length === 1);
let switchToB: Promise<TabMeta[] | undefined> | undefined;
await act(async () => {
switchToB = controller?.switchTab("tab-b", tabB);
await flushPromises();
});
eq(setActiveCalls, 1, "SetActiveTab is called for the selected tab");
eq(controller?.activeTabId, "tab-b", "switchTab updates the active tab before backend activation resolves");
eq(controller?.state.meta?.label, "model-tab-b", "switchTab applies optimistic tab metadata immediately");
eq(controller?.state.items.length, 0, "uncached target tab does not keep the previous transcript visible");
eq(controller?.state.hydrating, true, "target tab shows lightweight hydration state while backend activation is pending");
eq(controller?.state.backendActivationPending, true, "target tab gates unscoped actions while backend activation is pending");
ok(!historyCalls.includes("tab-b"), "HistoryForTab is not requested before SetActiveTab completes");
let newSessionWhileSwitching: Promise<void> | undefined;
await act(async () => {
newSessionWhileSwitching = controller?.newSession();
await flushPromises();
});
eq(newSessionCalls, 0, "newSession waits for backend activation before using the unscoped binding");
await act(async () => {
setActiveBGate.resolve();
await switchToB;
await newSessionWhileSwitching;
await flushPromises();
});
eq(newSessionCalls, 1, "newSession runs after the selected tab is active in the backend");
await waitFor("tab-b history request", () => historyCalls.includes("tab-b"));
const historyCallsBeforeReturnToA = historyCalls.length;
await act(async () => {
await controller?.switchTab("tab-a", tabA);
await flushPromises();
});
await waitFor("tab-a restored", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
eq(historyCalls.length, historyCallsBeforeReturnToA, "cached idle tab skips history hydration when reselected");
await act(async () => {
historyB.resolve([userMessage("late B")]);
await historyB.promise;
await flushPromises();
});
eq(controller?.activeTabId, "tab-a", "late history for another tab does not change the active tab");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "cached A") ?? false, "late history for another tab does not overwrite the active transcript");
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "late B") ?? false), "late history stays scoped to its tab state");
const historyCallsBeforeFallbackSync = historyCalls.length;
backendActiveId = "tab-b";
await act(async () => {
await controller?.syncActiveTab(false);
await flushPromises();
});
eq(controller?.activeTabId, "tab-b", "backend fallback sync activates the backend-selected cached tab");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "late B") ?? false, "backend fallback sync keeps the cached transcript");
eq(historyCalls.length, historyCallsBeforeFallbackSync, "backend fallback sync preserves cached history instead of reloading it");
await act(async () => {
await controller?.switchTab("tab-a", tabA);
await flushPromises();
});
await waitFor("tab-a restored after fallback sync", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
failSetActiveFor = "tab-b";
const historyCallsBeforeFailedSwitch = historyCalls.length;
await act(async () => {
await controller?.switchTab("tab-b", tabB);
await flushPromises();
});
eq(controller?.activeTabId, "tab-a", "failed backend tab switch reverts to the previous active tab");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "cached A") ?? false, "failed backend tab switch keeps the previous transcript visible");
eq(historyCalls.length, historyCallsBeforeFailedSwitch, "failed backend tab switch does not hydrate the rejected target");
failSetActiveFor = "";
await act(async () => {
for (const handler of eventHandlers) handler({ kind: "phase", text: "Planner is thinking", tabId: "tab-a" });
for (const handler of eventHandlers) handler({ kind: "message", text: "Planner kept", reasoning: "Planner notes", tabId: "tab-a" });
await flushPromises();
});
await waitFor("cached planner transcript", () =>
controller?.state.items.some((item) => item.kind === "assistant" && item.text === "Planner kept" && item.reasoning === "Planner notes") ?? false
);
const historyCallsBeforeReady = historyCalls.length;
await act(async () => {
for (const handler of readyHandlers) handler();
await flushPromises();
});
await waitFor("ready hydration settled", () => controller?.state.hydrating === false);
eq(historyCalls.length, historyCallsBeforeReady, "agent ready with cached transcript skips executor-only history hydration");
ok(controller?.state.items.some((item) => item.kind === "phase" && item.text === "Planner is thinking") ?? false, "agent ready keeps cached planner phase");
ok(controller?.state.items.some((item) => item.kind === "assistant" && item.text === "Planner kept" && item.reasoning === "Planner notes") ?? false, "agent ready keeps cached planner answer");
let tabCSendResolved = false;
await act(async () => {
const sendPromise = controller?.sendToTab("tab-c", "streaming C");
sendPromise?.then(() => {
tabCSendResolved = true;
});
await flushPromises();
});
eq(tabCSendResolved, true, "sendToTab resolves after optimistic dispatch before backend submit completes");
await act(async () => {
await controller?.switchTab("tab-c", tabC);
await flushPromises();
});
eq(controller?.activeTabId, "tab-c", "switching to a cached running tab still updates the active tab");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "streaming C") ?? false, "cached running tab keeps its optimistic transcript");
ok(!historyCalls.includes("tab-c"), "cached running tab skips history hydration");
await act(async () => {
submitTabCGate.resolve();
await submitTabCGate.promise;
await flushPromises();
});
holdNextContextForD = true;
await act(async () => {
await controller?.openProjectTab(tabD.workspaceRoot, tabD.topicId || "");
await flushPromises();
});
eq(controller?.activeTabId, "tab-d", "openProjectTab activates the opened tab");
eq(controller?.state.items.length, 0, "open topic keeps the new tab transcript empty while hydrating");
ok(controller?.state.hydratePlaceholderItems?.some((item) => item.kind === "user" && item.text === "streaming C") ?? false, "open topic stores previous transcript only as a hydration placeholder");
await act(async () => {
historyD.resolve([userMessage("history D")]);
await historyD.promise;
await flushPromises();
});
eq(controller?.state.hydrating, false, "topic history clears visible hydration before ancillary phase 2 settles");
await waitFor("open topic phase 2 started", () => contextDCalls === 1);
const contextCallsBeforeReadyD = contextDCalls;
const historyCallsBeforeReadyD = historyCalls.length;
await act(async () => {
for (const handler of readyHandlers) {
handler("tab-b");
handler("tab-d");
handler();
}
await flushPromises();
});
eq(contextDCalls, contextCallsBeforeReadyD, "agent ready reuses in-flight open-topic hydration for the active tab");
eq(historyCalls.length, historyCallsBeforeReadyD, "background ready events do not hydrate the active tab");
await act(async () => {
contextDGate.resolve(context);
await contextDGate.promise;
await flushPromises();
});
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history D") ?? false, "topic history replaces the hydration placeholder");
eq(controller?.state.hydratePlaceholderItems?.length ?? 0, 0, "topic history clears the hydration placeholder");
const historyCallsBeforeReopenD = historyCalls.length;
await act(async () => {
await controller?.openProjectTab(tabD.workspaceRoot, tabD.topicId || "");
await flushPromises();
});
eq(controller?.activeTabId, "tab-d", "reopening an already hydrated topic keeps it active");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history D") ?? false, "reopened cached topic keeps its transcript");
eq(historyCalls.length, historyCallsBeforeReopenD, "reopening an already hydrated topic skips history hydration");
const contextCallsBeforeInactiveD = contextDCalls;
await act(async () => {
await controller?.openProjectTab(tabD.workspaceRoot, tabD.topicId || "");
await controller?.switchTab("tab-a", tabA);
await flushPromises();
});
await act(async () => {
await flushPromises();
await flushPromises();
});
eq(contextDCalls, contextCallsBeforeInactiveD, "inactive topic skips ancillary hydration after quick tab switch");
tabsById.set("tab-d", { ...tabD, sessionPath: `${tabD.workspaceRoot}/sessions/next-tab-d.jsonl` });
const historyCallsBeforeReboundD = historyCalls.length;
await act(async () => {
await controller?.openProjectTab(tabD.workspaceRoot, tabD.topicId || "");
await flushPromises();
});
eq(historyCalls.length, historyCallsBeforeReboundD + 1, "rebound topic reloads history when session path changes");
await act(async () => {
root.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -316,5 +316,25 @@ console.log("\ntool data archiving on tool_result");
eq(failedTodo?.status, "error", "failed todo_write keeps error status");
}
// ── Test 10: A successful empty todo_write becomes canonical without unarchiving older args ──
{
const oldArgs = todoArgs("old");
const clearArgs = `{"todos":[]}`;
let s = initialState;
s = reducer(s, { type: "event", e: { kind: "turn_started" } });
s = reducer(s, { type: "event", e: { kind: "tool_dispatch", tool: { id: "todo-old", name: "todo_write", args: oldArgs, readOnly: true } } });
s = reducer(s, { type: "event", e: { kind: "tool_result", tool: { id: "todo-old", name: "todo_write", readOnly: true, output: "Todos updated" } } });
s = reducer(s, { type: "event", e: { kind: "tool_dispatch", tool: { id: "todo-clear", name: "todo_write", args: clearArgs, readOnly: true } } });
s = reducer(s, { type: "event", e: { kind: "tool_result", tool: { id: "todo-clear", name: "todo_write", readOnly: true, output: "Todos updated" } } });
const tools = toolItems(s);
const oldTodo = tools.find((tool) => tool.id === "todo-old");
const clearTodo = tools.find((tool) => tool.id === "todo-clear");
ok((oldTodo?.args.length ?? 0) <= 205, "older todo_write args stay archived when latest todo_write clears the list");
ok(oldTodo?.args !== oldArgs, "older todo_write does not keep full JSON after a clear");
eq(clearTodo?.args, clearArgs, "empty todo_write clear keeps parseable canonical args");
eq(JSON.parse(clearTodo?.args ?? "{}").todos.length, 0, "empty todo_write clear remains parseable as the latest canonical list");
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,27 @@
// Run: tsx src/__tests__/tool-subject.test.ts
import { subjectOf } from "../lib/tools";
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("\ntool subject contract");
eq(subjectOf("task", JSON.stringify({ description: "audit docs" })), "audit docs", "task subject uses description");
eq(subjectOf("run_skill", JSON.stringify({ name: "code-reviewer", arguments: "review this branch" })), "code-reviewer", "run_skill subject uses skill name");
if (failed) {
process.stdout.write(`\n${failed} failed, ${passed} passed\n`);
process.exit(1);
}
process.stdout.write(`\n${passed} passed\n`);
@@ -3,7 +3,7 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { buildTurnGroups } from "../lib/transcriptGrouping";
import { buildStepGroups, buildTurnGroups, createWarmLayerState, questionTurnsById, warmColdPageForTurn, warmLayerForSession, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination } from "../lib/transcriptGrouping";
import type { Item } from "../lib/useController";
let passed = 0;
@@ -68,6 +68,45 @@ console.log("\ntranscript grouping contract");
eq(groups[2].assistantPreview, "answer 2", "keeps latest assistant preview for each turn");
}
{
const groups = buildStepGroups([
{ kind: "user", id: "u0", text: "fix this" },
{ kind: "assistant", id: "a1", text: "visible answer before retry", reasoning: "", streaming: false },
{ kind: "notice", id: "s1", level: "info", text: "↪ steer" },
{ kind: "assistant", id: "a2", text: "", reasoning: "", streaming: true },
] as Item[]);
eq(groups[1]?.isFinal, true, "visible assistant text before a later assistant stays outside processed folds");
}
{
const groups = buildStepGroups([
{ kind: "user", id: "u0", text: "use tools" },
{ kind: "assistant", id: "a1", text: "", reasoning: "", streaming: false },
{ kind: "tool", id: "t1", name: "read_file", args: "{}", readOnly: true, status: "done" },
{ kind: "assistant", id: "a2", text: "final answer", reasoning: "", streaming: false },
] as Item[]);
eq(groups[1]?.isFinal, false, "tool-only completed steps still fold in compact mode");
eq(groups[2]?.isFinal, true, "later visible final answer renders directly");
}
{
const visibleTurns = questionTurnsById([
{ id: "u0", text: "first", turn: 0 },
{ id: "u1", text: "second", turn: 1 },
]);
eq(visibleTurns.get("u0"), 0, "falls back to visible ordinal when no checkpoint turns exist");
eq(visibleTurns.get("u1"), 1, "visible ordinal fallback increments by question");
const backendTurns = questionTurnsById([
{ 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 },
]);
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");
}
{
const items = syntheticTranscriptItems(10_000, 1);
const start = performance.now();
@@ -77,5 +116,50 @@ console.log("\ntranscript grouping contract");
ok(elapsed < 50, `groups 10k turns in ${elapsed.toFixed(2)}ms`);
}
{
const firstPage = warmPagination({ turnCount: 100, hotTurns: 30, pageSize: 20, coldPage: 0 });
eq(firstPage.warmStartTurn, 50, "long transcripts initially render only the latest warm page");
eq(firstPage.warmEndTurn, 70, "warm page stops before the hot zone");
eq(firstPage.coldTurnCount, 50, "older cold turns stay hidden behind the load-more button");
const secondPage = warmPagination({ turnCount: 100, hotTurns: 30, pageSize: 20, coldPage: 1 });
eq(secondPage.warmStartTurn, 30, "loading earlier history adds one more warm page");
eq(secondPage.warmEndTurn, 70, "loading earlier history keeps the hot-zone boundary stable");
eq(secondPage.coldTurnCount, 30, "loading earlier history reduces the hidden cold count");
const shortTranscript = warmPagination({ turnCount: 25, hotTurns: 30, pageSize: 20, coldPage: 0 });
eq(shortTranscript.warmStartTurn, 0, "short transcripts have no warm zone");
eq(shortTranscript.warmEndTurn, 0, "short transcripts have no warm boundary");
eq(shortTranscript.coldTurnCount, 0, "short transcripts have no cold turns");
}
{
eq(warmColdPageForTurn({ turn: 10, turnCount: 100, hotTurns: 30, pageSize: 20 }), 2, "jumping to cold-zone turn 10 loads enough warm pages");
eq(warmColdPageForTurn({ turn: 0, turnCount: 100, hotTurns: 30, pageSize: 20 }), 3, "jumping to the first warm turn loads all warm pages");
eq(warmColdPageForTurn({ turn: 65, turnCount: 100, hotTurns: 30, pageSize: 20 }), 0, "jumping inside the initial warm page needs no extra cold page");
eq(warmColdPageForTurn({ turn: 80, turnCount: 100, hotTurns: 30, pageSize: 20 }), 0, "jumping inside the hot zone needs no warm pagination");
}
{
let state = createWarmLayerState("tab-a|0|a-u0");
state = warmLayerWithNextColdPage(state, "tab-a|0|a-u0");
state = warmLayerWithNextColdPage(state, "tab-a|0|a-u0");
state = warmLayerWithNextColdPage(state, "tab-a|0|a-u0");
state = warmLayerWithExpandedTurn(state, "tab-a|0|a-u0", 10, true);
eq(state.coldPage, 3, "loading earlier history advances the current session page");
ok(state.expandedWarmTurns.has(10), "expanded warm turns stay scoped to the current session");
const switched = warmLayerForSession(state, "tab-b|1|b-u0");
eq(switched.coldPage, 0, "switching sessions resets warm pagination before rendering");
eq(switched.expandedWarmTurns.size, 0, "switching sessions clears expanded warm turns");
const switchedPage = warmPagination({ turnCount: 100, hotTurns: 30, pageSize: 20, coldPage: switched.coldPage });
eq(switchedPage.warmStartTurn, 50, "switched long transcripts render only the latest warm page first");
const paged = warmLayerWithColdPageAtLeast(switched, "tab-b|1|b-u0", 2);
eq(paged.coldPage, 2, "jumping to a cold-zone question raises the session warm page");
eq(warmLayerWithColdPageAtLeast(paged, "tab-b|1|b-u0", 1).coldPage, 2, "jumping never lowers the loaded warm page");
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -127,6 +127,8 @@ for (const selector of [
".context-panel__section-head span",
".context-panel__metric span",
".context-panel__metric strong",
".app--creation .context-panel__mini-stat span",
".app--creation .context-panel__mini-stat strong",
".topbar__model",
".composer-modebar__item span",
".composer-more-menu__item span",
@@ -134,7 +136,34 @@ for (const selector of [
clipsSingleLine(selector);
}
eq(
finalDeclaration(".app--creation .layout.layout--workspace-open", "transition"),
"grid-template-columns 0s, min-width 0s",
"creation dock skips zero-width grid interpolation on open",
);
eq(
finalDeclaration(".app--creation .context-panel__usage", "animation"),
"none",
"creation overview usage card disables inherited entrance animation",
);
ok(
finalDeclaration(".app--creation .context-panel__mini-stat", "justify-content") !== "space-between",
"creation overview rows avoid edge-pinned value alignment",
);
ok(
finalDeclaration(".app--creation .context-panel__mini-stat", "grid-template-columns") !== "minmax(0, 1fr) auto",
"creation overview rows avoid the spacer grid that pushes values to the edge",
);
ok(
finalDeclaration(".app--creation .context-panel__mini-stat strong", "max-width") !== "14ch",
"creation overview values are not capped to a fixed 14ch width",
);
eq(finalDeclaration(".composer-modebar", "overflow"), "hidden", "chat mode switcher contains enlarged labels");
ok(
/@container\s*\(max-width:\s*760px\)[\s\S]*?\.composer-meta__control--model\s*\{[\s\S]*?flex\s*:\s*0 1 auto[\s\S]*?width\s*:\s*fit-content[\s\S]*?max-width\s*:\s*min\(240px,\s*42vw\)[\s\S]*?\.composer-meta--has-intent-chip\s+\.composer-meta__control--model\s*\{[\s\S]*?flex\s*:\s*0 1 auto[\s\S]*?width\s*:\s*fit-content[\s\S]*?max-width\s*:\s*min\(220px,\s*38vw\)[\s\S]*?\.composer-meta__control--effort\s*\{[\s\S]*?display\s*:\s*none[\s\S]*?\.composer-meta__control--more\s*\{[\s\S]*?display\s*:\s*inline-flex/.test(styles),
"composer compact controls activate at the capped theme width",
);
eq(finalDeclaration(".md table", "overflow-x"), "auto", "markdown tables scroll horizontally");
eq(finalDeclaration(".code", "overflow"), "auto", "code blocks scroll instead of widening the layout");
ok(
@@ -149,6 +178,10 @@ ok(
/@media\s*\(max-width:\s*760px\)[\s\S]*?\.settings-modal\s*\{[\s\S]*?width\s*:\s*100vw[\s\S]*?height\s*:\s*100vh/.test(styles),
"settings modal only becomes fullscreen at the narrow breakpoint",
);
ok(
/@media\s*\(max-width:\s*820px\)[\s\S]*?\.app\s+\.layout[\s\S]*?grid-template-columns\s*:\s*minmax\(0,\s*1fr\)\s*!important[\s\S]*?\.app\s+\.sidebar[\s\S]*?display\s*:\s*none\s*!important[\s\S]*?\.app\s+\.chat-pane[\s\S]*?grid-column\s*:\s*1\s*!important/.test(styles),
"narrow workbench layout hides side panels and keeps chat single-column",
);
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,187 @@
// Run: tsx src/__tests__/use-controller-cancel-reconcile.test.tsx
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { useController } from "../lib/useController";
import type { AppBindings } from "../lib/bridge";
import type { ContextInfo, EffortInfo, Meta, TabMeta, WireEvent } from "../lib/types";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`);
}
function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 50; attempt += 1) {
await act(async () => {
await flushPromises(20);
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta {
return {
id: "tab-a",
scope: "project",
workspaceRoot: "/repo",
workspaceName: "repo",
workspacePath: "/repo",
topicId: "topic-a",
topicTitle: "General",
label: "model",
ready: true,
running: false,
cancellable: false,
mode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
active: true,
cwd: "/repo",
...overrides,
};
}
function meta(): Meta {
return {
label: "model",
ready: true,
eventChannel: "agent:event",
cwd: "/repo",
workspaceRoot: "/repo",
workspaceName: "repo",
workspacePath: "/repo",
autoApproveTools: false,
bypass: false,
collaborationMode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
goal: "",
goalStatus: "stopped",
};
}
console.log("\nuse controller cancel 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 eventHandlers: Array<(e: WireEvent) => void> = [];
let backendRunning = false;
let cancelCalls = 0;
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
window.runtime = {
EventsOn: (name: string, cb: (payload: unknown) => void) => {
if (name === "agent:event") eventHandlers.push(cb as (e: WireEvent) => void);
return () => {};
},
BrowserOpenURL: () => {},
};
window.go = {
main: {
App: {
ListTabs: async () => [tabMeta({ running: backendRunning, cancellable: backendRunning })],
MetaForTab: async () => meta(),
ContextUsageForTab: async () => context,
EffortForTab: async () => effort,
BalanceForTab: async () => ({ available: false, display: "" }),
JobsForTab: async () => [],
CheckpointsForTab: async () => [],
HistoryForTab: async () => [],
HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
HistoryCheckpointTurnsForTab: async () => [],
ReplayPendingPrompts: async () => {},
CancelTab: async () => {
cancelCalls += 1;
backendRunning = false;
},
} 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("active tab", () => controller?.activeTabId === "tab-a");
await act(async () => {
await flushPromises(50);
});
backendRunning = true;
await act(async () => {
for (const handler of eventHandlers) handler({ kind: "turn_started", tabId: "tab-a" });
await flushPromises();
});
eq(controller?.state.running, true, "turn_started marks the tab running");
await act(async () => {
controller?.cancel();
await flushPromises();
await flushPromises();
});
for (let attempt = 0; attempt < 20 && controller?.state.running; attempt += 1) {
await act(async () => {
await flushPromises(50);
});
}
eq(controller?.state.running, false, "cancel reconciliation clears the running state");
eq(cancelCalls, 1, "CancelTab is called once");
eq(controller?.state.cancelRequested, false, "cancel reconciliation clears cancelRequested");
await act(async () => {
root.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -1,7 +1,9 @@
// Run: tsx src/__tests__/use-controller-meta.test.ts
import { foregroundRunningFromRuntimeMeta, initialState, reducer, sameMeta, shouldReconcileStaleTurn } from "../lib/useController";
import type { Meta, WireUsage } from "../lib/types";
import { foregroundRunningFromRuntimeMeta, initialState, metaFromTab, reducer, sameMeta, shouldReconcileStaleTurn } from "../lib/useController";
import type { Meta, TabMeta, WireUsage } from "../lib/types";
type LooseTabMeta = Omit<TabMeta, "toolApprovalMode"> & { toolApprovalMode?: TabMeta["toolApprovalMode"] | "" };
let passed = 0;
let failed = 0;
@@ -26,6 +28,7 @@ function meta(overrides: Partial<Meta> = {}): Meta {
workspaceName: "repo",
workspacePath: "/repo",
gitBranch: "main",
imageInputEnabled: true,
autoApproveTools: false,
bypass: false,
collaborationMode: "normal",
@@ -37,6 +40,31 @@ function meta(overrides: Partial<Meta> = {}): Meta {
};
}
function tab(overrides: Partial<LooseTabMeta> = {}): TabMeta {
return {
id: "tab-1",
scope: "project",
workspaceRoot: "/repo",
workspaceName: "repo",
workspacePath: "/repo",
gitBranch: "main",
topicId: "topic-1",
topicTitle: "Topic",
label: "DeepSeek-R1",
ready: true,
running: false,
mode: "normal",
collaborationMode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
goal: "",
goalStatus: "stopped",
active: true,
cwd: "/repo",
...overrides,
} as TabMeta;
}
function usage(source: string): WireUsage {
return {
promptTokens: 100,
@@ -59,6 +87,13 @@ console.log("\nuse controller meta");
eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "plan" })), false, "collaboration mode changes invalidate meta equality");
eq(sameMeta(meta({ workspacePath: "/repo" }), meta({ workspacePath: "/other" })), false, "workspace path changes invalidate meta equality");
eq(sameMeta(meta({ gitBranch: "main" }), meta({ gitBranch: "feature" })), false, "git branch changes invalidate meta equality");
eq(sameMeta(meta({ imageInputEnabled: true }), meta({ imageInputEnabled: false })), false, "image input capability changes invalidate meta equality");
}
{
const preserved = metaFromTab(tab({ toolApprovalMode: "" }), meta({ toolApprovalMode: "auto", autoApproveTools: false }));
eq(preserved.toolApprovalMode, "auto", "blank tab snapshot preserves explicit auto approval mode");
eq(preserved.autoApproveTools, false, "blank tab snapshot does not silently resurrect yolo approval");
}
{
@@ -100,6 +135,31 @@ console.log("\nuse controller meta");
eq(foregroundRunningFromRuntimeMeta({ running: true, backgroundJobs: 1 }), false, "background jobs without cancellable are background-only");
}
{
const restoredContext = reducer(initialState, {
type: "context",
context: {
used: 42,
window: 200,
sessionTokens: 120,
compactRatio: 0.5,
sessionCost: 0.012,
sessionCurrency: "$",
cacheHitTokens: 80,
cacheMissTokens: 20,
},
});
const reset = reducer(restoredContext, { type: "reset" });
eq(reset.context.used, 0, "reset clears context used tokens");
eq(reset.context.window, 200, "reset preserves context window");
eq(reset.context.sessionTokens, 0, "reset clears context session tokens");
eq(reset.context.cacheHitTokens, undefined, "reset clears restored cache hit tokens");
eq(reset.context.cacheMissTokens, undefined, "reset clears restored cache miss tokens");
eq(reset.context.sessionCost, undefined, "reset clears restored context session cost");
eq(reset.sessionCost, 0, "reset clears restored session cost state");
eq(reset.sessionCurrency, "¥", "reset restores default session currency");
}
{
const idleExecutor = reducer(
{ ...initialState, context: { used: 0, window: 200, sessionTokens: 0 } },
@@ -124,6 +184,78 @@ console.log("\nuse controller meta");
const activeHelper = reducer(active, { type: "event", e: { kind: "usage", usage: usage("subagent") } });
eq(activeHelper.sessionTokens, 120, "helper usage inside a turn still counts toward session tokens");
eq(activeHelper.sessionCost, 0.001, "helper usage inside a turn still counts toward session cost");
eq(activeHelper.usage, undefined, "helper usage inside a turn does not become displayed latest usage");
const plannerFirst = reducer(active, { type: "event", e: { kind: "usage", usage: usage("planner") } });
eq(plannerFirst.sessionTokens, 120, "planner usage inside a turn still counts toward session tokens");
eq(plannerFirst.usage, undefined, "planner usage does not fill the single displayed usage slot");
const activeExecutor = reducer(active, { type: "event", e: { kind: "usage", usage: usage("executor") } });
const afterCompaction = reducer(activeExecutor, { type: "event", e: { kind: "usage", usage: usage("compaction") } });
eq(afterCompaction.usage?.source, "executor", "compaction usage does not overwrite displayed executor usage");
eq(afterCompaction.sessionTokens, 240, "compaction usage still contributes to session token totals");
}
{
let s = reducer(initialState, { type: "user", text: "first", seq: 0 });
s = reducer(s, { type: "event", e: { kind: "turn_started" } });
s = reducer(s, { type: "event", e: { kind: "notice", level: "info", text: "runtime notice" } });
s = reducer(s, { type: "event", e: { kind: "turn_done" } });
const merged = reducer(s, {
type: "history_checkpoint_turns",
turns: [0],
});
const user = merged.items.find((item) => item.kind === "user");
const notice = merged.items.find((item) => item.kind === "notice" && item.text === "runtime notice");
eq(user?.kind === "user" && user.checkpointTurn, 0, "turn_done checkpoint merge stamps user turn zero");
eq(Boolean(notice), true, "turn_done checkpoint merge preserves runtime notices");
}
{
let s = reducer(initialState, {
type: "history_page",
mode: "replace",
page: {
messages: [
{ role: "user", content: "recent prompt" },
{ role: "assistant", content: "recent answer" },
],
startTurn: 60,
endTurn: 61,
totalTurns: 61,
hasOlder: true,
},
});
eq(s.items.some((item) => item.kind === "user" && item.text === "recent prompt"), true, "history page replace renders the latest window");
eq(s.historyStartTurn, 60, "history page stores the older cursor");
eq(s.historyHasOlder, true, "history page records older availability");
const checkpointed = reducer(s, {
type: "history_checkpoint_turns",
turns: Array.from({ length: 61 }, (_, index) => index + 1000),
});
const recentUser = checkpointed.items.find((item) => item.kind === "user" && item.text === "recent prompt");
eq(recentUser?.kind === "user" && recentUser.checkpointTurn, 1060, "paged checkpoint merge uses the window start turn");
s = reducer(s, { type: "history_older_start" });
eq(s.historyOlderLoading, true, "older history request marks loading");
s = reducer(s, {
type: "history_page",
mode: "prepend",
page: {
messages: [
{ role: "user", content: "older prompt" },
{ role: "assistant", content: "older answer" },
],
startTurn: 0,
endTurn: 1,
totalTurns: 61,
hasOlder: false,
},
});
const users = s.items.filter((item) => item.kind === "user");
eq(users[0]?.kind === "user" && users[0].text, "older prompt", "older history prepends before the current window");
eq(users[1]?.kind === "user" && users[1].text, "recent prompt", "older history keeps the current window");
eq(s.historyHasOlder, false, "older history clears hasOlder when all pages are loaded");
eq(s.historyOlderLoading, false, "older history clears loading");
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
@@ -0,0 +1,155 @@
// Run: tsx src/__tests__/workspace-changes-errors.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { WorkspacePanel } from "../components/WorkspacePanel";
import { LocaleProvider } from "../lib/i18n";
import type { AppBindings } from "../lib/bridge";
import type { WorkspaceChangesView } from "../lib/types";
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 flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 20; attempt += 1) {
await act(async () => {
await flushPromises();
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
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.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
globalThis.MutationObserver = dom.window.MutationObserver;
globalThis.ResizeObserver = TestResizeObserver;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
return dom;
}
async function renderWorkspace(changes: WorkspaceChangesView) {
const dom = installDom();
window.go = {
main: {
App: {
ListDir: async () => [],
WorkspaceGitHistory: async () => [],
WorkspaceChanges: async () => changes,
} as Partial<AppBindings> as AppBindings,
},
};
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(
<LocaleProvider>
<WorkspacePanel
open
tabId="tab-a"
cwd="/repo"
maximized={false}
initialViewMode="changed"
onClose={() => {}}
onToggleMaximized={() => {}}
/>
</LocaleProvider>,
);
await flushPromises();
});
await waitFor("workspace changes", () => Boolean(document.querySelector(".workspace-preview__body")));
return { dom, root };
}
console.log("\nworkspace changes git errors");
{
const { dom, root } = await renderWorkspace({ files: [], gitAvailable: false });
await waitFor("git unavailable warning", () => document.body.textContent?.includes("Git status is unavailable for this workspace.") === true);
ok(document.body.textContent?.includes("Git status is unavailable for this workspace.") === true, "gitAvailable=false renders a warning");
ok(document.body.textContent?.includes("No changed files") === false, "gitAvailable=false is not shown as a clean workspace");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const { dom, root } = await renderWorkspace({
files: [],
gitAvailable: true,
gitErr: "git status timed out",
});
await waitFor("git error warning without files", () => document.body.textContent?.includes("Git status is unavailable for this workspace.") === true);
ok(document.body.textContent?.includes("Git status is unavailable for this workspace.") === true, "gitErr without files renders a warning");
ok(document.body.textContent?.includes("No changed files") === false, "empty files plus gitErr is not shown as a clean workspace");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const { dom, root } = await renderWorkspace({
files: [
{
path: "src/app.ts",
sources: ["session"],
gitStatus: "modified",
latestPrompt: "edit app",
},
],
gitAvailable: true,
gitErr: "git status timed out",
});
await waitFor("git error warning with files", () => document.body.textContent?.includes("app.ts") === true);
ok(document.body.textContent?.includes("Git status is unavailable for this workspace.") === true, "gitErr renders a warning");
ok(document.body.textContent?.includes("app.ts") === true, "files still render when gitErr is present");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -1,13 +1,19 @@
// Run: tsx src/__tests__/workspace-layout.test.ts
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
availableWorkspacePanelWidth,
resolveLiveWorkspacePanelWidth,
resolveWorkspacePanelWidth,
workspacePanelAriaMinWidth,
} from "../lib/workspaceLayout";
let passed = 0;
let failed = 0;
const testDir = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
function eq(a: unknown, b: unknown, label: string) {
if (a === b) {
@@ -24,6 +30,7 @@ const SIDEBAR_WIDTH = 264;
const RESIZER_WIDTH = 8;
const PREVIEW_MIN_WIDTH = 420;
const PREVIEW_DEFAULT_WIDTH = 660;
const CHAT_COMFORT_MIN_WIDTH = 560;
console.log("\nworkspace dock layout");
@@ -108,5 +115,42 @@ eq(
"maximized panel preserves the saved preferred width",
);
eq(
resolveLiveWorkspacePanelWidth({
viewportWidth: 1268,
sidebarCollapsed: false,
sidebarWidth: 400,
chatMinWidth: CHAT_COMFORT_MIN_WIDTH,
resizerWidth: RESIZER_WIDTH,
open: true,
maximized: false,
preferredWidth: PREVIEW_MIN_WIDTH,
minWidth: PREVIEW_MIN_WIDTH,
}),
300,
"live dock drag clamps the hard minimum to the available dock width",
);
eq(
resolveLiveWorkspacePanelWidth({
viewportWidth: 1280,
sidebarCollapsed: false,
sidebarWidth: 500,
chatMinWidth: CHAT_COMFORT_MIN_WIDTH,
resizerWidth: RESIZER_WIDTH,
open: true,
maximized: false,
preferredWidth: PREVIEW_DEFAULT_WIDTH,
minWidth: PREVIEW_MIN_WIDTH,
}),
212,
"live sidebar drag recomputes dock width from the dragged sidebar width",
);
eq(
/const closeWorkspacePanel = useCallback\(\(\) => \{[\s\S]*?setLiveWorkspacePanelRenderWidth\(null\);[\s\S]*?setWorkspacePanelOpen\(false\);/.test(appSource),
true,
"closing the dock clears the transient render width before hiding the panel",
);
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,99 @@
// Run: tsx src/__tests__/workspace-preview-css.test.ts
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { JSDOM } from "jsdom";
const testDir = dirname(fileURLToPath(import.meta.url));
const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8");
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;
}
}
function matchingBlocks(selector: string): string[] {
const blocks: string[] = [];
const rule = /([^{}]+)\{([^{}]*)\}/g;
let match: RegExpExecArray | null;
while ((match = rule.exec(styles)) !== null) {
const selectors = match[1].split(",").map((part) => part.trim());
if (selectors.includes(selector)) blocks.push(match[2]);
}
return blocks;
}
function finalDeclaration(selector: string, property: string): string | undefined {
let value: string | undefined;
for (const block of matchingBlocks(selector)) {
const declaration = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "g");
let match: RegExpExecArray | null;
while ((match = declaration.exec(block)) !== null) {
value = match[1].trim();
}
}
return value;
}
function computedDeclaration(html: string, selector: string, property: string): string {
const dom = new JSDOM(html);
const style = dom.window.document.createElement("style");
style.textContent = styles;
dom.window.document.head.append(style);
const element = dom.window.document.querySelector(selector);
if (!element) throw new Error(`Missing selector in test DOM: ${selector}`);
return dom.window.getComputedStyle(element).getPropertyValue(property).trim();
}
console.log("\nworkspace preview css");
eq(finalDeclaration(".workspace-preview__body--code", "overflow"), "hidden", "code preview body does not create a nested scroller");
eq(finalDeclaration(".workspace-preview__body--code", "display"), "flex", "code preview body hosts an editor-like viewport");
eq(finalDeclaration(".workspace-preview__body--code", "flex-direction"), "column", "truncated code notes stack above the code viewport");
eq(
computedDeclaration(
`<html data-theme-style="default"><head></head><body><aside class="workspace-panel workspace-panel--embedded"><div class="workspace-preview__body workspace-preview__body--code"></div></aside></body></html>`,
".workspace-preview__body--code",
"padding",
),
"0px",
"code preview body keeps zero padding under embedded and themed cascade",
);
eq(finalDeclaration(".workspace-preview__body--code .workspace-note", "flex"), "0 0 auto", "code truncation note keeps its own row");
eq(finalDeclaration(".workspace-preview__body--code .code-block", "display"), "flex", "code block fills the preview viewport");
eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "display"), "flex", "code wrapper keeps the preview viewport height");
eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "flex"), "1 1 auto", "code wrapper participates in the preview flex height");
eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "min-height"), "0", "code wrapper can shrink around the scrollable code viewport");
eq(finalDeclaration(".workspace-preview__body--code .code", "overflow"), "auto", "code viewport owns horizontal and vertical scrolling");
eq(finalDeclaration(".workspace-preview__body--code .code", "min-height"), "0", "code viewport can shrink inside the preview pane");
eq(finalDeclaration(".workspace-preview__body--code .code", "margin"), "0", "code viewport scrollbar sits at the visible pane bottom");
eq(
finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden)", "grid-template-columns"),
"var(--workspace-tree-rail-width) var(--workspace-tree-width) minmax(var(--workspace-preview-min-width), 1fr)",
"split mode keeps a narrow tree toggle rail beside the file tree",
);
eq(finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden) .workspace-files", "grid-column"), "2", "file tree sits beside the rail");
eq(finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden) .workspace-preview", "grid-column"), "3", "preview sits after rail and tree");
eq(
finalDeclaration(".workspace-panel--with-tree-rail .workspace-tree-resizer", "left"),
"calc(var(--workspace-tree-rail-width) + var(--workspace-tree-width) - 4px)",
"tree resizer accounts for the persistent rail",
);
eq(
finalDeclaration(".workspace-panel--tree-hidden", "grid-template-columns"),
"var(--workspace-tree-rail-width) minmax(0, 1fr)",
"preview-only mode keeps a narrow tree toggle rail",
);
eq(finalDeclaration(".workspace-panel--tree-hidden .workspace-preview", "grid-column"), "2", "preview sits beside the rail");
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,257 @@
// Run: tsx src/__tests__/workspace-split.test.ts
import {
initialWorkspaceSplitTreeWidth,
resolveWorkspaceSplitTreeWidth,
shouldInitializeWorkspaceSplitOnFileSelect,
workspaceSplitCanFit,
workspaceSplitTreeWidthFromPointer,
} from "../lib/workspaceSplit";
import { resolveWorkspacePanelWidth } from "../lib/workspaceLayout";
import { closeWorkspacePreviewTab } from "../lib/workspacePreviewTabs";
import { shouldScrollWorkspaceTreeSelection } from "../lib/workspaceTreeReveal";
import { mergeWorkspaceSearchResults } from "../lib/workspaceTreeSearch";
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("\nworkspace file split");
const TREE_MIN_WIDTH = 140;
const TREE_RAIL_WIDTH = 44;
const PREVIEW_MIN_WIDTH = 140;
eq(
initialWorkspaceSplitTreeWidth({
panelWidth: 660,
railWidth: TREE_RAIL_WIDTH,
savedTreeWidth: null,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
}),
308,
"first split divides the file area evenly after reserving the tree rail",
);
eq(
initialWorkspaceSplitTreeWidth({
panelWidth: 660,
railWidth: TREE_RAIL_WIDTH,
savedTreeWidth: 620,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
}),
476,
"tree width is clamped so the preview keeps its minimum width",
);
eq(
workspaceSplitTreeWidthFromPointer({
clientX: 400,
panelLeft: 100,
panelWidth: 660,
railWidth: TREE_RAIL_WIDTH,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
}),
256,
"tree resize pointer coordinates start after the tree rail",
);
eq(
workspaceSplitTreeWidthFromPointer({
clientX: 700,
panelLeft: 100,
panelWidth: 660,
railWidth: TREE_RAIL_WIDTH,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
}),
476,
"tree resize pointer clamps against the preview minimum after reserving the rail",
);
eq(
resolveWorkspaceSplitTreeWidth({
mode: "even",
currentTreeWidth: 140,
panelWidth: 660,
railWidth: TREE_RAIL_WIDTH,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
}),
308,
"default split recomputes evenly after the parent preview width applies",
);
eq(
resolveWorkspaceSplitTreeWidth({
mode: "manual",
currentTreeWidth: 256,
panelWidth: 660,
railWidth: TREE_RAIL_WIDTH,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
}),
256,
"manual split width is preserved when the parent width changes",
);
eq(
resolveWorkspacePanelWidth({
open: true,
maximized: false,
preferredWidth: 660,
minWidth: 300,
availableWidth: 228,
}),
228,
"outer file area can still shrink below split target width",
);
eq(
workspaceSplitCanFit({
panelWidth: 323,
railWidth: TREE_RAIL_WIDTH,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
}),
false,
"file tree collapses before rail tree and preview would overflow a narrow panel",
);
eq(
workspaceSplitCanFit({
panelWidth: 324,
railWidth: TREE_RAIL_WIDTH,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
}),
true,
"file tree remains available at the exact split minimum width",
);
eq(
workspaceSplitCanFit({
panelWidth: undefined,
railWidth: TREE_RAIL_WIDTH,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
}),
true,
"file tree stays visible until the measured panel width is known",
);
const closedRecentPreview = closeWorkspacePreviewTab(["a.ts", "b.ts", "c.ts"], "c.ts");
eq(closedRecentPreview.selectedPath, "b.ts", "closing the current preview falls back to the previous recent file");
eq(closedRecentPreview.openTabs.join(","), "a.ts,b.ts", "closing one preview preserves the other recent files");
eq(
shouldInitializeWorkspaceSplitOnFileSelect({ previewVisible: false, treeVisible: true }),
true,
"opening the first preview initializes an even file split",
);
eq(
shouldInitializeWorkspaceSplitOnFileSelect({ previewVisible: true, treeVisible: true }),
false,
"switching files while preview and tree are visible preserves the manual split",
);
eq(
shouldInitializeWorkspaceSplitOnFileSelect({ previewVisible: true, treeVisible: false }),
true,
"revealing the hidden tree initializes an even file split",
);
const effectiveEvenTreeWidth = resolveWorkspaceSplitTreeWidth({
mode: "even",
currentTreeWidth: TREE_MIN_WIDTH,
panelWidth: 660,
railWidth: TREE_RAIL_WIDTH,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
});
eq(
resolveWorkspaceSplitTreeWidth({
mode: "manual",
currentTreeWidth: effectiveEvenTreeWidth,
panelWidth: 660,
railWidth: TREE_RAIL_WIDTH,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
}),
effectiveEvenTreeWidth,
"entering manual resize from even split keeps the currently rendered tree width",
);
eq(
shouldScrollWorkspaceTreeSelection({
selectedPath: "src/App.tsx",
pendingRevealPath: "src/App.tsx",
actualTreeVisible: true,
selectedIndex: 42,
}),
true,
"pending file reveal scrolls once the selected row is present",
);
eq(
shouldScrollWorkspaceTreeSelection({
selectedPath: "src/App.tsx",
pendingRevealPath: null,
actualTreeVisible: true,
selectedIndex: 42,
}),
false,
"manual folder changes do not re-scroll to the previous selected file",
);
eq(
shouldScrollWorkspaceTreeSelection({
selectedPath: "src/App.tsx",
pendingRevealPath: "src/App.tsx",
actualTreeVisible: true,
selectedIndex: -1,
}),
false,
"pending file reveal waits until async directory loading exposes the row",
);
eq(
mergeWorkspaceSearchResults(
[{ path: "README.md", entry: { name: "README.md", isDir: false } }],
[
{ name: "src/deep/README.md", isDir: false },
{ name: "README.md", isDir: false },
],
).map((row) => `${row.path}:${row.entry.name}`).join("|"),
"README.md:README.md|src/deep/README.md:README.md",
"workspace filter merges backend deep search results without duplicating loaded rows",
);
eq(
mergeWorkspaceSearchResults(
[{ path: "docs/assets/", entry: { name: "assets", isDir: true } }],
[
{ name: "docs/assets", isDir: true },
{ name: "docs/guides", isDir: true },
],
).map((row) => `${row.path}:${row.entry.name}:${row.entry.isDir}`).join("|"),
"docs/assets/:assets:true|docs/guides/:guides:true",
"workspace filter normalizes directory search results to tree row paths",
);
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
+1 -16
View File
@@ -1,4 +1,4 @@
import { Minus, PanelLeft, PanelRight, Search, Square, X } from "lucide-react";
import { PanelLeft, PanelRight, Search } from "lucide-react";
import { TabBar } from "./TabBar";
import type { TabMeta } from "../lib/types";
import { useT } from "../lib/i18n";
@@ -58,14 +58,12 @@ export function AppChrome({
}: AppChromeProps) {
const t = useT();
const darwinChrome = platform === "darwin";
const showWindowsPreviewControls = browserPreviewChrome && platform === "windows";
const chromeClassName = [
"app-chrome",
"app-chrome--tabs",
darwinChrome ? "app-chrome--darwin-tabs" : "app-chrome--native-tabs",
workbenchChrome ? "app-chrome--workbench" : "",
!darwinChrome ? "app-chrome--identityless" : "",
showWindowsPreviewControls ? "app-chrome--preview-window-controls" : "",
`app-chrome--platform-${platform}`,
].filter(Boolean).join(" ");
const tabBar = (
@@ -194,19 +192,6 @@ export function AppChrome({
<PanelRight size={16} />
</button>
)}
{showWindowsPreviewControls && (
<div className="app-chrome__window-controls app-chrome__window-controls--windows" aria-hidden="true">
<span className="app-chrome__window-control app-chrome__window-control--minimize">
<Minus size={12} strokeWidth={1.9} />
</span>
<span className="app-chrome__window-control app-chrome__window-control--maximize">
<Square size={10} strokeWidth={1.8} />
</span>
<span className="app-chrome__window-control app-chrome__window-control--close">
<X size={12} strokeWidth={1.9} />
</span>
</div>
)}
</header>
);
}
+139 -16
View File
@@ -1,10 +1,31 @@
import { useEffect, useRef, useState } from "react";
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 { WireApproval } from "../lib/types";
import type { ComposerInsertRequest, DirEntry, WireApproval } from "../lib/types";
import { PromptAction, PromptBadge, PromptHeaderAction, PromptShelf } from "./PromptShelf";
import { playAttentionChime } from "../lib/sound";
import { DUR_FAST } from "../lib/gsapAnimations";
import {
FileReferenceMenu,
insertTextAtSelection,
pickInlineFileReference,
useFileReferenceMenu,
} from "./FileReferenceMenu";
function animateShelfExit(
el: HTMLDivElement,
options: { opacity: number; y: number; duration: number; ease: string; onComplete: () => void },
) {
const animator = typeof gsap.to === "function"
? gsap
: (gsap as unknown as { default?: typeof gsap }).default;
if (animator && typeof animator.to === "function") {
animator.to(el, options);
return;
}
options.onComplete();
}
export function ApprovalModal({
approval,
@@ -12,33 +33,46 @@ export function ApprovalModal({
onRevisePlan,
onExitPlan,
onStop,
cwd,
insertRequest,
onRevisionActiveChange,
}: {
approval: WireApproval;
onAnswer: (allow: boolean, session: boolean, persist: boolean) => void;
onRevisePlan?: (text: string) => void;
onExitPlan?: () => void;
onStop: () => void;
cwd?: string;
insertRequest?: ComposerInsertRequest | null;
onRevisionActiveChange?: (active: boolean) => void;
}) {
const t = useT();
const isPlanApproval = approval.tool === "exit_plan_mode";
const subject = approval.subject.trim();
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 showToolDetailsByDefault = !isPlanApproval && hasToolDetails;
const [revisionOpen, setRevisionOpen] = useState(false);
const [revisionText, setRevisionText] = useState("");
const [detailsOpen, setDetailsOpen] = useState(() => showToolDetailsByDefault);
const [selectedIndex, setSelectedIndex] = useState(() => (isPlanApproval ? 1 : 0));
const cardRef = useRef<HTMLDivElement | null>(null);
const shelfRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const consumedInsertIdRef = useRef(0);
// When consecutive approvals arrive, animate the old card out before
// the new one slides in. GSAP fromTo on the shelf wrapper avoids the
// jarring pop when the API cycles through 4+ pending approvals.
const closingRef = useRef(false);
const subject = approval.subject.trim();
const fileMenu = useFileReferenceMenu(revisionText, cwd);
const answerWithExit = (fn: () => void) => {
if (closingRef.current) return;
closingRef.current = true;
const el = shelfRef.current;
if (el) {
gsap.to(el, {
animateShelfExit(el, {
opacity: 0,
y: 8,
duration: DUR_FAST,
@@ -69,9 +103,10 @@ export function ApprovalModal({
cardRef.current?.focus();
setRevisionOpen(false);
setRevisionText("");
setDetailsOpen(showToolDetailsByDefault);
setSelectedIndex(isPlanApproval ? 1 : 0);
playAttentionChime();
}, [approval.id, isPlanApproval]);
}, [approval.id, isPlanApproval, showToolDetailsByDefault]);
const actionCount = isPlanApproval ? 3 : 4;
const selectedIndexRef = useRef(selectedIndex);
@@ -106,8 +141,75 @@ export function ApprovalModal({
}, [isPlanApproval, onAnswer, onExitPlan, onStop, actionCount]);
useEffect(() => {
if (revisionOpen) inputRef.current?.focus();
}, [revisionOpen]);
if (revisionOpen) {
onRevisionActiveChange?.(true);
inputRef.current?.focus();
return () => onRevisionActiveChange?.(false);
}
onRevisionActiveChange?.(false);
}, [revisionOpen, onRevisionActiveChange]);
const focusRevisionInput = (caret = revisionText.length) => {
requestAnimationFrame(() => {
const input = inputRef.current;
if (!input) return;
input.focus();
input.setSelectionRange(caret, caret);
});
};
const insertRevisionText = useCallback((text: string) => {
const input = inputRef.current;
const start = input?.selectionStart ?? revisionText.length;
const end = input?.selectionEnd ?? start;
const next = insertTextAtSelection(revisionText, text, start, end);
setRevisionText(next.value);
focusRevisionInput(next.caret);
}, [revisionText]);
useEffect(() => {
if (!insertRequest || insertRequest.id === consumedInsertIdRef.current) return;
consumedInsertIdRef.current = insertRequest.id;
insertRevisionText(insertRequest.text);
}, [insertRequest, insertRevisionText]);
const pickRevisionFile = (entry: DirEntry) => {
const next = pickInlineFileReference(revisionText, fileMenu.atRaw, fileMenu.atDir, entry);
setRevisionText(next);
focusRevisionInput(next.length);
};
const onRevisionKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
submitRevision();
event.stopPropagation();
return;
}
if (fileMenu.open) {
if (event.key === "ArrowDown" && fileMenu.count > 0) {
event.preventDefault();
fileMenu.setActive((index) => (index + 1) % fileMenu.count);
return;
}
if (event.key === "ArrowUp" && fileMenu.count > 0) {
event.preventDefault();
fileMenu.setActive((index) => (index - 1 + fileMenu.count) % fileMenu.count);
return;
}
if ((event.key === "Enter" || event.key === "Tab") && fileMenu.count > 0) {
event.preventDefault();
const entry = fileMenu.items[fileMenu.active];
if (entry) pickRevisionFile(entry);
return;
}
if (event.key === "Escape") {
event.preventDefault();
fileMenu.dismiss();
return;
}
}
event.stopPropagation();
};
const submitRevision = () => {
const text = revisionText.trim();
@@ -123,6 +225,7 @@ export function ApprovalModal({
return (
<div ref={shelfRef}>
<PromptShelf
className="prompt-shelf--compact prompt-shelf--plan-approval"
barRef={cardRef}
titleId="plan-approval-title"
title={t("approval.planReady")}
@@ -155,11 +258,17 @@ export function ApprovalModal({
rows={3}
placeholder={t("approval.revisePlanPlaceholder")}
onChange={(event) => setRevisionText(event.target.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") submitRevision();
event.stopPropagation();
}}
onFocus={() => onRevisionActiveChange?.(true)}
onKeyDown={onRevisionKeyDown}
/>
{fileMenu.open && (
<FileReferenceMenu
items={fileMenu.items}
activeIndex={fileMenu.active}
onPick={pickRevisionFile}
onHover={fileMenu.setActive}
/>
)}
<div className="plan-revision__actions">
<button className="btn" onClick={() => setRevisionOpen(false)}>
{t("common.cancel")}
@@ -178,14 +287,23 @@ export function ApprovalModal({
return (
<div ref={shelfRef}>
<PromptShelf
className="prompt-shelf--compact prompt-shelf--tool-approval"
barRef={cardRef}
titleId="tool-approval-title"
title={t("approval.toolPending")}
badges={<PromptBadge>{approval.tool}</PromptBadge>}
meta={toolMeta}
headerActions={
<PromptHeaderAction onClick={() => answerWithExit(onStop)} ariaLabel={t("composer.stopShort")}>
Esc
</PromptHeaderAction>
<>
{hasToolDetails && (
<PromptHeaderAction onClick={() => setDetailsOpen((open) => !open)}>
{t(detailsOpen ? "approval.hideDetails" : "approval.details")}
</PromptHeaderAction>
)}
<PromptHeaderAction onClick={() => answerWithExit(onStop)} ariaLabel={t("composer.stopShort")}>
Esc
</PromptHeaderAction>
</>
}
actions={
<>
@@ -196,8 +314,13 @@ export function ApprovalModal({
</>
}
>
{subject && (
<pre className="approval-subject">{subject}</pre>
{detailsOpen && (
<div className="approval-details">
{approval.reason && <div className="approval-reason">{approval.reason}</div>}
{subject && (
<pre className="approval-subject">{subject}</pre>
)}
</div>
)}
</PromptShelf>
</div>
+20 -1
View File
@@ -22,8 +22,10 @@ export function AskCard({
// Per-question state: selected option labels, and an optional typed answer.
const [sel, setSel] = useState<Record<string, string[]>>({});
const [custom, setCustom] = useState<Record<string, string>>({});
const [customOpen, setCustomOpen] = useState(false);
const [active, setActive] = useState(0);
const shelfRef = useRef<HTMLDivElement | null>(null);
const customInputRef = useRef<HTMLInputElement | null>(null);
const advanceTimer = useRef<number | null>(null);
const questions = ask.questions;
@@ -36,6 +38,7 @@ export function AskCard({
shelfRef.current?.focus();
setSel({});
setCustom({});
setCustomOpen(false);
setActive(0);
if (advanceTimer.current != null) window.clearTimeout(advanceTimer.current);
playAttentionChime();
@@ -66,7 +69,15 @@ export function AskCard({
(sel[question.id]?.length ?? 0) > 0 || (custom[question.id]?.trim() ?? "") !== "";
const currentAnswered = q ? answered(q) : false;
const showSubmitAction = q ? q.multi || Boolean(custom[q.id]?.trim()) : false;
const showSubmitAction = q ? q.multi || customOpen || Boolean(custom[q.id]?.trim()) : false;
useEffect(() => {
setCustomOpen(false);
}, [active]);
useEffect(() => {
if (customOpen) customInputRef.current?.focus();
}, [customOpen]);
const finishOrAdvance = (nextSel = sel, nextCustom = custom) => {
if (advanceTimer.current != null) {
@@ -89,6 +100,7 @@ export function AskCard({
setCustom(nextCustom);
setSel(nextSel);
setCustomOpen(false);
if (!question.multi) {
if (advanceTimer.current != null) window.clearTimeout(advanceTimer.current);
@@ -148,6 +160,7 @@ export function AskCard({
return (
<PromptShelf
className="prompt-shelf--compact prompt-shelf--ask"
barRef={shelfRef}
titleId="ask-shelf-title"
title={t("ask.title")}
@@ -165,6 +178,9 @@ export function AskCard({
headerActions={
<>
<PromptHeaderAction onClick={onDismiss}>{t("ask.justChat")}</PromptHeaderAction>
{!customOpen && (
<PromptHeaderAction onClick={() => setCustomOpen(true)}>{t("ask.customAnswer")}</PromptHeaderAction>
)}
<PromptHeaderAction onClick={onStop} ariaLabel={t("composer.stopShort")}>Esc</PromptHeaderAction>
</>
}
@@ -213,8 +229,10 @@ export function AskCard({
)
}
>
{customOpen && (
<div className="ask-shelf__custom-row">
<input
ref={customInputRef}
className="ask-shelf__custom"
placeholder={t("ask.customPlaceholder")}
value={custom[q.id] ?? ""}
@@ -225,6 +243,7 @@ export function AskCard({
}}
/>
</div>
)}
</PromptShelf>
);
}
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { ShieldCheck, ShieldOff } from "lucide-react";
import { asArray } from "../lib/array";
import { app, openExternal } from "../lib/bridge";
import { useT } from "../lib/i18n";
import { mcpServerLifecycleActions } from "../lib/mcpServerLifecycle";
import { mcpServerLifecycleActions, mcpServerRetryableFromAvailableList } from "../lib/mcpServerLifecycle";
import type { CapabilitiesView, MCPServerInput, ServerView, SkillRootSkillView, SkillRootView, SkillsSettingsView, SkillView, TabMeta } from "../lib/types";
import { InlineConfirmButton } from "./InlineConfirmButton";
import { ResizableDrawer } from "./ResizableDrawer";
@@ -108,6 +109,7 @@ export function CapabilitiesPanel({
active: servers.filter((s) => s.status !== "failed"),
};
}, [view]);
const retryableActiveServerNames = useMemo(() => retryableAvailableServerNames(serverGroups.active), [serverGroups.active]);
const toggleSkill = useCallback((name: string) => {
setExpandedSkills((prev) => {
const next = new Set(prev);
@@ -213,7 +215,17 @@ export function CapabilitiesPanel({
)}
{serverGroups.active.length > 0 && (
<div className="cap-server-section">
<div className="cap-server-section__title">{t("caps.availableServers")}</div>
<div className="cap-server-section__head">
<div className="cap-server-section__title">{t("caps.availableServers")}</div>
<button
className="btn btn--small"
disabled={busy || retryableActiveServerNames.length === 0}
type="button"
onClick={() => void mutate(() => Promise.allSettled(retryableActiveServerNames.map((name) => app.ReconnectMCPServer(name))))}
>
{t("caps.retryAll")}
</button>
</div>
<ServerGroup
busy={busy}
servers={serverGroups.active}
@@ -228,6 +240,9 @@ export function CapabilitiesPanel({
onRetry={(name) => void mutate(() => app.ReconnectMCPServer(name))}
onReconnect={(name) => void mutate(() => app.ReconnectMCPServer(name))}
onConfirmClearAuth={(name) => void mutate(() => app.ClearMCPServerAuthentication(name))}
onTrustTool={(name, toolName) => void mutate(() => app.TrustMCPServerTool(name, toolName))}
onTrustTools={(name, toolNames) => void mutate(() => app.TrustMCPServerTools(name, toolNames))}
onUntrustTool={(name, toolName) => void mutate(() => app.UntrustMCPServerTool(name, toolName))}
onToggle={(name, on) => void mutate(() => app.SetMCPServerEnabled(name, on))}
onUpdate={(name, input) =>
void mutate(() => app.UpdateMCPServer(name, input)).then((ok) => {
@@ -309,7 +324,9 @@ function normalizeServerViews(servers: ServerView[] | null | undefined): ServerV
...server,
args: asArray(server.args),
envKeys: asArray(server.envKeys),
headerKeys: asArray(server.headerKeys),
toolList: asArray(server.toolList),
trustedReadOnlyTools: asArray(server.trustedReadOnlyTools),
})),
);
}
@@ -640,6 +657,9 @@ function ServerGroup({
onRetry,
onReconnect,
onConfirmClearAuth,
onTrustTool,
onTrustTools,
onUntrustTool,
onToggle,
onUpdate,
onToggleDetails,
@@ -656,6 +676,9 @@ function ServerGroup({
onRetry: (name: string) => void;
onReconnect: (name: string) => void;
onConfirmClearAuth: (name: string) => void;
onTrustTool: (name: string, toolName: string) => void;
onTrustTools: (name: string, toolNames: string[]) => void;
onUntrustTool: (name: string, toolName: string) => void;
onToggle: (name: string, on: boolean) => void;
onUpdate: (name: string, input: MCPServerInput) => void;
onToggleDetails: (name: string) => void;
@@ -678,6 +701,9 @@ function ServerGroup({
onRetry={() => onRetry(s.name)}
onReconnect={() => onReconnect(s.name)}
onConfirmClearAuth={() => onConfirmClearAuth(s.name)}
onTrustTool={(toolName) => onTrustTool(s.name, toolName)}
onTrustTools={(toolNames) => onTrustTools(s.name, toolNames)}
onUntrustTool={(toolName) => onUntrustTool(s.name, toolName)}
onToggle={(on) => onToggle(s.name, on)}
onUpdate={(input) => onUpdate(s.name, input)}
onToggleDetails={() => onToggleDetails(s.name)}
@@ -834,6 +860,9 @@ function ServerRow({
onRetry,
onReconnect,
onConfirmClearAuth,
onTrustTool,
onTrustTools,
onUntrustTool,
onToggle,
onUpdate,
onToggleDetails,
@@ -850,6 +879,9 @@ function ServerRow({
onRetry: () => void;
onReconnect: () => void;
onConfirmClearAuth: () => void;
onTrustTool: (toolName: string) => void;
onTrustTools: (toolNames: string[]) => void;
onUntrustTool: (toolName: string) => void;
onToggle: (on: boolean) => void;
onUpdate: (input: MCPServerInput) => void;
onToggleDetails: () => void;
@@ -933,6 +965,9 @@ function ServerRow({
onConnectNow={onRetry}
onReconnect={onReconnect}
onConfirmClearAuth={onConfirmClearAuth}
onTrustTool={onTrustTool}
onTrustTools={onTrustTools}
onUntrustTool={onUntrustTool}
toolsExpanded={toolsExpanded}
editing={editing}
onEdit={onEdit}
@@ -953,6 +988,9 @@ function ServerDetails({
onConnectNow,
onReconnect,
onConfirmClearAuth,
onTrustTool,
onTrustTools,
onUntrustTool,
toolsExpanded,
editing,
onEdit,
@@ -967,6 +1005,9 @@ function ServerDetails({
onConnectNow: () => void;
onReconnect: () => void;
onConfirmClearAuth: () => void;
onTrustTool: (toolName: string) => void;
onTrustTools: (toolNames: string[]) => void;
onUntrustTool: (toolName: string) => void;
toolsExpanded: boolean;
editing: boolean;
onEdit: () => void;
@@ -983,6 +1024,11 @@ function ServerDetails({
const canShowTools = s.status === "connected" && ((s.tools ?? 0) > 0 || (tools?.length ?? 0) > 0);
const showClearAuth = canClearAuth(s);
const authLabel = serverAuthLabel(s, t);
const trustedReadOnlyTools = s.trustedReadOnlyTools ?? [];
const trustedReadOnlyToolNames = new Set(trustedReadOnlyTools);
const canTrustTool = s.configured && !s.builtIn;
const reportedReadOnlyToolNames = (tools ?? []).filter((tool) => tool.readOnlyHint).map((tool) => tool.name);
const bulkTrustToolNames = reportedReadOnlyToolNames.filter((name) => !trustedReadOnlyToolNames.has(name));
if (editing && canEditConfig) {
return (
<div className="cap-server-details">
@@ -1019,6 +1065,18 @@ function ServerDetails({
<span className="cap-detail__value">{s.envKeys.join(", ")}</span>
</div>
)}
{s.headerKeys && s.headerKeys.length > 0 && (
<div className="cap-detail cap-detail--wide">
<span className="cap-detail__label">{t("caps.headerKeys")}</span>
<span className="cap-detail__value">{s.headerKeys.join(", ")}</span>
</div>
)}
{trustedReadOnlyTools.length > 0 && (
<div className="cap-detail cap-detail--wide">
<span className="cap-detail__label">{t("caps.trustedReadOnlyTools")}</span>
<span className="cap-detail__code">{trustedReadOnlyTools.join(", ")}</span>
</div>
)}
</div>
<div className="cap-detail-actions">
{canConnectNow && (
@@ -1036,6 +1094,18 @@ function ServerDetails({
{toolsExpanded ? t("caps.hideTools") : t("caps.showTools")}
</button>
)}
{canTrustTool && bulkTrustToolNames.length > 0 && (
<button
className="btn btn--small cap-trust-bulk"
disabled={busy}
onClick={() => onTrustTools(bulkTrustToolNames)}
title={t("caps.trustReportedReadOnlyTitle")}
type="button"
>
<ShieldCheck aria-hidden size={13} strokeWidth={2.2} />
{t("caps.trustReportedReadOnly", { count: bulkTrustToolNames.length })}
</button>
)}
{showClearAuth && (
<InlineConfirmButton
label={t("caps.clearAuth")}
@@ -1065,12 +1135,55 @@ function ServerDetails({
tools && tools.length > 0 ? (
<div className="cap-tool-list">
<div className="cap-tool-list__title">{t("caps.tools")}</div>
{tools.map((tool) => (
<div className="cap-tool" key={tool.name}>
<div className="cap-tool__name">{tool.name}</div>
{tool.description && <div className="cap-tool__desc">{tool.description}</div>}
</div>
))}
{tools.map((tool) => {
const trusted = trustedReadOnlyToolNames.has(tool.name);
return (
<div className="cap-tool" key={tool.name}>
<div className="cap-tool__name">{tool.name}</div>
<div className="cap-tool__desc">
<span>{tool.description}</span>
{tool.readOnlyHint && (
<span className="cap-tool-hint" title={t("caps.reportedReadOnlyTitle")}>
{t("caps.reportedReadOnly")}
</span>
)}
</div>
<div className="cap-tool__action">
{canTrustTool ? (
trusted ? (
<div className="cap-tool-trust-stack">
<span className="cap-tool-trust cap-tool-trust--trusted" title={t("caps.trustedReadOnlyTitle")}>
<ShieldCheck aria-hidden size={12} strokeWidth={2.2} />
{t("caps.trustedReadOnly")}
</span>
<button
className="btn btn--small cap-tool-untrust-btn"
disabled={busy}
onClick={() => onUntrustTool(tool.name)}
title={t("caps.untrustReadOnlyTitle")}
type="button"
>
<ShieldOff aria-hidden size={12} strokeWidth={2.2} />
{t("caps.untrustReadOnly")}
</button>
</div>
) : (
<button
className="btn btn--small cap-tool-trust-btn"
disabled={busy}
onClick={() => onTrustTool(tool.name)}
title={t("caps.trustReadOnlyTitle")}
type="button"
>
<ShieldCheck aria-hidden size={12} strokeWidth={2.2} />
{t("caps.trustReadOnly")}
</button>
)
) : null}
</div>
</div>
);
})}
</div>
) : (
<div className="cap-tool-empty">{t("caps.noToolDetails")}</div>
@@ -1096,19 +1209,23 @@ function EditServerForm({
const [transport, setTransport] = useState(initialTransport);
const [command, setCommand] = useState(initialTransport === "stdio" ? serverCommand(s) : "");
const [url, setUrl] = useState(initialTransport === "stdio" ? "" : s.url || serverCommand(s));
const [headers, setHeaders] = useState("");
const [env, setEnv] = useState("");
const isStdio = transport === "stdio";
const ready = isStdio ? command.trim() !== "" : url.trim() !== "";
const submit = () => {
const envText = env.trim();
const headerText = headers.trim();
onSave({
name: s.name,
transport,
command: isStdio ? command.trim() : "",
args: [],
url: isStdio ? "" : url.trim(),
env: envText === "" ? null : parseEnvText(envText),
env: envText === "" ? null : parseKeyValueText(envText),
headers: isStdio || headerText === "" ? null : parseKeyValueText(headerText),
trustedReadOnlyTools: s.trustedReadOnlyTools ?? [],
});
};
@@ -1138,6 +1255,19 @@ function EditServerForm({
<input className="mem-input" value={url} disabled={busy} onChange={(e) => setUrl(e.target.value)} placeholder={t("caps.urlPlaceholder")} />
</label>
)}
{!isStdio && (
<label className="cap-detail cap-detail--wide">
<span className="cap-detail__label">{t("caps.headersLabel")}</span>
<textarea className="mem-textarea cap-config-edit__env" value={headers} disabled={busy} onChange={(e) => setHeaders(e.target.value)} placeholder={t("caps.headersPlaceholder")} spellCheck={false} />
</label>
)}
{!isStdio && s.headerKeys && s.headerKeys.length > 0 && (
<div className="cap-detail cap-detail--wide">
<span className="cap-detail__label">{t("caps.headerKeys")}</span>
<span className="cap-detail__value">{s.headerKeys.join(", ")}</span>
<span className="cap-edit-hint">{t("caps.headersPreserveHint")}</span>
</div>
)}
<label className="cap-detail cap-detail--wide">
<span className="cap-detail__label">{t("caps.envLabel")}</span>
<textarea className="mem-textarea cap-config-edit__env" value={env} disabled={busy} onChange={(e) => setEnv(e.target.value)} placeholder={t("caps.envPlaceholder")} spellCheck={false} />
@@ -1171,13 +1301,15 @@ function normalizeTransportValue(transport: string): string {
return transport === "http" || transport === "sse" ? transport : "stdio";
}
function parseEnvText(env: string): Record<string, string> {
const envMap: Record<string, string> = {};
for (const line of env.split("\n")) {
function parseKeyValueText(text: string): Record<string, string> {
const values: Record<string, string> = {};
for (const rawLine of text.split("\n")) {
const line = rawLine.trim();
if (!line) continue;
const eq = line.indexOf("=");
if (eq > 0) envMap[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
if (eq > 0) values[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
}
return envMap;
return values;
}
function serverStatusLabel(s: ServerView, t: ReturnType<typeof useT>): string {
@@ -1272,6 +1404,10 @@ function canBulkRemoveFailure(server: ServerView): boolean {
return kind === "missing-command" || kind === "command-unavailable";
}
function retryableAvailableServerNames(servers: ServerView[]): string[] {
return servers.filter(mcpServerRetryableFromAvailableList).map((s) => s.name);
}
function serverActionLabel(s: ServerView, t: ReturnType<typeof useT>): string {
const err = (s.error || "").toLowerCase();
if (shouldOpenAuth(s)) return t("caps.reauthorize");
@@ -1400,24 +1536,23 @@ function AddServerForm({
const [transport, setTransport] = useState("stdio");
const [command, setCommand] = useState("");
const [url, setUrl] = useState("");
const [headers, setHeaders] = useState("");
const [env, setEnv] = useState("");
const isStdio = transport === "stdio";
const ready = name.trim() !== "" && (isStdio ? command.trim() !== "" : url.trim() !== "");
const submit = () => {
const envMap: Record<string, string> = {};
for (const line of env.split("\n")) {
const eq = line.indexOf("=");
if (eq > 0) envMap[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
}
const envText = env.trim();
const headerText = headers.trim();
onAdd({
name: name.trim(),
transport,
command: isStdio ? command.trim() : "",
args: [],
url: isStdio ? "" : url.trim(),
env: envMap,
env: envText === "" ? null : parseKeyValueText(envText),
headers: isStdio || headerText === "" ? null : parseKeyValueText(headerText),
});
};
@@ -1435,6 +1570,12 @@ function AddServerForm({
) : (
<input className="mem-input" placeholder={t("caps.urlPlaceholder")} value={url} onChange={(e) => setUrl(e.target.value)} />
)}
{!isStdio && (
<>
<label className="set-label">{t("caps.headersLabel")}</label>
<textarea className="mem-textarea" value={headers} onChange={(e) => setHeaders(e.target.value)} placeholder={t("caps.headersPlaceholder")} spellCheck={false} />
</>
)}
<label className="set-label">{t("caps.envLabel")}</label>
<textarea className="mem-textarea" value={env} onChange={(e) => setEnv(e.target.value)} placeholder={t("caps.envPlaceholder")} spellCheck={false} />
<div className="prov-card__actions">
@@ -1509,6 +1650,7 @@ export function MCPServersSettingsPage() {
active: sorted.filter((s) => s.status !== "failed"),
};
}, [servers]);
const retryableActiveServerNames = useMemo(() => retryableAvailableServerNames(serverGroups.active), [serverGroups.active]);
const toggleError = useCallback((name: string) => {
setExpandedErrors((prev) => { const next = new Set(prev); if (next.has(name)) next.delete(name); else next.add(name); return next; });
}, []);
@@ -1561,7 +1703,17 @@ export function MCPServersSettingsPage() {
)}
{serverGroups.active.length > 0 && (
<div className="cap-server-section">
<div className="cap-server-section__title">{t("caps.availableServers")}</div>
<div className="cap-server-section__head">
<div className="cap-server-section__title">{t("caps.availableServers")}</div>
<button
className="btn btn--small"
disabled={actionBusy || retryableActiveServerNames.length === 0}
type="button"
onClick={() => void mutate(() => Promise.allSettled(retryableActiveServerNames.map((name) => app.ReconnectMCPServer(name))))}
>
{t("caps.retryAll")}
</button>
</div>
<ServerGroup
busy={actionBusy}
servers={serverGroups.active}
@@ -1574,6 +1726,9 @@ export function MCPServersSettingsPage() {
onRetry={(name) => void mutate(() => app.ReconnectMCPServer(name))}
onReconnect={(name) => void mutate(() => app.ReconnectMCPServer(name))}
onConfirmClearAuth={(name) => void mutate(() => app.ClearMCPServerAuthentication(name))}
onTrustTool={(name, toolName) => void mutate(() => app.TrustMCPServerTool(name, toolName))}
onTrustTools={(name, toolNames) => void mutate(() => app.TrustMCPServerTools(name, toolNames))}
onUntrustTool={(name, toolName) => void mutate(() => app.UntrustMCPServerTool(name, toolName))}
onToggle={(name, on) => void mutate(() => app.SetMCPServerEnabled(name, on))}
onUpdate={(name, input) =>
void mutate(() => app.UpdateMCPServer(name, input)).then((ok) => {
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { Command, Search } from "lucide-react";
import { useT } from "../lib/i18n";
import { useMountTransition } from "../lib/useMountTransition";
// CommandPalette is a ⌘K / Ctrl+K modal that surfaces the desktop app's
@@ -57,9 +58,12 @@ export function CommandPalette({
placeholder: string;
emptyText: string;
}) {
const t = useT();
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const [active, setActive] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const isOpenRef = useRef(false);
isOpenRef.current = open;
// Keep the palette mounted through its exit animation after `open` flips
// false; `status` drives the enter/exit keyframes via data-state.
const { mounted, status } = useMountTransition(open, 200);
@@ -67,13 +71,27 @@ export function CommandPalette({
// Re-init whenever the palette opens: clear the query, reset the
// highlight, and steal focus. Doing it on the open edge (not on every
// render) means a previously-typed query doesn't leak across opens.
useEffect(() => {
// useLayoutEffect fires synchronously after DOM mutations, before the
// browser paints — ensures focus lands before any paint-time transitions
// can interfere.
useLayoutEffect(() => {
if (open) {
setQuery("");
setActive(0);
requestAnimationFrame(() => inputRef.current?.focus());
setActive(items.length > 0 ? 0 : -1);
inputRef.current?.focus();
}
}, [open]);
}, [open, items.length]);
// Callback ref: when the input element mounts while the palette is open,
// focus it immediately. This handles the case where the DOM element
// becomes available after the useLayoutEffect already ran.
const inputCallbackRef = useCallback(
(el: HTMLInputElement | null) => {
inputRef.current = el;
if (el && isOpenRef.current) el.focus();
},
[],
);
// score is the fuzzy match: every space-separated query token must
// appear (case-insensitively) in the candidate's haystack, in the order
@@ -130,11 +148,11 @@ export function CommandPalette({
// Clamp the active index whenever the result set shrinks (e.g. user
// typed something that filtered out the previously-highlighted item).
useEffect(() => {
if (active >= flat.length) setActive(Math.max(0, flat.length - 1));
if (active >= 0 && active >= flat.length) setActive(Math.max(0, flat.length - 1));
}, [flat.length, active]);
// Reset the highlight to 0 on every query change — the user just refined
// their search, the old highlight is rarely still interesting.
// Reset the highlight to the first match on every query change — the user
// just refined their search, the old highlight is rarely still interesting.
useEffect(() => {
setActive(0);
}, [query]);
@@ -145,6 +163,8 @@ export function CommandPalette({
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
const closeButtonHasFocus = e.target instanceof HTMLElement && Boolean(e.target.closest("[data-palette-close]"));
if (closeButtonHasFocus && (e.key === "Enter" || e.key === " ")) return;
if (e.key === "Escape") {
e.preventDefault();
onClose();
@@ -152,12 +172,12 @@ export function CommandPalette({
}
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((i) => (flat.length === 0 ? 0 : (i + 1) % flat.length));
setActive((i) => (flat.length === 0 ? -1 : i < 0 ? 0 : (i + 1) % flat.length));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setActive((i) => (flat.length === 0 ? 0 : (i - 1 + flat.length) % flat.length));
setActive((i) => (flat.length === 0 ? -1 : i <= 0 ? flat.length - 1 : i - 1));
return;
}
if (e.key === "Enter") {
@@ -189,7 +209,7 @@ export function CommandPalette({
<div className="palette__inputrow">
<Search className="palette__search-icon" size={18} aria-hidden="true" />
<input
ref={inputRef}
ref={inputCallbackRef}
className="palette__input"
value={query}
onChange={(e) => setQuery(e.target.value)}
@@ -197,7 +217,16 @@ export function CommandPalette({
spellCheck={false}
autoComplete="off"
/>
<kbd className="palette__esc">esc</kbd>
<button
className="palette__esc"
type="button"
onClick={onClose}
aria-label={t("common.close")}
title={t("common.close")}
data-palette-close
>
esc
</button>
</div>
<div className="palette__list" role="listbox">
{flat.length === 0 ? (
File diff suppressed because it is too large. Load diff
+369 -135
View File
@@ -1,9 +1,9 @@
// ContextPanel shows the active tab's context gauge and token usage.
// All visible text is routed through the i18n dictionary.
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { asArray } from "../lib/array";
import { app } from "../lib/bridge";
import { useI18n, type Translator } from "../lib/i18n";
import { useI18n, type Locale, type Translator } from "../lib/i18n";
import { formatMoneyLocalized } from "../lib/money";
import type { DictKey } from "../locales/en";
import type { BalanceInfo, ContextInfo, ContextPanelInfo, UsageSourceStats, WireUsage } from "../lib/types";
@@ -23,9 +23,9 @@ interface ContextPanelProps {
refreshKey?: number;
}
function fmtTokens(n: number): string {
if (n >= 1000) return `${Math.round(n / 1000)}k`;
return String(n);
function fmtFullTokens(n: number): string {
if (n <= 0) return "0";
return String(Math.round(n));
}
function fmtDuration(ms: number, t: Translator): string {
@@ -42,35 +42,61 @@ function fmtOptionalTokens(tokens?: number): string {
return tokens.toLocaleString();
}
function fmtTurns(turns: number | undefined, t: Translator): string {
if (typeof turns !== "number" || turns < 0) return "-";
return t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns });
interface MetricTokenDisplay {
display: string;
exact: string;
}
function numberLocale(locale: Locale | string): string {
if (locale === "zh") return "zh-CN";
if (locale === "zh-TW") return "zh-TW";
return "en";
}
export function formatMetricTokens(tokens: number | undefined, locale: Locale | string): MetricTokenDisplay {
if (typeof tokens !== "number" || tokens <= 0) {
return { display: "-", exact: "-" };
}
const tag = numberLocale(locale);
const exact = tokens.toLocaleString(tag);
return { display: exact, exact };
}
function fmtUsageCacheRate(usage?: WireUsage): string {
if (!usage) return "-";
const denom = usage.cacheHitTokens + usage.cacheMissTokens || usage.promptTokens;
const denom = usage.cacheHitTokens + usage.cacheMissTokens;
if (denom <= 0) return "-";
return `${((usage.cacheHitTokens / denom) * 100).toFixed(2)}%`;
}
function fmtSessionCacheRate(usage?: WireUsage): string {
if (!usage) return "-";
const denom = usage.sessionCacheHitTokens + usage.sessionCacheMissTokens;
if (denom <= 0) return "-";
return `${((usage.sessionCacheHitTokens / denom) * 100).toFixed(2)}%`;
}
export function formatCacheHitRate(hitTokens: number, missTokens: number): string {
const denom = hitTokens + missTokens;
if (denom <= 0) return "-";
return `${((hitTokens / denom) * 100).toFixed(2)}%`;
}
interface HealthResult {
type MetricTone = "accent" | "good" | "notice" | "warn";
type UsageAnalysisView = "source" | "type";
export function cacheHitTone(hitTokens: number, missTokens: number): MetricTone | undefined {
const denom = hitTokens + missTokens;
if (denom <= 0) return undefined;
const pct = (hitTokens / denom) * 100;
if (pct >= 80) return "good";
if (pct >= 60) return "notice";
return "warn";
}
function formatSharePercent(value: number, total: number): string {
if (total <= 0 || value <= 0) return "-";
const pct = (value / total) * 100;
if (pct > 0 && pct < 1) return "<1%";
return `${Math.round(pct)}%`;
}
interface ContextWindowStatus {
tone: "good" | "notice" | "warn";
shortKey: DictKey;
vars: Record<string, string | number>;
key: DictKey;
}
export function contextCostDisplay({
@@ -158,37 +184,27 @@ export function contextBreakdown(
};
}
function contextHealth(usagePct: number, cachePct: number, readCount: number): HealthResult {
if (usagePct >= 85) {
return {
tone: "warn",
shortKey: "context.healthNearLimitShort",
vars: { pct: usagePct },
};
}
if (readCount >= 8) {
return {
tone: "notice",
shortKey: "context.healthManyFilesShort",
vars: { count: readCount },
};
}
if (cachePct > 0 && cachePct < 50) {
return {
tone: "notice",
shortKey: "context.healthLowCacheShort",
vars: { pct: cachePct },
};
}
return {
tone: "good",
shortKey: "context.healthGoodShort",
vars: {},
};
export function contextWindowStatus(usagePct: number, compactPct: number): ContextWindowStatus {
if (usagePct >= 90) return { tone: "warn", key: "context.windowStatusNearLimit" };
if (compactPct > 0 && usagePct >= compactPct) return { tone: "warn", key: "context.windowStatusPastCompact" };
if (compactPct > 0 && usagePct >= Math.max(0, compactPct - 10)) return { tone: "notice", key: "context.windowStatusWatch" };
return { tone: "good", key: "context.windowStatusHealthy" };
}
const SOURCE_ORDER = ["executor", "planner", "subagent", "compaction", "classifier", "title"];
function sourceTone(source: string): string {
switch (source) {
case "executor": return "teal";
case "planner": return "blue";
case "subagent": return "amber";
case "compaction": return "slate";
case "classifier": return "violet";
case "title": return "rose";
default: return "default";
}
}
function sourceLabel(source: string, t: Translator): string {
switch (source) {
case "executor": return t("context.sourceExecutor");
@@ -205,11 +221,35 @@ function sourceCost(stats: UsageSourceStats): number {
return stats.sessionCost && stats.sessionCost > 0 ? stats.sessionCost : stats.sessionCostUsd ?? 0;
}
function sourceRows(info: ContextPanelInfo | null, sessionCurrency?: string): Array<{ source: string; label: string; cost: number; currency?: string; requests: number }> {
function sourceTokenTotal(row: Pick<ContextSourceRow, "promptTokens" | "completionTokens" | "totalTokens">): number {
return row.totalTokens > 0 ? row.totalTokens : row.promptTokens + row.completionTokens;
}
export interface ContextSourceRow {
source: string;
label: string;
promptTokens: number;
completionTokens: number;
cacheHitTokens: number;
cacheMissTokens: number;
totalTokens: number;
cost: number;
currency?: string;
requests: number;
}
export function contextSourceRows(info: ContextPanelInfo | null, sessionCurrency?: string): ContextSourceRow[] {
const entries = Object.entries(info?.sources ?? {});
if (entries.length === 0) return [];
return entries
.filter(([, stats]) => (stats.requestCount ?? 0) > 0 || sourceCost(stats) > 0)
.filter(([, stats]) =>
(stats.requestCount ?? 0) > 0 ||
(stats.promptTokens ?? 0) > 0 ||
(stats.completionTokens ?? 0) > 0 ||
(stats.cacheHitTokens ?? 0) > 0 ||
(stats.cacheMissTokens ?? 0) > 0 ||
sourceCost(stats) > 0
)
.sort(([a], [b]) => {
const ia = SOURCE_ORDER.indexOf(a);
const ib = SOURCE_ORDER.indexOf(b);
@@ -219,6 +259,11 @@ function sourceRows(info: ContextPanelInfo | null, sessionCurrency?: string): Ar
.map(([source, stats]) => ({
source,
label: source,
promptTokens: stats.promptTokens ?? 0,
completionTokens: stats.completionTokens ?? 0,
cacheHitTokens: stats.cacheHitTokens ?? 0,
cacheMissTokens: stats.cacheMissTokens ?? 0,
totalTokens: stats.totalTokens ?? 0,
cost: sourceCost(stats),
currency: stats.sessionCurrency || sessionCurrency || info?.sessionCurrency,
requests: stats.requestCount ?? 0,
@@ -232,7 +277,6 @@ export function ContextPanel({
sessionTokens,
sessionCost,
sessionCurrency,
sessionTurns,
turnTokens,
turnCost,
balance,
@@ -241,6 +285,7 @@ export function ContextPanel({
}: ContextPanelProps) {
const { locale, t } = useI18n();
const [info, setInfo] = useState<ContextPanelInfo | null>(null);
const [analysisView, setAnalysisView] = useState<UsageAnalysisView>("source");
const refreshSeq = useRef(0);
const refresh = useCallback(async () => {
@@ -248,17 +293,14 @@ export function ContextPanel({
const seq = ++refreshSeq.current;
try {
const next = await app.ContextPanel(tabId);
if (refreshSeq.current === seq) setInfo(next);
if (refreshSeq.current === seq) {
setInfo(next);
}
} catch {
/* bridge unavailable */
}
}, [tabId]);
useEffect(() => {
const id = window.setInterval(() => void refresh(), 2000);
return () => window.clearInterval(id);
}, [refresh]);
useEffect(() => {
refreshSeq.current += 1;
setInfo(null);
@@ -290,23 +332,25 @@ export function ContextPanel({
? usage.totalTokens
: promptTokens + completionTokens;
const reasoningTokens = hasPanelUsage ? info?.reasoningTokens ?? 0 : usage?.reasoningTokens ?? 0;
const cacheHitTokens = hasPanelUsage ? info?.cacheHitTokens ?? 0 : usage?.cacheHitTokens ?? 0;
const cacheMissTokens = hasPanelUsage ? info?.cacheMissTokens ?? 0 : usage?.cacheMissTokens ?? 0;
// Session-cumulative values for the top summary.
const sessionCacheHit = info?.sessionCacheHitTokens ?? usage?.sessionCacheHitTokens ?? context?.cacheHitTokens ?? 0;
const sessionCacheMiss = info?.sessionCacheMissTokens ?? usage?.sessionCacheMissTokens ?? context?.cacheMissTokens ?? 0;
const totalTokensMetric = formatMetricTokens(totalTokens, locale);
const cost = contextCostDisplay({ info, sessionCost, sessionCurrency, usage });
const costSources = sourceRows(info, sessionCurrency);
const showCostSources = costSources.some((row) => row.source !== "executor") || costSources.length > 1;
const sourceUsageRows = contextSourceRows(info, sessionCurrency);
const showSourceUsageRows = sourceUsageRows.length > 0;
const sourceTotalTokens = sourceUsageRows.reduce((sum, row) => sum + sourceTokenTotal(row), 0);
const visibleSourceRows = sourceUsageRows.slice(0, 3);
const hiddenSourceRows = sourceUsageRows.slice(3);
const readFiles = asArray(info?.readFiles);
const changedFiles = asArray(info?.changedFiles);
const usagePct = windowTokens > 0 ? Math.min(100, Math.round((usedTokens / windowTokens) * 100)) : 0;
const compactPct = context?.compactRatio ? Math.round(context.compactRatio * 100) : 0;
const cacheDenom = cacheHitTokens + cacheMissTokens;
const cachePct = cacheDenom > 0 ? (cacheHitTokens / cacheDenom) * 100 : 0;
const cachePctDisplay = formatCacheHitRate(cacheHitTokens, cacheMissTokens);
const compactRatio = context?.compactRatio && context.compactRatio > 0 ? context.compactRatio : 0.8;
const compactPct = Math.round(compactRatio * 100);
const compactTokens = windowTokens > 0 ? Math.round(windowTokens * compactRatio) : 0;
const tokensUntilCompact = compactTokens > usedTokens ? compactTokens - usedTokens : 0;
const breakdown = contextBreakdown(usedTokens, windowTokens, promptTokens, completionTokens, reasoningTokens);
const donutStyle = {
background: `conic-gradient(#13a7a5 0 ${breakdown.promptPct}%, #2f6df6 ${breakdown.promptPct}% ${breakdown.completionPct}%, #f97316 ${breakdown.completionPct}% ${breakdown.reasoningPct}%, var(--border) ${breakdown.reasoningPct}% ${breakdown.otherPct}%, var(--border-soft) ${breakdown.otherPct}% 100%)`,
};
const eventTimes = [
...readFiles.map((file) => file.time),
...changedFiles.map((file) => file.latestTime ?? 0),
@@ -315,93 +359,236 @@ export function ContextPanel({
const elapsed = info?.elapsedMs && info.elapsedMs > 0 ? info.elapsedMs : derivedElapsed;
const derivedRequestCount = Math.max(readFiles.length + changedFiles.length, 0);
const requestCount = info?.requestCount && info.requestCount > 0 ? info.requestCount : derivedRequestCount;
const health = contextHealth(usagePct, Math.round(cachePct), readFiles.length);
const windowStatus = contextWindowStatus(usagePct, compactPct);
const balanceLabel = balance?.available && balance.display ? balance.display : "-";
const turnCostLabel = formatMoneyLocalized(turnCost, sessionCurrency, { locale, empty: "dash" });
const sessionCostLabel = formatMoneyLocalized(sessionCost, sessionCurrency, { locale, empty: "dash" });
const sessionCostLabel = formatMoneyLocalized(cost.amount, cost.currency, { locale, empty: "dash" });
const totalTokensTitle = totalTokensMetric.exact === "-" ? "-" : t("context.tokensValue", { value: totalTokensMetric.exact });
const usedLabel = fmtFullTokens(usedTokens);
const windowLabel = fmtFullTokens(windowTokens);
const compactRemainingLabel = tokensUntilCompact > 0 ? fmtFullTokens(tokensUntilCompact) : "0";
const compactMarkerPct = Math.max(0, Math.min(100, compactPct));
const usageMarkerPct = Math.max(6, Math.min(94, usagePct));
const compactLabelPct = Math.max(6, Math.min(94, compactMarkerPct));
const usageSummary = t("context.windowUsageSummary", { used: usedLabel, window: windowLabel, pct: usagePct });
const compactSummary = t("context.windowCompactRemaining", { used: usedLabel, window: windowLabel, tokens: compactRemainingLabel, pct: compactPct });
const activeAnalysisView: UsageAnalysisView = showSourceUsageRows ? analysisView : "type";
const tokenTypeRows = [
{ key: "prompt", label: t("context.prompt"), value: breakdown.promptTokens },
{ key: "completion", label: t("context.completion"), value: breakdown.completionTokens },
{ key: "reasoning", label: t("context.reasoning"), value: breakdown.reasoningTokens },
{ key: "other", label: t("context.other"), value: breakdown.otherTokens },
];
const tokenCompositionTotal = tokenTypeRows.reduce((sum, row) => sum + row.value, 0);
const renderSourceRow = (row: ContextSourceRow) => {
const inputMetric = formatMetricTokens(row.promptTokens, locale);
const outputMetric = formatMetricTokens(row.completionTokens, locale);
const hitMetric = formatMetricTokens(row.cacheHitTokens, locale);
const missMetric = formatMetricTokens(row.cacheMissTokens, locale);
const totalMetric = formatMetricTokens(sourceTokenTotal(row), locale);
const cacheReported = row.cacheHitTokens + row.cacheMissTokens > 0;
const cacheRate = cacheReported ? formatCacheHitRate(row.cacheHitTokens, row.cacheMissTokens) : t("context.cacheNotReported");
const costLabel = formatMoneyLocalized(row.cost, row.currency, { locale, empty: "dash" });
return (
<div className="context-panel__source-row" key={row.source}>
<div className="context-panel__source-head">
<span>
<i className={`context-panel__source-dot context-panel__source-tone--${sourceTone(row.source)}`} aria-hidden="true" />
{sourceLabel(row.label, t)}
</span>
<em>{t("context.sourceRequests", { count: row.requests })}</em>
</div>
<div className="context-panel__source-summary">
<SourceMetric label={t("context.total")} value={totalMetric.display} title={totalMetric.exact} />
<SourceMetric label={t("context.sourceCacheRate")} value={cacheRate} />
<SourceMetric label={t("context.sourceCost")} value={costLabel} />
</div>
<details className="context-panel__source-details">
<summary>{t("context.sourceDetails")}</summary>
<div className="context-panel__source-details-body">
<SourceSplitBar
label={`${t("context.sourceInput")}/${t("context.sourceOutput")}`}
segments={[
{ label: t("context.sourceInput"), value: row.promptTokens, tone: "input" },
{ label: t("context.sourceOutput"), value: row.completionTokens, tone: "output" },
]}
/>
{cacheReported ? (
<SourceSplitBar
label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`}
segments={[
{ label: t("context.sourceCacheHit"), value: row.cacheHitTokens, tone: "hit" },
{ label: t("context.sourceCacheMiss"), value: row.cacheMissTokens, tone: "miss" },
]}
compact
/>
) : (
<SourceSplitBar label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`} segments={[]} compact />
)}
<div className="context-panel__source-metrics">
<SourceMetric label={t("context.sourceInput")} value={inputMetric.display} title={inputMetric.exact} />
<SourceMetric label={t("context.sourceOutput")} value={outputMetric.display} title={outputMetric.exact} />
<SourceMetric label={t("context.sourceCacheHit")} value={hitMetric.display} title={hitMetric.exact} />
<SourceMetric label={t("context.sourceCacheMiss")} value={missMetric.display} title={missMetric.exact} />
</div>
</div>
</details>
</div>
);
};
return (
<div className="context-panel">
<div className="context-panel__body">
<section className="context-panel__overview">
<section className="context-panel__usage">
<SectionHeading title={t("context.windowTitle")} meta={t("context.windowSubtitle")} />
<div className="context-panel__usage-visual">
<div className="context-panel__donut" style={donutStyle}>
<div className="context-panel__donut-core">
<strong>{fmtTokens(usedTokens)}</strong>
<span>/ {fmtTokens(windowTokens)} tokens</span>
<SectionHeading title={t("context.windowTitle")} />
<div className={`context-panel__capacity-card context-panel__capacity-card--${windowStatus.tone}`}>
<div className="context-panel__capacity-top">
<span className="context-panel__capacity-status">{t(windowStatus.key)}</span>
<strong>{usedLabel}/{windowLabel}</strong>
</div>
<div className="context-panel__usage-progress context-panel__capacity-meter" aria-label={`${t(windowStatus.key)}. ${usageSummary}. ${compactSummary}`}>
<div className="context-panel__capacity-scale" aria-hidden="true">
<span className="context-panel__capacity-pin context-panel__capacity-pin--used" style={{ left: `${usageMarkerPct}%` }}>{usagePct}%</span>
<span className="context-panel__capacity-pin context-panel__capacity-pin--compact" style={{ left: `${compactLabelPct}%` }}>{compactPct}%</span>
</div>
<div className="context-panel__progress-track" aria-hidden="true">
<span className="context-panel__progress-segment context-panel__progress-segment--prompt" style={{ width: `${breakdown.promptPct}%` }} />
<span className="context-panel__progress-segment context-panel__progress-segment--completion" style={{ width: `${Math.max(0, breakdown.completionPct - breakdown.promptPct)}%` }} />
<span className="context-panel__progress-segment context-panel__progress-segment--reasoning" style={{ width: `${Math.max(0, breakdown.reasoningPct - breakdown.completionPct)}%` }} />
<span className="context-panel__progress-segment context-panel__progress-segment--other" style={{ width: `${Math.max(0, breakdown.otherPct - breakdown.reasoningPct)}%` }} />
<span className="context-panel__compact-marker" style={{ left: `${compactMarkerPct}%` }} />
</div>
</div>
<div className="context-panel__percent">{usagePct}%</div>
</div>
<div className="context-panel__usage-progress" aria-label={t("context.windowSubtitle")}>
<div className="context-panel__progress-head">
<strong>{fmtTokens(usedTokens)} / {fmtTokens(windowTokens)}</strong>
<span>{usagePct}%</span>
</div>
<div className="context-panel__progress-track" aria-hidden="true">
<span className="context-panel__progress-fill" style={{ width: `${usagePct}%` }} />
<div className="context-panel__capacity-foot">
<span>{t("context.windowUsedLabel")}</span>
<span className="context-panel__capacity-remaining">
<span>{t("context.windowCompactDistance")}</span>
<strong>{compactRemainingLabel}</strong>
</span>
</div>
</div>
<div className="context-panel__summary-rows">
<MiniStat label={t("status.compactLabel")} value={compactPct > 0 ? `${compactPct}%` : "-"} />
<MiniStat label={t("status.cacheAvgLabel")} value={fmtSessionCacheRate(usage)} />
<MiniStat label={t("context.sessionCost")} value={sessionCostLabel} />
<MiniStat label={t("status.sessionTurnsLabel")} value={fmtTurns(sessionTurns, t)} />
</div>
<div className="context-panel__breakdown">
<TokenLegend label={t("context.prompt")} value={breakdown.promptTokens} color="prompt" />
<TokenLegend label={t("context.completion")} value={breakdown.completionTokens} color="completion" />
<TokenLegend label={t("context.reasoning")} value={breakdown.reasoningTokens} color="reasoning" />
<TokenLegend label={t("context.other")} value={breakdown.otherTokens} color="other" />
<div className="context-panel__total">
<span>{t("context.total")}</span>
<strong>{usedTokens.toLocaleString()} / {windowTokens.toLocaleString()}</strong>
</section>
<section className="context-panel__section context-panel__session-section">
<SectionHeading title={t("context.sessionMetrics")} />
<div className="context-panel__session-metrics">
<div className="context-panel__summary-rows">
<MiniStat label={t("status.cacheAvgLabel")} value={formatCacheHitRate(sessionCacheHit, sessionCacheMiss)} tone={cacheHitTone(sessionCacheHit, sessionCacheMiss)} />
<MiniStat label={t("context.sessionCost")} value={sessionCostLabel} />
<MiniStat label={t("context.time")} value={fmtDuration(elapsed, t)} />
<MiniStat label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
<MiniStat label={t("context.sessionTokensShort")} value={totalTokensMetric.display} title={totalTokensTitle} wide />
</div>
</div>
</section>
<section className="context-panel__creation-grid" aria-label={t("context.overview")}>
<MetricCard label={t("context.time")} value={fmtDuration(elapsed, t)} />
<MetricCard label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
<MetricCard label={t("status.cacheLabel")} value={fmtUsageCacheRate(usage)} tone="accent" />
<MetricCard label={t("status.turnTokensLabel")} value={fmtOptionalTokens(turnTokens)} />
<MetricCard label={t("status.turnCostLabel")} value={turnCostLabel} />
<MetricCard label={t("status.balanceLabel")} value={balanceLabel} tone="accent" />
</section>
<section className="context-panel__section">
<SectionHeading title={t("context.runtimeMetrics")} />
<div className="context-panel__stats">
<MetricCard label={t("context.time")} value={fmtDuration(elapsed, t)} />
<MetricCard label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
<MetricCard label={t("context.sessionTokens")} value={totalTokens > 0 ? totalTokens.toLocaleString() : "-"} wide />
</div>
</section>
<section className="context-panel__section">
<SectionHeading title={t("context.costMetrics")} />
<div className="context-panel__stats">
<MetricCard label={t("context.cacheHit")} value={cachePctDisplay} tone="accent" />
<MetricCard label={t("context.sessionCost")} value={formatMoneyLocalized(cost.amount, cost.currency, { locale, empty: "dash" })} />
</div>
{showCostSources && (
<div className="context-panel__source-list" aria-label={t("context.costBreakdown")}>
{costSources.map((row) => (
<div className="context-panel__source-row" key={row.source}>
<span>{sourceLabel(row.label, t)}</span>
<strong>{formatMoneyLocalized(row.cost, row.currency, { locale, empty: "dash" })}</strong>
<em>{t("context.sourceRequests", { count: row.requests })}</em>
<section className="context-panel__section context-panel__analysis">
<SectionHeading title={t("context.usageAnalysis")}>
{showSourceUsageRows && (
<div className="context-panel__view-switch" role="tablist" aria-label={t("context.usageAnalysisView")}>
<button
type="button"
className={`context-panel__view-tab${activeAnalysisView === "source" ? " context-panel__view-tab--active" : ""}`}
role="tab"
aria-selected={activeAnalysisView === "source"}
onClick={() => setAnalysisView("source")}
>
{t("context.usageAnalysisSource")}
</button>
<button
type="button"
className={`context-panel__view-tab${activeAnalysisView === "type" ? " context-panel__view-tab--active" : ""}`}
role="tab"
aria-selected={activeAnalysisView === "type"}
onClick={() => setAnalysisView("type")}
>
{t("context.usageAnalysisType")}
</button>
</div>
)}
</SectionHeading>
{activeAnalysisView === "source" ? (
<div className="context-panel__source-list" aria-label={t("context.sourceBreakdown")} role="tabpanel">
<div className="context-panel__source-overview">
<div className="context-panel__source-overview-head">
<strong>{t("context.sourceShareTitle")}</strong>
</div>
))}
<div className="context-panel__source-sharebar" aria-hidden="true">
{sourceUsageRows.map((row) => {
const sharePct = sourceTotalTokens > 0 ? (sourceTokenTotal(row) / sourceTotalTokens) * 100 : 0;
if (sharePct <= 0) return null;
return (
<span
className={`context-panel__source-share context-panel__source-tone--${sourceTone(row.source)}`}
key={row.source}
style={{ width: `${sharePct}%` }}
/>
);
})}
</div>
<div className="context-panel__source-legend">
{sourceUsageRows.map((row) => {
const sharePct = sourceTotalTokens > 0 ? (sourceTokenTotal(row) / sourceTotalTokens) * 100 : 0;
return (
<span key={row.source}>
<i className={`context-panel__source-dot context-panel__source-tone--${sourceTone(row.source)}`} aria-hidden="true" />
{sourceLabel(row.label, t)} {sharePct > 0 ? `${sharePct.toFixed(0)}%` : "-"}
</span>
);
})}
</div>
</div>
{visibleSourceRows.map(renderSourceRow)}
{hiddenSourceRows.length > 0 && (
<details className="context-panel__source-more">
<summary>{t("context.moreSources", { count: hiddenSourceRows.length })}</summary>
<div className="context-panel__source-more-list">
{hiddenSourceRows.map(renderSourceRow)}
</div>
</details>
)}
</div>
) : (
<div className="context-panel__type-panel" aria-label={t("context.tokenBreakdown")} role="tabpanel">
<div className="context-panel__type-overview">
<div className="context-panel__type-overview-head">
<strong>{t("context.tokenBreakdown")}</strong>
</div>
<div className="context-panel__type-sharebar" aria-hidden="true">
{tokenTypeRows.map((row) => row.value > 0 ? (
<span
className={`context-panel__type-share context-panel__type-share--${row.key}`}
key={row.key}
style={{ width: `${(row.value / Math.max(1, tokenCompositionTotal)) * 100}%` }}
/>
) : null)}
</div>
<div className="context-panel__type-legend">
{tokenTypeRows.map((row) => (
<span key={row.key}>
<i className={`context-panel__type-dot context-panel__type-dot--${row.key}`} aria-hidden="true" />
{row.label} {formatSharePercent(row.value, tokenCompositionTotal)}
</span>
))}
</div>
</div>
<details className="context-panel__breakdown-details">
<summary>{t("context.sourceDetails")}</summary>
<div className="context-panel__breakdown">
{tokenTypeRows.map((row) => (
<TokenLegend key={row.key} label={row.label} value={row.value} color={row.key} />
))}
</div>
</details>
</div>
)}
</section>
<section className="context-panel__section context-panel__section--status">
<SectionHeading title={t("context.sessionStatus")} />
<div className="context-panel__stats">
<MetricCard label={t("context.health")} value={t(health.shortKey, health.vars)} tone={health.tone} />
<MetricCard label={t("context.compaction")} value={compactPct > 0 ? `${compactPct}%` : "-"} />
</div>
</section>
</section>
</div>
@@ -409,11 +596,12 @@ export function ContextPanel({
);
}
function SectionHeading({ title, meta }: { title: string; meta?: string }) {
function SectionHeading({ title, meta, children }: { title: string; meta?: string; children?: ReactNode }) {
return (
<header className="context-panel__section-head">
<h3>{title}</h3>
{meta && <span>{meta}</span>}
{children}
</header>
);
}
@@ -428,22 +616,68 @@ function TokenLegend({ label, value, color }: { label: string; value: number; co
);
}
function MiniStat({ label, value }: { label: string; value: string }) {
function MiniStat({ label, value, title, tone, wide }: { label: string; value: string; title?: string; tone?: MetricTone; wide?: boolean }) {
const toneClass = tone ? ` context-panel__mini-stat--${tone}` : "";
const wideClass = wide ? " context-panel__mini-stat--wide" : "";
const exactTitle = title && title !== value ? title : undefined;
return (
<div className="context-panel__mini-stat">
<div className={`context-panel__mini-stat${toneClass}${wideClass}`} aria-label={exactTitle ? `${label}: ${exactTitle}` : undefined}>
<span>{label}</span>
<strong>{value}</strong>
<strong title={exactTitle}>{value}</strong>
</div>
);
}
function MetricCard({ label, value, tone, wide }: { label: string; value: string; tone?: "accent" | "good" | "notice" | "warn"; wide?: boolean }) {
function MetricCard({ label, value, valueTitle, tone, wide }: { label: string; value: string; valueTitle?: string; tone?: "accent" | "good" | "notice" | "warn"; wide?: boolean }) {
const toneClass = tone ? ` context-panel__metric--${tone}` : "";
const wideClass = wide ? " context-panel__metric--wide" : "";
const exactTitle = valueTitle && valueTitle !== value ? valueTitle : undefined;
return (
<div className={`context-panel__metric${toneClass}${wideClass}`}>
<div className={`context-panel__metric${toneClass}${wideClass}`} aria-label={exactTitle ? `${label}: ${exactTitle}` : undefined}>
<span>{label}</span>
<strong>{value}</strong>
<strong title={exactTitle}>{value}</strong>
</div>
);
}
function SourceMetric({ label, value, title }: { label: string; value: string; title?: string }) {
const exactTitle = title && title !== value ? title : undefined;
return (
<div className="context-panel__source-metric" aria-label={exactTitle ? `${label}: ${exactTitle}` : undefined}>
<span>{label}</span>
<strong title={exactTitle}>{value}</strong>
</div>
);
}
function SourceSplitBar({ label, segments, compact }: { label: string; segments: Array<{ label: string; value: number; tone: string }>; compact?: boolean }) {
const total = segments.reduce((sum, segment) => sum + Math.max(0, segment.value), 0);
const visible = segments.filter((segment) => segment.value > 0);
const compactClass = compact ? " context-panel__source-bar--compact" : "";
if (total <= 0 || visible.length === 0) {
return (
<div className="context-panel__source-bar-row">
<span>{label}</span>
<div className={`context-panel__source-bar context-panel__source-bar--empty${compactClass}`} aria-hidden="true" />
</div>
);
}
return (
<div className="context-panel__source-bar-row">
<span>{label}</span>
<div className={`context-panel__source-bar${compactClass}`}>
{visible.map((segment) => {
const width = (segment.value / total) * 100;
return (
<span
className={`context-panel__source-bar-segment context-panel__source-bar-segment--${segment.tone}`}
key={segment.tone}
style={{ width: `${width}%` }}
title={`${segment.label}: ${segment.value.toLocaleString()}`}
/>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,202 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { FileText, Folder } from "lucide-react";
import { asArray } from "../lib/array";
import { filterAtMatches } from "../lib/atMatches";
import { app } from "../lib/bridge";
import type { DirEntry } from "../lib/types";
import { VirtualMenu } from "./VirtualMenu";
export function dirEntrySubmitPath(entry: DirEntry, atDir: string): string {
return entry.path || atDir + entry.name;
}
export function dirEntryMenuLabel(entry: DirEntry): string {
return entry.displayName || entry.name;
}
export function activeFileReferenceToken(text: string): { raw: string; dir: string; frag: string } | null {
const match = /(?:^|\s)@([^\s]*)$/.exec(text);
if (!match) return null;
const raw = match[1];
const slash = raw.lastIndexOf("/");
return {
raw,
dir: slash >= 0 ? raw.slice(0, slash + 1) : "",
frag: (slash >= 0 ? raw.slice(slash + 1) : raw).toLowerCase(),
};
}
export function pickInlineFileReference(text: string, atRaw: string | null, atDir: string, entry: DirEntry): string {
const atPos = text.length - (atRaw?.length ?? 0) - 1;
const prefix = text.slice(0, Math.max(0, atPos));
const refPath = dirEntrySubmitPath(entry, atDir);
return prefix + "@" + refPath + (entry.isDir ? "/" : " ");
}
export function insertTextAtSelection(
value: string,
insert: string,
selectionStart = value.length,
selectionEnd = selectionStart,
): { value: string; caret: number } {
const before = value.slice(0, selectionStart);
const after = value.slice(selectionEnd);
const needsLeadingSpace = before.length > 0 && !/\s$/.test(before) && !/^\s/.test(insert);
const needsTrailingSpace = after.length > 0 && !/\s$/.test(insert) && !/^\s/.test(after);
const text = `${needsLeadingSpace ? " " : ""}${insert}${needsTrailingSpace ? " " : ""}`;
const next = before + text + after;
return { value: next, caret: before.length + text.length };
}
export function useFileReferenceMenu(text: string, cwd?: string) {
const token = useMemo(() => activeFileReferenceToken(text), [text]);
const atRaw = token?.raw ?? null;
const atDir = token?.dir ?? "";
const atFrag = token?.frag ?? "";
const [entries, setEntries] = useState<DirEntry[]>([]);
const [searchEntries, setSearchEntries] = useState<DirEntry[]>([]);
const [active, setActive] = useState(0);
const [dismissed, setDismissed] = useState(false);
const dirCache = useRef<Record<string, DirEntry[]>>({});
const searchCache = useRef<Record<string, DirEntry[]>>({});
const prevCwdRef = useRef(cwd);
useEffect(() => {
if (prevCwdRef.current === cwd) return;
prevCwdRef.current = cwd;
dirCache.current = {};
searchCache.current = {};
setEntries([]);
setSearchEntries([]);
setActive(0);
setDismissed(false);
}, [cwd]);
useEffect(() => {
setActive(0);
setDismissed(false);
}, [atRaw]);
useEffect(() => {
if (atRaw === null) return;
const cached = dirCache.current[atDir];
if (cached) {
setEntries(cached);
return;
}
let live = true;
app
.ListDir(atDir)
.then((next) => {
const list = asArray(next);
dirCache.current[atDir] = list;
if (live) setEntries(list);
})
.catch(() => {});
return () => {
live = false;
};
}, [atRaw === null, atDir, cwd]);
useEffect(() => {
if (atRaw === null || atDir !== "" || atFrag === "") {
setSearchEntries([]);
return;
}
const cached = searchCache.current[atFrag];
if (cached) {
setSearchEntries(cached);
return;
}
setSearchEntries([]);
let live = true;
app
.SearchFileRefs(atFrag)
.then((next) => {
const list = next ?? [];
searchCache.current[atFrag] = list;
if (live) setSearchEntries(list);
})
.catch(() => {});
return () => {
live = false;
};
}, [atRaw === null, atDir, atFrag, cwd]);
const items = useMemo(() => {
if (atRaw === null) return [];
return filterAtMatches(entries, searchEntries, atFrag);
}, [atRaw, atFrag, entries, searchEntries]);
useEffect(() => {
const maxIdx = Math.max(0, items.length - 1);
setActive((prev) => (prev > maxIdx ? 0 : prev));
}, [items.length]);
return {
atRaw,
atDir,
items,
active,
setActive,
count: atRaw !== null && !dismissed ? items.length : 0,
open: atRaw !== null && !dismissed,
dismiss: () => setDismissed(true),
};
}
export function FileReferenceMenu({
items,
activeIndex,
onPick,
onHover,
}: {
items: DirEntry[];
activeIndex: number;
onPick: (entry: DirEntry) => void;
onHover: (index: number) => void;
}) {
const renderEntry = (entry: DirEntry, index: number) => (
<button
role="option"
aria-selected={index === activeIndex}
className={`slashmenu__item ${index === activeIndex ? "slashmenu__item--active" : ""}`}
onMouseDown={(event) => {
event.preventDefault();
onPick(entry);
}}
onMouseMove={() => onHover(index)}
>
{entry.isDir ? (
<Folder size={13} className="filemenu__icon filemenu__icon--dir" />
) : (
<FileText size={13} className="filemenu__icon" />
)}
<span className="slashmenu__name slashmenu__name--file">
{dirEntryMenuLabel(entry)}
{entry.isDir ? "/" : ""}
</span>
</button>
);
if (typeof ResizeObserver === "undefined") {
return (
<div className="slashmenu" role="listbox">
{items.map((entry, index) => (
<div key={(entry.isDir ? "d:" : "f:") + (entry.path || entry.name)}>
{renderEntry(entry, index)}
</div>
))}
</div>
);
}
return (
<VirtualMenu
items={items}
activeIndex={activeIndex}
itemKey={(entry) => (entry.isDir ? "d:" : "f:") + (entry.path || entry.name)}
renderItem={renderEntry}
/>
);
}
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent } from "react";
import { Archive, Pencil, Search, Trash2, RotateCcw } from "lucide-react";
import { t, useT } from "../lib/i18n";
import { sessionActivityTime } from "../lib/session";
import { historySessionDisplayTitle, sessionActivityTime } from "../lib/session";
import type { HistoryMessage, SessionMeta } from "../lib/types";
import { historyMessagesToItems, type Item } from "../lib/useController";
import { Transcript } from "./Transcript";
@@ -83,7 +83,7 @@ export function HistoryPanel({
setEditing(null);
setPreview({
path: s.path,
title: sessionDisplayTitle(s, tr("history.emptySession")),
title: historySessionDisplayTitle(s, tr("history.emptySession")),
meta: sessionMetaLine(s, tr, isTrash),
messages: [],
loading: true,
@@ -464,7 +464,7 @@ export function HistoryPanel({
if (!isTrash && !running) onResume(s);
}}
>
<div className="hist-item__preview">{sessionDisplayTitle(s, tr("history.emptySession"))}</div>
<div className="hist-item__preview">{historySessionDisplayTitle(s, tr("history.emptySession"))}</div>
<div className="hist-item__meta">
{!isTrash && isChannelSession(s) && <span className="hist-item__badge hist-item__badge--open">{tr("history.channel")}</span>}
{!isTrash && s.current && <span className="hist-item__badge hist-item__badge--current">{tr("history.current")}</span>}
@@ -607,7 +607,6 @@ function sessionLocation(s: SessionMeta, tr: ReturnType<typeof useT>): string {
if (isChannelSession(s)) {
return [s.channelLabel || s.channel || tr("history.channel"), s.remoteId].filter(Boolean).join(" · ");
}
if (s.topicTitle) return s.topicTitle;
if (s.workspaceRoot) {
const parts = s.workspaceRoot.split(/[\\/]/).filter(Boolean);
return parts[parts.length - 1] || s.workspaceRoot;
@@ -615,10 +614,6 @@ function sessionLocation(s: SessionMeta, tr: ReturnType<typeof useT>): string {
return sessionScope(s) === "project" ? tr("history.filterProject") : tr("history.filterGlobal");
}
function sessionDisplayTitle(s: SessionMeta, fallback: string): string {
return s.title || s.preview || fallback;
}
function sessionMetaLine(s: SessionMeta, tr: ReturnType<typeof useT>, isTrash = false): string {
const time = timeLabel(isTrash ? s.deletedAt || sessionActivityTime(s) : sessionActivityTime(s));
const suffix = isTrash && s.deletedAt ? ` · ${tr("history.deleted")}` : "";
+3 -1
View File
@@ -4,12 +4,14 @@ const MarkdownRenderer = lazy(() => import("./MarkdownRenderer"));
export const Markdown = memo(function Markdown({
text,
plainStatusBlocks = false,
}: {
text: string;
plainStatusBlocks?: boolean;
}) {
return (
<Suspense fallback={<div className="md">{text}</div>}>
<MarkdownRenderer text={text} />
<MarkdownRenderer text={text} plainStatusBlocks={plainStatusBlocks} />
</Suspense>
);
});
@@ -1,4 +1,4 @@
import { memo, useMemo, useRef } from "react";
import { lazy, memo, Suspense, useMemo, useRef } from "react";
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
@@ -9,6 +9,8 @@ import { CodeViewer } from "./CodeViewer";
import { normalizeMath } from "./mathNormalize";
import { openExternal } from "../lib/bridge";
const MermaidDiagram = lazy(() => import("./MermaidDiagram"));
// Markdown rendering via react-markdown + remark-gfm (tables, task lists,
// strike, autolinks) and remark-math + rehype-katex for $/$$ KaTeX math.
// Fenced code blocks go through CodeViewer for syntax highlighting; inline
@@ -19,44 +21,125 @@ import { openExternal } from "../lib/bridge";
// pairs through a classifier to avoid false positives on $5, $PATH, etc.,
// and runs KaTeX-specific normalisations (text-mode escapes, |→\vert).
const components: Components = {
pre: ({ children }) => <>{children}</>,
code: ({ className, children }) => {
const text = String(children ?? "");
const match = /language-([\w-]+)/.exec(className ?? "");
const isBlock = match !== null || text.includes("\n");
if (isBlock) {
return <CodeViewer value={text.replace(/\n$/, "")} language={match?.[1]} maxHeight={360} />;
const STATUS_MARKER_RE = /(?:✅|☑|☒|✔️?|✓|\[[xX ]\])/;
const STATUS_MARKER_GLOBAL_RE = /(?:✅|☑|☒|✔️?|✓|\[[xX ]\])/g;
const BULLET_RE = /^[-*•]\s+\S/;
const DIVIDER_RE = /^[\s\-_=─━—]+$/;
function splitStatusLine(line: string): string[] {
const parts = (line.match(STATUS_MARKER_GLOBAL_RE) ?? []).length > 1
? line.split(/(?=(?:✅|☑|☒|✔️?|✓|\[[xX ]\]))/)
: [line];
return parts
.map((part) => part.replace(/^(?:✅|☑|☒|✔️?|✓|\[[xX ]\]|[-*•])\s*/i, "").trim())
.filter(Boolean)
.map((part) => part.replace(/\s{2,}/g, " · "));
}
function looksLikeDiagram(text: string): boolean {
return /[←→↔]|<{1,2}-{2,}|-{2,}>{1,2}|[-_=─━]{6,}/.test(text);
}
function splitPlainBlock(text: string): { preText: string; statusItems: string[] } {
const items: string[] = [];
const preLines: string[] = [];
const lines = text.split(/\r?\n/);
const bulletLines = lines.filter((line) => BULLET_RE.test(line.trim())).length;
const collectBulletLines = bulletLines >= 2 && !looksLikeDiagram(text);
for (const rawLine of lines) {
const line = rawLine.trim();
const marked = STATUS_MARKER_RE.test(line) || (collectBulletLines && BULLET_RE.test(line));
if (marked) {
items.push(...splitStatusLine(line));
} else if (DIVIDER_RE.test(line) && items.length > 0 && !looksLikeDiagram(text)) {
continue;
} else {
preLines.push(rawLine);
}
return <code className="md-code">{children}</code>;
},
a: ({ href, children }) => (
<a
href={href}
onClick={(e) => {
e.preventDefault();
if (href) openExternal(href);
}}
onAuxClick={(e) => {
e.preventDefault();
if (href) openExternal(href);
}}
onMouseDown={(e) => {
if (e.button === 1) e.preventDefault();
}}
>
{children}
</a>
),
};
}
while (preLines.length > 0 && preLines[0].trim() === "") preLines.shift();
while (preLines.length > 0 && preLines[preLines.length - 1].trim() === "") preLines.pop();
return { preText: preLines.join("\n"), statusItems: items };
}
function PlainMarkdownBlock({ text }: { text: string }) {
const { preText, statusItems } = splitPlainBlock(text);
const asList = statusItems.length >= 2;
return (
<div className={`md-plain-block${asList ? " md-plain-block--split" : " md-plain-block--pre"}`}>
<CodeViewer value={text} maxHeight={360} />
{asList && preText && (
<div className="md-plain-block__diagram">
<CodeViewer value={preText} maxHeight={360} />
</div>
)}
{asList && (
<div className="md-status-list">
{statusItems.map((item, index) => (
<div className="md-status-list__item" key={`${index}-${item}`}>
<span className="md-status-list__dot" aria-hidden="true" />
<span className="md-status-list__text">{item}</span>
</div>
))}
</div>
)}
</div>
);
}
function createComponents(plainStatusBlocks: boolean): Components {
return {
pre: ({ children }) => <>{children}</>,
code: ({ className, children }) => {
const text = String(children ?? "");
const match = /language-([\w-]+)/.exec(className ?? "");
const lang = match?.[1];
const isBlock = match !== null || text.includes("\n");
if (isBlock) {
const value = text.replace(/\n$/, "");
if (lang === "mermaid") {
return (
<Suspense fallback={<CodeViewer value={value} language="mermaid" maxHeight={360} />}>
<MermaidDiagram definition={value} />
</Suspense>
);
}
if (!match && plainStatusBlocks) return <PlainMarkdownBlock text={text.replace(/\n$/, "")} />;
return <CodeViewer value={value} language={lang} maxHeight={360} />;
}
return <code className="md-code">{children}</code>;
},
a: ({ href, children }) => (
<a
href={href}
onClick={(e) => {
e.preventDefault();
if (href) openExternal(href);
}}
onAuxClick={(e) => {
e.preventDefault();
if (href) openExternal(href);
}}
onMouseDown={(e) => {
if (e.button === 1) e.preventDefault();
}}
>
{children}
</a>
),
};
}
const MarkdownRenderer = memo(function MarkdownRenderer({
text,
plainStatusBlocks = false,
}: {
text: string;
plainStatusBlocks?: boolean;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const mathContent = useMemo(() => normalizeMath(text), [text]);
const components = useMemo(() => createComponents(plainStatusBlocks), [plainStatusBlocks]);
return (
<div className="md" ref={containerRef}>
<ReactMarkdown
@@ -0,0 +1,657 @@
import { memo, type RefObject, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { AlertCircle, Code2, Maximize2, Minimize2, Play, RotateCcw, ZoomIn, ZoomOut } from "lucide-react";
import { CopyButton } from "./CopyButton";
import { openExternal } from "../lib/bridge";
interface MermaidDiagramProps {
definition: string;
}
type DiagramState =
| { status: "loading" }
| { status: "rendered"; svg: string }
| { status: "error"; message: string };
type DiagramTab = "preview" | "code";
type MermaidThemeName = "dark" | "light";
type MermaidModule = typeof import("mermaid");
type MermaidApi = MermaidModule["default"];
type MermaidRenderAdapter = (
svgId: string,
definition: string,
theme: MermaidThemeName,
signal: AbortSignal,
) => Promise<string>;
type PanZoomInstance = {
destroy(): void;
resize(): unknown;
fit(): unknown;
center(): unknown;
zoomIn(): unknown;
zoomOut(): unknown;
reset(): unknown;
};
type PanZoomFactory = (svg: SVGSVGElement, options?: Record<string, unknown>) => PanZoomInstance;
const MAX_TEXT_SIZE = 100000;
const MIN_ZOOM = 0.3;
const MAX_ZOOM = 8;
const SAFE_LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
const XLINK_NS = "http://www.w3.org/1999/xlink";
let mermaidApi: MermaidApi | null = null;
let initPromise: Promise<MermaidApi> | null = null;
let renderQueue: Promise<void> = Promise.resolve();
let panZoomFactory: PanZoomFactory | null = null;
let panZoomPromise: Promise<PanZoomFactory | null> | null = null;
let renderAdapterForTest: MermaidRenderAdapter | null = null;
let panZoomFactoryForTest: PanZoomFactory | null | undefined;
export function __setMermaidRenderAdapterForTest(adapter: MermaidRenderAdapter | null): void {
renderAdapterForTest = adapter;
renderQueue = Promise.resolve();
}
export function __setMermaidPanZoomFactoryForTest(factory: PanZoomFactory | null | undefined): void {
panZoomFactoryForTest = factory;
}
function mermaidThemeVariables(theme: MermaidThemeName): Record<string, string | number | boolean> {
if (theme === "dark") {
return {
darkMode: true,
background: "#111319",
fontSize: "13px",
fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
primaryColor: "#1f2937",
primaryTextColor: "#f4f5f7",
primaryBorderColor: "#374151",
mainBkg: "#1f2937",
nodeBkg: "#1f2937",
nodeBorder: "#374151",
nodeTextColor: "#f4f5f7",
stateBkg: "#1f2937",
stateBorder: "#374151",
stateLabelColor: "#f4f5f7",
labelColor: "#d1d5db",
lineColor: "#6b7280",
textColor: "#d1d5db",
defaultLinkColor: "#6b7280",
edgeLabelBackground: "#111319",
clusterBkg: "#0f172a",
clusterBorder: "#374151",
actorBkg: "#1f2937",
actorBorder: "#374151",
actorTextColor: "#f4f5f7",
actorLineColor: "#4b5563",
signalColor: "#d1d5db",
signalTextColor: "#f4f5f7",
labelBoxBkgColor: "#1f2937",
labelBoxBorderColor: "#374151",
labelTextColor: "#f4f5f7",
loopTextColor: "#f4f5f7",
noteBkgColor: "#0f172a",
noteBorderColor: "#374151",
noteTextColor: "#e5e7eb",
classText: "#f4f5f7",
classBorder: "#374151",
classBkg: "#1f2937",
secondaryColor: "#111319",
tertiaryColor: "#1f2937",
};
}
return {
darkMode: false,
background: "#ffffff",
fontSize: "13px",
fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
primaryColor: "#f8fafc",
primaryTextColor: "#0f172a",
primaryBorderColor: "#cbd5e1",
mainBkg: "#f8fafc",
nodeBkg: "#f8fafc",
nodeBorder: "#cbd5e1",
nodeTextColor: "#0f172a",
stateBkg: "#f8fafc",
stateBorder: "#cbd5e1",
stateLabelColor: "#0f172a",
labelColor: "#475569",
lineColor: "#94a3b8",
textColor: "#475569",
defaultLinkColor: "#94a3b8",
edgeLabelBackground: "#ffffff",
clusterBkg: "#f1f5f9",
clusterBorder: "#e2e8f0",
actorBkg: "#f8fafc",
actorBorder: "#cbd5e1",
actorTextColor: "#0f172a",
actorLineColor: "#cbd5e1",
signalColor: "#475569",
signalTextColor: "#0f172a",
labelBoxBkgColor: "#f8fafc",
labelBoxBorderColor: "#cbd5e1",
labelTextColor: "#0f172a",
loopTextColor: "#0f172a",
noteBkgColor: "#f1f5f9",
noteBorderColor: "#e2e8f0",
noteTextColor: "#334155",
classText: "#0f172a",
classBorder: "#cbd5e1",
classBkg: "#f8fafc",
secondaryColor: "#f1f5f9",
tertiaryColor: "#f8fafc",
};
}
function mermaidConfigForTheme(theme: MermaidThemeName) {
return {
startOnLoad: false,
theme: "base" as const,
securityLevel: "antiscript" as const,
maxTextSize: MAX_TEXT_SIZE,
fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
flowchart: { htmlLabels: false },
themeVariables: mermaidThemeVariables(theme),
};
}
async function ensureMermaid(theme: MermaidThemeName): Promise<MermaidApi> {
if (mermaidApi) {
mermaidApi.initialize(mermaidConfigForTheme(theme));
return mermaidApi;
}
if (!initPromise) {
initPromise = import("mermaid").then((mod) => {
mermaidApi = mod.default;
mermaidApi.initialize(mermaidConfigForTheme(theme));
return mermaidApi;
}).catch((err) => {
initPromise = null;
mermaidApi = null;
throw err;
});
}
const api = await initPromise;
api.initialize(mermaidConfigForTheme(theme));
return api;
}
function queuedRender(
svgId: string,
definition: string,
theme: MermaidThemeName,
signal: AbortSignal,
): Promise<string> {
const promise = renderQueue.then(async () => {
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
const api = await ensureMermaid(theme);
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
const { svg } = await api.render(svgId, definition);
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
return svg;
});
renderQueue = promise.then(() => {}, () => {});
return promise;
}
async function renderMermaid(
svgId: string,
definition: string,
theme: MermaidThemeName,
signal: AbortSignal,
): Promise<string> {
if (renderAdapterForTest) return renderAdapterForTest(svgId, definition, theme, signal);
return queuedRender(svgId, definition, theme, signal);
}
async function ensurePanZoomFactory(): Promise<PanZoomFactory | null> {
if (panZoomFactoryForTest !== undefined) return panZoomFactoryForTest;
if (panZoomFactory) return panZoomFactory;
if (!panZoomPromise) {
panZoomPromise = import("svg-pan-zoom").then((mod) => {
const factory = (("default" in mod ? mod.default : mod) as unknown) as PanZoomFactory;
panZoomFactory = factory;
return factory;
}).catch(() => null);
}
return panZoomPromise;
}
export function isSafeMermaidHref(href: string | null | undefined): boolean {
const value = href?.trim();
if (!value) return false;
if (value.startsWith("#")) return true;
try {
return SAFE_LINK_PROTOCOLS.has(new URL(value).protocol);
} catch {
return false;
}
}
export function isOpenableMermaidHref(href: string | null | undefined): boolean {
const value = href?.trim();
return Boolean(value && !value.startsWith("#") && isSafeMermaidHref(value));
}
export function getMermaidAnchorHref(anchor: Element): string | null {
return anchor.getAttribute("href")
?? anchor.getAttribute("xlink:href")
?? anchor.getAttributeNS(XLINK_NS, "href");
}
export function sanitizeMermaidSvg(svg: string): string {
if (typeof DOMParser === "undefined" || typeof XMLSerializer === "undefined") return svg;
const doc = new DOMParser().parseFromString(svg, "image/svg+xml");
const svgEl = doc.documentElement;
if (svgEl.localName.toLowerCase() !== "svg") return svg;
const elements = [svgEl, ...Array.from(svgEl.querySelectorAll("*"))];
const toRemove: Element[] = [];
for (const element of elements) {
const name = element.localName.toLowerCase();
if (name === "script" || name === "iframe" || name === "object" || name === "embed") {
toRemove.push(element);
continue;
}
for (const attr of Array.from(element.attributes)) {
const attrName = attr.name;
const localName = attr.localName.toLowerCase();
if (/^on/i.test(attrName)) {
element.removeAttribute(attrName);
continue;
}
if (localName === "href" && !isSafeMermaidHref(attr.value)) {
element.removeAttribute(attrName);
}
}
}
for (const element of toRemove) element.parentNode?.removeChild(element);
return new XMLSerializer().serializeToString(svgEl);
}
export function resolveMermaidTheme(): MermaidThemeName {
if (typeof document === "undefined") return "dark";
const forced = document.documentElement.getAttribute("data-theme");
if (forced === "light" || forced === "dark") return forced;
return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
}
function useMermaidTheme(): MermaidThemeName {
const [theme, setTheme] = useState<MermaidThemeName>(resolveMermaidTheme);
useEffect(() => {
const html = document.documentElement;
const updateTheme = () => setTheme(resolveMermaidTheme());
const observer = new MutationObserver(updateTheme);
observer.observe(html, { attributeFilter: ["data-theme", "data-theme-mode"] });
const mq = window.matchMedia("(prefers-color-scheme: light)");
mq.addEventListener("change", updateTheme);
return () => {
observer.disconnect();
mq.removeEventListener("change", updateTheme);
};
}, []);
return theme;
}
function destroyPanZoom(instance: PanZoomInstance | null): void {
if (!instance) return;
try {
instance.destroy();
} catch {
/* svg-pan-zoom cleanup is best-effort across browser and test DOMs. */
}
}
const MermaidDiagram = memo(function MermaidDiagram({ definition }: MermaidDiagramProps) {
const [state, setState] = useState<DiagramState>({ status: "loading" });
const [tab, setTab] = useState<DiagramTab>("preview");
const [fullscreen, setFullscreen] = useState(false);
const [portalTarget, setPortalTarget] = useState<Element | null>(null);
const theme = useMermaidTheme();
const instanceId = useId().replace(/[^a-zA-Z0-9_-]/g, "-");
const svgId = `mermaid-${instanceId}`;
const previewRef = useRef<HTMLDivElement>(null);
const panZoomRef = useRef<PanZoomInstance | null>(null);
const mountedRef = useRef(true);
const source = useMemo(() => definition.replace(/\n$/, ""), [definition]);
useEffect(() => {
mountedRef.current = true;
const controller = new AbortController();
setState({ status: "loading" });
(async () => {
try {
const trimmed = source.trim();
if (!trimmed) {
setState({ status: "error", message: "Empty diagram definition" });
return;
}
const rendered = await renderMermaid(svgId, trimmed, theme, controller.signal);
if (controller.signal.aborted || !mountedRef.current) return;
setState({ status: "rendered", svg: sanitizeMermaidSvg(rendered) });
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
if (controller.signal.aborted || !mountedRef.current) return;
const message = err instanceof Error ? err.message : String(err);
setState({ status: "error", message });
}
})();
return () => {
controller.abort();
mountedRef.current = false;
};
}, [source, svgId, theme]);
useLayoutEffect(() => {
if (tab !== "preview" || state.status !== "rendered") return;
const container = previewRef.current;
if (!container) return;
let cancelled = false;
let raf = 0;
let instance: PanZoomInstance | null = null;
void ensurePanZoomFactory().then((factory) => {
if (cancelled || !factory) return;
raf = window.requestAnimationFrame(() => {
const svg = container.querySelector("svg") as SVGSVGElement | null;
if (cancelled || !svg) return;
svg.removeAttribute("width");
svg.removeAttribute("height");
svg.style.width = "100%";
svg.style.height = "100%";
svg.style.maxWidth = "none";
destroyPanZoom(panZoomRef.current);
try {
instance = factory(svg, {
zoomEnabled: true,
panEnabled: true,
controlIconsEnabled: false,
dblClickZoomEnabled: true,
fit: true,
center: true,
minZoom: MIN_ZOOM,
maxZoom: MAX_ZOOM,
zoomScaleSensitivity: 0.3,
});
panZoomRef.current = instance;
window.requestAnimationFrame(() => {
if (panZoomRef.current !== instance) return;
instance?.resize();
instance?.fit();
instance?.center();
});
} catch {
instance = null;
panZoomRef.current = null;
}
});
});
return () => {
cancelled = true;
if (raf) window.cancelAnimationFrame(raf);
if (panZoomRef.current === instance) panZoomRef.current = null;
destroyPanZoom(instance);
};
}, [state, tab, fullscreen]);
useEffect(() => {
if (!fullscreen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setFullscreen(false);
setPortalTarget(null);
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [fullscreen]);
useEffect(() => {
if (!fullscreen || !panZoomRef.current) return;
panZoomRef.current.resize();
panZoomRef.current.fit();
panZoomRef.current.center();
}, [fullscreen]);
const toggleFullscreen = useCallback(() => {
setFullscreen((current) => {
const next = !current;
setPortalTarget(next ? document.querySelector(".chat-pane") ?? document.body : null);
return next;
});
}, []);
const zoomIn = useCallback(() => {
panZoomRef.current?.zoomIn();
}, []);
const zoomOut = useCallback(() => {
panZoomRef.current?.zoomOut();
}, []);
const resetZoom = useCallback(() => {
const instance = panZoomRef.current;
if (!instance) return;
instance.reset();
instance.fit();
instance.center();
}, []);
const body = (
<MermaidBody
state={state}
source={source}
tab={tab}
previewRef={previewRef}
/>
);
const content = (
<div className={[
"mermaid-diagram",
state.status === "error" ? "mermaid-diagram--error" : "",
fullscreen ? "mermaid-diagram--fullscreen" : "",
].filter(Boolean).join(" ")}>
<MermaidToolbar
tab={tab}
source={source}
fullscreen={fullscreen}
onTabChange={setTab}
onFullscreenToggle={toggleFullscreen}
onZoomIn={zoomIn}
onZoomOut={zoomOut}
onResetZoom={resetZoom}
/>
{body}
</div>
);
if (fullscreen && portalTarget) {
return (
<>
<div className="mermaid-diagram mermaid-diagram--placeholder" aria-hidden="true" />
{createPortal(content, portalTarget)}
</>
);
}
return content;
});
function MermaidToolbar({
tab,
source,
fullscreen,
onTabChange,
onFullscreenToggle,
onZoomIn,
onZoomOut,
onResetZoom,
}: {
tab: DiagramTab;
source: string;
fullscreen: boolean;
onTabChange: (tab: DiagramTab) => void;
onFullscreenToggle: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
onResetZoom: () => void;
}) {
return (
<div className="mermaid-diagram__toolbar">
<div className="mermaid-diagram__title" aria-hidden="true">Mermaid</div>
<div className="mermaid-diagram__actions">
<button
type="button"
className={`mermaid-diagram__icon-btn${tab === "preview" ? " mermaid-diagram__icon-btn--active" : ""}`}
onClick={() => onTabChange("preview")}
aria-label="Preview diagram"
title="Preview diagram"
>
<Play size={14} />
</button>
<button
type="button"
className={`mermaid-diagram__icon-btn${tab === "code" ? " mermaid-diagram__icon-btn--active" : ""}`}
onClick={() => onTabChange("code")}
aria-label="Show diagram source"
title="Show diagram source"
>
<Code2 size={14} />
</button>
<button
type="button"
className="mermaid-diagram__icon-btn mermaid-diagram__zoom-action"
onClick={onZoomOut}
aria-label="Zoom out"
title="Zoom out"
>
<ZoomOut size={14} />
</button>
<button
type="button"
className="mermaid-diagram__icon-btn mermaid-diagram__zoom-action"
onClick={onZoomIn}
aria-label="Zoom in"
title="Zoom in"
>
<ZoomIn size={14} />
</button>
<button
type="button"
className="mermaid-diagram__icon-btn mermaid-diagram__zoom-action"
onClick={onResetZoom}
aria-label="Reset zoom"
title="Reset zoom"
>
<RotateCcw size={14} />
</button>
<CopyButton text={source} className="mermaid-diagram__copy-btn" showInlineLabel={false} />
<button
type="button"
className="mermaid-diagram__icon-btn"
onClick={onFullscreenToggle}
aria-label={fullscreen ? "Exit fullscreen" : "Open fullscreen"}
title={fullscreen ? "Exit fullscreen" : "Open fullscreen"}
>
{fullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</button>
</div>
</div>
);
}
function MermaidBody({
state,
source,
tab,
previewRef,
}: {
state: DiagramState;
source: string;
tab: DiagramTab;
previewRef: RefObject<HTMLDivElement | null>;
}) {
if (tab === "code") {
return (
<pre className="code hljs mermaid-diagram__code" data-lang="mermaid">
<code>{source}</code>
</pre>
);
}
if (state.status === "loading") {
return (
<div className="mermaid-diagram__loading">
<span className="mermaid-diagram__spinner" />
<span>Rendering diagram...</span>
</div>
);
}
if (state.status === "error") {
return (
<div className="mermaid-diagram__error">
<div className="mermaid-diagram__error-bar">
<AlertCircle size={14} className="mermaid-diagram__error-icon" />
<span>Diagram syntax error</span>
</div>
<pre className="code hljs mermaid-diagram__error-source" data-lang="mermaid">
<code>{source}</code>
</pre>
<details className="mermaid-diagram__error-details">
<summary>Error details</summary>
<pre className="mermaid-diagram__error-detail-text">{state.message}</pre>
</details>
</div>
);
}
return <SvgPreview refEl={previewRef} svg={state.svg} />;
}
function SvgPreview({ refEl, svg }: { refEl: RefObject<HTMLDivElement | null>; svg: string }) {
const openAnchor = useCallback((event: React.MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
const anchor = target.closest("a");
const href = anchor ? getMermaidAnchorHref(anchor) : null;
if (!href) return;
event.preventDefault();
if (isOpenableMermaidHref(href)) openExternal(href);
}, []);
const preventMiddleButtonNavigation = useCallback((event: React.MouseEvent) => {
if (event.button !== 1) return;
const target = event.target;
if (target instanceof Element && target.closest("a")) event.preventDefault();
}, []);
return (
<div className="mermaid-diagram__preview-wrap">
<div
className="mermaid-diagram__preview"
ref={refEl}
dangerouslySetInnerHTML={{ __html: svg }}
onClick={openAnchor}
onAuxClick={openAnchor}
onMouseDown={preventMiddleButtonNavigation}
/>
</div>
);
}
export default MermaidDiagram;
+99 -8
View File
@@ -1,6 +1,6 @@
import { memo, useEffect, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from "react";
import { ChevronDown, ChevronRight, FileText, Folder, GitBranch, Image, MessageSquare, Pencil, RotateCcw, ScrollText } from "lucide-react";
import { BrainCircuit, ChevronDown, ChevronRight, FileText, Folder, GitBranch, Image, MessageSquare, Pencil, RotateCcw, ScrollText } from "lucide-react";
import { Markdown } from "./Markdown";
import { CopyButton } from "./CopyButton";
import { ProcessBrainIcon } from "./ProcessCard";
@@ -10,10 +10,13 @@ import type { DisplayAttachment } from "../lib/attachmentDisplay";
import { app } from "../lib/bridge";
import { replaySubmitText } from "../lib/editReplay";
import { useT } from "../lib/i18n";
import { Tooltip } from "./Tooltip";
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
import { displayReasoningText } from "../lib/reasoningDisplay";
import { stripMemoryCompilerExecution } from "../lib/memoryCompilerDisplay";
import { visibleTranscriptMemoryCitations } from "../lib/memoryCitationVisibility";
import type { Item, MessageActionScope } from "../lib/useController";
import type { CheckpointMeta } from "../lib/types";
import type { CheckpointMeta, MemoryCitation } from "../lib/types";
type AssistantItem = Extract<Item, { kind: "assistant" }>;
export type TurnActionMenu = "summary" | "rewind";
@@ -78,6 +81,60 @@ function mergeDisplayAttachments(existing: DisplayAttachment[], incoming: Displa
return merged;
}
function MemoryCitations({ citations }: { citations?: MemoryCitation[] }) {
const t = useT();
const bodyRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const clean = visibleTranscriptMemoryCitations(citations)
.filter((citation) => (citation.source ?? citation.id ?? citation.note ?? "").trim() !== "")
.slice(0, 5);
useGSAPCollapse(bodyRef, open);
if (clean.length === 0) return null;
return (
<div className="msg-memory-citations">
<button
type="button"
className="msg-memory-citations__toggle"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
>
<ChevronRight className={`msg-memory-citations__chevron${open ? " msg-memory-citations__chevron--open" : ""}`} size={15} />
<span>{t("msg.memoryCompilerCitationsCount", { n: clean.length })}</span>
</button>
{open && (
<div ref={bodyRef} className="msg-memory-citations__body">
{clean.map((citation, index) => {
const lines = memoryCitationLines(citation, t);
return (
<div key={`${citation.id ?? citation.source}-${index}`} className="msg-memory-citations__item">
<div className="msg-memory-citations__source">
<span>{memoryCitationSource(citation)}</span>
{lines && <span className="msg-memory-citations__lines">{lines}</span>}
</div>
{citation.note && <div className="msg-memory-citations__note">{citation.note}</div>}
</div>
);
})}
</div>
)}
</div>
);
}
function memoryCitationSource(citation: MemoryCitation): string {
const source = (citation.source || citation.id || "Memory v5").trim();
if (citation.kind === "compiler_reference" && source === "Memory v5") return "Memory v5 compiler";
return source;
}
function memoryCitationLines(citation: MemoryCitation, t: ReturnType<typeof useT>): string {
const start = citation.lineStart ?? 0;
const end = citation.lineEnd ?? 0;
if (start <= 0) return "";
if (end > 0 && end !== start) return t("msg.memoryCitationLineRange", { start, end });
return t("msg.memoryCitationLine", { line: start });
}
function messageDate(value?: number): Date {
return new Date(typeof value === "number" && Number.isFinite(value) && value > 0 ? value : Date.now());
}
@@ -111,7 +168,8 @@ export function UserMessage({
}) {
const t = useT();
const imSource = parseImSourceMessage(text);
const actionText = imSource?.text ?? text;
const actionText = stripMemoryCompilerExecution(imSource?.text ?? text);
const hasMemoryCompiler = Boolean(submitText?.includes("<memory-compiler-execution>"));
const { text: displayText, attachments } = parseAttachmentRefsForDisplay(actionText);
const orderedAttachments = sortDisplayAttachments(attachments);
const sourceLabel = imSource ? imSourceLabel(imSource, t) : "";
@@ -327,6 +385,11 @@ export function UserMessage({
{formatMessageTime(sentAt)}
</time>
)}
{hasMemoryCompiler && (
<span className="msg-meta__indicator" title={t("msg.memoryCompilerApplied")} aria-hidden="true">
<BrainCircuit size={14} />
</span>
)}
<CopyButton text={actionText} label={t("msg.copy")} showInlineLabel={false} className="msg-meta__btn msg-meta__copy" />
{onEdit && (
<button
@@ -420,11 +483,34 @@ export function TurnActions({
}
};
const actionMeta = (scope: MessageActionScope): string => {
if ((scope === "code" || scope === "both") && checkpoint?.files?.length) {
return t("rewind.filesChanged", { count: checkpoint.files.length });
const total = checkpoint?.fileCount ?? checkpoint?.files?.length ?? 0;
if ((scope === "code" || scope === "both") && total > 0) {
const turnCount = checkpoint?.turnFileCount ?? 0;
if (turnCount > 0 && turnCount < total) {
return `${t("rewind.filesChanged", { count: total })} (${t("rewind.turnFiles", { count: turnCount })})`;
}
return t("rewind.filesChanged", { count: total });
}
return "";
};
const actionTooltipLabel = (scope: MessageActionScope) => {
const reason = actionDisabledReason(scope);
if (reason) return <span>{reason}</span>;
const files = checkpoint?.files ?? [];
const total = checkpoint?.fileCount ?? files.length;
if ((scope === "code" || scope === "both") && total > 0) {
const hidden = Math.max(0, total - files.length);
return (
<div className="rewind__files-tooltip">
{files.map((file) => (
<div key={file}>{file.split(/[/\\]/).pop() || file}</div>
))}
{hidden > 0 && <div>+{hidden}</div>}
</div>
);
}
return undefined;
};
const runAction = (scope: MessageActionScope) => {
setConfirmScope(null);
onOpenMenu?.(null);
@@ -441,7 +527,8 @@ export function TurnActions({
const renderAction = (scope: MessageActionScope, danger = false) => {
const disabledReason = actionDisabledReason(scope);
const meta = actionMeta(scope);
return (
const tipLabel = actionTooltipLabel(scope);
const button = (
<button
className={[
"rewind__menu-item",
@@ -450,13 +537,14 @@ export function TurnActions({
].filter(Boolean).join(" ")}
type="button"
disabled={Boolean(disabledReason)}
title={disabledReason || undefined}
{...(tipLabel ? {} : { title: disabledReason || undefined })}
onClick={() => selectRewind(scope)}
>
<span>{actionLabel(scope)}</span>
{meta && <span className="rewind__menu-meta">{meta}</span>}
</button>
);
return tipLabel ? <Tooltip key={scope} label={tipLabel} side="top" block fill>{button}</Tooltip> : button;
};
const forkDisabledReason = canAct ? actionDisabledReason("fork") : "";
const toggleMenu = (menu: TurnActionMenu) => {
@@ -543,6 +631,7 @@ export const AssistantMessage = memo(function AssistantMessage({
defaultExpanded = false,
expandWhileStreaming = true,
truncateStreamingReasoning = false,
creationMode = false,
}: {
item: AssistantItem;
defaultExpanded?: boolean;
@@ -550,6 +639,7 @@ export const AssistantMessage = memo(function AssistantMessage({
expandWhileStreaming?: boolean;
/** Opt-in for compact mode to keep live DeepSeek reasoning from growing an unbounded DOM. */
truncateStreamingReasoning?: boolean;
creationMode?: boolean;
}) {
const t = useT();
const reasoningBodyRef = useRef<HTMLDivElement>(null);
@@ -628,9 +718,10 @@ export const AssistantMessage = memo(function AssistantMessage({
)}
{hasText && (
<div className="msg__body">
<Markdown text={item.text} />
<Markdown text={item.text} plainStatusBlocks={creationMode} />
</div>
)}
<MemoryCitations citations={item.memoryCitations} />
</div>
);
});
@@ -5,6 +5,7 @@ import { app } from "../lib/bridge";
import { useT } from "../lib/i18n";
import type { ModelInfo } from "../lib/types";
import { AnchoredPopover } from "./AnchoredPopover";
import { Tooltip } from "./Tooltip";
// ModelSwitcher opens an upward popover listing configured providers. Selecting
// one switches the active model while the current conversation continues.
@@ -79,6 +80,7 @@ export function ModelSwitcher({ label, tabId, onPick }: { label: string; tabId?:
const cur = models.find((m) => m.current) ?? models.find((m) => m.model === label || m.ref === label);
return cur ? providerLabel(cur.provider, t) : null;
}, [label, models, t]);
const triggerLabel = currentProvider ? `${label} · ${currentProvider}` : label;
const pick = (name: string) => {
setModels((prev) => prev.map((m) => ({ ...m, current: m.ref === name })));
@@ -88,17 +90,20 @@ export function ModelSwitcher({ label, tabId, onPick }: { label: string; tabId?:
return (
<div className="modelsw">
<button
ref={triggerRef}
type="button"
className="modelsw__trigger"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<Brain size={13} className="modelsw__kind" />
<span className="modelsw__label">{label}{currentProvider && <span className="modelsw__provider"> · {currentProvider}</span>}</span>
<ChevronsUpDown size={11} />
</button>
<Tooltip label={triggerLabel} fill>
<button
ref={triggerRef}
type="button"
className="modelsw__trigger"
aria-label={triggerLabel}
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<Brain size={13} className="modelsw__kind" />
<span className="modelsw__label">{label}</span>
<ChevronsUpDown size={11} />
</button>
</Tooltip>
<AnchoredPopover
open={open}
anchorRef={triggerRef}
@@ -156,14 +161,6 @@ function providerLabel(provider: string, t: ReturnType<typeof useT>): string {
case "deepseek-flash":
case "deepseek-pro":
return t("settings.providerLabel.deepseek");
case "mimo-api":
case "mimo":
case "xiaomi-mimo":
return t("settings.providerLabel.mimoApi");
case "mimo-token-plan":
case "mimo-pro":
case "mimo-flash":
return t("settings.providerLabel.mimoTokenPlan");
default:
return provider;
}
+215 -8
View File
@@ -12,6 +12,8 @@ import type { ProjectNode, ProjectTopicStatus } from "../lib/types";
import { topicActivityTime } from "../lib/session";
import { getLocale, useT, type DictKey, type Translator } from "../lib/i18n";
import { PROJECT_COLOR_OPTIONS, projectColorValue } from "../lib/projectColors";
import { topicShortcutLabel, type TopicShortcutEntry } from "../lib/topicShortcuts";
import type { ShortcutPlatform } from "../lib/keyboardShortcuts";
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
import { Tooltip } from "./Tooltip";
@@ -33,6 +35,9 @@ interface ProjectTreeProps {
onTimeFilterChange: (filter: "all" | "10" | "20" | "1h" | "3h" | "5h" | "1d") => void;
searchExpanded?: boolean;
searchFocusSignal?: number;
showShortcutBadges?: boolean;
shortcutPlatform?: ShortcutPlatform;
onVisibleTopicsChange?: (topics: TopicShortcutEntry[]) => void;
}
type ProjectTreeImTopicSource = {
@@ -72,6 +77,22 @@ export function projectTreeTopicOpenRequest(node: ProjectNode): ProjectTreeTopic
};
}
type ProjectTreeTopicClickTarget = {
rowKey: string;
canRename: boolean;
};
type ProjectTreePendingTopicOpen = ProjectTreeTopicClickTarget & {
timer: ReturnType<typeof setTimeout>;
};
export function projectTreeShouldSuppressOpenForRename(
pending: ProjectTreeTopicClickTarget | null,
next: ProjectTreeTopicClickTarget,
): boolean {
return Boolean(pending && pending.rowKey === next.rowKey && pending.canRename && next.canRename);
}
export type ProjectTreeFolderDisclosure = {
canExpand: boolean;
isOpen: boolean;
@@ -138,6 +159,43 @@ function topicStatusLabel(node: ProjectNode, t: Translator): string {
return status ? t(topicStatusLabels[status]) : "";
}
function topicActivityAt(node: ProjectNode): number {
return node.lastActivityAt || node.createdAt || 0;
}
export function projectTreeReadActivityKey(node: ProjectNode): string | null {
const request = projectTreeTopicOpenRequest(node);
if (!request?.topicId) return null;
return [
request.scope,
request.workspaceRoot,
request.topicId,
request.sessionPath ?? "",
].join("\u001f");
}
type ProjectTreeReadActivity = Record<string, number>;
export function projectTreeTopicHasUnreadActivity(
node: ProjectNode,
readActivity: ProjectTreeReadActivity,
activeScope?: string,
activeWorkspaceRoot?: string,
activeTopicId?: string,
activeSessionPath?: string,
): boolean {
if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return false;
if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) return false;
if (topicStatus(node) !== "") return false;
const key = projectTreeReadActivityKey(node);
const activityAt = topicActivityAt(node);
return Boolean(key && activityAt > 0 && (readActivity[key] ?? 0) < activityAt);
}
export function projectTreeShouldRenderTopicActions(isSessionNode: boolean, compactTopics: boolean, unread: boolean): boolean {
return !isSessionNode && compactTopics && !unread;
}
function topicActivityLabel(ms: number, t: Translator, compact = false): string {
if (ms <= 0) return "";
const delta = Date.now() - ms;
@@ -205,6 +263,31 @@ type WorkbenchTreeSections = {
const GLOBAL_PROJECT_ORDER_KEY = "__global__";
const WORKBENCH_ORGANIZE_KEY = "projectTree:workbenchOrganize";
const WORKBENCH_SORT_KEY = "projectTree:workbenchSort";
const READ_ACTIVITY_KEY = "projectTree:readActivity";
const READ_ACTIVITY_INIT_KEY = "projectTree:readActivityInitialized";
function loadReadActivity(): ProjectTreeReadActivity {
try {
const raw = localStorage.getItem(READ_ACTIVITY_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as Record<string, unknown>;
const out: ProjectTreeReadActivity = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "number" && Number.isFinite(value)) out[key] = value;
}
return out;
} catch {
return {};
}
}
function saveReadActivity(readActivity: ProjectTreeReadActivity) {
try {
localStorage.setItem(READ_ACTIVITY_KEY, JSON.stringify(readActivity));
} catch {
/* localStorage unavailable */
}
}
function loadWorkbenchOrganizeMode(): WorkbenchOrganizeMode {
try {
@@ -446,6 +529,9 @@ export function ProjectTree({
onTimeFilterChange,
searchExpanded = true,
searchFocusSignal = 0,
showShortcutBadges = false,
shortcutPlatform,
onVisibleTopicsChange,
}: ProjectTreeProps) {
const t = useT();
const { showToast } = useToast();
@@ -473,11 +559,20 @@ export function ProjectTree({
const [workbenchHeaderMenu, setWorkbenchHeaderMenu] = useState<WorkbenchHeaderMenu>(null);
const [workbenchOrganizeMode, setWorkbenchOrganizeMode] = useState<WorkbenchOrganizeMode>(loadWorkbenchOrganizeMode);
const [workbenchSortMode, setWorkbenchSortMode] = useState<WorkbenchSortMode>(loadWorkbenchSortMode);
const [readActivity, setReadActivity] = useState<ProjectTreeReadActivity>(loadReadActivity);
const filterRef = useRef<HTMLDivElement>(null);
const filterTriggerRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const topicIndexRef = useRef(0);
const visibleTopicsCollectorRef = useRef<TopicShortcutEntry[]>([]);
const [filterMenuOpen, setFilterMenuOpen] = useState(false);
const creatingRef = useRef(false);
const clickTimerRef = useRef<ProjectTreePendingTopicOpen | null>(null);
useEffect(() => {
return () => {
if (clickTimerRef.current !== null) clearTimeout(clickTimerRef.current.timer);
};
}, []);
const manuallyCollapsedRef = useRef(manuallyCollapsed);
const closeMenu = useCallback(() => {
@@ -530,6 +625,67 @@ export function ProjectTree({
void refresh();
}, [refresh, refreshSignal]);
const markNodeRead = useCallback((node: ProjectNode) => {
const key = projectTreeReadActivityKey(node);
const activityAt = topicActivityAt(node);
if (!key || activityAt <= 0) return;
setReadActivity((prev) => {
if ((prev[key] ?? 0) >= activityAt) return prev;
const next = { ...prev, [key]: activityAt };
saveReadActivity(next);
return next;
});
}, []);
useEffect(() => {
if (tree.length === 0) return;
try {
if (localStorage.getItem(READ_ACTIVITY_INIT_KEY)) return;
} catch {
return;
}
const baseline: ProjectTreeReadActivity = {};
const collectBaseline = (nodes: ProjectNode[]) => {
for (const node of nodes) {
if ((isTopicNode(node) || isRuntimeSessionNode(node)) && topicStatus(node) === "") {
const key = projectTreeReadActivityKey(node);
const activityAt = topicActivityAt(node);
if (key && activityAt > 0) baseline[key] = Math.max(baseline[key] ?? 0, activityAt);
}
collectBaseline(asArray(node.children));
}
};
collectBaseline(tree);
try {
localStorage.setItem(READ_ACTIVITY_INIT_KEY, "1");
} catch {
/* localStorage unavailable */
}
if (Object.keys(baseline).length === 0) return;
setReadActivity((prev) => {
const next = { ...prev };
let changed = false;
for (const [key, value] of Object.entries(baseline)) {
if ((next[key] ?? 0) >= value) continue;
next[key] = value;
changed = true;
}
if (!changed) return prev;
saveReadActivity(next);
return next;
});
}, [tree]);
useEffect(() => {
const markActive = (nodes: ProjectNode[]) => {
for (const node of nodes) {
if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) markNodeRead(node);
markActive(asArray(node.children));
}
};
markActive(tree);
}, [activeScope, activeSessionPath, activeTopicId, activeWorkspaceRoot, markNodeRead, tree]);
useEffect(() => {
try {
localStorage.setItem(WORKBENCH_ORGANIZE_KEY, workbenchOrganizeMode);
@@ -954,7 +1110,7 @@ export function ProjectTree({
});
}, [activeAncestorKeys, manuallyCollapsed]);
const renderNode = (node: ProjectNode | null | undefined, depth: number, section: "pinned" | "projects" = "projects") => {
const renderNode = (node: ProjectNode | null | undefined, depth: number, section: "pinned" | "projects" = "projects", isVisible = true) => {
if (!node) return null;
const key = projectNodeKey(node, depth);
const children = asArray(node.children);
@@ -978,6 +1134,7 @@ export function ProjectTree({
const status = topicStatus(node);
const statusLabel = topicStatusLabel(node, t);
const showStatusInSide = status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job";
const unread = projectTreeTopicHasUnreadActivity(node, readActivity, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath);
const topicId = node.topicId ?? "";
const imSource = scope === "global" && topicId ? imTopicSources[topicId] : undefined;
const imSourceLabel = imSource?.label || "";
@@ -1029,7 +1186,7 @@ export function ProjectTree({
return (
<div
key={key}
className={`project-tree__topic project-tree__topic--editing${active ? " project-tree__topic--active" : ""}${imSource ? " project-tree__topic--im-source" : ""}`}
className={`project-tree__topic project-tree__topic--editing${active ? " project-tree__topic--active" : ""}${imSource ? " project-tree__topic--im-source" : ""}${meta ? " project-tree__topic--has-meta" : ""}`}
style={{ paddingLeft: 14 + depth * 16 }}
>
<input
@@ -1037,6 +1194,7 @@ export function ProjectTree({
className="project-tree__topic-input"
value={topicDraft}
onChange={(event) => setTopicDraft(event.target.value)}
onFocus={(event) => event.target.select()}
onKeyDown={(event) => {
if (event.key === "Enter") void commitRenameTopic(topicId);
if (event.key === "Escape") setEditingTopic(null);
@@ -1046,9 +1204,20 @@ export function ProjectTree({
</div>
);
}
const shortcutIndex = showShortcutBadges && isVisible && topicIndexRef.current < 9 ? topicIndexRef.current + 1 : 0;
if (shortcutIndex > 0) topicIndexRef.current++;
// Collect visible topics in render order for shortcut navigation
if (openRequest && isVisible) {
visibleTopicsCollectorRef.current.push({
scope: openRequest.scope,
workspaceRoot: openRequest.workspaceRoot,
topicId: openRequest.topicId,
sessionPath: openRequest.sessionPath,
});
}
const row = (
<div
className={`project-tree__topic${scopeClass}${isSessionNode ? " project-tree__topic--session" : ""}${active ? " project-tree__topic--active" : ""}${node.running ? " project-tree__topic--running" : ""}${status ? ` project-tree__topic--status-${status}` : ""}${!isSessionNode && pinned ? " project-tree__topic--pinned" : ""}${topicMenuOpen ? " project-tree__topic--menu-open" : ""}${sideTimeVisible && (timeLabel || showStatusInSide) ? " project-tree__topic--with-side" : meta ? " project-tree__topic--has-meta" : ""}${imSource ? " project-tree__topic--im-source" : ""}`}
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}
>
@@ -1058,13 +1227,35 @@ export function ProjectTree({
title={title}
style={{ paddingLeft: 14 + depth * 16 }}
onClick={() => {
if (openRequest) onOpenTopic(openRequest.scope, openRequest.workspaceRoot, openRequest.topicId, openRequest.sessionPath);
if (!openRequest) return;
const nextClick = { rowKey: key, canRename: !isSessionNode };
const pending = clickTimerRef.current;
if (pending !== null) {
clearTimeout(pending.timer);
clickTimerRef.current = null;
if (projectTreeShouldSuppressOpenForRename(pending, nextClick)) return;
}
const timer = setTimeout(() => {
if (clickTimerRef.current?.timer === timer) clickTimerRef.current = null;
markNodeRead(node);
onOpenTopic(openRequest.scope, openRequest.workspaceRoot, openRequest.topicId, openRequest.sessionPath);
}, 200);
clickTimerRef.current = { ...nextClick, timer };
}}
onKeyDown={(event) => {
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
openTopicMenu(event);
}
}}
onDoubleClick={(event) => {
if (isSessionNode) return;
event.stopPropagation();
if (clickTimerRef.current !== null && clickTimerRef.current.rowKey === key) {
clearTimeout(clickTimerRef.current.timer);
clickTimerRef.current = null;
}
startRenameTopic(node, label);
}}
>
<span className="project-tree__topic-copy">
<span className="project-tree__topic-heading">
@@ -1104,7 +1295,8 @@ export function ProjectTree({
</span>
)}
</button>
{!isSessionNode && compactTopics && (
{unread && <span className="project-tree__topic-unread-dot" aria-hidden="true" />}
{projectTreeShouldRenderTopicActions(isSessionNode, compactTopics, unread) && (
<span className="project-tree__topic-actions" aria-label={t("projectTree.topicActions")}>
<Tooltip label={pinLabel} side="top" className="project-tree__topic-action-slot">
<button
@@ -1147,6 +1339,11 @@ export function ProjectTree({
onClose={closeMenu}
/>
)}
{shortcutIndex > 0 && (
<span className="project-tree__topic-shortcut" aria-hidden="true">
{topicShortcutLabel(shortcutIndex, shortcutPlatform)}
</span>
)}
</div>
);
return (
@@ -1155,7 +1352,7 @@ export function ProjectTree({
{hasChildren && (
<div className={`project-tree__children${isExpanded ? " project-tree__children--expanded" : ""}`}>
<div className="project-tree__children-inner">
{children.map((child) => renderNode(child, depth + 1, section))}
{children.map((child) => renderNode(child, depth + 1, section, isVisible && isExpanded))}
</div>
</div>
)}
@@ -1386,7 +1583,7 @@ export function ProjectTree({
{hasChildren && (
<div className={`project-tree__children${isExpanded ? " project-tree__children--expanded" : ""}`}>
<div className="project-tree__children-inner">
{children.map((child) => renderNode(child, depth + 1, section))}
{children.map((child) => renderNode(child, depth + 1, section, isVisible && isExpanded))}
</div>
</div>
)}
@@ -1474,7 +1671,7 @@ export function ProjectTree({
{hasChildren && (
<div className={`project-tree__children${isExpanded ? " project-tree__children--expanded" : ""}`}>
<div className="project-tree__children-inner">
{children.map((child) => renderNode(child, depth + 1, section))}
{children.map((child) => renderNode(child, depth + 1, section, isVisible && isExpanded))}
</div>
</div>
)}
@@ -1826,6 +2023,16 @@ export function ProjectTree({
const hasWorkbenchRows = workbenchTreeSections.pinned.length > 0 || workbenchTreeSections.projects.length > 0;
// Report visible topics to parent after render so shortcuts match sidebar order.
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
onVisibleTopicsChange?.(visibleTopicsCollectorRef.current);
});
// Reset topic index counter and visible topics collector before each render.
topicIndexRef.current = 0;
visibleTopicsCollectorRef.current = [];
return (
<div className="project-tree">
{searchVisible && (
@@ -1,6 +1,8 @@
import type { ReactNode, RefObject } from "react";
export function PromptShelf({
className,
cardClassName,
titleId,
title,
badges,
@@ -13,6 +15,8 @@ export function PromptShelf({
barRef,
role = "dialog",
}: {
className?: string;
cardClassName?: string;
titleId: string;
title: ReactNode;
badges?: ReactNode;
@@ -26,10 +30,10 @@ export function PromptShelf({
role?: "dialog" | "region";
}) {
return (
<div className="prompt-shelf" aria-live="polite">
<div className={["prompt-shelf", className ?? ""].filter(Boolean).join(" ")} aria-live="polite">
<div
ref={barRef}
className="prompt-shelf__card"
className={["prompt-shelf__card", cardClassName ?? ""].filter(Boolean).join(" ")}
role={role}
aria-modal={role === "dialog" ? "false" : undefined}
aria-labelledby={titleId}
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react";
import { useT } from "../lib/i18n";
import { loadLayoutSize, saveLayoutSize, type LayoutSizeKey } from "../lib/layoutPreferences";
import { createRafResizeUpdater } from "../lib/resizeDrag";
import { useDeferredClose } from "../lib/useMountTransition";
const DRAWER_DEFAULT_WIDTH = 440;
@@ -51,6 +52,7 @@ export function ResizableDrawer({
}) {
const t = useT();
const config = drawerConfig(wide);
const drawerRef = useRef<HTMLElement>(null);
const [viewportWidth, setViewportWidth] = useState(() => (typeof window === "undefined" ? 1440 : window.innerWidth));
const [width, setWidth] = useState(() =>
loadLayoutSize(config.key, config.defaultWidth, (value) => clampDrawerWidth(value, wide)),
@@ -79,14 +81,22 @@ export function ResizableDrawer({
const startResize = useCallback(
(event: ReactPointerEvent<HTMLButtonElement>) => {
if (event.button !== 0) return;
const drawer = drawerRef.current;
if (!drawer) return;
event.preventDefault();
setResizing(true);
let nextWidth = effectiveWidth;
const liveResize = createRafResizeUpdater({
target: drawer,
separator: event.currentTarget,
cssVar: "--drawer-width",
});
const onMove = (moveEvent: PointerEvent) => {
nextWidth = clampDrawerWidth(window.innerWidth - moveEvent.clientX, wide, window.innerWidth);
setWidth(nextWidth);
liveResize.schedule(nextWidth);
};
const onDone = () => {
liveResize.flush();
setWidth(nextWidth);
saveLayoutSize(config.key, nextWidth);
setResizing(false);
@@ -124,6 +134,7 @@ export function ResizableDrawer({
return (
<div className={`drawer-backdrop${subtle ? " drawer-backdrop--subtle" : ""}`} data-state={status} onClick={requestClose}>
<aside
ref={drawerRef}
className={`drawer${wide ? " drawer--wide" : ""}${resizing ? " drawer--resizing" : ""}`}
data-state={status}
onClick={(e) => e.stopPropagation()}
File diff suppressed because it is too large. Load diff
+70 -83
View File
@@ -1,59 +1,13 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { type ReactNode } from "react";
import { Activity, CircleDollarSign, CircleGauge, Database, Folder, GitBranch, Layers, Percent, RefreshCw, Wallet, Zap } from "lucide-react";
import { Tooltip } from "./Tooltip";
import { useI18n, type Translator } from "../lib/i18n";
import { formatMoneyLocalized } from "../lib/money";
import { normalizeStatusBarItems, type StatusBarItemId } from "../lib/statusBarItems";
import { type BalanceInfo, type CollaborationMode, type ContextInfo, type JobView, type ToolApprovalMode, type WireUsage } from "../lib/types";
import { type BalanceInfo, type ContextInfo, type UsageSourceStats, type WireUsage } from "../lib/types";
type StatusBarLabelStyle = "icon" | "text";
// JobsChip is the status-bar background-jobs indicator: a count that opens an
// upward popover listing the running jobs (id · label · status), mirroring the
// ModelSwitcher's click-to-open pattern. With no jobs it stays hidden so the
// high-priority status metrics keep the compact left-to-right scan.
function JobsChip({ jobs }: { jobs: JobView[] }) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const closeOnOutsideClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (wrapRef.current?.contains(target)) return;
setOpen(false);
};
document.addEventListener("click", closeOnOutsideClick);
return () => document.removeEventListener("click", closeOnOutsideClick);
}, [open]);
if (jobs.length === 0) {
return null;
}
return (
<div className="statusbar__jobswrap" ref={wrapRef}>
<Tooltip label={t("status.jobsTitle")}>
<button className="stat stat--jobs statusbar__jobs" onClick={() => setOpen((v) => !v)}>
<span className="stat__label">{t("status.jobsLabel")}</span>
<b>{jobs.length}</b>
</button>
</Tooltip>
{open && (
<div className="modelsw__menu jobsmenu" role="listbox">
<div className="jobsmenu__head">{t("status.jobsTitle")}</div>
{jobs.map((j) => (
<div className="jobsmenu__item" key={j.id} role="option">
<span className="jobsmenu__id">{j.id}</span>
<span className="jobsmenu__label">{j.label || j.kind}</span>
<span className="jobsmenu__status">{j.status}</span>
</div>
))}
</div>
)}
</div>
);
}
function formatRate(hit: number, denom: number): string | null {
if (denom <= 0) return null;
return ((hit / denom) * 100).toFixed(2);
@@ -63,8 +17,7 @@ function formatRate(hit: number, denom: number): string | null {
// steeper number on a non-compacting DeepSeek session. null when nothing yet.
function nowRate(u?: WireUsage): string | null {
if (!u) return null;
let denom = u.cacheHitTokens + u.cacheMissTokens;
if (denom === 0) denom = u.promptTokens;
const denom = u.cacheHitTokens + u.cacheMissTokens;
return formatRate(u.cacheHitTokens, denom);
}
@@ -78,6 +31,15 @@ function avgRate(u?: WireUsage): string | null {
return formatRate(u.sessionCacheHitTokens, denom);
}
// 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.
function contextAvgRate(ctx: ContextInfo): string | null {
const hit = ctx.cacheHitTokens ?? 0;
const miss = ctx.cacheMissTokens ?? 0;
return formatRate(hit, hit + miss);
}
function rateValueClass(rate: string | null): string {
if (rate === null) return "stat__value--empty";
const pct = Number.parseFloat(rate);
@@ -97,6 +59,59 @@ function formatTurnCount(turns: number | undefined, t: Translator): string {
return t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns });
}
const STATUS_SOURCE_ORDER = ["executor", "planner", "subagent", "compaction", "classifier", "title"];
function sourceLabel(source: string, t: Translator): string {
switch (source) {
case "executor": return t("context.sourceExecutor");
case "planner": return t("context.sourcePlanner");
case "subagent": return t("context.sourceSubagent");
case "compaction": return t("context.sourceCompaction");
case "classifier": return t("context.sourceClassifier");
case "title": return t("context.sourceTitle");
default: return source;
}
}
function sourceRows(sources?: Record<string, UsageSourceStats>): Array<{ source: string; stats: UsageSourceStats }> {
return Object.entries(sources ?? {})
.filter(([, stats]) =>
(stats.requestCount ?? 0) > 0 ||
(stats.promptTokens ?? 0) > 0 ||
(stats.completionTokens ?? 0) > 0 ||
(stats.cacheHitTokens ?? 0) > 0 ||
(stats.cacheMissTokens ?? 0) > 0
)
.sort(([a], [b]) => {
const ia = STATUS_SOURCE_ORDER.indexOf(a);
const ib = STATUS_SOURCE_ORDER.indexOf(b);
if (ia >= 0 || ib >= 0) return (ia >= 0 ? ia : STATUS_SOURCE_ORDER.length) - (ib >= 0 ? ib : STATUS_SOURCE_ORDER.length);
return a.localeCompare(b);
})
.map(([source, stats]) => ({ source, stats }));
}
function sourceCacheTooltip(t: Translator, title: string, context: ContextInfo): ReactNode {
const rows = sourceRows(context.sources);
if (rows.length === 0) return title;
return (
<span className="statusbar__tooltip-stack">
<span>{title}</span>
{rows.map(({ source, stats }) => {
const denom = stats.cacheHitTokens + stats.cacheMissTokens;
const rate = denom > 0 ? `${formatRate(stats.cacheHitTokens, denom)}%` : t("context.cacheNotReported");
return (
<span key={source}>
{sourceLabel(source, t)}: {rate} · {t("context.sourceInput")} {formatTokenCount(stats.promptTokens)}
{" · "}{t("context.sourceOutput")} {formatTokenCount(stats.completionTokens)}
{" · "}{t("context.sourceRequests", { count: stats.requestCount ?? 0 })}
</span>
);
})}
</span>
);
}
function MetricLabel({ style, icon, label }: { style: StatusBarLabelStyle; icon: ReactNode; label: string }) {
return (
<span className={`stat__label stat__label--${style}`} aria-hidden={style === "icon" ? "true" : undefined}>
@@ -134,10 +149,7 @@ export function StatusBar({
context,
usage,
balance,
jobs,
running,
collaborationMode,
toolApprovalMode,
sessionTurns,
sessionTokens,
turnTokens,
@@ -154,10 +166,7 @@ export function StatusBar({
context: ContextInfo;
usage?: WireUsage;
balance?: BalanceInfo;
jobs?: JobView[];
running: boolean;
collaborationMode: CollaborationMode;
toolApprovalMode: ToolApprovalMode;
sessionTurns?: number;
sessionTokens?: number;
turnTokens?: number;
@@ -177,8 +186,7 @@ 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);
const jobsList = jobs ?? [];
const avgPct = avgRate(usage) ?? contextAvgRate(context);
const turnCostLabel = formatMoneyLocalized(turnCost, currency, { locale });
const costLabel = formatMoneyLocalized(cost, currency, { locale });
const displayWorkspacePath = (workspacePath || workspaceName || "").trim();
@@ -189,10 +197,10 @@ export function StatusBar({
const tokenLabel = formatTokenCount(sessionTokens);
const turnTokenLabel = formatTokenCount(turnTokens);
const balanceLabel = balance?.available && balance.display ? balance.display : "-";
const planMode = collaborationMode === "plan";
const goalMode = collaborationMode === "goal";
const metricLabelStyle = labelStyle === "text" ? "text" : "icon";
const visibleItems = normalizeStatusBarItems(items);
const cacheTooltip = sourceCacheTooltip(t, t("status.cacheTitle"), context);
const avgCacheTooltip = sourceCacheTooltip(t, t("status.cacheAvgTitle"), context);
const itemRenderers: Record<StatusBarItemId, ReactNode> = {
model: (
<Tooltip label={t("status.modelTitle")}>
@@ -219,7 +227,7 @@ export function StatusBar({
</Tooltip>
) : null,
cache: (
<Tooltip label={t("status.cacheTitle")} className="statusbar__metric statusbar__metric--cache">
<Tooltip label={cacheTooltip} className="statusbar__metric statusbar__metric--cache">
<span className="stat statusbar__cache">
<MetricLabel style={metricLabelStyle} icon={<Percent size={12} />} label={t("status.cacheLabel")} />
<b className={rateValueClass(nowPct) || undefined}>{nowPct !== null ? `${nowPct}%` : "-"}</b>
@@ -227,7 +235,7 @@ export function StatusBar({
</Tooltip>
),
cache_avg: (
<Tooltip label={t("status.cacheAvgTitle")} className="statusbar__metric statusbar__metric--avg">
<Tooltip label={avgCacheTooltip} className="statusbar__metric statusbar__metric--avg">
<span className="stat statusbar__avg">
<MetricLabel style={metricLabelStyle} icon={<Activity size={12} />} label={t("status.cacheAvgLabel")} />
<b className={rateValueClass(avgPct) || undefined}>{avgPct !== null ? `${avgPct}%` : "-"}</b>
@@ -309,21 +317,6 @@ export function StatusBar({
const renderedItems = visibleItems
.map((id) => ({ id, node: itemRenderers[id] }))
.filter(({ node }) => node !== null && node !== undefined && node !== false);
const modeIndicators = [
planMode ? <span className="statusbar__plan" key="plan">{t("status.plan")}</span> : null,
goalMode ? <span className="statusbar__plan" key="goal">{t("composer.goalMode")}</span> : null,
toolApprovalMode === "auto" ? (
<Tooltip label={t("composer.accessAutoTitle")} key="auto">
<span className="statusbar__yolo">{t("composer.accessAuto")}</span>
</Tooltip>
) : null,
toolApprovalMode === "yolo" ? (
<Tooltip label={t("status.yoloTitle")} key="yolo">
<span className="statusbar__yolo">{t("composer.accessYolo")}</span>
</Tooltip>
) : null,
].filter(Boolean);
return (
<div className={`statusbar statusbar--${metricLabelStyle}`}>
<div className="statusbar__group statusbar__group--items">
@@ -333,12 +326,6 @@ export function StatusBar({
</span>
))}
</div>
{modeIndicators.length > 0 && <div className="statusbar__group statusbar__group--modes">{modeIndicators}</div>}
{jobsList.length > 0 && (
<div className="statusbar__group statusbar__group--jobs">
<JobsChip jobs={jobsList} />
</div>
)}
</div>
);
}
+56 -8
View File
@@ -1,33 +1,75 @@
import { useEffect, useRef, useState } from "react";
import { useT } from "../lib/i18n";
import type { Todo } from "../lib/tools";
import { shouldOpenTodoPanelByDefault } from "../lib/todoVisibility";
import { PromptBadge, PromptHeaderAction, PromptShelf } from "./PromptShelf";
const STORAGE_KEY = "todoPanel:openStates";
const MAX_STORED_OPEN_STATES = 80;
function loadOpenStates(): Record<string, boolean> {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return {};
const parsed = JSON.parse(saved) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
const states: Record<string, boolean> = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "boolean") states[key] = value;
}
return states;
} catch {
return {};
}
}
function loadOpenState(stateKey: string, defaultOpen: boolean): boolean {
const states = loadOpenStates();
return Object.prototype.hasOwnProperty.call(states, stateKey) ? states[stateKey] : defaultOpen;
}
function saveOpenState(stateKey: string, open: boolean): void {
try {
const entries = Object.entries(loadOpenStates()).filter(([key]) => key !== stateKey);
entries.push([stateKey, open]);
const trimmed = entries.slice(-MAX_STORED_OPEN_STATES);
localStorage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(trimmed)));
} catch {
/* ignore quota errors */
}
}
// TodoPanel is the live task list pinned just above the composer — the kernel's
// latest todo_write call drives it, and it updates in place as the agent flips
// items to in_progress / completed. Completed lists collapse automatically so
// the user still sees the final state without the footer staying tall forever.
// items to in_progress / completed. Open state follows the current todo batch:
// new incomplete work opens by default, finished work collapses to a reviewable
// summary, and manual expand/collapse is restored only for the same batch.
export function TodoPanel({
todoKey,
stateKey,
todos,
onDismiss,
}: {
todoKey: string;
stateKey: string;
todos: Todo[];
onDismiss: () => void;
}) {
const t = useT();
const [open, setOpen] = useState(true);
const currentRef = useRef<HTMLLIElement | null>(null);
const done = todos.filter((t) => t.status === "completed").length;
const current = todos.find((t) => t.status === "in_progress");
const allDone = todos.length > 0 && done === todos.length;
const summary = current?.activeForm || current?.content || todos[todos.length - 1]?.content || "";
const [open, setOpen] = useState(() => loadOpenState(stateKey, shouldOpenTodoPanelByDefault(todos)));
const wasAllDoneRef = useRef(allDone);
useEffect(() => {
setOpen(!allDone);
}, [todoKey, allDone]);
if (allDone && !wasAllDoneRef.current) {
saveOpenState(stateKey, false);
setOpen(false);
}
wasAllDoneRef.current = allDone;
}, [allDone, stateKey]);
useEffect(() => {
if (!open) return;
@@ -45,7 +87,13 @@ export function TodoPanel({
role="region"
headerActions={
<>
<PromptHeaderAction onClick={() => setOpen((value) => !value)}>
<PromptHeaderAction
onClick={() => setOpen((value) => {
const next = !value;
saveOpenState(stateKey, next);
return next;
})}
>
{open ? t("common.collapse") : t("common.expand")}
</PromptHeaderAction>
{allDone && (
+8 -2
View File
@@ -41,7 +41,7 @@ function splitPreview(text: string, n: number): { preview: string; total: number
// ToolCard renders one tool call. `subcalls` are sub-agent calls nested under a
// `task` card (their ParentID points at this call); they render inline, live, so
// the sub-agent's work is visible as it happens.
export const ToolCard = memo(function ToolCard({ item, subcalls, tabId }: { item: ToolItem; subcalls?: ToolItem[]; tabId?: string }) {
export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayName }: { item: ToolItem; subcalls?: ToolItem[]; tabId?: string; displayName?: string }) {
const t = useT();
const nested = subcalls ?? [];
const hasNested = nested.length > 0;
@@ -131,7 +131,7 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId }: { item
{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>}
<span className="tool__name">{item.name}</span>
<span className="tool__name">{displayName ?? item.name}</span>
{subject && <span className="tool__subject">{subject}</span>}
</span>
{profileText && <span className="tool__profile">{profileText}</span>}
@@ -142,6 +142,12 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId }: { item
<ChevronRight size={12} />
</span>
)}
{item.status !== "running" && (
<span
className={`tool__dot${item.status === "done" ? " tool__dot--ok" : ""}${item.status === "error" ? " tool__dot--err" : ""}${item.status === "stopped" ? " tool__dot--stopped" : ""}`}
aria-hidden="true"
/>
)}
</button>
<div ref={toolBodyRef} className="tool__body">
@@ -0,0 +1,141 @@
import { memo, useRef, useState } from "react";
import { ChevronRight } from "lucide-react";
import { useT } from "../lib/i18n";
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
import type { Item } from "../lib/useController";
import { ToolCard } from "./ToolCard";
type ToolItem = Extract<Item, { kind: "tool" }>;
export type ToolGroupKind = "explore" | "modify" | "delegate" | "shell";
const SHELL_TOOLS = new Set(["bash", "bash_output", "wait", "waitJob", "kill_shell"]);
const EXPLORE_TOOLS = new Set(["read_file", "ls", "grep", "glob", "web_fetch", "code_index", "read_skill", "connect_tool_source"]);
const MODIFY_TOOLS = new Set(["write_file", "edit_file", "multi_edit", "move_file", "delete_range", "delete_symbol", "notebook_edit"]);
const DELEGATE_TOOLS = new Set(["task", "run_skill", "explore", "research", "review", "security_review"]);
export function toolGroupKind(item: ToolItem): ToolGroupKind | null {
if (item.parentId || item.name === "todo_write" || item.name === "exit_plan_mode") return null;
if (SHELL_TOOLS.has(item.name)) return "shell";
if (EXPLORE_TOOLS.has(item.name)) return "explore";
if (MODIFY_TOOLS.has(item.name)) return "modify";
if (DELEGATE_TOOLS.has(item.name)) return "delegate";
return item.readOnly ? "explore" : "modify";
}
export function isCreationGroupableTool(item: ToolItem): boolean {
return item.status !== "running" && toolGroupKind(item) !== null;
}
function count(items: ToolItem[], names: readonly string[]): number {
return items.filter((item) => names.includes(item.name)).length;
}
function titleFor(kind: ToolGroupKind, t: ReturnType<typeof useT>): string {
switch (kind) {
case "explore": return t("creation.toolGroup.explore");
case "modify": return t("creation.toolGroup.modify");
case "delegate": return t("creation.toolGroup.delegate");
case "shell": return t("creation.toolGroup.shell");
}
}
function groupSummary(kind: ToolGroupKind, items: ToolItem[], t: ReturnType<typeof useT>): string {
const parts: string[] = [];
if (kind === "explore") {
const readCount = count(items, ["read_file", "ls", "web_fetch", "read_skill"]);
const searchCount = count(items, ["grep", "glob", "code_index"]);
const otherCount = items.length - readCount - searchCount;
if (readCount > 0) parts.push(t("creation.toolStat.read", { n: readCount }));
if (searchCount > 0) parts.push(t("creation.toolStat.search", { n: searchCount }));
if (otherCount > 0) parts.push(t("creation.toolStat.other", { n: otherCount }));
} else if (kind === "modify") {
const writeCount = count(items, ["write_file"]);
const editCount = count(items, ["edit_file", "multi_edit", "notebook_edit"]);
const moveCount = count(items, ["move_file"]);
const deleteCount = count(items, ["delete_range", "delete_symbol"]);
const otherCount = items.length - writeCount - editCount - moveCount - deleteCount;
if (writeCount > 0) parts.push(t("creation.toolStat.write", { n: writeCount }));
if (editCount > 0) parts.push(t("creation.toolStat.edit", { n: editCount }));
if (moveCount > 0) parts.push(t("creation.toolStat.move", { n: moveCount }));
if (deleteCount > 0) parts.push(t("creation.toolStat.delete", { n: deleteCount }));
if (otherCount > 0) parts.push(t("creation.toolStat.other", { n: otherCount }));
} else if (kind === "delegate") {
const taskCount = count(items, ["task", "run_skill", "explore", "research", "review", "security_review"]);
const otherCount = items.length - taskCount;
if (taskCount > 0) parts.push(t("creation.toolStat.task", { n: taskCount }));
if (otherCount > 0) parts.push(t("creation.toolStat.other", { n: otherCount }));
} else {
const commandCount = count(items, ["bash"]);
const checkCount = count(items, ["bash_output", "wait", "waitJob"]);
const stopCount = count(items, ["kill_shell"]);
const otherCount = items.length - commandCount - checkCount - stopCount;
if (commandCount > 0) parts.push(t("creation.toolStat.command", { n: commandCount }));
if (checkCount > 0) parts.push(t("creation.toolStat.check", { n: checkCount }));
if (stopCount > 0) parts.push(t("creation.toolStat.stop", { n: stopCount }));
if (otherCount > 0) parts.push(t("creation.toolStat.other", { n: otherCount }));
}
return parts.join(", ");
}
function titleCaseName(name: string): string {
return name
.replace(/[-_]+/g, " ")
.split(" ")
.filter(Boolean)
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
.join(" ");
}
function toolDisplayName(name: string): string {
switch (name) {
case "read_file": return "Read";
case "ls": return "List";
case "web_fetch": return "Web Fetch";
case "code_index": return "Code Index";
case "write_file": return "Write";
case "edit_file": return "Edit";
case "multi_edit": return "Multi Edit";
case "move_file": return "Move";
case "bash": return "Shell";
case "bash_output": return "Shell Output";
case "kill_shell": return "Kill Shell";
case "wait":
case "waitJob": return "Wait";
default: return titleCaseName(name);
}
}
export const ToolGroup = memo(function ToolGroup({
kind,
items,
subcalls,
tabId,
}: {
kind: ToolGroupKind;
items: ToolItem[];
subcalls: ReadonlyMap<string, ToolItem[]>;
tabId?: string;
}) {
const t = useT();
const [open, setOpen] = useState(false);
const bodyRef = useRef<HTMLDivElement>(null);
useGSAPCollapse(bodyRef, open);
if (items.length === 0) return null;
return (
<div className={`tool-group tool-group--${kind}${open ? " tool-group--open" : ""}`} data-kind={kind} data-entrance={items[0]?.id}>
<button type="button" className="tool-group__head" onClick={() => setOpen((value) => !value)} aria-expanded={open}>
<span className="tool-group__title">{titleFor(kind, t)}</span>
<span className="tool-group__summary">{groupSummary(kind, items, t)}</span>
<ChevronRight className={`tool-group__chevron${open ? " tool-group__chevron--open" : ""}`} size={12} />
</button>
<div ref={bodyRef} className="tool-group__body">
{items.map((item) => (
<ToolCard key={item.id} item={item} subcalls={subcalls.get(item.id)} tabId={tabId} displayName={toolDisplayName(item.name)} />
))}
</div>
</div>
);
});
+222 -105
View File
@@ -5,15 +5,16 @@ import { useT } from "../lib/i18n";
import { AssistantMessage, TurnActions, UserMessage } from "./Message";
import { ProcessCompactIcon, ProcessPhaseIcon } from "./ProcessCard";
import { ToolCard } from "./ToolCard";
import { ChevronRight } from "lucide-react";
import { ArrowDown, ChevronRight } from "lucide-react";
import { Welcome } from "./Welcome";
import { ReadOnlyBatch } from "./ReadOnlyBatch";
import { ToolGroup, isCreationGroupableTool, toolGroupKind, type ToolGroupKind } from "./ToolGroup";
import { getDisplayMode, onDisplayModeChange, type DisplayMode } from "../lib/displayMode";
import { isReadOnlyTool } from "../lib/useController";
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
import { useEntranceAnimation } from "../lib/useEntranceAnimation";
import { useScrollManager } from "../lib/useScrollManager";
import { buildTurnGroups, compactQuestionText, questionAnchorId, scrollVersion, warmUserPreview, type QuestionAnchor, type TurnGroup } from "../lib/transcriptGrouping";
import { buildStepGroups, buildTurnGroups, compactQuestionText, createWarmLayerState, questionAnchorId, questionTurnsById, scrollVersion, warmColdPageForTurn, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination, warmUserPreview, type QuestionAnchor, type TurnGroup, type WarmLayerState } from "../lib/transcriptGrouping";
type ToolItem = Extract<Item, { kind: "tool" }>;
type AssistantItem = Extract<Item, { kind: "assistant" }>;
@@ -27,11 +28,13 @@ const LiveAssistantMessage = memo(function LiveAssistantMessage({
defaultExpanded = false,
expandWhileStreaming = true,
truncateStreamingReasoning = false,
creationMode = false,
}: {
item: AssistantItem;
defaultExpanded?: boolean;
expandWhileStreaming?: boolean;
truncateStreamingReasoning?: boolean;
creationMode?: boolean;
}) {
const live = useContext(LiveStreamContext);
const shown = useMemo(
@@ -47,6 +50,7 @@ const LiveAssistantMessage = memo(function LiveAssistantMessage({
defaultExpanded={defaultExpanded}
expandWhileStreaming={expandWhileStreaming}
truncateStreamingReasoning={truncateStreamingReasoning}
creationMode={creationMode}
/>
);
});
@@ -86,8 +90,15 @@ export function Transcript({
running = false,
questionNavigator = true,
welcomeVariant = "default",
creationMode = false,
actionHoverMenus = false,
rewindSignal = 0,
revealSignal = 0,
hydrating = false,
hasOlderHistory = false,
olderHistoryCount = 0,
loadingOlderHistory = false,
onLoadOlderHistory,
}: {
items: Item[];
live?: LiveStream;
@@ -102,22 +113,35 @@ export function Transcript({
running?: boolean;
questionNavigator?: boolean;
welcomeVariant?: "default" | "creation";
creationMode?: boolean;
actionHoverMenus?: boolean;
rewindSignal?: number;
revealSignal?: number;
hydrating?: boolean;
hasOlderHistory?: boolean;
olderHistoryCount?: number;
loadingOlderHistory?: boolean;
onLoadOlderHistory?: () => void;
}) {
const t = useT();
const {
scrollRef,
stick,
onScroll,
isAtBottom,
smoothScrollTo,
scrollToBottomAfterLayout,
trackQuestions,
repinIfWasPinned,
scheduleRepinIfWasPinned,
resizeFrame,
lastClientHeight,
lastFooterHeight,
} = useScrollManager();
const autoScrollFrame = useRef<number | null>(null);
const pendingRevealBottomScroll = useRef(false);
const pendingQuestionJump = useRef<QuestionAnchor | null>(null);
const sessionKey = useMemo(() => `${items[0]?.id ?? ""}|${items[items.length - 1]?.id ?? ""}`, [items]);
const warmLayerSessionKey = useMemo(() => `${tabId ?? ""}|${revealSignal}|${items[0]?.id ?? ""}`, [items, revealSignal, tabId]);
const entranceRef = useEntranceAnimation<HTMLDivElement>(sessionKey, items.length);
const [displayMode, setDisplayMode] = useState<DisplayMode>(() => getDisplayMode());
@@ -128,7 +152,7 @@ export function Transcript({
let turn = 0;
for (const it of items) {
if (it.kind !== "user") continue;
anchors.push({ id: it.id, text: compactQuestionText(it.text), turn });
anchors.push({ id: it.id, text: compactQuestionText(it.text), turn, checkpointTurn: it.checkpointTurn });
turn += 1;
}
return anchors;
@@ -144,7 +168,17 @@ export function Transcript({
// disables auto-scroll when the user had scrolled up in the old tab (#4584).
useEffect(() => {
stick.current = true;
}, [tabId]);
pendingRevealBottomScroll.current = true;
}, [tabId, revealSignal]);
useEffect(() => {
if (!pendingRevealBottomScroll.current || items.length === 0) return;
pendingRevealBottomScroll.current = false;
const frame = requestAnimationFrame(() => {
scrollToBottomAfterLayout(5);
});
return () => cancelAnimationFrame(frame);
}, [items.length, revealSignal, scrollToBottomAfterLayout, tabId]);
// Auto-scroll to bottom during streaming. Coalesce fast token/reasoning
// updates into one layout read/write per animation frame.
@@ -174,11 +208,12 @@ export function Transcript({
const el = scrollRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
lastClientHeight.current = el.clientHeight;
const observer = new ResizeObserver(() => {
const previous = lastClientHeight.current ?? el.clientHeight;
lastClientHeight.current = el.clientHeight;
const observer = new ResizeObserver((entries) => {
const height = entries[0]?.contentRect.height ?? el.clientHeight;
const previous = lastClientHeight.current ?? height;
lastClientHeight.current = height;
if (items.length === 0) return;
repinIfWasPinned(el.clientHeight - previous);
scheduleRepinIfWasPinned(height - previous);
});
observer.observe(el);
return () => {
@@ -188,7 +223,7 @@ export function Transcript({
resizeFrame.current = null;
}
};
}, [items.length]);
}, [items.length, scheduleRepinIfWasPinned]);
// Footer height changes → smooth scroll repin with GSAP.
useEffect(() => {
@@ -197,8 +232,8 @@ export function Transcript({
const previous = lastFooterHeight.current ?? footerHeight;
lastFooterHeight.current = footerHeight;
if (items.length === 0) return;
repinIfWasPinned(previous - footerHeight);
}, [footerHeight, items.length]);
scheduleRepinIfWasPinned(previous - footerHeight);
}, [footerHeight, items.length, scheduleRepinIfWasPinned]);
// After a non-fork rewind, scroll to the last user message (the
// rewound-to point) so the user knows where they are.
@@ -227,8 +262,12 @@ export function Transcript({
}, [items]);
// ── Layer state ────────────────────────────────────────────────────────────
const [expandedWarmTurns, setExpandedWarmTurns] = useState<Set<number>>(new Set());
const [coldPage, setColdPage] = useState(0);
const [warmLayerState, setWarmLayerState] = useState<WarmLayerState>(() => createWarmLayerState(warmLayerSessionKey));
const defaultWarmLayerState = useMemo<WarmLayerState>(() => createWarmLayerState(warmLayerSessionKey), [warmLayerSessionKey]);
const activeWarmLayerState = warmLayerState.sessionKey === warmLayerSessionKey
? warmLayerState
: defaultWarmLayerState;
const { expandedWarmTurns, coldPage } = activeWarmLayerState;
// Compute turn groups from the structural item list. Streaming text updates
// keep the same items[] reference, so this stays out of the token hot path.
@@ -247,9 +286,20 @@ export function Transcript({
}, [items]);
// How many turns are in the cold zone (not yet shown).
const warmTurnCount = turnGroups.length - Math.min(turnGroups.length, HOT_TURNS);
const shownWarmStart = Math.max(0, warmTurnCount - coldPage * WARM_PAGE_SIZE);
const coldTurnCount = shownWarmStart;
const { warmStartTurn, warmEndTurn, coldTurnCount } = useMemo(
() => warmPagination({ turnCount: turnGroups.length, hotTurns: HOT_TURNS, pageSize: WARM_PAGE_SIZE, coldPage }),
[coldPage, turnGroups.length],
);
useLayoutEffect(() => {
const question = pendingQuestionJump.current;
if (!question) return;
const node = document.getElementById(questionAnchorId(question.id));
if (!node) return;
pendingQuestionJump.current = null;
stick.current = false;
smoothScrollTo(node, 12);
}, [expandedWarmTurns, smoothScrollTo, stick, warmStartTurn]);
// ── The turn action menu ──────────────────────────────────────────────────
const [openAction, setOpenAction] = useState<OpenTurnAction | null>(null);
@@ -263,28 +313,36 @@ export function Transcript({
return () => document.removeEventListener("mousedown", onDown);
}, [openAction]);
const userTurn = useMemo(() => new Map(questions.map((question) => [question.id, question.turn])), [questions]);
const userTurn = useMemo(() => questionTurnsById(questions), [questions]);
const checkpointsByTurn = useMemo(() => new Map(checkpoints.map((checkpoint) => [checkpoint.turn, checkpoint])), [checkpoints]);
// ── JumpBar integration ───────────────────────────────────────────────────
const jumpToQuestion = (question: QuestionAnchor) => {
const node = document.getElementById(questionAnchorId(question.id));
if (!node) return;
pendingQuestionJump.current = null;
stick.current = false;
smoothScrollTo(node, 12);
};
const handleJumpToQuestion = useCallback((question: QuestionAnchor) => {
pendingQuestionJump.current = question;
// Auto-expand the warm turn when jumping to an old question.
const warmTurnStart = turnGroups.length - HOT_TURNS;
if (question.turn < warmTurnStart) {
setExpandedWarmTurns((prev) => {
if (prev.has(question.turn)) return prev;
return new Set([...prev, question.turn]);
const neededColdPage = warmColdPageForTurn({
turn: question.turn,
turnCount: turnGroups.length,
hotTurns: HOT_TURNS,
pageSize: WARM_PAGE_SIZE,
});
setWarmLayerState((prev) => {
const paged = warmLayerWithColdPageAtLeast(prev, warmLayerSessionKey, neededColdPage);
return warmLayerWithExpandedTurn(paged, warmLayerSessionKey, question.turn, true);
});
}
jumpToQuestion(question);
}, [turnGroups.length]);
}, [turnGroups.length, warmLayerSessionKey]);
// ── Hot zone: fully rendered from hotStartIdx to end ─────────────────────
// Memoized separately from the assembly so streaming tokens don't rebuild
@@ -309,37 +367,7 @@ export function Transcript({
// Each completed non-final step is folded into "Processed".
const stepGroups = useMemo(() => {
if (displayMode === "standard") return null;
const groups: { items: Item[]; isFinal: boolean; isComplete: boolean }[] = [];
let current: Item[] = [];
for (let i = hotStartIdx; i < items.length; i++) {
const it = items[i];
if (it.kind === "user") {
if (current.length > 0) {
const first = current[0];
const isFinal = first.kind === "assistant" && !first.streaming && first.text.trim() !== "";
groups.push({ items: current, isFinal, isComplete: true });
current = [];
}
groups.push({ items: [it], isFinal: false, isComplete: true });
continue;
}
if (it.kind === "assistant") {
if (current.length > 0) {
groups.push({ items: current, isFinal: false, isComplete: true });
current = [];
}
current.push(it);
} else {
current.push(it);
}
}
if (current.length > 0) {
const first = current[0];
const isFinal = first.kind === "assistant" && !first.streaming && first.text.trim() !== "";
groups.push({ items: current, isFinal, isComplete: false });
}
return groups;
return buildStepGroups(items, hotStartIdx);
}, [displayMode, hotStartIdx, items]);
const hotZoneNodes = useMemo<ReactNode[]>(() => {
@@ -389,6 +417,7 @@ export function Transcript({
mode={displayMode}
subcalls={subcallsByParent}
tabId={tabId}
creationMode={creationMode}
/>,
);
collapseBatch = [];
@@ -444,6 +473,7 @@ export function Transcript({
mode={displayMode}
subcalls={subcallsByParent}
tabId={tabId}
creationMode={creationMode}
/>,
);
} else if (nonAssistantItems.length > 0) {
@@ -466,6 +496,7 @@ export function Transcript({
defaultExpanded={false}
expandWhileStreaming={false}
truncateStreamingReasoning={true}
creationMode={creationMode}
/>,
);
if (!it.streaming && it.text.trim() !== "") {
@@ -479,14 +510,32 @@ export function Transcript({
} else {
// Standard mode: flat rendering
const roBatch: ToolItem[] = [];
const toolBatch: ToolItem[] = [];
let toolBatchKind: ToolGroupKind | null = null;
const flushRO = () => {
if (roBatch.length === 0) return;
out.push(<ReadOnlyBatch key={`rob-${roBatch[0].id}`} items={[...roBatch]} subcalls={subcallsByParent} tabId={tabId} />);
roBatch.length = 0;
};
const flushToolBatch = () => {
if (!toolBatchKind || toolBatch.length === 0) return;
out.push(<ToolGroup key={`tg-${toolBatch[0].id}`} kind={toolBatchKind} items={[...toolBatch]} subcalls={subcallsByParent} tabId={tabId} />);
toolBatch.length = 0;
toolBatchKind = null;
};
for (let i = hotStartIdx; i < items.length; i++) {
const it = items[i];
if (creationMode && it.kind === "tool" && isCreationGroupableTool(it as ToolItem)) {
const kind = toolGroupKind(it as ToolItem);
if (kind) {
if (toolBatchKind && toolBatchKind !== kind) flushToolBatch();
toolBatchKind = kind;
toolBatch.push(it as ToolItem);
continue;
}
}
if (
!creationMode &&
it.kind === "tool" &&
!it.parentId &&
it.status !== "running" &&
@@ -497,6 +546,7 @@ export function Transcript({
roBatch.push(it as ToolItem);
continue;
}
flushToolBatch();
flushRO();
switch (it.kind) {
case "user": {
@@ -521,7 +571,7 @@ export function Transcript({
break;
}
case "assistant":
out.push(<LiveAssistantMessage key={it.id} item={it as AssistantItem} defaultExpanded={false} />);
out.push(<LiveAssistantMessage key={it.id} item={it as AssistantItem} defaultExpanded={false} creationMode={creationMode} />);
if (!it.streaming && it.text.trim() !== "") {
actionText = it.text;
actionReady = true;
@@ -538,62 +588,85 @@ export function Transcript({
case "compaction": out.push(<CompactionCard key={it.id} item={it} />); break;
}
}
flushToolBatch();
flushRO();
if (!running) pushTurnActions();
}
return out;
}, [hotStartIdx, items, openAction, actionPending, rewindDisabled, running, onEditPrompt, onRewind, subcallsByParent, userTurn, checkpointsByTurn, displayMode, stepGroups, tabId, actionHoverMenus]);
}, [hotStartIdx, items, openAction, actionPending, rewindDisabled, running, onEditPrompt, onRewind, subcallsByParent, userTurn, checkpointsByTurn, displayMode, stepGroups, tabId, actionHoverMenus, creationMode]);
// ── Assemble rendered output ──────────────────────────────────────────────
// Warm/cold zone is a separate memo'd WarmZone component so streaming tokens
// don't rebuild it. The hot zone uses LiveAssistantMessage (reads live from
// LiveStreamContext) so streaming updates are captured immediately.
return (
<div
className={`transcript${empty ? " transcript--empty" : ""}`}
ref={scrollRef}
onScroll={onScroll}
>
{empty && <Welcome onPrompt={onPrompt} variant={welcomeVariant} />}
<div className="transcript-shell">
<div
className={`transcript${empty ? " transcript--empty" : ""}`}
ref={scrollRef}
onScroll={onScroll}
>
{empty && !hydrating && <Welcome onPrompt={onPrompt} variant={welcomeVariant} />}
<LiveStreamContext.Provider value={live}>
{hasOlderHistory && (
<button
type="button"
className="warm-collapse"
onClick={onLoadOlderHistory}
disabled={loadingOlderHistory}
>
{loadingOlderHistory ? t("common.loading") : t("transcript.showEarlierHistory", { n: olderHistoryCount })}
</button>
)}
{turnGroups.length > HOT_TURNS && (
<WarmZone
turnGroups={turnGroups}
expandedWarmTurns={expandedWarmTurns}
warmStartTurn={warmStartTurn}
warmEndTurn={warmEndTurn}
coldTurnCount={coldTurnCount}
scrollRef={scrollRef}
warmItems={items}
warmSubcalls={subcallsByParent}
warmUserTurn={userTurn}
warmCheckpoints={checkpointsByTurn}
warmOpenAction={openAction}
warmActionPending={actionPending}
warmRewindDisabled={rewindDisabled}
warmActionHoverMenus={actionHoverMenus}
warmOnRewind={onRewind}
warmSetOpenAction={setOpenAction}
warmOnEdit={onEditPrompt}
tabId={tabId}
creationMode={creationMode}
onToggleColdPage={() => setWarmLayerState((prev) => warmLayerWithNextColdPage(prev, warmLayerSessionKey))}
onToggleWarmTurn={(g, expand) => {
setWarmLayerState((prev) => warmLayerWithExpandedTurn(prev, warmLayerSessionKey, g, expand));
}}
/>
)}
<div ref={entranceRef}>
{hotZoneNodes}
</div>
</LiveStreamContext.Provider>
</div>
{!empty && showQuestionNav && (
<QuestionJumpBar questions={questions} onJump={handleJumpToQuestion} />
)}
<LiveStreamContext.Provider value={live}>
{turnGroups.length > HOT_TURNS && (
<WarmZone
turnGroups={turnGroups}
expandedWarmTurns={expandedWarmTurns}
shownWarmStart={shownWarmStart}
coldTurnCount={coldTurnCount}
scrollRef={scrollRef}
warmItems={items}
warmSubcalls={subcallsByParent}
warmUserTurn={userTurn}
warmCheckpoints={checkpointsByTurn}
warmOpenAction={openAction}
warmActionPending={actionPending}
warmRewindDisabled={rewindDisabled}
warmActionHoverMenus={actionHoverMenus}
warmOnRewind={onRewind}
warmSetOpenAction={setOpenAction}
warmOnEdit={onEditPrompt}
tabId={tabId}
onToggleColdPage={() => setColdPage((p) => p + 1)}
onToggleWarmTurn={(g, expand) => {
setExpandedWarmTurns((prev) => {
const next = new Set(prev);
if (expand) next.add(g); else next.delete(g);
return next;
});
}}
/>
)}
<div ref={entranceRef}>
{hotZoneNodes}
</div>
</LiveStreamContext.Provider>
{!empty && !isAtBottom && (
<button
type="button"
className="transcript__jump-bottom"
onClick={() => scrollToBottomAfterLayout(2)}
aria-label={t("transcript.jumpToBottom")}
title={t("transcript.jumpToBottom")}
>
<ArrowDown size={18} strokeWidth={2.2} aria-hidden="true" />
</button>
)}
</div>
);
}
@@ -605,7 +678,8 @@ export function Transcript({
const WarmZone = memo(function WarmZone({
turnGroups,
expandedWarmTurns,
shownWarmStart,
warmStartTurn,
warmEndTurn,
coldTurnCount,
scrollRef,
warmItems,
@@ -620,12 +694,14 @@ const WarmZone = memo(function WarmZone({
warmSetOpenAction,
warmOnEdit,
tabId,
creationMode,
onToggleColdPage,
onToggleWarmTurn,
}: {
turnGroups: TurnGroup[];
expandedWarmTurns: ReadonlySet<number>;
shownWarmStart: number;
warmStartTurn: number;
warmEndTurn: number;
coldTurnCount: number;
scrollRef: React.RefObject<HTMLDivElement | null>;
warmItems: readonly Item[];
@@ -640,6 +716,7 @@ const WarmZone = memo(function WarmZone({
warmSetOpenAction: (action: OpenTurnAction | null) => void;
warmOnEdit?: (turn: number, displayText: string, submitText?: string) => boolean | void | Promise<boolean | void>;
tabId?: string;
creationMode?: boolean;
onToggleColdPage: () => void;
onToggleWarmTurn: (g: number, expand: boolean) => void;
}) {
@@ -661,10 +738,8 @@ const WarmZone = memo(function WarmZone({
}
// 2. Warm zone: collapsed/expanded warm turn cards.
let warmStartTurn = 0;
if (turnGroups.length > HOT_TURNS) {
warmStartTurn = turnGroups.length - HOT_TURNS - shownWarmStart;
for (let g = warmStartTurn; g < turnGroups.length - HOT_TURNS; g++) {
for (let g = warmStartTurn; g < warmEndTurn; g++) {
const group = turnGroups[g];
if (!group) continue;
const expanded = expandedWarmTurns.has(g);
@@ -697,6 +772,7 @@ const WarmZone = memo(function WarmZone({
setOpenAction={warmSetOpenAction}
onEdit={warmOnEdit}
tabId={tabId}
creationMode={creationMode}
/>
</WarmTurnCard>,
);
@@ -743,6 +819,7 @@ function WarmTurnItems({
setOpenAction,
onEdit,
tabId,
creationMode = false,
}: {
startIdx: number;
endIdx: number;
@@ -758,6 +835,7 @@ function WarmTurnItems({
setOpenAction: (action: OpenTurnAction | null) => void;
onEdit?: (turn: number, displayText: string, submitText?: string) => boolean | void | Promise<boolean | void>;
tabId?: string;
creationMode?: boolean;
}) {
const nodes: React.ReactNode[] = [];
let actionText = "";
@@ -790,20 +868,38 @@ function WarmTurnItems({
// Group consecutive completed read-only tools into ReadOnlyBatch
const roBatch: ToolItem[] = [];
const toolBatch: ToolItem[] = [];
let toolBatchKind: ToolGroupKind | null = null;
const flushRO = () => {
if (roBatch.length === 0) return;
nodes.push(<ReadOnlyBatch key={`rob-${roBatch[0].id}`} items={[...roBatch]} subcalls={subcalls} tabId={tabId} />);
roBatch.length = 0;
};
const flushToolBatch = () => {
if (!toolBatchKind || toolBatch.length === 0) return;
nodes.push(<ToolGroup key={`tg-${toolBatch[0].id}`} kind={toolBatchKind} items={[...toolBatch]} subcalls={subcalls} tabId={tabId} />);
toolBatch.length = 0;
toolBatchKind = null;
};
for (let i = startIdx; i < endIdx && i < items.length; i++) {
const it = items[i];
// Completed read-only tools → batch into ReadOnlyBatch
if (it.kind === "tool" && !it.parentId && it.name !== "todo_write" && it.name !== "exit_plan_mode" && isReadOnlyTool(it.name)) {
if (creationMode && it.kind === "tool" && isCreationGroupableTool(it as ToolItem)) {
const kind = toolGroupKind(it as ToolItem);
if (kind) {
if (toolBatchKind && toolBatchKind !== kind) flushToolBatch();
toolBatchKind = kind;
toolBatch.push(it as ToolItem);
continue;
}
}
if (!creationMode && it.kind === "tool" && !it.parentId && it.name !== "todo_write" && it.name !== "exit_plan_mode" && isReadOnlyTool(it.name)) {
roBatch.push(it as ToolItem);
continue;
}
flushToolBatch();
flushRO();
switch (it.kind) {
@@ -828,7 +924,7 @@ function WarmTurnItems({
break;
}
case "assistant": {
nodes.push(<AssistantMessage key={it.id} item={it} defaultExpanded={false} />);
nodes.push(<AssistantMessage key={it.id} item={it} defaultExpanded={false} creationMode={creationMode} />);
if (!it.streaming && it.text.trim() !== "") {
actionText = it.text;
actionReady = true;
@@ -847,6 +943,7 @@ function WarmTurnItems({
case "compaction": nodes.push(<CompactionCard key={it.id} item={it} />); break;
}
}
flushToolBatch();
flushRO();
pushTurnActions();
return nodes;
@@ -918,9 +1015,10 @@ type TurnCollapseProps = {
mode: DisplayMode;
subcalls: Map<string, ToolItem[]>;
tabId?: string;
creationMode?: boolean;
};
function TurnCollapse({ items, durationMs, mode, subcalls, tabId }: TurnCollapseProps) {
function TurnCollapse({ items, durationMs, mode, subcalls, tabId, creationMode = false }: TurnCollapseProps) {
const t = useT();
const [open, setOpen] = useState(false);
const bodyRef = useRef<HTMLDivElement>(null);
@@ -960,16 +1058,34 @@ function TurnCollapse({ items, durationMs, mode, subcalls, tabId }: TurnCollapse
// Pre-compute body: group consecutive completed read-only tools into ReadOnlyBatch
const body: ReactNode[] = [];
const roBatch: ToolItem[] = [];
const toolBatch: ToolItem[] = [];
let toolBatchKind: ToolGroupKind | null = null;
const flushRO = () => {
if (roBatch.length === 0) return;
body.push(<ReadOnlyBatch key={`rob-${roBatch[0].id}`} items={[...roBatch]} subcalls={subcalls} tabId={tabId} />);
roBatch.length = 0;
};
const flushToolBatch = () => {
if (!toolBatchKind || toolBatch.length === 0) return;
body.push(<ToolGroup key={`tg-${toolBatch[0].id}`} kind={toolBatchKind} items={[...toolBatch]} subcalls={subcalls} tabId={tabId} />);
toolBatch.length = 0;
toolBatchKind = null;
};
for (const it of displayItems) {
if (it.kind === "tool" && !it.parentId && it.name !== "todo_write" && it.name !== "exit_plan_mode" && it.status !== "running" && isReadOnlyTool(it.name)) {
if (creationMode && it.kind === "tool" && isCreationGroupableTool(it as ToolItem)) {
const kind = toolGroupKind(it as ToolItem);
if (kind) {
if (toolBatchKind && toolBatchKind !== kind) flushToolBatch();
toolBatchKind = kind;
toolBatch.push(it as ToolItem);
continue;
}
}
if (!creationMode && it.kind === "tool" && !it.parentId && it.name !== "todo_write" && it.name !== "exit_plan_mode" && it.status !== "running" && isReadOnlyTool(it.name)) {
roBatch.push(it as ToolItem);
continue;
}
flushToolBatch();
flushRO();
switch (it.kind) {
case "tool":
@@ -981,11 +1097,12 @@ function TurnCollapse({ items, durationMs, mode, subcalls, tabId }: TurnCollapse
case "phase": body.push(<PhaseCard key={it.id} text={it.text} />); break;
case "assistant": {
const displayItem = it;
body.push(<AssistantMessage key={it.id} item={displayItem as AssistantItem} />);
body.push(<AssistantMessage key={it.id} item={displayItem as AssistantItem} creationMode={creationMode} />);
break;
}
}
}
flushToolBatch();
flushRO();
return (
+223 -104
View File
@@ -26,8 +26,20 @@ import {
} from "lucide-react";
import { app } from "../lib/bridge";
import { useT } from "../lib/i18n";
import { loadLayoutSize, saveLayoutSize } from "../lib/layoutPreferences";
import type { DirEntry, FilePreview, GitCommitView, GitCommitDetailView, WorkspaceChangeView } from "../lib/types";
import {
clampWorkspaceSplitTreeWidth,
initialWorkspaceSplitTreeWidth,
resolveWorkspaceSplitTreeWidth,
shouldInitializeWorkspaceSplitOnFileSelect,
type WorkspaceSplitTreeWidthMode,
workspaceSplitCanFit,
workspaceSplitTreeWidthFromPointer,
} from "../lib/workspaceSplit";
import { createRafResizeUpdater } from "../lib/resizeDrag";
import { closeWorkspacePreviewTab } from "../lib/workspacePreviewTabs";
import { shouldScrollWorkspaceTreeSelection } from "../lib/workspaceTreeReveal";
import { mergeWorkspaceSearchResults } from "../lib/workspaceTreeSearch";
import type { DirEntry, FilePreview, GitCommitView, GitCommitDetailView, WorkspaceChangesView } from "../lib/types";
import { formatWorkspaceReference, WORKSPACE_REF_DRAG_TYPE } from "../lib/workspaceDrag";
import { cleanGitDiff } from "../lib/diff";
import { CodeViewer } from "./CodeViewer";
@@ -37,12 +49,11 @@ import { Markdown } from "./Markdown";
import { Tooltip } from "./Tooltip";
import { AnchoredPopover } from "./AnchoredPopover";
const WORKSPACE_TREE_MIN_WIDTH = 300;
const WORKSPACE_TREE_MIN_WIDTH = 140;
const WORKSPACE_TREE_DEFAULT_WIDTH = 300;
const WORKSPACE_TREE_MAX_WIDTH = 340;
const WORKSPACE_PREVIEW_MIN_WIDTH = 360;
const WORKSPACE_TREE_RAIL_WIDTH = 44;
const WORKSPACE_PREVIEW_MIN_WIDTH = 140;
const WORKSPACE_PREVIEW_TARGET_WIDTH = 360;
const WORKSPACE_DUAL_PANEL_MIN_WIDTH = WORKSPACE_TREE_MIN_WIDTH + WORKSPACE_PREVIEW_MIN_WIDTH;
const WORKSPACE_DUAL_PANEL_TARGET_WIDTH = WORKSPACE_TREE_DEFAULT_WIDTH + WORKSPACE_PREVIEW_TARGET_WIDTH;
const WORKSPACE_CONTEXT_MENU_FILE_HEIGHT = 136;
const WORKSPACE_CONTEXT_MENU_REF_HEIGHT = 92;
@@ -55,20 +66,13 @@ type WorkspaceChangeListEntry = { key: string; path: string; meta: string; time:
type WorkspaceChangeListRequest = { id: number; changes: WorkspaceChangeListEntry[] };
function clampWorkspaceTreeWidth(width: number, panelWidth?: number): number {
const maxForPanel =
typeof panelWidth === "number" && Number.isFinite(panelWidth)
? Math.max(WORKSPACE_TREE_MIN_WIDTH, panelWidth - WORKSPACE_PREVIEW_MIN_WIDTH)
: WORKSPACE_TREE_MAX_WIDTH;
const max = Math.min(WORKSPACE_TREE_MAX_WIDTH, maxForPanel);
return Math.min(max, Math.max(WORKSPACE_TREE_MIN_WIDTH, Math.round(width)));
}
function loadWorkspaceTreeWidth(): number {
return loadLayoutSize("workspaceTreeWidth", WORKSPACE_TREE_DEFAULT_WIDTH, clampWorkspaceTreeWidth);
}
function saveWorkspaceTreeWidth(width: number): void {
saveLayoutSize("workspaceTreeWidth", width);
return clampWorkspaceSplitTreeWidth({
width,
panelWidth,
railWidth: WORKSPACE_TREE_RAIL_WIDTH,
treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
});
}
function entryPath(dir: string, entry: DirEntry): string {
@@ -241,7 +245,7 @@ export function WorkspacePanel({
const [loadingPreview, setLoadingPreview] = useState(false);
const [viewMode, setViewMode] = useState<"files" | "changed">(initialViewMode);
const [gitHistory, setGitHistory] = useState<GitCommitView[]>([]);
const [workspaceChanges, setWorkspaceChanges] = useState<WorkspaceChangeView[] | null>(null);
const [workspaceChanges, setWorkspaceChanges] = useState<WorkspaceChangesView | null>(null);
const [loadingHistory, setLoadingHistory] = useState(false);
const [expandedCommit, setExpandedCommit] = useState<string | null>(null);
const [commitDetail, setCommitDetail] = useState<GitCommitDetailView | null>(null);
@@ -250,10 +254,12 @@ export function WorkspacePanel({
const [treeMenu, setTreeMenu] = useState<{ x: number; y: number; path: string; isDir: boolean } | null>(null);
const [treeBlankMenuPoint, setTreeBlankMenuPoint] = useState<ContextMenuPoint | null>(null);
const [filter, setFilter] = useState("");
const [searchResults, setSearchResults] = useState<DirEntry[] | null>(null);
const [scopedFilePaths, setScopedFilePaths] = useState<string[] | null>(null);
const [scopedChangeRows, setScopedChangeRows] = useState<WorkspaceChangeListEntry[] | null>(null);
const [treeVisible, setTreeVisible] = useState(true);
const [treeWidth, setTreeWidth] = useState(loadWorkspaceTreeWidth);
const [treeWidth, setTreeWidth] = useState(WORKSPACE_TREE_DEFAULT_WIDTH);
const [treeWidthMode, setTreeWidthMode] = useState<WorkspaceSplitTreeWidthMode>("manual");
const [treeResizing, setTreeResizing] = useState(false);
const [recentOpen, setRecentOpen] = useState(false);
const lastPreviewModeActiveRef = useRef<boolean | null>(null);
@@ -271,6 +277,7 @@ export function WorkspacePanel({
const commitDetailRequestIdRef = useRef(0);
const recentAnchorRef = useRef<HTMLButtonElement>(null);
const openDirsRef = useRef(openDirs);
const pendingTreeRevealPathRef = useRef<string | null>(null);
useEffect(() => {
openDirsRef.current = openDirs;
@@ -307,11 +314,16 @@ export function WorkspacePanel({
try {
const result = await app.WorkspaceChanges(requestTabId);
if (workspaceChangesRequestIdRef.current === requestId && lastWorkspaceTabIdRef.current === requestTabId) {
setWorkspaceChanges(result.files && result.files.length > 0 ? result.files : null);
setWorkspaceChanges({
files: Array.isArray(result?.files) ? result.files : [],
gitAvailable: result?.gitAvailable !== false,
gitErr: result?.gitErr,
gitBranch: result?.gitBranch,
});
}
} catch {
if (workspaceChangesRequestIdRef.current === requestId && lastWorkspaceTabIdRef.current === requestTabId) {
setWorkspaceChanges(null);
setWorkspaceChanges({ files: [], gitAvailable: false });
}
}
}, [tabId]);
@@ -359,7 +371,21 @@ export function WorkspacePanel({
const selectFile = useCallback(
(path: string) => {
onRequestPanelWidth?.(WORKSPACE_DUAL_PANEL_TARGET_WIDTH);
const initializeSplit = shouldInitializeWorkspaceSplitOnFileSelect({
previewVisible: openTabs.length > 0 || selectedPath !== null,
treeVisible,
});
if (initializeSplit) {
setTreeWidth(initialWorkspaceSplitTreeWidth({
panelWidth,
railWidth: WORKSPACE_TREE_RAIL_WIDTH,
savedTreeWidth: null,
treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
}));
setTreeWidthMode("even");
}
pendingTreeRevealPathRef.current = path;
setSelectedPath(path);
setScopedFilePaths((current) => {
if (current) dismissedFileListRequestIdRef.current = lastFileListRequestIdRef.current;
@@ -377,7 +403,7 @@ export function WorkspacePanel({
if (!entriesByDir[dir]) void loadDir(dir);
});
},
[entriesByDir, loadDir, onRequestPanelWidth],
[entriesByDir, loadDir, openTabs.length, panelWidth, selectedPath, treeVisible],
);
useEffect(() => {
@@ -620,12 +646,13 @@ export function WorkspacePanel({
setTreeMenu(null);
if (viewMode === "changed") {
void loadGitHistory();
void loadWorkspaceChanges();
return;
}
const dirs = Array.from(openDirsRef.current);
setEntriesByDir({});
dirs.forEach((dir) => void loadDir(dir));
}, [loadGitHistory, loadDir, viewMode]);
}, [loadGitHistory, loadWorkspaceChanges, loadDir, viewMode]);
const refreshSelected = useCallback(() => {
if (!selectedPath) return;
@@ -679,36 +706,15 @@ export function WorkspacePanel({
[entriesByDir, loadDir],
);
const closeTab = (path: string) => {
if (lastRevealRequestIdRef.current === revealPathRequest?.id && revealPathRequest.path === path) {
dismissedRevealRequestIdRef.current = revealPathRequest.id;
}
if (lastChangeRevealRequestIdRef.current === changeRevealRequest?.id && changeRevealRequest.path === path) {
dismissedChangeRevealRequestIdRef.current = changeRevealRequest.id;
}
setOpenTabs((tabs) => {
const next = tabs.filter((tab) => tab !== path);
if (selectedPath === path) {
const replacement = next[next.length - 1] ?? null;
setSelectedPath(replacement);
if (!replacement) {
setPreview(null);
setTreeVisible(true);
}
setSelectionMenu(null);
setTreeMenu(null);
setRecentOpen(false);
}
return next;
});
};
const breadcrumbDirs = selectedPath ? parentDirs(selectedPath) : [""];
const pathParts = selectedPath?.split("/").filter(Boolean) ?? [];
const sessionChanges = useMemo(
() => workspaceChanges?.filter((c) => c.sources.includes("session")) ?? null,
() => workspaceChanges?.files.filter((c) => c.sources.includes("session")) ?? null,
[workspaceChanges],
);
const workspaceGitWarning = workspaceChanges && (!workspaceChanges.gitAvailable || workspaceChanges.gitErr?.trim())
? t("workspace.gitUnavailable")
: null;
const changedMode = viewMode === "changed";
const currentFileName = selectedPath ? basename(selectedPath) : t("workspace.noFile");
@@ -720,6 +726,22 @@ export function WorkspacePanel({
? scopedChangeRows ? t("context.changedMeta", { count: scopedChangeRows.length }) : shortCwd(cwd) || t("workspace.title")
: currentFileDir;
const recentFiles = useMemo(() => [...openTabs].reverse(), [openTabs]);
useEffect(() => {
const q = filter.trim();
if (!open || viewMode === "changed" || !q || scopedFilePaths) {
setSearchResults(null);
return;
}
let cancelled = false;
app.SearchFileRefs(q).then((results) => {
if (!cancelled) setSearchResults(results);
}).catch(() => {
if (!cancelled) setSearchResults(null);
});
return () => { cancelled = true; };
}, [filter, viewMode, scopedFilePaths, open]);
const flattened = useMemo(() => {
const q = filter.trim().toLowerCase();
if (scopedFilePaths) {
@@ -735,10 +757,10 @@ export function WorkspacePanel({
}
}
if (!q) return null;
return rows
return mergeWorkspaceSearchResults(rows, searchResults)
.filter((row) => row.path.toLowerCase().includes(q))
.sort((a, b) => a.path.localeCompare(b.path));
}, [entriesByDir, filter, scopedFilePaths]);
}, [entriesByDir, filter, scopedFilePaths, searchResults]);
const treeRows = useMemo<TreeRow[]>(() => {
if (flattened) {
@@ -784,34 +806,61 @@ export function WorkspacePanel({
const searchPlaceholder = t(scopedFilePaths ? "workspace.filterReferencedFiles" : changedMode ? "workspace.filterChanges" : "workspace.filter");
const effectiveTreeWidth = useMemo(() => clampWorkspaceTreeWidth(treeWidth, panelWidth), [panelWidth, treeWidth]);
const filePreviewActive = openTabs.length > 0 || selectedPath !== null;
const changeDetailActive = changedMode && expandedCommit !== null;
const previewVisible = changedMode || filePreviewActive;
const selectedFileVisible = selectedPath !== null;
const compactTreeRail =
treeVisible && selectedFileVisible && panelWidth !== undefined && panelWidth < WORKSPACE_DUAL_PANEL_MIN_WIDTH;
const actualTreeVisible = changedMode ? false : treeVisible && !compactTreeRail;
const showTreeRail = previewVisible && (!actualTreeVisible || compactTreeRail) && !changedMode;
const showTreeRail = previewVisible && !changedMode;
const splitPanesFit = useMemo(
() =>
workspaceSplitCanFit({
panelWidth,
railWidth: WORKSPACE_TREE_RAIL_WIDTH,
treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
}),
[panelWidth],
);
const actualTreeVisible = changedMode ? false : treeVisible && (!previewVisible || splitPanesFit);
const previewModeActive = open && (filePreviewActive || changeDetailActive);
const embeddedDockMode = !showViewTabs;
const showFileTools = showViewTabs || filePreviewActive;
const effectiveTreeWidth = useMemo(
() =>
resolveWorkspaceSplitTreeWidth({
mode: treeWidthMode,
currentTreeWidth: treeWidth,
panelWidth,
railWidth: WORKSPACE_TREE_RAIL_WIDTH,
treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
}),
[panelWidth, treeWidth, treeWidthMode],
);
const maxTreeWidthForPanel = useMemo(
() => Math.max(WORKSPACE_TREE_MIN_WIDTH, (panelWidth ?? WORKSPACE_DUAL_PANEL_TARGET_WIDTH) - WORKSPACE_TREE_RAIL_WIDTH - WORKSPACE_PREVIEW_MIN_WIDTH),
[panelWidth],
);
useEffect(() => {
if (!selectedPath || !actualTreeVisible) return;
const selectedIndex = treeRows.findIndex((row) => row.path === selectedPath);
if (selectedIndex !== -1) {
virtualizer.scrollToIndex(selectedIndex, { align: "auto" });
const pendingRevealPath = pendingTreeRevealPathRef.current;
if (!pendingRevealPath) return;
if (!selectedPath || pendingRevealPath !== selectedPath) {
pendingTreeRevealPathRef.current = null;
return;
}
const selectedIndex = treeRows.findIndex((row) => row.path === selectedPath);
if (!shouldScrollWorkspaceTreeSelection({ selectedPath, pendingRevealPath, actualTreeVisible, selectedIndex })) return;
virtualizer.scrollToIndex(selectedIndex, { align: "auto" });
pendingTreeRevealPathRef.current = null;
}, [selectedPath, actualTreeVisible, treeRows, virtualizer]);
const panelStyle = useMemo(
() =>
({
"--workspace-tree-width": `${effectiveTreeWidth}px`,
"--workspace-preview-min-width": compactTreeRail ? "0px" : `${WORKSPACE_PREVIEW_MIN_WIDTH}px`,
"--workspace-preview-min-width": `${WORKSPACE_PREVIEW_MIN_WIDTH}px`,
}) as CSSProperties,
[compactTreeRail, effectiveTreeWidth],
[effectiveTreeWidth],
);
useEffect(() => {
@@ -832,11 +881,48 @@ export function WorkspacePanel({
}
}, [onClose, previewVisible]);
const showTreeEvenSplit = useCallback(() => {
setTreeWidth(initialWorkspaceSplitTreeWidth({
panelWidth,
railWidth: WORKSPACE_TREE_RAIL_WIDTH,
savedTreeWidth: null,
treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
}));
setTreeWidthMode("even");
setTreeVisible(true);
}, [panelWidth]);
const toggleTreeRail = useCallback(() => {
if (actualTreeVisible) {
setTreeVisible(false);
return;
}
showTreeEvenSplit();
}, [actualTreeVisible, showTreeEvenSplit]);
const closePreviewArea = useCallback(() => {
if (lastRevealRequestIdRef.current === revealPathRequest?.id) {
dismissedRevealRequestIdRef.current = revealPathRequest.id;
}
if (lastChangeRevealRequestIdRef.current === changeRevealRequest?.id) {
dismissedChangeRevealRequestIdRef.current = changeRevealRequest.id;
}
const nextPreviewTabs = closeWorkspacePreviewTab(openTabs, selectedPath);
setSelectedPath(nextPreviewTabs.selectedPath);
setOpenTabs(nextPreviewTabs.openTabs);
setPreview(null);
setSelectionMenu(null);
setTreeMenu(null);
setRecentOpen(false);
setTreeVisible(true);
}, [changeRevealRequest, openTabs, revealPathRequest, selectedPath]);
const setSavedTreeWidth = useCallback(
(width: number) => {
const next = clampWorkspaceTreeWidth(width, panelWidth);
setTreeWidth(next);
saveWorkspaceTreeWidth(next);
setTreeWidthMode("manual");
},
[panelWidth],
);
@@ -844,18 +930,34 @@ export function WorkspacePanel({
const startTreeResize = useCallback(
(event: ReactPointerEvent<HTMLButtonElement>) => {
if (!treeVisible) return;
const rect = panelRef.current?.getBoundingClientRect();
if (!rect) return;
const panel = panelRef.current;
const rect = panel?.getBoundingClientRect();
if (!panel || !rect) return;
event.preventDefault();
const committedTreeWidth = clampWorkspaceTreeWidth(effectiveTreeWidth, panelWidth);
setTreeWidth(committedTreeWidth);
setTreeWidthMode("manual");
setTreeResizing(true);
let nextWidth = effectiveTreeWidth;
let nextWidth = committedTreeWidth;
const liveResize = createRafResizeUpdater({
target: panel,
separator: event.currentTarget,
cssVar: "--workspace-tree-width",
});
const onMove = (moveEvent: PointerEvent) => {
nextWidth = clampWorkspaceTreeWidth(moveEvent.clientX - rect.left, rect.width);
setTreeWidth(nextWidth);
nextWidth = workspaceSplitTreeWidthFromPointer({
clientX: moveEvent.clientX,
panelLeft: rect.left,
panelWidth: rect.width,
railWidth: WORKSPACE_TREE_RAIL_WIDTH,
treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
});
liveResize.schedule(nextWidth);
};
const onDone = () => {
liveResize.flush();
setTreeWidth(nextWidth);
saveWorkspaceTreeWidth(nextWidth);
setTreeResizing(false);
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onDone);
@@ -869,7 +971,7 @@ export function WorkspacePanel({
window.addEventListener("pointerup", onDone);
window.addEventListener("pointercancel", onDone);
},
[effectiveTreeWidth, treeVisible],
[effectiveTreeWidth, panelWidth, treeVisible],
);
const resizeTreeWithKeyboard = useCallback(
@@ -882,10 +984,10 @@ export function WorkspacePanel({
setSavedTreeWidth(WORKSPACE_TREE_MIN_WIDTH);
} else if (event.key === "End") {
event.preventDefault();
setSavedTreeWidth(WORKSPACE_TREE_MAX_WIDTH);
setSavedTreeWidth(maxTreeWidthForPanel);
}
},
[effectiveTreeWidth, setSavedTreeWidth],
[effectiveTreeWidth, maxTreeWidthForPanel, setSavedTreeWidth],
);
if (!open) return null;
@@ -1052,6 +1154,16 @@ export function WorkspacePanel({
};
const isMarkdown = selectedPath?.toLowerCase().endsWith(".md") ?? false;
const codePreviewActive = Boolean(
selectedPath &&
!changedMode &&
preview &&
!loadingPreview &&
!preview.err &&
!preview.kind &&
!preview.binary &&
!isMarkdown,
);
const treeBlankMenuItems: ContextMenuItem[] = [
{
key: "refresh-tree",
@@ -1064,7 +1176,7 @@ export function WorkspacePanel({
return (
<aside
ref={panelRef}
className={`workspace-panel${embeddedDockMode ? " workspace-panel--embedded" : ""}${changedMode ? " workspace-panel--detail-only" : ""}${previewVisible && actualTreeVisible ? " workspace-panel--split-preview" : ""}${compactTreeRail ? " workspace-panel--compact-rail" : ""}${actualTreeVisible ? "" : " workspace-panel--tree-hidden"}${previewVisible ? "" : " workspace-panel--preview-hidden"}${treeResizing ? " workspace-panel--tree-resizing" : ""}`}
className={`workspace-panel${embeddedDockMode ? " workspace-panel--embedded" : ""}${showTreeRail ? " workspace-panel--with-tree-rail" : ""}${changedMode ? " workspace-panel--detail-only" : ""}${previewVisible && actualTreeVisible ? " workspace-panel--split-preview" : ""}${actualTreeVisible ? "" : " workspace-panel--tree-hidden"}${previewVisible ? "" : " workspace-panel--preview-hidden"}${treeResizing ? " workspace-panel--tree-resizing" : ""}`}
aria-label={t("workspace.title")}
style={panelStyle}
>
@@ -1104,7 +1216,7 @@ export function WorkspacePanel({
</Tooltip>
{selectedPath && (
<Tooltip label={t("workspace.closePreview")}>
<button className="workspace-iconbtn" onClick={() => closeTab(selectedPath)}>
<button className="workspace-iconbtn" onClick={closePreviewArea}>
<X size={15} />
</button>
</Tooltip>
@@ -1148,8 +1260,7 @@ export function WorkspacePanel({
className="workspace-crumb"
onClick={() => {
setFilter("");
setTreeVisible(true);
onRequestPanelWidth?.(WORKSPACE_DUAL_PANEL_TARGET_WIDTH);
showTreeEvenSplit();
setOpenDirs((prev) => new Set([...Array.from(prev), ""]));
}}
>
@@ -1167,8 +1278,7 @@ export function WorkspacePanel({
className={`workspace-crumb${isLast ? " workspace-crumb--current" : ""}`}
onClick={() => {
if (isLast) return;
setTreeVisible(true);
onRequestPanelWidth?.(WORKSPACE_DUAL_PANEL_TARGET_WIDTH);
showTreeEvenSplit();
setFilter("");
setOpenDirs((prev) => new Set([...Array.from(prev), ...breadcrumbDirs, dir]));
void loadDir(dir);
@@ -1183,7 +1293,11 @@ export function WorkspacePanel({
{preview && preview.size > 0 && <span className="workspace-preview__size">{formatBytes(preview.size)}</span>}
</div>
<div className="workspace-preview__body" ref={previewBodyRef} onContextMenu={openSelectionMenu}>
<div
className={`workspace-preview__body${codePreviewActive ? " workspace-preview__body--code" : ""}`}
ref={previewBodyRef}
onContextMenu={openSelectionMenu}
>
{viewMode === "changed" && scopedChangeRows ? (
<div className="workspace-change-scope">
<div className="workspace-change-scope__head">
@@ -1237,6 +1351,11 @@ export function WorkspacePanel({
</div>
) : viewMode === "changed" && !selectedPath ? (
<div className="workspace-git-history">
{workspaceGitWarning && (
<div className="workspace-note workspace-note--warning" role="status">
{workspaceGitWarning}
</div>
)}
{sessionChanges && sessionChanges.length > 0 && (
<div className="workspace-change-scope">
<div className="workspace-change-scope__head">
@@ -1271,7 +1390,7 @@ export function WorkspacePanel({
{loadingHistory ? (
<div className="workspace-empty">{t("workspace.loading")}</div>
) : gitHistory.length === 0 && !(sessionChanges && sessionChanges.length > 0) ? (
<div className="workspace-empty">{t("workspace.noChanges")}</div>
<div className="workspace-empty">{workspaceGitWarning ? t("workspace.gitChangesUnknown") : t("workspace.noChanges")}</div>
) : (
<div className="workspace-git-history__list">
{gitHistory.map((commit) => (
@@ -1397,16 +1516,14 @@ export function WorkspacePanel({
</section>}
{showTreeRail && (
<section className="workspace-tree-rail" aria-label={t("workspace.showTree")}>
<Tooltip label={t("workspace.showTree")} side="right">
<section className="workspace-tree-rail" aria-label={actualTreeVisible ? t("workspace.hideTree") : t("workspace.showTree")}>
<Tooltip label={actualTreeVisible ? t("workspace.hideTree") : t("workspace.showTree")} side="right">
<button
className="workspace-tree-reveal workspace-iconbtn workspace-iconbtn--on"
className={`workspace-tree-reveal workspace-iconbtn${actualTreeVisible ? " workspace-iconbtn--on" : ""}`}
type="button"
aria-label={t("workspace.showTree")}
onClick={() => {
setTreeVisible(true);
onRequestPanelWidth?.(WORKSPACE_DUAL_PANEL_TARGET_WIDTH);
}}
aria-label={actualTreeVisible ? t("workspace.hideTree") : t("workspace.showTree")}
aria-pressed={actualTreeVisible}
onClick={toggleTreeRail}
>
<FolderTree size={15} />
</button>
@@ -1422,7 +1539,7 @@ export function WorkspacePanel({
aria-orientation="vertical"
aria-label={t("workspace.resizeTree")}
aria-valuemin={WORKSPACE_TREE_MIN_WIDTH}
aria-valuemax={WORKSPACE_TREE_MAX_WIDTH}
aria-valuemax={maxTreeWidthForPanel}
aria-valuenow={effectiveTreeWidth}
onPointerDown={startTreeResize}
onKeyDown={resizeTreeWithKeyboard}
@@ -1433,16 +1550,18 @@ export function WorkspacePanel({
<section className="workspace-files">
{showFileTools && (
<div className={`workspace-files__tools${embeddedDockMode ? " workspace-files__tools--embedded" : ""}`}>
<Tooltip label={previewVisible ? t("workspace.hideTree") : t("workspace.close")}>
<button
className="workspace-iconbtn workspace-iconbtn--on"
type="button"
aria-label={previewVisible ? t("workspace.hideTree") : t("workspace.close")}
onClick={hideTreeOrClosePanel}
>
{previewVisible ? <FolderX size={15} /> : <X size={15} />}
</button>
</Tooltip>
{showViewTabs && (
<Tooltip label={previewVisible ? t("workspace.hideTree") : t("workspace.close")}>
<button
className="workspace-iconbtn workspace-iconbtn--on"
type="button"
aria-label={previewVisible ? t("workspace.hideTree") : t("workspace.close")}
onClick={hideTreeOrClosePanel}
>
{previewVisible ? <FolderX size={15} /> : <X size={15} />}
</button>
</Tooltip>
)}
{showViewTabs && (
<div className="workspace-files__tabs" role="tablist" aria-label={t("workspace.viewMode")}>
<button
@@ -1465,7 +1584,7 @@ export function WorkspacePanel({
)}
{showViewTabs && (
<Tooltip label={t("workspace.refreshChanges")}>
<button className="workspace-iconbtn" onClick={() => void loadGitHistory()}>
<button className="workspace-iconbtn" onClick={() => { void loadGitHistory(); void loadWorkspaceChanges(); }}>
<RefreshCw size={14} />
</button>
</Tooltip>
Loaded 100 of 581 files, more files were not shown because too many files have changed in this diff. Show more