diff --git a/.gitignore b/.gitignore index 51ed5714..03d5f68f 100644 --- a/.gitignore +++ b/.gitignore @@ -68,5 +68,8 @@ data/ workspaces/ *.pid .worktrees/ + +# Marks a disposable clone dedicated to unattended maintenance tasks. +.maintenance-clone test-results/ artifacts/ diff --git a/.opencode/commands/as-fixes.md b/.opencode/commands/as-fixes.md index fb9152b6..8a4d87fc 100644 --- a/.opencode/commands/as-fixes.md +++ b/.opencode/commands/as-fixes.md @@ -13,7 +13,20 @@ First, verify the worktree is safe to use: `git status --porcelain` -If the output is not empty, stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, commit, or switch branches. Local work in progress must never end up in a maintenance PR. +If the output is not empty, decide which of two situations you are in. + +If the repository root contains a `.maintenance-clone` marker file, this working copy is a disposable clone dedicated to unattended maintenance. Nothing in it is human work in progress, so leftover changes are debris from an earlier task that failed to clean up after itself. Recover the clone rather than stopping: + +``` +git checkout -- . +git clean -fd +git checkout main +git pull +``` + +Report exactly which files you discarded, then continue with the task. A failed predecessor must not be able to jam the pipeline for every later run. + +If the marker file is absent, this is a working copy a person uses. Stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, commit, or switch branches. Then run: @@ -295,7 +308,18 @@ So, before you consider a selected file done: - A group of findings sharing one root cause counts as one reason, and that root cause is usually worth fixing. If eleven findings in a file all come from one untyped parser, fixing that parser is the point of the batch, not a reason to skip. - Leaving more than roughly a quarter of a file's findings behind means you have not finished. Either finish them or explain, per group, why the file was a bad selection in the first place. -Skip a finding only when the fix would require unclear behavior changes, or a change so large it would stop the pull request from being reviewable. Difficulty alone is not a reason. If skipped, give the specific reason in the PR body under `## Non-goals`. +Skip a finding when the fix would require unclear behavior changes, when the change would be so large that the pull request stops being reviewable, or when the only way you can see to close it is one of the forbidden patterns. That last case is not a loophole, it is the required outcome: an honest skip is always better than a laundered fix, and choosing the forbidden pattern to satisfy "finish the file" is the worse failure of the two. Ordinary difficulty, on its own, is still not a reason. If skipped, give the specific reason in the PR body under `## Non-goals`. + +### When the whole file is an external-data boundary + +Some files exist to receive data from outside the program: provider APIs, quota endpoints, extension host messages, configuration on disk. In such a file, most or all findings can share one root cause, and the honest fix is a real parsed boundary with named contracts, which is a substantial piece of work rather than a lint cleanup. + +Recognize this early, before editing. Read the file first and ask whether closing its findings means designing a data contract that does not exist yet. If it does, choose one of two outcomes, and never a third: + +- Do the work properly for a coherent part of the file: define the contract for one provider, one endpoint, or one message, parse it at its boundary, and leave the rest with a clear explanation of the remaining root cause. A correct partial fix with a named boundary is a good pull request. +- Conclude that the file is a poor batch selection, abort per "Aborting cleanly", and say in your report that the file needs a deliberate data-contract change rather than an unattended cleanup. + +What you must not do is invent a generic JSON contract to make the findings disappear. Generic record types, primitive unions, and `unknown`-based aliases over external data are exactly the patterns these rules exist to reject, and reintroducing them under time pressure defeats the purpose of the whole task. Hard prohibitions. Each of these makes the lint output greener while making the code worse, and each is grounds for rejecting the whole PR: - Do not disable, downgrade, or ignore anti-slop rules, in configuration or with inline comments. @@ -311,6 +335,21 @@ Hard prohibitions. Each of these makes the lint output greener while making the - Do not edit `CHANGELOG.md`, package versions, or release metadata. This is internal maintenance with no user-facing change. - Do not fix findings outside the selected files. +## Aborting cleanly + +You may reach a point where the batch cannot be completed correctly: validation keeps failing, or the only remaining way to close the findings is a pattern this task forbids. Stopping there is the right decision. Stopping there and walking away from a modified working copy is not. + +Whatever edits exist in the working copy at that moment are your own, made minutes ago in this session. They are not human work in progress, and nothing is lost by removing them. Leaving them behind jams every scheduled run that follows, because those runs correctly refuse to operate on a dirty worktree. + +So when you abort, in this order: + +1. Revert every file you modified: `git checkout -- `, plus `git clean -fd` for files you created. Verify with `git status --porcelain` that the result is empty. +2. Release the claim so the files return to the pool: ``bun run deslop -- release --run ``. +3. Return to `main`. +4. Report what you attempted, precisely why you stopped, and confirm that both the worktree is clean and the claim is released. + +Never leave a partially fixed working copy as a message to the next run. If a file resists a correct fix, that belongs in your report, not on disk. + After edits, run: `bun run deslop -- check-batch --run ` diff --git a/.opencode/commands/as-follow-up.md b/.opencode/commands/as-follow-up.md index 6e1c6faf..c7877b7e 100644 --- a/.opencode/commands/as-follow-up.md +++ b/.opencode/commands/as-follow-up.md @@ -13,7 +13,20 @@ First, verify the worktree is safe to use: `git status --porcelain` -If the output is not empty, stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, or switch branches. +If the output is not empty, decide which of two situations you are in. + +If the repository root contains a `.maintenance-clone` marker file, this working copy is a disposable clone dedicated to unattended maintenance. Nothing in it is human work in progress, so leftover changes are debris from an earlier task that failed to clean up after itself. Recover the clone rather than stopping: + +``` +git checkout -- . +git clean -fd +git checkout main +git pull +``` + +Report exactly which files you discarded, then continue with the task. A failed predecessor must not be able to jam the pipeline for every later run. + +If the marker file is absent, this is a working copy a person uses. Stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, commit, or switch branches. List the active batches: diff --git a/.opencode/commands/maintenance-review.md b/.opencode/commands/maintenance-review.md index 74eddeea..d0ec290f 100644 --- a/.opencode/commands/maintenance-review.md +++ b/.opencode/commands/maintenance-review.md @@ -29,7 +29,9 @@ Verify the worktree is clean: `git status --porcelain` -If the output is not empty, stop immediately and report it. Do not stash, reset, or discard anything. +If the output is not empty and the repository root contains a `.maintenance-clone` marker file, this is a disposable maintenance clone and the changes are debris from an earlier failed task. Recover it with `git checkout -- .`, `git clean -fd`, `git checkout main`, `git pull`, report exactly which files you discarded, and continue. + +If the marker file is absent, stop immediately and report it. Do not stash, reset, or discard anything. Read `AGENTS.md`, and read `.opencode/commands/as-fixes.md` in full, including the sections "What a good fix looks like" and "Hard prohibitions". Those describe the standard the anti-slop PRs were supposed to meet. Your job includes verifying they actually met it. diff --git a/.opencode/commands/rd-fixes.md b/.opencode/commands/rd-fixes.md index b610a4d0..df2d6bb8 100644 --- a/.opencode/commands/rd-fixes.md +++ b/.opencode/commands/rd-fixes.md @@ -13,7 +13,20 @@ First, verify the worktree is safe to use: `git status --porcelain` -If the output is not empty, stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, commit, or switch branches. Local work in progress must never end up in a maintenance PR. +If the output is not empty, decide which of two situations you are in. + +If the repository root contains a `.maintenance-clone` marker file, this working copy is a disposable clone dedicated to unattended maintenance. Nothing in it is human work in progress, so leftover changes are debris from an earlier task that failed to clean up after itself. Recover the clone rather than stopping: + +``` +git checkout -- . +git clean -fd +git checkout main +git pull +``` + +Report exactly which files you discarded, then continue with the task. A failed predecessor must not be able to jam the pipeline for every later run. + +If the marker file is absent, this is a working copy a person uses. Stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, commit, or switch branches. Then run: @@ -36,10 +49,26 @@ Workflow: - Finish each selected file. A file is finished when it has zero React Doctor diagnostics, or when every remaining diagnostic has an individual, specific reason to stay. A half-fixed file will be selected again later and cost a second pull request, a second review, and a second merge over the same code. - Before considering a file done, re-run `bun run doctor -- file ` and read what is left. Leaving more than roughly a quarter of a file's diagnostics behind means you have not finished. - A group of diagnostics sharing one root cause counts as one reason, and that root cause is usually worth fixing rather than deferring. -- Skip a diagnostic only when the fix would require unclear behavior changes, or a change so large it would stop the pull request from being reviewable. Difficulty alone is not a reason. If skipped, give the specific reason in the PR body under `## Non-goals`. +- Skip a diagnostic when the fix would require unclear behavior changes, when the change would be so large that the pull request stops being reviewable, or when the only way you can see to close it is a change you would not defend in review. An honest skip is always better than a forced fix. Ordinary difficulty, on its own, is still not a reason. If skipped, give the specific reason in the PR body under `## Non-goals`. +- If a whole selected file turns out to need a deliberate architectural change rather than a cleanup, abort per "Aborting cleanly" and report that the file was a poor batch selection. - Do not suppress React Doctor diagnostics unless there is a clear false positive. - If a listed diagnostic requires changes outside the selected files, make only the minimal required supporting change. Do not expand the cleanup scope. +## Aborting cleanly + +You may reach a point where the batch cannot be completed correctly: validation keeps failing, or the only remaining way to close the findings is a pattern this task forbids. Stopping there is the right decision. Stopping there and walking away from a modified working copy is not. + +Whatever edits exist in the working copy at that moment are your own, made minutes ago in this session. They are not human work in progress, and nothing is lost by removing them. Leaving them behind jams every scheduled run that follows, because those runs correctly refuse to operate on a dirty worktree. + +So when you abort, in this order: + +1. Revert every file you modified: `git checkout -- `, plus `git clean -fd` for files you created. Verify with `git status --porcelain` that the result is empty. +2. Release the claim so the files return to the pool: ``bun run doctor -- release --run ``. +3. Return to `main`. +4. Report what you attempted, precisely why you stopped, and confirm that both the worktree is clean and the claim is released. + +Never leave a partially fixed working copy as a message to the next run. If a file resists a correct fix, that belongs in your report, not on disk. + After edits, run: `bun run doctor -- check-batch --run ` diff --git a/.opencode/commands/rd-follow-up.md b/.opencode/commands/rd-follow-up.md index 478a03a7..7bac4435 100644 --- a/.opencode/commands/rd-follow-up.md +++ b/.opencode/commands/rd-follow-up.md @@ -13,7 +13,20 @@ First, verify the worktree is safe to use: `git status --porcelain` -If the output is not empty, stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, or switch branches. +If the output is not empty, decide which of two situations you are in. + +If the repository root contains a `.maintenance-clone` marker file, this working copy is a disposable clone dedicated to unattended maintenance. Nothing in it is human work in progress, so leftover changes are debris from an earlier task that failed to clean up after itself. Recover the clone rather than stopping: + +``` +git checkout -- . +git clean -fd +git checkout main +git pull +``` + +Report exactly which files you discarded, then continue with the task. A failed predecessor must not be able to jam the pipeline for every later run. + +If the marker file is absent, this is a working copy a person uses. Stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, commit, or switch branches. List the active batches: diff --git a/CHANGELOG.md b/CHANGELOG.md index 46731e19..349fb84d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,18 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **Project knowledge:** the Project notes panel is now Project knowledge, with notes, todos, plans and their search in a resizable sidebar. Notes are cards you expand by clicking anywhere on them, plans open and edit in the panel itself instead of a separate tab, and notes and plans you pin travel with every message you send in that project until you unpin them. +- **Work status:** the Context sources section now names each pinned note and plan riding along with your messages, and its pin button unpins them from there. +- **Settings:** OpenChamber no longer replaces a full OpenCode config with an empty `$schema`-only stub when the file uses JSON5-style unquoted keys; Settings changes now fail instead of wiping plugins, MCP servers, and providers (thanks to @makeittech). - **Settings/Integrations:** a new Integrations settings page lists Claude Code, Command Code, and Cursor plugins with install, update, setup, and remove actions, plus Discord and Telegram Coming soon placeholders. +- **Chat:** an open conversation no longer keeps re-coloring the same code blocks in the background, so browsing files with a chat open stops pinning a CPU core and spinning up the fans (thanks to @makeittech). +- **Stability/Proxy:** the local server now reuses its connection to OpenCode instead of opening a new one for every API request. Under sustained traffic the old behavior could use up every outgoing network port on the machine, at which point nothing on the computer could open a new connection until the traffic stopped and the ports were released (thanks to @alohaninja). - Usage/Claude: Claude plan limits now work when you are signed in through Claude Code, without also signing into Anthropic in OpenCode; the account is read from Claude Code's own login on macOS, Linux, and WSL. The page shows your session and weekly limits again, adds per-model weekly limits and extra usage spending, and names your plan. Limits are kept on screen instead of disappearing when Anthropic temporarily blocks refreshes. - Git: the pull request panel now follows the branch's current open PR, and an open PR always wins over an older merged or closed one. After a PR is merged or closed the panel keeps showing it as the branch's last PR and offers creating the next one right below it (thanks to @makeittech). - Chat: new chats no longer start against a deleted last worktree directory; they fall back to the active project instead of saving the first message and never starting. - Chat: opening a busy subagent in the context panel now shows its history instead of only the working-status line (thanks to @makeittech). - Chat: saved chats in the context panel open again instead of staying blank. +- Chat: the context meter no longer climbs over 100% (330% readouts) after turns with many tool calls and no longer jumps when reopening an older session; it now shows what the window actually holds, everywhere the value appears — header, context sidebar, work status panel, mini chat, and mobile (thanks to @pocharlies). - Projects: project names now match the folder name exactly, so `.ssh` and `opencode-claude` are no longer shown as `.Ssh` and `Opencode Claude` in the sidebar, window title, settings and notifications; names you renamed yourself are kept. - Settings: the session retention action you pick is now saved instead of being dropped (thanks to @Gautam0507). - Browser: typing a comment on a page no longer triggers app shortcuts. diff --git a/bun.lock b/bun.lock index 7dff8dfe..dc2f7923 100644 --- a/bun.lock +++ b/bun.lock @@ -6,25 +6,25 @@ "name": "openchamber-monorepo", "dependencies": { "@base-ui/react": "^1.4.0", - "@codemirror/autocomplete": "^6.20.0", - "@codemirror/commands": "^6.10.1", + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/commands": "^6.11.0", "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-go": "^6.0.1", - "@codemirror/lang-html": "^6.4.11", - "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-html": "^6.4.12", + "@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-json": "^6.0.2", - "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-markdown": "^6.5.2", "@codemirror/lang-python": "^6.2.1", "@codemirror/lang-rust": "^6.0.2", "@codemirror/lang-sql": "^6.10.0", "@codemirror/lang-xml": "^6.1.0", - "@codemirror/lang-yaml": "^6.1.2", - "@codemirror/language": "6.12.2", - "@codemirror/lint": "^6.9.2", - "@codemirror/search": "^6.6.0", - "@codemirror/state": "^6.5.4", - "@codemirror/view": "6.39.13", + "@codemirror/lang-yaml": "^6.1.3", + "@codemirror/language": "6.12.4", + "@codemirror/lint": "^6.9.7", + "@codemirror/search": "^6.7.1", + "@codemirror/state": "^6.7.1", + "@codemirror/view": "6.43.9", "@heroui/scroll-shadow": "^2.3.18", "@heroui/system": "^2.4.23", "@heroui/theme": "^2.4.23", @@ -143,34 +143,34 @@ "@capacitor/keyboard": "^8.0.0", "@capacitor/push-notifications": "^8.1.1", "@capacitor/status-bar": "^8.0.0", - "@codemirror/autocomplete": "^6.20.0", - "@codemirror/commands": "^6.10.1", + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/commands": "^6.11.0", "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-go": "^6.0.1", - "@codemirror/lang-html": "^6.4.11", - "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-html": "^6.4.12", + "@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-json": "^6.0.2", - "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-markdown": "^6.5.2", "@codemirror/lang-python": "^6.2.1", "@codemirror/lang-rust": "^6.0.2", "@codemirror/lang-sql": "^6.10.0", "@codemirror/lang-xml": "^6.1.0", - "@codemirror/lang-yaml": "^6.1.2", - "@codemirror/language": "6.12.2", + "@codemirror/lang-yaml": "^6.1.3", + "@codemirror/language": "6.12.4", "@codemirror/language-data": "^6.5.2", - "@codemirror/legacy-modes": "^6.5.2", - "@codemirror/lint": "^6.9.2", - "@codemirror/search": "^6.6.0", - "@codemirror/state": "^6.5.4", - "@codemirror/view": "6.39.13", + "@codemirror/legacy-modes": "^6.5.3", + "@codemirror/lint": "^6.9.7", + "@codemirror/search": "^6.7.1", + "@codemirror/state": "^6.7.1", + "@codemirror/view": "6.43.9", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@lezer/highlight": "^1.2.3", "@opencode-ai/sdk": "1.18.18", "@pierre/diffs": "1.3.0-beta.6", - "@replit/codemirror-vim": "^6.3.0", + "@replit/codemirror-vim": "^6.4.0", "@simplewebauthn/browser": "13.3.0", "@tanstack/react-virtual": "3.14.5", "@xenova/transformers": "^2.17.2", @@ -355,8 +355,18 @@ "bun-pty@0.4.8": "bun-patches/bun-pty@0.4.8.patch", }, "overrides": { - "@codemirror/language": "6.12.2", - "@codemirror/view": "6.39.13", + "@codemirror/autocomplete": "6.20.3", + "@codemirror/commands": "6.11.0", + "@codemirror/lang-html": "6.4.12", + "@codemirror/lang-javascript": "6.2.5", + "@codemirror/lang-markdown": "6.5.2", + "@codemirror/lang-yaml": "6.1.3", + "@codemirror/language": "6.12.4", + "@codemirror/legacy-modes": "6.5.3", + "@codemirror/lint": "6.9.7", + "@codemirror/search": "6.7.1", + "@codemirror/state": "6.7.1", + "@codemirror/view": "6.43.9", }, "packages": { "7zip-bin": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="], @@ -603,9 +613,9 @@ "@clack/prompts": ["@clack/prompts@1.1.0", "", { "dependencies": { "@clack/core": "1.1.0", "sisteransi": "^1.0.5" } }, "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g=="], - "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg=="], + "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g=="], - "@codemirror/commands": ["@codemirror/commands@6.10.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ=="], + "@codemirror/commands": ["@codemirror/commands@6.11.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA=="], "@codemirror/lang-angular": ["@codemirror/lang-angular@0.1.4", "", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-javascript": "^6.1.2", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.3" } }, "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g=="], @@ -615,11 +625,11 @@ "@codemirror/lang-go": ["@codemirror/lang-go@6.0.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/go": "^1.0.0" } }, "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg=="], - "@codemirror/lang-html": ["@codemirror/lang-html@6.4.11", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw=="], + "@codemirror/lang-html": ["@codemirror/lang-html@6.4.12", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w=="], "@codemirror/lang-java": ["@codemirror/lang-java@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/java": "^1.0.0" } }, "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ=="], - "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.4", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA=="], + "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="], "@codemirror/lang-jinja": ["@codemirror/lang-jinja@6.0.0", "", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.4.0" } }, "sha512-47MFmRcR8UAxd8DReVgj7WJN1WSAMT7OJnewwugZM4XiHWkOjgJQqvEM1NpMj9ALMPyxmlziEI1opH9IaEvmaw=="], @@ -629,7 +639,7 @@ "@codemirror/lang-liquid": ["@codemirror/lang-liquid@6.3.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.1" } }, "sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw=="], - "@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw=="], + "@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw=="], "@codemirror/lang-php": ["@codemirror/lang-php@6.0.2", "", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/php": "^1.0.0" } }, "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA=="], @@ -647,21 +657,21 @@ "@codemirror/lang-xml": ["@codemirror/lang-xml@6.1.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/xml": "^1.0.0" } }, "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg=="], - "@codemirror/lang-yaml": ["@codemirror/lang-yaml@6.1.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.0.0", "@lezer/yaml": "^1.0.0" } }, "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw=="], + "@codemirror/lang-yaml": ["@codemirror/lang-yaml@6.1.3", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.0.0", "@lezer/yaml": "^1.0.0" } }, "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ=="], - "@codemirror/language": ["@codemirror/language@6.12.2", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg=="], + "@codemirror/language": ["@codemirror/language@6.12.4", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A=="], "@codemirror/language-data": ["@codemirror/language-data@6.5.2", "", { "dependencies": { "@codemirror/lang-angular": "^0.1.0", "@codemirror/lang-cpp": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-go": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-java": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/lang-jinja": "^6.0.0", "@codemirror/lang-json": "^6.0.0", "@codemirror/lang-less": "^6.0.0", "@codemirror/lang-liquid": "^6.0.0", "@codemirror/lang-markdown": "^6.0.0", "@codemirror/lang-php": "^6.0.0", "@codemirror/lang-python": "^6.0.0", "@codemirror/lang-rust": "^6.0.0", "@codemirror/lang-sass": "^6.0.0", "@codemirror/lang-sql": "^6.0.0", "@codemirror/lang-vue": "^0.1.1", "@codemirror/lang-wast": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", "@codemirror/lang-yaml": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/legacy-modes": "^6.4.0" } }, "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg=="], - "@codemirror/legacy-modes": ["@codemirror/legacy-modes@6.5.2", "", { "dependencies": { "@codemirror/language": "^6.0.0" } }, "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q=="], + "@codemirror/legacy-modes": ["@codemirror/legacy-modes@6.5.3", "", { "dependencies": { "@codemirror/language": "^6.0.0" } }, "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg=="], - "@codemirror/lint": ["@codemirror/lint@6.9.4", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw=="], + "@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="], - "@codemirror/search": ["@codemirror/search@6.6.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw=="], + "@codemirror/search": ["@codemirror/search@6.7.1", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA=="], - "@codemirror/state": ["@codemirror/state@6.5.4", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw=="], + "@codemirror/state": ["@codemirror/state@6.7.1", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A=="], - "@codemirror/view": ["@codemirror/view@6.39.13", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-QBO8ZsgJLCbI28KdY0/oDy5NQLqOQVZCozBknxc2/7L98V+TVYFHnfaCsnGh1U+alpd2LOkStVwYY7nW2R1xbw=="], + "@codemirror/view": ["@codemirror/view@6.43.9", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-sTuUzTpPMFebRhg6dawChoKKgndIwfjmJgKVxBefPElcU2NwQ6AFroupk0SFqEerQyZOGRfDNnSN8Dw/lMAsXw=="], "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], @@ -1191,7 +1201,9 @@ "@remixicon/react": ["@remixicon/react@4.9.0", "", { "peerDependencies": { "react": ">=18.2.0" } }, "sha512-5/jLDD4DtKxH2B4QVXTobvV1C2uL8ab9D5yAYNtFt+w80O0Ys1xFOrspqROL3fjrZi+7ElFUWE37hBfaAl6U+Q=="], - "@replit/codemirror-vim": ["@replit/codemirror-vim@6.3.0", "", { "peerDependencies": { "@codemirror/commands": "6.x.x", "@codemirror/language": "6.x.x", "@codemirror/search": "6.x.x", "@codemirror/state": "6.x.x", "@codemirror/view": "6.x.x" } }, "sha512-aTx931ULAMuJx6xLf7KQDOL7CxD+Sa05FktTDrtLaSy53uj01ll3Zf17JdKsriER248oS55GBzg0CfCTjEneAQ=="], + "@replit/codemirror-vim": ["@replit/codemirror-vim@6.4.0", "", { "dependencies": { "@replit/codemirror-vim-core": "^0.1.0" }, "peerDependencies": { "@codemirror/commands": "6.x.x", "@codemirror/language": "6.x.x", "@codemirror/search": "6.x.x", "@codemirror/state": "6.x.x", "@codemirror/view": "6.x.x" } }, "sha512-t9UMDNhkmeAkl0uRbiJVotv97bGD6mf4GyJJoEbrjUqa/Pov0s9eL+4AaI0Xto3OGri17i9qwIpT0JbVlVXt8A=="], + + "@replit/codemirror-vim-core": ["@replit/codemirror-vim-core@0.1.0", "", {}, "sha512-1i6EBKpcNfDKvTmTh6N6g9lL6udD5t+uFNh4JCqozRnVlvUGOps7h/QzS2ne4zcvPUjvApKpmcP7Grc3fNbZiQ=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], diff --git a/package.json b/package.json index ca220980..4a7751e4 100644 --- a/package.json +++ b/package.json @@ -92,25 +92,25 @@ }, "dependencies": { "@base-ui/react": "^1.4.0", - "@codemirror/autocomplete": "^6.20.0", - "@codemirror/commands": "^6.10.1", + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/commands": "^6.11.0", "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-go": "^6.0.1", - "@codemirror/lang-html": "^6.4.11", - "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-html": "^6.4.12", + "@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-json": "^6.0.2", - "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-markdown": "^6.5.2", "@codemirror/lang-python": "^6.2.1", "@codemirror/lang-rust": "^6.0.2", "@codemirror/lang-sql": "^6.10.0", "@codemirror/lang-xml": "^6.1.0", - "@codemirror/lang-yaml": "^6.1.2", - "@codemirror/language": "6.12.2", - "@codemirror/lint": "^6.9.2", - "@codemirror/search": "^6.6.0", - "@codemirror/state": "^6.5.4", - "@codemirror/view": "6.39.13", + "@codemirror/lang-yaml": "^6.1.3", + "@codemirror/language": "6.12.4", + "@codemirror/lint": "^6.9.7", + "@codemirror/search": "^6.7.1", + "@codemirror/state": "^6.7.1", + "@codemirror/view": "6.43.9", "@heroui/scroll-shadow": "^2.3.18", "@heroui/system": "^2.4.23", "@heroui/theme": "^2.4.23", @@ -149,8 +149,18 @@ "zustand": "^5.0.8" }, "overrides": { - "@codemirror/language": "6.12.2", - "@codemirror/view": "6.39.13" + "@codemirror/autocomplete": "6.20.3", + "@codemirror/commands": "6.11.0", + "@codemirror/lang-html": "6.4.12", + "@codemirror/lang-javascript": "6.2.5", + "@codemirror/lang-markdown": "6.5.2", + "@codemirror/lang-yaml": "6.1.3", + "@codemirror/language": "6.12.4", + "@codemirror/legacy-modes": "6.5.3", + "@codemirror/lint": "6.9.7", + "@codemirror/search": "6.7.1", + "@codemirror/state": "6.7.1", + "@codemirror/view": "6.43.9" }, "devDependencies": { "@clack/prompts": "^1.1.0", diff --git a/packages/ui/package.json b/packages/ui/package.json index 9dc25467..751a2956 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -19,34 +19,34 @@ "@capacitor/keyboard": "^8.0.0", "@capacitor/push-notifications": "^8.1.1", "@capacitor/status-bar": "^8.0.0", - "@codemirror/autocomplete": "^6.20.0", - "@codemirror/commands": "^6.10.1", + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/commands": "^6.11.0", "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-go": "^6.0.1", - "@codemirror/lang-html": "^6.4.11", - "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-html": "^6.4.12", + "@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-json": "^6.0.2", - "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-markdown": "^6.5.2", "@codemirror/lang-python": "^6.2.1", "@codemirror/lang-rust": "^6.0.2", "@codemirror/lang-sql": "^6.10.0", "@codemirror/lang-xml": "^6.1.0", - "@codemirror/lang-yaml": "^6.1.2", - "@codemirror/language": "6.12.2", + "@codemirror/lang-yaml": "^6.1.3", + "@codemirror/language": "6.12.4", "@codemirror/language-data": "^6.5.2", - "@codemirror/legacy-modes": "^6.5.2", - "@codemirror/lint": "^6.9.2", - "@codemirror/search": "^6.6.0", - "@codemirror/state": "^6.5.4", - "@codemirror/view": "6.39.13", + "@codemirror/legacy-modes": "^6.5.3", + "@codemirror/lint": "^6.9.7", + "@codemirror/search": "^6.7.1", + "@codemirror/state": "^6.7.1", + "@codemirror/view": "6.43.9", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@lezer/highlight": "^1.2.3", "@opencode-ai/sdk": "1.18.18", "@pierre/diffs": "1.3.0-beta.6", - "@replit/codemirror-vim": "^6.3.0", + "@replit/codemirror-vim": "^6.4.0", "@simplewebauthn/browser": "13.3.0", "@tanstack/react-virtual": "3.14.5", "@xenova/transformers": "^2.17.2", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index ad4bc2f4..ff0ffe7c 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -14,6 +14,7 @@ import { useTraySync } from '@/hooks/useTraySync'; import { useRouter } from '@/hooks/useRouter'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; import { useWebNotificationStream } from '@/hooks/useWebNotificationStream'; +import { useAgentMemorySync } from '@/hooks/useAgentMemorySync'; import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt'; import { useWindowTitle } from '@/hooks/useWindowTitle'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -703,6 +704,10 @@ function App({ apis }: AppProps) { usePushVisibilityBeacon({ enabled: embeddedBackgroundWorkEnabled }); useWebNotificationStream({ enabled: embeddedBackgroundWorkEnabled }); + // Loaded here rather than by the Memory tab: the session index is built from + // this snapshot, so leaving it to the panel meant a user who never opened + // Project notes sent every message with no memory index at all. + useAgentMemorySync(currentDirectory || null); usePwaInstallPrompt(); useWindowTitle(); diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 42d419c8..b790270c 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -109,7 +109,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc const [workspaceTab, setWorkspaceTab] = React.useState('changes'); // A plan opened from the workspace drawer's Notes tab, shown as a fullscreen // layer on top of it (back returns to the notes). - const [openPlan, setOpenPlan] = React.useState<{ path: string; title: string } | null>(null); + const [openPlan, setOpenPlan] = React.useState<{ id: string; title: string } | null>(null); const [settingsInitialMobileStage, setSettingsInitialMobileStage] = React.useState<'nav' | 'page-content'>('nav'); // When set, the Changes surface opens directly into the per-file diff for this path. const [pendingChangesDiff, setPendingChangesDiff] = React.useState<{ path: string; staged: boolean } | null>(null); @@ -540,7 +540,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc > { closeSurface(); closeWorkspace(); diff --git a/packages/ui/src/apps/MobileSessionMetadata.tsx b/packages/ui/src/apps/MobileSessionMetadata.tsx index 9ffe0d66..3d239d1f 100644 --- a/packages/ui/src/apps/MobileSessionMetadata.tsx +++ b/packages/ui/src/apps/MobileSessionMetadata.tsx @@ -388,6 +388,7 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) { const message = activeSessionMessages[i] as typeof activeSessionMessages[number] & { tokens?: { + total?: unknown; input?: unknown; output?: unknown; reasoning?: unknown; @@ -395,6 +396,11 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta }; }; if (message.role !== 'assistant' || !message.tokens) continue; + // Multi-step turns accumulate the fields across API round-trips, so + // summing them overstates the window. The server-reported total is the + // final round-trip's window; sum only when the server did not send it. + const reportedTotal = getTokenCount(message.tokens.total); + if (reportedTotal > 0) return reportedTotal; const total = getTokenCount(message.tokens.input) + getTokenCount(message.tokens.output) + getTokenCount(message.tokens.reasoning) diff --git a/packages/ui/src/apps/MobileWorkspaceDrawer.tsx b/packages/ui/src/apps/MobileWorkspaceDrawer.tsx index b3c94c24..f0847aa1 100644 --- a/packages/ui/src/apps/MobileWorkspaceDrawer.tsx +++ b/packages/ui/src/apps/MobileWorkspaceDrawer.tsx @@ -105,7 +105,7 @@ export const MobileWorkspaceDrawer: React.FC<{ /** When set, the Changes tab opens directly into the per-file diff. */ pendingChangesDiff: { path: string; staged: boolean } | null; /** Notes tab: opens a plan fullscreen (layered above the drawer). */ - onOpenPlan: (plan: { path: string; title: string }) => void; + onOpenPlan: (plan: { id: string; title: string }) => void; /** MCP tab: jump to the MCP settings page pre-seeded with a new server draft. */ onOpenMcpSettings: () => void; variant?: 'drawer' | 'panel'; diff --git a/packages/ui/src/apps/mobileConnections.ts b/packages/ui/src/apps/mobileConnections.ts index 77c83c24..78b5851b 100644 --- a/packages/ui/src/apps/mobileConnections.ts +++ b/packages/ui/src/apps/mobileConnections.ts @@ -23,7 +23,7 @@ import type { PairingConnectionPayload, PairingEndpointCandidate } from '@/lib/c import { isCapacitorApp } from '@/lib/platform'; import { adoptRelayTunnel, isRelayModeActive } from '@/lib/relay/runtime-tunnel'; import { createRelayTunnelClient } from '@/lib/relay/tunnel-client'; -import { runtimeFetch } from '@/lib/runtime-fetch'; +import { addRuntimeProxyHeaders, runtimeFetch } from '@/lib/runtime-fetch'; import { getRuntimeApiBaseUrl, getRuntimeKey, switchRuntimeEndpoint } from '@/lib/runtime-switch'; import { recordMobileConnectDebug } from './mobileConnectionDebug'; @@ -346,11 +346,11 @@ const nativeHttpRequest = async (url: string, init?: RequestInit): Promise + Command Code + + + diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index d5e4a900..f822053d 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -7,11 +7,12 @@ import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, typ import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; -import { useInputStore } from '@/sync/input-store'; +import { prepareLocalAttachments, useInputStore } from '@/sync/input-store'; import { ACCEPTED_ATTACHMENT_EXTENSIONS, ATTACHMENT_ACCEPT, getUnsupportedAttachmentInputs, + isDocumentAttachmentFilename, type AttachmentInputModality, } from '@/sync/attachment-files'; import type { AttachedFile } from '@/stores/types/sessionTypes'; @@ -24,6 +25,7 @@ import { appendInlineComments } from '@/lib/messages/inlineComments'; import { renderMagicPrompt } from '@/lib/magicPrompts'; import { startReviewFlow } from '@/lib/reviewFlow'; import { getRuntimeKey } from '@/lib/runtime-switch'; +import { runtimeFetch } from '@/lib/runtime-fetch'; import { createChatDraftIdentity, readChatDraft, @@ -602,59 +604,62 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [], ); - const extractInlineFileMentions = React.useCallback((rawText: string): { sanitizedText: string; attachments: AttachedFile[] } => { + const resolveInlineFileMention = React.useCallback((mentionPath: string): { serverPath: string; filename: string } | null => { + const kind = classifyMention(mentionPath, { + knownAgentNames: knownAgentNamesRef.current, + confirmedMentions: confirmedMentionsRef.current, + }); + if (kind !== 'file') return null; + + const normalizedMentionPath = mentionPath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, ''); + if (!normalizedMentionPath) return null; + + const clientDirectory = opencodeClient.getDirectory() || ''; + const root = (chatSearchDirectory || clientDirectory).replace(/\\/g, '/').replace(/\/+$/, ''); + let serverPath: string | null = null; + if (mentionPath.startsWith('/')) { + serverPath = mentionPath.replace(/\\/g, '/'); + } else if (root) { + serverPath = `${root}/${normalizedMentionPath}`; + } + if (!serverPath) return null; + + return { + serverPath: serverPath.replace(/\/+/g, '/'), + filename: normalizedMentionPath.split('/').filter(Boolean).pop() || normalizedMentionPath, + }; + }, [chatSearchDirectory]); + + const extractInlineFileMentions = React.useCallback(( + rawText: string, + preparedDocumentMentions?: ReadonlyMap, + ) => { if (!rawText || !rawText.includes('@')) { return { sanitizedText: rawText, attachments: [] }; } - const clientDirectory = opencodeClient.getDirectory() || ''; - const root = (chatSearchDirectory || clientDirectory).replace(/\\/g, '/').replace(/\/+$/, ''); const seenPaths = new Set(); const attachments: AttachedFile[] = []; for (const token of scanMentions(rawText)) { - const mentionPath = token.name; - const kind = classifyMention(mentionPath, { - knownAgentNames: knownAgentNamesRef.current, - confirmedMentions: confirmedMentionsRef.current, - }); - // Agents are routed separately by parseAgentMentions; only file - // references become attachments here. - if (kind !== 'file') { + const mention = resolveInlineFileMention(token.name); + if (!mention || seenPaths.has(mention.serverPath)) continue; + seenPaths.add(mention.serverPath); + + const prepared = preparedDocumentMentions?.get(mention.serverPath); + if (prepared) { + attachments.push(...prepared); continue; } - - const normalizedMentionPath = mentionPath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, ''); - if (!normalizedMentionPath) { - continue; - } - - const serverPath = mentionPath.startsWith('/') - ? mentionPath.replace(/\\/g, '/') - : root - ? `${root}/${normalizedMentionPath}` - : null; - - if (!serverPath) { - continue; - } - - const normalizedServerPath = serverPath.replace(/\/+/g, '/'); - if (seenPaths.has(normalizedServerPath)) { - continue; - } - seenPaths.add(normalizedServerPath); - - const filename = normalizedMentionPath.split('/').filter(Boolean).pop() || normalizedMentionPath; attachments.push({ id: `inline-server-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, - file: new File([], filename, { type: 'text/plain' }), - filename, + file: new File([], mention.filename, { type: 'text/plain' }), + filename: mention.filename, mimeType: 'text/plain', size: 0, - dataUrl: toServerFileUrl(normalizedServerPath), + dataUrl: toServerFileUrl(mention.serverPath), source: 'server', - serverPath: normalizedServerPath, + serverPath: mention.serverPath, }); } @@ -662,7 +667,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo sanitizedText: rawText, attachments, }; - }, [chatSearchDirectory]); + }, [resolveInlineFileMention]); const abortTimeoutRef = React.useRef | null>(null); const prevWasAbortedRef = React.useRef(false); @@ -983,6 +988,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }; const handleSubmit = async (options?: SubmitOptions) => { + const submitRuntimeKey = getRuntimeKey(); const queuedOnly = options?.queuedOnly ?? false; const queuedMessageId = options?.queuedMessageId; const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined; @@ -1074,6 +1080,44 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } : undefined; + const preparedDocumentMentions = new Map(); + const reservedFilenames = new Set([ + ...attachedFiles.map((attachment) => attachment.filename), + ...queuedMessagesToSend.flatMap((queued) => queued.attachments?.map((attachment) => attachment.filename) ?? []), + ]); + const mentionTexts = [ + ...queuedMessagesToSend.map((queued) => queued.content), + ...(!queuedOnly && inputSnapshot.hasContent ? [inputSnapshot.message] : []), + ]; + for (const rawText of mentionTexts) { + for (const token of scanMentions(rawText)) { + const mention = resolveInlineFileMention(token.name); + if ( + !mention + || !isDocumentAttachmentFilename(mention.filename) + || preparedDocumentMentions.has(mention.serverPath) + ) { + continue; + } + try { + const response = await runtimeFetch('/api/fs/raw', { query: { path: mention.serverPath } }); + if (!response.ok) throw new Error(`Failed to read ${mention.filename}`); + const sourceBlob = await response.blob(); + if (getRuntimeKey() !== submitRuntimeKey) return; + const source = new File([sourceBlob], mention.filename); + const prepared = await prepareLocalAttachments(source, reservedFilenames); + if (!prepared || prepared.length === 0) throw new Error(`Failed to prepare ${mention.filename}`); + if (getRuntimeKey() !== submitRuntimeKey) return; + preparedDocumentMentions.set(mention.serverPath, prepared); + for (const attachment of prepared) reservedFilenames.add(attachment.filename); + } catch { + if (getRuntimeKey() !== submitRuntimeKey) return; + toast.error(t('chat.chatInput.toast.attachNamedFailed', { name: mention.filename })); + return; + } + } + } + // Inline review comments and synthetic context are consumed before // assembly so a failed send can restore exactly what it took. const syntheticParts = consumePendingSyntheticParts(); @@ -1102,7 +1146,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo return { text: sanitizedText, agentName: mention?.name }; }, extractFileMentions: (text) => { - const { sanitizedText, attachments } = extractInlineFileMentions(text); + const { sanitizedText, attachments } = extractInlineFileMentions(text, preparedDocumentMentions); return { text: sanitizedText, attachments }; }, sanitizeAttachments: sanitizeAttachmentsForSend, diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 69690752..6d4665e2 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -835,7 +835,6 @@ const useMorphdomMarkdown = ({ containerRef, text, streaming, - cacheKey, imageMode = 'inline', syntaxVars, ctx, @@ -843,7 +842,6 @@ const useMorphdomMarkdown = ({ containerRef: React.RefObject; text: string; streaming: boolean; - cacheKey: string; imageMode?: MarkdownImageMode; syntaxVars: Record; ctx: DecorateContext; @@ -908,7 +906,7 @@ const useMorphdomMarkdown = ({ const target = container.querySelector('[data-markdown-content]') ?? container; let active = true; - void renderMarkdownBlocks(text, streaming, cacheKey, imageMode).then((blocks) => { + void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => { if (!active) return; const existing = Array.from(target.children) as HTMLElement[]; @@ -959,7 +957,7 @@ const useMorphdomMarkdown = ({ return () => { active = false; }; - }, [containerRef, text, streaming, cacheKey, imageMode, ctx, refreshMermaidViewers]); + }, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]); React.useEffect(() => { const container = containerRef.current; @@ -1040,13 +1038,13 @@ const MarkdownRendererImpl: React.FC = ({ const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS); - const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`; + // Identity for the fade-in wrapper: a new part/message restarts the animation. + const fadeKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`; useMorphdomMarkdown({ containerRef, text: content, streaming: live, - cacheKey, imageMode: variant === 'assistant' ? 'label' : 'inline', syntaxVars, ctx, @@ -1060,7 +1058,7 @@ const MarkdownRendererImpl: React.FC = ({ if (isAnimated) { return ( - + {markdownContent} ); @@ -1137,7 +1135,6 @@ const SimpleMarkdownRendererImpl: React.FC<{ containerRef, text: renderedContent, streaming: false, - cacheKey: `simple:${variant}`, syntaxVars, ctx, }); diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index 3544932a..667c2db7 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -61,20 +61,46 @@ question of design, not of feasibility. Selection rendering: every device runs CodeMirror's `drawSelection()` — it keeps typing on the drawn-selection code path, and removing it makes CodeMirror enforce cursor association on the native selection, which iOS -answers with severe input lag. Every device also layers -`composerNativeSelectionExtension` (`editor/theme.ts`) on top: it re-shows +answers with severe input lag. **That much is not platform-specific and must +not be undone.** What differs is who paints the selection, and +`composerSelectionExtension` (`editor/theme.ts`) picks that per platform. + +When CodeMirror 6.43.9's iOS predicate does not match, +`composerNativeSelectionExtension` layers over `drawSelection()`: it re-shows the native selection, and — only while a range is selected — the native caret, hiding the painted layers those replace. The native selection is the one that shows for two reasons: the painted layer sits behind the content, so tokens -with their own background (inline code, fences) cover it completely; and -iOS's selection drag handles attach to the visible native selection and take -their colour from the caret, so a transparent caret means invisible handles. -The range-only caret scoping is load-bearing — a native caret visible while -typing makes WebKit re-render its caret UI after every keystroke, felt as -severe input lag. The selection tint comes from `--primary`, not the selection -token: -themes define `--interactive-selection` with its own alpha, so a translucent -mix of it is nearly invisible. +with their own background (inline code, fences) cover it completely; and the +platform's selection drag handles attach to the visible native selection and +take their colour from the caret, so a transparent caret means invisible +handles. The range-only caret scoping is load-bearing — a native caret visible +while typing makes the browser re-render its caret UI after every keystroke, +felt as severe input lag. + +When CodeMirror 6.43.9's exact iOS predicate matches, +`composerIOSSelectionExtension` leaves selection-handle geometry and appearance +to CodeMirror. CodeMirror puts the handles in `.cm-selectionLayer`, normally at +`z-index: -1`; the extension raises that layer above the content so opaque +token backgrounds cannot cover them, and leaves it transparent to touch. +The handle dots extend 8px past their range; matching scroller padding and +negative margin expand the clip area without moving the text or changing the +composer height. iOS still paints its taller system selection overlay even +when CSS makes `::selection` transparent. The extension therefore suppresses +CodeMirror's synthetic selection rectangles on iOS while leaving its handles, +cursor path and `nativeSelectionHidden` facet active. Otherwise the grey system +highlight and themed rectangle overlap with visibly different heights. +Do not add a second custom layer or custom handles here: overlapping translucent +rectangles make selection darker at their seams and imitated handles drift from +the geometry WebKit actually manipulates. What iOS avoids is installing the +native-selection workaround above: explicitly restoring native paint and caret +makes WebKit re-measure them after every decoration redraw, and the composer +rebuilds every decoration on every keystroke. That cost is felt worst during +IME composition. + +The non-iOS native selection tint comes from `--primary`, not the selection +token: themes define `--interactive-selection` with its own alpha, so mixing it +with transparent again is nearly invisible. The iOS system overlay owns its +visible selection fill. `composerLanguage.ts` retokenizes the whole document on every change. The composer holds a prompt, not a source file: it is short enough that a full pass diff --git a/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx b/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx index 41a2825f..990f62ee 100644 --- a/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx +++ b/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx @@ -36,7 +36,7 @@ import { cn } from '@/lib/utils'; import type { ComposerLanguageContext } from '../language/tokenize'; import { composerLanguage, setLanguageContext } from './composerLanguage'; import type { ComposerEditorViewStore } from './viewStore'; -import { composerEditorTheme, composerNativeSelectionExtension } from './theme'; +import { composerEditorTheme, composerSelectionExtension } from './theme'; import { handleComposerHostMouseDown } from './hostMouseDown'; export interface ComposerSelection { @@ -234,14 +234,13 @@ export const ComposerEditor = React.forwardRef { /** * EditorView.theme compiles its selectors when this module is imported and @@ -20,13 +33,7 @@ describe('composerEditorTheme', () => { * surfaces only in the running app, where it takes the composer down. */ test('its selectors compile and the theme can be installed', () => { - let failure: unknown = null; - try { - EditorState.create({ extensions: [composerEditorTheme] }); - } catch (error) { - failure = error; - } - expect(failure).toBeNull(); + expect(installationError(composerEditorTheme)).toBeNull(); }); /** @@ -101,6 +108,10 @@ describe('composerEditorTheme', () => { expect(rule.background.includes('transparent')).toBe(true); } }); + + test('the common theme does not re-show the native selection', () => { + expect(selectors.some((selector) => selector.includes('::selection'))).toBe(false); + }); }); describe('composerNativeSelectionTheme', () => { @@ -108,21 +119,16 @@ describe('composerNativeSelectionTheme', () => { const nativeDeclarations = JSON.stringify(NATIVE_SELECTION_THEME_SPEC); /** - * Every device layers this over `drawSelection()`: the native selection - * paints over token backgrounds (the painted layer is hidden behind them) - * and iOS attaches its selection handles to it. `drawSelection()` must - * NOT be removed for that: without it CodeMirror starts enforcing cursor - * association on the native selection while typing in wrapped text, and - * iOS answers those programmatic selection moves with severe input lag. + * Every device except iOS layers this over `drawSelection()`: the native + * selection paints over token backgrounds (the painted layer is hidden + * behind them) and the platform attaches its selection handles to it. + * `drawSelection()` must NOT be removed for that: without it CodeMirror + * starts enforcing cursor association on the native selection while typing + * in wrapped text, and iOS answers those programmatic selection moves with + * severe input lag. */ test('it compiles and can be installed', () => { - let failure: unknown = null; - try { - EditorState.create({ extensions: [composerNativeSelectionExtension] }); - } catch (error) { - failure = error; - } - expect(failure).toBeNull(); + expect(installationError(composerNativeSelectionExtension)).toBeNull(); }); /** @@ -150,9 +156,9 @@ describe('composerNativeSelectionTheme', () => { }); /** - * iOS colours its selection drag handles from the caret colour. With - * `drawSelection()`'s `caret-color: transparent !important` in effect the - * handles are drawn — invisibly. The native caret must come back with + * A platform showing native handles colours them from the caret colour. + * With `drawSelection()`'s `caret-color: transparent !important` in effect + * the handles are drawn — invisibly. The native caret must come back with * enough weight to win, and the drawn cursor layer must go so there are * not two carets. * @@ -195,3 +201,106 @@ describe('composerNativeSelectionTheme', () => { expect(tokens.filter((token) => /[A-Z]/.test(token))).toEqual([]); }); }); + +describe('composerIOSSelectionExtension', () => { + const layerRule = IOS_SELECTION_THEME_SPEC['& .cm-scroller > .cm-selectionLayer']; + const scrollerRule = IOS_SELECTION_THEME_SPEC['& .cm-scroller']; + const selectionBackgroundRule = IOS_SELECTION_THEME_SPEC['& .cm-selectionBackground']; + + test('it compiles and can be installed', () => { + expect(installationError(composerIOSSelectionExtension)).toBeNull(); + }); + + /** + * CodeMirror renders its selection layer at `z-index: -1`, behind the + * text. Inline code and code fences have opaque backgrounds and otherwise + * cover both the selection and the iOS handles. The base value is inline, + * so raising it without `!important` silently does nothing. + */ + test('CodeMirror selection and handles are raised above token backgrounds', () => { + expect(layerRule.zIndex).toBe('100 !important'); + }); + + /** + * The layer now sits over the content and would intercept taps and drags + * by default. It only paints; CodeMirror/WebKit still own the gestures. + */ + test('the layer does not intercept touch', () => { + expect(layerRule.pointerEvents).toBe('none'); + }); + + /** + * A higher z-index cannot escape overflow clipping. CodeMirror's dots + * extend 8px past the range, so the scroller needs that much internal room; + * the matching negative margin keeps the text and composer height fixed. + */ + test('the scroller reserves unclipped room for both handles', () => { + expect(scrollerRule.paddingBlock).toBe('8px'); + expect(scrollerRule.marginBlock).toBe('-8px'); + }); + + test('the CodeMirror fill does not stack over the iOS system highlight', () => { + expect(selectionBackgroundRule.background).toBe('transparent !important'); + }); + + /** + * A second custom layer was visually indistinguishable from duplicate + * native selection UI. iOS must only reposition the one layer that + * CodeMirror already uses for both selection rectangles and handles. + */ + test('it does not add a second selection implementation', () => { + expect(Object.keys(IOS_SELECTION_THEME_SPEC)).toEqual([ + '& .cm-scroller', + '& .cm-scroller > .cm-selectionLayer', + '& .cm-selectionBackground', + ]); + }); +}); + +describe('composerSelectionExtension', () => { + /** + * The split is the point: iOS is the only platform that pays for a visible + * native selection during composition, and CodeMirror 6.43.9 draws its + * handles. Collapsing the two branches into one would + * either restore the latency on iOS or leave every other platform without + * discoverable range selection. + */ + test('the CodeMirror iOS path uses its handles; other platforms keep native selection', () => { + expect(composerSelectionExtension(true)).toBe(composerIOSSelectionExtension); + expect(composerSelectionExtension(false)).toBe(composerNativeSelectionExtension); + }); + + /** + * The composer may remove the native fallback only when CodeMirror's own + * browser predicate enables its replacement handles. This deliberately + * includes CodeMirror's vendor and touch thresholds rather than using a + * broader application-level iOS heuristic. + */ + test('the platform predicate matches CodeMirror 6.43.9', () => { + expect(isCodeMirrorIOSNavigator( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6) Mobile/15E148 Safari/604.1', + 'Apple Computer, Inc.', + 5, + )).toBe(true); + expect(isCodeMirrorIOSNavigator( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)', + 'Apple Computer, Inc.', + 5, + )).toBe(true); + expect(isCodeMirrorIOSNavigator( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)', + 'Google Inc.', + 5, + )).toBe(false); + expect(isCodeMirrorIOSNavigator( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)', + 'Apple Computer, Inc.', + 0, + )).toBe(false); + expect(isCodeMirrorIOSNavigator( + 'Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0)', + 'Apple Computer, Inc.', + 5, + )).toBe(false); + }); +}); diff --git a/packages/ui/src/components/chat/composer/editor/__tests__/writebackCompositionGuard.test.ts b/packages/ui/src/components/chat/composer/editor/__tests__/writebackCompositionGuard.test.ts new file mode 100644 index 00000000..94df2c8f --- /dev/null +++ b/packages/ui/src/components/chat/composer/editor/__tests__/writebackCompositionGuard.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; + +const composerEditorSource = readFileSync( + new URL('../ComposerEditor.tsx', import.meta.url), + 'utf-8', +); + +const writebackEffect = (): string => { + const start = composerEditorSource.indexOf('// Controlled value:'); + expect(start).toBeGreaterThan(-1); + const end = composerEditorSource.indexOf('}, [value]);', start); + expect(end).toBeGreaterThan(start); + return composerEditorSource.slice(start, end); +}; + +describe('composer value writeback composition guard (issue #2527)', () => { + test('checks equality, then composition, before dispatching', () => { + const effect = writebackEffect(); + const equalityCheck = effect.indexOf('if (current === value) return;'); + const compositionGuard = effect.indexOf('if (view.compositionStarted) return;'); + const dispatch = effect.indexOf('view.dispatch({'); + + expect(equalityCheck).toBeGreaterThan(-1); + expect(compositionGuard).toBeGreaterThan(equalityCheck); + expect(dispatch).toBeGreaterThan(compositionGuard); + }); +}); diff --git a/packages/ui/src/components/chat/composer/editor/theme.ts b/packages/ui/src/components/chat/composer/editor/theme.ts index 7a103d9d..ccc9a933 100644 --- a/packages/ui/src/components/chat/composer/editor/theme.ts +++ b/packages/ui/src/components/chat/composer/editor/theme.ts @@ -5,6 +5,7 @@ * language layer emits, so the composer and the message list stay in step. */ +import type { Extension } from '@codemirror/state'; import { EditorView } from '@codemirror/view'; /** @@ -78,23 +79,16 @@ export const COMPOSER_EDITOR_THEME_SPEC = { '&.cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': { background: 'color-mix(in srgb, var(--interactive-selection) 55%, transparent)', }, - // The native selection still shows through in places CodeMirror does not - // draw over, such as the placeholder. Same colour as the native-selection - // theme below, for the same reason: the selection token carries its own - // alpha and reads as nearly invisible when mixed down again. - '& ::selection': { - background: 'color-mix(in srgb, var(--primary) 25%, transparent)', - }, }; export const composerEditorTheme = EditorView.theme(COMPOSER_EDITOR_THEME_SPEC); /** - * Every device keeps `drawSelection()` but shows the NATIVE selection through - * it, for two independent reasons: + * Outside CodeMirror's iOS branch, devices keep `drawSelection()` but show the + * NATIVE selection through it, for two independent reasons: * - * - iOS attaches its selection handles (the draggable pins after a - * double-tap) to the *visible* native selection, and `drawSelection()` + * - Their selection drag handles (the draggable pins after a double-tap) + * attach to the *visible* native selection, and `drawSelection()` * hides it with `.cm-line ::selection { background: transparent * !important }`, so the handles never appear and range selection is * undiscoverable. @@ -103,12 +97,15 @@ export const composerEditorTheme = EditorView.theme(COMPOSER_EDITOR_THEME_SPEC); * the selection is invisible inside those spans. The native selection * paints over element backgrounds. * - * Dropping `drawSelection()` entirely is NOT an option: without it CodeMirror - * clears the `nativeSelectionHidden` facet and starts enforcing cursor - * association on the native selection while typing in wrapped text — - * programmatic selection moves that iOS answers with severe input lag (each - * one also resets the keyboard's autocorrect context). Typing must stay on - * the drawn-selection code path; only the paint changes. + * Dropping `drawSelection()` entirely is NOT an option, on any platform: + * without it CodeMirror clears the `nativeSelectionHidden` facet and starts + * enforcing cursor association on the native selection while typing in + * wrapped text — programmatic selection moves that iOS answers with severe + * input lag (each one also resets the keyboard's autocorrect context). Typing + * must stay on the drawn-selection code path; only the paint changes. + * + * CodeMirror's iOS branch does NOT use this arrangement — + * `composerIOSSelectionExtension` below explains why. * * Both rules below fight `drawSelection()`'s own `Prec.highest` theme, so * they carry `!important` and one class more specificity @@ -155,14 +152,103 @@ export const NATIVE_SELECTION_THEME_SPEC = { const composerNativeSelectionTheme = EditorView.theme(NATIVE_SELECTION_THEME_SPEC); /** - * The native-selection arrangement, installed on every device: the theme - * above plus the `.oc-native-range` marker class that scopes its caret rules - * to the moments a range is actually selected. `editorAttributes` + * The native-selection arrangement, installed outside CodeMirror's iOS branch: + * the theme above plus the `.oc-native-range` marker class that scopes its + * caret rules to the moments a range is actually selected. `editorAttributes` * re-evaluates on every update, so the class follows the selection with no * listener of its own. */ -export const composerNativeSelectionExtension = [ +export const composerNativeSelectionExtension: Extension = [ composerNativeSelectionTheme, EditorView.editorAttributes.of((view) => view.state.selection.main.empty ? null : { class: 'oc-native-range' }), ]; + +/** + * When its iOS predicate matches, CodeMirror 6.43.9 draws the range handles + * into the same layer as the selection, so CodeMirror owns both their geometry + * and appearance. + * + * That layer normally renders at `z-index: -1`, behind the content. Inline + * code and code fences have opaque backgrounds and would cover both the tint + * and handles. Raising the one existing layer fixes that without introducing + * a second set of rectangles or trying to imitate WebKit's controls. The + * layer remains transparent to touch so WebKit receives selection gestures. + * + * What iOS avoids is the native-selection workaround above: explicitly + * restoring the native highlight and caret makes WebKit re-measure and repaint + * that UI after every decoration redraw. `composerLanguage.ts` rebuilds the + * whole decoration set on every keystroke, so the cost is felt worst during + * IME composition where each intermediate replacement pays for it. WebKit's + * unavoidable system selection overlay remains the only visible fill. + */ +export const IOS_SELECTION_THEME_SPEC = { + // The handles extend 8px above/below their range. The scroller clips them + // at its own edge even when the layer has a high z-index, so reserve that + // room inside the clipping box and pull the box outward by the same amount. + // Text and composer height stay where they were; only the clip area grows. + '& .cm-scroller': { + marginBlock: '-8px', + paddingBlock: '8px', + }, + '& .cm-scroller > .cm-selectionLayer': { + // CodeMirror writes `z-index: -1` inline. `!important` is intentional: + // without it token backgrounds cover the selection and its handles. + zIndex: '100 !important', + pointerEvents: 'none', + }, + // iOS keeps showing its taller system selection overlay even when + // ::selection is transparent. Painting CodeMirror's themed rectangles as + // well produces two visibly misaligned fills, so only the synthetic + // background is suppressed. The handles in this layer remain visible. + '& .cm-selectionBackground': { + background: 'transparent !important', + }, +}; + +export const composerIOSSelectionExtension: Extension = + EditorView.theme(IOS_SELECTION_THEME_SPEC); + +/** + * Which selection paint the composer installs. The split is the platform's, + * not a preference: iOS is the one place where restoring native selection + * paint and caret costs measurable input latency, and the only place + * CodeMirror supplies replacement drag handles. + * + * The caller can pass the policy, so the choice stays testable and is made + * once per editor rather than once per module load. + */ +export function composerSelectionExtension( + useCodeMirrorIOSHandles: boolean = usesCodeMirrorIOSSelectionHandles(), +): Extension { + return useCodeMirrorIOSHandles + ? composerIOSSelectionExtension + : composerNativeSelectionExtension; +} + +/** + * Mirrors @codemirror/view 6.43.9's iOS predicate. This branch may only rely + * on the drawn handles when CodeMirror itself will create them; a broader iOS + * heuristic could remove the native fallback without installing a replacement. + */ +export function isCodeMirrorIOSNavigator( + userAgent: string, + vendor: string, + maxTouchPoints: number, +): boolean { + const isIE = /Edge\/(\d+)/.test(userAgent) + || /MSIE \d/.test(userAgent) + || /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.test(userAgent); + if (isIE || !/Apple Computer/.test(vendor)) return false; + return /Mobile\/\w+/.test(userAgent) || maxTouchPoints > 2; +} + +function usesCodeMirrorIOSSelectionHandles(): boolean { + const nav = globalThis.navigator; + if (!nav) return false; + return isCodeMirrorIOSNavigator( + nav.userAgent || '', + nav.vendor || '', + nav.maxTouchPoints ?? 0, + ); +} diff --git a/packages/ui/src/components/chat/markdown/highlightResultCache.ts b/packages/ui/src/components/chat/markdown/highlightResultCache.ts new file mode 100644 index 00000000..a73fd0bc --- /dev/null +++ b/packages/ui/src/components/chat/markdown/highlightResultCache.ts @@ -0,0 +1,125 @@ +// Bounded LRU for rendered markdown / Shiki highlight results. +// +// Used by `markdownCore` (per-block HTML) and by the main-thread markdown +// worker client (highlight results) so unchanged content is never re-rendered +// or re-tokenized. Keys are short content fingerprints (not the full source) so +// cache maps do not duplicate large strings. Entry byte sizes are recorded once +// at insert time — get/evict never re-walk the payload. + +export type HighlightResultCacheOptions = { + maxEntries: number; + maxBytes: number; +}; + +type CacheEntry = { + value: T; + bytes: number; +}; + +/** UTF-16 storage estimate for a JS string (chars × 2). Avoids TextEncoder allocs. */ +export const utf16Bytes = (value: string): number => value.length * 2; + +/** Final avalanche so near-identical sources do not land in adjacent buckets. */ +const mix32 = (hash: number): number => { + let h = hash; + h ^= h >>> 16; + h = Math.imul(h, 0x85ebca6b); + h ^= h >>> 13; + return h >>> 0; +}; + +/** + * Short stable fingerprint for cache keys: length + two independent 32-bit + * multiplicative hashes (~64 bits of key space). + * + * These caches are content-addressed and global, so a collision does not merely + * mis-color a block — the cache returns a *different* block's rendered HTML and + * the user is shown source they never wrote. One 32-bit hash is not enough for + * that failure mode: a few thousand same-length entries reach a birthday + * collision probability worth caring about, and the result would be + * undiagnosable in the field. Two multiplies per character are free next to + * Shiki tokenization. + */ +export const contentFingerprint = (value: string): string => { + let h1 = 0x811c9dc5; + let h2 = 0xc2b2ae35; + for (let i = 0; i < value.length; i += 1) { + const code = value.charCodeAt(i); + h1 = Math.imul(h1 ^ code, 0x01000193); + h2 = Math.imul(h2 ^ code, 0x27220a95); + } + return `${value.length.toString(36)}_${mix32(h1).toString(36)}_${mix32(h2).toString(36)}`; +}; + +/** Approximate byte cost of token-run lines without JSON.stringify. */ +export const estimateTokenRunsBytes = ( + lines: ReadonlyArray>, +): number => { + let total = 0; + for (const line of lines) { + total += 4; + for (const run of line) { + total += 8 + utf16Bytes(run[1]); + } + } + return total; +}; + +export class HighlightResultCache { + private readonly maxEntries: number; + private readonly maxBytes: number; + private readonly map = new Map>(); + private totalBytes = 0; + + constructor(options: HighlightResultCacheOptions) { + this.maxEntries = Math.max(1, options.maxEntries); + this.maxBytes = Math.max(1, options.maxBytes); + } + + get size(): number { + return this.map.size; + } + + get bytes(): number { + return this.totalBytes; + } + + get(key: string): T | undefined { + const entry = this.map.get(key); + if (entry === undefined) return undefined; + // Refresh LRU order without recomputing size. + this.map.delete(key); + this.map.set(key, entry); + return entry.value; + } + + set(key: string, value: T, bytes: number): void { + const existing = this.map.get(key); + if (existing !== undefined) { + this.totalBytes -= existing.bytes; + this.map.delete(key); + } + + const entryBytes = Math.max(0, bytes); + while ( + this.map.size > 0 + && (this.map.size >= this.maxEntries || this.totalBytes + entryBytes > this.maxBytes) + ) { + const oldest = this.map.keys().next().value; + if (oldest === undefined) break; + const oldestEntry = this.map.get(oldest); + if (oldestEntry !== undefined) this.totalBytes -= oldestEntry.bytes; + this.map.delete(oldest); + // Always allow a single oversized entry so huge files still cache once. + if (this.map.size === 0) break; + } + + this.map.set(key, { value, bytes: entryBytes }); + this.totalBytes += entryBytes; + } + + clear(): void { + this.map.clear(); + this.totalBytes = 0; + } +} diff --git a/packages/ui/src/components/chat/markdown/markdown-worker.ts b/packages/ui/src/components/chat/markdown/markdown-worker.ts index 85d061bd..ab5b6eb6 100644 --- a/packages/ui/src/components/chat/markdown/markdown-worker.ts +++ b/packages/ui/src/components/chat/markdown/markdown-worker.ts @@ -1,4 +1,10 @@ import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url'; +import { + contentFingerprint, + estimateTokenRunsBytes, + HighlightResultCache, + utf16Bytes, +} from './highlightResultCache'; import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol'; // Main-thread client for the markdown Shiki worker. Moves syntax tokenization @@ -6,9 +12,39 @@ import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } // ready-to-splice Shiki HTML. On any failure (no worker support, worker crash, // tokenization error) the promise resolves to `null` and the caller keeps the // escaped plain-text code — highlighting never falls back onto the main thread. +// +// Results are memoized by content fingerprint (+ lang / theme). Unchanged +// content must not re-enter the worker — that was the sustained ~40 msg/s +// re-highlight load in openchamber/openchamber#2769. In-flight requests with +// the same key coalesce so remount storms share one round-trip. Cache keys are +// fingerprints (not full source) so large files are not duplicated in the Map. +// +// This module is the only sender to the worker, so memoizing here is sufficient +// and the worker itself stays stateless apart from the Shiki instance. A second +// cache inside the worker would only duplicate these payloads in another heap. +// +// `highlight` / `highlightLines` results are theme-independent: the worker +// tokenizes with the CSS-variable `MARKDOWN_SHIKI_THEME`, so a theme switch +// repaints via CSS and must not invalidate these entries. Only +// `highlightTokens` resolves concrete colors, so only its key carries a theme. type PendingResolver = (response: MarkdownWorkerResponse | null) => void; +type CachedHighlight = + | { type: 'highlight'; html: string } + | { type: 'highlightLines'; lines: string[] } + | { type: 'highlightTokens'; lines: MarkdownTokenRun[][] }; + +const CLIENT_CACHE_MAX_ENTRIES = 2000; +const CLIENT_CACHE_MAX_BYTES = 24 * 1024 * 1024; + +const resultCache = new HighlightResultCache({ + maxEntries: CLIENT_CACHE_MAX_ENTRIES, + maxBytes: CLIENT_CACHE_MAX_BYTES, +}); + +const inflight = new Map>(); + let worker: Worker | undefined; let nextId = 0; const pending = new Map(); @@ -16,10 +52,23 @@ const pending = new Map(); // repeat tokenization sends only the name (not the whole theme object) again. const sentThemes = new Set(); +const entryBytes = (key: string, value: CachedHighlight): number => { + const keyBytes = utf16Bytes(key); + if (value.type === 'highlight') return keyBytes + utf16Bytes(value.html); + if (value.type === 'highlightLines') { + let total = keyBytes; + for (const line of value.lines) total += utf16Bytes(line); + return total; + } + return keyBytes + estimateTokenRunsBytes(value.lines); +}; + const failAll = (): void => { pending.forEach((resolve) => resolve(null)); pending.clear(); sentThemes.clear(); + // Drop in-flight waiters; cached results remain valid (pure fn of inputs). + inflight.clear(); worker?.terminate(); worker = undefined; }; @@ -55,13 +104,47 @@ const request = (payload: (id: number) => MarkdownWorkerRequest): Promise Promise, +): Promise => { + const existing = inflight.get(key); + if (existing) return existing; + const pendingRequest = run().finally(() => { + inflight.delete(key); + }); + inflight.set(key, pendingRequest); + return pendingRequest; +}; + +const cacheKeyFor = (kind: string, lang: string, code: string, themeName?: string): string => { + const fp = contentFingerprint(code); + return themeName === undefined ? `${kind}:${lang}:${fp}` : `${kind}:${themeName}:${lang}:${fp}`; +}; + +/** Test-only: clear client-side highlight memoization. */ +export const resetMarkdownWorkerClientCacheForTests = (): void => { + resultCache.clear(); + inflight.clear(); +}; + /** * Highlight a complete code block in the worker. Resolves to Shiki `
` HTML,
  * or `null` if highlighting is unavailable or failed (caller keeps plain code).
  */
 export const highlightCodeInWorker = async (code: string, lang: string): Promise => {
-  const response = await request((id) => ({ type: 'highlight', id, code, lang }));
-  return response?.type === 'highlight' ? response.html : null;
+  const key = cacheKeyFor('highlight', lang, code);
+  const cached = resultCache.get(key);
+  if (cached?.type === 'highlight') return cached.html;
+
+  const result = await coalesce(key, async () => {
+    const response = await request((id) => ({ type: 'highlight', id, code, lang }));
+    if (response?.type !== 'highlight') return null;
+    const entry: CachedHighlight = { type: 'highlight', html: response.html };
+    resultCache.set(key, entry, entryBytes(key, entry));
+    return entry;
+  });
+  return result?.type === 'highlight' ? result.html : null;
 };
 
 /**
@@ -70,8 +153,18 @@ export const highlightCodeInWorker = async (code: string, lang: string): Promise
  * round-trip instead of one per line. Resolves to `null` on failure.
  */
 export const highlightLinesInWorker = async (code: string, lang: string): Promise => {
-  const response = await request((id) => ({ type: 'highlightLines', id, code, lang }));
-  return response?.type === 'highlightLines' ? response.lines : null;
+  const key = cacheKeyFor('highlightLines', lang, code);
+  const cached = resultCache.get(key);
+  if (cached?.type === 'highlightLines') return cached.lines;
+
+  const result = await coalesce(key, async () => {
+    const response = await request((id) => ({ type: 'highlightLines', id, code, lang }));
+    if (response?.type !== 'highlightLines') return null;
+    const entry: CachedHighlight = { type: 'highlightLines', lines: response.lines };
+    resultCache.set(key, entry, entryBytes(key, entry));
+    return entry;
+  });
+  return result?.type === 'highlightLines' ? result.lines : null;
 };
 
 /**
@@ -86,18 +179,25 @@ export const highlightTokensInWorker = async (
   themeName: string,
   theme: unknown,
 ): Promise => {
-  const needsTheme = !sentThemes.has(themeName);
-  const response = await request((id) => ({
-    type: 'highlightTokens',
-    id,
-    code,
-    lang,
-    themeName,
-    ...(needsTheme ? { theme } : {}),
-  }));
-  if (response?.type === 'highlightTokens') {
+  const key = cacheKeyFor('highlightTokens', lang, code, themeName);
+  const cached = resultCache.get(key);
+  if (cached?.type === 'highlightTokens') return cached.lines;
+
+  const result = await coalesce(key, async () => {
+    const needsTheme = !sentThemes.has(themeName);
+    const response = await request((id) => ({
+      type: 'highlightTokens',
+      id,
+      code,
+      lang,
+      themeName,
+      ...(needsTheme ? { theme } : {}),
+    }));
+    if (response?.type !== 'highlightTokens') return null;
     sentThemes.add(themeName);
-    return response.lines;
-  }
-  return null;
+    const entry: CachedHighlight = { type: 'highlightTokens', lines: response.lines };
+    resultCache.set(key, entry, entryBytes(key, entry));
+    return entry;
+  });
+  return result?.type === 'highlightTokens' ? result.lines : null;
 };
diff --git a/packages/ui/src/components/chat/markdown/markdownCore.ts b/packages/ui/src/components/chat/markdown/markdownCore.ts
index c82c2218..2d3cb7a9 100644
--- a/packages/ui/src/components/chat/markdown/markdownCore.ts
+++ b/packages/ui/src/components/chat/markdown/markdownCore.ts
@@ -4,6 +4,7 @@ import katex from 'katex';
 import DOMPurify from 'dompurify';
 import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks';
 import { isVSCodeRuntime } from '@/lib/desktop';
+import { contentFingerprint, HighlightResultCache, utf16Bytes } from './highlightResultCache';
 import { highlightCodeInWorker } from './markdown-worker';
 import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
 
@@ -415,32 +416,37 @@ const highlightCodeBlocks = async (html: string): Promise => {
 
   const lineLimit = isVSCodeRuntime() ? VSCODE_CODE_HIGHLIGHT_LINE_LIMIT : CODE_HIGHLIGHT_LINE_LIMIT;
 
-  let result = html;
-  for (const match of matches) {
-    const [full, rawLang, escapedCode] = match;
-    const requested = (rawLang || 'text').toLowerCase();
-    // Leave mermaid fences untouched so the decorate pass can render them as
-    // diagrams (highlighting would strip the `language-mermaid` class).
-    if (requested === 'mermaid') continue;
+  // Highlight all eligible fences concurrently — sequential await was O(n)
+  // worker round-trips for messages with multiple code blocks.
+  const replacements = await Promise.all(
+    matches.map(async (match) => {
+      const [full, rawLang, escapedCode] = match;
+      const requested = (rawLang || 'text').toLowerCase();
+      // Leave mermaid fences untouched so the decorate pass can render them as
+      // diagrams (highlighting would strip the `language-mermaid` class).
+      if (requested === 'mermaid') return null;
 
-    const code = unescapeHtml(escapedCode ?? '');
+      const code = unescapeHtml(escapedCode ?? '');
 
-    // Oversized block: skip highlight, keep plain code but stamp the language.
-    if (exceedsLineLimit(code, lineLimit)) {
-      result = result.replace(full, () => full.replace(' (no main-thread highlight).
-    const highlighted = await highlightCodeInWorker(code, requested);
-    if (highlighted) {
+      // Tokenize off the main thread. On failure the worker resolves to null and
+      // we keep the original escaped 
 (no main-thread highlight).
+      const highlighted = await highlightCodeInWorker(code, requested);
+      if (!highlighted) return null;
       // Stamp the language so the decorate pass can show a header label.
-      const stamped = highlighted.replace(/^
 stamped);
-    }
-  }
+      return { full, next: highlighted.replace(/^
 replacement.next);
+  }
   return result;
 };
 
@@ -483,29 +489,60 @@ const sanitize = (html: string): string => {
 
 
 // ---------------------------------------------------------------------------
-// Per-block HTML cache (LRU, mirrors OpenCode's checksum cache)
+// Per-block HTML cache (content-addressed LRU)
 // ---------------------------------------------------------------------------
+//
+// Keyed by content hash + mode + highlight flag + image mode — NOT by renderer
+// instance id. `SimpleMarkdownRenderer` historically used a shared
+// `simple:${variant}` key, so every same-variant instance fought over one cache
+// slot and re-highlighted unchanged content on every pass
+// (openchamber/openchamber#2769). Content addressing makes identical blocks
+// share one entry and stops that thrash. Bounds are high enough for long
+// sessions; byte cap keeps memory bounded.
+//
+// `full` (settled) and `live` (trailing, still streaming) blocks get separate
+// caches. A live block's content changes on every stream step, so under one
+// shared content-addressed cache each step would insert a new entry and a long
+// streaming message would evict the settled blocks this fix exists to keep
+// warm. The live cache is small on purpose: it only has to absorb repeat
+// renders of the *same* step.
 
-const CACHE_MAX = 240;
-const htmlCache = new Map();
+const FULL_CACHE_MAX_ENTRIES = 2000;
+const FULL_CACHE_MAX_BYTES = 24 * 1024 * 1024;
+const LIVE_CACHE_MAX_ENTRIES = 32;
+const LIVE_CACHE_MAX_BYTES = 2 * 1024 * 1024;
 
-// FNV-1a 32-bit hash of the block content.
-const hash = (value: string): string => {
-  let h = 0x811c9dc5;
-  for (let i = 0; i < value.length; i += 1) {
-    h ^= value.charCodeAt(i);
-    h = Math.imul(h, 0x01000193);
-  }
-  return (h >>> 0).toString(36);
+const fullBlockCache = new HighlightResultCache({
+  maxEntries: FULL_CACHE_MAX_ENTRIES,
+  maxBytes: FULL_CACHE_MAX_BYTES,
+});
+const liveBlockCache = new HighlightResultCache({
+  maxEntries: LIVE_CACHE_MAX_ENTRIES,
+  maxBytes: LIVE_CACHE_MAX_BYTES,
+});
+
+const cacheForMode = (mode: MarkdownBlock['mode']): HighlightResultCache =>
+  (mode === 'live' ? liveBlockCache : fullBlockCache);
+
+/** Content-addressed cache key for a markdown block. */
+const markdownBlockCacheKey = (
+  contentHash: string,
+  mode: MarkdownBlock['mode'],
+  highlight: boolean,
+  imageMode: MarkdownImageMode,
+): string => `${contentHash}:${mode}:${highlight ? 1 : 0}:${imageMode}`;
+
+/** Test-only: clear the render HTML caches between cases. */
+export const resetMarkdownHtmlCacheForTests = (): void => {
+  fullBlockCache.clear();
+  liveBlockCache.clear();
 };
 
-const touch = (key: string, entry: { hash: string; html: string }): void => {
-  htmlCache.delete(key);
-  htmlCache.set(key, entry);
-  if (htmlCache.size <= CACHE_MAX) return;
-  const oldest = htmlCache.keys().next().value;
-  if (oldest) htmlCache.delete(oldest);
-};
+/** Test-only: entry counts per block cache, for churn/eviction assertions. */
+export const __markdownBlockCacheSizesForTests = (): { full: number; live: number } => ({
+  full: fullBlockCache.size,
+  live: liveBlockCache.size,
+});
 
 const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise => {
   const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
@@ -545,28 +582,29 @@ export type RenderedBlock = {
  * splits into blocks, caches per-block, heals incomplete syntax. Returning
  * blocks (instead of one joined string) lets the renderer re-morph only the
  * block that changed, keeping per-step streaming cost ~O(last block).
+ *
+ * Lookup is content-addressed: distinct renderers holding identical blocks
+ * share one entry and cannot evict each other by identity collision.
  */
 export const renderMarkdownBlocks = async (
   text: string,
   streaming: boolean,
-  cacheKey: string,
   imageMode: MarkdownImageMode = 'inline',
 ): Promise => {
   if (!text) return [];
 
   const blocks = streamBlocks(text, streaming);
   return Promise.all(
-    blocks.map(async (block, index) => {
-      const contentHash = hash(block.raw);
-      const id = `${contentHash}:${block.mode}:${block.highlight ? 1 : 0}:${imageMode}`;
-      const key = `${cacheKey}:${index}:${block.mode}:${imageMode}`;
-      const cached = htmlCache.get(key);
-      if (cached && cached.hash === contentHash) {
-        touch(key, cached);
-        return { id, html: cached.html };
+    blocks.map(async (block) => {
+      const contentHash = contentFingerprint(block.raw);
+      const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight, imageMode);
+      const cache = cacheForMode(block.mode);
+      const cached = cache.get(id);
+      if (cached !== undefined) {
+        return { id, html: cached };
       }
       const html = await parseBlock(block, imageMode);
-      touch(key, { hash: contentHash, html });
+      cache.set(id, html, utf16Bytes(id) + utf16Bytes(html));
       return { id, html };
     }),
   );
diff --git a/packages/ui/src/components/chat/markdown/markdownHighlightingRepro.test.ts b/packages/ui/src/components/chat/markdown/markdownHighlightingRepro.test.ts
new file mode 100644
index 00000000..db51aad5
--- /dev/null
+++ b/packages/ui/src/components/chat/markdown/markdownHighlightingRepro.test.ts
@@ -0,0 +1,242 @@
+/**
+ * Regression tests for https://github.com/openchamber/openchamber/issues/2769
+ *
+ * Sustained Shiki worker CPU came from re-tokenizing unchanged content:
+ *  1. `htmlCache` keyed by renderer identity (`simple:${variant}`) so
+ *     same-variant instances evicted each other every pass.
+ *  2. LRU capped at 240 entries, so long sessions missed 100% on every pass.
+ *  3. Worker/client had no result memoization.
+ *
+ * These tests assert the fixed contracts: content-addressed caching, room for
+ * long sessions, bounded LRU behavior, and fingerprint-key helpers.
+ */
+import { beforeEach, describe, expect, mock, test } from 'bun:test';
+
+import {
+  contentFingerprint,
+  estimateTokenRunsBytes,
+  HighlightResultCache,
+  utf16Bytes,
+} from './highlightResultCache';
+
+let highlightCalls = 0;
+let highlightInflight = 0;
+let highlightMaxInflight = 0;
+
+const highlightCodeInWorkerMock = mock(async (code: string, lang: string) => {
+  highlightCalls += 1;
+  highlightInflight += 1;
+  highlightMaxInflight = Math.max(highlightMaxInflight, highlightInflight);
+  await Promise.resolve();
+  highlightInflight -= 1;
+  return `
${code}
`; +}); + +mock.module('./markdown-worker', () => ({ + highlightCodeInWorker: highlightCodeInWorkerMock, + highlightLinesInWorker: mock(async () => null), + highlightTokensInWorker: mock(async () => null), + resetMarkdownWorkerClientCacheForTests: mock(() => undefined), +})); + +const { + renderMarkdownBlocks, + resetMarkdownHtmlCacheForTests, + __markdownBlockCacheSizesForTests, +} = await import('./markdownCore'); + +const { resetMarkdownWorkerClientCacheForTests } = await import('./markdown-worker'); + +beforeEach(() => { + resetMarkdownHtmlCacheForTests(); + resetMarkdownWorkerClientCacheForTests(); + highlightCalls = 0; + highlightInflight = 0; + highlightMaxInflight = 0; +}); + +describe('HighlightResultCache', () => { + test('returns cached values for identical keys and refreshes LRU order', () => { + const cache = new HighlightResultCache({ maxEntries: 2, maxBytes: 10_000 }); + cache.set('a', 'one', utf16Bytes('a') + utf16Bytes('one')); + cache.set('b', 'two', utf16Bytes('b') + utf16Bytes('two')); + expect(cache.get('a')).toBe('one'); + // Touch `a` so `b` is oldest; inserting `c` should evict `b`. + cache.set('c', 'three', utf16Bytes('c') + utf16Bytes('three')); + expect(cache.get('b')).toEqual(undefined); + expect(cache.get('a')).toBe('one'); + expect(cache.get('c')).toBe('three'); + }); + + test('evicts by byte budget while still caching a single oversized entry', () => { + const cache = new HighlightResultCache({ maxEntries: 10, maxBytes: 64 }); + cache.set('small', 'x', utf16Bytes('small') + utf16Bytes('x')); + cache.set('huge', 'y'.repeat(200), utf16Bytes('huge') + utf16Bytes('y'.repeat(200))); + expect(cache.get('huge')).toBe('y'.repeat(200)); + // Oversized insert cleared prior entries to make room. + expect(cache.size).toBe(1); + }); + + test('contentFingerprint is stable and length-qualified', () => { + expect(contentFingerprint('const x = 1')).toBe(contentFingerprint('const x = 1')); + expect(contentFingerprint('const x = 1')).not.toBe(contentFingerprint('const x = 2')); + expect(contentFingerprint('ab')).not.toBe(contentFingerprint('abc')); + }); + + test('contentFingerprint stays collision-free across a realistic session', () => { + // A collision here does not mis-color a block — it returns a *different* + // block's HTML, showing the user source they never wrote. Keep enough key + // space that a session-sized working set never collides. + const seen = new Map(); + for (let i = 0; i < 20_000; i += 1) { + // Same-length, near-identical sources are the realistic worst case: + // repeated tool output differing by a few characters. + const source = `const value_${String(i).padStart(6, '0')} = ${String(i).padStart(6, '0')};`; + const fingerprint = contentFingerprint(source); + expect(seen.get(fingerprint) ?? source).toBe(source); + seen.set(fingerprint, source); + } + expect(seen.size).toBe(20_000); + }); + + test('estimateTokenRunsBytes avoids JSON and stays positive', () => { + const lines: Array> = [ + [[3, '#fff', 0], [1, '', 1]], + [[8, 'var(--md-syntax-keyword)', 0]], + ]; + expect(estimateTokenRunsBytes(lines)).toBeGreaterThan(0); + }); +}); + +describe('markdownCore content-addressed htmlCache (#2769)', () => { + test('repeat renders of unchanged content never re-enter the worker', async () => { + const toolOutputA = '```ts\nconst a = 1;\n```'; + const toolOutputB = '```ts\nconst b = 2;\n```'; + + // First pass: cold miss for each distinct block. + await renderMarkdownBlocks(toolOutputA, false); + await renderMarkdownBlocks(toolOutputB, false); + const coldCalls = highlightCalls; + expect(coldCalls).toBeGreaterThan(0); + + // 100 more passes. Renderers used to pass a shared `simple:${variant}` + // identity key here and evict each other every pass; lookup is now + // content-addressed, so no additional worker calls may happen. + for (let pass = 0; pass < 100; pass += 1) { + await renderMarkdownBlocks(toolOutputA, false); + await renderMarkdownBlocks(toolOutputB, false); + } + + expect(highlightCalls).toBe(coldCalls); + }); + + test('long sessions (working set > former 240 cap) stay warm across re-render passes', async () => { + const parts = Array.from({ length: 600 }, (_, i) => ({ + content: `\`\`\`ts\nconst value_${i} = ${i};\n\`\`\``, + })); + + for (const part of parts) { + await renderMarkdownBlocks(part.content, false); + } + const afterCold = highlightCalls; + expect(afterCold).toBe(parts.length); + + for (let pass = 0; pass < 5; pass += 1) { + for (const part of parts) { + await renderMarkdownBlocks(part.content, false); + } + } + + // Unchanged content must not re-enter the worker. + expect(highlightCalls).toBe(afterCold); + }); + + test('content changes invalidate only the changed block', async () => { + const stable = '```ts\nconst stable = true;\n```'; + const changing = '```ts\nconst n = 1;\n```'; + + await renderMarkdownBlocks(stable, false); + await renderMarkdownBlocks(changing, false); + const afterFirst = highlightCalls; + + await renderMarkdownBlocks(stable, false); + await renderMarkdownBlocks('```ts\nconst n = 2;\n```', false); + expect(highlightCalls).toBe(afterFirst + 1); + + await renderMarkdownBlocks(stable, false); + expect(highlightCalls).toBe(afterFirst + 1); + }); + + test('image mode is part of the cache identity, not shared across modes', async () => { + const source = '![diagram](https://example.com/a.png)'; + + const [inline] = await renderMarkdownBlocks(source, false, 'inline'); + expect(__markdownBlockCacheSizesForTests().full).toBe(1); + + // Same source, different rendering: content addressing must not let the + // first-rendered mode answer for both. + const [label] = await renderMarkdownBlocks(source, false, 'label'); + expect(inline?.id).not.toBe(label?.id); + expect(__markdownBlockCacheSizesForTests().full).toBe(2); + + // Re-rendering a mode already seen stays a cache hit. + const [inlineAgain] = await renderMarkdownBlocks(source, false, 'inline'); + expect(inlineAgain?.id).toBe(inline?.id); + expect(__markdownBlockCacheSizesForTests().full).toBe(2); + }); + + test('streaming a message does not evict settled blocks (live cache is separate)', async () => { + const settled = Array.from( + { length: 40 }, + (_, i) => `\`\`\`ts\nconst settled_${i} = ${i};\n\`\`\``, + ); + for (const block of settled) { + await renderMarkdownBlocks(block, false); + } + const settledEntries = __markdownBlockCacheSizesForTests().full; + expect(settledEntries).toBe(settled.length); + const afterSettled = highlightCalls; + + // Stream a message: every step is new content for the trailing live block, + // so a single shared content-addressed cache would insert one entry per + // step and evict the settled working set this fix exists to keep warm. + let streamed = ''; + for (let step = 0; step < 150; step += 1) { + streamed += `word_${step} `; + await renderMarkdownBlocks(streamed, true); + } + + const sizes = __markdownBlockCacheSizesForTests(); + expect(sizes.live).toBeLessThanOrEqual(32); + expect(sizes.full).toBe(settledEntries); + + for (const block of settled) { + await renderMarkdownBlocks(block, false); + } + expect(highlightCalls).toBe(afterSettled); + }); + + test('a repeated streaming step is served from the live cache', async () => { + const step = 'partial answer text'; + const [first] = await renderMarkdownBlocks(step, true); + const [second] = await renderMarkdownBlocks(step, true); + + expect(second?.id).toBe(first?.id); + expect(__markdownBlockCacheSizesForTests()).toEqual({ full: 0, live: 1 }); + }); + + test('multiple code fences in one document highlight concurrently', async () => { + const multi = [ + '```ts\nconst a = 1;\n```', + '', + '```ts\nconst b = 2;\n```', + '', + '```ts\nconst c = 3;\n```', + ].join('\n'); + + await renderMarkdownBlocks(multi, false); + expect(highlightCalls).toBe(3); + // Sequential awaits would keep max inflight at 1. + expect(highlightMaxInflight).toBeGreaterThan(1); + }); +}); diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 44e53291..fa0bbfa3 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -41,7 +41,7 @@ import { ToolRevealOnMount } from './parts/ToolRevealOnMount'; import { StaticToolRow } from './parts/ProgressiveGroup'; import { isExpandableTool, isStandaloneTool } from './parts/toolRenderUtils'; import TurnActivity from '../components/TurnActivity'; -import { createProjectPlanFile } from '@/lib/openchamberConfig'; +import { useProjectContextStore } from '@/stores/useProjectContextStore'; import { resolveProjectForSessionDirectory } from '@/lib/projectResolution'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useI18n } from '@/lib/i18n'; @@ -1509,7 +1509,7 @@ const AssistantMessageBody = React.memo(({ setIsSavingPlan(true); try { - const created = await createProjectPlanFile(currentProjectRef, { + const created = await useProjectContextStore.getState().createPlan(currentProjectRef, { title, body: assistantPlanText, }); @@ -1517,9 +1517,6 @@ const AssistantMessageBody = React.memo(({ toast.error(t('chat.messageBody.toast.savePlanFailed')); return; } - window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', { - detail: { projectId: currentProjectRef.id }, - })); setIsPlanDialogOpen(false); toast.success(t('chat.messageBody.toast.planSaved')); } finally { diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx index c8ecc9f4..ab1947c1 100644 --- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -9,7 +9,8 @@ import { cn } from '@/lib/utils'; import { copyTextToClipboard } from '@/lib/clipboard'; import { toast } from '@/components/ui'; import { Icon } from "@/components/icon/Icon"; -import { OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH, getProjectNotesAndTodos, saveProjectNotesAndTodos } from '@/lib/openchamberConfig'; +import { PROJECT_NOTE_BODY_MAX_LENGTH } from '@/lib/projectContextApi'; +import { useProjectContextStore } from '@/stores/useProjectContextStore'; import { summarizeSelectionForNotes } from '@/lib/smallModel'; import { resolveProjectForSessionDirectory } from '@/lib/projectResolution'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; @@ -34,15 +35,9 @@ interface SelectionPayload { rect: DOMRect; } -const appendDistilledInsightToNotes = (existingNotes: string, insight: string): string => { - const trimmedInsight = insight.trim().replace(/^[-*+]\s+/, '').slice(0, OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH); - if (!trimmedInsight) { - return existingNotes; - } - - const trimmedNotes = existingNotes.trimEnd(); - return trimmedNotes ? `${trimmedNotes}\n${trimmedInsight}` : trimmedInsight; -}; +const normalizeDistilledInsight = (insight: string): string => ( + insight.trim().replace(/^[-*+]\s+/, '').slice(0, PROJECT_NOTE_BODY_MAX_LENGTH) +); const DESKTOP_MENU_SIDE_MARGIN_PX = 8; const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280; @@ -366,19 +361,22 @@ export const TextSelectionMenu: React.FC = ({ containerR // Long selections are distilled into a compact note by the small model; // short ones (and any generation failure) go in verbatim. const noteText = await summarizeSelectionForNotes(selectedTextMarkdown || selectedText, currentSessionId); - const projectData = await getProjectNotesAndTodos(currentProjectRef); - const nextNotes = appendDistilledInsightToNotes(projectData.notes, noteText); - const saved = await saveProjectNotesAndTodos(currentProjectRef, { - notes: nextNotes, - todos: projectData.todos, + const insight = normalizeDistilledInsight(noteText); + if (!insight) { + toast.error(t('chat.textSelection.toast.addToNotesFailed')); + return; + } + // Recorded as its own note with provenance, so the distilled insight can + // later be traced back to the conversation it came from. + const saved = await useProjectContextStore.getState().createNote(currentProjectRef, { + body: insight, + source: 'selection', + ...(currentSessionId ? { origin: { sessionId: currentSessionId } } : {}), }); if (!saved) { toast.error(t('chat.textSelection.toast.addToNotesFailed')); return; } - window.dispatchEvent(new CustomEvent('openchamber:project-notes-updated', { - detail: { projectId: currentProjectRef.id }, - })); toast.success(t('chat.textSelection.toast.addToNotesSuccess')); hideMenu(); window.getSelection()?.removeAllRanges(); diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 9b627aef..29b5553b 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -1259,6 +1259,7 @@ const ToolExpandedContent: React.FC = React.memo(({ const hasVisualDiffEntry = diffEntries.some((entry) => entry.renderMode === 'diff'); const hideToolInputPreview = part.tool === 'openchamber' || part.tool === 'openchamber_web' + || part.tool === 'openchamber_memory' || part.tool === 'apply_patch' || part.tool === 'edit' || part.tool === 'multiedit'; diff --git a/packages/ui/src/components/chat/message/parts/toolPresentation.tsx b/packages/ui/src/components/chat/message/parts/toolPresentation.tsx index 9219fde1..f4b627ba 100644 --- a/packages/ui/src/components/chat/message/parts/toolPresentation.tsx +++ b/packages/ui/src/components/chat/message/parts/toolPresentation.tsx @@ -59,6 +59,9 @@ export const getToolIcon = (toolName: string) => { if (tool === 'openchamber_web') { return ; } + if (tool === 'openchamber_memory') { + return ; + } if (tool === 'question') { return ; } diff --git a/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx index d7ba437c..ff664261 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx @@ -10,7 +10,13 @@ import { useSession } from '@/sync/sync-context'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { getLinkedIssues, parseLinkedIssueRef, type LinkedIssue } from '@/lib/linkedIssues'; import { linkedEntityLiveInvalidate, useLinkedEntityLive, type LinkedEntityLive } from '@/lib/linkedEntityLive'; +import { fetchSessionKnowledgeSummary, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi'; +import { resolveProjectForSessionDirectory } from '@/lib/projectResolution'; import { setLinkedIssue } from '@/sync/session-actions'; +import { useProjectContextStore } from '@/stores/useProjectContextStore'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { WorkStatusCollapsibleSection, WorkStatusPill, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives'; import { useReportWorkStatusPresence } from './presenceContext'; import { WorkStatusLinkDialog } from './WorkStatusLinkDialog'; @@ -193,6 +199,54 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory void loadSkills(); }, [directory, loadSkills]); + /** + * What the project sends along with every message. Read from the server + * rather than from the notes panel's store, because this must be right + * whether or not that panel has ever been opened. + */ + const [knowledge, setKnowledge] = React.useState( + { notes: [], plans: [], memory: { global: 0, project: 0 } }, + ); + + // Re-read whenever the stores that own pins or memory change, not only when + // the directory does. Unpinning is a write those stores make, and a panel + // that keeps listing what was just unpinned tells the user it is still going + // to the agent when it is not. + const contextEntries = useProjectContextStore((state) => state.entries); + const memoryProject = useAgentMemoryStore((state) => state.project); + const memoryGlobal = useAgentMemoryStore((state) => state.global); + + React.useEffect(() => { + let cancelled = false; + void fetchSessionKnowledgeSummary(directory).then((summary) => { + if (!cancelled) setKnowledge(summary); + }); + return () => { cancelled = true; }; + }, [directory, contextEntries, memoryProject, memoryGlobal]); + + const projects = useProjectsStore((state) => state.projects); + const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); + const setNotePinned = useProjectContextStore((state) => state.setNotePinned); + const setPlanPinned = useProjectContextStore((state) => state.setPlanPinned); + + const projectRef = React.useMemo(() => { + const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory ?? ''); + return resolved ? { id: resolved.id, path: resolved.path } : null; + }, [availableWorktreesByProject, directory, projects]); + + // Unpinning from here, like the pinned-messages section: a panel that says + // what is attached should be able to detach it, or the user has to go find + // the surface that can. + const unpinNote = React.useCallback((noteId: string) => { + if (projectRef) void setNotePinned(projectRef, noteId, false); + }, [projectRef, setNotePinned]); + const unpinPlan = React.useCallback((planId: string) => { + if (projectRef) void setPlanPinned(projectRef, planId, false); + }, [projectRef, setPlanPinned]); + + const memoryCount = knowledge.memory.global + knowledge.memory.project; + const pinnedCount = knowledge.notes.length + knowledge.plans.length; + const linked = React.useMemo(() => getLinkedIssues(session), [session]); // Connected servers only. A disabled server contributes nothing to the // context, so counting it here contradicts the MCP section right above, @@ -202,9 +256,14 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory [mcpStatus], ); - useReportWorkStatusPresence('context-sources', linked.length > 0 || skills.length > 0 || mcpCount > 0); + useReportWorkStatusPresence( + 'context-sources', + linked.length > 0 || skills.length > 0 || mcpCount > 0 || pinnedCount > 0 || memoryCount > 0, + ); - if (linked.length === 0 && skills.length === 0 && mcpCount === 0) return null; + if (linked.length === 0 && skills.length === 0 && mcpCount === 0 && pinnedCount === 0 && memoryCount === 0) { + return null; + } // The heading names what is distinctive about this session when there is // something — an attached thread — and falls back to the ambient counts @@ -222,6 +281,14 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory ? t('chat.workStatus.breakdown.prCountSingle', { count: prCount }) : t('chat.workStatus.breakdown.prCountPlural', { count: prCount })); } + // Pinned knowledge outranks the ambient counts in the summary: it is + // something the user chose for this project, not something that happens to + // be installed. + if (summaryParts.length === 0 && pinnedCount > 0) { + summaryParts.push(pinnedCount === 1 + ? t('chat.workStatus.breakdown.pinnedKnowledgeSingle', { count: pinnedCount }) + : t('chat.workStatus.breakdown.pinnedKnowledgePlural', { count: pinnedCount })); + } if (summaryParts.length === 0) { if (skills.length > 0) { summaryParts.push(skills.length === 1 @@ -272,6 +339,63 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory ) : null} + {/* Named individually: a count alone would not tell the user which note + is riding along with every message they send. */} + {/* The pin is the control, exactly as in the pinned-messages section + above: same icon, same placement, same behaviour. Two pins that look + different in one panel would read as two different things. */} + {knowledge.notes.map((note) => ( + { + event.stopPropagation(); + unpinNote(note.id); + }} + className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-40" + > + + + )} + label={note.body.trim().split('\n')[0] || note.body.trim()} + value={{t('chat.workStatus.breakdown.pinnedNote')}} + /> + ))} + {knowledge.plans.map((plan) => ( + { + event.stopPropagation(); + unpinPlan(plan.id); + }} + className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-40" + > + + + )} + label={plan.title} + value={{t('chat.workStatus.breakdown.pinnedPlan')}} + /> + ))} + {memoryCount > 0 ? ( + {memoryCount}} + /> + ) : null} + = ({ directory }) => { > {mcpServers.map(([name, entry]) => { const connected = entry?.status === 'connected'; + const busy = busyServer === name; const needsAuth = entry?.status === 'needs_auth' || entry?.status === 'needs_client_registration'; const failed = entry?.status === 'failed'; return ( @@ -105,8 +106,9 @@ export const WorkStatusMcpSection: React.FC = ({ directory }) => { leading={( { void handleToggle(name, checked); }} /> @@ -118,7 +120,7 @@ export const WorkStatusMcpSection: React.FC = ({ directory }) => { value={needsAuth ? ( { void handleAuthorize(name); }} > {t('chat.workStatus.mcp.needsAuth')} @@ -126,7 +128,7 @@ export const WorkStatusMcpSection: React.FC = ({ directory }) => { ) : failed ? ( { void handleToggle(name, true); }} > {t('chat.workStatus.mcp.failed')} diff --git a/packages/ui/src/components/chat/work-status/WorkStatusPrimitives.tsx b/packages/ui/src/components/chat/work-status/WorkStatusPrimitives.tsx index 4ef98696..626bb0c4 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusPrimitives.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusPrimitives.tsx @@ -25,7 +25,7 @@ const SECTION_CLASS = cn( '[&:not(:first-child)]:border-[var(--interactive-border)] [&:not(:first-child)]:pt-3', ); -const HEADING_CLASS = 'text-xs font-normal text-muted-foreground'; +const HEADING_CLASS = 'text-xs font-semibold text-foreground'; export const WorkStatusSection: React.FC<{ title: string; @@ -158,7 +158,10 @@ export const WorkStatusRow: React.FC = ({ ); - const shared = cn('flex h-7 w-full items-center gap-2 rounded-md px-1 text-left', className); + const shared = cn( + 'flex h-7 w-full items-center gap-2 rounded-md px-1 text-left text-muted-foreground', + className, + ); if (!onClick) return
{body}
; diff --git a/packages/ui/src/components/chat/work-status/WorkStatusUsageSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusUsageSection.tsx index e26f55e1..3484512e 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusUsageSection.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusUsageSection.tsx @@ -124,8 +124,7 @@ export const WorkStatusUsageSection: React.FC = () => { } - label={group.providerName} - muted + label={{group.providerName}} value={group.status && group.rows.length === 0 ? ( {group.status} ) : undefined} diff --git a/packages/ui/src/components/chat/work-status/contextUsage.test.ts b/packages/ui/src/components/chat/work-status/contextUsage.test.ts index c2e0a6e4..34f538f6 100644 --- a/packages/ui/src/components/chat/work-status/contextUsage.test.ts +++ b/packages/ui/src/components/chat/work-status/contextUsage.test.ts @@ -14,8 +14,8 @@ describe('computeContextUsage', () => { }); test('reports the latest turn rather than a sum across turns', () => { - // Each assistant turn reports the whole window it saw, so adding them up - // would report several times the real fill. + // A turn's tokens describe that turn's window, so adding turns up would + // report several times the real fill. const usage = computeContextUsage( [ assistant({ input: 400, output: 0, reasoning: 0 }, 'old'), @@ -61,4 +61,24 @@ describe('computeContextUsage', () => { const usage = computeContextUsage([assistant({ input: 10 })], 100); expect(usage?.totalTokens).toBe(10); }); + + test('prefers the server-reported total over summing round-trip fields', () => { + // Real payload from opencode 1.18.18: ~14 tool-call round-trips accumulated + // cache.read to 3.29M while the 1M window really held 232,872. Summing + // rendered 330.6%; the reported total renders the real 23.3%. + const usage = computeContextUsage( + [assistant({ total: 232_872, input: 0, output: 14_523, reasoning: 0, cache: { read: 3_291_956, write: 0 } })], + 1_000_000, + ); + expect(usage?.totalTokens).toBe(232_872); + expect(usage?.percent.toFixed(4)).toBe('23.2872'); + }); + + test('selects a message whose only signal is the reported total', () => { + const usage = computeContextUsage( + [assistant({ total: 5_000, input: 0, output: 0, reasoning: 0 })], + 100_000, + ); + expect(usage?.totalTokens).toBe(5_000); + }); }); diff --git a/packages/ui/src/components/chat/work-status/contextUsage.ts b/packages/ui/src/components/chat/work-status/contextUsage.ts index c30d57f6..920d6963 100644 --- a/packages/ui/src/components/chat/work-status/contextUsage.ts +++ b/packages/ui/src/components/chat/work-status/contextUsage.ts @@ -13,7 +13,11 @@ * global read to race with. */ +import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils'; + type MessageTokens = { + /** Server-reported window of the turn's final round-trip; absent on older servers. */ + total?: number; input?: number; output?: number; reasoning?: number; @@ -37,18 +41,12 @@ type WorkStatusContextUsage = { /** The store's own fallback when a model exposes no context limit. */ export const DEFAULT_CONTEXT_LIMIT = 200_000; -const sumTokens = (tokens: MessageTokens): number => ( - (tokens.input ?? 0) - + (tokens.output ?? 0) - + (tokens.reasoning ?? 0) - + (tokens.cache?.read ?? 0) - + (tokens.cache?.write ?? 0) -); - /** * Usage from the newest assistant message that reported a non-zero token count. - * Each assistant turn reports the whole window it saw, so the latest one is the - * current fill — not a sum across turns. + * The latest turn describes the current fill — not a sum across turns. Within + * a turn, the server-reported `total` is the final round-trip's window; + * summing the breakdown fields instead overstates multi-step turns, whose + * input/cache fields accumulate across round-trips. */ export const computeContextUsage = ( messages: readonly MessageLike[], @@ -60,7 +58,7 @@ export const computeContextUsage = ( const message = messages[index]; if (message?.role !== 'assistant' || !message.tokens) continue; - const totalTokens = sumTokens(message.tokens); + const totalTokens = contextTokensFromBreakdown(message.tokens); if (totalTokens <= 0) continue; const limit = contextLimit > 0 ? contextLimit : DEFAULT_CONTEXT_LIMIT; diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts index cb9bfff5..d970926f 100644 --- a/packages/ui/src/components/icon/sprite.ts +++ b/packages/ui/src/components/icon/sprite.ts @@ -28,10 +28,12 @@ export const iconSpriteData = { "bar-chart-2": ``, "bar-chart-box": ``, "book": ``, + "book-marked": ``, "book-open": ``, "booklet": ``, "braces": ``, "brain": ``, + "brain-4": ``, "brain-ai-3": ``, "briefcase": ``, "bug": ``, diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 1fcb44e5..655a38be 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -947,7 +947,7 @@ export const ContextPanel: React.FC = () => { : activeTab?.mode === 'notes' ? : activeTab?.mode === 'plan' - ? + ? : null; const browserTabs = React.useMemo( diff --git a/packages/ui/src/components/layout/ContextSidebarTab.tsx b/packages/ui/src/components/layout/ContextSidebarTab.tsx index 1e08bd0b..3f41e331 100644 --- a/packages/ui/src/components/layout/ContextSidebarTab.tsx +++ b/packages/ui/src/components/layout/ContextSidebarTab.tsx @@ -92,6 +92,7 @@ const extractTokenBreakdown = (message: SessionMessage): TokenBreakdown => { } const breakdown = source as { + total?: unknown; input?: unknown; output?: unknown; reasoning?: unknown; @@ -103,6 +104,10 @@ const extractTokenBreakdown = (message: SessionMessage): TokenBreakdown => { const reasoning = toNonNegativeNumber(breakdown.reasoning); const cacheRead = toNonNegativeNumber(breakdown.cache?.read); const cacheWrite = toNonNegativeNumber(breakdown.cache?.write); + // Multi-step turns accumulate the fields across API round-trips (every tool + // call re-reads the whole cached prompt), so summing them overstates the + // window. The server-reported total is the final round-trip's window. + const reportedTotal = toNonNegativeNumber(breakdown.total); return { input, @@ -110,7 +115,7 @@ const extractTokenBreakdown = (message: SessionMessage): TokenBreakdown => { reasoning, cacheRead, cacheWrite, - total: input + output + reasoning + cacheRead + cacheWrite, + total: reportedTotal > 0 ? reportedTotal : input + output + reasoning + cacheRead + cacheWrite, }; }; diff --git a/packages/ui/src/components/layout/RightSidebarTabs.tsx b/packages/ui/src/components/layout/RightSidebarTabs.tsx index dbfb46f4..cd8b2f1e 100644 --- a/packages/ui/src/components/layout/RightSidebarTabs.tsx +++ b/packages/ui/src/components/layout/RightSidebarTabs.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { ProjectNotesTodoPanel } from '@/components/session/ProjectNotesTodoPanel'; +import { ProjectNotesTodoPanel } from '@/components/session/project-context/ProjectNotesTodoPanel'; import { useGitStore } from '@/stores/useGitStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; @@ -8,7 +8,7 @@ import { formatDirectoryName } from '@/lib/utils'; export const ProjectContextPanel: React.FC<{ onActionComplete?: () => void; - onOpenPlan?: (plan: { path: string; title: string }) => void; + onOpenPlan?: (plan: { id: string; title: string }) => void; }> = ({ onActionComplete, onOpenPlan }) => { const activeProjectId = useProjectsStore((state) => state.activeProjectId); const projects = useProjectsStore((state) => state.projects); @@ -49,7 +49,8 @@ export const ProjectContextPanel: React.FC<{ }, [activeProject, gitDirectories]); return ( -
+ /* The panel scrolls its own tab content; a scroller here would nest. */ +
( + Array.from(dataTransfer.types).includes('Files') +); + +const getExternalFiles = (dataTransfer: DataTransfer): File[] => { + const items = Array.from(dataTransfer.items); + if (items.length === 0) return Array.from(dataTransfer.files); + + return items.flatMap((item) => { + if (item.kind !== 'file' || item.webkitGetAsEntry()?.isDirectory) return []; + const file = item.getAsFile(); + return file ? [file] : []; + }); +}; + +const getUploadName = (file: File): string | null => { + const name = file.name; + if (!name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) { + return null; + } + return name; +}; + const sortNodes = (items: FileNode[]) => items.slice().sort((a, b) => { if (a.type !== b.type) { @@ -93,6 +128,22 @@ const getRelativePath = (root: string, path: string): string => { return normalizedPath.slice(normalizedRoot.length + 1); }; +const getDropTargetLabel = (root: string, target: string): string => { + const relativePath = getRelativePath(root, target); + if (relativePath !== '.') return relativePath; + + const normalizedRoot = normalizePath(root); + return normalizedRoot.split('/').filter(Boolean).pop() ?? normalizedRoot; +}; + +const getParentPath = (value: string): string => { + const normalized = normalizePath(value); + const separatorIndex = normalized.lastIndexOf('/'); + if (separatorIndex < 0) return ''; + if (separatorIndex === 0) return '/'; + return normalized.slice(0, separatorIndex); +}; + const isAbsolutePath = (value: string): boolean => { return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value); }; @@ -194,6 +245,8 @@ interface FileRowProps { isBrowserClient: boolean; status?: FileStatus | null; badge?: { modified: number; added: number } | null; + isDropTarget: boolean; + canUpload: boolean; permissions: { canRename: boolean; canCreateFile: boolean; @@ -206,6 +259,8 @@ interface FileRowProps { onToggle: (path: string) => void; onRevealPath: (path: string) => void; onOpenDialog: (type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => void; + onSetDropTarget: (path: string | null) => void; + onDropFiles: (directory: string, dataTransfer: DataTransfer) => void; } const FileRow: React.FC = ({ @@ -216,15 +271,20 @@ const FileRow: React.FC = ({ isBrowserClient, status, badge, + isDropTarget, + canUpload, permissions, downloadFile, onSelect, onToggle, onRevealPath, onOpenDialog, + onSetDropTarget, + onDropFiles, }) => { const { t } = useI18n(); const isDir = node.type === 'directory'; + const uploadDirectory = isDir ? node.path : getParentPath(node.path); const { canRename, canCreateFile, canCreateFolder, canDelete, canReveal } = permissions; const canDownload = !isDir && Boolean(downloadFile); const canRevealPath = canReveal && !isBrowserClient; @@ -333,9 +393,40 @@ const FileRow: React.FC = ({ e.dataTransfer.effectAllowed = 'copy'; }, [node.path, root]); + const handleExternalDragOver = React.useCallback((event: React.DragEvent) => { + if (!canUpload || !uploadDirectory || !hasExternalFiles(event.dataTransfer)) return; + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'copy'; + onSetDropTarget(uploadDirectory); + }, [canUpload, onSetDropTarget, uploadDirectory]); + + const handleExternalDragLeave = React.useCallback((event: React.DragEvent) => { + if (!canUpload || !uploadDirectory || !hasExternalFiles(event.dataTransfer)) return; + if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return; + event.stopPropagation(); + onSetDropTarget(null); + }, [canUpload, onSetDropTarget, uploadDirectory]); + + const handleExternalDrop = React.useCallback((event: React.DragEvent) => { + if (!canUpload || !uploadDirectory || !hasExternalFiles(event.dataTransfer)) return; + event.preventDefault(); + event.stopPropagation(); + onDropFiles(uploadDirectory, event.dataTransfer); + }, [canUpload, onDropFiles, uploadDirectory]); + return ( - }> + + )}>
- +
+
    {searching ? (
  • @@ -1235,7 +1449,52 @@ export const SidebarFilesTree: React.FC = () => {
  • {t('sidebarFilesTree.state.loading')}
  • )}
-
+ + {dropTarget ? ( +
+ + + {t(isUploading ? 'sidebarFilesTree.drop.uploading' : 'sidebarFilesTree.drop.target', { path: dropTargetLabel })} + +
+ ) : null} +
+ + !open && setUploadConflicts(null)}> + + + {t('sidebarFilesTree.dialog.uploadConflicts.title')} + + {t('sidebarFilesTree.dialog.uploadConflicts.description', { path: uploadConflicts?.directory ?? '' })} + + + + {uploadConflicts?.files.map((file, index) => ( +
+ {file.name} +
+ ))} +
+ + + + +
+
{/* CRUD dialogs (matching FilesView) */} !open && setActiveDialog(null)}> diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index de1f1772..0b592fe0 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -8,6 +8,7 @@ import { useViewportStore } from '@/sync/viewport-store'; import { useSessions, useDirectorySync, useSessionMessages, useSessionMessagesResolved } from '@/sync/sync-context'; import { useConfigStore } from '@/stores/useConfigStore'; import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; +import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; import { McpDropdown } from '@/components/mcp/McpDropdown'; import { ArchiveAllDropdown } from '@/components/session/ArchiveAllDropdown'; @@ -702,7 +703,7 @@ const VSCodeHeader: React.FC = ({ title, showBack, onBack, on } if (!lastTokens && message.tokens) { - const total = message.tokens.input + message.tokens.output + message.tokens.reasoning + (message.tokens.cache?.read ?? 0) + (message.tokens.cache?.write ?? 0); + const total = contextTokensFromBreakdown(message.tokens); if (total > 0) { lastTokens = message.tokens; lastMessageId = (currentSessionMessages[i] as { id?: string }).id; @@ -730,7 +731,7 @@ const VSCodeHeader: React.FC = ({ title, showBack, onBack, on } const lastTokens = headerMessageSummary.lastTokens; - const totalTokens = lastTokens.input + lastTokens.output + lastTokens.reasoning + (lastTokens.cache?.read ?? 0) + (lastTokens.cache?.write ?? 0); + const totalTokens = contextTokensFromBreakdown(lastTokens); const thresholdLimit = contextLimit > 0 ? contextLimit : 200000; const percentage = contextLimit > 0 ? Math.round((totalTokens / contextLimit) * 100) : 0; const normalizedOutput = outputLimit > 0 ? Math.round((lastTokens.output / outputLimit) * 100) : undefined; diff --git a/packages/ui/src/components/mini-chat/MiniChatLayout.tsx b/packages/ui/src/components/mini-chat/MiniChatLayout.tsx index 4eea6d8f..a32779a1 100644 --- a/packages/ui/src/components/mini-chat/MiniChatLayout.tsx +++ b/packages/ui/src/components/mini-chat/MiniChatLayout.tsx @@ -18,6 +18,7 @@ import { useGitBranchLabel, useGitStore } from '@/stores/useGitStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { Icon } from "@/components/icon/Icon"; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils'; import type { SessionContextUsage } from '@/stores/types/sessionTypes'; type MiniChatMode = 'session' | 'draft'; @@ -157,7 +158,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { return null; } - type AssistantTokens = { input: number; output: number; reasoning: number; cache: { read: number; write: number } }; + type AssistantTokens = { total?: number; input: number; output: number; reasoning: number; cache: { read: number; write: number } }; let lastTokens: AssistantTokens | undefined; let lastMessageId: string | undefined; @@ -166,7 +167,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { if (message.role !== 'assistant') continue; const tokens = (message as { tokens?: AssistantTokens }).tokens; if (!tokens) continue; - const total = tokens.input + tokens.output + tokens.reasoning + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0); + const total = contextTokensFromBreakdown(tokens); if (total > 0) { lastTokens = tokens; lastMessageId = message.id; @@ -178,7 +179,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { return null; } - const totalTokens = lastTokens.input + lastTokens.output + lastTokens.reasoning + (lastTokens.cache?.read ?? 0) + (lastTokens.cache?.write ?? 0); + const totalTokens = contextTokensFromBreakdown(lastTokens); const thresholdLimit = contextLimit > 0 ? contextLimit : 200000; const percentage = contextLimit > 0 ? Math.round((totalTokens / contextLimit) * 100) : 0; const normalizedOutput = outputLimit > 0 ? Math.round((lastTokens.output / outputLimit) * 100) : undefined; diff --git a/packages/ui/src/components/sections/integrations/ThirdPartyIntegrationsSection.tsx b/packages/ui/src/components/sections/integrations/ThirdPartyIntegrationsSection.tsx index ab10597c..b520b8e1 100644 --- a/packages/ui/src/components/sections/integrations/ThirdPartyIntegrationsSection.tsx +++ b/packages/ui/src/components/sections/integrations/ThirdPartyIntegrationsSection.tsx @@ -13,6 +13,7 @@ import { toast } from '@/components/ui'; import { Icon } from '@/components/icon/Icon'; import { SettingsSection } from '@/components/sections/shared/SettingsSection'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { useI18n } from '@/lib/i18n'; import { openExternalUrl } from '@/lib/url'; import { cn } from '@/lib/utils'; @@ -327,7 +328,11 @@ export const ThirdPartyIntegrationsSection: React.FC
- + {plugin.providerId === 'command-code' ? ( + + ) : ( + + )}
{t(plugin.nameKey)}
diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberToolsSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberToolsSettings.tsx index df8cfbed..46b0bcf2 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberToolsSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberToolsSettings.tsx @@ -7,6 +7,7 @@ import { } from '@/components/sections/shared/SettingsSection'; import { recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart'; import { updateDesktopSettings } from '@/lib/persistence'; +import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore'; import { useUIStore } from '@/stores/useUIStore'; import { useI18n } from '@/lib/i18n'; @@ -27,6 +28,11 @@ export const OpenChamberToolsSettings: React.FC = () => { const setAgentControlToolEnabled = useUIStore((state) => state.setAgentControlToolEnabled); const agentWebToolEnabled = useUIStore((state) => state.agentWebToolEnabled); const setAgentWebToolEnabled = useUIStore((state) => state.setAgentWebToolEnabled); + const agentMemoryToolEnabled = useUIStore((state) => state.agentMemoryToolEnabled); + // Absent, not merely off: the feature is finished but unreleased, and a + // visible switch invites turning on something that was never announced. + const agentMemoryAvailable = useUIStore((state) => state.agentMemoryFeatureAvailable); + const setAgentMemoryToolEnabled = useUIStore((state) => state.setAgentMemoryToolEnabled); const handleAgentControlToolChange = React.useCallback((enabled: boolean) => { setAgentControlToolEnabled(enabled); @@ -40,6 +46,24 @@ export const OpenChamberToolsSettings: React.FC = () => { recordDeferredOpenCodeRestart('cli', { id: 'agent-web-tool' }); }, [setAgentWebToolEnabled]); + // Turning memory off removes the whole feature, not just the tool: the panel + // tab goes with it and sessions stop being given the index. Showing the user + // what is stored would be pointless once the agent can no longer manage it. + const handleAgentMemoryToolChange = React.useCallback((enabled: boolean) => { + setAgentMemoryToolEnabled(enabled); + // Re-read after the write lands, not before. The switch flips the client + // immediately, which makes the panel ask the server straight away — and + // while the setting is still being written the server truthfully answers + // "disabled", which used to leave the tab hidden until a restart. + void updateDesktopSettings({ agentMemoryToolEnabled: enabled }) + .finally(() => { + if (enabled) { + void useAgentMemoryStore.getState().refresh(); + } + }); + recordDeferredOpenCodeRestart('cli', { id: 'agent-memory-tool' }); + }, [setAgentMemoryToolEnabled]); + return (
@@ -60,6 +84,17 @@ export const OpenChamberToolsSettings: React.FC = () => { ariaLabel={t('settings.openchamber.tools.field.agentWebToolAria')} info={t('settings.openchamber.tools.field.agentWebToolInfo')} /> + + {agentMemoryAvailable ? ( + + ) : null}
); diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index df5b7b4a..5b70ad29 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -117,20 +117,6 @@ const normalizeBranchName = (value: string): string => { .replace(/^\/+|\/+$/g, ''); }; -const slugifyWorktreeName = (value: string): string => { - return value - .trim() - .replace(/^refs\/heads\//, '') - .replace(/^heads\//, '') - .replace(/\s+/g, '-') - .replace(/^\/+|\/+$/g, '') - .split('/').join('-') - .replace(/[^A-Za-z0-9._-]+/g, '-') - .replace(/-+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 80); -}; - const sanitizeRemoteName = (value: string): string => { const normalized = String(value || '') .trim() @@ -184,10 +170,14 @@ const resolvePrWorktreeConfig = (pr: GitHubPullRequestSummary, localBranches: st const ownerFromLabel = String(pr.headLabel || '').split(':')[0]?.trim(); const remoteSeed = pr.headRepo?.owner || ownerFromLabel || 'pr-head'; const remoteName = `pr-${sanitizeRemoteName(remoteSeed)}`; - const remoteUrl = pr.headRepo?.sshUrl || pr.headRepo?.cloneUrl || ''; + // Prefer HTTPS so anonymous public fetches do not require SSH agent setup. + const remoteUrl = pr.headRepo?.cloneUrl || pr.headRepo?.sshUrl || ''; if (!remoteUrl) { - throw new Error('PR head repository URL is unavailable'); + throw new Error( + 'PR head repository URL is unavailable. The fork may have been deleted; ' + + 'push the branch to a reachable repository and try again.' + ); } return { @@ -201,6 +191,20 @@ const resolvePrWorktreeConfig = (pr: GitHubPullRequestSummary, localBranches: st }; }; +const slugifyWorktreeName = (value: string): string => { + return value + .trim() + .replace(/^refs\/heads\//, '') + .replace(/^heads\//, '') + .replace(/\s+/g, '-') + .replace(/^\/+|\/+$/g, '') + .split('/').join('-') + .replace(/[^A-Za-z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80); +}; + interface NewWorktreeDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -1273,7 +1277,7 @@ export function NewWorktreeDialog({ ...(sourceBranch && mode === 'new-branch' ? { startRef: sourceBranch } : {}), }; })(); - + const resolvedArgs = await withWorktreeUpstreamDefaults(projectDirectory, args); const metadata = await createWorktree(projectRef, resolvedArgs); diff --git a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx b/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx deleted file mode 100644 index 5f912444..00000000 --- a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx +++ /dev/null @@ -1,1056 +0,0 @@ -import React from 'react'; -import { - DndContext, - PointerSensor, - closestCenter, - useSensor, - useSensors, - type DragEndEvent, -} from '@dnd-kit/core'; -import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'; -import { CSS as DndCSS } from '@dnd-kit/utilities'; -import { toast } from '@/components/ui'; -import { Checkbox } from '@/components/ui/checkbox'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { Input } from '@/components/ui/input'; -import { Textarea } from '@/components/ui/textarea'; -import { Icon } from "@/components/icon/Icon"; -import { - deleteProjectPlanFile, - getProjectContextData, - importProjectPlanFileFromContent, - OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH, - readProjectPlanFile, - OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH, - saveProjectNotesAndTodos, - type OpenChamberProjectPlanFileLink, - type OpenChamberProjectTodoItem, - type ProjectRef, -} from '@/lib/openchamberConfig'; -import { requestFileAccess } from '@/lib/desktop'; -import { generateBranchName } from '@/lib/git/branchNameGenerator'; -import { useDirectoryStore } from '@/stores/useDirectoryStore'; -import { useUIStore } from '@/stores/useUIStore'; -import { useConfigStore } from '@/stores/useConfigStore'; -import { useSessionUIStore } from '@/sync/session-ui-store'; -import { useSelectionStore } from '@/sync/selection-store'; -import { useInputStore } from '@/sync/input-store'; -import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator'; -import { cn } from '@/lib/utils'; -import { renderMagicPrompt } from '@/lib/magicPrompts'; -import { getCurrentIntlLocale, useI18n } from '@/lib/i18n'; -import { runtimeFetch } from '@/lib/runtime-fetch'; -import { TodoSendDialog, type TodoSendExecution } from './TodoSendDialog'; - -const TODO_PANEL_MIN_ITEMS = 5; -const TODO_PANEL_MAX_ITEMS = 15; - -// Per-project chain of in-flight saveProjectNotesAndTodos calls. Subsequent -// saves await the previous one so a fast todo toggle or blur that lands -// while the debounced notes save is still on the wire is appended, not -// racing against it. The chain is module-scoped so it survives remounts -// (e.g. when the user switches the right sidebar tab away and back). -const projectSaveChainByProject = new Map>(); - -const getEffectiveItemHeight = (padding: number) => { - const scale = Math.sqrt(padding / 100); - const paddingPx = 12 * scale; - const contentPx = 24 * scale; // h-6 uses --spacing-6 which also scales with --padding-scale - const borderPx = 1; - return Math.ceil(paddingPx + contentPx + borderPx); -}; - -const getPanelHeightForItems = (itemCount: number, padding: number) => { - const itemHeight = getEffectiveItemHeight(padding); - return Math.max( - itemHeight * TODO_PANEL_MIN_ITEMS, - Math.min(itemHeight * TODO_PANEL_MAX_ITEMS, itemHeight * itemCount) - ); -}; - -interface ProjectNotesTodoPanelProps { - projectRef: ProjectRef | null; - projectLabel?: string | null; - canCreateWorktree?: boolean; - onActionComplete?: () => void; - /** When provided, opening a plan calls this instead of the desktop context - panel tab — hosts without ContextPanel (mobile) render their own viewer. */ - onOpenPlan?: (plan: { path: string; title: string }) => void; - className?: string; -} - -type PendingSendTarget = { - kind: 'session' | 'worktree'; - todoId: string; - todoText: string; -}; - -type ProjectPlanListItem = OpenChamberProjectPlanFileLink & { - title: string; -}; - -const toPlanListItem = async ( - plan: OpenChamberProjectPlanFileLink, - fallbackTitle: string, -): Promise => { - const file = await readProjectPlanFile(plan.path); - return { - ...plan, - title: file?.title || plan.path.split('/').pop() || fallbackTitle, - }; -}; - -const createTodoId = (): string => { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - return crypto.randomUUID(); - } - return `todo_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; -}; - -const sortTodosWithCompletedLast = (items: OpenChamberProjectTodoItem[]): OpenChamberProjectTodoItem[] => [ - ...items.filter((todo) => !todo.completed), - ...items.filter((todo) => todo.completed), -]; - -const insertTodoBeforeCompleted = (items: OpenChamberProjectTodoItem[], item: OpenChamberProjectTodoItem): OpenChamberProjectTodoItem[] => { - const firstCompletedIndex = items.findIndex((todo) => todo.completed); - if (firstCompletedIndex === -1) { - return [...items, item]; - } - return [...items.slice(0, firstCompletedIndex), item, ...items.slice(firstCompletedIndex)]; -}; - -type SortableTodoHandleProps = { - attributes: ReturnType['attributes']; - listeners: ReturnType['listeners']; - setActivatorNodeRef: ReturnType['setActivatorNodeRef']; - isDragging: boolean; -}; - -const SortableTodoItem: React.FC<{ - id: string; - children: (dragHandleProps: SortableTodoHandleProps) => React.ReactNode; -}> = ({ id, children }) => { - const { - attributes, - listeners, - setNodeRef, - setActivatorNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id }); - - return ( -
  • - {children({ attributes, listeners, setActivatorNodeRef, isDragging })} -
  • - ); -}; - -export const ProjectNotesTodoPanel: React.FC = ({ - projectRef, - projectLabel, - canCreateWorktree = false, - onActionComplete, - onOpenPlan, - className, -}) => { - const { t } = useI18n(); - const [isLoading, setIsLoading] = React.useState(false); - const [notes, setNotes] = React.useState(''); - const [todos, setTodos] = React.useState([]); - const [newTodoText, setNewTodoText] = React.useState(''); - const [sendingTodoId, setSendingTodoId] = React.useState(null); - const [expandedTodoIds, setExpandedTodoIds] = React.useState>(() => new Set()); - const [plans, setPlans] = React.useState([]); - const [pendingSendTarget, setPendingSendTarget] = React.useState(null); - const [isSendDialogSubmitting, setIsSendDialogSubmitting] = React.useState(false); - const [contextReloadTick, setContextReloadTick] = React.useState(0); - const notesHydratedRef = React.useRef(false); - const lastSavedNotesRef = React.useRef(''); - const notesDebounceTimerRef = React.useRef(null); - const todoPanelHeight = useUIStore((state) => state.todoPanelHeight); - const setTodoPanelHeight = useUIStore((state) => state.setTodoPanelHeight); - const notesPanelHeight = useUIStore((state) => state.notesPanelHeight); - const setNotesPanelHeight = useUIStore((state) => state.setNotesPanelHeight); - const [isTodoPanelResizing, setIsTodoPanelResizing] = React.useState(false); - const todoPanelStartYRef = React.useRef(0); - const todoPanelStartHeightRef = React.useRef(todoPanelHeight); - - const currentSessionId = useSessionUIStore((state) => state.currentSessionId); - const createSession = useSessionUIStore((state) => state.createSession); - const initializeNewOpenChamberSession = useSessionUIStore((state) => state.initializeNewOpenChamberSession); - const sendMessage = useSessionUIStore((state) => state.sendMessage); - const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); - const setPendingInputText = useInputStore((state) => state.setPendingInputText); - const currentDirectory = useDirectoryStore((state) => state.currentDirectory); - const openContextPanelTab = useUIStore((state) => state.openContextPanelTab); - const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); - const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); - const padding = useUIStore((state) => state.padding); - - const persistProjectData = React.useCallback( - async (nextNotes: string, nextTodos: OpenChamberProjectTodoItem[]) => { - if (!projectRef) { - return false; - } - const key = projectRef.id; - // Serialize concurrent saves per project: a fast toggle/strike while the - // debounce-driven notes save is in flight no longer races the network. - const previous = projectSaveChainByProject.get(key) ?? Promise.resolve(); - const next = previous.catch(() => undefined).then(() => - saveProjectNotesAndTodos(projectRef, { - notes: nextNotes, - todos: nextTodos, - }) - ); - projectSaveChainByProject.set(key, next); - try { - const saved = await next; - if (!saved) { - toast.error(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed')); - } - return saved; - } finally { - if (projectSaveChainByProject.get(key) === next) { - projectSaveChainByProject.delete(key); - } - } - }, - [projectRef, t] - ); - - React.useEffect(() => { - if (!projectRef) { - setNotes(''); - setTodos([]); - setPlans([]); - setNewTodoText(''); - setExpandedTodoIds(new Set()); - return; - } - - let cancelled = false; - setIsLoading(true); - - (async () => { - try { - const data = await getProjectContextData(projectRef); - const nextPlans = await Promise.all( - data.plans.map((plan) => toPlanListItem(plan, t('rightSidebar.contextNotesTodo.plan.defaultTitle'))) - ); - if (cancelled) { - return; - } - setNotes(data.notes); - setTodos(sortTodosWithCompletedLast(data.todos)); - setPlans(nextPlans); - lastSavedNotesRef.current = data.notes; - notesHydratedRef.current = true; - setNewTodoText(''); - setExpandedTodoIds(new Set()); - } catch { - if (!cancelled) { - toast.error(t('rightSidebar.contextNotesTodo.toast.loadNotesFailed')); - setNotes(''); - setTodos([]); - setPlans([]); - lastSavedNotesRef.current = ''; - notesHydratedRef.current = true; - } - } finally { - if (!cancelled) { - setIsLoading(false); - } - } - })(); - - return () => { - cancelled = true; - }; - }, [contextReloadTick, projectRef, t]); - - React.useEffect(() => { - if (!projectRef) { - return; - } - - const handleProjectContextRefresh = (event: Event) => { - const detail = (event as CustomEvent<{ projectId?: string }>).detail; - if (detail?.projectId && detail.projectId !== projectRef.id) { - return; - } - setContextReloadTick((previous) => previous + 1); - }; - - window.addEventListener('openchamber:project-plan-saved', handleProjectContextRefresh); - window.addEventListener('openchamber:project-notes-updated', handleProjectContextRefresh); - return () => { - window.removeEventListener('openchamber:project-plan-saved', handleProjectContextRefresh); - window.removeEventListener('openchamber:project-notes-updated', handleProjectContextRefresh); - }; - }, [projectRef]); - - React.useEffect(() => { - if (todos.length < 7) { - return; - } - const targetHeight = getPanelHeightForItems(todos.length, padding); - const minHeight = getEffectiveItemHeight(padding) * TODO_PANEL_MIN_ITEMS; - if ( - todoPanelHeight !== targetHeight - && (todoPanelHeight < minHeight || todoPanelHeight > targetHeight) - ) { - setTodoPanelHeight(targetHeight); - } - }, [todos.length, padding, todoPanelHeight, setTodoPanelHeight]); - - React.useEffect(() => { - if (!isTodoPanelResizing) { - return; - } - - const handlePointerMove = (event: PointerEvent) => { - const delta = event.clientY - todoPanelStartYRef.current; - const nextHeight = Math.min( - getEffectiveItemHeight(padding) * TODO_PANEL_MAX_ITEMS, - Math.max(getEffectiveItemHeight(padding) * TODO_PANEL_MIN_ITEMS, todoPanelStartHeightRef.current + delta) - ); - setTodoPanelHeight(nextHeight); - }; - - const handlePointerEnd = () => { - setIsTodoPanelResizing(false); - }; - - window.addEventListener('pointermove', handlePointerMove); - window.addEventListener('pointerup', handlePointerEnd, { once: true }); - window.addEventListener('pointercancel', handlePointerEnd, { once: true }); - - return () => { - window.removeEventListener('pointermove', handlePointerMove); - window.removeEventListener('pointerup', handlePointerEnd); - window.removeEventListener('pointercancel', handlePointerEnd); - }; - }, [isTodoPanelResizing, padding, setTodoPanelHeight]); - - const handleTodoPanelResizeStart = React.useCallback((event: React.PointerEvent) => { - setIsTodoPanelResizing(true); - todoPanelStartYRef.current = event.clientY; - todoPanelStartHeightRef.current = todoPanelHeight; - event.preventDefault(); - }, [todoPanelHeight]); - - const cancelNotesDebounce = React.useCallback(() => { - if (notesDebounceTimerRef.current !== null) { - window.clearTimeout(notesDebounceTimerRef.current); - notesDebounceTimerRef.current = null; - } - }, []); - - const handleNotesBlur = React.useCallback(() => { - cancelNotesDebounce(); - lastSavedNotesRef.current = notes; - void persistProjectData(notes, todos); - }, [cancelNotesDebounce, notes, persistProjectData, todos]); - - React.useEffect(() => { - if (!projectRef || !notesHydratedRef.current) { - return; - } - - if (notes === lastSavedNotesRef.current) { - return; - } - - notesDebounceTimerRef.current = window.setTimeout(() => { - notesDebounceTimerRef.current = null; - lastSavedNotesRef.current = notes; - void persistProjectData(notes, todos); - }, 400); - - return () => { - cancelNotesDebounce(); - }; - }, [cancelNotesDebounce, notes, persistProjectData, projectRef, todos]); - - React.useEffect(() => () => cancelNotesDebounce(), [cancelNotesDebounce]); - - const handleAddTodo = React.useCallback(() => { - const trimmed = newTodoText.trim(); - if (!trimmed) { - return; - } - - const nextTodos = insertTodoBeforeCompleted(todos, { - id: createTodoId(), - text: trimmed.slice(0, OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH), - completed: false, - createdAt: Date.now(), - }); - setTodos(nextTodos); - setNewTodoText(''); - void persistProjectData(notes, nextTodos); - }, [newTodoText, notes, persistProjectData, todos]); - - const handleToggleTodoExpanded = React.useCallback((id: string) => { - setExpandedTodoIds((previous) => { - const next = new Set(previous); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - return next; - }); - }, []); - - const handleToggleTodo = React.useCallback( - (id: string, completed: boolean) => { - const todo = todos.find((item) => item.id === id); - if (!todo || todo.completed === completed) { - return; - } - const remainingTodos = todos.filter((item) => item.id !== id); - const updatedTodo = { ...todo, completed }; - const nextTodos = completed - ? [...remainingTodos, updatedTodo] - : insertTodoBeforeCompleted(remainingTodos, updatedTodo); - setTodos(nextTodos); - void persistProjectData(notes, nextTodos); - }, - [notes, persistProjectData, todos] - ); - - const handleDeleteTodo = React.useCallback( - (id: string) => { - const nextTodos = todos.filter((todo) => todo.id !== id); - setTodos(nextTodos); - void persistProjectData(notes, nextTodos); - }, - [notes, persistProjectData, todos] - ); - - const handleClearCompletedTodos = React.useCallback(() => { - const nextTodos = todos.filter((todo) => !todo.completed); - if (nextTodos.length === todos.length) { - return; - } - setTodos(nextTodos); - void persistProjectData(notes, nextTodos); - }, [notes, persistProjectData, todos]); - - const handleTodoReorder = React.useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - if (!over || active.id === over.id) { - return; - } - const oldIndex = todos.findIndex((todo) => todo.id === active.id); - const newIndex = todos.findIndex((todo) => todo.id === over.id); - if (oldIndex === -1 || newIndex === -1) { - return; - } - const nextTodos = sortTodosWithCompletedLast(arrayMove(todos, oldIndex, newIndex)); - setTodos(nextTodos); - void persistProjectData(notes, nextTodos); - }, - [notes, persistProjectData, todos] - ); - - const todoSensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 8 } }) - ); - - const todoInputValue = newTodoText.slice(0, OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH); - const completedTodoCount = todos.reduce((count, todo) => count + (todo.completed ? 1 : 0), 0); - - const routeToChat = React.useCallback(() => { - setActiveMainTab('chat'); - setSessionSwitcherOpen(false); - }, [setActiveMainTab, setSessionSwitcherOpen]); - - const handleSendToNewSession = React.useCallback( - (todoId: string, todoText: string) => { - if (!projectRef || sendingTodoId) { - return; - } - setPendingSendTarget({ kind: 'session', todoId, todoText }); - }, - [projectRef, sendingTodoId] - ); - - const handleSendToCurrentSession = React.useCallback( - (todoText: string) => { - if (!currentSessionId) { - toast.error(t('rightSidebar.contextNotesTodo.toast.noActiveSession')); - return; - } - routeToChat(); - const fenced = `\`\`\`md\n${todoText}\n\`\`\``; - setPendingInputText(fenced, 'append'); - toast.success(t('rightSidebar.contextNotesTodo.toast.sentToCurrentSession')); - onActionComplete?.(); - }, - [currentSessionId, onActionComplete, routeToChat, setPendingInputText, t] - ); - - const handleSendToNewWorktreeSession = React.useCallback( - (todoId: string, todoText: string) => { - if (!projectRef || sendingTodoId) { - return; - } - if (!canCreateWorktree) { - toast.error(t('rightSidebar.contextNotesTodo.toast.worktreeRequiresGitRepo')); - return; - } - setPendingSendTarget({ kind: 'worktree', todoId, todoText }); - }, - [canCreateWorktree, projectRef, sendingTodoId, t] - ); - - const handleConfirmSend = React.useCallback( - async (execution: TodoSendExecution) => { - if (!projectRef || !pendingSendTarget) { - return; - } - - const visiblePrompt = await renderMagicPrompt('plan.todo.visible', { - todo_text: pendingSendTarget.todoText, - }); - const instructionsText = await renderMagicPrompt('plan.todo.instructions', { - todo_text: pendingSendTarget.todoText, - }); - const syntheticParts = [{ synthetic: true as const, text: instructionsText }]; - - setIsSendDialogSubmitting(true); - setSendingTodoId(pendingSendTarget.todoId); - - try { - routeToChat(); - - let sessionId: string | null = null; - let directoryHint: string | null = projectRef.path; - - if (pendingSendTarget.kind === 'worktree') { - if (!canCreateWorktree) { - toast.error(t('rightSidebar.contextNotesTodo.toast.worktreeRequiresGitRepo')); - return; - } - const created = await createWorktreeSessionForNewBranch(projectRef.path, generateBranchName()); - if (!created?.id) { - return; - } - sessionId = created.id; - directoryHint = created.path; - } else { - const session = await createSession(undefined, projectRef.path, null); - if (!session?.id) { - toast.error(t('rightSidebar.contextNotesTodo.toast.createSessionFailed')); - return; - } - sessionId = session.id; - directoryHint = session.directory ?? projectRef.path; - initializeNewOpenChamberSession(session.id, useConfigStore.getState().agents ?? []); - } - - if (!sessionId) { - return; - } - - const selectionState = useSelectionStore.getState(); - selectionState.saveSessionModelSelection(sessionId, execution.providerID, execution.modelID); - if (execution.agent.trim()) { - selectionState.saveSessionAgentSelection(sessionId, execution.agent); - selectionState.saveAgentModelForSession(sessionId, execution.agent, execution.providerID, execution.modelID); - selectionState.saveAgentModelVariantForSession( - sessionId, - execution.agent, - execution.providerID, - execution.modelID, - execution.variant || undefined, - ); - } - - setCurrentSession(sessionId, directoryHint); - await sendMessage( - visiblePrompt, - execution.providerID, - execution.modelID, - execution.agent.trim() || undefined, - undefined, - undefined, - syntheticParts, - execution.variant || undefined, - ); - - toast.success( - pendingSendTarget.kind === 'worktree' - ? t('rightSidebar.contextNotesTodo.toast.sentToNewWorktreeSession') - : t('rightSidebar.contextNotesTodo.toast.sentToNewSession') - ); - setPendingSendTarget(null); - onActionComplete?.(); - } catch (error) { - const description = error instanceof Error ? error.message : undefined; - toast.error(t('rightSidebar.contextNotesTodo.toast.sendTodoFailed'), description ? { description } : undefined); - } finally { - setIsSendDialogSubmitting(false); - setSendingTodoId(null); - } - }, - [canCreateWorktree, createSession, initializeNewOpenChamberSession, onActionComplete, pendingSendTarget, projectRef, routeToChat, sendMessage, setCurrentSession, t] - ); - - const planFileInputRef = React.useRef(null); - const [isImportingPlan, setIsImportingPlan] = React.useState(false); - const [deletingPlanId, setDeletingPlanId] = React.useState(null); - - const handleDeletePlan = React.useCallback( - async (planId: string) => { - if (!projectRef || deletingPlanId) { - return; - } - setDeletingPlanId(planId); - try { - const ok = await deleteProjectPlanFile(projectRef, planId); - if (!ok) { - toast.error(t('rightSidebar.contextNotesTodo.toast.deletePlanFailed')); - return; - } - setPlans((previous) => previous.filter((entry) => entry.id !== planId)); - window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', { - detail: { projectId: projectRef.id }, - })); - } finally { - setDeletingPlanId(null); - } - }, - [deletingPlanId, projectRef, t] - ); - - const handleTriggerUploadPlan = React.useCallback(async () => { - if (!projectRef || isImportingPlan) { - return; - } - const result = await requestFileAccess({ - defaultPath: projectRef.path, - filters: [ - { name: 'Plan files', extensions: ['md', 'markdown', 'txt'] }, - { name: 'All files', extensions: ['*'] }, - ], - }); - if (result.success && result.path) { - setIsImportingPlan(true); - try { - const params = new URLSearchParams({ - path: result.path, - allowOutsideWorkspace: 'true', - }); - if (result.outsideFileGrant) { - params.set('outsideFileGrant', result.outsideFileGrant); - } - const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, { cache: 'no-store' }); - if (!response.ok) { - toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed')); - return; - } - const text = await response.text(); - if (!text.trim()) { - toast.error(t('rightSidebar.contextNotesTodo.toast.planFileEmpty')); - return; - } - const fallbackTitle = result.path.split('/').pop()?.replace(/\.(md|markdown|txt)$/i, '').trim() || ''; - const created = await importProjectPlanFileFromContent(projectRef, text, fallbackTitle); - if (!created) { - toast.error(t('rightSidebar.contextNotesTodo.toast.importPlanFailed')); - return; - } - window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', { - detail: { projectId: projectRef.id }, - })); - toast.success(t('rightSidebar.contextNotesTodo.toast.planImported')); - } catch (error) { - const description = error instanceof Error ? error.message : undefined; - toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'), description ? { description } : undefined); - } finally { - setIsImportingPlan(false); - } - } else if (result.error === 'Native file picker not available') { - // Fall back to HTML file input for web/non-desktop runtimes - planFileInputRef.current?.click(); - } - }, [isImportingPlan, projectRef, t]); - - const handleUploadPlanFile = React.useCallback( - async (file: File | null) => { - if (!projectRef || !file) { - return; - } - setIsImportingPlan(true); - try { - const text = await file.text(); - if (!text.trim()) { - toast.error(t('rightSidebar.contextNotesTodo.toast.planFileEmpty')); - return; - } - const fallbackTitle = file.name.replace(/\.(md|markdown|txt)$/i, '').trim(); - const created = await importProjectPlanFileFromContent(projectRef, text, fallbackTitle); - if (!created) { - toast.error(t('rightSidebar.contextNotesTodo.toast.importPlanFailed')); - return; - } - window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', { - detail: { projectId: projectRef.id }, - })); - toast.success(t('rightSidebar.contextNotesTodo.toast.planImported')); - } catch (error) { - const description = error instanceof Error ? error.message : undefined; - toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'), description ? { description } : undefined); - } finally { - setIsImportingPlan(false); - } - }, - [projectRef, t] - ); - - const handleOpenPlan = React.useCallback( - (plan: ProjectPlanListItem) => { - if (onOpenPlan) { - onOpenPlan({ path: plan.path, title: plan.title }); - return; - } - const projectPath = projectRef?.path?.trim(); - const panelDirectory = currentDirectory?.trim() || projectPath; - if (!panelDirectory) { - return; - } - openContextPanelTab(panelDirectory, { - mode: 'plan', - targetPath: plan.path, - dedupeKey: plan.path, - label: plan.title, - }); - }, - [currentDirectory, onOpenPlan, openContextPanelTab, projectRef] - ); - - if (!projectRef) { - return ( -
    -

    - {t('rightSidebar.contextNotesTodo.empty.selectProject')} -

    -
    - ); - } - - return ( -
    -
    -
    -

    - {t('rightSidebar.contextNotesTodo.notes.title', { - project: projectLabel?.trim() || projectRef.path.split('/').filter(Boolean).pop() || projectRef.path, - })} -

    - {notes.length}/{OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH} -
    -