Merge branch 'openchamber:main' into github-usage-rework

This commit is contained in:
Jakub Syty
2026-08-26 13:58:58 +02:00
committed by GitHub
513 changed files with 31772 additions and 12940 deletions
+3 -3
View File
@@ -24,13 +24,13 @@ jobs:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with: with:
repo-token: ${{ steps.app-token.outputs.token }} repo-token: ${{ steps.app-token.outputs.token }}
days-before-stale: 60 days-before-stale: 28
days-before-close: 7 days-before-close: 7
stale-issue-label: stale stale-issue-label: stale
stale-pr-label: stale stale-pr-label: stale
stale-issue-message: > stale-issue-message: >
This issue has been automatically marked as stale because it has not had This issue has been automatically marked as stale because it has not had
any activity in the last 60 days. It will be closed in 7 days if no any activity in the last 28 days. It will be closed in 7 days if no
further activity occurs. further activity occurs.
close-issue-message: > close-issue-message: >
This issue has been automatically closed because it has been stale for This issue has been automatically closed because it has been stale for
@@ -38,7 +38,7 @@ jobs:
reopen the issue. reopen the issue.
stale-pr-message: > stale-pr-message: >
This pull request has been automatically marked as stale because it has This pull request has been automatically marked as stale because it has
not had any activity in the last 60 days. It will be closed in 7 days not had any activity in the last 28 days. It will be closed in 7 days
if no further activity occurs. if no further activity occurs.
close-pr-message: > close-pr-message: >
This pull request has been automatically closed because it has been This pull request has been automatically closed because it has been
+72
View File
@@ -4,7 +4,79 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [Unreleased]
- **Chat scrolling rebuilt around your message.** Sending a message parks it near the top of the view and the reply streams into the space below it, so you read from where you asked instead of chasing the bottom. Streamed text arrives a paragraph at a time (code blocks line by line) with a soft fade, and the view glides after it in one continuous motion instead of snapping per line. Scrolling up during a stream immediately hands you the wheel — nothing yanks the view back — and the scroll-to-bottom pill appears on the left, carrying the model's working status while you're away from the live edge. Sending from anywhere mid-conversation jumps you straight to your new message, and opening a session goes straight to the newest message with no scroll animation.
- **Chat context attachments:** everything you attach to a message — diff/file/plan comments, terminal selections, browser annotations, PR comments and failed checks, linked issues and PRs — now shows up in the conversation as a compact context card: a header naming the source, the captured content behind an expander, and your comment below it. Previously most of these arrived as a wall of raw text inside your message.
- **Faster session switching in large workspaces** (thanks @c-w-xiaohei): switching sessions no longer rebuilds the whole sidebar, returning to a recently viewed session restores its rendered messages instead of re-rendering them (file links included), and scrolling long conversations costs less. In a workspace with thousands of loaded sessions, end-to-end switch time dropped by roughly half.
- **Session tabs (opt-in):** the web/desktop header can show your open sessions as browser-style tabs — turn them on in Settings → General → Navigation → Session tabs. Every session you open joins the strip, clicking a tab switches the whole workspace (chat, project, panels), and closing one (its × button, middle-click, or Alt+W — rebindable in Shortcuts) never touches the session itself. Tabs reorder by drag, scroll behind the header buttons when there are many and carry the sidebar's running/unread dot. Each tab has the full session menu — on the "..." button or right-click — plus Close other tabs; renaming works right in the tab.
- **Keyboard shortcuts learned sequences** (thanks @ChangeHow): shortcuts can now be two steps — press Cmd/Ctrl+S, then a letter: P opens the draft's project picker, G its branch picker, L the session list. A held sequence shows no menu but forgives you: Escape or three quiet seconds cancel it, and typing into a text field never triggers one armed elsewhere. Dropdown menus and pickers now also answer Ctrl+N/Ctrl+P for down/up, the session switcher opens focused on your current session, and every tooltip and menu label shows the binding you actually have set, not the default.
- **Keyboard shortcuts redesigned.** Defaults now follow one model: single chords for everyday actions, a two-step Cmd/Ctrl+K leader for open/go actions (K then P/G/L — project picker, branch picker, session list; T timeline, N prompt navigator, I services, H shortcut help, C theme), held Cmd/Ctrl+digit for header session tabs and held Cmd/Ctrl+Option+digit for context panel surfaces. Cmd/Ctrl+B now toggles the sidebar; when it's already open, the session-list shortcut jumps into its search. Rare actions moved into the command palette instead of carrying obscure default bindings, and custom bindings recorded under the old layout are reset once. Shortcuts also stopped requiring an English keyboard layout — bindings follow the physical key on non-Latin layouts (and Option-modified digits on macOS), including when recording custom ones.
- Chat: comment on a reply — select text in a chat message (or in a rendered markdown preview in Files) and choose Comment to attach exactly that quote, with a source line range when it can be located, plus your note to the next message. The selection stays highlighted while you type, and the selection menu was restyled — Add to chat is now Add to input.
- Diff: comment like a review — hovering a line shows a + button in the gutter; clicking it, clicking a line, or dragging across lines opens the comment editor for that line or range. The comment editor and saved-comment cards match the chat's comment style.
- Composer: hovering or tapping a context chip above the input opens a stacked preview of everything attached, where a comment can be edited in place or an item removed before sending.
- Mobile: the chat comment input overlays the composer exactly and rides the keyboard; Enter makes a new line there, with attach on the button.
- Terminal: terminals no longer vanish or die behind your back. Opening the app in another browser tab, on another device, or after a reload shows the terminals already running on the server instead of an empty list, and terminals sitting in background tabs are no longer closed by the server's idle cleanup while the app is open.
- Search: every searchable picker — branches, projects, agents, models, providers, stashes, SSH hosts, skills, archived sessions — now uses one matcher: best matches come first, multi-word queries match in any order, and punctuation doesn't matter (so "gpt4o" finds "gpt-4o"). Ctrl/Cmd+P also matches the whole file path, not just the file name, and the git branch and gitmoji pickers stopped silently dropping rows a second built-in filter didn't like. Sidebar session search and the Todos/Memory/Plans/Notes filters match the same way.
- Chat: @ file mentions rank files and directories together by how well they match, so the file you typed is at the top instead of below unrelated directories. Multi-word queries match in any order, and long paths keep the folder next to the file name visible so identical-looking index.md rows are distinguishable.
- Chat: a new "Follow new content while streaming" checkbox (Settings → Chat → Streaming, on by default) turns the automatic following off entirely — your message still parks at the top on send, but the view never moves on its own afterwards.
- Mobile: narrowing a browser window past phone size switches into the mobile app layout (and back when widened) instead of squeezing the desktop layout. The old/new mobile layout setting is gone — phones always get the mobile layout.
- Chat: streamed code blocks are syntax-highlighted while they stream, and finished messages no longer jump when a code block's line numbers fill in at the end of a reply.
- Chat: finished replies no longer flicker — tool cards stopped re-rendering (and replaying their reveal animation) when they completed, and resizing the window no longer throws the conversation up and down while you're at the bottom.
- Chat: clicking the last item in the prompt rail now always lands on it, and rail jumps teleport instead of a long smooth scroll that could stop halfway.
- Mobile: scrolling during a streaming reply works again — a drag immediately takes over, the scroll-to-bottom pill shows up, and the load-older button no longer throws you to the bottom of the chat.
- Fixed file links in messages being checked twice against the filesystem, and against the wrong project directory on the first pass.
- Fixed the selected project or session briefly jumping back to a previous choice when settings responses arrived out of order.
- Fixed sessions staying on "loading sessions" forever after the connection to OpenCode went half-open — stalled reads now time out and retry instead of holding bootstrap hostage (thanks @herjarsa).
- Files: previews of files above the editable size cap now show the whole file instead of the first 200k characters, virtualized so opening and scrolling a huge file no longer freezes the app (thanks @gaojunran).
- VSCode: the chat view no longer stays stuck on its loading screen on slow or remote connections (for example code-server behind a reverse proxy) — the connection status is re-sent until the webview is ready to hear it (thanks @VinciYan).
- Terminal: mobile keyboards no longer capitalize the first letter of every command on iOS and Android.
- Desktop: a freshly installed or updated build no longer keeps loading the previous version's interface from cache.
- Devices: re-pairing a phone (or logging in again) keeps the device's existing name in Connected Devices instead of resetting it to "OpenChamber Mobile".
- Relay: paired devices no longer get logged out when the app restarts (for example during an update) while another local OpenChamber process is running — the restarted app keeps serving them instead of a bystander process taking over.
- Sessions: headers now find archived sessions too, so an archived session's title no longer goes missing.
- Files: the editor toolbar is now always docked under the file tabs; the floating hover toolbar and its setting were removed.
- UI: the chat's top and bottom scroll fades are back, and the first uncached open of a session fades the conversation in instead of popping.
- UI: the timeline dialog now fits small screens instead of squeezing the message list to a couple of rows (thanks to @gaojunran).
- Chat: OpenCode notices now share one style.
- UI: draft target menus stay inside the chat area instead of overlapping the header.
- UI: Linear and Cloudflare tools now show their own icons.
- UI: sidebar item tooltips no longer appear instantly on passing hover.
- UI: the btw panel's shadow is lighter, matching the composer.
## [1.20.0] - 2026-08-23
- **Session: /btw side questions.** Type `/btw` followed by your question to ask something off-topic in a temporary session forked from the current conversation, so it inherits the full context but leaves the chat itself untouched. The answer streams into a panel above the composer, which talks to that session while the panel is open; you can collapse it to a slim header bar, keep it as a full session, or discard it. The temporary session stays out of the sidebar and session lists until you keep it (thanks to @jaygupta17).
- **Chat sessions:** start chats without choosing a project. They live in their own Chats section, rather than inheriting a project's repository and worktree context.
- **Desktop/Remote instances:** adding an SSH connection now starts from the hosts in your SSH config instead of a blank command field. Ports, install method and passwords moved behind Advanced settings, and each connection shows Connected, Connecting, or Needs attention with the failure text and a button that resolves it.
- Desktop/Remote instances: connecting to a remote machine now works when bun, OpenChamber or the opencode CLI live in your home directory rather than on the system path. Installing no longer fails with a permission error, and a missing opencode CLI is now reported before the connection starts instead of as a stack trace.
- Desktop/Remote instances: a managed remote server can now also be published to the remote machine's own network, so other devices there reach it without the SSH tunnel. It requires a UI password, and stays private to the tunnel otherwise.
- Desktop/Remote instances: disconnecting from a connection set to not keep the server running now actually stops that remote server.
- Skills catalog: browse curated GitHub skill collections in a card-based catalog with cross-source search, skill counts, stars, recent updates, and links back to each skill's repository.
- Diff: the context-panel diff can now show every change on the current branch against its base branch. OpenChamber detects the base when Git knows it, or lets you choose one once when it does not.
- Dictation: speech is now transcribed after you stop recording. The composer shows a live waveform and timer, and long recordings split at pauses instead of cutting words.
- Settings: the project selector on Providers, Agents, MCP, Commands and Skills now only changes what those pages show. It used to switch the whole app, so opening another project's configuration moved your chat, session list and file tree with it.
- Settings/Projects: a project can now pin a thinking level next to its model, for models that offer levels. Both sit in one Defaults for new chats group, laid out like the Sessions defaults.
- Settings/General: changing the default model, variant or agent no longer repoints an open chat that already carries a model you picked for it. Chats following the default still switch immediately.
- Settings/Providers: the provider you select no longer jumps to a different one on its own. Changing the chat's model or agent, and background provider refreshes, used to move the settings selection with them.
- Settings/Integrations: the experimental page now only lists integrations that can be installed; unavailable and Coming soon entries were removed.
- Chat: file paths in messages now open from the session's project, even if you last browsed files in another project (thanks to @tomzx).
- Chat: app links such as `spotify://` now ask for confirmation before opening another app. You can trust an app link type on one device and manage trusted links in Settings.
- Files/Desktop: files opened from outside the workspace remain readable after their temporary access expires instead of failing until you reopen them (thanks to @pascalandr).
- Diff: creating an inline comment now opens the chat and focuses the composer for your follow-up.
- Chat: in the expanded composer, Enter now starts a new line and Cmd/Ctrl+Enter sends, so a long prompt is harder to send by accident.
- Providers: expanded support for custom providers.
- Small Model: summaries, goal audits, commit messages, and walkthroughs now support more providers.
- Git: generated commit messages now match the repository's recent commit style and language.
- Git: generating a pull request description now picks up the repository's own PR template when it has one, so the draft comes back in your project's sections and checklists instead of the built-in Summary/Why/Testing layout.
- Sidebar: switch between the full project list and a focused view of one project. Sessions created outside OpenChamber now also appear in the sidebar and Recent list without a page refresh (thanks to @tomzx).
- Chat: if OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117). - Chat: if OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117).
- Chat: while a reply streams, the model status line under the last message now turns into the finished message's info row in place, instead of jumping when the reply completes.
- Chat: newly sent messages and syntax-highlighted code blocks no longer briefly flicker. Bash output can also grow with its content instead of being cut off.
- Chat: long user messages can be expanded even when their final layout finishes after they first appear.
- Chat: in a chat without a project, the work status card again steps aside when the context panel is open, instead of sitting next to it.
- Usage: Z.ai credit limits now appear alongside its other quota windows.
- Git: pull-request checks in Work status stay current as their status changes.
- UI: the default dialog close button is easier to click or tap (thanks to @rockinrimmer).
- Desktop/Windows: the close button now aligns correctly with the rest of the window chrome.
## [1.19.0] - 2026-08-19 ## [1.19.0] - 2026-08-19
+11 -1
View File
@@ -204,7 +204,7 @@ state why it remains valid. If there is genuinely no user-visible change, say
so and provide a concrete reason; deleting the evidence section is not an so and provide a concrete reason; deleting the evidence section is not an
exemption. exemption.
### Review Enforcement ### Review enforcement
The automated reviewer performs one unified review of correctness, repository The automated reviewer performs one unified review of correctness, repository
guidance compliance, pull request quality, and evidence. It independently guidance compliance, pull request quality, and evidence. It independently
@@ -238,6 +238,16 @@ verify a trustworthy result, in which case it applies `review:automation-failed`
Each completed review creates a new comment tied to its reviewed HEAD so the Each completed review creates a new comment tied to its reviewed HEAD so the
conversation remains chronological. Previous review comments are not rewritten. conversation remains chronological. Previous review comments are not rewritten.
### Keeping PRs active
Stale PRs add review load and make it hard to tell what's still being worked on, so the stale bot keeps the open list current. A PR with no activity for 28 days is automatically labeled `stale`, and closed 7 days later if it stays inactive. To keep a PR open:
- Push updates or respond to review feedback
- Leave a comment if you're waiting on a reviewer
- Add the `pinned`, `security`, or `help wanted` label to exempt a long-running PR from the stale bot
Reopening a closed PR is fine if it becomes relevant again.
## Project Structure ## Project Structure
``` ```
+24 -10
View File
@@ -30,7 +30,7 @@
"@heroui/theme": "^2.4.23", "@heroui/theme": "^2.4.23",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@octokit/rest": "^22.0.1", "@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "1.18.18", "@opencode-ai/sdk": "1.18.21",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -97,7 +97,7 @@
}, },
"packages/electron": { "packages/electron": {
"name": "@openchamber/electron", "name": "@openchamber/electron",
"version": "1.19.0", "version": "1.20.0",
"dependencies": { "dependencies": {
"@openchamber/web": "workspace:*", "@openchamber/web": "workspace:*",
"electron-context-menu": "^4.1.2", "electron-context-menu": "^4.1.2",
@@ -134,7 +134,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@openchamber/ui", "name": "@openchamber/ui",
"version": "1.19.0", "version": "1.20.0",
"dependencies": { "dependencies": {
"@aparajita/capacitor-secure-storage": "^8.0.0", "@aparajita/capacitor-secure-storage": "^8.0.0",
"@base-ui/react": "^1.4.0", "@base-ui/react": "^1.4.0",
@@ -167,8 +167,9 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@legendapp/list": "3.3.8",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "1.18.18", "@opencode-ai/sdk": "1.18.21",
"@pierre/diffs": "1.3.0-beta.6", "@pierre/diffs": "1.3.0-beta.6",
"@replit/codemirror-vim": "^6.4.0", "@replit/codemirror-vim": "^6.4.0",
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "13.3.0",
@@ -227,6 +228,7 @@
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.5.0", "eslint-plugin-react-refresh": "^0.5.0",
"globals": "^16.3.0", "globals": "^16.3.0",
"happy-dom": "^18.0.1",
"nodemon": "^3.1.7", "nodemon": "^3.1.7",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"tsx": "^4.20.6", "tsx": "^4.20.6",
@@ -238,10 +240,10 @@
}, },
"packages/vscode": { "packages/vscode": {
"name": "openchamber", "name": "openchamber",
"version": "1.19.0", "version": "1.20.0",
"dependencies": { "dependencies": {
"@openchamber/ui": "workspace:*", "@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "1.18.18", "@opencode-ai/sdk": "1.18.21",
"adm-zip": "^0.6.0", "adm-zip": "^0.6.0",
"jsonc-parser": "^3.3.1", "jsonc-parser": "^3.3.1",
"react": "^19.1.1", "react": "^19.1.1",
@@ -261,14 +263,14 @@
}, },
"packages/web": { "packages/web": {
"name": "@openchamber/web", "name": "@openchamber/web",
"version": "1.19.0", "version": "1.20.0",
"bin": { "bin": {
"openchamber": "./bin/cli.js", "openchamber": "./bin/cli.js",
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.1.0", "@clack/prompts": "^1.1.0",
"@octokit/rest": "^22.0.1", "@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "1.18.18", "@opencode-ai/sdk": "1.18.21",
"@simplewebauthn/server": "13.3.1", "@simplewebauthn/server": "13.3.1",
"bun-pty": "^0.4.5", "bun-pty": "^0.4.5",
"compression": "^1.8.1", "compression": "^1.8.1",
@@ -917,6 +919,8 @@
"@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="], "@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="],
"@legendapp/list": ["@legendapp/list@3.3.8", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom", "react-native"] }, "sha512-GM4Hca/6WDvcY33XXCieR9MaG9CoZmACzwqQwRhKFSaNKfQV1lTLiTOpWuA9wnE8n8+6WeA52DwNKC9yrnPPeg=="],
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
"@lezer/common": ["@lezer/common@1.5.1", "", {}, "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw=="], "@lezer/common": ["@lezer/common@1.5.1", "", {}, "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw=="],
@@ -1003,7 +1007,7 @@
"@openchamber/web": ["@openchamber/web@workspace:packages/web"], "@openchamber/web": ["@openchamber/web@workspace:packages/web"],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.18", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-zJlwXskIR47V1dkPJqeKBgq7nejG1uU8lJaGIGqbX3MWRCT8vKn0fEotbxuPCKnTdmWsDyNGNg9q1qIliDSMDA=="], "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.21", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-k6iHQ5C8wOPglk+LgFyYnst168cGMQYumgpbVoeXJ+iC1AtvwD5zmjuF8CxMze/y9G1K2bOeO6p9yRvA7eHZLA=="],
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.78.0", "", { "os": "android", "cpu": "arm" }, "sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw=="], "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.78.0", "", { "os": "android", "cpu": "arm" }, "sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw=="],
@@ -1451,6 +1455,8 @@
"@types/vscode": ["@types/vscode@1.109.0", "", {}, "sha512-0Pf95rnwEIwDbmXGC08r0B4TQhAbsHQ5UyTIgVgoieDe4cOnf92usuR5dEczb6bTKEp7ziZH4TV1TRGPPCExtw=="], "@types/vscode": ["@types/vscode@1.109.0", "", {}, "sha512-0Pf95rnwEIwDbmXGC08r0B4TQhAbsHQ5UyTIgVgoieDe4cOnf92usuR5dEczb6bTKEp7ziZH4TV1TRGPPCExtw=="],
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg=="],
@@ -2139,6 +2145,8 @@
"guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="], "guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="],
"happy-dom": ["happy-dom@18.0.1", "", { "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" } }, "sha512-qn+rKOW7KWpVTtgIUi6RVmTBZJSe2k0Db0vh1f7CWrWclkkc7/Q+FrOfkZIb2eiErLyqu5AXEzE7XthO9JVxRA=="],
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
@@ -3351,7 +3359,7 @@
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
@@ -3589,6 +3597,8 @@
"cheerio/undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="], "cheerio/undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="],
"cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"cli-truncate/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "cli-truncate/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
@@ -3629,6 +3639,8 @@
"globby/slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], "globby/slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="],
"happy-dom/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
"iconv-corefoundation/cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="], "iconv-corefoundation/cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="],
"iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
@@ -3815,6 +3827,8 @@
"glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], "glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"happy-dom/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], "iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="],
"micromark-extension-math/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "micromark-extension-math/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "openchamber-monorepo", "name": "openchamber-monorepo",
"version": "1.19.0", "version": "1.20.0",
"description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes", "description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -115,7 +115,7 @@
"@heroui/theme": "^2.4.23", "@heroui/theme": "^2.4.23",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@octokit/rest": "^22.0.1", "@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "1.18.18", "@opencode-ai/sdk": "1.18.21",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
+4 -12
View File
@@ -1,16 +1,17 @@
--- ---
title: Integrationen title: Integrationen
description: Nutze dein Claude-, Command-Code- oder Cursor-Abo als Provider. description: Nutze dein Claude- oder Cursor-Abo als Provider.
--- ---
# Integrationen # Integrationen
Eine Integration ist ein kleines Plugin, das OpenChamber einen Provider hinzufügt — auf Basis eines Abos, das du bereits hast. Verwalten kannst du sie unter **Settings → Integrations**. Eine Integration ist ein kleines Plugin, das OpenChamber einen Provider hinzufügt — auf Basis eines Abos, das du bereits hast. Verwalten kannst du sie unter **Settings → Integrations**.
> **Experimentelle Funktion.** Wir bemühen uns, die Richtlinien der Anbieter zu respektieren, aber Kontobeschränkungen und Sperrungen liegen bei jedem Anbieter. Nutze Integrationen auf eigenes Risiko.
Verfügbare Integrationen: Verfügbare Integrationen:
- **Claude Code** — dein Claude Pro- oder Max-Plan, ohne API-Keys - **Claude Code** — dein Claude Pro- oder Max-Plan, ohne API-Keys
- **Command Code** — dein Command-Code-Plan
- **Cursor** — die Modell-Limits deines Cursor-Plans - **Cursor** — die Modell-Limits deines Cursor-Plans
## Integration installieren ## Integration installieren
@@ -29,19 +30,10 @@ Claude Code nutzt deinen Claude Pro- oder Max-Plan — ohne API-Keys und ohne se
1. Installiere die Integration (siehe oben). 1. Installiere die Integration (siehe oben).
2. Wähle **Set up** und melde dich an. Wenn du die Claude Code CLI noch nicht hast, bietet die Einrichtung an, sie zuerst zu installieren, und meldet dich danach an. 2. Wähle **Set up** und melde dich an. Wenn du die Claude Code CLI noch nicht hast, bietet die Einrichtung an, sie zuerst zu installieren, und meldet dich danach an.
Claude Code ist die einzige Integration hier, die ihre Provider-CLI installiert und angemeldet benötigt. Command Code und Cursor brauchen ihre CLIs nicht. Claude Code ist die einzige Integration hier, die ihre Provider-CLI installiert und angemeldet benötigt. Cursor braucht seine CLI nicht.
**Wie dein Claude-Konto geschützt bleibt:** Diese Integration nutzt das offizielle Claude Agent SDK von Anthropic und deine installierte Claude Code CLI. Sie kapert kein OAuth, extrahiert oder wiederholt keine Browser-Tokens, gibt sich nicht als nicht unterstützter Client aus und umgeht nicht Anthropics Authentifizierung. Sie bleibt auf dem von Anthropic unterstützten Zugriffsweg und trägt daher nicht das mit Token-Hijacking oder unautorisierten Authentifizierungsumgehungen verbundene Sperrrisiko. **Wie dein Claude-Konto geschützt bleibt:** Diese Integration nutzt das offizielle Claude Agent SDK von Anthropic und deine installierte Claude Code CLI. Sie kapert kein OAuth, extrahiert oder wiederholt keine Browser-Tokens, gibt sich nicht als nicht unterstützter Client aus und umgeht nicht Anthropics Authentifizierung. Sie bleibt auf dem von Anthropic unterstützten Zugriffsweg und trägt daher nicht das mit Token-Hijacking oder unautorisierten Authentifizierungsumgehungen verbundene Sperrrisiko.
## Command Code
Command Code nutzt deinen Command-Code-Plan.
1. Installiere die Integration (siehe oben).
2. Wähle **Set up** — es öffnet sich eine Browserseite. Genehmige den Zugriff und kehre zu OpenChamber zurück.
Auf einem Headless-Server oder in CI setze stattdessen die Umgebungsvariable `COMMAND_CODE_API_KEY`, anstatt dich im Browser anzumelden.
## Cursor ## Cursor
Cursor macht die Modelle deines Cursor-Plans in OpenChamber nutzbar. Cursor macht die Modelle deines Cursor-Plans in OpenChamber nutzbar.
+4 -12
View File
@@ -1,16 +1,17 @@
--- ---
title: Integraciones title: Integraciones
description: Usa tu suscripción de Claude, Command Code o Cursor como proveedor. description: Usa tu suscripción de Claude o Cursor como proveedor.
--- ---
# Integraciones # Integraciones
Una integración es un pequeño plugin que añade un proveedor a OpenChamber usando una suscripción que ya tienes. Las gestionas en **Settings → Integrations**. Una integración es un pequeño plugin que añade un proveedor a OpenChamber usando una suscripción que ya tienes. Las gestionas en **Settings → Integrations**.
> **Función experimental.** Buscamos respetar las políticas de los proveedores, pero las restricciones y suspensiones de cuentas son decisión de cada proveedor. Usa las integraciones bajo tu propia responsabilidad.
Integraciones disponibles: Integraciones disponibles:
- **Claude Code** — tu plan Claude Pro o Max, sin claves de API - **Claude Code** — tu plan Claude Pro o Max, sin claves de API
- **Command Code** — tu plan de Command Code
- **Cursor** — los límites de modelos de tu plan de Cursor - **Cursor** — los límites de modelos de tu plan de Cursor
## Instalar una integración ## Instalar una integración
@@ -29,19 +30,10 @@ Claude Code usa tu plan Claude Pro o Max — sin claves de API y sin una app de
1. Instala la integración (arriba). 1. Instala la integración (arriba).
2. Elige **Set up** e inicia sesión. Si aún no tienes la CLI de Claude Code, la configuración ofrece instalarla primero y luego iniciar sesión. 2. Elige **Set up** e inicia sesión. Si aún no tienes la CLI de Claude Code, la configuración ofrece instalarla primero y luego iniciar sesión.
Claude Code es la única integración de esta página que requiere tener la CLI de su proveedor instalada y con sesión iniciada. Command Code y Cursor no requieren sus CLIs. Claude Code es la única integración de esta página que requiere tener la CLI de su proveedor instalada y con sesión iniciada. Cursor no requiere su CLI.
**Cómo se protege tu cuenta de Claude:** esta integración usa el Claude Agent SDK oficial de Anthropic y tu CLI de Claude Code instalada. No secuestra OAuth, no extrae ni reutiliza tokens del navegador, no se hace pasar por un cliente no admitido ni omite la autenticación de Anthropic. Se mantiene en la vía de acceso admitida por Anthropic, por lo que no conlleva el riesgo de baneo asociado al secuestro de tokens o a rodeos de autenticación no autorizados. **Cómo se protege tu cuenta de Claude:** esta integración usa el Claude Agent SDK oficial de Anthropic y tu CLI de Claude Code instalada. No secuestra OAuth, no extrae ni reutiliza tokens del navegador, no se hace pasar por un cliente no admitido ni omite la autenticación de Anthropic. Se mantiene en la vía de acceso admitida por Anthropic, por lo que no conlleva el riesgo de baneo asociado al secuestro de tokens o a rodeos de autenticación no autorizados.
## Command Code
Command Code usa tu plan de Command Code.
1. Instala la integración (arriba).
2. Elige **Set up** — se abre una página en el navegador. Autoriza el acceso y vuelve a OpenChamber.
En una máquina sin interfaz gráfica o en CI, define la variable de entorno `COMMAND_CODE_API_KEY` en lugar de iniciar sesión en el navegador.
## Cursor ## Cursor
Cursor hace disponibles en OpenChamber los modelos incluidos en tu plan de Cursor. Cursor hace disponibles en OpenChamber los modelos incluidos en tu plan de Cursor.
+1 -1
View File
@@ -45,6 +45,6 @@ Los inicios de sesión de los proveedores los guarda OpenCode, no OpenChamber, a
## Relacionado ## Relacionado
- [Integraciones](/es/integrations/) — usa una suscripción de Claude, Command Code o Cursor como proveedor - [Integraciones](/es/integrations/) — usa una suscripción de Claude o Cursor como proveedor
- [Servidores MCP](/es/mcp/) — añade herramientas extra para los agentes - [Servidores MCP](/es/mcp/) — añade herramientas extra para los agentes
- [Uso y cuotas](/es/usage/) — controla cuánto has consumido - [Uso y cuotas](/es/usage/) — controla cuánto has consumido
+4 -12
View File
@@ -1,16 +1,17 @@
--- ---
title: Intégrations title: Intégrations
description: Utilise ton abonnement Claude, Command Code ou Cursor comme fournisseur. description: Utilise ton abonnement Claude ou Cursor comme fournisseur.
--- ---
# Intégrations # Intégrations
Une intégration est un petit plugin qui ajoute un fournisseur à OpenChamber à partir d'un abonnement que tu possèdes déjà. Tu les gères dans **Settings → Integrations**. Une intégration est un petit plugin qui ajoute un fournisseur à OpenChamber à partir d'un abonnement que tu possèdes déjà. Tu les gères dans **Settings → Integrations**.
> **Fonctionnalité expérimentale.** Nous cherchons à respecter les règles des fournisseurs, mais les restrictions et suspensions de compte relèvent de leur décision. Utilise les intégrations à tes risques.
Intégrations disponibles : Intégrations disponibles :
- **Claude Code** — ton plan Claude Pro ou Max, sans clés API - **Claude Code** — ton plan Claude Pro ou Max, sans clés API
- **Command Code** — ton plan Command Code
- **Cursor** — les limites de modèles de ton plan Cursor - **Cursor** — les limites de modèles de ton plan Cursor
## Installer une intégration ## Installer une intégration
@@ -29,19 +30,10 @@ Claude Code utilise ton plan Claude Pro ou Max — sans clés API et sans applic
1. Installe l'intégration (ci-dessus). 1. Installe l'intégration (ci-dessus).
2. Choisis **Set up** et connecte-toi. Si tu n'as pas encore la CLI Claude Code, la configuration propose de l'installer d'abord, puis de te connecter. 2. Choisis **Set up** et connecte-toi. Si tu n'as pas encore la CLI Claude Code, la configuration propose de l'installer d'abord, puis de te connecter.
Claude Code est la seule intégration ici qui exige que la CLI de son fournisseur soit installée et connectée. Command Code et Cursor n'exigent pas leurs CLIs. Claude Code est la seule intégration ici qui exige que la CLI de son fournisseur soit installée et connectée. Cursor n'exige pas sa CLI.
**Comment ton compte Claude reste protégé :** cette intégration utilise le Claude Agent SDK officiel d'Anthropic et ta CLI Claude Code installée. Elle ne détourne pas l'OAuth, n'extrait ni rejoue de tokens de navigateur, ne se fait pas passer pour un client non pris en charge et ne contourne pas l'authentification d'Anthropic. Elle reste sur la voie d'accès prise en charge par Anthropic et ne porte donc pas le risque de bannissement associé au détournement de tokens ou aux contournements d'authentification non autorisés. **Comment ton compte Claude reste protégé :** cette intégration utilise le Claude Agent SDK officiel d'Anthropic et ta CLI Claude Code installée. Elle ne détourne pas l'OAuth, n'extrait ni rejoue de tokens de navigateur, ne se fait pas passer pour un client non pris en charge et ne contourne pas l'authentification d'Anthropic. Elle reste sur la voie d'accès prise en charge par Anthropic et ne porte donc pas le risque de bannissement associé au détournement de tokens ou aux contournements d'authentification non autorisés.
## Command Code
Command Code utilise ton plan Command Code.
1. Installe l'intégration (ci-dessus).
2. Choisis **Set up** — une page s'ouvre dans le navigateur. Autorise l'accès, puis reviens dans OpenChamber.
Sur une machine sans interface graphique ou en CI, définis la variable d'environnement `COMMAND_CODE_API_KEY` au lieu de te connecter via le navigateur.
## Cursor ## Cursor
Cursor rend disponibles dans OpenChamber les modèles inclus dans ton plan Cursor. Cursor rend disponibles dans OpenChamber les modèles inclus dans ton plan Cursor.
+1 -1
View File
@@ -45,6 +45,6 @@ Les connexions aux fournisseurs sont stockées par OpenCode, pas OpenChamber ; e
## Pages liées ## Pages liées
- [Intégrations](/integrations/) — utiliser un abonnement Claude, Command Code ou Cursor comme fournisseur - [Intégrations](/integrations/) — utiliser un abonnement Claude ou Cursor comme fournisseur
- [Serveurs MCP](/mcp/) — ajouter des outils supplémentaires aux agents - [Serveurs MCP](/mcp/) — ajouter des outils supplémentaires aux agents
- [Utilisation et quotas](/usage/) — suivre votre consommation - [Utilisation et quotas](/usage/) — suivre votre consommation
+4 -12
View File
@@ -1,16 +1,17 @@
--- ---
title: Integrations title: Integrations
description: Use your Claude, Command Code, or Cursor subscription as a provider. description: Use your Claude or Cursor subscription as a provider.
--- ---
# Integrations # Integrations
An integration is a small plugin that adds a provider to OpenChamber using a subscription you already have. You manage them at **Settings → Integrations**. An integration is a small plugin that adds a provider to OpenChamber using a subscription you already have. You manage them at **Settings → Integrations**.
> **Experimental feature.** We aim to respect provider policies, but account restrictions and suspensions remain each provider's decision. Use integrations at your own risk.
Available integrations: Available integrations:
- **Claude Code** — your Claude Pro or Max plan, no API keys - **Claude Code** — your Claude Pro or Max plan, no API keys
- **Command Code** — your Command Code plan
- **Cursor** — the model limits of your Cursor plan - **Cursor** — the model limits of your Cursor plan
## Install an integration ## Install an integration
@@ -29,19 +30,10 @@ Claude Code uses your Claude Pro or Max plan — no API keys and no separate Cla
1. Install the integration (above). 1. Install the integration (above).
2. Choose **Set up** and sign in. If you don't have the Claude Code CLI yet, setup offers to install it first and then sign you in. 2. Choose **Set up** and sign in. If you don't have the Claude Code CLI yet, setup offers to install it first and then sign you in.
Claude Code is the only integration here that requires its provider CLI to be installed and signed in. Command Code and Cursor do not require their CLIs. Claude Code is the only integration here that requires its provider CLI to be installed and signed in. Cursor does not require its CLI.
**How your Claude account stays safe:** this integration uses Anthropic's official Claude Agent SDK and your installed Claude Code CLI. It does not hijack OAuth, extract or replay browser tokens, impersonate an unsupported client, or bypass Anthropic's authentication flow. It stays on Anthropic's supported access path, so it does not carry the account-ban risk of token hijacking or unauthorized authentication workarounds. **How your Claude account stays safe:** this integration uses Anthropic's official Claude Agent SDK and your installed Claude Code CLI. It does not hijack OAuth, extract or replay browser tokens, impersonate an unsupported client, or bypass Anthropic's authentication flow. It stays on Anthropic's supported access path, so it does not carry the account-ban risk of token hijacking or unauthorized authentication workarounds.
## Command Code
Command Code uses your Command Code plan.
1. Install the integration (above).
2. Choose **Set up** — a browser page opens. Approve access, then return to OpenChamber.
On a headless machine or in CI, set the `COMMAND_CODE_API_KEY` environment variable instead of signing in in the browser.
## Cursor ## Cursor
Cursor makes the models included in your Cursor plan available in OpenChamber. Cursor makes the models included in your Cursor plan available in OpenChamber.
+4 -12
View File
@@ -1,16 +1,17 @@
--- ---
title: 統合機能 title: 統合機能
description: Claude、Command Code、Cursor のサブスクリプションをプロバイダーとして使う。 description: Claude または Cursor のサブスクリプションをプロバイダーとして使う。
--- ---
# 統合機能 # 統合機能
統合機能(インテグレーション)は、すでに持っているサブスクリプションを使って OpenChamber にプロバイダーを追加する小さなプラグインです。**Settings → Integrations** で管理します。 統合機能(インテグレーション)は、すでに持っているサブスクリプションを使って OpenChamber にプロバイダーを追加する小さなプラグインです。**Settings → Integrations** で管理します。
> **実験的な機能。** プロバイダーの方針を尊重するよう努めていますが、アカウントの制限や停止は各プロバイダーの判断に委ねられます。自己責任で連携を使用してください。
利用できる統合機能: 利用できる統合機能:
- **Claude Code** — Claude Pro または Max プラン、API キー不要 - **Claude Code** — Claude Pro または Max プラン、API キー不要
- **Command Code** — Command Code のプラン
- **Cursor** — Cursor プランのモデル利用枠 - **Cursor** — Cursor プランのモデル利用枠
## 統合機能をインストールする ## 統合機能をインストールする
@@ -29,19 +30,10 @@ Claude Code は Claude Pro または Max プランを使います — API キー
1. 統合機能をインストールします(上記)。 1. 統合機能をインストールします(上記)。
2. **Set up** を選んでサインインします。Claude Code CLI がまだない場合は、セットアップがまずインストールを提案し、その後サインインします。 2. **Set up** を選んでサインインします。Claude Code CLI がまだない場合は、セットアップがまずインストールを提案し、その後サインインします。
Claude Code は、ここで唯一プロバイダーの CLI のインストールとサインインを必要とする統合機能です。Command Code と Cursor は CLI を必要としません。 Claude Code は、ここで唯一プロバイダーの CLI のインストールとサインインを必要とする統合機能です。Cursor は CLI を必要としません。
**Claude アカウントが守られる仕組み:** この統合機能は Anthropic の公式 Claude Agent SDK と、インストール済みの Claude Code CLI を使用します。OAuth の乗っ取り、ブラウザートークンの抽出や再生、未対応クライアントへの偽装、Anthropic の認証フローの回避は一切行いません。Anthropic がサポートする正規のアクセス経路を使うため、トークン乗っ取りや不正な認証の回避につきもののアカウント停止リスクはありません。 **Claude アカウントが守られる仕組み:** この統合機能は Anthropic の公式 Claude Agent SDK と、インストール済みの Claude Code CLI を使用します。OAuth の乗っ取り、ブラウザートークンの抽出や再生、未対応クライアントへの偽装、Anthropic の認証フローの回避は一切行いません。Anthropic がサポートする正規のアクセス経路を使うため、トークン乗っ取りや不正な認証の回避につきもののアカウント停止リスクはありません。
## Command Code
Command Code は Command Code のプランを使います。
1. 統合機能をインストールします(上記)。
2. **Set up** を選ぶとブラウザーでページが開きます。アクセスを許可して OpenChamber に戻ります。
画面のないサーバーや CI では、ブラウザーでサインインする代わりに環境変数 `COMMAND_CODE_API_KEY` を設定してください。
## Cursor ## Cursor
Cursor は Cursor プランに含まれるモデルを OpenChamber で使えるようにします。 Cursor は Cursor プランに含まれるモデルを OpenChamber で使えるようにします。
+1 -1
View File
@@ -45,6 +45,6 @@ OpenChamber が何かを行うには、少なくとも 1 つの AI プロバイ
## 関連 ## 関連
- [統合機能](/integrations/) — Claude、Command Code、Cursor のサブスクリプションをプロバイダーとして使う - [統合機能](/integrations/) — Claude または Cursor のサブスクリプションをプロバイダーとして使う
- [MCP サーバー](/mcp/) — エージェントに追加ツールを加える - [MCP サーバー](/mcp/) — エージェントに追加ツールを加える
- [使用量とクォータ](/usage/) — 使った量を追跡する - [使用量とクォータ](/usage/) — 使った量を追跡する
+4 -12
View File
@@ -1,16 +1,17 @@
--- ---
title: 통합 기능 title: 통합 기능
description: Claude, Command Code 또는 Cursor 구독을 공급자로 사용하세요. description: Claude 또는 Cursor 구독을 공급자로 사용하세요.
--- ---
# 통합 기능 # 통합 기능
통합 기능(인테그레이션)은 이미 가지고 있는 구독을 사용해 OpenChamber에 공급자를 추가하는 작은 플러그인입니다. **Settings → Integrations**에서 관리합니다. 통합 기능(인테그레이션)은 이미 가지고 있는 구독을 사용해 OpenChamber에 공급자를 추가하는 작은 플러그인입니다. **Settings → Integrations**에서 관리합니다.
> **실험 단계 기능.** 프로바이더 정책을 존중하려 노력하지만, 계정 제한과 정지는 각 프로바이더의 결정입니다. 본인의 책임 아래 통합 기능을 사용하세요.
사용 가능한 통합 기능: 사용 가능한 통합 기능:
- **Claude Code** — Claude Pro 또는 Max 플랜, API 키 불필요 - **Claude Code** — Claude Pro 또는 Max 플랜, API 키 불필요
- **Command Code** — Command Code 플랜
- **Cursor** — Cursor 플랜의 모델 한도 - **Cursor** — Cursor 플랜의 모델 한도
## 통합 기능 설치 ## 통합 기능 설치
@@ -29,19 +30,10 @@ Claude Code는 Claude Pro 또는 Max 플랜을 사용합니다 — API 키도
1. 통합 기능을 설치합니다(위 참고). 1. 통합 기능을 설치합니다(위 참고).
2. **Set up**를 선택하고 로그인합니다. Claude Code CLI가 아직 없으면 설정에서 먼저 설치를 제안한 뒤 로그인을 진행합니다. 2. **Set up**를 선택하고 로그인합니다. Claude Code CLI가 아직 없으면 설정에서 먼저 설치를 제안한 뒤 로그인을 진행합니다.
Claude Code는 여기에서 유일하게 공급자 CLI 설치와 로그인을 필요로 하는 통합 기능입니다. Command Code와 Cursor는 CLI가 필요 없습니다. Claude Code는 여기에서 유일하게 공급자 CLI 설치와 로그인을 필요로 하는 통합 기능입니다. Cursor는 CLI가 필요 없습니다.
**Claude 계정이 안전하게 유지되는 방식:** 이 통합 기능은 Anthropic의 공식 Claude Agent SDK와 설치된 Claude Code CLI를 사용합니다. OAuth 탈취, 브라우저 토큰 추출·재사용, 지원되지 않는 클라이언트로의 위장, Anthropic 인증 우회를 하지 않습니다. Anthropic이 지원하는 정상 경로를 사용하므로 토큰 탈취나 비인가 인증 우회에 따른 계정 정지 위험이 없습니다. **Claude 계정이 안전하게 유지되는 방식:** 이 통합 기능은 Anthropic의 공식 Claude Agent SDK와 설치된 Claude Code CLI를 사용합니다. OAuth 탈취, 브라우저 토큰 추출·재사용, 지원되지 않는 클라이언트로의 위장, Anthropic 인증 우회를 하지 않습니다. Anthropic이 지원하는 정상 경로를 사용하므로 토큰 탈취나 비인가 인증 우회에 따른 계정 정지 위험이 없습니다.
## Command Code
Command Code는 Command Code 플랜을 사용합니다.
1. 통합 기능을 설치합니다(위 참고).
2. **Set up**를 선택하면 브라우저에서 페이지가 열립니다. 접근을 승인한 뒤 OpenChamber로 돌아옵니다.
화면이 없는 서버나 CI 환경에서는 브라우저 로그인 대신 `COMMAND_CODE_API_KEY` 환경 변수를 설정하세요.
## Cursor ## Cursor
Cursor는 Cursor 플랜에 포함된 모델을 OpenChamber에서 사용할 수 있게 합니다. Cursor는 Cursor 플랜에 포함된 모델을 OpenChamber에서 사용할 수 있게 합니다.
+1 -1
View File
@@ -45,6 +45,6 @@ OpenChamber가 무언가를 하려면 먼저 최소한 하나의 AI 공급자가
## 관련 항목 ## 관련 항목
- [통합 기능](/ko/integrations/) — Claude, Command Code, Cursor 구독을 공급자로 사용 - [통합 기능](/ko/integrations/) — Claude 또는 Cursor 구독을 공급자로 사용
- [MCP Servers](/ko/mcp/) — 에이전트에 추가 도구를 제공합니다 - [MCP Servers](/ko/mcp/) — 에이전트에 추가 도구를 제공합니다
- [Usage & Quotas](/ko/usage/) — 사용량을 추적합니다 - [Usage & Quotas](/ko/usage/) — 사용량을 추적합니다
+4 -12
View File
@@ -1,16 +1,17 @@
--- ---
title: Integracje title: Integracje
description: Używaj subskrypcji Claude, Command Code lub Cursor jako dostawcy. description: Używaj subskrypcji Claude lub Cursor jako dostawcy.
--- ---
# Integracje # Integracje
Integracja to mała wtyczka, która dodaje dostawcę do OpenChamber na podstawie subskrypcji, którą już masz. Zarządzasz nimi w **Settings → Integrations**. Integracja to mała wtyczka, która dodaje dostawcę do OpenChamber na podstawie subskrypcji, którą już masz. Zarządzasz nimi w **Settings → Integrations**.
> **Funkcja eksperymentalna.** Staramy się przestrzegać zasad dostawców, ale ograniczenia i zawieszenia kont pozostają decyzją każdego dostawcy. Używaj integracji na własne ryzyko.
Dostępne integracje: Dostępne integracje:
- **Claude Code** — Twój plan Claude Pro lub Max, bez kluczy API - **Claude Code** — Twój plan Claude Pro lub Max, bez kluczy API
- **Command Code** — Twój plan Command Code
- **Cursor** — limity modeli z Twojego planu Cursor - **Cursor** — limity modeli z Twojego planu Cursor
## Instalacja integracji ## Instalacja integracji
@@ -29,19 +30,10 @@ Claude Code korzysta z Twojego planu Claude Pro lub Max — bez kluczy API i bez
1. Zainstaluj integrację (patrz wyżej). 1. Zainstaluj integrację (patrz wyżej).
2. Wybierz **Set up** i zaloguj się. Jeśli nie masz jeszcze Claude Code CLI, konfiguracja zaoferuje najpierw jego instalację, a potem logowanie. 2. Wybierz **Set up** i zaloguj się. Jeśli nie masz jeszcze Claude Code CLI, konfiguracja zaoferuje najpierw jego instalację, a potem logowanie.
Claude Code jest jedyną integracją tutaj, która wymaga zainstalowanego i zalogowanego CLI swojego dostawcy. Command Code i Cursor nie wymaga swoich CLI. Claude Code jest jedyną integracją tutaj, która wymaga zainstalowanego i zalogowanego CLI swojego dostawcy. Cursor nie wymaga swojego CLI.
**Jak chronione jest Twoje konto Claude:** ta integracja używa oficjalnego Claude Agent SDK od Anthropic i Twojego zainstalowanego Claude Code CLI. Nie przechwytuje OAuth, nie wyodrębnia ani nie odtwarza tokenów przeglądarki, nie podszywa się pod nieobsługiwany klient i nie omija uwierzytelniania Anthropic. Działa na obsługiwanej przez Anthropic ścieżce dostępu, więc nie niesie ryzyka zablokowania konta związanego z przechwytywaniem tokenów lub nieautoryzowanymi obejściami uwierzytelniania. **Jak chronione jest Twoje konto Claude:** ta integracja używa oficjalnego Claude Agent SDK od Anthropic i Twojego zainstalowanego Claude Code CLI. Nie przechwytuje OAuth, nie wyodrębnia ani nie odtwarza tokenów przeglądarki, nie podszywa się pod nieobsługiwany klient i nie omija uwierzytelniania Anthropic. Działa na obsługiwanej przez Anthropic ścieżce dostępu, więc nie niesie ryzyka zablokowania konta związanego z przechwytywaniem tokenów lub nieautoryzowanymi obejściami uwierzytelniania.
## Command Code
Command Code korzysta z Twojego planu Command Code.
1. Zainstaluj integrację (patrz wyżej).
2. Wybierz **Set up** — w przeglądarce otworzy się strona. Zatwierdź dostęp i wróć do OpenChamber.
Na maszynie bez interfejsu graficznego lub w CI ustaw zmienną środowiskową `COMMAND_CODE_API_KEY` zamiast logowania w przeglądarce.
## Cursor ## Cursor
Cursor udostępnia w OpenChamber modele zawarte w Twoim planie Cursor. Cursor udostępnia w OpenChamber modele zawarte w Twoim planie Cursor.
+1 -1
View File
@@ -45,6 +45,6 @@ Logowania dostawców są przechowywane przez OpenCode, a nie OpenChamber, więc
## Powiązane ## Powiązane
- [Integracje](/pl/integrations/) — używaj subskrypcji Claude, Command Code lub Cursor jako dostawcy - [Integracje](/pl/integrations/) — używaj subskrypcji Claude lub Cursor jako dostawcy
- [Serwery MCP](/pl/mcp/) — dodaj agentom dodatkowe narzędzia - [Serwery MCP](/pl/mcp/) — dodaj agentom dodatkowe narzędzia
- [Zużycie i limity](/pl/usage/) — śledź, ile już wykorzystałeś - [Zużycie i limity](/pl/usage/) — śledź, ile już wykorzystałeś
+1 -1
View File
@@ -57,6 +57,6 @@ Provider sign-ins are stored by OpenCode, not OpenChamber, so they're shared wit
## Related ## Related
- [Integrations](/integrations/) — use a Claude, Command Code, or Cursor subscription as a provider - [Integrations](/integrations/) — use a Claude or Cursor subscription as a provider
- [MCP Servers](/mcp/) — add extra tools for agents - [MCP Servers](/mcp/) — add extra tools for agents
- [Usage & Quotas](/usage/) — track how much you've used - [Usage & Quotas](/usage/) — track how much you've used
@@ -1,16 +1,17 @@
--- ---
title: Integrações title: Integrações
description: Use sua assinatura Claude, Command Code ou Cursor como provedor. description: Use sua assinatura Claude ou Cursor como provedor.
--- ---
# Integrações # Integrações
Uma integração é um pequeno plugin que adiciona um provedor ao OpenChamber usando uma assinatura que você já tem. Você as gerencia em **Settings → Integrations**. Uma integração é um pequeno plugin que adiciona um provedor ao OpenChamber usando uma assinatura que você já tem. Você as gerencia em **Settings → Integrations**.
> **Recurso experimental.** Buscamos respeitar as políticas dos provedores, mas restrições e suspensões de conta continuam sendo decisão de cada provedor. Use as integrações por sua conta e risco.
Integrações disponíveis: Integrações disponíveis:
- **Claude Code** — seu plano Claude Pro ou Max, sem chaves de API - **Claude Code** — seu plano Claude Pro ou Max, sem chaves de API
- **Command Code** — seu plano Command Code
- **Cursor** — os limites de modelos do seu plano Cursor - **Cursor** — os limites de modelos do seu plano Cursor
## Instalar uma integração ## Instalar uma integração
@@ -29,19 +30,10 @@ O Claude Code usa seu plano Claude Pro ou Max — sem chaves de API e sem um app
1. Instale a integração (acima). 1. Instale a integração (acima).
2. Escolha **Set up** e faça login. Se você ainda não tem a CLI do Claude Code, a configuração oferece instalá-la primeiro e depois fazer login. 2. Escolha **Set up** e faça login. Se você ainda não tem a CLI do Claude Code, a configuração oferece instalá-la primeiro e depois fazer login.
O Claude Code é a única integração aqui que exige que a CLI do provedor esteja instalada e autenticada. Command Code e Cursor não exigem suas CLIs. O Claude Code é a única integração aqui que exige que a CLI do provedor esteja instalada e autenticada. Cursor não exige sua CLI.
**Como sua conta Claude fica protegida:** esta integração usa o Claude Agent SDK oficial da Anthropic e a CLI do Claude Code instalada em sua máquina. Ela não sequestra OAuth, não extrai nem reproduz tokens do navegador, não se passa por um cliente não suportado e não contorna a autenticação da Anthropic. Ela permanece no caminho de acesso suportado pela Anthropic, portanto não traz o risco de banimento de conta associado a sequestro de tokens ou a contornos de autenticação não autorizados. **Como sua conta Claude fica protegida:** esta integração usa o Claude Agent SDK oficial da Anthropic e a CLI do Claude Code instalada em sua máquina. Ela não sequestra OAuth, não extrai nem reproduz tokens do navegador, não se passa por um cliente não suportado e não contorna a autenticação da Anthropic. Ela permanece no caminho de acesso suportado pela Anthropic, portanto não traz o risco de banimento de conta associado a sequestro de tokens ou a contornos de autenticação não autorizados.
## Command Code
O Command Code usa seu plano Command Code.
1. Instale a integração (acima).
2. Escolha **Set up** — uma página abre no navegador. Autorize o acesso e volte ao OpenChamber.
Em uma máquina sem interface gráfica ou em CI, defina a variável de ambiente `COMMAND_CODE_API_KEY` em vez de fazer login pelo navegador.
## Cursor ## Cursor
O Cursor torna disponíveis no OpenChamber os modelos incluídos no seu plano Cursor. O Cursor torna disponíveis no OpenChamber os modelos incluídos no seu plano Cursor.
@@ -45,6 +45,6 @@ Os logins de provedores são armazenados pelo OpenCode, não pelo OpenChamber, e
## Relacionado ## Relacionado
- [Integrações](/pt-br/integrations/) — use uma assinatura Claude, Command Code ou Cursor como provedor - [Integrações](/pt-br/integrations/) — use uma assinatura Claude ou Cursor como provedor
- [Servidores MCP](/pt-br/mcp/) — adicione ferramentas extras para os agentes - [Servidores MCP](/pt-br/mcp/) — adicione ferramentas extras para os agentes
- [Uso e Cotas](/pt-br/usage/) — acompanhe quanto você já usou - [Uso e Cotas](/pt-br/usage/) — acompanhe quanto você já usou
+4 -12
View File
@@ -1,16 +1,17 @@
--- ---
title: Інтеграції title: Інтеграції
description: Використовуйте підписки Claude, Command Code або Cursor як провайдерів. description: Використовуйте підписки Claude або Cursor як провайдерів.
--- ---
# Інтеграції # Інтеграції
Інтеграція — це невеликий плагін, що додає провайдера до OpenChamber на основі підписки, яка в вас уже є. Керувати ними можна в **Settings → Integrations**. Інтеграція — це невеликий плагін, що додає провайдера до OpenChamber на основі підписки, яка в вас уже є. Керувати ними можна в **Settings → Integrations**.
> **Експериментальна функція.** Ми прагнемо дотримуватися політик провайдерів, але обмеження та блокування облікових записів залишаються рішенням кожного провайдера. Використовуйте інтеграції на власний ризик.
Доступні інтеграції: Доступні інтеграції:
- **Claude Code** — ваша підписка Claude Pro або Max, без API-ключів - **Claude Code** — ваша підписка Claude Pro або Max, без API-ключів
- **Command Code** — ваша підписка Command Code
- **Cursor** — ліміти моделей вашої підписки Cursor - **Cursor** — ліміти моделей вашої підписки Cursor
## Встановлення інтеграції ## Встановлення інтеграції
@@ -29,19 +30,10 @@ Claude Code використовує вашу підписку Claude Pro або
1. Встановіть інтеграцію (вище). 1. Встановіть інтеграцію (вище).
2. Натисніть **Set up** і увійдіть. Якщо у вас ще немає Claude Code CLI, програма встановлення спершу запропонує його встановити, а потім виконає вхід. 2. Натисніть **Set up** і увійдіть. Якщо у вас ще немає Claude Code CLI, програма встановлення спершу запропонує його встановити, а потім виконає вхід.
Claude Code — єдина інтеграція тут, яка вимагає встановленого та залогіненого CLI свого провайдера. Для Command Code і Cursor їхні CLI не потрібні. Claude Code — єдина інтеграція тут, яка вимагає встановленого та залогіненого CLI свого провайдера. Для Cursor CLI не потрібен.
**Як захищається ваш обліковий запис Claude:** ця інтеграція використовує офіційний Claude Agent SDK від Anthropic і ваш встановлений Claude Code CLI. Вона не перехоплює OAuth, не витягує й не відтворює браузерні токени, не видає себе за непідтримуваний клієнт і не обходить процес автентифікації Anthropic. Усе працює через підтримуваний Anthropic шлях доступу, тож інтеграція не несе ризику блокування облікового запису, пов'язаного з перехопленням токенів або несанкціонованими способами автентифікації. **Як захищається ваш обліковий запис Claude:** ця інтеграція використовує офіційний Claude Agent SDK від Anthropic і ваш встановлений Claude Code CLI. Вона не перехоплює OAuth, не витягує й не відтворює браузерні токени, не видає себе за непідтримуваний клієнт і не обходить процес автентифікації Anthropic. Усе працює через підтримуваний Anthropic шлях доступу, тож інтеграція не несе ризику блокування облікового запису, пов'язаного з перехопленням токенів або несанкціонованими способами автентифікації.
## Command Code
Command Code використовує вашу підписку Command Code.
1. Встановіть інтеграцію (вище).
2. Натисніть **Set up** — відкриється сторінка в браузері. Підтвердьте доступ і поверніться до OpenChamber.
На сервері без графічного інтерфейсу або в CI замість входу через браузер задайте змінну середовища `COMMAND_CODE_API_KEY`.
## Cursor ## Cursor
Cursor робить доступними в OpenChamber моделі, що входять у вашу підписку Cursor. Cursor робить доступними в OpenChamber моделі, що входять у вашу підписку Cursor.
+1 -1
View File
@@ -45,6 +45,6 @@ description: Підключайте AI-провайдерів, обирайте
## Пов'язане ## Пов'язане
- [Інтеграції](/uk/integrations/) — використовуйте підписки Claude, Command Code або Cursor як провайдерів - [Інтеграції](/uk/integrations/) — використовуйте підписки Claude або Cursor як провайдерів
- [MCP Servers](/uk/mcp/) — додайте агентам додаткові інструменти - [MCP Servers](/uk/mcp/) — додайте агентам додаткові інструменти
- [Використання та квоти](/uk/usage/) — відстежуйте, скільки ви витратили - [Використання та квоти](/uk/usage/) — відстежуйте, скільки ви витратили
@@ -1,16 +1,17 @@
--- ---
title: 集成 title: 集成
description: 将你的 Claude、Command Code 或 Cursor 订阅用作提供商。 description: 将你的 Claude 或 Cursor 订阅用作提供商。
--- ---
# 集成 # 集成
集成是一个小型插件,它使用你已有的订阅为 OpenChamber 添加一个提供商。你可以在 **Settings → Integrations** 中管理它们。 集成是一个小型插件,它使用你已有的订阅为 OpenChamber 添加一个提供商。你可以在 **Settings → Integrations** 中管理它们。
> **实验性功能。**我们力求遵守提供商的政策,但帐户限制和暂停仍由各提供商决定。请自行承担使用集成的风险。
可用的集成: 可用的集成:
- **Claude Code** — 你的 Claude Pro 或 Max 套餐,无需 API 密钥 - **Claude Code** — 你的 Claude Pro 或 Max 套餐,无需 API 密钥
- **Command Code** — 你的 Command Code 套餐
- **Cursor** — 你的 Cursor 套餐的模型额度 - **Cursor** — 你的 Cursor 套餐的模型额度
## 安装集成 ## 安装集成
@@ -29,19 +30,10 @@ Claude Code 使用你的 Claude Pro 或 Max 套餐 — 无需 API 密钥,也
1. 安装集成(见上文)。 1. 安装集成(见上文)。
2. 选择 **Set up** 并登录。如果你还没有 Claude Code CLI,安装向导会先提供安装,然后再登录。 2. 选择 **Set up** 并登录。如果你还没有 Claude Code CLI,安装向导会先提供安装,然后再登录。
Claude Code 是这里唯一要求安装并登录其提供商 CLI 的集成。Command Code 和 Cursor 不需要它们的 CLI。 Claude Code 是这里唯一要求安装并登录其提供商 CLI 的集成。Cursor 不需要 CLI。
**你的 Claude 账户如何受到保护:** 此集成使用 Anthropic 官方的 Claude Agent SDK 和你已安装的 Claude Code CLI。它不会劫持 OAuth,不会提取或重放浏览器令牌,不会冒充不受支持的客户端,也不会绕过 Anthropic 的身份验证。它始终运行在 Anthropic 支持的访问路径上,因此不会带来与令牌劫持或未授权身份验证变通手段相关的封号风险。 **你的 Claude 账户如何受到保护:** 此集成使用 Anthropic 官方的 Claude Agent SDK 和你已安装的 Claude Code CLI。它不会劫持 OAuth,不会提取或重放浏览器令牌,不会冒充不受支持的客户端,也不会绕过 Anthropic 的身份验证。它始终运行在 Anthropic 支持的访问路径上,因此不会带来与令牌劫持或未授权身份验证变通手段相关的封号风险。
## Command Code
Command Code 使用你的 Command Code 套餐。
1. 安装集成(见上文)。
2. 选择 **Set up** — 浏览器中会打开一个页面。授权访问,然后返回 OpenChamber。
在没有图形界面的服务器或 CI 环境中,请设置环境变量 `COMMAND_CODE_API_KEY` 来代替浏览器登录。
## Cursor ## Cursor
Cursor 让你的 Cursor 套餐中包含的模型可以在 OpenChamber 中使用。 Cursor 让你的 Cursor 套餐中包含的模型可以在 OpenChamber 中使用。
@@ -45,6 +45,6 @@ description: 连接 AI 提供商、选择模型并设置智能体。
## 相关内容 ## 相关内容
- [集成](/zh-cn/integrations/) — 将 Claude、Command Code 或 Cursor 订阅用作提供商 - [集成](/zh-cn/integrations/) — 将 Claude 或 Cursor 订阅用作提供商
- [MCP Servers](/zh-cn/mcp/) — 为智能体添加额外工具 - [MCP Servers](/zh-cn/mcp/) — 为智能体添加额外工具
- [用量与配额](/zh-cn/usage/) — 跟踪你已使用的量 - [用量与配额](/zh-cn/usage/) — 跟踪你已使用的量
+1
View File
@@ -143,6 +143,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u
## Native Features Owned Here ## Native Features Owned Here
- Floating Mini Chat windows. - Floating Mini Chat windows.
- New Mini Chat windows default to the managed Chats target. Explicit project/worktree drafts retain their target, existing managed chat sessions reopen in their own directory, and the compact header omits project/branch metadata for Chats. Opening a managed draft back in the main window preserves that target.
- Multiple native windows. - Multiple native windows.
- Native notifications. - Native notifications.
- User-confirmed local folder selection. The shared UI supplies the requested directory as the picker `defaultPath`; confirmation is required before filesystem access is retried. - User-confirmed local folder selection. The shared UI supplies the requested directory as the picker `defaultPath`; confirmation is required before filesystem access is retried.
+19 -5
View File
@@ -11,6 +11,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import updaterPkg from 'electron-updater'; import updaterPkg from 'electron-updater';
import { ElectronSshManager } from './ssh-manager.mjs'; import { ElectronSshManager } from './ssh-manager.mjs';
import { replaceFileWithRetry } from './windows-file-replace.mjs';
import { createTrayController } from './tray.mjs'; import { createTrayController } from './tray.mjs';
import { resolveManagedOpenCodeCwd } from './opencode-cwd.mjs'; import { resolveManagedOpenCodeCwd } from './opencode-cwd.mjs';
import { resolveStartupUrlProbePlan, shouldIgnoreLoopbackConnectionLimit } from './startup-url-selection.mjs'; import { resolveStartupUrlProbePlan, shouldIgnoreLoopbackConnectionLimit } from './startup-url-selection.mjs';
@@ -560,10 +561,15 @@ const writeJsonFile = async (filePath, data) => {
// Atomic: write to a temp file then rename. Readers never see a partial // Atomic: write to a temp file then rename. Readers never see a partial
// JSON file that could parse-error and get coerced to {}. // JSON file that could parse-error and get coerced to {}.
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
await fsp.writeFile(tmp, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 }); try {
if (process.platform !== 'win32') await fsp.chmod(tmp, 0o600); await fsp.writeFile(tmp, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
await fsp.rename(tmp, filePath); if (process.platform !== 'win32') await fsp.chmod(tmp, 0o600);
if (process.platform !== 'win32') await fsp.chmod(filePath, 0o600); await replaceFileWithRetry(tmp, filePath);
if (process.platform !== 'win32') await fsp.chmod(filePath, 0o600);
} catch (error) {
await fsp.rm(tmp, { force: true }).catch(() => {});
throw error;
}
}; };
const readSettingsRoot = () => { const readSettingsRoot = () => {
@@ -1234,7 +1240,15 @@ const registerPackagedUiProtocol = () => {
if (filePath.endsWith('.html')) { if (filePath.endsWith('.html')) {
const html = await fsp.readFile(filePath, 'utf8'); const html = await fsp.readFile(filePath, 'utf8');
const body = injectRuntimeConfigIntoHtml(html); const body = injectRuntimeConfigIntoHtml(html);
return new Response(body, { headers: { 'Content-Type': 'text/html; charset=utf-8' } }); // index.html must never be cached: it names the hashed asset
// bundles, and a cached copy keeps a freshly installed build
// loading the previous version's UI from the renderer disk cache.
return new Response(body, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
},
});
} }
return electronNet.fetch(pathToFileURL(filePath).toString()); return electronNet.fetch(pathToFileURL(filePath).toString());
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@openchamber/electron", "name": "@openchamber/electron",
"version": "1.19.0", "version": "1.20.0",
"private": true, "private": true,
"description": "Electron desktop runtime for OpenChamber", "description": "Electron desktop runtime for OpenChamber",
"author": "OpenChamber", "author": "OpenChamber",
+1 -1
View File
@@ -220,7 +220,7 @@ async function main() {
}); });
} }
const electron = spawnProcess('npx', ['electron', './main.mjs'], { const electron = spawnProcess('bun', ['x', 'electron', './main.mjs'], {
cwd: electronDir, cwd: electronDir,
env: { env: {
...process.env, ...process.env,
+137 -48
View File
@@ -5,9 +5,30 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { replaceFileWithRetry } from './windows-file-replace.mjs';
const LOCAL_HOST_ID = 'local'; const LOCAL_HOST_ID = 'local';
const DEFAULT_CONNECTION_TIMEOUT_SEC = 60; const DEFAULT_CONNECTION_TIMEOUT_SEC = 60;
const DEFAULT_LOCAL_BIND_HOST = '127.0.0.1'; const DEFAULT_LOCAL_BIND_HOST = '127.0.0.1';
// Global npm prefixes are root-owned on most distributions, so `npm install -g`
// fails with EACCES for a normal SSH user. Everything we install goes to a
// prefix inside the user's home instead.
const REMOTE_USER_PREFIX = '$HOME/.openchamber/npm-global';
const REMOTE_BUN_CANDIDATE = '"${BUN_INSTALL:-$HOME/.bun}/bin/bun"';
// The opencode CLI usually installs into the user's home, which an SSH login
// shell does not have on PATH. The remote server only looks at OPENCODE_BINARY
// and PATH, so resolve the CLI here and hand it over explicitly.
const REMOTE_OPENCODE_CANDIDATES = [
'"$HOME/.opencode/bin/opencode"',
'"${BUN_INSTALL:-$HOME/.bun}/bin/opencode"',
'"$HOME/.local/bin/opencode"',
'"$HOME/.openchamber/npm-global/bin/opencode"',
];
const REMOTE_PATH_PREFIX = '$HOME/.opencode/bin:${BUN_INSTALL:-$HOME/.bun}/bin:$HOME/.local/bin:$HOME/.openchamber/npm-global/bin';
const REMOTE_BIN_CANDIDATES = [
'"$HOME/.openchamber/npm-global/bin/openchamber"',
'"${BUN_INSTALL:-$HOME/.bun}/bin/openchamber"',
];
const DEFAULT_CONTROL_PERSIST_SEC = 300; const DEFAULT_CONTROL_PERSIST_SEC = 300;
const DEFAULT_READY_TIMEOUT_SEC = 30; const DEFAULT_READY_TIMEOUT_SEC = 30;
const DEFAULT_RECONNECT_MAX_ATTEMPTS = 5; const DEFAULT_RECONNECT_MAX_ATTEMPTS = 5;
@@ -77,10 +98,15 @@ const writeJsonRoot = async (settingsFilePath, root) => {
await fsp.mkdir(path.dirname(settingsFilePath), { recursive: true }); await fsp.mkdir(path.dirname(settingsFilePath), { recursive: true });
// Atomic write: concurrent readers (main.mjs, web server) would otherwise // Atomic write: concurrent readers (main.mjs, web server) would otherwise
// see partial JSON and readJsonRoot()'s catch would silently coerce to {}, // see partial JSON and readJsonRoot()'s catch would silently coerce to {},
// causing the next read-modify-write to wipe the entire settings file. // causing the next read-modify-write wipe the entire settings file.
const tmp = `${settingsFilePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const tmp = `${settingsFilePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
await fsp.writeFile(tmp, JSON.stringify(root, null, 2)); try {
await fsp.rename(tmp, settingsFilePath); await fsp.writeFile(tmp, JSON.stringify(root, null, 2));
await replaceFileWithRetry(tmp, settingsFilePath);
} catch (error) {
await fsp.rm(tmp, { force: true }).catch(() => {});
throw error;
}
}; };
const defaultTrue = () => true; const defaultTrue = () => true;
@@ -578,6 +604,7 @@ export class ElectronSshManager {
localUrl: null, localUrl: null,
localPort: null, localPort: null,
remotePort: null, remotePort: null,
remoteBinPath: null,
startedByUs: false, startedByUs: false,
retryAttempt: 0, retryAttempt: 0,
requiresUserAction: false, requiresUserAction: false,
@@ -804,9 +831,10 @@ export class ElectronSshManager {
mode: instance?.remoteOpenchamber?.mode === 'external' ? 'external' : 'managed', mode: instance?.remoteOpenchamber?.mode === 'external' ? 'external' : 'managed',
keepRunning: instance?.remoteOpenchamber?.keepRunning !== false, keepRunning: instance?.remoteOpenchamber?.keepRunning !== false,
...(Number.isFinite(instance?.remoteOpenchamber?.preferredPort) ? { preferredPort: Number(instance.remoteOpenchamber.preferredPort) } : {}), ...(Number.isFinite(instance?.remoteOpenchamber?.preferredPort) ? { preferredPort: Number(instance.remoteOpenchamber.preferredPort) } : {}),
installMethod: ['npm', 'bun', 'download_release', 'upload_bundle'].includes(instance?.remoteOpenchamber?.installMethod) installMethod: ['auto', 'npm', 'bun'].includes(instance?.remoteOpenchamber?.installMethod)
? instance.remoteOpenchamber.installMethod ? instance.remoteOpenchamber.installMethod
: 'bun', : 'auto',
bindHost: instance?.remoteOpenchamber?.bindHost === '0.0.0.0' ? '0.0.0.0' : '127.0.0.1',
uploadBundleOverSsh: Boolean(instance?.remoteOpenchamber?.uploadBundleOverSsh), uploadBundleOverSsh: Boolean(instance?.remoteOpenchamber?.uploadBundleOverSsh),
}, },
localForward: { localForward: {
@@ -981,38 +1009,77 @@ export class ElectronSshManager {
return secret?.enabled && typeof secret.value === 'string' && secret.value.trim() ? secret.value.trim() : null; return secret?.enabled && typeof secret.value === 'string' && secret.value.trim() ? secret.value.trim() : null;
} }
async remoteCommandExists(parsed, controlPath, commandName) { // A login shell over SSH does not source the user's interactive rc files, so
try { // tools installed into a home directory (bun above all) are missing from PATH
const output = await this.runRemoteCommand(parsed, controlPath, `command -v ${commandName} >/dev/null 2>&1 && echo yes || echo no`); // even when they exist. Look at their known install locations too.
return output.trim() === 'yes'; async resolveRemoteTool(parsed, controlPath, commandName, extraCandidates = []) {
} catch { const candidateList = [...extraCandidates, `"$(command -v ${commandName} 2>/dev/null)"`].join(' ');
return false; const script = [
} `for candidate in ${candidateList}; do`,
} ' [ -n "$candidate" ] || continue;',
' [ -x "$candidate" ] || continue;',
` printf '%s' "$candidate";`,
' exit 0;',
'done',
].join(' ');
async currentRemoteOpenChamberVersion(parsed, controlPath) {
try { try {
const output = await this.runRemoteCommand(parsed, controlPath, 'openchamber --version 2>/dev/null || true'); const output = await this.runRemoteCommand(parsed, controlPath, script);
return parseVersionToken(output); return output.trim() || null;
} catch { } catch {
return null; return null;
} }
} }
async installOpenChamberManaged(parsed, controlPath, version, preferred) { // Every place OpenChamber may live on the remote host, with the version each
const hasBun = await this.remoteCommandExists(parsed, controlPath, 'bun'); // one reports. Installs land in the user prefix while an older copy can still
const hasNpm = await this.remoteCommandExists(parsed, controlPath, 'npm'); // sit on PATH, so the caller picks by version instead of trusting PATH order.
const commands = []; async remoteOpenChamberCandidates(parsed, controlPath) {
const script = [
`for candidate in ${REMOTE_BIN_CANDIDATES.join(' ')} "$(command -v openchamber 2>/dev/null)"; do`,
' [ -n "$candidate" ] || continue;',
' [ -x "$candidate" ] || continue;',
` printf '%s\t%s\n' "$candidate" "$("$candidate" --version 2>/dev/null | head -n 1)";`,
'done',
].join(' ');
if (preferred === 'bun') { let output = '';
if (hasBun) commands.push(`bun add -g @openchamber/web@${version}`); try {
if (hasNpm) commands.push(`npm install -g @openchamber/web@${version}`); output = await this.runRemoteCommand(parsed, controlPath, script);
} else if (preferred === 'npm') { } catch {
if (hasNpm) commands.push(`npm install -g @openchamber/web@${version}`); return [];
if (hasBun) commands.push(`bun add -g @openchamber/web@${version}`); }
const candidates = [];
const seen = new Set();
for (const line of output.split(/\r?\n/)) {
const [binPath, versionRaw] = line.split('\t');
const trimmed = (binPath || '').trim();
if (!trimmed || seen.has(trimmed)) continue;
seen.add(trimmed);
candidates.push({ binPath: trimmed, version: parseVersionToken(versionRaw || '') });
}
return candidates;
}
async installOpenChamberManaged(parsed, controlPath, version, preferred) {
const bunPath = await this.resolveRemoteTool(parsed, controlPath, 'bun', [REMOTE_BUN_CANDIDATE]);
const npmPath = await this.resolveRemoteTool(parsed, controlPath, 'npm');
// bun's global install already targets ~/.bun; npm is pinned to a prefix in
// the user's home so it never touches the root-owned global directory.
const bunCommand = bunPath ? `${shellQuote(bunPath)} add -g @openchamber/web@${version}` : null;
const npmCommand = npmPath
? `mkdir -p "${REMOTE_USER_PREFIX}" && ${shellQuote(npmPath)} install -g --prefix "${REMOTE_USER_PREFIX}" @openchamber/web@${version}`
: null;
const commands = [];
if (preferred === 'npm') {
if (npmCommand) commands.push(npmCommand);
if (bunCommand) commands.push(bunCommand);
} else { } else {
if (hasBun) commands.push(`bun add -g @openchamber/web@${version}`); if (bunCommand) commands.push(bunCommand);
if (hasNpm) commands.push(`npm install -g @openchamber/web@${version}`); if (npmCommand) commands.push(npmCommand);
} }
if (commands.length === 0) { if (commands.length === 0) {
@@ -1072,24 +1139,35 @@ export class ElectronSshManager {
} }
} }
async startRemoteServerManaged(parsed, controlPath, instance, desiredPort) { async startRemoteServerManaged(parsed, controlPath, instance, desiredPort, binPath) {
let envPrefix = 'OPENCHAMBER_RUNTIME=ssh-remote'; const opencodePath = await this.resolveRemoteTool(parsed, controlPath, 'opencode', REMOTE_OPENCODE_CANDIDATES);
if (!opencodePath) {
throw new Error('The opencode CLI is not installed on the remote machine. Install it there, then connect again');
}
const secret = this.configuredOpenChamberPassword(instance); const secret = this.configuredOpenChamberPassword(instance);
const remoteBindHost = instance.remoteOpenchamber?.bindHost === '0.0.0.0' ? '0.0.0.0' : '127.0.0.1';
// Binding the remote server to every interface publishes its UI to the
// remote machine's whole network, so it may not run without a password.
if (remoteBindHost === '0.0.0.0' && !secret) {
throw new Error('Exposing the remote server to its network requires a UI password');
}
let envPrefix = `PATH="${REMOTE_PATH_PREFIX}:$PATH" OPENCODE_BINARY=${shellQuote(opencodePath)} OPENCHAMBER_RUNTIME=ssh-remote`;
if (secret) { if (secret) {
envPrefix += ` OPENCHAMBER_UI_PASSWORD=${shellQuote(secret)}`; envPrefix += ` OPENCHAMBER_UI_PASSWORD=${shellQuote(secret)}`;
} }
const output = await this.runRemoteCommand(parsed, controlPath, `${envPrefix} openchamber serve --hostname 127.0.0.1 --port ${desiredPort}`); const output = await this.runRemoteCommand(parsed, controlPath, `${envPrefix} ${shellQuote(binPath)} serve --hostname ${remoteBindHost} --port ${desiredPort}`);
const port = output.split(/\s+/).map((token) => Number.parseInt(token, 10)).find((value) => Number.isFinite(value)); const port = output.split(/\s+/).map((token) => Number.parseInt(token, 10)).find((value) => Number.isFinite(value));
return port || desiredPort; return port || desiredPort;
} }
async stopRemoteServerBestEffort(parsed, controlPath, remotePort) { // `openchamber stop` owns the daemon lifecycle. The HTTP shutdown route sits
// behind UI authentication, so it cannot stop a password-protected server.
async stopRemoteServerBestEffort(parsed, controlPath, remotePort, remoteBinPath) {
if (!remoteBinPath) return;
try { try {
await this.runRemoteCommand( await this.runRemoteCommand(parsed, controlPath, `${shellQuote(remoteBinPath)} stop --port ${remotePort}`);
parsed,
controlPath,
`if command -v curl >/dev/null 2>&1; then curl -fsS -X POST http://127.0.0.1:${remotePort}/api/system/shutdown >/dev/null 2>&1 || true; elif command -v wget >/dev/null 2>&1; then wget -qO- --method=POST http://127.0.0.1:${remotePort}/api/system/shutdown >/dev/null 2>&1 || true; fi`,
);
} catch { } catch {
} }
} }
@@ -1143,17 +1221,27 @@ export class ElectronSshManager {
const port = instance.remoteOpenchamber.preferredPort; const port = instance.remoteOpenchamber.preferredPort;
this.setStatus(instance.id, 'server_detecting', 'Probing external OpenChamber server', null, null, port, false, 0, false); this.setStatus(instance.id, 'server_detecting', 'Probing external OpenChamber server', null, null, port, false, 0, false);
await this.probeRemoteSystemInfo(parsed, controlPath, port, this.configuredOpenChamberPassword(instance)); await this.probeRemoteSystemInfo(parsed, controlPath, port, this.configuredOpenChamberPassword(instance));
return { remotePort: port, startedByUs: false }; return { remotePort: port, startedByUs: false, remoteBinPath: null };
} }
this.setStatus(instance.id, 'remote_probe', 'Checking remote OpenChamber installation'); this.setStatus(instance.id, 'remote_probe', 'Checking remote OpenChamber installation');
const installedVersion = await this.currentRemoteOpenChamberVersion(parsed, controlPath); const installed = await this.remoteOpenChamberCandidates(parsed, controlPath);
if (!installedVersion) { let binary = installed.find((candidate) => candidate.version === this.appVersion) || null;
this.setStatus(instance.id, 'installing', 'Installing OpenChamber on remote host');
await this.installOpenChamberManaged(parsed, controlPath, this.appVersion, instance.remoteOpenchamber.installMethod); if (!binary) {
} else if (installedVersion !== this.appVersion) { const existing = installed[0] || null;
this.setStatus(instance.id, 'updating', `Updating remote OpenChamber from ${installedVersion} to ${this.appVersion}`); if (existing) {
this.setStatus(instance.id, 'updating', `Updating remote OpenChamber from ${existing.version || 'unknown'} to ${this.appVersion}`);
} else {
this.setStatus(instance.id, 'installing', 'Installing OpenChamber on remote host');
}
await this.installOpenChamberManaged(parsed, controlPath, this.appVersion, instance.remoteOpenchamber.installMethod); await this.installOpenChamberManaged(parsed, controlPath, this.appVersion, instance.remoteOpenchamber.installMethod);
const afterInstall = await this.remoteOpenChamberCandidates(parsed, controlPath);
binary = afterInstall.find((candidate) => candidate.version === this.appVersion) || afterInstall[0] || existing;
if (!binary) {
throw new Error('OpenChamber was installed on the remote host but no openchamber binary could be found');
}
} }
this.setStatus(instance.id, 'server_detecting', 'Detecting managed OpenChamber server'); this.setStatus(instance.id, 'server_detecting', 'Detecting managed OpenChamber server');
@@ -1165,13 +1253,13 @@ export class ElectronSshManager {
if (!remotePort) { if (!remotePort) {
this.setStatus(instance.id, 'server_starting', 'Starting managed OpenChamber server'); this.setStatus(instance.id, 'server_starting', 'Starting managed OpenChamber server');
const desiredPort = instance.remoteOpenchamber.preferredPort || randomPortCandidate(instance.id); const desiredPort = instance.remoteOpenchamber.preferredPort || randomPortCandidate(instance.id);
remotePort = await this.startRemoteServerManaged(parsed, controlPath, instance, desiredPort); remotePort = await this.startRemoteServerManaged(parsed, controlPath, instance, desiredPort, binary.binPath);
startedByUs = true; startedByUs = true;
} }
if (!(await this.remoteServerRunning(parsed, controlPath, remotePort, this.configuredOpenChamberPassword(instance)))) { if (!(await this.remoteServerRunning(parsed, controlPath, remotePort, this.configuredOpenChamberPassword(instance)))) {
throw new Error('Managed OpenChamber server failed to become reachable'); throw new Error('Managed OpenChamber server failed to become reachable');
} }
return { remotePort, startedByUs }; return { remotePort, startedByUs, remoteBinPath: binary.binPath };
} }
async disconnectInternal(id, reportIdle) { async disconnectInternal(id, reportIdle) {
@@ -1186,7 +1274,7 @@ export class ElectronSshManager {
if (session) { if (session) {
if (session.startedByUs && session.remotePort && session.instance.remoteOpenchamber.mode === 'managed' && !session.instance.remoteOpenchamber.keepRunning) { if (session.startedByUs && session.remotePort && session.instance.remoteOpenchamber.mode === 'managed' && !session.instance.remoteOpenchamber.keepRunning) {
await this.stopRemoteServerBestEffort(session.parsed, session.controlPath, session.remotePort); await this.stopRemoteServerBestEffort(session.parsed, session.controlPath, session.remotePort, session.remoteBinPath);
} }
await this.stopControlMasterBestEffort(session.parsed, session.controlPath); await this.stopControlMasterBestEffort(session.parsed, session.controlPath);
const auth = this.sshAuth.get(session.parsed); const auth = this.sshAuth.get(session.parsed);
@@ -1262,9 +1350,10 @@ export class ElectronSshManager {
throw new Error(`Unsupported remote OS: ${remoteOs}`); throw new Error(`Unsupported remote OS: ${remoteOs}`);
} }
const { remotePort, startedByUs } = await this.ensureRemoteServer(instance, parsed, controlPath); const { remotePort, startedByUs, remoteBinPath } = await this.ensureRemoteServer(instance, parsed, controlPath);
session.remotePort = remotePort; session.remotePort = remotePort;
session.startedByUs = startedByUs; session.startedByUs = startedByUs;
session.remoteBinPath = remoteBinPath;
this.setStatus(id, 'forwarding', 'Setting up port forwards', null, null, remotePort, startedByUs, 0, false); this.setStatus(id, 'forwarding', 'Setting up port forwards', null, null, remotePort, startedByUs, 0, false);
const bindHost = sanitizeBindHost(instance.localForward?.bindHost); const bindHost = sanitizeBindHost(instance.localForward?.bindHost);
+157
View File
@@ -289,4 +289,161 @@ describe('ElectronSshManager', () => {
}); });
expect(settings.desktopHosts).toEqual([{ id: 'ssh-1', label: 'SSH Host', url: localUrl, apiUrl: localUrl, clientToken: 'ssh-client-token' }]); expect(settings.desktopHosts).toEqual([{ id: 'ssh-1', label: 'SSH Host', url: localUrl, apiUrl: localUrl, clientToken: 'ssh-client-token' }]);
}); });
test('installs OpenChamber into a home-owned npm prefix instead of the root-owned global one', async () => {
const commands = [];
const manager = new ElectronSshManager({
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
appVersion: '1.2.3',
emit: () => undefined,
});
manager.resolveRemoteTool = async (_parsed, _controlPath, name) => (name === 'npm' ? '/usr/bin/npm' : null);
manager.runRemoteCommand = async (_parsed, _controlPath, script) => {
commands.push(script);
return '';
};
await manager.installOpenChamberManaged({ destination: 'user@example.test', args: [] }, '/tmp/control.sock', '1.2.3', 'auto');
expect(commands).toHaveLength(1);
expect(commands[0]).toContain('--prefix "$HOME/.openchamber/npm-global"');
expect(commands[0]).not.toMatch(/npm install -g @openchamber/);
});
test('lists every remote OpenChamber binary with its reported version', async () => {
const manager = new ElectronSshManager({
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
appVersion: '1.2.3',
emit: () => undefined,
});
manager.runRemoteCommand = async () => [
'/home/pi/.openchamber/npm-global/bin/openchamber\t1.2.3',
'/usr/bin/openchamber\t0.9.0',
'',
].join('\n');
const candidates = await manager.remoteOpenChamberCandidates({ destination: 'user@example.test', args: [] }, '/tmp/control.sock');
expect(candidates).toEqual([
{ binPath: '/home/pi/.openchamber/npm-global/bin/openchamber', version: '1.2.3' },
{ binPath: '/usr/bin/openchamber', version: '0.9.0' },
]);
});
test('starts the resolved OpenChamber binary rather than whatever PATH exposes', async () => {
let started = '';
const manager = new ElectronSshManager({
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
appVersion: '1.2.3',
emit: () => undefined,
});
manager.resolveRemoteTool = async () => '/home/pi/.opencode/bin/opencode';
manager.runRemoteCommand = async (_parsed, _controlPath, script) => {
started = script;
return '4321\n';
};
const instance = { id: 'ssh-1', auth: {}, remoteOpenchamber: { mode: 'managed' } };
const port = await manager.startRemoteServerManaged(
{ destination: 'user@example.test', args: [] },
'/tmp/control.sock',
instance,
4321,
'/home/pi/.openchamber/npm-global/bin/openchamber',
);
expect(port).toBe(4321);
expect(started).toContain("'/home/pi/.openchamber/npm-global/bin/openchamber' serve");
expect(started).toContain("OPENCODE_BINARY='/home/pi/.opencode/bin/opencode'");
expect(started).toContain('$HOME/.opencode/bin:');
});
test('refuses to start when the remote machine has no opencode CLI', async () => {
const manager = new ElectronSshManager({
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
appVersion: '1.2.3',
emit: () => undefined,
});
manager.resolveRemoteTool = async () => null;
manager.runRemoteCommand = async () => {
throw new Error('should not start the server without a CLI');
};
await expect(manager.startRemoteServerManaged(
{ destination: 'user@example.test', args: [] },
'/tmp/control.sock',
{ id: 'ssh-1', auth: {}, remoteOpenchamber: { mode: 'managed' } },
4321,
'/home/pi/.bun/bin/openchamber',
)).rejects.toThrow(/opencode CLI is not installed/);
});
test('prefers a bun that only exists in the home directory over npm', async () => {
const commands = [];
const manager = new ElectronSshManager({
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
appVersion: '1.2.3',
emit: () => undefined,
});
// A login shell over SSH does not put ~/.bun/bin on PATH.
manager.resolveRemoteTool = async (_parsed, _controlPath, name) =>
(name === 'bun' ? '/home/pi/.bun/bin/bun' : '/usr/bin/npm');
manager.runRemoteCommand = async (_parsed, _controlPath, script) => {
commands.push(script);
return '';
};
await manager.installOpenChamberManaged({ destination: 'user@example.test', args: [] }, '/tmp/control.sock', '1.2.3', 'auto');
expect(commands).toEqual(["'/home/pi/.bun/bin/bun' add -g @openchamber/web@1.2.3"]);
});
test('stops a remote server it started through the CLI, not the authenticated HTTP route', async () => {
const scripts = [];
const manager = new ElectronSshManager({
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
appVersion: '1.2.3',
emit: () => undefined,
});
manager.runRemoteCommand = async (_parsed, _controlPath, script) => {
scripts.push(script);
return '';
};
await manager.stopRemoteServerBestEffort(
{ destination: 'user@example.test', args: [] },
'/tmp/control.sock',
41777,
'/home/pi/.bun/bin/openchamber',
);
expect(scripts).toEqual(["'/home/pi/.bun/bin/openchamber' stop --port 41777"]);
});
test('publishes the remote server to its network only with a UI password', async () => {
const manager = new ElectronSshManager({
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
appVersion: '1.2.3',
emit: () => undefined,
});
manager.resolveRemoteTool = async () => '/home/pi/.opencode/bin/opencode';
let started = '';
manager.runRemoteCommand = async (_parsed, _controlPath, script) => {
started = script;
return '4321\n';
};
const parsed = { destination: 'user@example.test', args: [] };
const exposed = {
id: 'ssh-1',
auth: {},
remoteOpenchamber: { mode: 'managed', bindHost: '0.0.0.0' },
};
await expect(manager.startRemoteServerManaged(parsed, '/tmp/control.sock', exposed, 4321, '/bin/openchamber'))
.rejects.toThrow(/requires a UI password/);
const secured = {
...exposed,
auth: { openchamberPassword: { enabled: true, value: 'remote-secret', store: 'settings' } },
};
await manager.startRemoteServerManaged(parsed, '/tmp/control.sock', secured, 4321, '/bin/openchamber');
expect(started).toContain('--hostname 0.0.0.0');
});
}); });
@@ -0,0 +1,28 @@
import fsp from 'node:fs/promises';
const WINDOWS_RETRY_DELAYS_MS = [50, 100, 200, 400, 800, 1_000, 1_000];
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
const isTransientWindowsFileError = (error, platform) => {
if (platform !== 'win32') return false;
const code = error?.code;
return code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
};
export const replaceFileWithRetry = async (source, target, options = {}) => {
const platform = options.platform ?? process.platform;
const rename = options.rename ?? fsp.rename;
const wait = options.wait ?? sleep;
for (let attempt = 0; ; attempt += 1) {
try {
await rename(source, target);
return;
} catch (error) {
const delay = WINDOWS_RETRY_DELAYS_MS[attempt];
if (delay === undefined || !isTransientWindowsFileError(error, platform)) throw error;
await wait(delay);
}
}
};
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { replaceFileWithRetry } from './windows-file-replace.mjs';
const fileError = (code = 'EPERM') => Object.assign(new Error(code), { code });
test('retries transient Windows rename failures until replacement succeeds', async () => {
const delays = [];
let attempts = 0;
await replaceFileWithRetry('settings.tmp', 'settings.json', {
platform: 'win32',
rename: async () => {
attempts += 1;
if (attempts < 4) throw fileError();
},
wait: async (delay) => delays.push(delay),
});
assert.equal(attempts, 4);
assert.deepEqual(delays, [50, 100, 200]);
});
test('does not retry rename errors that are not transient Windows locks', async () => {
let attempts = 0;
const error = fileError('ENOENT');
await assert.rejects(
replaceFileWithRetry('settings.tmp', 'settings.json', {
platform: 'win32',
rename: async () => {
attempts += 1;
throw error;
},
wait: async () => assert.fail('unexpected wait'),
}),
error,
);
assert.equal(attempts, 1);
});
test('does not retry transient error codes outside Windows', async () => {
let attempts = 0;
const error = fileError();
await assert.rejects(
replaceFileWithRetry('settings.tmp', 'settings.json', {
platform: 'linux',
rename: async () => {
attempts += 1;
throw error;
},
wait: async () => assert.fail('unexpected wait'),
}),
error,
);
assert.equal(attempts, 1);
});
test('returns the final Windows lock error after the retry window', async () => {
const delays = [];
let attempts = 0;
await assert.rejects(
replaceFileWithRetry('settings.tmp', 'settings.json', {
platform: 'win32',
rename: async () => {
attempts += 1;
throw fileError();
},
wait: async (delay) => delays.push(delay),
}),
{ code: 'EPERM' },
);
assert.equal(attempts, 8);
assert.deepEqual(delays, [50, 100, 200, 400, 800, 1_000, 1_000]);
});
+4 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@openchamber/ui", "name": "@openchamber/ui",
"version": "1.19.0", "version": "1.20.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/main.tsx", "main": "src/main.tsx",
@@ -43,8 +43,9 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@legendapp/list": "3.3.8",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "1.18.18", "@opencode-ai/sdk": "1.18.21",
"@pierre/diffs": "1.3.0-beta.6", "@pierre/diffs": "1.3.0-beta.6",
"@replit/codemirror-vim": "^6.4.0", "@replit/codemirror-vim": "^6.4.0",
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "13.3.0",
@@ -103,6 +104,7 @@
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.5.0", "eslint-plugin-react-refresh": "^0.5.0",
"globals": "^16.3.0", "globals": "^16.3.0",
"happy-dom": "^18.0.1",
"nodemon": "^3.1.7", "nodemon": "^3.1.7",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"tsx": "^4.20.6", "tsx": "^4.20.6",
+16 -29
View File
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { MainLayout } from '@/components/layout/MainLayout'; import { MainLayout } from '@/components/layout/MainLayout';
import { ChatView } from '@/components/views/ChatView'; import { ChatView } from '@/components/views/ChatView';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { FireworksProvider } from '@/contexts/FireworksContext'; import { FireworksProvider } from '@/contexts/FireworksContext';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -11,6 +12,7 @@ import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { useMenuActions } from '@/hooks/useMenuActions'; import { useMenuActions } from '@/hooks/useMenuActions';
import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap'; import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
import { useTraySync } from '@/hooks/useTraySync'; import { useTraySync } from '@/hooks/useTraySync';
import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling';
import { useRouter } from '@/hooks/useRouter'; import { useRouter } from '@/hooks/useRouter';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useWebNotificationStream } from '@/hooks/useWebNotificationStream'; import { useWebNotificationStream } from '@/hooks/useWebNotificationStream';
@@ -18,7 +20,6 @@ import { useAgentMemorySync } from '@/hooks/useAgentMemorySync';
import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt'; import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt';
import { useWindowTitle } from '@/hooks/useWindowTitle'; import { useWindowTitle } from '@/hooks/useWindowTitle';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { hasModifier } from '@/lib/utils';
import { isDesktopLocalOriginActive, isDesktopShell, restartDesktopApp, invokeDesktop } from '@/lib/desktop'; import { isDesktopLocalOriginActive, isDesktopShell, restartDesktopApp, invokeDesktop } from '@/lib/desktop';
import { import {
getInjectedBootOutcome, getInjectedBootOutcome,
@@ -33,7 +34,6 @@ import type { RecoveryVariant } from '@/components/onboarding/DesktopConnectionR
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { markSessionViewed } from '@/sync/notification-store'; import { markSessionViewed } from '@/sync/notification-store';
import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
@@ -625,7 +625,6 @@ function App({ apis }: AppProps) {
const directory = typeof detail?.directory === 'string' && detail.directory.trim().length > 0 const directory = typeof detail?.directory === 'string' && detail.directory.trim().length > 0
? detail.directory.trim() ? detail.directory.trim()
: null; : null;
useUIStore.getState().setActiveMainTab('chat');
void useSessionUIStore.getState().setCurrentSession(sessionId, directory); void useSessionUIStore.getState().setCurrentSession(sessionId, directory);
}; };
@@ -639,12 +638,9 @@ function App({ apis }: AppProps) {
React.useEffect(() => { React.useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const onOpenMiniChat = () => { const onOpenMiniChat = () => {
const currentDir = useDirectoryStore.getState().currentDirectory;
const { activeProjectId, projects } = useProjectsStore.getState();
const activeProject = projects.find((p) => p.id === activeProjectId) ?? null;
void invokeDesktop('desktop_open_draft_mini_chat_window', { void invokeDesktop('desktop_open_draft_mini_chat_window', {
directory: currentDir || activeProject?.path || '', directory: '',
projectId: activeProject?.id ?? null, projectId: null,
}); });
}; };
window.addEventListener('openchamber:open-mini-chat', onOpenMiniChat); window.addEventListener('openchamber:open-mini-chat', onOpenMiniChat);
@@ -676,11 +672,12 @@ function App({ apis }: AppProps) {
const projectId = typeof detail?.projectId === 'string' && detail.projectId.trim().length > 0 const projectId = typeof detail?.projectId === 'string' && detail.projectId.trim().length > 0
? detail.projectId.trim() ? detail.projectId.trim()
: null; : null;
useUIStore.getState().setActiveMainTab('chat'); const hasProjectTarget = Boolean(directory || projectId);
useUIStore.getState().setSessionSwitcherOpen(false); useUIStore.getState().setSessionSwitcherOpen(false);
useSessionUIStore.getState().openNewSessionDraft({ useSessionUIStore.getState().openNewSessionDraft({
selectedProjectId: projectId, target: hasProjectTarget ? 'project' : 'chat',
directoryOverride: directory, selectedProjectId: hasProjectTarget ? projectId : null,
directoryOverride: hasProjectTarget ? directory : null,
preserveDirectoryOverride: Boolean(directory), preserveDirectoryOverride: Boolean(directory),
}); });
}; };
@@ -721,28 +718,16 @@ function App({ apis }: AppProps) {
useMenuActions(handleToggleMemoryDebug); useMenuActions(handleToggleMemoryDebug);
useTraySync(); useTraySync();
useGlobalSessionsPolling(!embeddedSessionChat);
useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled }); useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled });
// Palette-only action: the memory debug panel has no keyboard shortcut.
React.useEffect(() => { React.useEffect(() => {
if (embeddedSessionChat) { if (embeddedSessionChat) return;
return; const handleToggle = () => setShowMemoryDebug((previous) => !previous);
} window.addEventListener('openchamber:memory-debug-toggle', handleToggle);
return () => window.removeEventListener('openchamber:memory-debug-toggle', handleToggle);
const handleKeyDown = (e: KeyboardEvent) => {
const isDebugShortcut = hasModifier(e)
&& e.shiftKey
&& !e.altKey
&& (e.code === 'KeyD' || e.key.toLowerCase() === 'd');
if (isDebugShortcut) {
e.preventDefault();
setShowMemoryDebug(prev => !prev);
}
};
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [embeddedSessionChat]); }, [embeddedSessionChat]);
React.useEffect(() => { React.useEffect(() => {
@@ -908,6 +893,7 @@ function App({ apis }: AppProps) {
isVSCodeRuntime={isVSCodeRuntime} isVSCodeRuntime={isVSCodeRuntime}
embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled}
/> />
<AppLinkConfirmDialog />
</div> </div>
</TooltipProvider> </TooltipProvider>
</RuntimeAPIProvider> </RuntimeAPIProvider>
@@ -951,6 +937,7 @@ function App({ apis }: AppProps) {
<OpenCodeUpdateToast /> <OpenCodeUpdateToast />
<MainLayout /> <MainLayout />
<Toaster /> <Toaster />
<AppLinkConfirmDialog />
{!isBootShell && ( {!isBootShell && (
<> <>
<ConfigUpdateOverlay /> <ConfigUpdateOverlay />
+13 -7
View File
@@ -5,6 +5,7 @@ import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { TooltipProvider } from '@/components/ui/tooltip'; import { TooltipProvider } from '@/components/ui/tooltip';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import { MiniChatLayout } from '@/components/mini-chat/MiniChatLayout'; import { MiniChatLayout } from '@/components/mini-chat/MiniChatLayout';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useWindowTitle } from '@/hooks/useWindowTitle'; import { useWindowTitle } from '@/hooks/useWindowTitle';
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
@@ -25,6 +26,7 @@ import {
worktreeMapsEqual, worktreeMapsEqual,
} from '@/lib/worktrees/worktreeManager'; } from '@/lib/worktrees/worktreeManager';
import type { WorktreeMetadata } from '@/types/worktree'; import type { WorktreeMetadata } from '@/types/worktree';
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
const MINI_CHAT_PRESENCE_CHANNEL = 'openchamber:mini-chat-presence'; const MINI_CHAT_PRESENCE_CHANNEL = 'openchamber:mini-chat-presence';
@@ -153,9 +155,9 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
const sessionId = typeof detail?.sessionId === 'string' ? detail.sessionId.trim() : ''; const sessionId = typeof detail?.sessionId === 'string' ? detail.sessionId.trim() : '';
if (!sessionId) return; if (!sessionId) return;
if (useSessionUIStore.getState().currentSessionId === sessionId) return; if (useSessionUIStore.getState().currentSessionId === sessionId) return;
const directory = typeof detail?.directory === 'string' && detail.directory.trim().length > 0 const sessionDirectory = (sessions.find((entry) => entry.id === sessionId) as { directory?: string | null } | undefined)?.directory?.trim();
? detail.directory.trim() const directory = sessionDirectory
: (sessions.find((entry) => entry.id === sessionId) as { directory?: string | null } | undefined)?.directory ?? null; || (typeof detail?.directory === 'string' && detail.directory.trim().length > 0 ? detail.directory.trim() : null);
void sync.ensureSessionRenderable(sessionId); void sync.ensureSessionRenderable(sessionId);
setCurrentSession(sessionId, directory); setCurrentSession(sessionId, directory);
sessionBootstrappedRef.current = true; sessionBootstrappedRef.current = true;
@@ -166,9 +168,11 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
React.useEffect(() => { React.useEffect(() => {
if (config.mode !== 'draft' || draftOpen || currentSessionId) return; if (config.mode !== 'draft' || draftOpen || currentSessionId) return;
const hasProjectTarget = Boolean(config.projectId || config.directory);
openNewSessionDraft({ openNewSessionDraft({
selectedProjectId: config.projectId, target: hasProjectTarget ? 'project' : 'chat',
directoryOverride: config.directory, selectedProjectId: hasProjectTarget ? config.projectId : CHAT_DRAFT_PROJECT_ID,
directoryOverride: hasProjectTarget ? config.directory : null,
preserveDirectoryOverride: Boolean(config.directory), preserveDirectoryOverride: Boolean(config.directory),
}); });
}, [config, currentSessionId, draftOpen, openNewSessionDraft]); }, [config, currentSessionId, draftOpen, openNewSessionDraft]);
@@ -278,10 +282,11 @@ const MiniChatPresencePublisher: React.FC = () => {
const useSessionUnavailable = (config: MiniChatConfig): boolean => { const useSessionUnavailable = (config: MiniChatConfig): boolean => {
const sessions = useSessions(); const sessions = useSessions();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const draftOpen = useSessionUIStore((state) => state.newSessionDraft.open);
const [timedOut, setTimedOut] = React.useState(false); const [timedOut, setTimedOut] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
if (config.mode !== 'session' || !config.sessionId || currentSessionId === config.sessionId) { if (draftOpen || config.mode !== 'session' || !config.sessionId || currentSessionId) {
setTimedOut(false); setTimedOut(false);
return; return;
} }
@@ -291,7 +296,7 @@ const useSessionUnavailable = (config: MiniChatConfig): boolean => {
} }
const timeout = window.setTimeout(() => setTimedOut(true), 5000); const timeout = window.setTimeout(() => setTimedOut(true), 5000);
return () => window.clearTimeout(timeout); return () => window.clearTimeout(timeout);
}, [config.mode, config.sessionId, currentSessionId, sessions]); }, [config.mode, config.sessionId, currentSessionId, draftOpen, sessions]);
return timedOut; return timedOut;
}; };
@@ -321,6 +326,7 @@ export function ElectronMiniChatApp({ apis }: ElectronMiniChatAppProps) {
<TooltipProvider delayDuration={300} skipDelayDuration={150}> <TooltipProvider delayDuration={300} skipDelayDuration={150}>
<div className="h-full text-foreground bg-background"> <div className="h-full text-foreground bg-background">
<ElectronMiniChatContent config={config} /> <ElectronMiniChatContent config={config} />
<AppLinkConfirmDialog />
<Toaster /> <Toaster />
</div> </div>
</TooltipProvider> </TooltipProvider>
+2
View File
@@ -9,6 +9,7 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { ChatView } from '@/components/views/ChatView'; import { ChatView } from '@/components/views/ChatView';
import { PlanView } from '@/components/views/PlanView'; import { PlanView } from '@/components/views/PlanView';
import { SettingsView } from '@/components/views/SettingsView'; import { SettingsView } from '@/components/views/SettingsView';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider'; import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
@@ -1258,6 +1259,7 @@ export function MobileApp({ apis }: MobileAppProps) {
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
setConnectionEpoch((value) => value + 1); setConnectionEpoch((value) => value + 1);
}} /> }} />
<AppLinkConfirmDialog />
<Toaster position="top-center" offset="calc(var(--oc-safe-area-top, 0px) + 16px)" /> <Toaster position="top-center" offset="calc(var(--oc-safe-area-top, 0px) + 16px)" />
{isInitialized ? <ConfigUpdateOverlay /> : null} {isInitialized ? <ConfigUpdateOverlay /> : null}
</div> </div>
@@ -3,7 +3,7 @@ import type { Session } from '@opencode-ai/sdk/v2';
import { SessionActivityDuration } from '@/components/session/SessionActivityDuration'; import { SessionActivityDuration } from '@/components/session/SessionActivityDuration';
import { formatSessionCompactDateLabel } from '@/components/session/sidebar/utils'; import { formatSessionCompactDateLabel } from '@/components/session/sidebar/utils';
import { useSwitcherItems } from '@/components/session/sidebar/hooks/useSwitcherItems'; import { useSwitcherItems } from '@/components/session/sidebar/shell/useSwitcherItems';
import { useTabletLayout } from '@/lib/device'; import { useTabletLayout } from '@/lib/device';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
+5 -8
View File
@@ -43,6 +43,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
import { getProjectLabel, normalizePath } from './mobilePaths'; import { getProjectLabel, normalizePath } from './mobilePaths';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta'; import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
@@ -188,11 +189,8 @@ const findExactProjectMatch = (projects: ProjectMeta[], directory: string): Proj
return projects.find((project) => projectMatchesExactDirectory(project, normalizedDirectory)) ?? null; return projects.find((project) => projectMatchesExactDirectory(project, normalizedDirectory)) ?? null;
}; };
const sessionMatchesQuery = (session: Session, projectLabel: string, query: string): boolean => { const sessionMatchesQuery = (session: Session, projectLabel: string, query: string): boolean =>
if (!query) return true; matchesRankQuery([session.title, session.id, getSessionDirectory(session), projectLabel], query);
const haystack = `${session.title ?? ''} ${session.id} ${getSessionDirectory(session)} ${projectLabel}`.toLowerCase();
return haystack.includes(query);
};
const MobileProjectIcon: React.FC<{ const MobileProjectIcon: React.FC<{
project: Pick<ProjectMeta, 'id' | 'icon' | 'color' | 'iconImage' | 'iconBackground'>; project: Pick<ProjectMeta, 'id' | 'icon' | 'color' | 'iconImage' | 'iconBackground'>;
@@ -1355,7 +1353,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
const filteredNodes = React.useMemo(() => { const filteredNodes = React.useMemo(() => {
if (!normalizedQuery) return projectNodes; if (!normalizedQuery) return projectNodes;
return projectNodes.filter((node) => { return projectNodes.filter((node) => {
if (`${node.project.label} ${node.project.path}`.toLowerCase().includes(normalizedQuery)) return true; if (matchesRankQuery([node.project.label, node.project.path], normalizedQuery)) return true;
return node.buckets.some((bucket) => return node.buckets.some((bucket) =>
bucket.sessions.some((session) => sessionMatchesQuery(session, node.project.label, normalizedQuery)), bucket.sessions.some((session) => sessionMatchesQuery(session, node.project.label, normalizedQuery)),
); );
@@ -1385,8 +1383,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
const searchProjectMatches = React.useMemo(() => { const searchProjectMatches = React.useMemo(() => {
if (!normalizedQuery) return [] as Array<ProjectMeta & { sessionCount: number }>; if (!normalizedQuery) return [] as Array<ProjectMeta & { sessionCount: number }>;
return projectsMeta return rankByQuery(projectsMeta, normalizedQuery, (project) => [project.label, project.path])
.filter((project) => `${project.label} ${project.path}`.toLowerCase().includes(normalizedQuery))
.map((project) => ({ .map((project) => ({
...project, ...project,
sessionCount: sessions.filter((session) => { sessionCount: sessions.filter((session) => {
+5
View File
@@ -8,8 +8,10 @@ import { Toaster } from '@/components/ui/sonner';
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay'; import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast'; import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { VSCodeLayout } from '@/components/layout/VSCodeLayout'; import { VSCodeLayout } from '@/components/layout/VSCodeLayout';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling';
import { useRouter } from '@/hooks/useRouter'; import { useRouter } from '@/hooks/useRouter';
import { useWindowTitle } from '@/hooks/useWindowTitle'; import { useWindowTitle } from '@/hooks/useWindowTitle';
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
@@ -56,6 +58,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
usePushVisibilityBeacon({ enabled: true }); usePushVisibilityBeacon({ enabled: true });
useWindowTitle(); useWindowTitle();
useRouter(); useRouter();
useGlobalSessionsPolling(panelType !== 'agentManager');
React.useEffect(() => { React.useEffect(() => {
document.documentElement.classList.toggle('wide-chat-layout', wideChatLayoutEnabled); document.documentElement.classList.toggle('wide-chat-layout', wideChatLayoutEnabled);
@@ -108,6 +111,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
<div className="h-full text-foreground bg-background"> <div className="h-full text-foreground bg-background">
<SyncAppEffects embeddedBackgroundWorkEnabled={true} /> <SyncAppEffects embeddedBackgroundWorkEnabled={true} />
<AgentManagerView /> <AgentManagerView />
<AppLinkConfirmDialog />
<OpenCodeUpdateToast /> <OpenCodeUpdateToast />
<Toaster position="top-center" /> <Toaster position="top-center" />
</div> </div>
@@ -127,6 +131,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
<div className="h-full text-foreground bg-background"> <div className="h-full text-foreground bg-background">
<SyncAppEffects embeddedBackgroundWorkEnabled={true} /> <SyncAppEffects embeddedBackgroundWorkEnabled={true} />
<VSCodeLayout /> <VSCodeLayout />
<AppLinkConfirmDialog />
<OpenCodeUpdateToast /> <OpenCodeUpdateToast />
<Toaster position="top-center" /> <Toaster position="top-center" />
<ConfigUpdateOverlay /> <ConfigUpdateOverlay />
+2 -5
View File
@@ -6,7 +6,6 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { useProjectContextStore } from '@/stores/useProjectContextStore'; import { useProjectContextStore } from '@/stores/useProjectContextStore';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
import { useUIStore } from '@/stores/useUIStore';
import { usePermissionStore } from '@/stores/permissionStore'; import { usePermissionStore } from '@/stores/permissionStore';
import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useGitStore } from '@/stores/useGitStore'; import { useGitStore } from '@/stores/useGitStore';
@@ -16,7 +15,7 @@ import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
import { useTerminalStore } from '@/stores/useTerminalStore'; import { useTerminalStore } from '@/stores/useTerminalStore';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { resetStreamingState } from '@/sync/streaming'; import { resetStreamingState } from '@/sync/streaming';
import { useGlobalSessionStatusStore } from '@/sync/global-session-status'; import { useGlobalSessionStatusStore, replaceGlobalSessionStatusById } from '@/sync/global-session-status';
import { resetSessionOrdering } from '@/sync/session-ordering'; import { resetSessionOrdering } from '@/sync/session-ordering';
import { resetSessionActivityTiming } from '@/sync/session-activity-timing'; import { resetSessionActivityTiming } from '@/sync/session-activity-timing';
import { syncDesktopSettings } from '@/lib/persistence'; import { syncDesktopSettings } from '@/lib/persistence';
@@ -37,7 +36,6 @@ export const reconnectAppForTransportSwitch = (): void => {
export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedDetail): void => { export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedDetail): void => {
useSessionUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey); useSessionUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
useUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
if (detail.previousRuntimeKey) { if (detail.previousRuntimeKey) {
useAutoReviewStore.getState().stopRunningRunsForRuntime(detail.previousRuntimeKey); useAutoReviewStore.getState().stopRunningRunsForRuntime(detail.previousRuntimeKey);
} }
@@ -59,7 +57,7 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
// Cross-project session list (mobile sessions sheet & co) belongs to the // Cross-project session list (mobile sessions sheet & co) belongs to the
// previous instance — drop it so stale sessions can't linger after a switch. // previous instance — drop it so stale sessions can't linger after a switch.
useGlobalSessionsStore.getState().resetForRuntimeSwitch(); useGlobalSessionsStore.getState().resetForRuntimeSwitch();
useGlobalSessionStatusStore.setState({ statusById: new Map() }); replaceGlobalSessionStatusById(new Map());
resetSessionOrdering(); resetSessionOrdering();
// Turn timings belong to the previous instance's sessions, and the reset also // Turn timings belong to the previous instance's sessions, and the reset also
// restarts the resume window so the switch is treated as a fresh load. // restarts the resume window so the switch is treated as a fresh load.
@@ -71,7 +69,6 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
useSessionFoldersStore.getState().resetForRuntimeSwitch(detail.runtimeKey); useSessionFoldersStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
useFilesViewTabsStore.getState().resetForRuntimeSwitch(detail.runtimeKey); useFilesViewTabsStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey); useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
resetStreamingState(); resetStreamingState();
queueMicrotask(() => void syncDesktopSettings()); queueMicrotask(() => void syncDesktopSettings());
}; };
@@ -0,0 +1,46 @@
import React from 'react';
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '@/lib/i18n';
mock.module('@/components/ui/dialog', () => ({
Dialog: ({ children }: React.PropsWithChildren) => <>{children}</>,
DialogContent: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogDescription: ({ children }: React.PropsWithChildren) => <p>{children}</p>,
DialogFooter: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogHeader: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogTitle: ({ children }: React.PropsWithChildren) => <h2>{children}</h2>,
}));
const { AppLinkConfirmDialog } = await import('./AppLinkConfirmDialog');
const {
getAppLinkConfirmationSnapshot,
openAppLinkWithConfirmation,
settleAppLinkConfirmation,
} = await import('./appLinkConfirmation');
describe('AppLinkConfirmDialog', () => {
beforeEach(() => {
if (getAppLinkConfirmationSnapshot()) {
settleAppLinkConfirmation('cancel');
}
});
test('keeps cancel visible and focused beside both open choices', () => {
void openAppLinkWithConfirmation('obsidian://open?vault=Notebook');
const markup = renderToStaticMarkup(
<I18nProvider>
<AppLinkConfirmDialog />
</I18nProvider>,
);
expect(markup).toContain('>Cancel</button>');
expect(markup).toContain('autofocus=""');
expect(markup).toContain('>Open once</button>');
expect(markup).toContain('>Trust and open</button>');
settleAppLinkConfirmation('cancel');
});
});
@@ -0,0 +1,77 @@
import * as React from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useI18n } from '@/lib/i18n';
import { getUrlScheme } from '@/lib/url';
import {
getAppLinkConfirmationSnapshot,
settleAppLinkConfirmation,
subscribeAppLinkConfirmation,
type AppLinkConfirmationChoice,
} from './appLinkConfirmation';
/**
* App-level dialog confirming application deep links (obsidian://, vscode://,
* ...) rendered in chat markdown before the OS is asked to open them.
* Dismissing via the close button, Escape, or the backdrop cancels the open.
*/
export const AppLinkConfirmDialog = () => {
const { t } = useI18n();
const request = React.useSyncExternalStore(
subscribeAppLinkConfirmation,
getAppLinkConfirmationSnapshot,
getAppLinkConfirmationSnapshot,
);
const url = request?.url ?? '';
const scheme = getUrlScheme(url) ?? '';
const settle = React.useCallback((choice: AppLinkConfirmationChoice) => {
settleAppLinkConfirmation(choice);
}, []);
return (
<Dialog
open={Boolean(request)}
onOpenChange={(open: boolean) => {
if (!open) {
settle('cancel');
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('chat.appLink.confirm.title')}</DialogTitle>
<DialogDescription>
{scheme
? t('chat.appLink.confirm.description', { scheme: `${scheme}://` })
: t('chat.appLink.confirm.descriptionPlain')}
</DialogDescription>
</DialogHeader>
<div className="rounded-lg bg-[var(--surface-muted)] px-3 py-2 text-[13px] leading-relaxed break-all text-[var(--surface-foreground)]">
{url}
</div>
<DialogFooter>
<Button variant="ghost" autoFocus onClick={() => settle('cancel')}>
{t('chat.appLink.confirm.cancel')}
</Button>
<Button variant="outline" onClick={() => settle('trust')}>
{t('chat.appLink.confirm.trustAndOpen')}
</Button>
<Button variant="default" onClick={() => settle('open')}>
{t('chat.appLink.confirm.open')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+407 -263
View File
@@ -18,8 +18,8 @@ import { StatusRowContainer } from './StatusRowContainer';
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer'; import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
import ScrollToBottomButton from './components/ScrollToBottomButton'; import ScrollToBottomButton from './components/ScrollToBottomButton';
import { PromptNavigatorRail } from './components/PromptNavigatorRail'; import { PromptNavigatorRail } from './components/PromptNavigatorRail';
import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { useScrollShadow } from '@/components/ui/useScrollShadow';
import { useChatAutoFollow, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatAutoFollow'; import { useChatTimelineScroll, type TimelineListHandle } from '@/hooks/useChatTimelineScroll';
import { useChatTimelineController } from './hooks/useChatTimelineController'; import { useChatTimelineController } from './hooks/useChatTimelineController';
import { TimelineDialog } from './TimelineDialog'; import { TimelineDialog } from './TimelineDialog';
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation'; import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
@@ -60,11 +60,14 @@ import { findShellCommandForMessage, isUserShellMarkerMessage } from './lib/shel
import { resolveChatPromptReadOnly } from './chatPromptReadOnly'; import { resolveChatPromptReadOnly } from './chatPromptReadOnly';
import { getRuntimeKey } from '@/lib/runtime-switch'; import { getRuntimeKey } from '@/lib/runtime-switch';
import { createFirstVisibleSessionPerformanceTracker } from '@/sync/session-load-performance'; import { createFirstVisibleSessionPerformanceTracker } from '@/sync/session-load-performance';
import { isChatDirectoryPath } from '@/lib/chatDirectories';
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = []; const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
const IDLE_SESSION_STATUS = { type: 'idle' as const }; const IDLE_SESSION_STATUS = { type: 'idle' as const };
const CHAT_FORCE_SCROLL_BOTTOM_EVENT = 'openchamber:chat-force-scroll-bottom'; const CHAT_FORCE_SCROLL_BOTTOM_EVENT = 'openchamber:chat-force-scroll-bottom';
const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.'; const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.';
const DRAFT_EXIT_DURATION_MS = 120;
const COMPOSER_MOVE_DURATION_MS = 180;
const CHAT_SCROLL_STYLE = { const CHAT_SCROLL_STYLE = {
overflowAnchor: 'none', overflowAnchor: 'none',
overscrollBehavior: 'contain', overscrollBehavior: 'contain',
@@ -148,11 +151,15 @@ type ChatViewportProps = {
currentSessionKey: string; currentSessionKey: string;
isDesktopExpandedInput: boolean; isDesktopExpandedInput: boolean;
isMobile: boolean; isMobile: boolean;
stickyUserHeader: boolean;
directory?: string; directory?: string;
scrollRef: React.RefObject<HTMLDivElement | null>; scrollRef: React.RefObject<HTMLDivElement | null>;
messageListRef: React.RefObject<MessageListHandle | null>; messageListRef: React.RefObject<MessageListHandle | null>;
pendingRevealWork: boolean; registerList: (list: TimelineListHandle | null) => void;
anchorMessageId: string | null;
onAnchorReady: (messageId: string, anchorIndex: number) => void;
onAnchorSizeChanged: (messageId: string) => void;
onIsAtEndChange: (isAtEnd: boolean) => void;
onTimelineDataChange: () => void;
renderedMessages: SessionMessageRecord[]; renderedMessages: SessionMessageRecord[];
isLoadingOlder: boolean; isLoadingOlder: boolean;
sessionIsWorking: boolean; sessionIsWorking: boolean;
@@ -164,10 +171,11 @@ type ChatViewportProps = {
confirmedAt?: number; confirmedAt?: number;
fallbackTimestamp?: number; fallbackTimestamp?: number;
} | null; } | null;
handleMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
handleHistoryScroll: () => void;
scrollToBottom: () => void; scrollToBottom: () => void;
endPinningReleased: boolean;
// One-shot fade for content that replaced the hydration skeleton;
// cached sessions render instantly without it.
revealContent: boolean;
sessionQuestions: QuestionRequest[]; sessionQuestions: QuestionRequest[];
sessionPermissions: PermissionRequest[]; sessionPermissions: PermissionRequest[];
isProgrammaticFollowActive: boolean; isProgrammaticFollowActive: boolean;
@@ -187,21 +195,24 @@ const ChatViewport = React.memo(({
currentSessionKey, currentSessionKey,
isDesktopExpandedInput, isDesktopExpandedInput,
isMobile, isMobile,
stickyUserHeader,
directory, directory,
scrollRef, scrollRef,
messageListRef, messageListRef,
pendingRevealWork, registerList,
anchorMessageId,
onAnchorReady,
onAnchorSizeChanged,
onIsAtEndChange,
onTimelineDataChange,
renderedMessages, renderedMessages,
isLoadingOlder, isLoadingOlder,
sessionIsWorking, sessionIsWorking,
streamingMessageId, streamingMessageId,
activeStreamingPhase, activeStreamingPhase,
retryOverlay, retryOverlay,
handleMessageContentChange,
getAnimationHandlers,
handleHistoryScroll,
scrollToBottom, scrollToBottom,
endPinningReleased,
revealContent,
sessionQuestions, sessionQuestions,
sessionPermissions, sessionPermissions,
isProgrammaticFollowActive, isProgrammaticFollowActive,
@@ -312,83 +323,96 @@ const ChatViewport = React.memo(({
scrollRef.current?.focus({ preventScroll: true }); scrollRef.current?.focus({ preventScroll: true });
}, [scrollRef]); }, [scrollRef]);
// Everything that used to sit beside the list inside the scroll container
// now renders as the list's header/footer, so it keeps scrolling with the
// rows exactly as before.
const listHeader = React.useMemo(() => (
showLoadOlderButton ? (
<div className="flex justify-center pt-3 pb-1">
<Button
variant="secondary"
size="sm"
onClick={onLoadOlder}
disabled={isLoadingOlder}
>
{isLoadingOlder && (
<Icon name="loader-4" className="size-4 animate-spin" />
)}
{t('chat.history.loadOlder')}
</Button>
</div>
) : null
), [isLoadingOlder, onLoadOlder, showLoadOlderButton, t]);
const listFooter = React.useMemo(() => (
<>
{(sessionQuestions.length > 0 || sessionPermissions.length > 0) && (
<div>
{sessionQuestions.map((question) => (
<QuestionCard key={question.id} question={question} />
))}
{sessionPermissions.map((permission) => (
<PermissionCard key={permission.id} permission={permission} />
))}
</div>
)}
<SessionRecapNote sessionId={currentSessionId} directory={directory} isMobile={isMobile} />
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
</>
), [currentSessionId, directory, isMobile, sessionPermissions, sessionQuestions]);
const scrollContainerProps = React.useMemo(() => ({
className: 'absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target',
style: CHAT_SCROLL_STYLE,
tabIndex: 0,
onClick: focusScrollContainer,
'data-scrollbar': 'chat',
'data-scroll-shadow': 'true',
'data-orientation': 'vertical',
}), [focusScrollContainer]);
return ( return (
<div <div
className={cn( className={cn(
'relative min-h-0', 'relative min-h-0',
isDesktopExpandedInput isDesktopExpandedInput
? 'absolute inset-0 opacity-0 pointer-events-none' ? 'absolute inset-0 opacity-0 pointer-events-none'
: 'flex-1' : 'flex-1',
revealContent && !isDesktopExpandedInput && 'oc-chat-hydration-reveal',
)} )}
aria-hidden={isDesktopExpandedInput} aria-hidden={isDesktopExpandedInput}
> >
<div className="absolute inset-0"> <div className="absolute inset-0">
<ScrollShadow <MessageList
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target" key={currentSessionKey}
ref={scrollRef} ref={messageListRef}
style={CHAT_SCROLL_STYLE} sessionKey={currentSessionId}
observeMutations={false} messages={renderedMessages}
hideTopShadow={isMobile && stickyUserHeader} sessionIsWorking={sessionIsWorking}
tabIndex={0} activeStreamingMessageId={streamingMessageId}
onClick={focusScrollContainer} activeStreamingPhase={activeStreamingPhase}
onScroll={handleHistoryScroll} retryOverlay={retryOverlay}
data-scroll-shadow="true" isLoadingOlder={isLoadingOlder}
data-scrollbar="chat" scrollToBottom={scrollToBottom}
> endPinningReleased={endPinningReleased}
<div className="relative z-0 min-h-full"> directory={directory}
{showLoadOlderButton && ( registerList={registerList}
<div className="flex justify-center pt-3 pb-1"> anchorMessageId={anchorMessageId}
<Button onAnchorReady={onAnchorReady}
variant="secondary" onAnchorSizeChanged={onAnchorSizeChanged}
size="sm" // Zero end inset: the footer spacer already reserves the
onClick={onLoadOlder} // zone the floating status row covers; adding its height
disabled={isLoadingOlder} // again produced a double-tall blank band at rest.
> composerOverlayHeight={0}
{isLoadingOlder && ( onIsAtEndChange={onIsAtEndChange}
<Icon name="loader-4" className="size-4 animate-spin" /> onTimelineDataChange={onTimelineDataChange}
)} listHeader={listHeader}
{t('chat.history.loadOlder')} listFooter={listFooter}
</Button> scrollContainerProps={scrollContainerProps}
</div> />
)} <OverlayScrollbar containerRef={scrollRef} disableHorizontal suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
<MessageList
key={currentSessionKey}
ref={messageListRef}
sessionKey={currentSessionId}
disableStaging={pendingRevealWork}
messages={renderedMessages}
sessionIsWorking={sessionIsWorking}
activeStreamingMessageId={streamingMessageId}
activeStreamingPhase={activeStreamingPhase}
retryOverlay={retryOverlay}
onMessageContentChange={handleMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
isLoadingOlder={isLoadingOlder}
scrollToBottom={scrollToBottom}
scrollRef={scrollRef}
directory={directory}
/>
{(sessionQuestions.length > 0 || sessionPermissions.length > 0) && (
<div>
{sessionQuestions.map((question) => (
<QuestionCard key={question.id} question={question} />
))}
{sessionPermissions.map((permission) => (
<PermissionCard key={permission.id} permission={permission} />
))}
</div>
)}
<SessionRecapNote sessionId={currentSessionId} directory={directory} isMobile={isMobile} />
<div className="mb-3">
<StatusRowContainer />
</div>
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
</div>
</ScrollShadow>
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
{showPromptNavigator && promptTurnIds.length >= 2 ? ( {showPromptNavigator && promptTurnIds.length >= 2 ? (
<PromptNavigatorRail <PromptNavigatorRail
turnIds={promptTurnIds} turnIds={promptTurnIds}
@@ -408,21 +432,18 @@ const ChatViewport = React.memo(({
&& prev.currentSessionKey === next.currentSessionKey && prev.currentSessionKey === next.currentSessionKey
&& prev.isDesktopExpandedInput === next.isDesktopExpandedInput && prev.isDesktopExpandedInput === next.isDesktopExpandedInput
&& prev.isMobile === next.isMobile && prev.isMobile === next.isMobile
&& prev.stickyUserHeader === next.stickyUserHeader
&& prev.directory === next.directory && prev.directory === next.directory
&& prev.scrollRef === next.scrollRef && prev.scrollRef === next.scrollRef
&& prev.messageListRef === next.messageListRef && prev.messageListRef === next.messageListRef
&& prev.pendingRevealWork === next.pendingRevealWork
&& prev.renderedMessages === next.renderedMessages && prev.renderedMessages === next.renderedMessages
&& prev.isLoadingOlder === next.isLoadingOlder && prev.isLoadingOlder === next.isLoadingOlder
&& prev.sessionIsWorking === next.sessionIsWorking && prev.sessionIsWorking === next.sessionIsWorking
&& prev.streamingMessageId === next.streamingMessageId && prev.streamingMessageId === next.streamingMessageId
&& prev.activeStreamingPhase === next.activeStreamingPhase && prev.activeStreamingPhase === next.activeStreamingPhase
&& prev.retryOverlay === next.retryOverlay && prev.retryOverlay === next.retryOverlay
&& prev.handleMessageContentChange === next.handleMessageContentChange
&& prev.getAnimationHandlers === next.getAnimationHandlers
&& prev.handleHistoryScroll === next.handleHistoryScroll
&& prev.scrollToBottom === next.scrollToBottom && prev.scrollToBottom === next.scrollToBottom
&& prev.endPinningReleased === next.endPinningReleased
&& prev.revealContent === next.revealContent
&& prev.sessionQuestions === next.sessionQuestions && prev.sessionQuestions === next.sessionQuestions
&& prev.sessionPermissions === next.sessionPermissions && prev.sessionPermissions === next.sessionPermissions
&& prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive && prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive
@@ -475,9 +496,11 @@ const ReadOnlyPromptBanner: React.FC = () => {
const { t } = useI18n(); const { t } = useI18n();
return ( return (
<div className="p-3"> <div className="w-full py-3">
<div className="rounded-2xl border border-border/70 bg-[var(--surface-background)] px-4 py-3 typography-ui-label text-muted-foreground"> <div className="chat-input-column">
{t('chat.container.readOnlySubagentPromptBanner')} <div className="rounded-2xl border border-border/70 bg-[var(--surface-background)] px-4 py-3 text-center typography-ui-label text-muted-foreground">
{t('chat.container.readOnlySubagentPromptBanner')}
</div>
</div> </div>
</div> </div>
); );
@@ -502,19 +525,24 @@ const renderDraftTitle = (title: string, projectLabel: string | null): React.Rea
); );
}; };
const DraftWelcome: React.FC = () => { const DraftWelcome: React.FC<{ exiting?: boolean }> = ({ exiting = false }) => {
const { t } = useI18n(); const { t } = useI18n();
const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target);
const selectedProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId ?? null); const selectedProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId ?? null);
const projectLabel = useProjectsStore(React.useCallback((state) => { const projectLabel = useProjectsStore(React.useCallback((state) => {
if (draftTarget === 'chat') return null;
const projectId = selectedProjectId ?? state.activeProjectId; const projectId = selectedProjectId ?? state.activeProjectId;
const project = (projectId const project = (projectId
? state.projects.find((candidate) => candidate.id === projectId) ? state.projects.find((candidate) => candidate.id === projectId)
: null) ?? state.projects[0] ?? null; : null) ?? state.projects[0] ?? null;
return project ? getProjectDisplayLabel(project) : null; return project ? getProjectDisplayLabel(project) : null;
}, [selectedProjectId])); }, [draftTarget, selectedProjectId]));
return ( return (
<div className="oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center"> <div className={cn(
'oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center transition-opacity duration-[120ms] ease-out motion-reduce:transition-none',
exiting && 'pointer-events-none opacity-0',
)}>
<h1 className="text-balance text-3xl font-normal tracking-tight text-foreground"> <h1 className="text-balance text-3xl font-normal tracking-tight text-foreground">
{renderDraftTitle( {renderDraftTitle(
projectLabel projectLabel
@@ -558,6 +586,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
// Session UI state // Session UI state
const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const currentSessionDirectory = useSessionUIStore((s) => s.currentSessionDirectory); const currentSessionDirectory = useSessionUIStore((s) => s.currentSessionDirectory);
const materializedDraftSessionId = useSessionUIStore((s) => s.materializedDraftSessionId);
const clearMaterializedDraftSession = useSessionUIStore((s) => s.clearMaterializedDraftSession);
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession); const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
@@ -731,12 +761,15 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
const isVSCode = isVSCodeRuntime(); const isVSCode = isVSCodeRuntime();
const chatSurfaceMode = useChatSurfaceMode(); const chatSurfaceMode = useChatSurfaceMode();
const draftOpen = Boolean(newSessionDraft?.open); const draftOpen = Boolean(newSessionDraft?.open);
const isManagedChatContext = draftOpen
? newSessionDraft?.target === 'chat'
: isChatDirectoryPath(effectiveSessionDirectory);
// A draft can target another project or a pending worktree before it has a // A draft can target another project or a pending worktree before it has a
// session. Keep the panel on that same directory so its project, MCP, and // session. Keep the panel on that same directory so its project, MCP, and
// usage readouts describe where the draft will run rather than the project // usage readouts describe where the draft will run rather than the project
// the user came from. // the user came from.
const workStatusDirectory = draftOpen const workStatusDirectory = draftOpen
? newSessionDraft?.bootstrapPendingDirectory ?? newSessionDraft?.directoryOverride ?? effectiveSessionDirectory ? (isManagedChatContext ? null : newSessionDraft?.bootstrapPendingDirectory ?? newSessionDraft?.directoryOverride ?? effectiveSessionDirectory)
: effectiveSessionDirectory; : effectiveSessionDirectory;
const initError = useGlobalSyncStore((s) => s.error); const initError = useGlobalSyncStore((s) => s.error);
// Despite the historical name, this now covers mobile too: the mobile // Despite the historical name, this now covers mobile too: the mobile
@@ -748,7 +781,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
// row that holds both columns, so its width never depends on the panel's // row that holds both columns, so its width never depends on the panel's
// own visibility. // own visibility.
const { rowRef: workStatusRowRef, visible: workStatusVisible, fits: workStatusFits } = useWorkStatusVisibility({ const { rowRef: workStatusRowRef, visible: workStatusVisible, fits: workStatusFits } = useWorkStatusVisibility({
directory: workStatusDirectory,
isMobile, isMobile,
isVSCode, isVSCode,
}); });
@@ -785,6 +817,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
return () => setWorkStatusPanelVisible(false); return () => setWorkStatusPanelVisible(false);
}, [setWorkStatusPanelVisible, showWorkStatusPanel]); }, [setWorkStatusPanelVisible, showWorkStatusPanel]);
const messageListRef = React.useRef<MessageListHandle | null>(null); const messageListRef = React.useRef<MessageListHandle | null>(null);
// Session keys that showed the hydration skeleton this app run; their
// content gets a one-shot reveal fade once it replaces the skeleton.
const hydrationRevealKeyRef = React.useRef<string | null>(null);
const currentSession = useSession(currentSessionId, effectiveSessionDirectory); const currentSession = useSession(currentSessionId, effectiveSessionDirectory);
const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory); const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory);
@@ -877,23 +913,67 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
activeTurnChangeRef.current(turnId); activeTurnChangeRef.current(turnId);
}, []); }, []);
// The composer sits below the timeline, but the status/working row floats
// OVER the timeline's bottom edge; its measured height keeps the live
// streaming line above it and reserves matching end inset in the list.
const [statusOverlayHeight, setStatusOverlayHeight] = React.useState(0);
const composerOverlayHeight = statusOverlayHeight;
const statusOverlayObserverRef = React.useRef<ResizeObserver | null>(null);
const onStatusOverlayNode = React.useCallback((node: HTMLDivElement | null) => {
statusOverlayObserverRef.current?.disconnect();
statusOverlayObserverRef.current = null;
if (!node || !globalThis.ResizeObserver) {
setStatusOverlayHeight(0);
return;
}
const update = () => {
// +8 for the mb-2 gap between the row and the composer, which the
// node's own box does not include.
const height = node.getBoundingClientRect().height + 8;
setStatusOverlayHeight((prev) => (Math.abs(prev - height) < 1 ? prev : height));
};
const observer = new ResizeObserver(update);
observer.observe(node);
statusOverlayObserverRef.current = observer;
update();
}, []);
React.useEffect(() => () => {
statusOverlayObserverRef.current?.disconnect();
statusOverlayObserverRef.current = null;
}, []);
const lastUserMessageId = React.useMemo(() => {
for (let index = sessionMessages.length - 1; index >= 0; index -= 1) {
const message = sessionMessages[index];
if (message.info.role === 'user') {
return message.info.id;
}
}
return null;
}, [sessionMessages]);
const { const {
scrollRef, scrollRef,
notifyContentChange: handleMessageContentChange, scrollNode,
getAnimationHandlers, registerList,
anchorMessageId,
onAnchorReady,
onAnchorSizeChanged,
onIsAtEndChange,
onManualNavigation,
onTimelineDataChange,
goToBottom, goToBottom,
scrollToBottomOnSend, scrollToBottomOnSend,
releaseAutoFollow,
restoreSnapshot, restoreSnapshot,
isPinned, isPinned,
isFollowingProgrammatically, isFollowingProgrammatically,
showScrollButton, showScrollButton,
} = useChatAutoFollow({ userOwnsScroll,
} = useChatTimelineScroll({
currentSessionId, currentSessionId,
currentSessionKey, currentSessionKey,
sessionMessageCount, sessionMessageCount,
sessionIsWorking, composerOverlayHeight,
isMobile, lastUserMessageId,
onActiveTurnChange: handleActiveTurnChange, onActiveTurnChange: handleActiveTurnChange,
}); });
@@ -908,33 +988,49 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
messageListRef, messageListRef,
loadMoreMessages, loadMoreMessages,
goToBottom, goToBottom,
releaseAutoFollow, releaseAutoFollow: onManualNavigation,
isPinned, isPinned,
showScrollButton, showScrollButton,
}); });
// The list owns the scroll element, so the shadows and the load-older
// trigger bind to its node rather than to a wrapper we render.
const scrollNodeRef = React.useMemo(() => ({ current: scrollNode }), [scrollNode]);
useScrollShadow(scrollNodeRef, {
observeMutations: false,
hideTopShadow: isMobile && stickyUserHeader,
});
const handleHistoryScroll = timelineController.handleHistoryScroll;
React.useEffect(() => {
if (!scrollNode) return;
const onScroll = () => handleHistoryScroll();
scrollNode.addEventListener('scroll', onScroll, { passive: true });
return () => {
scrollNode.removeEventListener('scroll', onScroll);
};
}, [handleHistoryScroll, scrollNode]);
const resumeToLatestInstant = React.useCallback(() => { const resumeToLatestInstant = React.useCallback(() => {
goToBottom('instant'); goToBottom('instant');
}, [goToBottom]); }, [goToBottom]);
// Mobile loads older history via an explicit top button instead of a // Mobile loads older history via an explicit top button instead of a
// scroll-position trigger (see handleHistoryScroll in the controller). // scroll-position trigger (see handleHistoryScroll in the controller).
const showLoadOlderButton = isMobileSurfaceRuntime() const showLoadOlderButton = isMobileSurfaceRuntime()
&& timelineController.historySignals.canLoadEarlier; && timelineController.historySignals.canLoadEarlier;
const timelineLoadEarlier = timelineController.loadEarlier; const timelineLoadEarlier = timelineController.loadEarlier;
const handleLoadOlderClick = React.useCallback(() => { const handleLoadOlderClick = React.useCallback(() => {
// Loading older history is an explicit move INTO the past: release
// live follow first, or the prepend's content growth would trigger an
// end correction and throw the viewport to the bottom.
onManualNavigation();
void timelineLoadEarlier({ userInitiated: true }); void timelineLoadEarlier({ userInitiated: true });
}, [timelineLoadEarlier]); }, [onManualNavigation, timelineLoadEarlier]);
React.useEffect(() => { React.useEffect(() => {
activeTurnChangeRef.current = timelineController.handleActiveTurnChange; activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
}, [timelineController.handleActiveTurnChange]); }, [timelineController.handleActiveTurnChange]);
React.useEffect(() => {
if (sessionPermissions.length === 0 && sessionQuestions.length === 0) {
return;
}
handleMessageContentChange('permission');
}, [handleMessageContentChange, sessionPermissions, sessionQuestions]);
const navigation = useChatTurnNavigation({ const navigation = useChatTurnNavigation({
sessionId: currentSessionId, sessionId: currentSessionId,
turnIds: timelineController.turnIds, turnIds: timelineController.turnIds,
@@ -944,7 +1040,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
resumeToBottom: timelineController.resumeToBottomInstant, resumeToBottom: timelineController.resumeToBottomInstant,
}); });
const handlePromptNavigatorSelect = React.useCallback((turnId: string) => { const handlePromptNavigatorSelect = React.useCallback((turnId: string) => {
void navigation.scrollToTurnId(turnId, { behavior: 'smooth' }); // Instant on purpose: a long smooth scroll through a virtualized
// timeline gets cancelled by row remounts and lands mid-way or on the
// wrong message; a teleport always arrives.
void navigation.scrollToTurnId(turnId, { behavior: 'auto' });
}, [navigation]); }, [navigation]);
const canLoadEarlierPrompts = timelineController.historySignals.canLoadEarlier; const canLoadEarlierPrompts = timelineController.historySignals.canLoadEarlier;
const showPromptNavigator = !isMobile const showPromptNavigator = !isMobile
@@ -992,8 +1091,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
return; return;
} }
const { activeMainTab } = useUIStore.getState(); if (hasBlockingChatOverlay()) {
if (activeMainTab !== 'chat' || hasBlockingChatOverlay()) {
return; return;
} }
@@ -1058,6 +1156,15 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
const isSessionHydrating = const isSessionHydrating =
Boolean(currentSessionId) Boolean(currentSessionId)
&& !hasRenderableSessionSnapshot; && !hasRenderableSessionSnapshot;
React.useEffect(() => {
if (isSessionHydrating || hydrationRevealKeyRef.current === null) return;
// One-shot: forget the key after the reveal animation has played so a
// later (now cached) visit to the same session opens instantly.
const timer = setTimeout(() => {
hydrationRevealKeyRef.current = null;
}, 400);
return () => clearTimeout(timer);
}, [isSessionHydrating, currentSessionKey]);
const retrySessionLoad = React.useCallback(() => { const retrySessionLoad = React.useCallback(() => {
if (!messagesEnabled || !currentSessionId) return; if (!messagesEnabled || !currentSessionId) return;
void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory); void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory);
@@ -1071,7 +1178,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
lastScrolledSessionKeyRef.current = currentSessionKey; lastScrolledSessionKeyRef.current = currentSessionKey;
if (hasHashTarget) { if (hasHashTarget) {
// Hash navigation handler will scroll to target; we just release auto-follow. // Hash navigation handler will scroll to target; we just release auto-follow.
releaseAutoFollow(); onManualNavigation();
return; return;
} }
@@ -1083,7 +1190,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
} else { } else {
window.requestAnimationFrame(run); window.requestAnimationFrame(run);
} }
}, [active, currentSessionId, currentSessionKey, releaseAutoFollow, restoreSnapshot]); }, [active, currentSessionId, currentSessionKey, onManualNavigation, restoreSnapshot]);
React.useEffect(() => { React.useEffect(() => {
if (!messagesEnabled || !currentSessionId) return; if (!messagesEnabled || !currentSessionId) return;
@@ -1091,11 +1198,75 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
void ensureSessionRenderable(currentSessionId); void ensureSessionRenderable(currentSessionId);
}, [currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot, messagesEnabled]); }, [currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot, messagesEnabled]);
const composerSlotRef = React.useRef<HTMLDivElement | null>(null);
const previousComposerRectRef = React.useRef<DOMRect | null>(null);
const previousDraftOpenRef = React.useRef(draftOpen);
const previousDraftLayoutVisibleRef = React.useRef(draftOpen);
const [draftExitAnimating, setDraftExitAnimating] = React.useState(false);
const shouldAnimateDraftTransition = Boolean(
currentSessionId && materializedDraftSessionId === currentSessionId,
);
const draftPresentationExiting = draftExitAnimating
|| (previousDraftOpenRef.current && !draftOpen && shouldAnimateDraftTransition);
const draftLayoutVisible = draftOpen || draftPresentationExiting;
React.useLayoutEffect(() => {
if (draftOpen) {
setDraftExitAnimating(false);
return;
}
if (!previousDraftOpenRef.current || !shouldAnimateDraftTransition) return;
setDraftExitAnimating(true);
const timeoutId = window.setTimeout(() => setDraftExitAnimating(false), DRAFT_EXIT_DURATION_MS);
return () => window.clearTimeout(timeoutId);
}, [draftOpen, shouldAnimateDraftTransition]);
React.useLayoutEffect(() => {
previousDraftOpenRef.current = draftOpen;
}, [draftOpen]);
React.useLayoutEffect(() => {
const composerSlot = composerSlotRef.current;
if (!composerSlot) return;
const composerEditor = composerSlot.querySelector('[data-testid="chat-input"]');
const currentRect = composerEditor?.getBoundingClientRect() ?? composerSlot.getBoundingClientRect();
const previousRect = previousComposerRectRef.current;
const leftDraftLayout = previousDraftLayoutVisibleRef.current
&& !draftLayoutVisible
&& Boolean(currentSessionId);
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
const shouldMoveComposer = leftDraftLayout && shouldAnimateDraftTransition;
if (shouldMoveComposer && previousRect && !reduceMotion && !useCompactDraftLayout && !isDesktopExpandedInput) {
const deltaX = previousRect.left - currentRect.left;
const deltaY = previousRect.top - currentRect.top;
composerSlot.animate(
[
{ transform: `translate(${deltaX}px, ${deltaY}px)` },
{ transform: 'translate(0, 0)' },
],
{ duration: COMPOSER_MOVE_DURATION_MS, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' },
);
}
previousComposerRectRef.current = currentRect;
previousDraftLayoutVisibleRef.current = draftLayoutVisible;
if (leftDraftLayout && currentSessionId) {
clearMaterializedDraftSession(currentSessionId);
}
}, [
clearMaterializedDraftSession,
currentSessionId,
draftLayoutVisible,
isDesktopExpandedInput,
shouldAnimateDraftTransition,
useCompactDraftLayout,
]);
if (!currentSessionId && !draftOpen) { if (!currentSessionId && !draftOpen) {
// With auto-open, the draft welcome opens on the next tick (effect below), // The auto-open effect runs on the next tick. Use a neutral background
// so the empty state is only ever transient here — render a neutral // until then instead of flashing the standard empty state.
// background instead of flashing the logo / "start a new chat" on refresh.
// Keep the empty state when there's nothing to auto-open or an init error to show.
if (autoOpenDraft && !initError) { if (autoOpenDraft && !initError) {
return <div className="flex h-full flex-col bg-background" />; return <div className="flex h-full flex-col bg-background" />;
} }
@@ -1106,82 +1277,42 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
); );
} }
if (!currentSessionId && draftOpen) { const sessionSurface = (() => {
return ( if (draftOpen || draftPresentationExiting) {
// No transform on this root: it would become the containing block for if (!useCompactDraftLayout || isDesktopExpandedInput) {
// the fullscreen composer's position:fixed visual-viewport pinning in return null;
// mobile browsers (see ChatInput's composerFormRef effect). }
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background"> return <DraftWelcome exiting={draftPresentationExiting} />;
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col bg-background"> }
{useCompactDraftLayout && !isDesktopExpandedInput ? <DraftWelcome /> : null}
<div
className={cn(
'relative z-10 flex min-h-0',
isDesktopExpandedInput
? 'flex-1 bg-background'
: useCompactDraftLayout
? 'bg-background px-0'
: 'flex-1 items-center justify-center bg-background px-0 pb-[6vh]'
)}
>
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
{workStatusOverlayMountable ? (
<WorkStatusPanel
overlay
visible={showWorkStatusOverlay}
sessionId={null}
directory={workStatusDirectory ?? null}
/>
) : null}
</div>
{workStatusPanelMountable ? (
<WorkStatusPanel
visible={showWorkStatusPanel}
sessionId={null}
directory={workStatusDirectory ?? null}
/>
) : null}
</div>
);
}
if (!currentSessionId) { const showHydrationSkeleton = isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking;
return null; if (showHydrationSkeleton) {
} hydrationRevealKeyRef.current = currentSessionKey ?? currentSessionId ?? null;
}
if (showHydrationSkeleton) {
if (sessionMessageLoadState.status === 'error') {
return (
<div className="flex min-h-0 flex-1 items-center justify-center px-6">
<div className="max-w-sm text-center">
<div className="mx-auto mb-3 flex size-9 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--status-error)_10%,transparent)] text-[var(--status-error)]">
<Icon name="error-warning" className="size-4" />
</div>
<p className="typography-ui-label font-medium text-foreground">{t('chat.container.sessionLoadError.title')}</p>
<p className="typography-meta mt-1 text-muted-foreground">{t('chat.container.sessionLoadError.description')}</p>
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
{t('chat.container.sessionLoadError.retry')}
</Button>
</div>
</div>
);
}
if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) { return (
if (sessionMessageLoadState.status === 'error') { <div
return ( data-chat-hydration-skeleton=""
<div data-composer-bound className="relative flex h-full flex-col bg-background"> className={cn(
{returnToParentButton} 'relative min-h-0',
<div className="flex min-h-0 flex-1 items-center justify-center px-6"> isDesktopExpandedInput ? 'pointer-events-none absolute inset-0 opacity-0' : 'flex-1',
<div className="max-w-sm text-center">
<div className="mx-auto mb-3 flex size-9 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--status-error)_10%,transparent)] text-[var(--status-error)]">
<Icon name="error-warning" className="size-4" />
</div>
<p className="typography-ui-label font-medium text-foreground">{t('chat.container.sessionLoadError.title')}</p>
<p className="typography-meta mt-1 text-muted-foreground">{t('chat.container.sessionLoadError.description')}</p>
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
{t('chat.container.sessionLoadError.retry')}
</Button>
</div>
</div>
<div className="relative z-10 bg-background">
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
</div>
);
}
return (
<div data-composer-bound className="relative flex flex-col h-full bg-background">
{returnToParentButton}
<div
className={cn(
'relative min-h-0',
isDesktopExpandedInput
? 'absolute inset-0 opacity-0 pointer-events-none'
: 'flex-1'
)} )}
aria-hidden={isDesktopExpandedInput} aria-hidden={isDesktopExpandedInput}
> >
@@ -1192,20 +1323,18 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
<div className="chat-message-column"> <div className="chat-message-column">
<div className="space-y-2.5 px-4 py-3"> <div className="space-y-2.5 px-4 py-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
{item.toolRows.map((row) => { {item.toolRows.map((row) => (
return ( <div key={`${item.id}-${row.id}`} className="flex items-center gap-2">
<div key={`${item.id}-${row.id}`} className="flex items-center gap-2"> <Skeleton className="h-3.5 w-3.5 shrink-0 rounded-full" />
<Skeleton className="h-3.5 w-3.5 rounded-full flex-shrink-0" /> <Skeleton className={cn('h-4 rounded-md', row.titleWidth)} />
<Skeleton className={cn('h-4 rounded-md', row.titleWidth)} /> <Skeleton className={cn('h-4 rounded-md', row.detailWidth)} />
<Skeleton className={cn('h-4 rounded-md', row.detailWidth)} /> </div>
</div> ))}
);
})}
</div> </div>
<div className="space-y-1.5 pt-1"> <div className="space-y-1.5 pt-1">
<Skeleton className={cn('h-4 rounded-md', item.textWidths[0])} /> {item.textWidths.map((width, index) => (
<Skeleton className={cn('h-4 rounded-md', item.textWidths[1])} /> <Skeleton key={`${item.id}-text-${index}`} className={cn('h-4 rounded-md', width)} />
<Skeleton className={cn('h-4 rounded-md', item.textWidths[2])} /> ))}
</div> </div>
</div> </div>
</div> </div>
@@ -1214,79 +1343,45 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
</div> </div>
</div> </div>
</div> </div>
);
}
if (sessionMessages.length === 0 && !sessionIsWorking) {
return (
<div <div
className={cn( className={cn(
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background'
)}
>
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
</div>
);
}
if (sessionMessages.length === 0 && !sessionIsWorking) {
return (
// No transform here either — same fixed-positioning constraint as the
// draft branch above.
<div data-composer-bound className="relative flex flex-col h-full bg-background">
{returnToParentButton}
<div
className={cn(
'relative min-h-0', 'relative min-h-0',
isDesktopExpandedInput isDesktopExpandedInput ? 'pointer-events-none absolute inset-0 opacity-0' : 'flex-1',
? 'absolute inset-0 opacity-0 pointer-events-none'
: 'flex-1'
)} )}
aria-hidden={isDesktopExpandedInput} aria-hidden={isDesktopExpandedInput}
> />
{!isDesktopExpandedInput ? ( );
<div className="absolute inset-0 flex items-center justify-center"> }
<ChatEmptyState />
</div>
) : null}
</div>
<div
className={cn(
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background'
)}
>
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
</div>
);
}
return ( return (
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background"> <ChatViewport
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col h-full bg-background"> currentSessionId={currentSessionId ?? ''}
{returnToParentButton} currentSessionKey={currentSessionKey ?? currentSessionId ?? ''}
<ChatViewport
currentSessionId={currentSessionId}
currentSessionKey={currentSessionKey ?? currentSessionId}
isDesktopExpandedInput={isDesktopExpandedInput} isDesktopExpandedInput={isDesktopExpandedInput}
isMobile={isMobile} isMobile={isMobile}
stickyUserHeader={stickyUserHeader}
directory={effectiveSessionDirectory} directory={effectiveSessionDirectory}
scrollRef={scrollRef} scrollRef={scrollRef}
registerList={registerList}
anchorMessageId={anchorMessageId}
onAnchorReady={onAnchorReady}
onAnchorSizeChanged={onAnchorSizeChanged}
onIsAtEndChange={onIsAtEndChange}
onTimelineDataChange={onTimelineDataChange}
messageListRef={messageListRef} messageListRef={messageListRef}
pendingRevealWork={timelineController.pendingRevealWork}
renderedMessages={timelineController.renderedMessages} renderedMessages={timelineController.renderedMessages}
isLoadingOlder={timelineController.isLoadingOlder} isLoadingOlder={timelineController.isLoadingOlder}
sessionIsWorking={sessionIsWorking} sessionIsWorking={sessionIsWorking}
streamingMessageId={streamingMessageId} streamingMessageId={streamingMessageId}
activeStreamingPhase={activeStreamingPhase} activeStreamingPhase={activeStreamingPhase}
retryOverlay={retryOverlay} retryOverlay={retryOverlay}
handleMessageContentChange={handleMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
handleHistoryScroll={timelineController.handleHistoryScroll}
scrollToBottom={resumeToLatestInstant} scrollToBottom={resumeToLatestInstant}
endPinningReleased={userOwnsScroll}
revealContent={hydrationRevealKeyRef.current !== null && hydrationRevealKeyRef.current === (currentSessionKey ?? currentSessionId ?? null)}
sessionQuestions={sessionQuestions} sessionQuestions={sessionQuestions}
sessionPermissions={sessionPermissions} sessionPermissions={sessionPermissions}
isProgrammaticFollowActive={isFollowingProgrammatically} isProgrammaticFollowActive={isFollowingProgrammatically}
@@ -1300,22 +1395,69 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
isLoadingOlderPrompts={timelineController.isLoadingOlder} isLoadingOlderPrompts={timelineController.isLoadingOlder}
onLoadEarlierPrompts={handleLoadOlderClick} onLoadEarlierPrompts={handleLoadOlderClick}
/> />
);
})();
return (
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col h-full bg-background">
{returnToParentButton}
{sessionSurface}
<div <div
ref={composerSlotRef}
className={cn( className={cn(
'relative z-10', 'relative z-10 flex min-h-0',
isDesktopExpandedInput isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background' ? 'flex-1 min-h-0 bg-background'
: draftLayoutVisible && !useCompactDraftLayout
? 'flex-1 items-center justify-center bg-background pb-[6vh]'
: 'bg-background' : 'bg-background'
)} )}
> >
{!isDesktopExpandedInput && sessionMessages.length > 0 && ( {!draftLayoutVisible && !isDesktopExpandedInput && sessionMessages.length > 0 && (
<ScrollToBottomButton <>
visible={timelineController.showScrollToBottom} <ScrollToBottomButton
onClick={navigation.resumeToLatest} visible={timelineController.showScrollToBottom}
working={sessionIsWorking}
onClick={navigation.resumeToLatest}
/>
{/* Same anchor and column as the pill, so the status
row and the pill it hands off to share the exact
distance from the input and the same left edge. */}
<div
className={cn(
'pointer-events-none absolute bottom-full inset-x-0 mb-2 transition-opacity duration-100',
userOwnsScroll && 'opacity-0',
)}
>
<div className="chat-input-column">
{/* The glass chip itself is rendered inside
StatusRow (its root is a size container
that cannot shrink-wrap). */}
<div
ref={onStatusOverlayNode}
className={cn(
'[&:not(:has(*))]:hidden',
userOwnsScroll ? 'pointer-events-none' : 'pointer-events-auto',
)}
>
<StatusRowContainer />
</div>
</div>
</div>
</>
)}
{promptReadOnly ? (
<ReadOnlyPromptBanner />
) : (
<ChatInput
active={active}
scrollToBottom={scrollToBottomOnSend}
scrollToLatest={resumeToLatestInstant}
draftPresentationExiting={draftPresentationExiting}
/> />
)} )}
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div> </div>
{/* Inside the chat column, not beside it: as a row sibling it took {/* Inside the chat column, not beside it: as a row sibling it took
@@ -1327,6 +1469,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
visible={showWorkStatusOverlay} visible={showWorkStatusOverlay}
sessionId={currentSessionId ?? null} sessionId={currentSessionId ?? null}
directory={workStatusDirectory ?? null} directory={workStatusDirectory ?? null}
repositoryEnabled={!isManagedChatContext}
/> />
) : null} ) : null}
@@ -1349,6 +1492,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
visible={showWorkStatusPanel} visible={showWorkStatusPanel}
sessionId={currentSessionId ?? null} sessionId={currentSessionId ?? null}
directory={workStatusDirectory ?? null} directory={workStatusDirectory ?? null}
repositoryEnabled={!isManagedChatContext}
/> />
) : null} ) : null}
</div> </div>
+180 -130
View File
@@ -21,7 +21,6 @@ import { buildLinkedIssue } from '@/lib/linkedIssues';
import { useUserMessageHistory } from "@/sync/sync-context"; import { useUserMessageHistory } from "@/sync/sync-context";
import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore'; import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore';
import { useSnippetsStore } from '@/stores/useSnippetsStore'; import { useSnippetsStore } from '@/stores/useSnippetsStore';
import { appendInlineComments } from '@/lib/messages/inlineComments';
import { renderMagicPrompt } from '@/lib/magicPrompts'; import { renderMagicPrompt } from '@/lib/magicPrompts';
import { startReviewFlow } from '@/lib/reviewFlow'; import { startReviewFlow } from '@/lib/reviewFlow';
import { getRuntimeKey } from '@/lib/runtime-switch'; import { getRuntimeKey } from '@/lib/runtime-switch';
@@ -34,6 +33,9 @@ import {
type ChatDraftSnapshot, type ChatDraftSnapshot,
} from '@/lib/chatDraftPersistence'; } from '@/lib/chatDraftPersistence';
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog'; import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
import { BtwPanel } from './btw/BtwPanel';
import { useBtwPanelState } from './btw/useBtwPanelState';
import { destroyBtwSession, startBtwSession, type BtwSessionRef } from '@/lib/btw';
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment'; import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import type { ToolPopupContent } from './message/types'; import type { ToolPopupContent } from './message/types';
@@ -46,12 +48,12 @@ import type { SnippetAutocompleteHandle } from './SnippetAutocomplete';
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ModelControls } from './ModelControls'; import { ModelControls } from './ModelControls';
import { parseAgentMentions } from '@/lib/messages/agentMentions'; import { parseAgentMentions } from '@/lib/messages/agentMentions';
import { StatusRow } from './StatusRow'; import { ComposerStatusBar } from './ComposerStatusBar';
import { PendingChangesBar } from './PendingChangesBar'; import { PendingChangesBar } from './PendingChangesBar';
import { useChatSurfaceMode } from './useChatSurfaceMode'; import { useChatSurfaceMode } from './useChatSurfaceMode';
import { MobileAgentButton } from './MobileAgentButton'; import { MobileAgentButton } from './MobileAgentButton';
import { MobileModelButton } from './MobileModelButton'; import { MobileModelButton } from './MobileModelButton';
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; import { useCurrentSessionActivity, useSessionActivity } from '@/hooks/useSessionActivity';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
// useMessageStore removed — messages now come from sync system // useMessageStore removed — messages now come from sync system
import { isVSCodeRuntime } from '@/lib/desktop'; import { isVSCodeRuntime } from '@/lib/desktop';
@@ -218,12 +220,17 @@ const MemoModelControls = React.memo(ModelControls);
const MemoComposerDictation = React.memo(ComposerDictation); const MemoComposerDictation = React.memo(ComposerDictation);
const MemoMobileAgentButton = React.memo(MobileAgentButton); const MemoMobileAgentButton = React.memo(MobileAgentButton);
const MemoMobileModelButton = React.memo(MobileModelButton); const MemoMobileModelButton = React.memo(MobileModelButton);
const MemoStatusRow = React.memo(StatusRow); const MemoComposerStatusBar = React.memo(ComposerStatusBar);
interface ChatInputProps { interface ChatInputProps {
onOpenSettings?: () => void; onOpenSettings?: () => void;
scrollToBottom?: () => void; scrollToBottom?: () => void;
// Queued sends do not create a user row (the queue delivers later), so
// the anchor-arming scrollToBottom is wrong for them; this returns the
// viewport to the live edge instead.
scrollToLatest?: () => void;
active?: boolean; active?: boolean;
draftPresentationExiting?: boolean;
} }
const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity | null => { const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity | null => {
@@ -237,7 +244,13 @@ const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity |
return createChatDraftIdentity(getRuntimeKey(), directory, sessionId); return createChatDraftIdentity(getRuntimeKey(), directory, sessionId);
}; };
const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom, active = true }) => { const ChatInputComponent: React.FC<ChatInputProps> = ({
onOpenSettings,
scrollToBottom,
scrollToLatest,
active = true,
draftPresentationExiting = false,
}) => {
const { t } = useI18n(); const { t } = useI18n();
// Track if we restored a draft on mount (for text selection) // Track if we restored a draft on mount (for text selection)
const initialDraftRef = React.useRef<string | null>(null); const initialDraftRef = React.useRef<string | null>(null);
@@ -309,6 +322,20 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const currentSessionDirectoryForSync = useSessionUIStore( const currentSessionDirectoryForSync = useSessionUIStore(
React.useCallback((s) => currentSessionId ? s.getDirectoryForSession(currentSessionId) : null, [currentSessionId]), React.useCallback((s) => currentSessionId ? s.getDirectoryForSession(currentSessionId) : null, [currentSessionId]),
); );
// btw mode: the CURRENT session's metadata links an active btw fork and
// the panel is expanded, so this composer's sends route to the fork
// instead of the main session. Collapsed keeps the fork alive (chip stays
// visible) while the composer talks to the main session again.
const btwPanel = useBtwPanelState(currentSessionId, currentSessionDirectoryForSync ?? currentDirectory ?? undefined);
const btwSessionId = btwPanel.btwSessionId;
const btwDirectory = btwPanel.btwDirectory;
const btwSessionRef = React.useMemo<BtwSessionRef | null>(
() => (currentSessionId && btwSessionId && btwDirectory
? { parentSessionId: currentSessionId, btwSessionId, directory: btwDirectory }
: null),
[btwDirectory, btwSessionId, currentSessionId],
);
const isBtwActive = Boolean(btwSessionRef) && !btwPanel.collapsed;
const activeRuntimeKey = getRuntimeKey(); const activeRuntimeKey = getRuntimeKey();
const chatDraftIdentity = React.useMemo( const chatDraftIdentity = React.useMemo(
() => createChatDraftIdentity( () => createChatDraftIdentity(
@@ -326,6 +353,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget); const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget);
const setDraftPermissionAutoAcceptEnabled = useSessionUIStore((s) => s.setDraftPermissionAutoAcceptEnabled); const setDraftPermissionAutoAcceptEnabled = useSessionUIStore((s) => s.setDraftPermissionAutoAcceptEnabled);
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const prepareChatDraftDirectory = useSessionUIStore((s) => s.prepareChatDraftDirectory);
const abortPromptSessionId = useSessionUIStore((s) => s.abortPromptSessionId); const abortPromptSessionId = useSessionUIStore((s) => s.abortPromptSessionId);
const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt); const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt);
const attachedFiles = useInputStore((s) => s.attachedFiles); const attachedFiles = useInputStore((s) => s.attachedFiles);
@@ -336,6 +364,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const pendingPresetSubmit = useInputStore((s) => s.pendingPresetSubmit); const pendingPresetSubmit = useInputStore((s) => s.pendingPresetSubmit);
const setPendingInputText = useInputStore((s) => s.setPendingInputText); const setPendingInputText = useInputStore((s) => s.setPendingInputText);
const pendingInputText = useInputStore((s) => s.pendingInputText); const pendingInputText = useInputStore((s) => s.pendingInputText);
React.useEffect(() => {
if (!newSessionDraftOpen || newSessionDraft.target !== 'chat' || message.trim().length === 0) return;
void prepareChatDraftDirectory();
}, [message, newSessionDraft.target, newSessionDraftOpen, prepareChatDraftDirectory]);
const consumePendingSyntheticParts = useInputStore((s) => s.consumePendingSyntheticParts); const consumePendingSyntheticParts = useInputStore((s) => s.consumePendingSyntheticParts);
const acknowledgeSessionAbort = useSessionUIStore((s) => s.acknowledgeSessionAbort); const acknowledgeSessionAbort = useSessionUIStore((s) => s.acknowledgeSessionAbort);
const abortCurrentOperation = React.useCallback( const abortCurrentOperation = React.useCallback(
@@ -551,7 +584,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const availableSkills = useSkillsStore((s) => s.skills); const availableSkills = useSkillsStore((s) => s.skills);
const knownSlashNames = React.useMemo(() => { const knownSlashNames = React.useMemo(() => {
const names = new Set<string>([ const names = new Set<string>([
'init', 'review', 'undo', 'redo', 'timeline', 'compact', 'summary', 'workspace-review', 'plan-feature', 'craft-goal', 'schedule-task', 'catch-up', 'debug', 'weigh', 'explore', 'init', 'review', 'undo', 'redo', 'timeline', 'compact', 'btw', 'summary', 'workspace-review', 'plan-feature', 'craft-goal', 'schedule-task', 'catch-up', 'debug', 'weigh', 'explore',
]); ]);
if (!isMobile && !isVSCodeRuntime()) names.add('handoff-review'); if (!isMobile && !isVSCodeRuntime()) names.add('handoff-review');
for (const command of availableCommands) names.add(command.name.toLowerCase()); for (const command of availableCommands) names.add(command.name.toLowerCase());
@@ -724,55 +757,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
[inlineDraftKey] [inlineDraftKey]
) )
); );
const draftSourceKey = useInlineCommentDraftStore(
React.useCallback(
(state) => {
const drafts = inlineDraftKey ? (state.drafts[inlineDraftKey] ?? []) : [];
let previewConsole = 0;
let previewAnnotation = 0;
let review = 0;
let terminal = 0;
let prComment = 0;
let prCheck = 0;
for (const draft of drafts) {
if (draft.source === 'preview-console') previewConsole += 1;
else if (draft.source === 'preview-annotation') previewAnnotation += 1;
else if (draft.source === 'terminal') terminal += 1;
else if (draft.source === 'pr-comment') prComment += 1;
else if (draft.source === 'pr-check') prCheck += 1;
else review += 1;
}
return `${previewConsole}:${previewAnnotation}:${review}:${terminal}:${prComment}:${prCheck}`;
},
[inlineDraftKey]
)
);
const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts); const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
const removeInlineCommentDraft = useInlineCommentDraftStore((state) => state.removeDraft);
const hasDrafts = draftCount > 0; const hasDrafts = draftCount > 0;
const [previewConsoleCount, previewAnnotationCount, reviewCount, terminalContextCount, prCommentCount, prCheckCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0);
const terminalContextDrafts = terminalContextCount > 0
? (inlineDraftKey ? useInlineCommentDraftStore.getState().drafts[inlineDraftKey] ?? [] : []).filter((draft) => draft.source === 'terminal')
: [];
const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation' | 'pr-comment' | 'pr-check') => {
if (!inlineDraftTarget) return;
const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget);
for (const draft of drafts) {
if (draft.source === source) {
removeInlineCommentDraft(inlineDraftTarget, draft.id);
}
}
}, [inlineDraftTarget, removeInlineCommentDraft]);
// Review comments are the inline-comment drafts that aren't preview sources.
const removeReviewDrafts = React.useCallback(() => {
if (!inlineDraftTarget) return;
const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget);
for (const draft of drafts) {
if (draft.source !== 'preview-console' && draft.source !== 'preview-annotation' && draft.source !== 'terminal' && draft.source !== 'pr-comment' && draft.source !== 'pr-check') {
removeInlineCommentDraft(inlineDraftTarget, draft.id);
}
}
}, [inlineDraftTarget, removeInlineCommentDraft]);
// User message history for up/down arrow navigation. // User message history for up/down arrow navigation.
// Keep this on a narrow hook instead of full session message records. // Keep this on a narrow hook instead of full session message records.
@@ -821,8 +807,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
prevNewSessionDraftOpenRef.current = newSessionDraftOpen; prevNewSessionDraftOpenRef.current = newSessionDraftOpen;
}, [newSessionDraftOpen, isMobile]); }, [newSessionDraftOpen, isMobile]);
// Session activity for queue availability and controls // Session activity for queue availability and controls. In btw mode the
const { phase: sessionPhase } = useCurrentSessionActivity(); // composer controls the temporary fork, so the stop button and send-button
// state follow the FORK's activity; the queue affordance stays tied to the
// main session (queued messages always belong to the main chat).
const { phase: currentSessionPhase } = useCurrentSessionActivity();
const { phase: btwSessionPhase } = useSessionActivity(btwSessionId, btwDirectory ?? undefined);
const sessionPhase = isBtwActive ? btwSessionPhase : currentSessionPhase;
const autoReviewRunning = useAutoReviewStore(React.useCallback((state) => { const autoReviewRunning = useAutoReviewStore(React.useCallback((state) => {
if (!currentSessionId) return false; if (!currentSessionId) return false;
const run = state.runsByOriginalSessionID[currentSessionId]; const run = state.runsByOriginalSessionID[currentSessionId];
@@ -896,12 +887,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const inputSnapshot = getCurrentInputSnapshot(); const inputSnapshot = getCurrentInputSnapshot();
if (!inputSnapshot.hasContent || !currentSessionId || !messageQueueTarget) return; if (!inputSnapshot.hasContent || !currentSessionId || !messageQueueTarget) return;
const drafts = inlineDraftTarget ? consumeDrafts(inlineDraftTarget) : []; // Context drafts stay in their store: the send that later delivers the
// queue consumes them and attaches them as structured context parts.
let messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, ''); const messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, '');
if (drafts.length > 0) {
messageToQueue = appendInlineComments(messageToQueue, drafts);
}
const attachmentsToQueue = sanitizeAttachmentsForSend(attachedFiles); const attachmentsToQueue = sanitizeAttachmentsForSend(attachedFiles);
addToQueue(messageQueueTarget, { addToQueue(messageQueueTarget, {
@@ -915,6 +903,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} : undefined, } : undefined,
}); });
// Sending while the agent works must still take the reader to the
// live edge — a queued message produces no user row yet, so the
// anchor path has nothing to claim and would leave the viewport
// parked mid-history.
scrollToLatest?.();
// Clear input and attachments // Clear input and attachments
// Note: confirmedMentionsRef is NOT cleared here because queued messages // Note: confirmedMentionsRef is NOT cleared here because queued messages
// are processed later in handleSubmit which reads the ref via extractInlineFileMentions. // are processed later in handleSubmit which reads the ref via extractInlineFileMentions.
@@ -927,7 +921,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (!isMobile) { if (!isMobile) {
composerRef.current?.focus(); composerRef.current?.focus();
} }
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, inlineDraftTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]); }, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, currentProviderId, currentModelId, currentAgentName, currentVariant, scrollToLatest]);
const handleQueuedMessageEdit = React.useCallback((content: string) => { const handleQueuedMessageEdit = React.useCallback((content: string) => {
setMessage(content); setMessage(content);
@@ -1020,12 +1014,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
// queued-message auto-send hook delivers it as the next turn once the // queued-message auto-send hook delivers it as the next turn once the
// rejected turn winds down and the session returns to idle. This avoids // rejected turn winds down and the session returns to idle. This avoids
// aborting the turn (which would surface an "aborted" notice). // aborting the turn (which would surface an "aborted" notice).
if (currentSessionId && !queuedOnly && autoReviewRunning) { if (currentSessionId && !queuedOnly && autoReviewRunning && !isBtwActive) {
handleQueueMessage(); handleQueueMessage();
return; return;
} }
if (currentSessionId && !queuedOnly) { // btw mode: the child fork's blocking prompts are answered inside the
// panel; the composer send goes straight to the fork (routeMessage
// queues if the fork's own turn is busy).
if (currentSessionId && !queuedOnly && !isBtwActive) {
// Sending is authoritative for blocking prompts: deny pending // Sending is authoritative for blocking prompts: deny pending
// permissions and dismiss open questions for the session subtree, // permissions and dismiss open questions for the session subtree,
// then queue the message once if either was open. The deny/clear // then queue the message once if either was open. The deny/clear
@@ -1045,17 +1042,24 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} }
} }
const sendMessageOptions: { let sendMessageOptions: {
target?: NonNullable<typeof capturedTarget>; target?: NonNullable<typeof capturedTarget>;
sessionId?: string;
directory?: string;
draftSnapshot?: NonNullable<typeof capturedDraftSnapshot>; draftSnapshot?: NonNullable<typeof capturedDraftSnapshot>;
delivery?: 'steer'; delivery?: 'steer';
} | undefined = (capturedTarget || capturedDraftSnapshot || delivery) } | undefined;
? { if (isBtwActive && btwSessionId && btwDirectory) {
...(capturedTarget ? { target: capturedTarget } : {}), sendMessageOptions = {
...(capturedDraftSnapshot ? { draftSnapshot: capturedDraftSnapshot } : {}), sessionId: btwSessionId,
...(delivery ? { delivery } : {}), directory: btwDirectory,
} };
: undefined; } else if (capturedTarget || capturedDraftSnapshot || delivery) {
sendMessageOptions = {};
if (capturedTarget) sendMessageOptions.target = capturedTarget;
if (capturedDraftSnapshot) sendMessageOptions.draftSnapshot = capturedDraftSnapshot;
}
if (delivery && sendMessageOptions) sendMessageOptions.delivery = delivery;
const preparedDocumentMentions = new Map<string, AttachedFile[]>(); const preparedDocumentMentions = new Map<string, AttachedFile[]>();
const reservedFilenames = new Set([ const reservedFilenames = new Set([
@@ -1096,9 +1100,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} }
// Inline review comments and synthetic context are consumed before // Inline review comments and synthetic context are consumed before
// assembly so a failed send can restore exactly what it took. // assembly so a failed send can restore exactly what it took. Context
// drafts ride with whichever send goes out next, including queued
// auto-sends: queueing leaves them in the store on purpose.
const syntheticParts = consumePendingSyntheticParts(); const syntheticParts = consumePendingSyntheticParts();
const consumedDraftTarget = queuedOnly ? null : inlineDraftTarget; const consumedDraftTarget = inlineDraftTarget;
const drafts: InlineCommentDraft[] = consumedDraftTarget const drafts: InlineCommentDraft[] = consumedDraftTarget
? consumeDrafts(consumedDraftTarget) ? consumeDrafts(consumedDraftTarget)
: []; : [];
@@ -1113,9 +1119,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
composerAttachments: attachedFiles, composerAttachments: attachedFiles,
inlineComments: drafts, inlineComments: drafts,
syntheticTexts: syntheticParts?.map((part) => part.text) ?? [], syntheticTexts: syntheticParts?.map((part) => part.text) ?? [],
linkedIssueContext: linkedIssue?.contextText ?? null, linkedIssue: linkedIssue
? { number: linkedIssue.number, title: linkedIssue.title, url: linkedIssue.url, contextText: linkedIssue.contextText }
: null,
linkedPr: linkedPr linkedPr: linkedPr
? { instructions: linkedPr.instructionsText, context: linkedPr.contextText } ? { number: linkedPr.number, title: linkedPr.title, url: linkedPr.url, instructions: linkedPr.instructionsText, context: linkedPr.contextText }
: null, : null,
}, { }, {
parseAgentMention: (text) => { parseAgentMention: (text) => {
@@ -1128,8 +1136,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}, },
sanitizeAttachments: sanitizeAttachmentsForSend, sanitizeAttachments: sanitizeAttachmentsForSend,
collectSkillNames: (text) => collectInlineSkillMentions(text, availableSkillNames), collectSkillNames: (text) => collectInlineSkillMentions(text, availableSkillNames),
appendComments: (text, comments) =>
appendInlineComments(text, comments as InlineCommentDraft[]),
buildSkillInstruction: buildSkillMentionInstruction, buildSkillInstruction: buildSkillMentionInstruction,
}); });
@@ -1196,6 +1202,40 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} }
return; return;
} }
if (commandName === 'btw' && currentSessionId) {
const question = argument.trim();
if (!question) {
toast.error(t('chat.btw.toast.emptyArgument'));
return;
}
const targetDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId)
|| currentDirectory
|| null;
if (!targetDirectory) {
toast.error(t('chat.btw.toast.createFailed'));
return;
}
try {
// A new btw replaces this session's current one: destroy
// the previous fork first so forks never accumulate.
if (btwSessionRef) {
await destroyBtwSession(btwSessionRef);
}
await startBtwSession({
parentSessionId: currentSessionId,
question,
directory: targetDirectory,
providerID: providerIdToSend,
modelID: modelIdToSend,
agent: agentNameToSend,
variant: variantToSend,
});
scrollToBottom?.();
} catch (error) {
toast.error(getSubmitErrorMessage(error, t('chat.btw.toast.createFailed')));
}
return;
}
// The rest render a visible prompt plus synthetic instructions and // The rest render a visible prompt plus synthetic instructions and
// send them as one message. // send them as one message.
@@ -1231,7 +1271,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} }
const currentSessionDirectory = capturedTarget?.directory ?? currentDirectory; const currentSessionDirectory = capturedTarget?.directory ?? currentDirectory;
const shouldAddResponseStyle = newSessionDraftOpen || (currentSessionId ? !hasUserMessages(currentSessionId, currentSessionDirectory) : false); // btw mode: the fork already carries the question plus full history,
// so the response-style instruction never applies there.
const shouldAddResponseStyle = !isBtwActive && (newSessionDraftOpen || (currentSessionId ? !hasUserMessages(currentSessionId, currentSessionDirectory) : false));
if (shouldAddResponseStyle) { if (shouldAddResponseStyle) {
const responseStyleInstruction = await fetchResponseStyleInstruction().catch(() => null); const responseStyleInstruction = await fetchResponseStyleInstruction().catch(() => null);
if (responseStyleInstruction) { if (responseStyleInstruction) {
@@ -1258,6 +1300,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
...additionalParts.flatMap(p => p.attachments ?? []), ...additionalParts.flatMap(p => p.attachments ?? []),
]; ];
// Arm the timeline anchor BEFORE the optimistic user row can commit;
// arming after (or a frame later) races the commit and the anchor
// never claims the new message.
scrollToBottom?.();
const sendPromise = sendMessage( const sendPromise = sendMessage(
primaryText, primaryText,
providerIdToSend, providerIdToSend,
@@ -1276,14 +1323,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} }
}; };
if (typeof window === 'undefined') {
scrollToBottom?.();
} else {
window.requestAnimationFrame(() => {
scrollToBottom?.();
});
}
void sendPromise.then(() => { void sendPromise.then(() => {
// Record what this session was pointed at, so the work-status panel // Record what this session was pointed at, so the work-status panel
// can show it as a context source long after the message scrolled // can show it as a context source long after the message scrolled
@@ -1401,7 +1440,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
// Primary action for send/queue button — respects selected follow-up behavior // Primary action for send/queue button — respects selected follow-up behavior
const handlePrimaryAction = React.useCallback(() => { const handlePrimaryAction = React.useCallback(() => {
const inputSnapshot = getCurrentInputSnapshot(); const inputSnapshot = getCurrentInputSnapshot();
const canQueue = inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning); const canQueue = !isBtwActive && inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && (currentSessionPhase !== 'idle' || autoReviewRunning);
if (followUpBehavior === 'queue' && canQueue) { if (followUpBehavior === 'queue' && canQueue) {
handleQueueMessage(); handleQueueMessage();
} else if (followUpBehavior === 'steer' && canQueue) { } else if (followUpBehavior === 'steer' && canQueue) {
@@ -1409,7 +1448,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} else { } else {
void handleSubmitRef.current(); void handleSubmitRef.current();
} }
}, [inputMode, getCurrentInputSnapshot, currentSessionId, sessionPhase, autoReviewRunning, followUpBehavior, handleQueueMessage]); }, [inputMode, getCurrentInputSnapshot, currentSessionId, currentSessionPhase, autoReviewRunning, followUpBehavior, handleQueueMessage, isBtwActive]);
// Draft welcome presets: submit immediately. // Draft welcome presets: submit immediately.
const submitPresetPrompt = React.useCallback((text: string, type: 'command' | 'skill') => { const submitPresetPrompt = React.useCallback((text: string, type: 'command' | 'skill') => {
@@ -1613,15 +1652,19 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
return; return;
} }
// Handle Enter/Ctrl+Enter based on selected follow-up behavior. // Handle Enter/Ctrl+Enter based on selected follow-up behavior. On
if (e.key === 'Enter' && !e.shiftKey && (!isMobile || e.ctrlKey || e.metaKey)) { // mobile, and in desktop focus mode, plain Enter writes a newline and
// only Cmd/Ctrl+Enter sends: both are surfaces for composing long
// prompts, where an accidental send costs more than an extra keypress.
const requiresModifierToSend = isMobile || isDesktopExpanded;
if (e.key === 'Enter' && !e.shiftKey && (!requiresModifierToSend || e.ctrlKey || e.metaKey)) {
e.preventDefault(); e.preventDefault();
const isCtrlEnter = e.ctrlKey || e.metaKey; const isCtrlEnter = e.ctrlKey || e.metaKey;
// Queueing / steering only works when there's an existing busy // Queueing / steering only works when there's an existing busy
// session (or an active auto-review run). // session (or an active auto-review run).
const canQueue = inputMode === 'normal' && hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning); const canQueue = !isBtwActive && inputMode === 'normal' && hasContent && currentSessionId && (currentSessionPhase !== 'idle' || autoReviewRunning);
if (followUpBehavior === 'queue') { if (followUpBehavior === 'queue') {
if (isCtrlEnter || !canQueue) { if (isCtrlEnter || !canQueue) {
@@ -1671,8 +1714,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
clearAbortPrompt(); clearAbortPrompt();
startAbortIndicator(); startAbortIndicator();
void abortCurrentOperation(currentSessionId || undefined); // btw mode: the stop button stops the fork's turn, not the main
}, [abortCurrentOperation, clearAbortPrompt, currentSessionId, startAbortIndicator]); // session's.
const abortTarget = isBtwActive && btwSessionId ? btwSessionId : currentSessionId;
void abortCurrentOperation(abortTarget || undefined);
}, [abortCurrentOperation, btwSessionId, clearAbortPrompt, currentSessionId, isBtwActive, startAbortIndicator]);
const handleCycleAgent = React.useCallback((direction: 1 | -1 = 1) => { const handleCycleAgent = React.useCallback((direction: 1 | -1 = 1) => {
const nextAgentName = getCycledPrimaryAgentName(agents, currentAgentName, direction); const nextAgentName = getCycledPrimaryAgentName(agents, currentAgentName, direction);
@@ -2369,6 +2415,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const chatSurfaceMode = useChatSurfaceMode(); const chatSurfaceMode = useChatSurfaceMode();
const isMiniChatSurface = chatSurfaceMode === 'mini-chat'; const isMiniChatSurface = chatSurfaceMode === 'mini-chat';
const showDesktopDraftPresentation = (newSessionDraftOpen || draftPresentationExiting)
&& !isDesktopExpanded
&& !isMobile
&& !isVSCode
&& !isMiniChatSurface;
const draftPresentationClassName = cn(
'transition-opacity duration-[120ms] ease-out motion-reduce:transition-none',
draftPresentationExiting && 'pointer-events-none opacity-0',
);
const hasPendingChanges = React.useMemo(() => { const hasPendingChanges = React.useMemo(() => {
if (isMiniChatSurface) { if (isMiniChatSurface) {
@@ -2382,7 +2437,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
React.useEffect(() => { React.useEffect(() => {
if (!showDraftTargetSelectors || !selectedDraftProject || !selectedDraftDirectory) { if (!showDraftTargetSelectors || !selectedDraftProject || selectedDraftProject.kind === 'chat' || !selectedDraftDirectory) {
return; return;
} }
if (newSessionDraft?.pendingWorktreeRequestId || newSessionDraft?.bootstrapPendingDirectory || newSessionDraft?.preserveDirectoryOverride) { if (newSessionDraft?.pendingWorktreeRequestId || newSessionDraft?.bootstrapPendingDirectory || newSessionDraft?.preserveDirectoryOverride) {
@@ -2546,8 +2601,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
)} )}
style={isMobile && inputBarOffset > 0 ? { marginBottom: `${inputBarOffset}px` } : undefined} style={isMobile && inputBarOffset > 0 ? { marginBottom: `${inputBarOffset}px` } : undefined}
> >
{newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? ( {showDesktopDraftPresentation ? (
<div className="chat-input-column mb-7 text-center"> <div className={cn('chat-input-column mb-7 text-center', draftPresentationClassName)}>
<h1 className="text-balance text-2xl font-normal tracking-tight text-foreground md:text-3xl"> <h1 className="text-balance text-2xl font-normal tracking-tight text-foreground md:text-3xl">
{renderDraftTitle( {renderDraftTitle(
draftProjectLabel draftProjectLabel
@@ -2567,16 +2622,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
<AutoReviewBanner /> <AutoReviewBanner />
{hasDrafts ? ( {hasDrafts ? (
<ComposerContextChips <ComposerContextChips
terminalDrafts={terminalContextDrafts}
reviewCount={reviewCount}
prCommentCount={prCommentCount}
prCheckCount={prCheckCount}
previewConsoleCount={previewConsoleCount}
previewAnnotationCount={previewAnnotationCount}
draftTarget={inlineDraftTarget} draftTarget={inlineDraftTarget}
onRemoveDraft={removeInlineCommentDraft}
onRemoveReviewDrafts={removeReviewDrafts}
onRemovePreviewDrafts={removePreviewDrafts}
colors={currentTheme.colors} colors={currentTheme.colors}
/> />
) : null} ) : null}
@@ -2610,29 +2656,30 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
sessionId={currentSessionId} sessionId={currentSessionId}
directory={currentSessionDirectoryForSync ?? currentDirectory} directory={currentSessionDirectoryForSync ?? currentDirectory}
/> />
<MemoStatusRow <MemoComposerStatusBar
showAbortStatus={showAbortStatus} showAbortStatus={showAbortStatus}
showAssistantStatus={false}
showTodos={composerStatusExtrasEnabled} showTodos={composerStatusExtrasEnabled}
leftAccessory={!composerStatusExtrasEnabled || newSessionDraftOpen || !hasPendingChanges leftAccessory={!composerStatusExtrasEnabled || newSessionDraftOpen || !hasPendingChanges
? null ? null
: <PendingChangesBar />} : <PendingChangesBar />}
/> />
{!isMobile && showDraftTargetSelectors && selectedDraftProject ? ( {!isMobile && (showDraftTargetSelectors || draftPresentationExiting) && selectedDraftProject ? (
<DraftTargetSelectors <div className={draftPresentationClassName}>
projects={draftProjects} <DraftTargetSelectors
selectedProject={selectedDraftProject} projects={draftProjects}
selectedDirectory={selectedDraftDirectory} selectedProject={selectedDraftProject}
selectedBranchLabel={selectedDraftBranchLabel} selectedDirectory={selectedDraftDirectory}
selectedBranchIsKnown={selectedDraftBranchIsKnown} selectedBranchLabel={selectedDraftBranchLabel}
projectRootBranchOption={projectRootBranchOption} selectedBranchIsKnown={selectedDraftBranchIsKnown}
worktreeBranchOptions={worktreeBranchOptions} projectRootBranchOption={projectRootBranchOption}
branchItems={draftBranchItems} worktreeBranchOptions={worktreeBranchOptions}
showBranchSelector={shouldShowDraftBranchSelector} branchItems={draftBranchItems}
onProjectChange={handleDraftProjectChange} showBranchSelector={shouldShowDraftBranchSelector}
onDirectoryChange={handleDraftDirectoryChange} onProjectChange={handleDraftProjectChange}
theme={currentTheme} onDirectoryChange={handleDraftDirectoryChange}
/> theme={currentTheme}
/>
</div>
) : null} ) : null}
{isMobile && showDraftTargetSelectors && selectedDraftProject ? ( {isMobile && showDraftTargetSelectors && selectedDraftProject ? (
<MobileDraftTargetTriggers <MobileDraftTargetTriggers
@@ -2799,11 +2846,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}} }}
onFocus={mobileShell.onEditorFocus} onFocus={mobileShell.onEditorFocus}
onBlur={mobileShell.onEditorBlur} onBlur={mobileShell.onEditorBlur}
placeholder={currentSessionId || newSessionDraftOpen placeholder={isBtwActive
? inputMode === 'shell' ? t('chat.btw.mainComposerPlaceholder')
? t('chat.chatInput.placeholder.shell') : currentSessionId || newSessionDraftOpen
: t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat') ? inputMode === 'shell'
: t('chat.chatInput.placeholder.selectSession')} ? t('chat.chatInput.placeholder.shell')
: t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat')
: t('chat.chatInput.placeholder.selectSession')}
editable={Boolean(currentSessionId || newSessionDraftOpen)} editable={Boolean(currentSessionId || newSessionDraftOpen)}
autoCorrect={isMobile} autoCorrect={isMobile}
autoCapitalize={isMobile ? 'sentences' : 'none'} autoCapitalize={isMobile ? 'sentences' : 'none'}
@@ -2896,12 +2945,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
/> />
) : null} ) : null}
</div> </div>
{newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? ( {showDesktopDraftPresentation ? (
<DraftPresetChips <DraftPresetChips
onSubmit={(starter) => submitPresetPrompt(starter.submitText, starter.ref.type)} onSubmit={(starter) => submitPresetPrompt(starter.submitText, starter.ref.type)}
className="chat-input-column mt-4" className={cn('chat-input-column mt-4', draftPresentationClassName)}
/> />
) : null} ) : null}
{currentSessionId ? <BtwPanel parentSessionId={currentSessionId} panel={btwPanel} /> : null}
</form> </form>
{/* Issue Picker Dialog */} {/* Issue Picker Dialog */}
+8 -152
View File
@@ -12,8 +12,8 @@ import { useSelectionStore } from '@/sync/selection-store';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useThemeSystem } from '@/contexts/useThemeSystem';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useChatSurfaceMode } from './useChatSurfaceMode';
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
import MessageBody from './message/MessageBody'; import MessageBody from './message/MessageBody';
import type { AgentMentionInfo } from './message/types'; import type { AgentMentionInfo } from './message/types';
import type { StreamPhase, ToolPopupContent } from './message/types'; import type { StreamPhase, ToolPopupContent } from './message/types';
@@ -131,8 +131,6 @@ interface ChatMessageProps {
info: Message; info: Message;
parts: Part[]; parts: Part[];
}; };
onContentChange?: (reason?: ContentChangeReason) => void;
animationHandlers?: AnimationHandlers;
scrollToBottom?: () => void; scrollToBottom?: () => void;
turnGroupingContext?: TurnGroupingContext; turnGroupingContext?: TurnGroupingContext;
assistantHeaderMessageId?: string; assistantHeaderMessageId?: string;
@@ -147,8 +145,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
message, message,
previousMessage, previousMessage,
nextMessage, nextMessage,
onContentChange,
animationHandlers,
turnGroupingContext, turnGroupingContext,
assistantHeaderMessageId, assistantHeaderMessageId,
isInActiveTurn = false, isInActiveTurn = false,
@@ -202,6 +198,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]); const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]);
const isUser = messageRole.isUser; const isUser = messageRole.isUser;
const chatSurfaceMode = useChatSurfaceMode();
const useExternalUserActionsRow = isUser && (isMobile || !stickyUserHeader); const useExternalUserActionsRow = isUser && (isMobile || !stickyUserHeader);
const showStickyInlineHoverRow = isUser && !isMobile && stickyUserHeader && !useExternalUserActionsRow; const showStickyInlineHoverRow = isUser && !isMobile && stickyUserHeader && !useExternalUserActionsRow;
@@ -703,30 +700,25 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
} }
if (errorName === 'SessionRetry') { if (errorName === 'SessionRetry') {
return { return {
text: `Opencode failed to send a message. Retry attempt info: \n\`${detail}\``, text: `Opencode failed to send a message. Retry attempt info: ${detail}`,
variant: 'info' as const,
}; };
} }
if (isLikelyProviderAuthFailure(detail)) { if (isLikelyProviderAuthFailure(detail)) {
return { return {
text: PROVIDER_AUTH_FAILURE_MESSAGE, text: PROVIDER_AUTH_FAILURE_MESSAGE,
variant: 'error' as const,
}; };
} }
if (detail.trim().toLowerCase() === 'aborted') { if (detail.trim().toLowerCase() === 'aborted') {
return { return {
text: 'The running turn was stopped before OpenCode could send the next message.', text: 'The running turn was stopped before OpenCode could send the next message.',
variant: 'info' as const,
}; };
} }
return { return {
text: `Opencode failed to send message with error:\n\`${detail}\``, text: `Opencode failed to send message with error: ${detail}`,
variant: 'error' as const,
}; };
}, [isUser, message.info]); }, [isUser, message.info]);
const assistantErrorText = assistantError?.text; const assistantErrorText = assistantError?.text;
const assistantErrorVariant = assistantError?.variant;
const messageTextContent = React.useMemo(() => { const messageTextContent = React.useMemo(() => {
if (isUser) { if (isUser) {
@@ -853,35 +845,12 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
}); });
}, [defaultOpenToolIds, effectiveExpandedTools, message.info.id]); }, [defaultOpenToolIds, effectiveExpandedTools, message.info.id]);
const resolvedAnimationHandlers = animationHandlers ?? null;
const hasAnnouncedAuxiliaryScrollRef = React.useRef(false);
const animationCompletedRef = React.useRef(false);
const hasRequestedReservationRef = React.useRef(false);
const animationStartNotifiedRef = React.useRef(false);
const hasTriggeredReservationOnceRef = React.useRef(false);
const hasEverStreamedRef = React.useRef(false); const hasEverStreamedRef = React.useRef(false);
React.useEffect(() => { React.useEffect(() => {
animationCompletedRef.current = false;
hasRequestedReservationRef.current = false;
animationStartNotifiedRef.current = false;
hasTriggeredReservationOnceRef.current = false;
hasAnnouncedAuxiliaryScrollRef.current = false;
hasEverStreamedRef.current = false; hasEverStreamedRef.current = false;
}, [message.info.id]); }, [message.info.id]);
const handleAuxiliaryContentComplete = React.useCallback(() => {
if (isUser) {
return;
}
if (hasAnnouncedAuxiliaryScrollRef.current) {
return;
}
hasAnnouncedAuxiliaryScrollRef.current = true;
onContentChange?.('structural');
}, [isUser, onContentChange]);
const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen); const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen);
const handleShowPopup = React.useCallback((content: ToolPopupContent) => { const handleShowPopup = React.useCallback((content: ToolPopupContent) => {
@@ -904,114 +873,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
hasEverStreamedRef.current = true; hasEverStreamedRef.current = true;
} }
const hasReasoningParts = React.useMemo(() => {
if (isUser) {
return false;
}
return visibleParts.some((part) => part.type === 'reasoning');
}, [isUser, visibleParts]);
const allowAnimation = shouldAnimateMessage && !isAnimationSettled && !isStreamingPhase && !hasEverStreamedRef.current; const allowAnimation = shouldAnimateMessage && !isAnimationSettled && !isStreamingPhase && !hasEverStreamedRef.current;
const shouldReserveAnimationSpace = !isUser && shouldAnimateMessage && assistantTextParts.length > 0 && !shouldCoordinateRendering;
React.useEffect(() => {
if (!resolvedAnimationHandlers?.onStreamingCandidate) {
return;
}
if (!shouldReserveAnimationSpace) {
if (hasRequestedReservationRef.current) {
if (hasReasoningParts && resolvedAnimationHandlers?.onReasoningBlock) {
resolvedAnimationHandlers.onReasoningBlock();
} else if (resolvedAnimationHandlers?.onReservationCancelled) {
resolvedAnimationHandlers.onReservationCancelled();
}
hasRequestedReservationRef.current = false;
}
return;
}
if (hasTriggeredReservationOnceRef.current) {
return;
}
hasTriggeredReservationOnceRef.current = true;
resolvedAnimationHandlers.onStreamingCandidate();
hasRequestedReservationRef.current = true;
}, [resolvedAnimationHandlers, shouldReserveAnimationSpace, hasReasoningParts]);
React.useEffect(() => {
if (!resolvedAnimationHandlers?.onAnimationStart) {
return;
}
if (!allowAnimation) {
return;
}
if (animationStartNotifiedRef.current) {
return;
}
resolvedAnimationHandlers.onAnimationStart();
animationStartNotifiedRef.current = true;
}, [resolvedAnimationHandlers, allowAnimation]);
React.useEffect(() => {
if (isUser) {
return;
}
const handler = resolvedAnimationHandlers?.onAnimatedHeightChange;
if (!handler) {
return;
}
const shouldTrackHeight = allowAnimation || shouldReserveAnimationSpace;
if (!shouldTrackHeight) {
return;
}
const element = messageContainerRef.current;
if (!element) {
return;
}
if (typeof window === 'undefined' || typeof ResizeObserver === 'undefined') {
handler(element.getBoundingClientRect().height);
return;
}
let rafId: number | null = null;
const notifyHeight = (height: number) => {
if (typeof window === 'undefined') {
handler(height);
return;
}
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
rafId = window.requestAnimationFrame(() => {
handler(height);
});
};
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) {
return;
}
notifyHeight(entry.contentRect.height);
});
observer.observe(element);
notifyHeight(element.getBoundingClientRect().height);
return () => {
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
rafId = null;
}
observer.disconnect();
};
}, [allowAnimation, isUser, resolvedAnimationHandlers, shouldReserveAnimationSpace]);
if (shouldHideUserMessage) { if (shouldHideUserMessage) {
return null; return null;
@@ -1044,7 +906,10 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
respectReducedMotion respectReducedMotion
> >
<div className={cn('relative flex justify-end', !isMobile ? 'group/user-shell' : undefined)}> <div className={cn('relative flex justify-end', !isMobile ? 'group/user-shell' : undefined)}>
<div className={cn('max-w-[85%]', showStickyInlineHoverRow ? 'pb-5' : undefined)}> {/* peek: the action row under the bubble is suppressed, so
reserve its gap to the next message here, OUTSIDE the
bubble background. */}
<div className={cn('max-w-[85%]', showStickyInlineHoverRow ? 'pb-5' : undefined, chatSurfaceMode === 'peek' ? 'pb-3' : undefined)}>
<div <div
style={{ style={{
backgroundColor: 'var(--chat-user-message-bg)', backgroundColor: 'var(--chat-user-message-bg)',
@@ -1070,13 +935,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
onShowPopup={handleShowPopup} onShowPopup={handleShowPopup}
streamPhase={streamPhase} streamPhase={streamPhase}
allowAnimation={allowAnimation} allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={false} shouldShowHeader={false}
hasTextContent={hasTextContent} hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage} onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage} copiedMessage={copiedMessage}
showReasoningTraces={showReasoningTraces} showReasoningTraces={showReasoningTraces}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
agentMention={agentMention} agentMention={agentMention}
onRevert={handleRevert} onRevert={handleRevert}
onFork={isUser ? handleFork : undefined} onFork={isUser ? handleFork : undefined}
@@ -1084,7 +947,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
contextPinPending={pinPending} contextPinPending={pinPending}
onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined} onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined}
errorMessage={assistantErrorText} errorMessage={assistantErrorText}
errorVariant={assistantErrorVariant}
userActionsMode={useExternalUserActionsRow ? 'external-content' : 'inline'} userActionsMode={useExternalUserActionsRow ? 'external-content' : 'inline'}
stickyUserHeaderEnabled={stickyUserHeader} stickyUserHeaderEnabled={stickyUserHeader}
/> />
@@ -1107,13 +969,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
onShowPopup={handleShowPopup} onShowPopup={handleShowPopup}
streamPhase={streamPhase} streamPhase={streamPhase}
allowAnimation={allowAnimation} allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={false} shouldShowHeader={false}
hasTextContent={hasTextContent} hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage} onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage} copiedMessage={copiedMessage}
showReasoningTraces={showReasoningTraces} showReasoningTraces={showReasoningTraces}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
agentMention={agentMention} agentMention={agentMention}
onRevert={handleRevert} onRevert={handleRevert}
onFork={isUser ? handleFork : undefined} onFork={isUser ? handleFork : undefined}
@@ -1121,7 +981,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
contextPinPending={pinPending} contextPinPending={pinPending}
onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined} onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined}
errorMessage={assistantErrorText} errorMessage={assistantErrorText}
errorVariant={assistantErrorVariant}
userActionsMode="external-actions" userActionsMode="external-actions"
stickyUserHeaderEnabled={stickyUserHeader} stickyUserHeaderEnabled={stickyUserHeader}
/> />
@@ -1154,17 +1013,14 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
onShowPopup={handleShowPopup} onShowPopup={handleShowPopup}
streamPhase={streamPhase} streamPhase={streamPhase}
allowAnimation={allowAnimation} allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={shouldShowHeader} shouldShowHeader={shouldShowHeader}
hasTextContent={hasTextContent} hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage} onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage} copiedMessage={copiedMessage}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
showReasoningTraces={showReasoningTraces} showReasoningTraces={showReasoningTraces}
agentMention={agentMention} agentMention={agentMention}
turnGroupingContext={turnGroupingContext} turnGroupingContext={turnGroupingContext}
errorMessage={assistantErrorText} errorMessage={assistantErrorText}
errorVariant={assistantErrorVariant}
reviewTransferDirection={reviewTransferDirection} reviewTransferDirection={reviewTransferDirection}
footerProviderID={headerProviderID} footerProviderID={headerProviderID}
footerModelName={headerModelName} footerModelName={headerModelName}
@@ -11,6 +11,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { isVSCodeRuntime } from '@/lib/desktop'; import { isVSCodeRuntime } from '@/lib/desktop';
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight'; import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
import { commandMatchesSearch, mergeCommandAutocompleteItems } from './commandAutocompleteItems'; import { commandMatchesSearch, mergeCommandAutocompleteItems } from './commandAutocompleteItems';
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
type CommandSource = 'openchamber' | 'opencode' | 'skill'; type CommandSource = 'openchamber' | 'opencode' | 'skill';
@@ -84,7 +85,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
const keyboardNavigationRef = React.useRef(false); const keyboardNavigationRef = React.useRef(false);
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const containerRef = React.useRef<HTMLDivElement | null>(null); const containerRef = React.useRef<HTMLDivElement | null>(null);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile); const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true);
const ignoreClickRef = React.useRef(false); const ignoreClickRef = React.useRef(false);
const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null); const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null);
const pointerMovedRef = React.useRef(false); const pointerMovedRef = React.useRef(false);
@@ -152,6 +153,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
: [] : []
), ),
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true }, { id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
...(hasSession
? [{ id: 'openchamber:btw', name: 'btw', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.btwDescription'), isOpenChamber: true }]
: []
),
...(hasSession ...(hasSession
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }] ? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }]
: [] : []
@@ -226,6 +231,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
: [] : []
), ),
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true }, { id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
...(hasSession
? [{ id: 'openchamber:btw', name: 'btw', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.btwDescription'), isOpenChamber: true }]
: []
),
...(hasSession ...(hasSession
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }] ? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }]
: [] : []
@@ -376,6 +385,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
const isSystem = command.isBuiltIn; const isSystem = command.isBuiltIn;
const isOpenChamberBadge = command.isOpenChamber; const isOpenChamberBadge = command.isOpenChamber;
return ( return (
<AutocompleteRowTooltip description={command.description} active={!isMobile && index === selectedIndex}>
<div <div
key={command.id} key={command.id}
ref={(el) => { itemRefs.current[index] = el; }} ref={(el) => { itemRefs.current[index] = el; }}
@@ -471,13 +481,9 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
</span> </span>
)} )}
</div> </div>
{command.description && !isMobile && (
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
{command.description}
</div>
)}
</div> </div>
</div> </div>
</AutocompleteRowTooltip>
); );
})} })}
{commands.length === 0 && ( {commands.length === 0 && (
@@ -0,0 +1,305 @@
import React from "react";
import { useSessionUIStore } from '@/sync/session-ui-store';
import { cn } from "@/lib/utils";
import { useDirectorySync } from "@/sync/sync-context";
import type { Todo } from "@opencode-ai/sdk/v2/client";
import { useUIStore } from "@/stores/useUIStore";
import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
import { isVSCodeRuntime } from "@/lib/desktop";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Icon } from "@/components/icon/Icon";
import { useI18n } from "@/lib/i18n";
// The bar that sits in the composer stack: pending-changes accessory, abort
// status, and the todos dropdown. Deliberately a separate component from
// StatusRow — that one is the floating assistant-status chip above the
// composer, and sharing markup meant every restyle of the chip (glass,
// placement) silently restyled this bar and its dropdown too.
type TodoItem = Todo & { id?: string };
const COMPOSER_STATUS_BAR_CONTAINER_STYLE = { containerType: "inline-size" as const, containerName: "composer-status-bar" };
const statusConfig = {
in_progress: { textClassName: "text-foreground" },
pending: { textClassName: "text-foreground" },
completed: { textClassName: "text-muted-foreground line-through" },
cancelled: { textClassName: "text-muted-foreground line-through" },
};
const priorityClassName = {
high: "text-[var(--status-warning)]",
medium: "text-muted-foreground",
low: "text-muted-foreground/70",
};
const priorityIcon = {
high: <Icon name="arrow-up-double" className="h-3.5 w-3.5" aria-hidden="true" />,
medium: <Icon name="arrow-up-s" className="h-3.5 w-3.5" aria-hidden="true" />,
low: <Icon name="arrow-down-s" className="h-3.5 w-3.5" aria-hidden="true" />,
};
const statusLabelKey = {
in_progress: "chat.statusRow.todo.status.inProgress",
pending: "chat.statusRow.todo.status.pending",
completed: "chat.statusRow.todo.status.completed",
cancelled: "chat.statusRow.todo.status.cancelled",
};
const priorityLabelKey = {
high: "chat.statusRow.todo.priority.high",
medium: "chat.statusRow.todo.priority.medium",
low: "chat.statusRow.todo.priority.low",
};
// SAFETY: todo.status / todo.priority arrive from the SDK as open strings;
// lookups treat them as candidate keys and every call site falls back to a
// default entry when the value is outside the known set.
const knownStatus = (status: string) =>
// SAFETY: candidate-key narrowing; misses resolve to undefined and callers fall back.
status as keyof typeof statusConfig;
const knownPriority = (priority: string) =>
// SAFETY: candidate-key narrowing; misses resolve to undefined and callers fall back.
priority as keyof typeof priorityClassName;
const TodoItemRow: React.FC<{ todo: TodoItem }> = ({ todo }) => {
const { t } = useI18n();
const config = statusConfig[knownStatus(todo.status)] || statusConfig.pending;
// SAFETY: the label keys are literal members of the i18n dictionary; the
// lookup narrows an open SDK string with a known fallback, and t() accepts
// only the generated key union.
const statusKey = (statusLabelKey[knownStatus(todo.status)] ?? statusLabelKey.pending) as Parameters<typeof t>[0];
// SAFETY: same literal-member narrowing as statusKey above.
const priorityKey = (priorityLabelKey[knownPriority(todo.priority)] ?? priorityLabelKey.medium) as Parameters<typeof t>[0];
const statusIcon =
todo.status === "in_progress" ? (
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" aria-hidden="true" />
) : todo.status === "completed" ? (
<Icon name="checkbox-circle" className="h-3.5 w-3.5 text-[var(--status-success)]" aria-hidden="true" />
) : (
<Icon name="time" className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
);
return (
<div className="flex items-center min-w-0 py-0.5 gap-2">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-shrink-0">{statusIcon}</span>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
{t(statusKey)}
</TooltipContent>
</Tooltip>
<span className={cn("flex-1 typography-ui-label", config.textClassName)}>
{todo.content}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"typography-meta flex items-center justify-center flex-shrink-0 leading-none",
priorityClassName[knownPriority(todo.priority)] ?? priorityClassName.medium,
)}
>
{priorityIcon[knownPriority(todo.priority)] ?? priorityIcon.medium}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{t(priorityKey)}
</TooltipContent>
</Tooltip>
</div>
);
};
const EMPTY_TODOS: TodoItem[] = [];
interface ComposerStatusBarProps {
showAbortStatus?: boolean;
showTodos?: boolean;
leftAccessory?: React.ReactNode;
}
export const ComposerStatusBar: React.FC<ComposerStatusBarProps> = ({
showAbortStatus,
showTodos = true,
leftAccessory,
}) => {
const { t } = useI18n();
const [isExpanded, setIsExpanded] = React.useState(false);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionDirectory = useSessionUIStore(
React.useCallback(
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
[currentSessionId],
),
);
const liveTodos = useDirectorySync(
React.useCallback(
(state) => {
if (!showTodos || !currentSessionId) return EMPTY_TODOS;
return state.todo[currentSessionId] ?? EMPTY_TODOS;
},
[currentSessionId, showTodos],
),
);
const persistedSessionTodos = useTodosPersistStore(
React.useCallback(
(state) => (showTodos && currentSessionId && currentSessionDirectory
? state.getSessionTodos(currentSessionDirectory, currentSessionId)
: undefined),
[currentSessionDirectory, currentSessionId, showTodos],
),
);
const todos: TodoItem[] = React.useMemo(() => {
if (!currentSessionId) return EMPTY_TODOS;
if (liveTodos.length > 0) return liveTodos;
return persistedSessionTodos ?? EMPTY_TODOS;
}, [liveTodos, persistedSessionTodos, currentSessionId]);
const isMobile = useUIStore((state) => state.isMobile);
const isCompact = isMobile || isVSCodeRuntime();
const visibleTodos = React.useMemo(() => {
return todos.filter((todo) => todo.status !== "cancelled");
}, [todos]);
const activeTodo = React.useMemo(() => {
return (
visibleTodos.find((todo) => todo.status === "in_progress") ||
visibleTodos.find((todo) => todo.status === "pending") ||
null
);
}, [visibleTodos]);
const progress = React.useMemo(() => {
const total = todos.filter((todo) => todo.status !== "cancelled").length;
const completed = todos.filter((todo) => todo.status === "completed").length;
return { completed, total };
}, [todos]);
const statusSummary = React.useMemo(() => {
const active = visibleTodos.filter((todo) => todo.status === "in_progress").length;
const left = visibleTodos.filter((todo) => todo.status === "in_progress" || todo.status === "pending").length;
return { active, left };
}, [visibleTodos]);
const hasTodoContent = showTodos && statusSummary.left > 0;
const hasLeftAccessory = Boolean(leftAccessory);
const hasContent = Boolean(showAbortStatus) || hasTodoContent || hasLeftAccessory;
const popoverRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!isExpanded) return;
const handleClickOutside = (event: MouseEvent) => {
// SAFETY: mousedown targets are DOM nodes; contains() only needs Node.
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
setIsExpanded(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isExpanded]);
const toggleExpanded = () => setIsExpanded((prev) => !prev);
const todoSummaryLabel = t('chat.statusRow.summary.activeLeft', {
active: statusSummary.active,
left: statusSummary.left,
});
const todoTrigger = hasTodoContent ? (
<button
type="button"
onClick={toggleExpanded}
className="flex items-center gap-1 flex-shrink-0 text-muted-foreground"
aria-label={todoSummaryLabel}
title={todoSummaryLabel}
>
{!isCompact && activeTodo ? (
<span className="composer-status-bar__active-todo typography-ui-label text-foreground truncate max-w-[200px]">
{activeTodo.content}
</span>
) : (
<span className="typography-ui-label">{t('chat.statusRow.tasksTitle')}</span>
)}
<span className="typography-meta flex items-center gap-1 tabular-nums" aria-hidden="true">
<span className="flex items-center gap-0.5">
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" />
{statusSummary.active}
</span>
<span>·</span>
<span className="flex items-center gap-0.5">
<Icon name="time" className="h-3.5 w-3.5" />
{statusSummary.left}
</span>
</span>
{isExpanded ? (
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
) : (
<Icon name="arrow-down-s" className="h-3.5 w-3.5" />
)}
</button>
) : null;
if (!hasContent) {
return null;
}
return (
<div className="mb-2" style={COMPOSER_STATUS_BAR_CONTAINER_STYLE}>
<div className={cn("flex items-center justify-between gap-2 h-8", hasLeftAccessory && "px-0.5")}>
{/* Left: abort status | pending-changes accessory */}
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
{showAbortStatus ? (
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
<span className="flex items-center gap-1.5 typography-ui-label">
<Icon name="close-circle" aria-hidden="true" />
{t('chat.statusRow.aborted')}
</span>
</div>
) : leftAccessory ? (
leftAccessory
) : null}
</div>
{/* Right: todos dropdown */}
<div className={cn("relative flex items-center gap-2 flex-shrink-0", hasLeftAccessory && "pr-1.5")} ref={popoverRef}>
{todoTrigger}
{isExpanded && hasTodoContent && (
<div
style={{
maxWidth: "min(28rem, calc(100cqw - 4ch))",
backgroundColor: "var(--surface-elevated)",
color: "var(--surface-elevated-foreground)",
}}
className={cn(
"absolute right-0 bottom-full mb-1 z-50",
"w-max min-w-[200px] rounded-xl p-1",
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)]",
"dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)]",
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
"duration-150",
)}
>
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
<span>{t('chat.statusRow.tasksTitle')}</span>
<span className="typography-meta tabular-nums">
{progress.completed}/{progress.total}
</span>
</div>
<div className="px-1 max-h-[200px] overflow-y-auto">
{visibleTodos.map((todo, index) => (
<TodoItemRow key={todo.id ?? `todo-${index}`} todo={todo} />
))}
</div>
</div>
)}
</div>
</div>
</div>
);
};
+28 -12
View File
@@ -3,17 +3,29 @@ import { cn } from '@/lib/utils';
import { getLanguageFromExtension } from '@/lib/toolHelpers'; import { getLanguageFromExtension } from '@/lib/toolHelpers';
import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useThemeSystem } from '@/contexts/useThemeSystem';
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars'; import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars';
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines'; import {
useWorkerHighlightedLines,
type WorkerHighlightedLinesResult,
} from '@/components/code/useWorkerHighlightedLines';
import { parseDiffToUnified } from './message/toolRenderers'; import { parseDiffToUnified } from './message/toolRenderers';
// One highlighted line: swaps in worker-tokenized inner HTML when ready, falls // Keep the line's layout stable while a cold worker request finishes. Plain
// back to plain text while loading or on failure. // text appears only if highlighting fails, avoiding a visible color flash.
const CodeLineContent: React.FC<{ content: string; html: string | undefined }> = ({ content, html }) => interface CodeLineContentProps {
html !== undefined ? ( content: string;
<span className="whitespace-pre-wrap break-all" dangerouslySetInnerHTML={{ __html: html }} /> html: string | undefined;
) : ( status: WorkerHighlightedLinesResult['status'];
<span className="whitespace-pre-wrap break-all">{content}</span> }
);
const CodeLineContent: React.FC<CodeLineContentProps> = ({ content, html, status }) => {
if (status === 'ready' && html !== undefined) {
return <span className="whitespace-pre-wrap break-all" dangerouslySetInnerHTML={{ __html: html }} />;
}
if (status === 'loading') {
return <span aria-hidden className="invisible whitespace-pre-wrap break-all">{content}</span>;
}
return <span className="whitespace-pre-wrap break-all">{content}</span>;
};
interface DiffPreviewProps { interface DiffPreviewProps {
diff: string; diff: string;
@@ -44,7 +56,7 @@ export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, filePath }) => {
<div> <div>
{hunk.lines.map((line, lineIdx) => { {hunk.lines.map((line, lineIdx) => {
const html = highlighted?.[lineCursor]; const html = highlighted.lines?.[lineCursor];
lineCursor += 1; lineCursor += 1;
return ( return (
<div <div
@@ -67,7 +79,7 @@ export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, filePath }) => {
{line.lineNumber || ''} {line.lineNumber || ''}
</span> </span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<CodeLineContent content={line.content} html={html} /> <CodeLineContent content={line.content} html={html} status={highlighted.status} />
</div> </div>
</div> </div>
); );
@@ -106,7 +118,11 @@ export const WritePreview: React.FC<WritePreviewProps> = ({ content, filePath })
{lineIdx + 1} {lineIdx + 1}
</span> </span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<CodeLineContent content={line || ' '} html={highlighted?.[lineIdx]} /> <CodeLineContent
content={line || ' '}
html={highlighted.lines?.[lineIdx]}
status={highlighted.status}
/>
</div> </div>
</div> </div>
))} ))}
@@ -2,6 +2,7 @@ import React, { useRef, memo } from 'react';
import { useInputStore } from '@/sync/input-store'; import { useInputStore } from '@/sync/input-store';
import type { AttachedFile } from '@/sync/session-ui-store'; import type { AttachedFile } from '@/sync/session-ui-store';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { openExternalUrl } from '@/lib/url'; import { openExternalUrl } from '@/lib/url';
@@ -833,7 +834,10 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
useUIStore.getState().navigateToDiagram(filePath); const directory = useDirectoryStore.getState().currentDirectory;
if (directory) {
useUIStore.getState().openContextFile(directory, filePath);
}
}} }}
className={cn( className={cn(
"flex items-center gap-2 p-2 rounded-lg border border-border/40 bg-muted/10 hover:bg-muted/20 transition-colors text-left cursor-pointer", "flex items-center gap-2 p-2 rounded-lg border border-border/40 bg-muted/10 hover:bg-muted/20 transition-colors text-left cursor-pointer",
@@ -14,6 +14,9 @@ import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight'; import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
import { mentionServerQuery, rankFileMentionResults } from './fileMentionResults';
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
type FileInfo = ProjectFileSearchHit; type FileInfo = ProjectFileSearchHit;
type AgentInfo = { type AgentInfo = {
@@ -80,7 +83,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
const measureRefs = React.useRef<(HTMLSpanElement | null)[]>([]); const measureRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
const containerRef = React.useRef<HTMLDivElement | null>(null); const containerRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useUIStore((state) => state.isMobile); const isMobile = useUIStore((state) => state.isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile); const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true);
const normalizedSearchQuery = (searchQuery ?? '').trim(); const normalizedSearchQuery = (searchQuery ?? '').trim();
const recentFiles = React.useMemo(() => { const recentFiles = React.useMemo(() => {
if (!projectRoot || !projectTabs) { if (!projectRoot || !projectTabs) {
@@ -93,14 +96,12 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
].filter((value): value is string => typeof value === 'string' && value.length > 0); ].filter((value): value is string => typeof value === 'string' && value.length > 0);
const seen = new Set<string>(); const seen = new Set<string>();
const queryLower = normalizedSearchQuery.toLowerCase();
const mapped = ordered const mapped = ordered
.filter((filePath) => { .filter((filePath) => {
if (seen.has(filePath)) return false; if (seen.has(filePath)) return false;
seen.add(filePath); seen.add(filePath);
const relative = filePath.startsWith(`${projectRoot}/`) ? filePath.slice(projectRoot.length + 1) : filePath; const relative = filePath.startsWith(`${projectRoot}/`) ? filePath.slice(projectRoot.length + 1) : filePath;
if (!queryLower) return true; return matchesRankQuery([relative], normalizedSearchQuery);
return relative.toLowerCase().includes(queryLower);
}) })
.slice(0, 6) .slice(0, 6)
.map((filePath) => { .map((filePath) => {
@@ -123,9 +124,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
() => normalizedSearchQuery.length > 0 ? agents : agents.slice(0, 2), () => normalizedSearchQuery.length > 0 ? agents : agents.slice(0, 2),
[agents, normalizedSearchQuery.length], [agents, normalizedSearchQuery.length],
); );
const visibleDirectories = directories;
const visibleRecentFiles = recentFiles; const visibleRecentFiles = recentFiles;
const visibleFiles = files; const visibleResults = React.useMemo(
() => rankFileMentionResults(files, directories, normalizedSearchQuery, 20),
[files, directories, normalizedSearchQuery],
);
React.useEffect(() => { React.useEffect(() => {
const handlePointerDown = (event: MouseEvent | TouchEvent) => { const handlePointerDown = (event: MouseEvent | TouchEvent) => {
@@ -151,13 +154,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
return; return;
} }
const normalizedQuery = (debouncedQuery ?? '').trim(); const serverQuery = mentionServerQuery(debouncedQuery ?? '');
const normalizedQueryLower = normalizedQuery
.replace(/^\.\//, '')
.replace(/^\/+/, '')
.toLowerCase();
if (!normalizedQueryLower) { if (!serverQuery) {
setFiles([]); setFiles([]);
return; return;
} }
@@ -166,7 +165,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
pendingSearchRef.current++; pendingSearchRef.current++;
setLoading(true); setLoading(true);
searchFiles(currentDirectory, normalizedQueryLower, 80, { searchFiles(currentDirectory, serverQuery, 80, {
includeHidden: showHidden, includeHidden: showHidden,
respectGitignore: !showGitignored, respectGitignore: !showGitignored,
type: 'file', type: 'file',
@@ -177,7 +176,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
} }
const recentSet = new Set(recentFiles.map((file) => file.path)); const recentSet = new Set(recentFiles.map((file) => file.path));
setFiles(hits.filter((hit) => !recentSet.has(hit.path)).slice(0, 15)); setFiles(hits.filter((hit) => !recentSet.has(hit.path)));
}) })
.catch(() => { .catch(() => {
if (!cancelled) { if (!cancelled) {
@@ -209,13 +208,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
return; return;
} }
const normalizedQuery = (debouncedQuery ?? '').trim(); const serverQuery = mentionServerQuery(debouncedQuery ?? '');
const normalizedQueryLower = normalizedQuery
.replace(/^\.\//, '')
.replace(/^\/+/, '')
.toLowerCase();
if (!normalizedQueryLower) { if (!serverQuery) {
setDirectories([]); setDirectories([]);
return; return;
} }
@@ -224,14 +219,14 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
pendingSearchRef.current++; pendingSearchRef.current++;
setLoading(true); setLoading(true);
searchFiles(currentDirectory, normalizedQueryLower, 20, { searchFiles(currentDirectory, serverQuery, 20, {
includeHidden: showHidden, includeHidden: showHidden,
respectGitignore: !showGitignored, respectGitignore: !showGitignored,
type: 'directory', type: 'directory',
}) })
.then((hits) => { .then((hits) => {
if (!cancelled) { if (!cancelled) {
setDirectories(hits.slice(0, 10)); setDirectories(hits);
} }
}) })
.catch(() => { .catch(() => {
@@ -260,28 +255,22 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
React.useEffect(() => { React.useEffect(() => {
const visibleAgents = getVisibleAgents(); const visibleAgents = getVisibleAgents();
const normalizedQuery = (searchQuery ?? '').trim().toLowerCase(); const subagents = visibleAgents
const filtered = visibleAgents
.filter((agent) => agent.mode && agent.mode !== 'primary') .filter((agent) => agent.mode && agent.mode !== 'primary')
.filter((agent) => {
if (!normalizedQuery) return true;
const haystack = `${agent.name} ${agent.description ?? ''}`.toLowerCase();
return haystack.includes(normalizedQuery);
})
.map((agent) => ({ .map((agent) => ({
name: agent.name, name: agent.name,
description: agent.description, description: agent.description,
mode: agent.mode, mode: agent.mode,
})) }))
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
setAgents(filtered); setAgents(rankByQuery(subagents, searchQuery ?? '', (agent) => [agent.name, agent.description]));
}, [getVisibleAgents, searchQuery]); }, [getVisibleAgents, searchQuery]);
React.useEffect(() => { React.useEffect(() => {
setSelectedIndex(0); setSelectedIndex(0);
setOverflowMap({}); setOverflowMap({});
setMarqueeDurations({}); setMarqueeDurations({});
}, [visibleFiles, visibleDirectories, visibleRecentFiles.length, visibleAgents.length]); }, [visibleResults, visibleRecentFiles.length, visibleAgents.length]);
React.useEffect(() => { React.useEffect(() => {
selectedIndexRef.current = selectedIndex; selectedIndexRef.current = selectedIndex;
@@ -331,7 +320,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
} }
window.removeEventListener('resize', updateOverflow); window.removeEventListener('resize', updateOverflow);
}; };
}, [visibleFiles, visibleDirectories]); }, [visibleResults]);
React.useEffect(() => { React.useEffect(() => {
const labelNode = labelRefs.current[selectedIndex]; const labelNode = labelRefs.current[selectedIndex];
@@ -375,7 +364,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
return; return;
} }
const total = visibleAgents.length + visibleDirectories.length + visibleRecentFiles.length + visibleFiles.length; const total = visibleAgents.length + visibleRecentFiles.length + visibleResults.length;
if (total === 0) { if (total === 0) {
return; return;
} }
@@ -399,24 +388,16 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
} }
return; return;
} }
const dirIndex = safeIndex - visibleAgents.length; const recentIndex = safeIndex - visibleAgents.length;
if (dirIndex < visibleDirectories.length) { const selectedFile = recentIndex < visibleRecentFiles.length
const dir = visibleDirectories[dirIndex]; ? visibleRecentFiles[recentIndex]
if (dir) { : visibleResults[recentIndex - visibleRecentFiles.length];
handleFileSelect(dir);
}
return;
}
const fileIndex = dirIndex - visibleDirectories.length;
const selectedFile = fileIndex < visibleRecentFiles.length
? visibleRecentFiles[fileIndex]
: visibleFiles[fileIndex - visibleRecentFiles.length];
if (selectedFile) { if (selectedFile) {
handleFileSelect(selectedFile); handleFileSelect(selectedFile);
} }
} }
} }
}), [visibleFiles, visibleDirectories, visibleRecentFiles, visibleAgents, onClose, handleFileSelect, handleAgentPick]); }), [visibleResults, visibleRecentFiles, visibleAgents, onClose, handleFileSelect, handleAgentPick]);
const getFileIcon = (file: FileInfo) => { const getFileIcon = (file: FileInfo) => {
const ext = file.extension?.toLowerCase(); const ext = file.extension?.toLowerCase();
@@ -458,6 +439,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
{visibleAgents.map((agent, index) => { {visibleAgents.map((agent, index) => {
const isSelected = selectedIndex === index; const isSelected = selectedIndex === index;
return ( return (
<AutocompleteRowTooltip description={agent.description} active={!isMobile && isSelected}>
<div <div
key={`agent-${agent.name}`} key={`agent-${agent.name}`}
ref={(el) => { itemRefs.current[index] = el; }} ref={(el) => { itemRefs.current[index] = el; }}
@@ -470,11 +452,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="font-semibold truncate">@{agent.name}</div> <div className="font-semibold truncate">@{agent.name}</div>
{agent.description && !isMobile ? (
<div className="typography-meta text-muted-foreground truncate">{agent.description}</div>
) : null}
</div> </div>
</div> </div>
</AutocompleteRowTooltip>
); );
})} })}
{visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && ( {visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && (
@@ -482,38 +462,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
{t('chat.fileMentionAutocomplete.searchMoreAgents')} {t('chat.fileMentionAutocomplete.searchMoreAgents')}
</div> </div>
)} )}
{visibleAgents.length > 0 && (visibleDirectories.length > 0 || visibleRecentFiles.length > 0 || visibleFiles.length > 0) && ( {visibleAgents.length > 0 && (visibleRecentFiles.length > 0 || visibleResults.length > 0) && (
<div className="my-1 border-t border-border/60" />
)}
{visibleDirectories.map((dir, index) => {
const rowIndex = visibleAgents.length + index;
const relativePath = dir.relativePath || dir.name;
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
const isSelected = selectedIndex === rowIndex;
return (
<div
key={`dir-${dir.path}`}
ref={(el) => { itemRefs.current[rowIndex] = el; }}
className={cn(
"flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg",
isSelected && "bg-interactive-selection"
)}
onClick={() => handleFileSelect(dir)}
onMouseMove={() => setSelectedIndex(rowIndex)}
>
<Icon name="folder-3-fill" className="h-3.5 w-3.5 text-primary/60" />
<span className="flex-1 min-w-0 truncate" aria-label={relativePath}>
{displayPath}
</span>
</div>
);
})}
{visibleDirectories.length > 0 && (visibleRecentFiles.length > 0 || visibleFiles.length > 0) && (
<div className="my-1 border-t border-border/60" /> <div className="my-1 border-t border-border/60" />
)} )}
{visibleRecentFiles.map((file, index) => { {visibleRecentFiles.map((file, index) => {
const rowIndex = visibleAgents.length + visibleDirectories.length + index; const rowIndex = visibleAgents.length + index;
const relativePath = file.relativePath || file.name; const relativePath = file.relativePath || file.name;
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 }); const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
const isSelected = selectedIndex === rowIndex; const isSelected = selectedIndex === rowIndex;
@@ -561,11 +514,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
</div> </div>
); );
})} })}
{visibleRecentFiles.length > 0 && visibleFiles.length > 0 && ( {visibleRecentFiles.length > 0 && visibleResults.length > 0 && (
<div className="my-1 border-t border-border/60" /> <div className="my-1 border-t border-border/60" />
)} )}
{visibleFiles.map((file, index) => { {visibleResults.map((file, index) => {
const rowIndex = visibleAgents.length + visibleDirectories.length + visibleRecentFiles.length + index; const rowIndex = visibleAgents.length + visibleRecentFiles.length + index;
const relativePath = file.relativePath || file.name; const relativePath = file.relativePath || file.name;
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 }); const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
const isSelected = selectedIndex === rowIndex; const isSelected = selectedIndex === rowIndex;
@@ -582,7 +535,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
onClick={() => handleFileSelect(file)} onClick={() => handleFileSelect(file)}
onMouseMove={() => setSelectedIndex(rowIndex)} onMouseMove={() => setSelectedIndex(rowIndex)}
> >
{getFileIcon(file)} {file.kind === 'directory'
? <Icon name="folder-3-fill" className="h-3.5 w-3.5 text-primary/60" />
: getFileIcon(file)}
<span <span
ref={(el) => { labelRefs.current[rowIndex] = el; }} ref={(el) => { labelRefs.current[rowIndex] = el; }}
className="relative flex-1 min-w-0 overflow-hidden file-mention-marquee-container" className="relative flex-1 min-w-0 overflow-hidden file-mention-marquee-container"
@@ -613,12 +568,12 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
); );
return ( return (
<React.Fragment key={file.path}> <React.Fragment key={`${file.kind}-${file.path}`}>
{item} {item}
</React.Fragment> </React.Fragment>
); );
})} })}
{visibleFiles.length === 0 && visibleDirectories.length === 0 && visibleRecentFiles.length === 0 && visibleAgents.length === 0 && ( {visibleResults.length === 0 && visibleRecentFiles.length === 0 && visibleAgents.length === 0 && (
<div className="px-3 py-2 typography-ui-label text-muted-foreground"> <div className="px-3 py-2 typography-ui-label text-muted-foreground">
{t('chat.fileMentionAutocomplete.empty')} {t('chat.fileMentionAutocomplete.empty')}
</div> </div>
@@ -0,0 +1,562 @@
import { afterAll, describe, expect, test } from 'bun:test';
import { Window } from 'happy-dom';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { TextPart } from '@opencode-ai/sdk/v2';
type OperationCounts = {
innerHTMLWrites: number;
spriteIconInnerHTMLWrites: number;
querySelectorAllCalls: number;
appendCalls: number;
replaceCalls: number;
removeCalls: number;
getBoundingClientRectCalls: number;
viewBoxWrites: number;
resizeObserverCreates: number;
resizeObserverObserveCalls: number;
geometrySequence: Array<'read' | 'write'>;
};
type FixtureMetrics = OperationCounts & {
renderers: number;
markdownBlocks: number;
mermaidBlocks: number;
mermaidRenderedCount: number;
mermaidSvgCount: number;
};
const fixture = [
'# Synthetic mount fixture',
'',
'A paragraph with **bold text**, a table, and a stable link.',
'',
'| name | value |',
'| --- | ---: |',
'| alpha | 1 |',
'| beta | 2 |',
'',
'```typescript',
'const answer = 42;',
'console.log(answer);',
'```',
'',
'```mermaid',
'graph TD',
' A[Start] --> B[Finish]',
'```',
'',
'```mermaid',
'graph LR',
' Client[Client] --> Server[Server]',
'```',
].join('\n');
const fixtureWorkload = {
rendererCount: 3,
domBlocksPerRenderer: 1,
mermaidBlocksPerRenderer: 2,
};
let windowInstance: Window;
let previousGlobals: Map<string, PropertyDescriptor | undefined>;
let activeCounts: OperationCounts | null = null;
let animationFrameQueue: FrameRequestCallback[] = [];
let notifyResize: ((entries: Array<{ target: Element; contentRect: { width: number; height: number } }>) => void) | null = null;
let MarkdownRenderer: React.ComponentType<{
content: string;
messageId: string;
part?: TextPart;
isAnimated?: boolean;
isStreaming?: boolean;
enableFileReferences?: boolean;
}>;
let clearDetachedMarkdownDomCache: () => void;
let detachedMarkdownDomCacheStats: () => { sessions: number; entries: number };
const makeCounts = (): OperationCounts => ({
innerHTMLWrites: 0,
spriteIconInnerHTMLWrites: 0,
querySelectorAllCalls: 0,
appendCalls: 0,
replaceCalls: 0,
removeCalls: 0,
getBoundingClientRectCalls: 0,
viewBoxWrites: 0,
resizeObserverCreates: 0,
resizeObserverObserveCalls: 0,
geometrySequence: [],
});
const installGlobal = (name: string, value: Window[keyof Window]): void => {
previousGlobals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
};
const waitForSettledEffects = async (): Promise<void> => {
await new Promise<void>((resolve) => setTimeout(resolve, 25));
await Promise.resolve();
};
const flushAnimationFrame = async (): Promise<void> => {
const callbacks = animationFrameQueue;
animationFrameQueue = [];
await act(async () => {
for (const callback of callbacks) callback(windowInstance.performance.now());
await Promise.resolve();
});
};
const flushDeferredMermaidInitialization = async (): Promise<void> => {
await flushAnimationFrame();
await flushAnimationFrame();
};
const mountFixture = async (rendererCount: number): Promise<{
root: Root;
host: HTMLDivElement;
operations: OperationCounts;
counts: FixtureMetrics;
}> => {
const counts = makeCounts();
activeCounts = counts;
const host = document.createElement('div');
document.body.replaceChildren(host);
const root = createRoot(host);
await act(async () => {
root.render(
<>
{Array.from({ length: rendererCount }, (_, index) => (
<MarkdownRenderer
key={`fixture-${index}`}
content={fixture}
messageId={`fixture-message-${index}`}
isAnimated={false}
enableFileReferences={false}
/>
))}
</>,
);
await waitForSettledEffects();
});
await act(async () => waitForSettledEffects());
const mermaidBlocks = host.querySelectorAll('[data-markdown="mermaid-block"]').length;
const mermaidRenderedCount = host.querySelectorAll('[data-mermaid-render]').length;
const mermaidSvgCount = host.querySelectorAll('[data-markdown="mermaid"] svg').length;
return {
root,
host,
operations: counts,
counts: {
...counts,
renderers: rendererCount,
markdownBlocks: host.querySelectorAll('[data-md-block]').length,
mermaidBlocks,
mermaidRenderedCount,
mermaidSvgCount,
},
};
};
const runFixture = async (rendererCount: number): Promise<FixtureMetrics> => {
const { root, host, operations } = await mountFixture(rendererCount);
await flushDeferredMermaidInitialization();
const counts: FixtureMetrics = {
...operations,
renderers: rendererCount,
markdownBlocks: host.querySelectorAll('[data-md-block]').length,
mermaidBlocks: host.querySelectorAll('[data-markdown="mermaid-block"]').length,
mermaidRenderedCount: host.querySelectorAll('[data-mermaid-render]').length,
mermaidSvgCount: host.querySelectorAll('[data-markdown="mermaid"] svg').length,
};
await act(async () => root.unmount());
return counts;
};
const initializePerformanceDom = async (): Promise<void> => {
windowInstance = new Window({ url: 'http://localhost/' });
windowInstance.document.write('<!doctype html><html><head></head><body></body></html>');
windowInstance.document.close();
previousGlobals = new Map();
installGlobal('window', windowInstance);
installGlobal('document', windowInstance.document);
installGlobal('navigator', windowInstance.navigator);
installGlobal('customElements', windowInstance.customElements);
for (const name of ['Document', 'Element', 'HTMLElement', 'SVGElement', 'Node', 'Text', 'NodeFilter', 'MutationObserver', 'DOMParser', 'XMLSerializer', 'HTMLAnchorElement', 'HTMLButtonElement']) {
// SAFETY: these names are the DOM constructors installed by this happy-dom Window.
const globalValue = windowInstance[name as keyof Window];
if (globalValue === undefined) throw new Error(`happy-dom global is unavailable: ${name}`);
installGlobal(name, globalValue);
}
Object.defineProperty(windowInstance, 'matchMedia', { configurable: true, value: () => ({ matches: false, media: '', onchange: null, addListener: () => undefined, removeListener: () => undefined, addEventListener: () => undefined, removeEventListener: () => undefined, dispatchEvent: () => false }) });
Object.defineProperty(windowInstance, 'requestAnimationFrame', { configurable: true, value: (callback: FrameRequestCallback) => {
animationFrameQueue.push(callback);
return animationFrameQueue.length;
} });
Object.defineProperty(windowInstance, 'cancelAnimationFrame', { configurable: true, value: () => undefined });
installGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const elementPrototype = Element.prototype;
const nodePrototype = Node.prototype;
const documentPrototype = Document.prototype;
const innerHTMLDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML');
if (!innerHTMLDescriptor?.set || !innerHTMLDescriptor.get) throw new Error('happy-dom innerHTML descriptor unavailable');
Object.defineProperty(Element.prototype, 'innerHTML', {
configurable: true,
get: innerHTMLDescriptor.get,
set(value: string) {
if (activeCounts) {
activeCounts.innerHTMLWrites += 1;
if (value.includes('href="#oc-')) activeCounts.spriteIconInnerHTMLWrites += 1;
}
innerHTMLDescriptor.set?.call(this, value);
},
});
const originalQuerySelectorAll = elementPrototype.querySelectorAll;
Object.defineProperty(elementPrototype, 'querySelectorAll', { configurable: true, value: function (selectors: string): NodeListOf<Element> {
if (activeCounts) activeCounts.querySelectorAllCalls += 1;
return originalQuerySelectorAll.call(this, selectors);
} });
const originalDocumentQuerySelectorAll = documentPrototype.querySelectorAll;
Object.defineProperty(documentPrototype, 'querySelectorAll', { configurable: true, value: function (selectors: string): NodeListOf<Element> {
if (activeCounts) activeCounts.querySelectorAllCalls += 1;
return originalDocumentQuerySelectorAll.call(this, selectors);
} });
const originalAppendChild = nodePrototype.appendChild;
Object.defineProperty(nodePrototype, 'appendChild', { configurable: true, value: function (node: Node): Node {
if (activeCounts) activeCounts.appendCalls += 1;
return originalAppendChild.call(this, node);
} });
const originalReplaceWith = elementPrototype.replaceWith;
Object.defineProperty(elementPrototype, 'replaceWith', { configurable: true, value: function (...nodes: (Node | string)[]): void {
if (activeCounts) activeCounts.replaceCalls += 1;
return originalReplaceWith.apply(this, nodes);
} });
const originalRemove = elementPrototype.remove;
Object.defineProperty(elementPrototype, 'remove', { configurable: true, value: function (): void {
if (activeCounts) activeCounts.removeCalls += 1;
return originalRemove.call(this);
} });
const originalGetBoundingClientRect = elementPrototype.getBoundingClientRect;
Object.defineProperty(elementPrototype, 'getBoundingClientRect', { configurable: true, value: function (): DOMRect {
if (activeCounts) {
activeCounts.getBoundingClientRectCalls += 1;
activeCounts.geometrySequence.push('read');
}
return originalGetBoundingClientRect.call(this);
} });
const svgSetAttribute = SVGElement.prototype.setAttribute;
Object.defineProperty(SVGElement.prototype, 'setAttribute', { configurable: true, value: function (name: string, value: string): void {
if (name === 'viewBox' && activeCounts && this.closest('[data-markdown="mermaid"]')) {
activeCounts.viewBoxWrites += 1;
activeCounts.geometrySequence.push('write');
}
return svgSetAttribute.call(this, name, value);
} });
class CountingResizeObserver {
constructor(callback: (entries: Array<{ target: Element; contentRect: { width: number; height: number } }>) => void) {
if (activeCounts) activeCounts.resizeObserverCreates += 1;
notifyResize = callback;
}
observe(): void {
if (activeCounts) activeCounts.resizeObserverObserveCalls += 1;
}
unobserve(): void {}
disconnect(): void {}
}
installGlobal('ResizeObserver', CountingResizeObserver);
const fakeState = {
openContextPreview: () => undefined,
codeBlockLineWrap: false,
mermaidRenderingMode: 'svg',
};
type UIStateSelection = typeof fakeState[keyof typeof fakeState];
const { mock } = await import('bun:test');
mock.module('@/lib/utils', () => ({ cn: (...values: string[]) => values.filter(Boolean).join(' ') }));
mock.module('@/lib/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }));
mock.module('@/contexts/useThemeSystem', () => ({ useOptionalThemeSystem: () => null }));
mock.module('@/stores/useUIStore', () => ({ useUIStore: Object.assign((selector: (state: typeof fakeState) => UIStateSelection) => selector(fakeState), { getState: () => fakeState }) }));
mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => null }));
mock.module('@/hooks/useRuntimeAPIs', () => ({ useRuntimeAPIs: () => ({ editor: undefined, runtime: { isVSCode: false } }) }));
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch: async () => ({ ok: false }) }));
mock.module('@/lib/url', () => ({ getUrlScheme: () => null, isAppLinkUrl: () => false, isExternalHttpUrl: () => false, openConfirmedAppLinkUrl: async () => false, openExternalUrl: async () => undefined, getExternalFaviconUrl: () => null, isLoopbackHttpUrl: () => false }));
mock.module('@/lib/desktop', () => ({ isDesktopLocalOriginActive: () => false, isDesktopShell: () => false, isVSCodeRuntime: () => false }));
mock.module('@/lib/runtimeSurface', () => ({ isMobileSurfaceRuntime: () => false }));
mock.module('@/lib/outsideFileGrants', () => ({ ensureOutsideFileGrantForDesktop: async () => undefined }));
mock.module('@/lib/path-utils', () => ({ getDirectoryForFilePath: () => '', isFilePathWithinDirectory: () => true, toAbsoluteFilePath: () => '', normalizeFilePath: (value: string) => value, isAbsoluteFilePath: (value: string) => value.startsWith('/') }));
mock.module('@/lib/clipboard', () => ({ copyTextToClipboard: async () => undefined }));
mock.module('beautiful-mermaid', () => ({
renderMermaidASCII: () => 'diagram',
renderMermaidSVG: () => '<svg viewBox="0 0 240 120" width="240" height="120"><path d="M0 0h1v1z" /></svg>',
}));
mock.module('@/stores/utils/streamDebug', () => ({ streamPerfCount: () => undefined, streamPerfObserve: () => undefined }));
mock.module('./markdown/markdown-worker', () => ({
highlightCodeInWorker: async () => null,
highlightLinesInWorker: async () => null,
highlightTokensInWorker: async () => null,
}));
mock.module('./message/FadeInOnReveal', () => ({ FadeInOnReveal: ({ children }: { children: React.ReactNode }) => children }));
const imported = await import('./MarkdownRendererImpl');
MarkdownRenderer = imported.MarkdownRenderer;
const { detachedMarkdownDomCache } = await import('./markdown/detachedMarkdownDomCache');
clearDetachedMarkdownDomCache = () => detachedMarkdownDomCache.clear();
detachedMarkdownDomCacheStats = () => detachedMarkdownDomCache.stats();
};
await initializePerformanceDom();
afterAll(() => {
for (const [name, descriptor] of previousGlobals) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
});
describe('MarkdownRenderer DOM mount performance contract', () => {
test('builds Markdown sprite controls without parsing SVG markup', async () => {
const mounted = await mountFixture(1);
const spriteControlCount = mounted.host.querySelectorAll('[data-md-action] use[href^="#oc-"]').length;
const spriteIconInnerHTMLWrites = mounted.operations.spriteIconInnerHTMLWrites;
await act(async () => mounted.root.unmount());
expect(spriteControlCount).toBeGreaterThan(0);
expect(spriteIconInnerHTMLWrites).toBe(0);
});
test('reuses settled Markdown DOM without parsing or decorating it again', async () => {
clearDetachedMarkdownDomCache();
const content = '# Cached viewport\n\nA settled paragraph.';
const part: TextPart = {
id: 'part-cache',
sessionID: 'session-cache',
messageID: 'message-cache',
type: 'text',
text: content,
time: { start: 0, end: 1 },
};
const host = document.createElement('div');
document.body.replaceChildren(host);
const render = (root: Root) => root.render(
<MarkdownRenderer
content={content}
messageId="message-cache"
part={part}
isAnimated={false}
enableFileReferences={false}
/>,
);
const firstCounts = makeCounts();
activeCounts = firstCounts;
const firstRoot = createRoot(host);
await act(async () => {
render(firstRoot);
await waitForSettledEffects();
});
const originalBlock = host.querySelector('[data-md-block]');
expect(originalBlock).not.toBeNull();
expect(firstCounts.innerHTMLWrites).toBeGreaterThan(0);
await act(async () => firstRoot.unmount());
const secondCounts = makeCounts();
activeCounts = secondCounts;
const secondRoot = createRoot(host);
await act(async () => {
render(secondRoot);
await waitForSettledEffects();
});
expect(host.querySelector('[data-md-block]')).toBe(originalBlock);
expect(secondCounts.innerHTMLWrites).toBe(0);
await act(async () => secondRoot.unmount());
clearDetachedMarkdownDomCache();
});
test('does not cache streaming, unfinished, or Mermaid DOM', async () => {
clearDetachedMarkdownDomCache();
const host = document.createElement('div');
document.body.replaceChildren(host);
const renderScoped = (
root: Root,
content: string,
partId: string,
isStreaming = false,
) => root.render(
<MarkdownRenderer
content={content}
messageId="message-cache"
part={{
id: partId,
sessionID: 'session-cache',
messageID: 'message-cache',
type: 'text',
text: content,
time: { start: 0, end: 1 },
}}
isAnimated={false}
isStreaming={isStreaming}
enableFileReferences={false}
/>,
);
const streamingRoot = createRoot(host);
await act(async () => {
renderScoped(streamingRoot, 'streaming content', 'part-streaming', true);
await waitForSettledEffects();
});
await act(async () => streamingRoot.unmount());
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
const unfinalizedRoot = createRoot(host);
await act(async () => {
unfinalizedRoot.render(
<MarkdownRenderer
content="unfinalized content"
messageId="message-unfinalized"
part={{
id: 'part-unfinalized',
sessionID: 'session-cache',
messageID: 'message-unfinalized',
type: 'text',
text: 'unfinalized content',
}}
isAnimated={false}
enableFileReferences={false}
/>,
);
await waitForSettledEffects();
});
await act(async () => unfinalizedRoot.unmount());
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
const mermaidRoot = createRoot(host);
await act(async () => {
renderScoped(mermaidRoot, '```mermaid\ngraph TD\nA --> B\n```', 'part-mermaid');
await waitForSettledEffects();
});
await act(async () => mermaidRoot.unmount());
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
clearDetachedMarkdownDomCache();
});
test('does not detach Markdown DOM that intersects the active selection', async () => {
clearDetachedMarkdownDomCache();
const content = 'selected content';
const host = document.createElement('div');
document.body.replaceChildren(host);
const root = createRoot(host);
await act(async () => {
root.render(
<MarkdownRenderer
content={content}
messageId="message-selected"
part={{
id: 'part-selected',
sessionID: 'session-selected',
messageID: 'message-selected',
type: 'text',
text: content,
time: { start: 0, end: 1 },
}}
isAnimated={false}
enableFileReferences={false}
/>,
);
await waitForSettledEffects();
});
const markdown = host.querySelector<HTMLElement>('[data-markdown-content]');
if (!markdown) throw new Error('Expected mounted Markdown content');
const originalGetSelection = window.getSelection;
Object.defineProperty(window, 'getSelection', {
configurable: true,
value: () => ({
rangeCount: 1,
isCollapsed: false,
getRangeAt: () => ({ intersectsNode: (node: Node) => node === markdown }),
}),
});
try {
await act(async () => root.unmount());
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
} finally {
Object.defineProperty(window, 'getSelection', { configurable: true, value: originalGetSelection });
clearDetachedMarkdownDomCache();
}
});
test('defers and batches Mermaid controller initialization after Markdown mount', async () => {
const mounted = await mountFixture(fixtureWorkload.rendererCount);
const critical = mounted.counts;
expect(critical.getBoundingClientRectCalls).toBe(0);
expect(critical.viewBoxWrites).toBe(0);
expect(critical.resizeObserverCreates).toBe(0);
expect(mounted.host.querySelectorAll('[data-markdown="mermaid"] svg')).toHaveLength(6);
await flushDeferredMermaidInitialization();
const metrics = {
...mounted.operations,
renderers: fixtureWorkload.rendererCount,
markdownBlocks: mounted.host.querySelectorAll('[data-md-block]').length,
mermaidBlocks: mounted.host.querySelectorAll('[data-markdown="mermaid-block"]').length,
mermaidRenderedCount: mounted.host.querySelectorAll('[data-mermaid-render]').length,
mermaidSvgCount: mounted.host.querySelectorAll('[data-markdown="mermaid"] svg').length,
};
expect(metrics.renderers).toBe(3);
expect(metrics.markdownBlocks).toBe(fixtureWorkload.rendererCount * fixtureWorkload.domBlocksPerRenderer);
expect(metrics.mermaidBlocks).toBe(fixtureWorkload.rendererCount * fixtureWorkload.mermaidBlocksPerRenderer);
expect(metrics.mermaidRenderedCount).toBeGreaterThan(0);
expect(metrics.innerHTMLWrites).toBeGreaterThan(0);
expect(metrics.querySelectorAllCalls).toBeGreaterThan(0);
expect(metrics.appendCalls).toBeGreaterThan(0);
expect(metrics.getBoundingClientRectCalls).toBe(metrics.mermaidRenderedCount);
expect(metrics.viewBoxWrites).toBe(metrics.mermaidRenderedCount);
expect(metrics.resizeObserverCreates).toBe(1);
expect(metrics.resizeObserverObserveCalls).toBe(metrics.mermaidRenderedCount);
expect(metrics.geometrySequence.lastIndexOf('read')).toBeLessThan(metrics.geometrySequence.indexOf('write'));
const viewport = mounted.host.querySelector<HTMLElement>('[data-markdown="mermaid-viewport"]');
if (!viewport || !notifyResize) throw new Error('Expected initialized Mermaid viewport and shared observer');
const readsBeforeResize = mounted.operations.getBoundingClientRectCalls;
const writesBeforeResize = mounted.operations.viewBoxWrites;
notifyResize([{ target: viewport, contentRect: { width: 320, height: 180 } }]);
expect(mounted.operations.getBoundingClientRectCalls).toBe(readsBeforeResize);
expect(mounted.operations.viewBoxWrites).toBe(writesBeforeResize + 1);
console.log(JSON.stringify({ fixture: fixtureWorkload, baseline: metrics }));
await act(async () => mounted.root.unmount());
});
test('cancels deferred Mermaid initialization when the renderer unmounts first', async () => {
const mounted = await mountFixture(1);
await act(async () => mounted.root.unmount());
await flushDeferredMermaidInitialization();
expect(mounted.operations.getBoundingClientRectCalls).toBe(0);
expect(mounted.operations.viewBoxWrites).toBe(0);
expect(mounted.operations.resizeObserverCreates).toBe(0);
});
test('keeps DOM operation fanout linear when renderer count doubles', async () => {
const three = await runFixture(3);
const six = await runFixture(6);
expect(six.mermaidBlocks).toBe(three.mermaidBlocks * 2);
expect(six.mermaidRenderedCount).toBe(three.mermaidRenderedCount * 2);
expect(six.innerHTMLWrites).toBeLessThanOrEqual(three.innerHTMLWrites * 2 + 6);
expect(six.querySelectorAllCalls).toBeLessThanOrEqual(three.querySelectorAllCalls * 2 + 12);
expect(six.appendCalls).toBeLessThanOrEqual(three.appendCalls * 2 + 12);
expect(six.getBoundingClientRectCalls).toBe(three.getBoundingClientRectCalls * 2);
expect(six.viewBoxWrites).toBe(three.viewBoxWrites * 2);
expect(three.resizeObserverCreates).toBe(1);
expect(six.resizeObserverCreates).toBe(1);
expect(six.resizeObserverObserveCalls).toBe(three.resizeObserverObserveCalls * 2);
});
});
@@ -1,9 +1,377 @@
import { describe, expect, test } from 'bun:test'; import { describe, expect, mock, test } from 'bun:test';
import { localPathFromFileUrl, parseFileReference, type ParsedFileReference } from './fileReferenceParser'; import { localPathFromFileUrl, parseFileReference, type ParsedFileReference } from './fileReferenceParser';
const parse = (value: string): ParsedFileReference | null => parseFileReference(value); const parse = (value: string): ParsedFileReference | null => parseFileReference(value);
type FakeElement = {
childNodes: FakeElement[];
children: FakeElement[];
parentNode: FakeElement | null;
attributes: Map<string, string>;
style: { display: string; setProperty: () => void };
innerHTML: string;
setAttribute: (name: string, value: string) => void;
getAttribute: (name: string) => string | null;
appendChild: (child: FakeElement) => FakeElement;
replaceWith: (replacement: FakeElement) => void;
remove: () => void;
querySelector: (selector: string) => FakeElement | null;
querySelectorAll: <T>(selector: string) => T[];
addEventListener: () => void;
removeEventListener: () => void;
contains: (child: FakeElement) => boolean;
isEqualNode: () => boolean;
};
type FakeDocument = { createElement: () => FakeElement };
type FakeJsxProps = {
ref?: { current: FakeElement | null };
children?: FakeElement | FakeElement[];
className?: string;
'data-markdown-content'?: boolean;
};
let syncRenderCalls = 0;
let morphCalls = 0;
let decorateCalls = 0;
let mermaidRegistryCreates = 0;
let mermaidRegistryCleanups = 0;
let cachedRendererBlocks: Array<{ id: string; html: string }> | null = null;
let renderedRendererBlocks: Array<{ id: string; html: string }> = [];
let renderMarkdownBlocksForTest = async () => renderedRendererBlocks;
let currentContextVersion = 0;
const layoutEffects: Array<() => void> = [];
const passiveEffects: Array<() => void | (() => void)> = [];
let hookCursor = 0;
let hookStates: Array<{ current: null } | undefined> = [];
let activeFakeDocument: FakeDocument | null = null;
const makeFakeElement = (ownerDocument: { createElement: () => FakeElement }): FakeElement => {
void ownerDocument;
let html = '';
const element: FakeElement = {
childNodes: [],
children: [],
parentNode: null,
attributes: new Map(),
style: { display: '', setProperty: () => undefined },
get innerHTML() {
return html;
},
set innerHTML(value: string) {
html = value;
},
setAttribute(name, value) {
this.attributes.set(name, value);
},
getAttribute(name) {
return this.attributes.get(name) ?? null;
},
appendChild(child) {
child.parentNode = this;
this.childNodes.push(child);
this.children.push(child);
return child;
},
replaceWith(replacement) {
if (!this.parentNode) return;
const parent = this.parentNode;
const index = parent.children.indexOf(this);
if (index < 0) return;
replacement.parentNode = parent;
parent.children[index] = replacement;
parent.childNodes[index] = replacement;
this.parentNode = null;
},
remove() {
if (!this.parentNode) return;
const parent = this.parentNode;
parent.children = parent.children.filter((child) => child !== this);
parent.childNodes = parent.childNodes.filter((child) => child !== this);
this.parentNode = null;
},
querySelector(selector) {
if (selector === '[data-markdown-content]') {
return this.children.find((child) => child.getAttribute('data-markdown-content') === '') ?? null;
}
if (selector === '[data-markdown="mermaid-block"]' && html.includes('data-markdown="mermaid-block"')) {
return this;
}
for (const child of this.children) {
const match = child.querySelector(selector);
if (match) return match;
}
return null;
},
querySelectorAll: () => [],
addEventListener: () => undefined,
removeEventListener: () => undefined,
contains(child) {
return child === this || this.children.some((candidate) => candidate.contains(child));
},
isEqualNode: () => false,
};
return element;
};
const installRendererDom = () => {
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousMutationObserver = Object.getOwnPropertyDescriptor(globalThis, 'MutationObserver');
const documentStub: FakeDocument = { createElement: () => makeFakeElement(documentStub) };
activeFakeDocument = documentStub;
Object.defineProperty(globalThis, 'document', { configurable: true, value: documentStub });
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
matchMedia: () => ({ matches: false }),
setTimeout,
clearTimeout,
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
},
});
Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true,
value: class {
observe() {}
disconnect() {}
},
});
return () => {
if (previousDocument) Object.defineProperty(globalThis, 'document', previousDocument);
else Reflect.deleteProperty(globalThis, 'document');
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
else Reflect.deleteProperty(globalThis, 'window');
if (previousMutationObserver) Object.defineProperty(globalThis, 'MutationObserver', previousMutationObserver);
else Reflect.deleteProperty(globalThis, 'MutationObserver');
activeFakeDocument = null;
};
};
const rendererThemes = [{
metadata: { id: 'renderer-test' },
colors: {
surface: { elevated: '#fff', foreground: '#000', mutedForeground: '#666', muted: '#eee' },
interactive: { border: '#ccc' },
primary: { base: '#00f' },
},
}, {
metadata: { id: 'renderer-test-next' },
colors: {
surface: { elevated: '#eee', foreground: '#111', mutedForeground: '#555', muted: '#ddd' },
interactive: { border: '#bbb' },
primary: { base: '#f00' },
},
}];
let rendererThemeIndex = 0;
const rendererTheme = () => rendererThemes[rendererThemeIndex] ?? rendererThemes[0];
const rendererUiState = {
codeBlockLineWrap: false,
mermaidRenderingMode: 'svg',
setCodeBlockLineWrap: () => undefined,
openContextPreview: () => undefined,
};
const fakeReact = {
useCallback: <T>(callback: T): T => {
hookCursor += 1;
return callback;
},
useEffect: (effect: () => void | (() => void)) => { passiveEffects.push(effect); },
useLayoutEffect: (effect: () => void) => { layoutEffects.push(effect); },
useMemo: <T>(factory: () => T): T => {
hookCursor += 1;
return factory();
},
useRef: <T>(current: T) => {
void current;
const index = hookCursor;
hookCursor += 1;
if (!hookStates[index]) hookStates[index] = { current: null };
// SAFETY: this test hook preserves one mutable ref slot per hook index.
return hookStates[index] as { current: T };
},
memo: <T>(component: T): T => component,
};
const fakeJsx = (_type: string, props: FakeJsxProps | null, ...children: FakeElement[]): FakeElement => {
const ref = props?.ref;
// SAFETY: the renderer test installs the typed fake document before JSX is
// evaluated; this branch only supplies its fake element factory.
const fakeDocument = activeFakeDocument;
if (!fakeDocument) throw new Error('Renderer fake document is not installed');
const element = ref?.current ?? makeFakeElement(fakeDocument);
if (!ref?.current) {
element.childNodes.length = 0;
element.children.length = 0;
}
if (props) {
if (ref) ref.current = element;
if (props.className) element.setAttribute('class', props.className);
if (props['data-markdown-content']) element.setAttribute('data-markdown-content', '');
}
const jsxChildren = props?.children;
const allChildren = jsxChildren === undefined ? children : Array.isArray(jsxChildren) ? jsxChildren : [jsxChildren];
for (const child of allChildren) {
if (child) element.appendChild(child);
}
return element;
};
mock.module('react', () => ({ default: fakeReact }));
mock.module('react/jsx-runtime', () => ({ jsx: fakeJsx, jsxs: fakeJsx, Fragment: 'fragment' }));
mock.module('react/jsx-dev-runtime', () => ({ jsxDEV: fakeJsx, Fragment: 'fragment' }));
mock.module('beautiful-mermaid', () => ({
renderMermaidASCII: () => '',
renderMermaidSVG: (_source: string, colors: { bg: string }) => colors.bg,
}));
mock.module('@/lib/utils', () => ({ cn: (...values: string[]) => values.filter(Boolean).join(' ') }));
mock.module('@/lib/i18n', () => ({ useI18n: () => ({ t: (key: string) => `${key}:${currentContextVersion}` }) }));
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch: async () => ({ ok: false }) }));
mock.module('@/lib/url', () => ({
getUrlScheme: () => null,
isAppLinkUrl: () => false,
isExternalHttpUrl: () => false,
openConfirmedAppLinkUrl: async () => false,
openExternalUrl: async () => undefined,
}));
mock.module('@/contexts/useThemeSystem', () => ({ useOptionalThemeSystem: () => ({ currentTheme: rendererTheme() }) }));
mock.module('@/lib/theme/themes', () => ({ getDefaultTheme: () => rendererTheme() }));
mock.module('./message/FadeInOnReveal', () => ({ FadeInOnReveal: ({ children }: { children: FakeElement | FakeElement[] }) => children }));
type RendererUiSelectorResult = boolean | string | (() => void);
const fakeUseUIStore = Object.assign(
(selector: (state: typeof rendererUiState) => RendererUiSelectorResult) => selector(rendererUiState),
{ getState: () => rendererUiState },
);
mock.module('@/stores/useUIStore', () => ({ useUIStore: fakeUseUIStore }));
mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => null }));
mock.module('@/hooks/useRuntimeAPIs', () => ({ useRuntimeAPIs: () => ({ editor: undefined, runtime: { isVSCode: false } }) }));
mock.module('@/lib/desktop', () => ({ isDesktopLocalOriginActive: () => false, isDesktopShell: () => false, isVSCodeRuntime: () => false }));
mock.module('@/lib/runtimeSurface', () => ({ isMobileSurfaceRuntime: () => false }));
mock.module('@/lib/outsideFileGrants', () => ({ ensureOutsideFileGrantForDesktop: async () => undefined }));
mock.module('@/lib/path-utils', () => ({ getDirectoryForFilePath: () => '', isFilePathWithinDirectory: () => true, toAbsoluteFilePath: () => '' }));
mock.module('./markdown/markdownCore', () => ({
getCachedMarkdownBlocks: () => cachedRendererBlocks,
renderMarkdownBlocks: () => renderMarkdownBlocksForTest(),
renderMarkdownSync: () => {
syncRenderCalls += 1;
return '<p>cold</p>';
},
}));
mock.module('./markdown/markdownTheme', () => ({ ensureMarkdownShikiTheme: () => undefined }));
mock.module('./markdown/markdownSyntaxVars', () => ({ getMarkdownSyntaxVars: () => ({}) }));
mock.module('./markdown/detachedMarkdownDomCache', () => ({
detachedMarkdownDomCache: {
take: () => null,
store: () => undefined,
},
}));
mock.module('@/lib/runtime-switch', () => ({ getRuntimeKey: () => 'runtime' }));
type TestDecorateContext = {
labels: { copy: string };
codeBlockLineWrap: boolean;
renderMermaid: (source: string) => { svg?: string };
};
mock.module('./markdown/decorate', () => ({
attachMarkdownInteractions: () => () => undefined,
applyMarkdownCodeBlockWrapState: () => undefined,
decorateMarkdown: (root: FakeElement, ctx: TestDecorateContext) => {
decorateCalls += 1;
if (root.getAttribute('data-test-decoration-marker') === 'true') return;
root.setAttribute('data-test-decoration-marker', 'true');
root.setAttribute(
'data-test-decoration',
`${ctx.labels.copy}|${ctx.codeBlockLineWrap}|${ctx.renderMermaid('test').svg ?? ''}`,
);
},
getMarkdownCodeText: () => '',
}));
mock.module('./markdown/textPosition', () => ({ findTextPosition: () => null }));
mock.module('./markdown/mermaidViewer', () => ({
createMermaidViewerRegistry: () => {
mermaidRegistryCreates += 1;
return {
refresh: () => undefined,
cleanup: () => { mermaidRegistryCleanups += 1; },
};
},
MERMAID_BLOCK_SELECTOR: '[data-markdown="mermaid-block"]',
shouldRefreshMermaidViewers: (container: Pick<FakeElement, 'querySelector'>) => container.querySelector('[data-markdown="mermaid-block"]') !== null,
}));
mock.module('@/stores/utils/streamDebug', () => ({ streamPerfCount: () => undefined, streamPerfObserve: () => undefined }));
mock.module('morphdom', () => ({ default: () => { morphCalls += 1; } }));
const { MarkdownRenderer } = await import('./MarkdownRendererImpl');
const resetRendererTestState = () => {
cachedRendererBlocks = null;
renderedRendererBlocks = [];
renderMarkdownBlocksForTest = async () => renderedRendererBlocks;
syncRenderCalls = 0;
morphCalls = 0;
decorateCalls = 0;
mermaidRegistryCreates = 0;
mermaidRegistryCleanups = 0;
hookCursor = 0;
hookStates = [];
layoutEffects.length = 0;
passiveEffects.length = 0;
currentContextVersion = 0;
rendererThemeIndex = 0;
rendererUiState.codeBlockLineWrap = false;
};
const beginRendererRender = () => {
hookCursor = 0;
return renderMarkdownForTest();
};
const rendererRoot = (value: ReturnType<typeof renderMarkdownForTest>): FakeElement => {
if (!(value instanceof Object) || !('childNodes' in value) || !('getAttribute' in value)) {
throw new Error('Renderer test did not return its fake JSX root');
}
// SAFETY: the structural check confirms this ReactNode is the object
// returned by the mocked JSX runtime.
const candidate = value as object;
// SAFETY: the mocked JSX runtime creates the complete FakeElement shape.
return candidate as FakeElement;
};
const runRendererLayoutEffects = () => {
const pending = layoutEffects.splice(0);
for (const effect of pending) effect();
};
const runRendererPassiveEffects = () => passiveEffects.splice(0).map((effect) => effect());
const findBlock = (root: FakeElement, id: string): FakeElement | null => {
if (root.getAttribute('data-md-id') === id) return root;
for (const child of root.children) {
const match = findBlock(child, id);
if (match) return match;
}
return null;
};
const renderMarkdownForTest = () => MarkdownRenderer({
content: 'cached markdown',
messageId: 'message-1',
isAnimated: false,
isStreaming: false,
});
const withRendererDom = async (run: () => void | Promise<void>): Promise<void> => {
const restoreDom = installRendererDom();
const previousThemeIndex = rendererThemeIndex;
try {
await run();
} finally {
rendererThemeIndex = previousThemeIndex;
restoreDom();
}
};
describe('parseFileReference', () => { describe('parseFileReference', () => {
test('returns null for empty or whitespace input', () => { test('returns null for empty or whitespace input', () => {
expect(parse('')).toBeNull(); expect(parse('')).toBeNull();
@@ -72,11 +440,7 @@ describe('parseFileReference', () => {
}); });
test('preserves line:col form (does not interpret as range)', () => { test('preserves line:col form (does not interpret as range)', () => {
expect(parse('src/foo.ts:42:8')).toEqual({ expect(parse('src/foo.ts:42:8')).toEqual({ path: 'src/foo.ts', line: 42, column: 8 });
path: 'src/foo.ts',
line: 42,
column: 8,
});
}); });
test('preserves hash form', () => { test('preserves hash form', () => {
@@ -110,3 +474,120 @@ describe('localPathFromFileUrl', () => {
expect(localPathFromFileUrl('file:///tmp/bad%ZZpath')).toBeNull(); expect(localPathFromFileUrl('file:///tmp/bad%ZZpath')).toBeNull();
}); });
}); });
describe('MarkdownRenderer warm settled path', () => {
test('installs cached blocks without sync fallback and skips same-ID morph', async () => {
await withRendererDom(async () => {
resetRendererTestState();
cachedRendererBlocks = [{ id: 'full:cached', html: '<p>cached</p>' }];
renderedRendererBlocks = cachedRendererBlocks;
syncRenderCalls = 0;
morphCalls = 0;
decorateCalls = 0;
// SAFETY: the test JSX adapter returns the fake element assigned to the
// renderer container ref and exposes the DOM members used below.
const root = rendererRoot(beginRendererRender());
runRendererLayoutEffects();
expect(syncRenderCalls).toBe(0);
const block = findBlock(root, 'full:cached');
expect(block).not.toBeNull();
expect(block?.innerHTML).toBe('<p>cached</p>');
expect(block?.getAttribute('data-md-block')).toBe('');
expect(block?.getAttribute('data-md-id')).toBe('full:cached');
expect(block?.style.display).toBe('contents');
expect(decorateCalls).toBe(1);
runRendererPassiveEffects();
await Promise.resolve();
expect(morphCalls).toBe(0);
});
});
test('recreates the Mermaid registry after StrictMode-like cleanup without remounting blocks', () => {
return withRendererDom(() => {
resetRendererTestState();
const mermaidHtml = '<div data-markdown="mermaid-block"><svg></svg></div>';
cachedRendererBlocks = [{ id: 'full:mermaid', html: mermaidHtml }];
renderedRendererBlocks = cachedRendererBlocks;
mermaidRegistryCreates = 0;
mermaidRegistryCleanups = 0;
morphCalls = 0;
const root = rendererRoot(beginRendererRender());
runRendererLayoutEffects();
expect(mermaidRegistryCreates).toBe(1);
const cleanups = runRendererPassiveEffects();
for (const cleanup of cleanups) cleanup?.();
expect(mermaidRegistryCleanups).toBe(1);
beginRendererRender();
runRendererLayoutEffects();
expect(mermaidRegistryCreates).toBe(2);
expect(findBlock(root, 'full:mermaid')).not.toBeNull();
expect(morphCalls).toBe(0);
});
});
test('redecorates a same-ID block when decoration context changes before async completion', async () => {
await withRendererDom(async () => {
resetRendererTestState();
cachedRendererBlocks = [{
id: 'full:context',
html: '<div data-markdown="mermaid-block"><p>cached</p></div>',
}];
renderedRendererBlocks = cachedRendererBlocks;
const root = rendererRoot(beginRendererRender());
runRendererLayoutEffects();
const block = findBlock(root, 'full:context');
const firstDecorationId = block?.getAttribute('data-md-decoration-id');
expect(firstDecorationId).not.toBeNull();
const firstDecorateCalls = decorateCalls;
rendererThemeIndex = 1;
currentContextVersion = 1;
rendererUiState.codeBlockLineWrap = true;
beginRendererRender();
runRendererLayoutEffects();
runRendererPassiveEffects();
await Promise.resolve();
expect(decorateCalls).toBeGreaterThan(firstDecorateCalls);
expect(syncRenderCalls).toBe(0);
expect(morphCalls).toBe(0);
const updatedBlock = findBlock(root, 'full:context');
expect(updatedBlock?.getAttribute('data-md-decoration-id')).not.toBe(firstDecorationId);
expect(updatedBlock?.getAttribute('data-test-decoration')).toContain(':1|true|#eee');
expect(updatedBlock?.getAttribute('data-test-decoration-marker')).toBe('true');
expect(mermaidRegistryCleanups).toBeGreaterThan(0);
expect(mermaidRegistryCreates).toBeGreaterThan(1);
});
});
test('rejects an older async render after a newer layout commit', async () => {
await withRendererDom(async () => {
resetRendererTestState();
cachedRendererBlocks = [{ id: 'full:initial', html: '<p>initial</p>' }];
let resolveOldRender: ((blocks: Array<{ id: string; html: string }>) => void) | undefined;
const oldRender = new Promise<Array<{ id: string; html: string }>>((resolve) => {
resolveOldRender = resolve;
});
renderMarkdownBlocksForTest = () => oldRender;
beginRendererRender();
runRendererLayoutEffects();
runRendererPassiveEffects();
cachedRendererBlocks = [{ id: 'full:new', html: '<p>new</p>' }];
beginRendererRender();
runRendererLayoutEffects();
expect(resolveOldRender).toBeDefined();
resolveOldRender?.([{ id: 'full:old-late', html: '<p>old late</p>' }]);
await Promise.resolve();
expect(morphCalls).toBe(0);
});
});
});
@@ -4,11 +4,12 @@ import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
import type { Part } from '@opencode-ai/sdk/v2'; import type { Part } from '@opencode-ai/sdk/v2';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch'; import { openExternalUrl } from '@/lib/url';
import { isExternalHttpUrl, openExternalUrl } from '@/lib/url';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { getDefaultTheme } from '@/lib/theme/themes'; import { getDefaultTheme } from '@/lib/theme/themes';
import type { Theme } from '@/types/theme'; import type { Theme } from '@/types/theme';
import { openAppLinkWithConfirmation } from './appLinkConfirmation';
import { attachAppLinkInteractions } from './appLinkInteractions';
import type { ToolPopupContent } from './message/types'; import type { ToolPopupContent } from './message/types';
import { FadeInOnReveal } from './message/FadeInOnReveal'; import { FadeInOnReveal } from './message/FadeInOnReveal';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
@@ -19,7 +20,12 @@ import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/l
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants'; import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils'; import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
import { renderMarkdownBlocks, renderMarkdownSync, type MarkdownImageMode } from './markdown/markdownCore'; import {
getCachedMarkdownBlocks,
renderMarkdownBlocks,
renderMarkdownSync,
type MarkdownImageMode,
} from './markdown/markdownCore';
import { ensureMarkdownShikiTheme } from './markdown/markdownTheme'; import { ensureMarkdownShikiTheme } from './markdown/markdownTheme';
import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars'; import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars';
import { import {
@@ -42,7 +48,10 @@ import {
parseFileReference, parseFileReference,
type ParsedFileReference, type ParsedFileReference,
} from './fileReferenceParser'; } from './fileReferenceParser';
import { fileReferenceExists } from './fileReferenceStat';
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug'; import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
import { detachedMarkdownDomCache, type DetachedMarkdownDomKey } from './markdown/detachedMarkdownDomCache';
import { getRuntimeKey } from '@/lib/runtime-switch';
const useCurrentMermaidTheme = () => { const useCurrentMermaidTheme = () => {
const themeSystem = useOptionalThemeSystem(); const themeSystem = useOptionalThemeSystem();
@@ -55,7 +64,7 @@ const useCurrentMermaidTheme = () => {
: fallbackLight); : fallbackLight);
}; };
const useExternalLinkInteractions = ({ const useLinkInteractions = ({
containerRef, containerRef,
enabled, enabled,
}: { }: {
@@ -63,48 +72,16 @@ const useExternalLinkInteractions = ({
enabled?: boolean; enabled?: boolean;
}) => { }) => {
React.useEffect(() => { React.useEffect(() => {
if (enabled === false) {
return;
}
const container = containerRef.current; const container = containerRef.current;
if (!container) { if (!container) {
return; return;
} }
const handleClick = (event: MouseEvent) => { return attachAppLinkInteractions(container, {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) { allowExternalHttp: enabled !== false,
return; openAppLink: (href) => void openAppLinkWithConfirmation(href),
} openExternalHttp: (href) => void openExternalUrl(href),
});
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const anchor = target.closest('a[href]');
if (!(anchor instanceof HTMLAnchorElement)) {
return;
}
if (anchor.getAttribute('data-openchamber-file-link') === 'true') {
return;
}
const href = anchor.getAttribute('href') ?? '';
if (!isExternalHttpUrl(href)) {
return;
}
event.preventDefault();
event.stopPropagation();
void openExternalUrl(href);
};
container.addEventListener('click', handleClick);
return () => {
container.removeEventListener('click', handleClick);
};
}, [containerRef, enabled]); }, [containerRef, enabled]);
}; };
@@ -151,19 +128,9 @@ const CODE_BLOCK_PATH_SCANNED_ATTR = 'data-openchamber-block-paths-scanned';
// output. The regex is defined in `./fileReferenceParser`; the inline-code // output. The regex is defined in `./fileReferenceParser`; the inline-code
// pipeline reads full text content rather than using this regex. // pipeline reads full text content rather than using this regex.
const MAX_BLOCK_CODE_SCAN_LENGTH = 200_000; const MAX_BLOCK_CODE_SCAN_LENGTH = 200_000;
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
const FILE_REFERENCE_LINK_LIMIT = 80; const FILE_REFERENCE_LINK_LIMIT = 80;
const VSCODE_FILE_REFERENCE_LINK_LIMIT = 40; const VSCODE_FILE_REFERENCE_LINK_LIMIT = 40;
const FILE_REFERENCE_ANNOTATION_DELAY_MS = 160; const FILE_REFERENCE_ANNOTATION_DELAY_MS = 160;
const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>();
let activeFileReferenceStatCount = 0;
const pendingFileReferenceStats: Array<() => void> = [];
const getFileReferenceStatCacheMax = (): number => (
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_STAT_CACHE_MAX : FILE_REFERENCE_STAT_CACHE_MAX
);
const getFileReferenceLinkLimit = (): number => ( const getFileReferenceLinkLimit = (): number => (
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_LINK_LIMIT : FILE_REFERENCE_LINK_LIMIT isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_LINK_LIMIT : FILE_REFERENCE_LINK_LIMIT
@@ -361,61 +328,6 @@ const getResolvedReference = (rawValue: string, effectiveDirectory: string): (Pa
}; };
}; };
const fileReferenceExists = (resolvedPath: string): Promise<boolean> => {
const normalizedPath = normalizePath(resolvedPath);
if (!normalizedPath) {
return Promise.resolve(false);
}
const cached = FILE_REFERENCE_STAT_CACHE.get(normalizedPath);
if (cached) {
FILE_REFERENCE_STAT_CACHE.delete(normalizedPath);
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, cached);
return cached;
}
const request = new Promise<boolean>((resolve) => {
const run = () => {
activeFileReferenceStatCount += 1;
void runtimeFetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}&optional=true`, {
method: 'GET',
cache: 'no-store',
})
.then(async (response) => {
if (!response.ok) {
resolve(false);
return;
}
const payload = await response.json().catch(() => null) as { exists?: unknown } | null;
resolve(payload?.exists !== false);
})
.catch(() => resolve(false))
.finally(() => {
activeFileReferenceStatCount = Math.max(0, activeFileReferenceStatCount - 1);
pendingFileReferenceStats.shift()?.();
});
};
if (activeFileReferenceStatCount < FILE_REFERENCE_STAT_CONCURRENCY) {
run();
return;
}
pendingFileReferenceStats.push(run);
});
const maxCacheEntries = getFileReferenceStatCacheMax();
while (FILE_REFERENCE_STAT_CACHE.size >= maxCacheEntries) {
const oldest = FILE_REFERENCE_STAT_CACHE.keys().next().value;
if (typeof oldest !== 'string') {
break;
}
FILE_REFERENCE_STAT_CACHE.delete(oldest);
}
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, request);
return request;
};
const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => { const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => {
return effectiveDirectory || getDirectoryForFilePath(effectiveDirectory, resolvedPath); return effectiveDirectory || getDirectoryForFilePath(effectiveDirectory, resolvedPath);
}; };
@@ -440,6 +352,13 @@ const useFileReferenceInteractions = ({
if (!container) { if (!container) {
return; return;
} }
// Wait for the real directory: annotating against an empty/fallback
// directory issues stat probes under the wrong cache key (and the wrong
// server directory), and the pass reruns anyway once the directory
// resolves — every link ended up verified twice.
if (enabled && !effectiveDirectory) {
return;
}
let cancelled = false; let cancelled = false;
const fileReferenceLinkLimit = getFileReferenceLinkLimit(); const fileReferenceLinkLimit = getFileReferenceLinkLimit();
// On mobile surfaces, file-reference highlighting is disabled entirely — not // On mobile surfaces, file-reference highlighting is disabled entirely — not
@@ -493,6 +412,19 @@ const useFileReferenceInteractions = ({
}; };
const annotateFileLinks = () => { const annotateFileLinks = () => {
annotationWriteDepth += 1;
try {
annotateFileLinksInner();
} finally {
// Let the mutation events from our own writes flush before the
// observer starts listening for real content changes again.
queueMicrotask(() => {
annotationWriteDepth -= 1;
});
}
};
const annotateFileLinksInner = () => {
if (fileReferencesEnabled) { if (fileReferencesEnabled) {
wrapBlockCodePathTokens(container); wrapBlockCodePathTokens(container);
} }
@@ -521,7 +453,7 @@ const useFileReferenceInteractions = ({
&& !isFilePathWithinDirectory(resolved.resolvedPath, effectiveDirectory); && !isFilePathWithinDirectory(resolved.resolvedPath, effectiveDirectory);
const existsPromise = canGrantOutsideFile const existsPromise = canGrantOutsideFile
? Promise.resolve(true) ? Promise.resolve(true)
: fileReferenceExists(resolved.resolvedPath); : fileReferenceExists(resolved.resolvedPath, effectiveDirectory);
void existsPromise.then((exists) => { void existsPromise.then((exists) => {
if (cancelled || !exists || !container.contains(candidate)) { if (cancelled || !exists || !container.contains(candidate)) {
@@ -621,7 +553,12 @@ const useFileReferenceInteractions = ({
scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS); scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
// Our own annotation writes (path-token wrapping, attribute updates) fire
// childList mutations too; observing them re-ran the whole pass — every
// link was scanned and verified twice per render.
let annotationWriteDepth = 0;
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
if (annotationWriteDepth > 0) return;
scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS); scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
}); });
observer.observe(container, { observer.observe(container, {
@@ -754,6 +691,19 @@ const useMermaidInlineInteractions = ({
// so a stable diagram is laid out once and served from cache thereafter. // so a stable diagram is laid out once and served from cache thereafter.
const MERMAID_RENDER_CACHE = new Map<string, MermaidRender>(); const MERMAID_RENDER_CACHE = new Map<string, MermaidRender>();
const MERMAID_RENDER_CACHE_MAX = 100; const MERMAID_RENDER_CACHE_MAX = 100;
const MARKDOWN_DECORATION_ID_ATTR = 'data-md-decoration-id';
const MARKDOWN_DECORATION_IDS = new WeakMap<DecorateContext, string>();
let nextMarkdownDecorationId = 0;
const MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS = 200_000;
const getMarkdownDecorationId = (ctx: DecorateContext): string => {
const existing = MARKDOWN_DECORATION_IDS.get(ctx);
if (existing) return existing;
const id = `decoration-${nextMarkdownDecorationId}`;
nextMarkdownDecorationId += 1;
MARKDOWN_DECORATION_IDS.set(ctx, id);
return id;
};
const cachedMermaidRender = (key: string, compute: () => MermaidRender): MermaidRender => { const cachedMermaidRender = (key: string, compute: () => MermaidRender): MermaidRender => {
const existing = MERMAID_RENDER_CACHE.get(key); const existing = MERMAID_RENDER_CACHE.get(key);
@@ -838,6 +788,7 @@ const useMorphdomMarkdown = ({
imageMode = 'inline', imageMode = 'inline',
syntaxVars, syntaxVars,
ctx, ctx,
domCacheKey,
}: { }: {
containerRef: React.RefObject<HTMLDivElement | null>; containerRef: React.RefObject<HTMLDivElement | null>;
text: string; text: string;
@@ -845,12 +796,20 @@ const useMorphdomMarkdown = ({
imageMode?: MarkdownImageMode; imageMode?: MarkdownImageMode;
syntaxVars: Record<string, string>; syntaxVars: Record<string, string>;
ctx: DecorateContext; ctx: DecorateContext;
domCacheKey?: DetachedMarkdownDomKey | null;
}) => { }) => {
React.useEffect(() => { React.useEffect(() => {
ensureMarkdownShikiTheme(); ensureMarkdownShikiTheme();
}, []); }, []);
const mermaidViewerRef = React.useRef<ReturnType<typeof createMermaidViewerRegistry> | null>(null); const mermaidViewerRef = React.useRef<ReturnType<typeof createMermaidViewerRegistry> | null>(null);
const renderRevisionRef = React.useRef(0);
// Only DOM that was actually restored or completed by the async pipeline is
// eligible for capture. A fallback from an earlier content revision is not.
const mountedDomRef = React.useRef<{
key: DetachedMarkdownDomKey;
copiedLabel: string;
} | null>(null);
const refreshMermaidViewers = React.useCallback(() => { const refreshMermaidViewers = React.useCallback(() => {
const container = containerRef.current; const container = containerRef.current;
if (!container) { if (!container) {
@@ -866,6 +825,63 @@ const useMorphdomMarkdown = ({
mermaidViewerRef.current.refresh(); mermaidViewerRef.current.refresh();
}, [containerRef]); }, [containerRef]);
React.useLayoutEffect(() => {
renderRevisionRef.current += 1;
mountedDomRef.current = null;
}, [ctx, imageMode, streaming, text]);
React.useLayoutEffect(() => {
if (!domCacheKey) return;
const container = containerRef.current;
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
if (!target || target.childNodes.length > 0) return;
const cached = detachedMarkdownDomCache.take(domCacheKey);
if (cached) {
target.appendChild(cached);
const decorationId = getMarkdownDecorationId(ctx);
for (const block of Array.from(target.children)) {
block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
}
for (const [key, value] of Object.entries(syntaxVars)) target.style.setProperty(key, value);
applyMarkdownCodeBlockWrapState(target, ctx.codeBlockLineWrap, ctx.labels);
mountedDomRef.current = {
key: domCacheKey,
copiedLabel: ctx.labels.copied,
};
streamPerfCount('ui.markdown_renderer.dom_cache.hit');
}
}, [containerRef, ctx, domCacheKey, syntaxVars, text.length]);
// Restoration follows the cache identity above, but capture must only happen
// when this renderer lifecycle ends. Combining both in one keyed effect would
// detach the live DOM on ordinary content, theme, or locale updates.
React.useLayoutEffect(() => {
const container = containerRef.current;
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
if (!target) return;
return () => {
const mountedDom = mountedDomRef.current;
if (!mountedDom) return;
// Viewer controllers and transient interaction state belong to the
// current renderer instance and must not cross the cache boundary.
if (target.childNodes.length === 0 || shouldRefreshMermaidViewers(target)) return;
if (Array.from(target.children).some((block) => !block.hasAttribute('data-md-id'))) return;
if (target.querySelector('[data-md-copy-pending]')) return;
const selection = window.getSelection();
if (selection?.rangeCount && !selection.isCollapsed && selection.getRangeAt(0).intersectsNode(target)) return;
const openMenu = target.querySelector<HTMLElement>('[data-md-menu]:not(.hidden)');
const copiedButton = Array.from(target.querySelectorAll<HTMLButtonElement>('[data-md-action]'))
.some((button) => button.getAttribute('title') === mountedDom.copiedLabel);
if (openMenu || copiedButton) return;
const fragment = document.createDocumentFragment();
fragment.append(...Array.from(target.childNodes));
detachedMarkdownDomCache.store({ ...mountedDom.key, fragment });
streamPerfCount('ui.markdown_renderer.dom_cache.capture');
};
}, [containerRef]);
// Synchronous first paint: while the async parse is in-flight, show escaped // Synchronous first paint: while the async parse is in-flight, show escaped
// plain text immediately so there is no blank frame on initial mount. Only // plain text immediately so there is no blank frame on initial mount. Only
// runs when the target is empty — subsequent updates keep the prior rich DOM // runs when the target is empty — subsequent updates keep the prior rich DOM
@@ -875,25 +891,40 @@ const useMorphdomMarkdown = ({
const container = containerRef.current; const container = containerRef.current;
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container; const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
if (!target) return; if (!target) return;
const decorationId = getMarkdownDecorationId(ctx);
if (text && target.childNodes.length === 0) { if (text && target.childNodes.length === 0) {
const block = document.createElement('div'); const cachedBlocks = !streaming ? getCachedMarkdownBlocks(text, imageMode) : null;
block.setAttribute('data-md-block', ''); if (cachedBlocks) {
// `display:contents` keeps margin-collapsing/spacing identical to a flat let hasMermaidBlock = false;
// HTML body — the wrapper exists only for per-block reconciliation. for (const cachedBlock of cachedBlocks) {
block.style.display = 'contents'; const block = document.createElement('div');
block.innerHTML = renderMarkdownSync(text, imageMode); block.setAttribute('data-md-block', '');
// Decorate synchronously too: wrap code blocks in their framed card, block.style.display = 'contents';
// mark inline code, build table controls, etc. The async pass re-decorates block.innerHTML = cachedBlock.html;
// its own DOM before morphing, so without this the first paint shows bare decorateMarkdown(block, ctx);
// <pre>/tables that "snap" into their decorated form a tick later. Matching block.setAttribute('data-md-id', cachedBlock.id);
// the structure here keeps the async morph to syntax colors only. block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
decorateMarkdown(block, ctx); hasMermaidBlock ||= shouldRefreshMermaidViewers(block);
target.appendChild(block); target.appendChild(block);
if (shouldRefreshMermaidViewers(block)) { }
refreshMermaidViewers(); if (hasMermaidBlock) refreshMermaidViewers();
} else {
const block = document.createElement('div');
block.setAttribute('data-md-block', '');
block.style.display = 'contents';
block.innerHTML = renderMarkdownSync(text, imageMode);
decorateMarkdown(block, ctx);
block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
target.appendChild(block);
if (shouldRefreshMermaidViewers(block)) refreshMermaidViewers();
} }
} else if (!mermaidViewerRef.current && shouldRefreshMermaidViewers(target)) {
// StrictMode re-runs this setup after the cleanup probe. The DOM remains,
// but the viewer registry does not, so recreate it without reinstalling
// or re-decorating ordinary blocks.
refreshMermaidViewers();
} }
}, [containerRef, text, imageMode, ctx, refreshMermaidViewers]); }, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]);
React.useEffect(() => () => { React.useEffect(() => () => {
mermaidViewerRef.current?.cleanup(); mermaidViewerRef.current?.cleanup();
@@ -905,27 +936,70 @@ const useMorphdomMarkdown = ({
if (!container) return; if (!container) return;
const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container; const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
let active = true; let active = true;
const renderRevision = renderRevisionRef.current;
const decorationId = getMarkdownDecorationId(ctx);
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => { void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
if (!active) return; if (!active || renderRevisionRef.current !== renderRevision) return;
const existing = Array.from(target.children) as HTMLElement[]; const existing = Array.from(target.children) as HTMLElement[];
// Reconcile per block: only re-morph blocks whose content changed, leaving // Reconcile per block: only re-morph blocks whose content changed, leaving
// stable leading blocks untouched. Keeps per-stream-step DOM work bounded // stable leading blocks untouched. Keeps per-stream-step DOM work bounded
// to the trailing (growing) block instead of the whole message. // to the trailing (growing) block instead of the whole message.
let enteredThisPass = 0;
blocks.forEach((block, index) => { blocks.forEach((block, index) => {
let el = existing[index]; let el = existing[index];
let isNewBlock = false;
if (!el) { if (!el) {
el = document.createElement('div'); el = document.createElement('div');
el.setAttribute('data-md-block', ''); el.setAttribute('data-md-block', '');
el.style.display = 'contents'; el.style.display = 'contents';
target.appendChild(el); target.appendChild(el);
isNewBlock = true;
}
if (el.getAttribute('data-md-id') === block.id) {
if (el.getAttribute(MARKDOWN_DECORATION_ID_ATTR) !== decorationId) {
const hasMermaidBlock = shouldRefreshMermaidViewers(el);
if (hasMermaidBlock) {
mermaidViewerRef.current?.cleanup();
mermaidViewerRef.current = null;
}
const replacement = document.createElement('div');
replacement.setAttribute('data-md-block', '');
replacement.style.display = 'contents';
replacement.innerHTML = block.html;
decorateMarkdown(replacement, ctx);
replacement.setAttribute('data-md-id', block.id);
replacement.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
el.replaceWith(replacement);
if (hasMermaidBlock || shouldRefreshMermaidViewers(replacement)) refreshMermaidViewers();
}
if (!mermaidViewerRef.current && shouldRefreshMermaidViewers(el)) {
refreshMermaidViewers();
}
return;
} }
if (el.getAttribute('data-md-id') === block.id) return;
const temp = document.createElement('div'); const temp = document.createElement('div');
temp.innerHTML = block.html; temp.innerHTML = block.html;
decorateMarkdown(temp, ctx); decorateMarkdown(temp, ctx);
if (isNewBlock && streaming && index > 0) {
// A freshly committed block enters with a short reveal. The class
// goes on the block's children — the wrapper is display:contents
// and cannot animate — and the transform never changes layout, so
// row measurement stays exact. Skipped for the first block so a
// full initial render does not shimmer. Several blocks committed
// in one tick cascade with a small stagger instead of popping in
// together.
const delayMs = Math.min(enteredThisPass, 4) * 55;
enteredThisPass += 1;
for (const child of Array.from(temp.children)) {
child.classList.add('oc-md-block-enter');
if (delayMs > 0 && child instanceof HTMLElement) {
child.style.setProperty('--oc-md-enter-delay', `${delayMs}ms`);
}
}
}
const hadMermaidBlock = shouldRefreshMermaidViewers(el); const hadMermaidBlock = shouldRefreshMermaidViewers(el);
const tempHasMermaidBlock = shouldRefreshMermaidViewers(temp); const tempHasMermaidBlock = shouldRefreshMermaidViewers(temp);
morphdom(el, temp, { morphdom(el, temp, {
@@ -933,12 +1007,12 @@ const useMorphdomMarkdown = ({
onBeforeElUpdated: (fromEl, toEl) => !fromEl.isEqualNode(toEl), onBeforeElUpdated: (fromEl, toEl) => !fromEl.isEqualNode(toEl),
}); });
el.setAttribute('data-md-id', block.id); el.setAttribute('data-md-id', block.id);
el.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
if (hadMermaidBlock || tempHasMermaidBlock || shouldRefreshMermaidViewers(el)) { if (hadMermaidBlock || tempHasMermaidBlock || shouldRefreshMermaidViewers(el)) {
refreshMermaidViewers(); refreshMermaidViewers();
} }
}); });
// Remove any trailing block elements no longer present.
const hadMermaidBeforeTrailingCleanup = shouldRefreshMermaidViewers(target); const hadMermaidBeforeTrailingCleanup = shouldRefreshMermaidViewers(target);
let removedMermaidBlock = false; let removedMermaidBlock = false;
for (let i = existing.length - 1; i >= blocks.length; i -= 1) { for (let i = existing.length - 1; i >= blocks.length; i -= 1) {
@@ -951,13 +1025,15 @@ const useMorphdomMarkdown = ({
if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) { if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) {
refreshMermaidViewers(); refreshMermaidViewers();
} }
mountedDomRef.current = domCacheKey
? { key: domCacheKey, copiedLabel: ctx.labels.copied }
: null;
}); });
return () => { return () => {
active = false; active = false;
}; };
}, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]); }, [containerRef, ctx, domCacheKey, imageMode, refreshMermaidViewers, streaming, text]);
React.useEffect(() => { React.useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
@@ -1034,10 +1110,37 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
preferRuntimeEditor: runtime.isVSCode, preferRuntimeEditor: runtime.isVSCode,
enabled: enableFileReferences && !isStreaming, enabled: enableFileReferences && !isStreaming,
}); });
useExternalLinkInteractions({ containerRef }); useLinkInteractions({ containerRef });
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS); const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
const { locale } = useI18n();
const imageMode: MarkdownImageMode = variant === 'assistant' ? 'label' : 'inline';
const settledPart = part
&& (part.type === 'text' || part.type === 'reasoning')
&& part.time?.end !== undefined
? part
: null;
const runtimeKey = getRuntimeKey();
// Memoized on scalar identities, not the part object: sync-store reducers
// recreate part objects on unrelated updates, and an object-identity dep
// re-ran the async render pipeline for identical content.
const settledSessionID = settledPart?.sessionID;
const settledMessageID = settledPart?.messageID;
const settledPartID = settledPart?.id;
const domCacheKey = React.useMemo<DetachedMarkdownDomKey | null>(() => {
// Streaming, unfinished, oversized, and identity-less Markdown continues
// through the normal rendering pipeline and never retains detached DOM.
if (isStreaming || !settledSessionID || !settledMessageID || !settledPartID || content.length === 0 || content.length > MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS) return null;
// content.length is a cheap fingerprint: an edited or reverted part that
// re-materializes under the same id must not restore the old DOM.
return {
scope: `${runtimeKey}\0${settledSessionID}`,
id: `${settledMessageID}\0${settledPartID}\0${imageMode}\0${content.length}`,
locale,
directory: effectiveDirectory,
};
}, [content.length, effectiveDirectory, imageMode, isStreaming, locale, runtimeKey, settledSessionID, settledMessageID, settledPartID]);
// Identity for the fade-in wrapper: a new part/message restarts the animation. // Identity for the fade-in wrapper: a new part/message restarts the animation.
const fadeKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`; const fadeKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
@@ -1045,9 +1148,10 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
containerRef, containerRef,
text: content, text: content,
streaming: live, streaming: live,
imageMode: variant === 'assistant' ? 'label' : 'inline', imageMode,
syntaxVars, syntaxVars,
ctx, ctx,
domCacheKey,
}); });
const markdownContent = ( const markdownContent = (
@@ -1085,6 +1189,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
content: string; content: string;
className?: string; className?: string;
variant?: MarkdownVariant; variant?: MarkdownVariant;
// App links remain confirmed even where ordinary HTTP link handling is off.
disableLinkSafety?: boolean; disableLinkSafety?: boolean;
stripFrontmatter?: boolean; stripFrontmatter?: boolean;
onShowPopup?: (content: ToolPopupContent) => void; onShowPopup?: (content: ToolPopupContent) => void;
@@ -1126,7 +1231,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
preferRuntimeEditor: runtime.isVSCode, preferRuntimeEditor: runtime.isVSCode,
enabled: enableFileReferences, enabled: enableFileReferences,
}); });
useExternalLinkInteractions({ containerRef, enabled: !disableLinkSafety }); useLinkInteractions({ containerRef, enabled: !disableLinkSafety });
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
const ctx = useDecorateContext(currentTheme, false, undefined, mermaidControls); const ctx = useDecorateContext(currentTheme, false, undefined, mermaidControls);
File diff suppressed because it is too large Load Diff
@@ -25,7 +25,8 @@ import { useDeviceInfo } from '@/lib/device';
import { mergeModelMetadataWithLiveModel } from '@/lib/modelMetadata'; import { mergeModelMetadataWithLiveModel } from '@/lib/modelMetadata';
import { getModelDisplayName as getSharedModelDisplayName } from '@/lib/modelDisplay'; import { getModelDisplayName as getSharedModelDisplayName } from '@/lib/modelDisplay';
import { getEditModeColors } from '@/lib/permissions/editModeColors'; import { getEditModeColors } from '@/lib/permissions/editModeColors';
import { cn, fuzzyMatch } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
import { useContextStore } from '@/stores/contextStore'; import { useContextStore } from '@/stores/contextStore';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -57,6 +58,28 @@ type MobileVariantTarget = { providerId: string; modelId: string };
const buildModelRefKey = (providerID: string, modelID: string) => `${providerID}:${modelID}`; const buildModelRefKey = (providerID: string, modelID: string) => `${providerID}:${modelID}`;
const MAX_INLINE_MOBILE_VARIANT_OPTIONS = 6; const MAX_INLINE_MOBILE_VARIANT_OPTIONS = 6;
const AgentDescriptionTooltip: React.FC<{
description?: string;
children: React.ReactElement;
}> = ({ description, children }) => {
if (!description) {
return children;
}
return (
<Tooltip delayDuration={450}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={8}
className="max-w-xs text-left transition-none data-[starting-style]:opacity-100 data-[starting-style]:scale-100 data-[ending-style]:opacity-100 data-[ending-style]:scale-100"
>
<span className="typography-meta text-muted-foreground">{description}</span>
</TooltipContent>
</Tooltip>
);
};
const asPermissionRuleset = (value: unknown): PermissionRule[] | null => { const asPermissionRuleset = (value: unknown): PermissionRule[] | null => {
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
return null; return null;
@@ -506,13 +529,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const sortedAndFilteredAgents = React.useMemo(() => { const sortedAndFilteredAgents = React.useMemo(() => {
const sorted = [...selectableDesktopAgents].sort((a, b) => a.name.localeCompare(b.name)); const sorted = [...selectableDesktopAgents].sort((a, b) => a.name.localeCompare(b.name));
if (!agentSearchQuery.trim()) { return rankByQuery(sorted, agentSearchQuery, (agent) => [agent.name, agent.description]);
return sorted;
}
return sorted.filter((agent) =>
fuzzyMatch(agent.name, agentSearchQuery) ||
(agent.description && fuzzyMatch(agent.description, agentSearchQuery))
);
}, [selectableDesktopAgents, agentSearchQuery]); }, [selectableDesktopAgents, agentSearchQuery]);
const defaultAgentName = React.useMemo(() => { const defaultAgentName = React.useMemo(() => {
@@ -558,38 +575,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return result; return result;
}, [providers, hiddenModels]); }, [providers, hiddenModels]);
const normalizeModelSearchValue = React.useCallback((value: string) => { const matchesModelSearch = React.useCallback(
const lower = value.toLowerCase().trim(); (candidate: string, query: string) => matchesRankQuery([candidate], query),
const compact = lower.replace(/[^a-z0-9]/g, ''); [],
const tokens = lower.split(/[^a-z0-9]+/).filter(Boolean); );
return { lower, compact, tokens };
}, []);
const matchesModelSearch = React.useCallback((candidate: string, query: string) => {
const normalizedQuery = normalizeModelSearchValue(query);
if (!normalizedQuery.lower) {
return true;
}
const normalizedCandidate = normalizeModelSearchValue(candidate);
if (normalizedCandidate.lower.includes(normalizedQuery.lower)) {
return true;
}
if (normalizedQuery.compact.length >= 2 && normalizedCandidate.compact.includes(normalizedQuery.compact)) {
return true;
}
if (normalizedQuery.tokens.length === 0) {
return false;
}
return normalizedQuery.tokens.every((queryToken) =>
normalizedCandidate.tokens.some((candidateToken) =>
candidateToken.startsWith(queryToken) || candidateToken.includes(queryToken)
)
);
}, [normalizeModelSearchValue]);
const currentModelForMetadata = currentModelId const currentModelForMetadata = currentModelId
? models.find((model: ProviderModel) => model.id === currentModelId) ? models.find((model: ProviderModel) => model.id === currentModelId)
@@ -893,25 +882,29 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
? useSelectionStore.getState().getSessionAgentSelection(currentSessionId) ? useSelectionStore.getState().getSessionAgentSelection(currentSessionId)
: null; : null;
if (savedAgentName) { if (savedAgentName) {
if (currentAgentName !== savedAgentName) {
setAgent(savedAgentName);
}
const savedModel = getAgentModelForSession(currentSessionId, savedAgentName); const savedModel = getAgentModelForSession(currentSessionId, savedAgentName);
if (savedModel) { if (savedModel) {
const result = tryApplyModelSelection(savedModel.providerId, savedModel.modelId, savedAgentName); const result = tryApplyModelSelection(savedModel.providerId, savedModel.modelId, savedAgentName);
if (result === 'applied') { if (result === 'applied') {
if (currentAgentName !== savedAgentName) {
setAgent(savedAgentName);
}
return 'resolved'; return 'resolved';
} }
if (result === 'provider-missing') { if (result === 'provider-missing') {
return 'waiting'; return 'waiting';
} }
} else if (currentAgentName !== savedAgentName) {
setAgent(savedAgentName);
} }
} }
if (savedSessionModel) { if (savedSessionModel) {
const result = tryApplyModelSelection(savedSessionModel.providerId, savedSessionModel.modelId, savedAgentName || currentAgentName || undefined); const result = tryApplyModelSelection(savedSessionModel.providerId, savedSessionModel.modelId, savedAgentName || currentAgentName || undefined);
if (result === 'applied') { if (result === 'applied') {
if (savedAgentName && currentAgentName !== savedAgentName) {
setAgent(savedAgentName);
}
return 'resolved'; return 'resolved';
} }
if (result === 'provider-missing') { if (result === 'provider-missing') {
@@ -925,16 +918,15 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
continue; continue;
} }
if (currentAgentName !== agent.name) {
setAgent(agent.name);
}
const existingSelection = useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
if (!existingSelection) {
saveSessionAgentSelection(currentSessionId, agent.name);
}
const result = tryApplyModelSelection(selection.providerId, selection.modelId, agent.name); const result = tryApplyModelSelection(selection.providerId, selection.modelId, agent.name);
if (result === 'applied') { if (result === 'applied') {
if (currentAgentName !== agent.name) {
setAgent(agent.name);
}
const existingSelection = useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
if (!existingSelection) {
saveSessionAgentSelection(currentSessionId, agent.name);
}
return 'resolved'; return 'resolved';
} }
if (result === 'provider-missing') { if (result === 'provider-missing') {
@@ -2316,9 +2308,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</DropdownMenuTrigger> </DropdownMenuTrigger>
</TooltipTrigger> </TooltipTrigger>
<DropdownMenuContent <DropdownMenuContent
className="w-[min(380px,calc(100vw-2rem))] p-0 flex flex-col" side="top"
className="w-[min(380px,calc(100vw-2rem))] p-0 flex flex-col overflow-hidden"
align="end" align="end"
alignOffset={-40} alignOffset={-40}
constrainToMain
collisionAvoidance={{ side: 'none', align: 'shift' }}
onKeyDownCapture={handleModelShortcutKeyDownCapture} onKeyDownCapture={handleModelShortcutKeyDownCapture}
> >
<div className="p-1 border-b border-border/40"> <div className="p-1 border-b border-border/40">
@@ -2375,6 +2370,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div> </div>
); );
}} }}
maxHeightClassName="max-h-[min(400px,calc(var(--available-height)-4rem))] flex-1"
tooltipsEnabled={agentMenuOpen} tooltipsEnabled={agentMenuOpen}
onEscape={() => setAgentMenuOpen(false)} onEscape={() => setAgentMenuOpen(false)}
/> />
@@ -2618,7 +2614,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
</TooltipTrigger> </TooltipTrigger>
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(180px,calc(100vw-2rem))]"> <DropdownMenuContent side="top" align="end" alignOffset={-40} className="w-[min(180px,calc(100vw-2rem))]">
<DropdownMenuLabel className="typography-ui-header font-semibold text-foreground">{t('chat.modelControls.thinking')}</DropdownMenuLabel> <DropdownMenuLabel className="typography-ui-header font-semibold text-foreground">{t('chat.modelControls.thinking')}</DropdownMenuLabel>
<DropdownMenuItem className="typography-meta" onSelect={() => handleVariantSelect(undefined)}> <DropdownMenuItem className="typography-meta" onSelect={() => handleVariantSelect(undefined)}>
<div className="flex items-center justify-between gap-2 w-full min-w-0"> <div className="flex items-center justify-between gap-2 w-full min-w-0">
@@ -2708,7 +2704,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
</TooltipTrigger> </TooltipTrigger>
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(280px,calc(100vw-2rem))] p-0 flex flex-col"> <DropdownMenuContent side="top" align="end" alignOffset={-40} constrainToMain collisionAvoidance={{ side: 'none', align: 'shift' }} className="w-[min(280px,calc(100vw-2rem))] p-0 flex flex-col overflow-hidden">
<div className="p-2 border-b border-border/40"> <div className="p-2 border-b border-border/40">
<div className="relative"> <div className="relative">
<Icon name="search" className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" /> <Icon name="search" className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
@@ -2724,7 +2720,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
/> />
</div> </div>
</div> </div>
<ScrollableOverlay outerClassName="max-h-[min(400px,calc(100dvh-12rem))] flex-1"> <ScrollableOverlay outerClassName="max-h-[min(400px,calc(var(--available-height)-4rem))] flex-1">
<div className="p-1"> <div className="p-1">
{!agentSearchQuery.trim() && defaultAgentName && ( {!agentSearchQuery.trim() && defaultAgentName && (
<> <>
@@ -2746,12 +2742,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div> </div>
) : ( ) : (
sortedAndFilteredAgents.map((agent) => ( sortedAndFilteredAgents.map((agent) => (
<DropdownMenuItem <AgentDescriptionTooltip key={agent.name} description={agent.description}>
key={agent.name} <DropdownMenuItem
className="typography-meta" className="typography-meta"
onSelect={() => handleAgentChange(agent.name)} onSelect={() => handleAgentChange(agent.name)}
> >
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className={cn( <div className={cn(
'h-1 w-1 rounded-full agent-dot', 'h-1 w-1 rounded-full agent-dot',
@@ -2759,13 +2754,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
)} /> )} />
<span className="font-medium">{capitalizeAgentName(agent.name)}</span> <span className="font-medium">{capitalizeAgentName(agent.name)}</span>
</div> </div>
{agent.description && ( </DropdownMenuItem>
<span className="typography-meta text-muted-foreground max-w-[200px] ml-2.5 break-words"> </AgentDescriptionTooltip>
{agent.description}
</span>
)}
</div>
</DropdownMenuItem>
)) ))
)} )}
</div> </div>
@@ -107,7 +107,7 @@ export const PendingChangesBar: React.FC = React.memo(() => {
> >
<Icon name="file-edit" className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]" /> <Icon name="file-edit" className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]" />
<span className="min-w-0 typography-ui-label text-foreground flex-shrink-0">{labelHead}</span> <span className="min-w-0 typography-ui-label text-foreground flex-shrink-0">{labelHead}</span>
<span className="status-row__changed-label min-w-0 typography-ui-label text-foreground truncate"> <span className="composer-status-bar__changed-label min-w-0 typography-ui-label text-foreground truncate">
{t('chat.pendingChanges.changedInWorkspace')} {t('chat.pendingChanges.changedInWorkspace')}
</span> </span>
<span className="text-[0.75rem] tabular-nums inline-flex items-baseline gap-1 flex-shrink-0"> <span className="text-[0.75rem] tabular-nums inline-flex items-baseline gap-1 flex-shrink-0">
@@ -66,6 +66,14 @@ const getToolIcon = (toolName: string) => {
return <Icon name="global" className={iconClass} />; return <Icon name="global" className={iconClass} />;
} }
if (tool === 'linear' || tool.startsWith('linear_')) {
return <Icon name="linear" className={iconClass} />;
}
if (tool === 'cloudflare' || tool.startsWith('cloudflare_') || tool === 'claudflare' || tool.startsWith('claudflare_')) {
return <Icon name="cloudflare" className={iconClass} />;
}
return <Icon name="tools" className={iconClass} />; return <Icon name="tools" className={iconClass} />;
}; };
@@ -4,6 +4,7 @@ import { useSkillsStore } from '@/stores/useSkillsStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight'; import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
interface SkillInfo { interface SkillInfo {
name: string; name: string;
@@ -31,7 +32,7 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
}, ref) => { }, ref) => {
const containerRef = React.useRef<HTMLDivElement | null>(null); const containerRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useUIStore((state) => state.isMobile); const isMobile = useUIStore((state) => state.isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile); const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true, 240);
const [selectedIndex, setSelectedIndex] = React.useState(0); const [selectedIndex, setSelectedIndex] = React.useState(0);
const selectedIndexRef = React.useRef(0); const selectedIndexRef = React.useRef(0);
const keyboardNavigationRef = React.useRef(false); const keyboardNavigationRef = React.useRef(false);
@@ -126,6 +127,7 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
const isProject = skill.scope === 'project'; const isProject = skill.scope === 'project';
const source = skill.source || 'opencode'; const source = skill.source || 'opencode';
return ( return (
<AutocompleteRowTooltip description={skill.description} active={!isMobile && index === selectedIndex}>
<div <div
key={`${skill.name}-${skill.scope}`} key={`${skill.name}-${skill.scope}`}
ref={(el) => { ref={(el) => {
@@ -157,13 +159,9 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
{source} {source}
</span> </span>
</div> </div>
{skill.description && !isMobile && (
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
{skill.description}
</div>
)}
</div> </div>
</div> </div>
</AutocompleteRowTooltip>
); );
}; };
@@ -32,7 +32,7 @@ export const SnippetAutocomplete = React.forwardRef<SnippetAutocompleteHandle, S
const { t } = useI18n(); const { t } = useI18n();
const containerRef = React.useRef<HTMLDivElement | null>(null); const containerRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useUIStore((state) => state.isMobile); const isMobile = useUIStore((state) => state.isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile); const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true, 240);
const [selectedIndex, setSelectedIndex] = React.useState(0); const [selectedIndex, setSelectedIndex] = React.useState(0);
const selectedIndexRef = React.useRef(0); const selectedIndexRef = React.useRef(0);
const [filteredSnippets, setFilteredSnippets] = React.useState<Snippet[]>([]); const [filteredSnippets, setFilteredSnippets] = React.useState<Snippet[]>([]);
+19 -313
View File
@@ -1,123 +1,18 @@
import React from "react"; import React from "react";
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { cn } from "@/lib/utils";
import { useDirectorySync } from "@/sync/sync-context";
import type { Todo } from "@opencode-ai/sdk/v2/client";
// Compat aliases for old TodoItem shape
type TodoItem = Todo & { id?: string };
type TodoStatus = string;
type TodoPriority = string;
import { useUIStore } from "@/stores/useUIStore";
import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder"; import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
import { isVSCodeRuntime } from "@/lib/desktop";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
// The floating assistant-status chip that hovers above the composer while the
// agent works ("Claude is working…", abort notice). ONLY that. The composer's
// own bar — pending changes, todos dropdown — is ComposerStatusBar: they used
// to share this component, and every restyle of this chip (glass, placement)
// silently dragged the composer bar and its dropdown along with it.
const STATUS_ROW_CONTAINER_STYLE = { containerType: "inline-size" as const, containerName: "status-row" }; const STATUS_ROW_CONTAINER_STYLE = { containerType: "inline-size" as const, containerName: "status-row" };
const statusConfig: Record<TodoStatus, { textClassName: string }> = {
in_progress: {
textClassName: "text-foreground",
},
pending: {
textClassName: "text-foreground",
},
completed: {
textClassName: "text-muted-foreground line-through",
},
cancelled: {
textClassName: "text-muted-foreground line-through",
},
};
const priorityClassName: Record<TodoPriority, string> = {
high: "text-[var(--status-warning)]",
medium: "text-muted-foreground",
low: "text-muted-foreground/70",
};
const priorityIcon: Record<TodoPriority, React.ReactNode> = {
high: <Icon name="arrow-up-double" className="h-3.5 w-3.5" aria-hidden="true"/>,
medium: <Icon name="arrow-up-s" className="h-3.5 w-3.5" aria-hidden="true"/>,
low: <Icon name="arrow-down-s" className="h-3.5 w-3.5" aria-hidden="true"/>,
};
const statusLabelKey: Record<TodoStatus, string> = {
in_progress: "chat.statusRow.todo.status.inProgress",
pending: "chat.statusRow.todo.status.pending",
completed: "chat.statusRow.todo.status.completed",
cancelled: "chat.statusRow.todo.status.cancelled",
};
const priorityLabelKey: Record<TodoPriority, string> = {
high: "chat.statusRow.todo.priority.high",
medium: "chat.statusRow.todo.priority.medium",
low: "chat.statusRow.todo.priority.low",
};
interface TodoItemRowProps {
todo: TodoItem;
}
const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
const { t } = useI18n();
const config = statusConfig[todo.status] || statusConfig.pending;
const statusKey = statusLabelKey[todo.status] ?? statusLabelKey.pending;
const priorityKey = priorityLabelKey[todo.priority] ?? priorityLabelKey.medium;
const statusIcon =
todo.status === "in_progress" ? (
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" aria-hidden="true"/>
) : todo.status === "completed" ? (
<Icon name="checkbox-circle" className="h-3.5 w-3.5 text-[var(--status-success)]" aria-hidden="true"/>
) : (
<Icon name="time" className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true"/>
);
return (
<div className="flex items-center min-w-0 py-0.5 gap-2">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-shrink-0">{statusIcon}</span>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
{t(statusKey as never)}
</TooltipContent>
</Tooltip>
<span
className={cn(
"flex-1 typography-ui-label",
config.textClassName
)}
>
{todo.content}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"typography-meta flex items-center justify-center flex-shrink-0 leading-none",
priorityClassName[todo.priority] ?? priorityClassName.medium
)}
>
{priorityIcon[todo.priority] ?? priorityIcon.medium}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{t(priorityKey as never)}
</TooltipContent>
</Tooltip>
</div>
);
};
const EMPTY_TODOS: TodoItem[] = [];
interface StatusRowProps { interface StatusRowProps {
// Working state
isWorking?: boolean; isWorking?: boolean;
statusText?: string | null; statusText?: string | null;
isGenericStatus?: boolean; isGenericStatus?: boolean;
@@ -125,17 +20,10 @@ interface StatusRowProps {
wasAborted?: boolean; wasAborted?: boolean;
abortActive?: boolean; abortActive?: boolean;
retryInfo?: { attempt?: number; next?: number } | null; retryInfo?: { attempt?: number; next?: number } | null;
// Abort state (for mobile/vscode)
showAbort?: boolean;
onAbort?: () => void;
// Abort status display
showAbortStatus?: boolean; showAbortStatus?: boolean;
showAssistantStatus?: boolean;
showTodos?: boolean;
agentName?: string; agentName?: string;
modelName?: string | null; modelName?: string | null;
providerId?: string | null; providerId?: string | null;
leftAccessory?: React.ReactNode;
} }
export const StatusRow: React.FC<StatusRowProps> = ({ export const StatusRow: React.FC<StatusRowProps> = ({
@@ -146,183 +34,43 @@ export const StatusRow: React.FC<StatusRowProps> = ({
wasAborted, wasAborted,
abortActive, abortActive,
retryInfo, retryInfo,
showAbort,
onAbort,
showAbortStatus, showAbortStatus,
showAssistantStatus = true,
showTodos = true,
agentName, agentName,
modelName, modelName,
providerId, providerId,
leftAccessory,
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
const [isExpanded, setIsExpanded] = React.useState(false);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionDirectory = useSessionUIStore(
React.useCallback(
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
[currentSessionId],
),
);
const liveTodos = useDirectorySync(
React.useCallback(
(state) => {
if (!showTodos || !currentSessionId) return EMPTY_TODOS;
return state.todo[currentSessionId] ?? EMPTY_TODOS;
},
[currentSessionId, showTodos],
),
);
const persistedSessionTodos = useTodosPersistStore(
React.useCallback(
(state) => (showTodos && currentSessionId && currentSessionDirectory
? state.getSessionTodos(currentSessionDirectory, currentSessionId)
: undefined),
[currentSessionDirectory, currentSessionId, showTodos],
),
);
const todos: TodoItem[] = React.useMemo(() => {
if (!currentSessionId) return EMPTY_TODOS;
if (liveTodos.length > 0) return liveTodos;
return persistedSessionTodos ?? EMPTY_TODOS;
}, [liveTodos, persistedSessionTodos, currentSessionId]);
const isMobile = useUIStore((state) => state.isMobile);
const isCompact = isMobile || isVSCodeRuntime();
// Filter out cancelled todos for display and keep original order.
// This prevents items from jumping around when status changes.
const visibleTodos = React.useMemo(() => {
return todos.filter((todo) => todo.status !== "cancelled");
}, [todos]);
// Find the current active todo (first in_progress, or first pending)
const activeTodo = React.useMemo(() => {
return (
visibleTodos.find((t) => t.status === "in_progress") ||
visibleTodos.find((t) => t.status === "pending") ||
null
);
}, [visibleTodos]);
// Calculate progress
const progress = React.useMemo(() => {
const total = todos.filter((t) => t.status !== "cancelled").length;
const completed = todos.filter((t) => t.status === "completed").length;
return { completed, total };
}, [todos]);
const statusSummary = React.useMemo(() => {
const active = visibleTodos.filter((t) => t.status === "in_progress").length;
const left = visibleTodos.filter((t) => t.status === "in_progress" || t.status === "pending").length;
return { active, left };
}, [visibleTodos]);
const hasTodoContent = showTodos && statusSummary.left > 0;
const hasAssistantContent = showAssistantStatus && (
isWorking ||
Boolean(wasAborted) ||
Boolean(showAbortStatus)
);
const hasLeftAccessory = Boolean(leftAccessory);
// Original logic from ChatInput
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive); const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
const hasContent = isWorking || Boolean(wasAborted) || Boolean(showAbortStatus);
const hasContent = hasAssistantContent || hasTodoContent || hasLeftAccessory;
// Close popover when clicking outside
const popoverRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!isExpanded) return;
const handleClickOutside = (event: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
setIsExpanded(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isExpanded]);
const toggleExpanded = () => setIsExpanded((prev) => !prev);
const todoSummaryLabel = t('chat.statusRow.summary.activeLeft', {
active: statusSummary.active,
left: statusSummary.left,
});
// Abort button for mobile/vscode
const abortButton = showAbort && onAbort ? (
<button
type="button"
onClick={onAbort}
className="flex items-center justify-center h-[1.2rem] w-[1.2rem] text-[var(--status-error)] transition-opacity hover:opacity-80 focus-visible:outline-none flex-shrink-0"
aria-label={t('chat.statusRow.actions.stopGeneratingAria')}
>
<Icon name="close-circle" aria-hidden="true"/>
</button>
) : null;
// Todo trigger button
const todoTrigger = hasTodoContent ? (
<button
type="button"
onClick={toggleExpanded}
className="flex items-center gap-1 flex-shrink-0 text-muted-foreground"
aria-label={todoSummaryLabel}
title={todoSummaryLabel}
>
{/* Desktop: show task text; Mobile/VSCode: just "Tasks" */}
{!isCompact && activeTodo ? (
<span className="status-row__active-todo typography-ui-label text-foreground truncate max-w-[200px]">
{activeTodo.content}
</span>
) : (
<span className="typography-ui-label">{t('chat.statusRow.tasksTitle')}</span>
)}
<span className="typography-meta flex items-center gap-1 tabular-nums" aria-hidden="true">
<span className="flex items-center gap-0.5">
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" />
{statusSummary.active}
</span>
<span>·</span>
<span className="flex items-center gap-0.5">
<Icon name="time" className="h-3.5 w-3.5" />
{statusSummary.left}
</span>
</span>
{isExpanded ? (
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
) : (
<Icon name="arrow-down-s" className="h-3.5 w-3.5" />
)}
</button>
) : null;
// Don't render if nothing to show
if (!hasContent) { if (!hasContent) {
return null; return null;
} }
return ( return (
<div <div
// Mobile: breathing room between the last message and the agent status // The row renders inside the composer-anchored overlay, which owns the
// line — without it the "<model> is running…" row sits flush against // distance to the input and the horizontal column (the same ones the
// the message above. // scroll-to-bottom pill uses).
className={cn("mb-1", isMobile && "mt-2", !hasLeftAccessory && "chat-column")}
style={STATUS_ROW_CONTAINER_STYLE} style={STATUS_ROW_CONTAINER_STYLE}
> >
<div className={cn("flex items-center justify-between py-0.5 gap-2 h-[1.2rem]", hasLeftAccessory && "px-0.5")}> {/* h-8 matches the turn footer's real row height: its h-8 action
{/* Left: Abort status | Working placeholder | leftAccessory */} buttons define the footer line, with the meta text centered in it. */}
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}> {/* The glass chip lives here, not on the container: the root above is
{showAssistantStatus && showAbortStatus ? ( an inline-size query container, whose width ignores its children
a shrink-to-fit wrapper around it always collapsed to zero. */}
<div className="oc-glass-popover inline-flex w-max max-w-full items-center gap-2 h-8 whitespace-nowrap rounded-full [corner-shape:round] px-3">
<div className="flex items-center min-w-0 gap-2 overflow-x-hidden">
{showAbortStatus ? (
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5"> <div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
<span className="flex items-center gap-1.5 typography-ui-label"> <span className="flex items-center gap-1.5 typography-ui-label">
<Icon name="close-circle" aria-hidden="true"/> <Icon name="close-circle" aria-hidden="true"/>
{t('chat.statusRow.aborted')} {t('chat.statusRow.aborted')}
</span> </span>
</div> </div>
) : showAssistantStatus && shouldRenderPlaceholder ? ( ) : shouldRenderPlaceholder ? (
<WorkingPlaceholder <WorkingPlaceholder
key={currentSessionId ?? "no-session"} key={currentSessionId ?? "no-session"}
isWorking={isWorking} isWorking={isWorking}
@@ -334,50 +82,8 @@ export const StatusRow: React.FC<StatusRowProps> = ({
modelName={modelName} modelName={modelName}
providerId={providerId} providerId={providerId}
/> />
) : leftAccessory ? (
leftAccessory
) : null} ) : null}
</div> </div>
{/* Right: Abort (mobile only) + Todo */}
<div className={cn("relative flex items-center gap-2 flex-shrink-0", hasLeftAccessory ? "pr-1.5" : "-mr-3")} ref={popoverRef}>
{abortButton}
{todoTrigger}
{/* Popover dropdown */}
{isExpanded && hasTodoContent && (
<div
style={{
maxWidth: "min(28rem, calc(100cqw - 4ch))",
backgroundColor: "var(--surface-elevated)",
color: "var(--surface-elevated-foreground)",
}}
className={cn(
"absolute right-0 bottom-full mb-1 z-50",
"w-max min-w-[200px] rounded-xl p-1",
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)]",
"dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)]",
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
"duration-150"
)}
>
{/* Header */}
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
<span>{t('chat.statusRow.tasksTitle')}</span>
<span className="typography-meta tabular-nums">
{progress.completed}/{progress.total}
</span>
</div>
{/* Todo list */}
<div className="px-1 max-h-[200px] overflow-y-auto">
{visibleTodos.map((todo, index) => (
<TodoItemRow key={todo.id ?? `todo-${index}`} todo={todo} />
))}
</div>
</div>
)}
</div>
</div> </div>
</div> </div>
); );
@@ -46,8 +46,6 @@ export const StatusRowContainer: React.FC = React.memo(() => {
wasAborted={wasAborted || working.wasAborted} wasAborted={wasAborted || working.wasAborted}
abortActive={wasAborted || working.abortActive} abortActive={wasAborted || working.abortActive}
retryInfo={working.retryInfo} retryInfo={working.retryInfo}
showAssistantStatus
showTodos={false}
agentName={currentAgentName} agentName={currentAgentName}
modelName={modelDisplayName} modelName={modelDisplayName}
providerId={activeModel?.providerId ?? null} providerId={activeModel?.providerId ?? null}
@@ -223,20 +223,48 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
if (!currentSessionId) return null; if (!currentSessionId) return null;
const turnActions = (
<>
<button
type="button"
className="text-[11px] uppercase tracking-wide text-muted-foreground/90 hover:text-foreground"
onClick={() => {
void onScrollByTurnOffset?.(-1);
onOpenChange(false);
}}
>
{t('chat.timeline.actions.previousTurn')}
</button>
<span className="text-muted-foreground/50">/</span>
<button
type="button"
className="text-[11px] uppercase tracking-wide text-muted-foreground/90 hover:text-foreground"
onClick={() => {
onResumeToLatest?.();
onOpenChange(false);
}}
>
{t('chat.timeline.actions.latest')}
</button>
</>
);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col"> <DialogContent className="max-w-2xl max-h-[70vh] max-md:max-h-[85dvh] flex flex-col overflow-y-auto">
<DialogHeader> <DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Icon name="time" className="h-5 w-5" /> <Icon name="time" className="h-5 w-5" />
{t('chat.timeline.title')} {t('chat.timeline.title')}
</DialogTitle> </DialogTitle>
<DialogDescription> {!isMobile && (
{t('chat.timeline.description')} <DialogDescription>
</DialogDescription> {t('chat.timeline.description')}
</DialogDescription>
)}
</DialogHeader> </DialogHeader>
<div className="relative mt-2"> <div className="relative mt-2 shrink-0">
<Icon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Icon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
autoFocus autoFocus
@@ -249,7 +277,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
</div> </div>
{canLoadEarlier && onLoadEarlier && ( {canLoadEarlier && onLoadEarlier && (
<div className="flex justify-center py-1"> <div className="flex shrink-0 justify-center py-1">
<Button <Button
type="button" type="button"
variant="link" variant="link"
@@ -266,7 +294,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
</div> </div>
)} )}
<div ref={listRef} className="flex-1 overflow-y-auto"> <div ref={listRef} className="min-h-0 flex-1 overflow-y-auto">
{filteredMessages.length === 0 ? ( {filteredMessages.length === 0 ? (
<div className="text-center text-muted-foreground py-8"> <div className="text-center text-muted-foreground py-8">
{searchQuery ? t('chat.timeline.empty.search') : t('chat.timeline.empty.session')} {searchQuery ? t('chat.timeline.empty.search') : t('chat.timeline.empty.session')}
@@ -312,7 +340,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
onMouseEnter={() => setSelectedIndex(index)} onMouseEnter={() => setSelectedIndex(index)}
> >
<span className={cn( <span className={cn(
"typography-meta w-16 flex-shrink-0 text-right tabular-nums", "typography-meta min-w-16 flex-shrink-0 text-right tabular-nums whitespace-nowrap",
isSelected ? "text-interactive-selection-foreground/70" : "text-muted-foreground" isSelected ? "text-interactive-selection-foreground/70" : "text-muted-foreground"
)}> )}>
{messageTime} {messageTime}
@@ -373,45 +401,31 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
)} )}
</div> </div>
<div className="mt-4 p-3 bg-muted/30 rounded-lg"> {isMobile ? (
<p className="typography-meta text-muted-foreground font-medium mb-2">{t('chat.timeline.actions.title')}</p> <div className="mt-2 flex shrink-0 items-center justify-center gap-2 border-t border-border/60 pt-2">
<div className="mb-2 flex items-center gap-2"> {turnActions}
<button
type="button"
className="text-[11px] uppercase tracking-wide text-muted-foreground/90 hover:text-foreground"
onClick={() => {
void onScrollByTurnOffset?.(-1);
onOpenChange(false);
}}
>
{t('chat.timeline.actions.previousTurn')}
</button>
<span className="text-muted-foreground/50">/</span>
<button
type="button"
className="text-[11px] uppercase tracking-wide text-muted-foreground/90 hover:text-foreground"
onClick={() => {
onResumeToLatest?.();
onOpenChange(false);
}}
>
{t('chat.timeline.actions.latest')}
</button>
</div> </div>
<div className="flex flex-col gap-1.5 typography-meta text-muted-foreground"> ) : (
<div className="flex items-center gap-2"> <div className="mt-4 p-3 bg-muted/30 rounded-lg shrink-0">
<span>{t('chat.timeline.help.clickMessage')}</span> <p className="typography-meta text-muted-foreground font-medium mb-2">{t('chat.timeline.actions.title')}</p>
<div className="mb-2 flex items-center gap-2">
{turnActions}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-col gap-1.5 typography-meta text-muted-foreground">
<Icon name="arrow-go-back" className="h-4 w-4 flex-shrink-0" /> <div className="flex items-center gap-2">
<span>{t('chat.timeline.help.undoToPoint')}</span> <span>{t('chat.timeline.help.clickMessage')}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Icon name="git-branch" className="h-4 w-4 flex-shrink-0" /> <Icon name="arrow-go-back" className="h-4 w-4 flex-shrink-0" />
<span>{t('chat.timeline.help.createSessionFromHere')}</span> <span>{t('chat.timeline.help.undoToPoint')}</span>
</div>
<div className="flex items-center gap-2">
<Icon name="git-branch" className="h-4 w-4 flex-shrink-0" />
<span>{t('chat.timeline.help.createSessionFromHere')}</span>
</div>
</div> </div>
</div> </div>
</div> )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
@@ -226,20 +226,23 @@ describe('issue #2903 busy embedded subagent status-line-only', () => {
expect(chatContainerSource).toContain('void ensureSessionRenderable(currentSessionId);'); expect(chatContainerSource).toContain('void ensureSessionRenderable(currentSessionId);');
}); });
test('empty+busy branch skips empty state so StatusRowContainer can stand alone', () => { test('the empty and idle branch leaves the status row to the busy path', () => {
// A busy session with no messages yet must fall through to the viewport so
// StatusRowContainer is the only thing on screen. The idle branch returns
// before it and must not render one of its own. The empty state itself no
// longer lives here: the draft surface owns it since the draft transition
// animation landed.
expect(chatContainerSource).toContain('if (sessionMessages.length === 0 && !sessionIsWorking)'); expect(chatContainerSource).toContain('if (sessionMessages.length === 0 && !sessionIsWorking)');
expect(chatContainerSource).toContain('<ChatEmptyState');
expect(chatContainerSource).toContain('<StatusRowContainer />'); expect(chatContainerSource).toContain('<StatusRowContainer />');
const emptyBusyGuard = 'if (sessionMessages.length === 0 && !sessionIsWorking)'; const emptyIdleGuard = 'if (sessionMessages.length === 0 && !sessionIsWorking)';
const emptyStateReturn = chatContainerSource.indexOf(emptyBusyGuard); const emptyIdleReturn = chatContainerSource.indexOf(emptyIdleGuard);
expect(emptyStateReturn).toBeGreaterThan(-1); expect(emptyIdleReturn).toBeGreaterThan(-1);
const emptyStateBlock = chatContainerSource.slice( const emptyIdleBlock = chatContainerSource.slice(
emptyStateReturn, emptyIdleReturn,
emptyStateReturn + 1600, emptyIdleReturn + 1600,
); );
expect(emptyStateBlock).toContain('<ChatEmptyState'); expect(emptyIdleBlock).not.toContain('<StatusRowContainer />');
expect(emptyStateBlock).not.toContain('<StatusRowContainer />');
}); });
test('visibility handshake remains as defense-in-depth for background work', () => { test('visibility handshake remains as defense-in-depth for background work', () => {
@@ -0,0 +1,33 @@
/**
* Regression coverage for https://github.com/openchamber/openchamber/issues/3036.
*
* Restoring persisted agent/model pairs used to switch agents before checking
* whether each model still existed. Several stale pairs could therefore keep
* changing the active agent on every effect pass until React hit its nested
* update limit. The API error belongs in the assistant message; an invalid
* persisted pair must not mutate the current selection while it is rendered.
*/
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const modelControlsSource = readFileSync(join(__dirname, '..', 'ModelControls.tsx'), 'utf-8');
describe('issue #3036 stale persisted models', () => {
test('changes the agent only after its persisted model is accepted', () => {
const candidateLoop = modelControlsSource.slice(
modelControlsSource.indexOf('for (const agent of agents)'),
modelControlsSource.indexOf("return 'continue';"),
);
const applyIndex = candidateLoop.indexOf('const result = tryApplyModelSelection');
const acceptedIndex = candidateLoop.indexOf("if (result === 'applied')");
const setAgentIndex = candidateLoop.indexOf('setAgent(agent.name)');
expect(applyIndex).toBeGreaterThanOrEqual(0);
expect(acceptedIndex).toBeGreaterThan(applyIndex);
expect(setAgentIndex).toBeGreaterThan(acceptedIndex);
});
});
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore';
import {
getAppLinkConfirmationSnapshot,
openAppLinkWithConfirmation,
settleAppLinkConfirmation,
} from './appLinkConfirmation';
describe('app link confirmation', () => {
beforeEach(() => {
useAppLinkTrustStore.setState({ trustedSchemes: [] });
const pending = getAppLinkConfirmationSnapshot();
if (pending) {
settleAppLinkConfirmation('cancel');
}
});
test('opens trusted schemes without asking', async () => {
useAppLinkTrustStore.getState().trustScheme('obsidian');
await openAppLinkWithConfirmation('obsidian://open?vault=Notebook&file=notes');
expect(getAppLinkConfirmationSnapshot()).toBeNull();
expect(useAppLinkTrustStore.getState().isSchemeTrusted('obsidian')).toBe(true);
});
test('asks once and trusts the scheme when the user chooses trust', async () => {
const pending = openAppLinkWithConfirmation('linear://issue/ABC-1');
expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://issue/ABC-1');
settleAppLinkConfirmation('trust');
await pending;
expect(getAppLinkConfirmationSnapshot()).toBeNull();
expect(useAppLinkTrustStore.getState().isSchemeTrusted('linear')).toBe(true);
});
test('cancel opens nothing and keeps the scheme untrusted', async () => {
const pending = openAppLinkWithConfirmation('notion://note/xyz');
settleAppLinkConfirmation('cancel');
await pending;
expect(getAppLinkConfirmationSnapshot()).toBeNull();
expect(useAppLinkTrustStore.getState().isSchemeTrusted('notion')).toBe(false);
});
test('a newer request cancels the pending one', async () => {
const first = openAppLinkWithConfirmation('obsidian://open?vault=a');
const firstChoice = first.then(
() => 'settled',
() => 'settled',
);
const second = openAppLinkWithConfirmation('linear://open/1');
expect(await firstChoice).toBe('settled');
expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://open/1');
settleAppLinkConfirmation('open');
await second;
expect(getAppLinkConfirmationSnapshot()).toBeNull();
});
});
@@ -0,0 +1,71 @@
import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore';
import { getUrlScheme, openConfirmedAppLinkUrl } from '@/lib/url';
export type AppLinkConfirmationChoice = 'open' | 'trust' | 'cancel';
type PendingAppLinkRequest = {
url: string;
resolve: (choice: AppLinkConfirmationChoice) => void;
};
let pendingRequest: PendingAppLinkRequest | null = null;
const listeners = new Set<() => void>();
const emitChange = (): void => {
for (const listener of listeners) {
listener();
}
};
const getSnapshot = (): PendingAppLinkRequest | null => pendingRequest;
const subscribe = (listener: () => void): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
/**
* Ask the user (via the app-level confirmation dialog) whether an application
* deep link may be opened. Resolves immediately when the scheme was trusted
* earlier. Only one request is active at a time; a new request cancels the
* pending one.
*/
export const openAppLinkWithConfirmation = (url: string): Promise<void> => {
const scheme = getUrlScheme(url);
if (!scheme) {
return Promise.resolve();
}
const trustStore = useAppLinkTrustStore.getState();
if (trustStore.isSchemeTrusted(scheme)) {
return openConfirmedAppLinkUrl(url).then(() => undefined);
}
if (pendingRequest) {
pendingRequest.resolve('cancel');
}
return new Promise<AppLinkConfirmationChoice>((resolve) => {
pendingRequest = { url, resolve };
emitChange();
}).then((choice) => {
if (choice === 'trust') {
useAppLinkTrustStore.getState().trustScheme(scheme);
}
if (choice === 'open' || choice === 'trust') {
return openConfirmedAppLinkUrl(url).then(() => undefined);
}
});
};
export const settleAppLinkConfirmation = (choice: AppLinkConfirmationChoice): void => {
const request = pendingRequest;
pendingRequest = null;
emitChange();
request?.resolve(choice);
};
export const subscribeAppLinkConfirmation = subscribe;
export const getAppLinkConfirmationSnapshot = getSnapshot;
@@ -0,0 +1,93 @@
import { describe, expect, test } from 'bun:test';
import { attachAppLinkInteractions } from './appLinkInteractions';
const TestElement = class Element {};
const TestHTMLAnchorElement = class HTMLAnchorElement extends TestElement {};
Object.assign(globalThis, { Element: TestElement, HTMLAnchorElement: TestHTMLAnchorElement });
class TestAnchor extends HTMLAnchorElement {
constructor(private readonly rawHref: string) {
super();
}
getAttribute(name: string): string | null {
return name === 'href' ? this.rawHref : null;
}
closest(): TestAnchor {
return this;
}
}
class TestContainer {
listeners = new Map<string, EventListener>();
addEventListener(name: string, listener: (event: MouseEvent) => void): void {
// SAFETY: dispatch constructs every mouse field read by the production listener.
this.listeners.set(name, (event) => listener(event as MouseEvent));
}
removeEventListener(name: string, listener: (event: MouseEvent) => void): void {
void listener;
this.listeners.delete(name);
}
dispatch(name: string, href: string, init: Partial<MouseEvent> = {}): Event {
const event = new Event(name, { cancelable: true });
Object.defineProperties(event, {
target: { value: new TestAnchor(href) },
button: { value: init.button ?? 0 },
metaKey: { value: init.metaKey ?? false },
ctrlKey: { value: init.ctrlKey ?? false },
altKey: { value: init.altKey ?? false },
shiftKey: { value: init.shiftKey ?? false },
});
this.listeners.get(name)?.(event);
return event;
}
}
const setup = (allowExternalHttp = true) => {
const container = new TestContainer();
const appLinks: string[] = [];
const httpLinks: string[] = [];
const cleanup = attachAppLinkInteractions(container, {
allowExternalHttp,
openAppLink: (url) => appLinks.push(url),
openExternalHttp: (url) => httpLinks.push(url),
});
return { container, appLinks, httpLinks, cleanup };
};
describe('app link interactions', () => {
test('confirms plain, modifier, and middle-click activations', () => {
const { container, appLinks } = setup();
const href = 'obsidian://open?vault=Notes';
expect(container.dispatch('click', href).defaultPrevented).toBe(true);
expect(container.dispatch('click', href, { metaKey: true }).defaultPrevented).toBe(true);
expect(container.dispatch('auxclick', href, { button: 1 }).defaultPrevented).toBe(true);
expect(appLinks).toEqual([href, href, href]);
});
test('blocks drag activation without opening immediately', () => {
const { container, appLinks } = setup();
const href = 'obsidian://open?vault=Notes';
expect(container.dispatch('dragstart', href).defaultPrevented).toBe(true);
expect(appLinks).toEqual([]);
});
test('keeps HTTP modifier behavior and the disabled HTTP path unchanged', () => {
const enabled = setup();
const disabled = setup(false);
const href = 'https://example.com';
expect(enabled.container.dispatch('click', href, { ctrlKey: true }).defaultPrevented).toBe(false);
expect(enabled.container.dispatch('click', href).defaultPrevented).toBe(true);
expect(disabled.container.dispatch('click', href).defaultPrevented).toBe(false);
expect(enabled.httpLinks).toEqual([href]);
expect(disabled.httpLinks).toEqual([]);
});
});
@@ -0,0 +1,75 @@
import { isAppLinkUrl, isExternalHttpUrl } from '@/lib/url';
type AppLinkInteractionOptions = {
allowExternalHttp: boolean;
openAppLink: (url: string) => void;
openExternalHttp: (url: string) => void;
};
type LinkInteractionContainer = {
addEventListener: (type: string, listener: (event: MouseEvent) => void) => void;
removeEventListener: (type: string, listener: (event: MouseEvent) => void) => void;
};
const findLink = (event: MouseEvent | DragEvent): HTMLAnchorElement | null => {
const target = event.target;
if (!(target instanceof Element)) return null;
const anchor = target.closest('a[href]');
if (!(anchor instanceof HTMLAnchorElement)) return null;
if (anchor.getAttribute('data-openchamber-file-link') === 'true') return null;
return anchor;
};
const interceptAppLink = (
event: MouseEvent | DragEvent,
openAppLink?: (url: string) => void,
): boolean => {
if (event.defaultPrevented) return false;
const anchor = findLink(event);
const href = anchor?.getAttribute('href') ?? '';
if (!isAppLinkUrl(href)) return false;
event.preventDefault();
event.stopPropagation();
openAppLink?.(href);
return true;
};
const isPlainPrimaryClick = (event: MouseEvent): boolean => (
event.button === 0
&& !event.metaKey
&& !event.ctrlKey
&& !event.altKey
&& !event.shiftKey
);
export const attachAppLinkInteractions = (
container: LinkInteractionContainer,
options: AppLinkInteractionOptions,
): (() => void) => {
const handleClick = (event: MouseEvent) => {
if (interceptAppLink(event, options.openAppLink)) return;
if (!options.allowExternalHttp || event.defaultPrevented || !isPlainPrimaryClick(event)) return;
const href = findLink(event)?.getAttribute('href') ?? '';
if (!isExternalHttpUrl(href)) return;
event.preventDefault();
event.stopPropagation();
options.openExternalHttp(href);
};
const handleAuxClick = (event: MouseEvent) => {
if (event.button === 1) interceptAppLink(event, options.openAppLink);
};
const blockAlternateAppLinkActivation = (event: MouseEvent | DragEvent) => {
interceptAppLink(event);
};
container.addEventListener('click', handleClick);
container.addEventListener('auxclick', handleAuxClick);
container.addEventListener('dragstart', blockAlternateAppLinkActivation);
return () => {
container.removeEventListener('click', handleClick);
container.removeEventListener('auxclick', handleAuxClick);
container.removeEventListener('dragstart', blockAlternateAppLinkActivation);
};
};
@@ -0,0 +1,477 @@
import React from 'react';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
import { useBtwStore } from '@/stores/useBtwStore';
import { useSync } from '@/sync/use-sync';
import {
useSessionMessageRecords,
useSessionRenderable,
useSessionStatus,
useScopedBlockingPermissions,
useScopedBlockingQuestions,
} from '@/sync/sync-context';
import { useStreamingStore } from '@/sync/streaming';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { destroyBtwSession, filterBtwTailMessages, promoteBtwSession, type BtwSessionRef } from '@/lib/btw';
import type { BtwPanelState } from './useBtwPanelState';
import { ChatSurfaceProvider } from '../ChatSurfaceContext';
import { useMobileAutocompleteMaxHeight } from '../useMobileAutocompleteMaxHeight';
import ChatMessage from '../ChatMessage';
import { PermissionCard } from '../PermissionCard';
import { QuestionCard } from '../QuestionCard';
const IDLE_SESSION_STATUS = { type: 'idle' as const };
/**
* The `/btw` peek panel.
*
* Rendered from inside the composer form, so the sheet docks exactly above
* the main composer (`absolute bottom-full` on the composer column) on both
* desktop and mobile the main composer IS the btw input, so nothing may
* cover it. Identity is derived from the parent session's metadata (see
* `useBtwPanelState`), so the panel belongs to one parent session only.
*
* Three exits: collapse (panel minimizes to the composer chip, the composer
* returns to the main session), promote (the fork becomes a normal session
* and the app navigates to it), destroy (the fork is deleted; the main
* conversation is never touched).
*/
export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState }> = ({
parentSessionId,
panel,
}) => {
const { t } = useI18n();
if (panel.btwSessionId && panel.btwDirectory) {
return (
<BtwSheet
sessionRef={{
parentSessionId,
btwSessionId: panel.btwSessionId,
directory: panel.btwDirectory,
}}
title={panel.btwSession?.title?.trim() || t('chat.btw.titleFallback')}
boundaryMessageID={panel.boundaryMessageID}
collapsed={panel.collapsed}
/>
);
}
if (panel.creating) {
return (
<BtwFrame title={t('chat.btw.titleFallback')}>
<div className="flex items-center gap-2 px-4 py-4 text-sm text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
<span>{t('chat.btw.loading')}</span>
</div>
</BtwFrame>
);
}
return null;
};
const useBtwDestroy = (sessionRef: BtwSessionRef | null): (() => void) => {
const { t } = useI18n();
return React.useCallback(() => {
if (!sessionRef) return;
void destroyBtwSession(sessionRef).then((ok) => {
if (!ok) toast.error(t('chat.btw.toast.destroyFailed'));
});
}, [sessionRef, t]);
};
type BtwSessionData = {
messageRecords: Array<{ info: Message; parts: Part[] }>;
sessionIsWorking: boolean;
streamingMessageId: string | null;
activeStreamingPhase: 'streaming' | 'cooldown' | 'completed' | null;
sessionPermissions: ReturnType<typeof useScopedBlockingPermissions>;
sessionQuestions: ReturnType<typeof useScopedBlockingQuestions>;
isEmpty: boolean;
};
/**
* Live session data for the fork, all keyed by the fork's own ids. Only the
* fork's tail (messages after the inherited-history boundary) is shown.
*/
const useBtwSessionData = (
sessionId: string,
directory: string,
boundaryMessageID: string | null,
): BtwSessionData => {
const sync = useSync();
const renderable = useSessionRenderable(sessionId, directory);
React.useEffect(() => {
if (!renderable) {
void sync.ensureSessionRenderable(sessionId, false, directory);
}
}, [directory, renderable, sessionId, sync]);
const messageRecords = useSessionMessageRecords(sessionId, directory);
const status = useSessionStatus(sessionId, directory) ?? IDLE_SESSION_STATUS;
const streamingMessageId = useStreamingStore(
React.useCallback((s) => s.streamingMessageIds.get(sessionId) ?? null, [sessionId]),
);
const activeStreamingPhase = useStreamingStore(
React.useCallback(
(s) => (streamingMessageId ? s.messageStreamStates.get(streamingMessageId)?.phase ?? null : null),
[streamingMessageId],
),
);
const sessionPermissions = useScopedBlockingPermissions(sessionId, directory);
const sessionQuestions = useScopedBlockingQuestions(sessionId, directory);
const tailRecords = React.useMemo(
() => filterBtwTailMessages(messageRecords, boundaryMessageID),
[boundaryMessageID, messageRecords],
);
const sessionIsWorking = React.useMemo(() => {
if (sessionPermissions.length > 0 || sessionQuestions.length > 0) {
return false;
}
const statusType = status.type ?? 'idle';
if (statusType === 'busy' || statusType === 'retry') {
return true;
}
// SAFETY: reads only the optional `time.completed` field, which the
// SDK Message union does not expose uniformly; a missing value means
// the assistant turn has not completed.
const lastMessage = tailRecords[tailRecords.length - 1]?.info as (Message & { time?: { completed?: number } }) | undefined;
return Boolean(
lastMessage
&& lastMessage.role === 'assistant'
&& typeof lastMessage.time?.completed !== 'number',
);
}, [sessionPermissions.length, sessionQuestions.length, status.type, tailRecords]);
return {
messageRecords: tailRecords,
sessionIsWorking,
streamingMessageId,
activeStreamingPhase,
sessionPermissions,
sessionQuestions,
isEmpty: tailRecords.length === 0,
};
};
/** Esc collapses the sheet (never destroys) unless focus is in a text field. */
const useEscapeToCollapse = (onCollapse: () => void): void => {
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
// SAFETY: keydown targets are DOM elements (or null on window).
const target = event.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
onCollapse();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onCollapse]);
};
/**
* Stick-to-bottom auto-scroll. Streaming grows content inside one message
* without changing the record count, so following the tail needs a
* ResizeObserver on the content wrapper data-driven effects alone would
* stop following mid-stream.
*/
const useAutoScroll = (
bodyRef: React.RefObject<HTMLDivElement | null>,
contentRef: React.RefObject<HTMLDivElement | null>,
contentReady: boolean,
): ((event: React.UIEvent<HTMLDivElement>) => void) => {
const stickToBottomRef = React.useRef(true);
// `contentReady` is a dependency because the refs are only attached once
// the empty state gives way to the message list; an effect keyed on the
// refs alone would run against `null` and never re-attach the observer.
React.useEffect(() => {
if (!contentReady) return;
const content = contentRef.current;
const element = bodyRef.current;
if (element && stickToBottomRef.current) {
element.scrollTop = element.scrollHeight;
}
if (!content || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
const body = bodyRef.current;
if (body && stickToBottomRef.current) {
body.scrollTop = body.scrollHeight;
}
});
observer.observe(content);
return () => observer.disconnect();
}, [bodyRef, contentReady, contentRef]);
return React.useCallback((event: React.UIEvent<HTMLDivElement>) => {
const element = event.currentTarget;
stickToBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 80;
}, []);
};
const BtwFrame: React.FC<{
title: string;
actions?: React.ReactNode;
onTitleClick?: () => void;
titleClickLabel?: string;
collapsed?: boolean;
headerSpinner?: boolean;
children?: React.ReactNode;
}> = ({ title, actions, onTitleClick, titleClickLabel, collapsed, headerSpinner, children }) => (
<div
className="chat-input-column absolute bottom-full left-0 right-0 z-30 mb-3"
role="dialog"
aria-label="btw"
>
<div className="oc-glass-popover w-full overflow-hidden rounded-xl border border-[var(--interactive-border)] shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]">
<div className="flex items-center gap-2 px-3 py-1.5">
{onTitleClick ? (
<button
type="button"
onClick={onTitleClick}
aria-label={titleClickLabel}
title={titleClickLabel}
className="flex min-w-0 items-center gap-2 text-left text-muted-foreground transition-colors hover:text-foreground"
>
{headerSpinner ? (
<Icon name="loader-4" className="size-3.5 shrink-0 animate-spin" />
) : (
<Icon name="chat-ai-3" className="size-3.5 shrink-0" />
)}
<span className="typography-ui-label min-w-0 truncate font-semibold">
{title}
</span>
<Icon name={collapsed ? 'arrow-up-s' : 'arrow-down-s'} className="size-4 shrink-0" />
</button>
) : (
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
<Icon name="chat-ai-3" className="size-3.5 shrink-0" />
<h2 className="typography-ui-label min-w-0 truncate font-semibold">
{title}
</h2>
</span>
)}
<div className="min-w-0 flex-1" />
{actions}
</div>
{children ? (
<>
{children}
<div className="h-2" />
</>
) : null}
</div>
</div>
);
const BtwSheet: React.FC<{
sessionRef: BtwSessionRef;
title: string;
boundaryMessageID: string | null;
collapsed: boolean;
}> = ({ sessionRef, title, boundaryMessageID, collapsed }) => {
const { t } = useI18n();
const handleDestroy = useBtwDestroy(sessionRef);
const setCollapsed = React.useCallback((next: boolean) => {
useBtwStore.getState().setPanelState(sessionRef.parentSessionId, { collapsed: next });
}, [sessionRef.parentSessionId]);
const handleToggleCollapsed = React.useCallback(() => setCollapsed(!collapsed), [collapsed, setCollapsed]);
const handleCollapse = React.useCallback(() => setCollapsed(true), [setCollapsed]);
const handlePromote = React.useCallback(() => {
void promoteBtwSession(sessionRef).catch(() => {
toast.error(t('chat.btw.toast.promoteFailed'));
});
}, [sessionRef, t]);
useEscapeToCollapse(handleCollapse);
const toggleLabel = collapsed ? t('chat.btw.expandAria') : t('chat.btw.collapseAria');
const headerButtonClass = 'size-7 rounded-lg text-muted-foreground transition-colors hover:text-foreground hover:!bg-transparent active:!bg-transparent';
const actions = (
<div className="flex shrink-0 items-center gap-0.5">
<Button
type="button"
variant="ghost"
size="icon"
className={headerButtonClass}
onClick={handlePromote}
aria-label={t('chat.btw.promoteAria')}
title={t('chat.btw.promoteAria')}
>
<Icon name="external-link" className="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={headerButtonClass}
onClick={handleDestroy}
aria-label={t('chat.btw.destroyAria')}
title={t('chat.btw.destroyAria')}
>
<Icon name="close" className="size-4" />
</Button>
</div>
);
if (collapsed) {
return (
<BtwCollapsedStrip
sessionRef={sessionRef}
title={title}
actions={actions}
onExpand={handleToggleCollapsed}
expandLabel={toggleLabel}
/>
);
}
return (
<BtwExpandedSheet
sessionRef={sessionRef}
title={title}
boundaryMessageID={boundaryMessageID}
actions={actions}
onTitleClick={handleToggleCollapsed}
titleClickLabel={toggleLabel}
/>
);
};
/**
* Collapsed mode: only the header strip stays docked above the composer. The
* fork keeps running in the background; a spinner replaces the header icon
* while it is busy so activity stays visible without the message list.
*/
const BtwCollapsedStrip: React.FC<{
sessionRef: BtwSessionRef;
title: string;
actions: React.ReactNode;
onExpand: () => void;
expandLabel: string;
}> = ({ sessionRef, title, actions, onExpand, expandLabel }) => {
const status = useSessionStatus(sessionRef.btwSessionId, sessionRef.directory) ?? IDLE_SESSION_STATUS;
const isBusy = status.type === 'busy' || status.type === 'retry';
return (
<BtwFrame
title={title}
actions={actions}
onTitleClick={onExpand}
titleClickLabel={expandLabel}
collapsed
headerSpinner={isBusy}
/>
);
};
const BtwExpandedSheet: React.FC<{
sessionRef: BtwSessionRef;
title: string;
boundaryMessageID: string | null;
actions: React.ReactNode;
onTitleClick: () => void;
titleClickLabel: string;
}> = ({ sessionRef, title, boundaryMessageID, actions, onTitleClick, titleClickLabel }) => {
const data = useBtwSessionData(sessionRef.btwSessionId, sessionRef.directory, boundaryMessageID);
const bodyRef = React.useRef<HTMLDivElement | null>(null);
const contentRef = React.useRef<HTMLDivElement | null>(null);
const handleBodyScroll = useAutoScroll(bodyRef, contentRef, !data.isEmpty);
// With the on-screen keyboard open the composer (this panel's anchor)
// rises, and a vh-based cap would push the panel under the app header.
// Same protection as the composer autocomplete popups: clamp the scroll
// body to the space actually available above the anchor. The hook measures
// room for the scroll body itself, but the panel header and bottom spacer
// sit inside the same frame above/below it — reserve their height too.
const BTW_FRAME_CHROME_PX = 48;
const availableMaxHeight = useMobileAutocompleteMaxHeight(bodyRef, true, 520 + BTW_FRAME_CHROME_PX);
const mobileMaxHeight = availableMaxHeight !== undefined
? Math.max(120, availableMaxHeight - BTW_FRAME_CHROME_PX)
: undefined;
return (
<BtwFrame title={title} actions={actions} onTitleClick={onTitleClick} titleClickLabel={titleClickLabel} collapsed={false}>
<ChatSurfaceProvider mode="peek">
<BtwMessages
data={data}
bodyRef={bodyRef}
contentRef={contentRef}
onBodyScroll={handleBodyScroll}
maxHeight={mobileMaxHeight}
/>
</ChatSurfaceProvider>
</BtwFrame>
);
};
const BtwMessages: React.FC<{
data: BtwSessionData;
bodyRef: React.RefObject<HTMLDivElement | null>;
contentRef: React.RefObject<HTMLDivElement | null>;
onBodyScroll: (event: React.UIEvent<HTMLDivElement>) => void;
maxHeight?: number;
}> = ({ data, bodyRef, contentRef, onBodyScroll, maxHeight }) => {
const { t } = useI18n();
if (data.isEmpty) {
return (
<div className="flex items-center gap-2 px-4 py-4 text-sm text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
<span>{t('chat.btw.loading')}</span>
</div>
);
}
return (
<ScrollShadow
ref={bodyRef}
onScroll={onBodyScroll}
size={32}
data-scroll-shadow="true"
className="max-h-[min(55vh,520px)] min-h-0 overflow-y-auto px-3 py-1"
style={maxHeight !== undefined ? { maxHeight } : undefined}
>
<div ref={contentRef}>
{data.messageRecords.map((record, index) => (
<ChatMessage
key={record.info.id}
message={record}
previousMessage={data.messageRecords[index - 1]}
nextMessage={data.messageRecords[index + 1]}
isInActiveTurn={index === data.messageRecords.length - 1}
activeStreamingPhase={
record.info.id === data.streamingMessageId ? data.activeStreamingPhase : null
}
/>
))}
{data.sessionQuestions.length > 0 || data.sessionPermissions.length > 0 ? (
<div>
{data.sessionQuestions.map((question) => (
<QuestionCard key={question.id} question={question} />
))}
{data.sessionPermissions.map((permission) => (
<PermissionCard key={permission.id} permission={permission} />
))}
</div>
) : null}
{/* Always reserve this row so the content does not shift down
by a line when the indicator disappears. */}
<div
className={cn(
'flex items-center gap-2 px-1 py-2 text-xs text-muted-foreground',
!data.sessionIsWorking && 'invisible',
)}
aria-hidden={!data.sessionIsWorking}
>
<Icon name="loader-4" className="size-3.5 animate-spin" />
<span>{t('chat.btw.working')}</span>
</div>
</div>
</ScrollShadow>
);
};
@@ -0,0 +1,54 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { useSession } from '@/sync/sync-context';
import { getBtwBoundaryMessageID, getBtwSessionID } from '@/lib/sessionBtwMetadata';
import { useBtwStore } from '@/stores/useBtwStore';
export type BtwPanelState = {
/** The active fork for this parent, or null when no panel should exist. */
btwSessionId: string | null;
btwSession: Session | null;
/** The fork's directory identity (may be canonicalized by the server). */
btwDirectory: string | null;
/** Last message id inherited from the parent; the panel shows what's after it. */
boundaryMessageID: string | null;
collapsed: boolean;
creating: boolean;
};
/**
* Derive the `/btw` panel identity for one parent session from authoritative
* session metadata (`openchamber.btwSessionID`), plus the transient UI state
* kept in `useBtwStore`. The panel exists only while the parent's link AND the
* fork itself are present in the live stores, so a fork deleted anywhere
* (sidebar, another client) makes the panel disappear without extra tracking.
*/
export function useBtwPanelState(
parentSessionId: string | null | undefined,
directory: string | undefined,
): BtwPanelState {
const parentSession = useSession(parentSessionId, directory);
const linkedBtwSessionId = getBtwSessionID(parentSession);
const btwSession = useSession(linkedBtwSessionId, directory) ?? null;
const uiState = useBtwStore(
React.useCallback(
(s) => (parentSessionId ? s.byParent[parentSessionId] : undefined),
[parentSessionId],
),
);
const destroying = Boolean(uiState?.destroying);
const btwSessionId = btwSession && !destroying ? linkedBtwSessionId : null;
return {
btwSessionId,
btwSession: btwSessionId ? btwSession : null,
// SAFETY: the SDK Session type omits the server's `directory` field; this
// widening only reads it, with the parent's directory as the fallback.
btwDirectory: btwSessionId
? ((btwSession as (Session & { directory?: string | null }) | null)?.directory ?? directory ?? null)
: null,
boundaryMessageID: btwSessionId ? getBtwBoundaryMessageID(btwSession) : null,
collapsed: Boolean(uiState?.collapsed),
creating: Boolean(uiState?.creating),
};
}
@@ -1,5 +1,11 @@
import React from 'react'; import React from 'react';
export type ChatSurfaceMode = 'default' | 'mini-chat'; /**
* 'mini-chat' is the browser-panel side chat (compact, no fork/plan actions).
* 'peek' is a read-only glance surface (the /btw panel): messages render with
* no per-message controls at all no user action row, no assistant action
* buttons, no turn footer.
*/
export type ChatSurfaceMode = 'default' | 'mini-chat' | 'peek';
export const ChatSurfaceContext = React.createContext<ChatSurfaceMode>('default'); export const ChatSurfaceContext = React.createContext<ChatSurfaceMode>('default');
@@ -1,33 +1,85 @@
import React from 'react'; import React from 'react';
import { Button } from '@/components/ui/button';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
import { useConfigStore } from '@/stores/useConfigStore';
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
/**
* Compact one-line mirror of the status row for the pill: same label, none of
* the status row's animation machinery (which does not survive being squeezed
* into a 32px chip).
*/
const PillWorkingStatus: React.FC = () => {
const { t } = useI18n();
const { activeModel, working } = useAssistantStatus();
const providers = useConfigStore((state) => state.providers);
const modelName = React.useMemo(() => {
if (!activeModel) return null;
const provider = providers.find((candidate) => candidate.id === activeModel.providerId);
return getProviderModelDisplayName(provider, activeModel.modelId) || null;
}, [activeModel, providers]);
if (!working.isWorking || !working.statusText) return null;
const status = working.statusText;
const label = modelName && modelName.trim().length > 0
? t('chat.statusRow.modelStatus', { model: modelName.trim(), status })
: status.charAt(0).toUpperCase() + status.slice(1);
return (
<span className="min-w-0 truncate pr-3 text-sm text-muted-foreground">
{label}
<span className="animate-pulse"> </span>
</span>
);
};
interface ScrollToBottomButtonProps { interface ScrollToBottomButtonProps {
visible: boolean; visible: boolean;
/** The session is still streaming: the pill carries the status label
while the floating status row is hidden away from the live edge. */
working?: boolean;
onClick: () => void; onClick: () => void;
} }
const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, onClick }) => { const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, working = false, onClick }) => {
const { t } = useI18n(); const { t } = useI18n();
return ( return (
<div <div
className={cn( className={cn(
'absolute bottom-full left-1/2 -translate-x-1/2 mb-2 transition-all duration-150', 'pointer-events-none absolute bottom-full inset-x-0 mb-2 transition-opacity duration-100',
visible ? 'opacity-100 translate-y-0 scale-100 pointer-events-auto' : 'opacity-0 translate-y-2 scale-95 pointer-events-none', visible ? 'opacity-100' : 'opacity-0',
)} )}
> >
<Button {/* The same column that centres the composer, so the pill's left
variant="outline" edge lines up exactly with the input frame. */}
size="sm" <div className="chat-input-column">
onClick={onClick} {/* The soft shadow lives on this wrapper, away from the glass
className="size-8 rounded-full [corner-shape:round] p-0 shadow-none bg-background/95 hover:bg-interactive-hover" button's backdrop-filter: sharing one element made the
aria-label={t('chat.scrollToBottom.aria')} shadow intermittently drop after hide/show cycles. */}
> <div className="inline-flex max-w-full rounded-full shadow-[0_2px_6px_-2px_rgb(0_0_0_/_0.10)] dark:shadow-[0_2px_6px_-2px_rgb(0_0_0_/_0.35)]">
<Icon name="arrow-down" className="h-4 w-4" /> <button
</Button> type="button"
onClick={onClick}
aria-label={t('chat.scrollToBottom.aria')}
className={cn(
// Glass material with a hairline real border — much
// lighter than the oc-glass-floating stack.
'oc-glass-popover inline-flex h-8 max-w-full items-center rounded-full [corner-shape:round] text-left',
'border border-black/[0.06] dark:border-white/[0.08]',
visible ? 'pointer-events-auto' : 'pointer-events-none',
)}
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center text-muted-foreground">
<Icon name="arrow-down" className="h-4 w-4" />
</span>
{working && visible ? <PillWorkingStatus /> : null}
</button>
</div>
</div>
</div> </div>
); );
}; };
@@ -4,7 +4,6 @@ import ProgressiveGroup from '../message/parts/ProgressiveGroup';
import type { TurnActivityRecord } from '../lib/turns/types'; import type { TurnActivityRecord } from '../lib/turns/types';
import type { ToolPopupContent } from '../message/types'; import type { ToolPopupContent } from '../message/types';
import type { StreamPhase } from '../message/types'; import type { StreamPhase } from '../message/types';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
interface DiffStats { interface DiffStats {
additions: number; additions: number;
@@ -21,7 +20,6 @@ interface TurnActivityProps {
expandedTools: Set<string>; expandedTools: Set<string>;
onToggleTool: (toolId: string) => void; onToggleTool: (toolId: string) => void;
onShowPopup: (content: ToolPopupContent) => void; onShowPopup: (content: ToolPopupContent) => void;
onContentChange?: (reason?: ContentChangeReason) => void;
streamPhase: StreamPhase; streamPhase: StreamPhase;
showHeader: boolean; showHeader: boolean;
animateRows?: boolean; animateRows?: boolean;
@@ -7,6 +7,16 @@ everything between typing and sending.
own state and wires these modules together; it should not grow logic that own state and wires these modules together; it should not grow logic that
belongs to one of them. belongs to one of them.
`ChatContainer.tsx` keeps one `ChatInput` mounted while a new-session draft
becomes its first session. Draft-only UI first fades for 120ms while the editor
stays in place. The parent then moves the editor to its final session position
with a 180ms transform-only FLIP animation. Reduced-motion mode skips these
transitions. `session-ui-store.ts` marks sessions materialized from a submitted
draft, so selecting an existing session while a draft is open switches without
animation. Do not restore separate draft and session composer branches:
remounting the editor loses focus and interrupts the transition. Keep the
existing mobile fixed-position rules unchanged.
## Layers ## Layers
| Directory | Owns | | Directory | Owns |
@@ -114,10 +124,14 @@ and the send path reading the same grammar.
drawn caret through a class it only writes while applying an update, so the drawn caret through a class it only writes while applying an update, so the
selection has to be the update that follows the focus. selection has to be the update that follows the focus.
- `submit/buildOutgoingMessage.ts` flattens queued messages, the composer text, - `submit/buildOutgoingMessage.ts` flattens queued messages, the composer text,
inline comments and context into OpenCode's one-primary-plus-parts shape. The context drafts and linked references into OpenCode's one-primary-plus-parts
oldest queued message becomes primary; **inline comments attach to the last shape. The oldest queued message becomes primary. **Every attached context
body the user authored** rather than becoming their own part; PR instructions item (inline comments, terminal selections, browser annotations, PR context,
precede the PR diff. linked issue/PR) becomes its own synthetic text part carrying structured
metadata** built by `lib/messages/contextParts.ts`; the timeline reads that
metadata back to render context blocks. PR instructions precede the PR diff.
Queueing a message leaves context drafts in their store on purpose — the send
that later delivers the queue consumes them.
- `state/useComposerDraft.ts` — a draft belongs to a (runtime, directory, - `state/useComposerDraft.ts` — a draft belongs to a (runtime, directory,
session) identity. Writes are debounced while typing but forced at every edge session) identity. Writes are debounced while typing but forced at every edge
where the page may stop running, because a pending timer is not a saved where the page may stop running, because a pending timer is not a saved
@@ -127,6 +141,9 @@ and the send path reading the same grammar.
- `state/useDraftTarget.ts` — the draft can target a directory that does not - `state/useDraftTarget.ts` — the draft can target a directory that does not
exist yet (a worktree being created). It must survive not appearing in the exist yet (a worktree being created). It must survive not appearing in the
branch list, or the selector snaps back to the project root mid-creation. branch list, or the selector snaps back to the project root mid-creation.
- `ui/DraftTargetSelectors.tsx` owns the controlled project/worktree picker
state and registers its application shortcuts locally. The selectors only
consume their shared prefix while the draft target UI is mounted.
## Mobile ## Mobile
@@ -23,6 +23,8 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { buildSessionTargetOptions } from '@/sync/session-worktree-contract'; import { buildSessionTargetOptions } from '@/sync/session-worktree-contract';
import { normalizePath } from '../attachments/filePaths'; import { normalizePath } from '../attachments/filePaths';
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
import { useI18n } from '@/lib/i18n';
/** How long a cached branch list is served before it is refreshed. */ /** How long a cached branch list is served before it is refreshed. */
const BRANCHES_SWR_TTL_MS = 30_000; const BRANCHES_SWR_TTL_MS = 30_000;
@@ -35,6 +37,7 @@ export interface DraftTargetProject {
color?: string | null; color?: string | null;
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' } | null; iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' } | null;
iconBackground?: string | null; iconBackground?: string | null;
kind?: 'chat' | 'project';
} }
/** A project's display name, falling back to its directory name. */ /** A project's display name, falling back to its directory name. */
@@ -43,7 +46,15 @@ export function getProjectDisplayLabel(project: { label?: string; path: string }
} }
export function useDraftTarget(enabled: boolean) { export function useDraftTarget(enabled: boolean) {
const projects = useProjectsStore((state) => state.projects) as DraftTargetProject[]; const configuredProjects: readonly DraftTargetProject[] = useProjectsStore((state) => state.projects);
const { t } = useI18n();
const chatProject = React.useMemo<DraftTargetProject>(() => ({
id: CHAT_DRAFT_PROJECT_ID,
path: '',
label: t('layout.mainTab.chat'),
kind: 'chat',
}), [t]);
const projects = React.useMemo(() => [chatProject, ...configuredProjects], [chatProject, configuredProjects]);
const activeProjectId = useProjectsStore((state) => state.activeProjectId); const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly); const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
@@ -53,6 +64,7 @@ export function useDraftTarget(enabled: boolean) {
const { git: runtimeGit } = useRuntimeAPIs(); const { git: runtimeGit } = useRuntimeAPIs();
const selectedDraftProject = React.useMemo(() => { const selectedDraftProject = React.useMemo(() => {
if (newSessionDraft?.target === 'chat') return chatProject;
const explicit = newSessionDraft?.selectedProjectId const explicit = newSessionDraft?.selectedProjectId
? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null ? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null
: null; : null;
@@ -67,14 +79,16 @@ export function useDraftTarget(enabled: boolean) {
return active; return active;
} }
return projects[0] ?? null; return configuredProjects[0] ?? chatProject;
}, [activeProjectId, newSessionDraft?.selectedProjectId, projects]); }, [activeProjectId, chatProject, configuredProjects, newSessionDraft?.selectedProjectId, newSessionDraft?.target, projects]);
const selectedDraftProjectPath = React.useMemo( const selectedDraftProjectPath = React.useMemo(
() => normalizePath(selectedDraftProject?.path ?? null), () => selectedDraftProject?.kind === 'chat' ? null : normalizePath(selectedDraftProject?.path ?? null),
[selectedDraftProject?.path], [selectedDraftProject?.kind, selectedDraftProject?.path],
); );
const draftProjectLabel = selectedDraftProject ? getProjectDisplayLabel(selectedDraftProject) : null; const draftProjectLabel = selectedDraftProject && selectedDraftProject.kind !== 'chat'
? getProjectDisplayLabel(selectedDraftProject)
: null;
const selectedDraftProjectBranches = useGitBranches(selectedDraftProjectPath); const selectedDraftProjectBranches = useGitBranches(selectedDraftProjectPath);
const selectedDraftProjectBranchesFetchedAt = useGitStore( const selectedDraftProjectBranchesFetchedAt = useGitStore(
@@ -258,6 +272,10 @@ export function useDraftTarget(enabled: boolean) {
if (!project) { if (!project) {
return; return;
} }
if (project.kind === 'chat') {
setNewSessionDraftTarget({ projectId: CHAT_DRAFT_PROJECT_ID, directoryOverride: null }, { force: true });
return;
}
if (activeProjectId !== projectId) { if (activeProjectId !== projectId) {
setActiveProjectIdOnly(projectId); setActiveProjectIdOnly(projectId);
} }
@@ -1,6 +1,8 @@
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import type { AttachedFile } from '@/stores/types/sessionTypes'; import type { AttachedFile } from '@/stores/types/sessionTypes';
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
import { CONTEXT_METADATA_KEY, contextPayloadFromDraft } from '@/lib/messages/contextParts';
import { import {
buildOutgoingMessage, buildOutgoingMessage,
type OutgoingMessageDeps, type OutgoingMessageDeps,
@@ -26,7 +28,6 @@ const deps = (overrides: Partial<OutgoingMessageDeps> = {}): OutgoingMessageDeps
}, },
sanitizeAttachments: (files) => [...(files ?? [])], sanitizeAttachments: (files) => [...(files ?? [])],
collectSkillNames: (text) => [...text.matchAll(/\/(\w+)/g)].map((m) => m[1]), collectSkillNames: (text) => [...text.matchAll(/\/(\w+)/g)].map((m) => m[1]),
appendComments: (text, comments) => `${text}\n[${comments.length} comments]`,
buildSkillInstruction: (names) => (names.length ? `use: ${names.join(',')}` : null), buildSkillInstruction: (names) => (names.length ? `use: ${names.join(',')}` : null),
...overrides, ...overrides,
}); });
@@ -37,7 +38,7 @@ const input = (overrides: Partial<OutgoingMessageInput> = {}): OutgoingMessageIn
composerAttachments: [], composerAttachments: [],
inlineComments: [], inlineComments: [],
syntheticTexts: [], syntheticTexts: [],
linkedIssueContext: null, linkedIssue: null,
linkedPr: null, linkedPr: null,
...overrides, ...overrides,
}); });
@@ -130,36 +131,51 @@ describe('agent mentions', () => {
}); });
}); });
describe('inline comments', () => { const commentDraft = (overrides: Partial<InlineCommentDraft> = {}): InlineCommentDraft => ({
test('attach to the composer text when nothing was queued', () => { id: 'icd-1',
sessionKey: 's1',
source: 'diff',
fileLabel: 'src/app.ts',
startLine: 3,
endLine: 5,
side: 'modified',
code: 'const x = 1;',
language: 'ts',
text: 'fix this',
createdAt: 1,
...overrides,
});
describe('context drafts', () => {
test('each becomes a synthetic part carrying structured metadata', () => {
const result = buildOutgoingMessage(input({ const result = buildOutgoingMessage(input({
composerText: 'body', composerText: 'body',
inlineComments: [{}, {}], inlineComments: [commentDraft(), commentDraft({ id: 'icd-2', source: 'file', side: undefined })],
}), deps()); }), deps());
expect(result.primaryText).toBe('body\n[2 comments]'); expect(result.primaryText).toBe('body');
expect(result.additionalParts).toHaveLength(2);
expect(result.additionalParts.every((p) => p.synthetic)).toBe(true);
expect(result.additionalParts[0].metadata?.[CONTEXT_METADATA_KEY])
.toEqual(contextPayloadFromDraft(commentDraft()));
expect(result.additionalParts[1].metadata?.[CONTEXT_METADATA_KEY])
.toEqual(contextPayloadFromDraft(commentDraft({ id: 'icd-2', source: 'file', side: undefined })));
expect(result.additionalParts[0].text).toContain('Comment on `src/app.ts` lines 3-5 (modified):');
expect(result.additionalParts[0].text).toContain('fix this');
}); });
test('attach to the last authored part when messages were queued', () => { test('context parts precede other synthetic context', () => {
const result = buildOutgoingMessage(input({ const result = buildOutgoingMessage(input({
queued: [{ content: 'queued' }], composerText: 'body',
composerText: 'typed', inlineComments: [commentDraft()],
inlineComments: [{}], syntheticTexts: ['conflict note'],
}), deps()); }), deps());
expect(result.primaryText).toBe('queued'); expect(result.additionalParts.map((p) => p.text.startsWith('Comment on') ? 'comment' : p.text))
expect(result.additionalParts[0].text).toBe('typed\n[1 comments]'); .toEqual(['comment', 'conflict note']);
}); });
test('fall back to primary when the queue produced no additional parts', () => { test('no drafts changes nothing', () => {
const result = buildOutgoingMessage(input({ expect(buildOutgoingMessage(input({ composerText: 'body' }), deps()).additionalParts)
queued: [{ content: 'only queued' }], .toEqual([]);
inlineComments: [{}],
}), deps());
expect(result.primaryText).toBe('only queued\n[1 comments]');
});
test('no comments changes nothing', () => {
expect(buildOutgoingMessage(input({ composerText: 'body' }), deps()).primaryText)
.toBe('body');
}); });
}); });
@@ -167,26 +183,31 @@ describe('synthetic context', () => {
test('a linked PR sends its instructions before its diff', () => { test('a linked PR sends its instructions before its diff', () => {
const result = buildOutgoingMessage(input({ const result = buildOutgoingMessage(input({
composerText: 'review this', composerText: 'review this',
linkedPr: { instructions: 'how to read it', context: 'the diff' }, linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'how to read it', context: 'the diff' },
}), deps()); }), deps());
expect(result.additionalParts.map((p) => p.text)) expect(result.additionalParts.map((p) => p.text))
.toEqual(['how to read it', 'the diff']); .toEqual(['how to read it', 'the diff']);
expect(result.additionalParts.every((p) => p.synthetic)).toBe(true); expect(result.additionalParts.every((p) => p.synthetic)).toBe(true);
expect(result.additionalParts[1].metadata?.[CONTEXT_METADATA_KEY])
.toEqual({ kind: 'github-pr', number: 7, title: 'PR', url: 'https://x/pr/7' });
}); });
test('a linked issue is sent as context', () => { test('a linked issue is sent as context', () => {
const result = buildOutgoingMessage(input({ const result = buildOutgoingMessage(input({
composerText: 'fix it', composerText: 'fix it',
linkedIssueContext: 'issue body', linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' },
}), deps()); }), deps());
expect(result.additionalParts).toEqual([{ text: 'issue body', synthetic: true }]); expect(result.additionalParts).toHaveLength(1);
expect(result.additionalParts[0].text).toBe('issue body');
expect(result.additionalParts[0].metadata?.[CONTEXT_METADATA_KEY])
.toEqual({ kind: 'github-issue', number: 3, title: 'Bug', url: 'https://x/issues/3' });
}); });
test('synthetic texts precede the linked references', () => { test('synthetic texts precede the linked references', () => {
const result = buildOutgoingMessage(input({ const result = buildOutgoingMessage(input({
composerText: 'x', composerText: 'x',
syntheticTexts: ['conflict note'], syntheticTexts: ['conflict note'],
linkedIssueContext: 'issue body', linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' },
}), deps()); }), deps());
expect(result.additionalParts.map((p) => p.text)) expect(result.additionalParts.map((p) => p.text))
.toEqual(['conflict note', 'issue body']); .toEqual(['conflict note', 'issue body']);
@@ -211,7 +232,9 @@ describe('synthetic context', () => {
}); });
test('context alone is still worth sending', () => { test('context alone is still worth sending', () => {
const result = buildOutgoingMessage(input({ linkedIssueContext: 'issue body' }), deps()); const result = buildOutgoingMessage(input({
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' },
}), deps());
expect(result.isEmpty).toBe(false); expect(result.isEmpty).toBe(false);
}); });
@@ -230,8 +253,8 @@ describe('full assembly order', () => {
queued: [{ content: 'q1' }, { content: 'q2' }], queued: [{ content: 'q1' }, { content: 'q2' }],
composerText: 'typed /deploy', composerText: 'typed /deploy',
syntheticTexts: ['synthetic'], syntheticTexts: ['synthetic'],
linkedIssueContext: 'issue', linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue' },
linkedPr: { instructions: 'pr-how', context: 'pr-diff' }, linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'pr-how', context: 'pr-diff' },
}), deps()); }), deps());
expect(result.primaryText).toBe('q1'); expect(result.primaryText).toBe('q1');
@@ -14,12 +14,16 @@
*/ */
import type { AttachedFile } from '@/stores/types/sessionTypes'; import type { AttachedFile } from '@/stores/types/sessionTypes';
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
import { contextPayloadFromDraft, createContextPart, type ContextPartMetadata } from '@/lib/messages/contextParts';
export interface OutgoingPart { export interface OutgoingPart {
text: string; text: string;
attachments?: AttachedFile[]; attachments?: AttachedFile[];
/** Synthetic parts are context for the model, not shown as user content. */ /** Synthetic parts are context for the model, not shown as user content. */
synthetic?: boolean; synthetic?: boolean;
/** Structured context (see contextParts.ts), persisted with the part. */
metadata?: ContextPartMetadata;
} }
export interface OutgoingMessage { export interface OutgoingMessage {
@@ -43,12 +47,12 @@ export interface OutgoingMessageInput {
/** The composer's own text, or null when this send skips it. */ /** The composer's own text, or null when this send skips it. */
composerText: string | null; composerText: string | null;
composerAttachments: readonly AttachedFile[]; composerAttachments: readonly AttachedFile[];
/** Inline review comments, appended to the user's last authored text. */ /** Context drafts (code comments, terminal selections, annotations, PR context). */
inlineComments: readonly unknown[]; inlineComments: readonly InlineCommentDraft[];
/** Synthetic context produced elsewhere (conflict resolution, and such). */ /** Synthetic context produced elsewhere (conflict resolution, and such). */
syntheticTexts: readonly string[]; syntheticTexts: readonly string[];
linkedIssueContext: string | null; linkedIssue: { number: number; title: string; url: string; contextText: string } | null;
linkedPr: { instructions: string; context: string } | null; linkedPr: { number: number; title: string; url: string; instructions: string; context: string } | null;
} }
/** /**
@@ -64,8 +68,6 @@ export interface OutgoingMessageDeps {
sanitizeAttachments: (files: readonly AttachedFile[] | undefined) => AttachedFile[]; sanitizeAttachments: (files: readonly AttachedFile[] | undefined) => AttachedFile[];
/** Skills named inline with `/name`. */ /** Skills named inline with `/name`. */
collectSkillNames: (text: string) => string[]; collectSkillNames: (text: string) => string[];
/** Append inline review comments to a message body. */
appendComments: (text: string, comments: readonly unknown[]) => string;
/** Instruction telling the model which skills the user named. */ /** Instruction telling the model which skills the user named. */
buildSkillInstruction: (names: string[]) => string | null; buildSkillInstruction: (names: string[]) => string | null;
} }
@@ -134,33 +136,29 @@ export function buildOutgoingMessage(
} }
} }
// Inline comments attach to the last thing the user authored, so they read // Everything below is context for the model, never plain user text. Each
// as a continuation of it rather than as a separate turn. // attached context item becomes its own synthetic part carrying structured
if (input.inlineComments.length > 0) { // metadata, so the timeline can render it as a context block after the
const lastAuthored = input.queued.length > 0 && additionalParts.length > 0 // server echoes the message back.
? additionalParts[additionalParts.length - 1] for (const draft of input.inlineComments) {
: null; additionalParts.push(createContextPart(contextPayloadFromDraft(draft)));
if (lastAuthored) {
lastAuthored.text = deps.appendComments(lastAuthored.text, input.inlineComments);
} else {
primaryText = deps.appendComments(primaryText, input.inlineComments);
}
} }
// Everything below is context for the model, never user-visible content.
for (const text of input.syntheticTexts) { for (const text of input.syntheticTexts) {
additionalParts.push({ text, synthetic: true }); additionalParts.push({ text, synthetic: true });
} }
if (input.linkedIssueContext) { if (input.linkedIssue) {
additionalParts.push({ text: input.linkedIssueContext, synthetic: true }); const { number, title, url, contextText } = input.linkedIssue;
additionalParts.push(createContextPart({ kind: 'github-issue', number, title, url }, contextText));
} }
if (input.linkedPr) { if (input.linkedPr) {
// Instructions before context: the model is told how to read the diff // Instructions before context: the model is told how to read the diff
// before it is given the diff. // before it is given the diff.
additionalParts.push({ text: input.linkedPr.instructions, synthetic: true }); const { number, title, url, instructions, context } = input.linkedPr;
additionalParts.push({ text: input.linkedPr.context, synthetic: true }); additionalParts.push({ text: instructions, synthetic: true });
additionalParts.push(createContextPart({ kind: 'github-pr', number, title, url }, context));
} }
const skillInstruction = deps.buildSkillInstruction(skillNames); const skillInstruction = deps.buildSkillInstruction(skillNames);
@@ -0,0 +1,40 @@
import React from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
interface AutocompleteRowTooltipProps {
description?: string;
active: boolean;
children: React.ReactElement;
}
export function AutocompleteRowTooltip({ description, active, children }: AutocompleteRowTooltipProps) {
const [delayedActive, setDelayedActive] = React.useState(false);
React.useEffect(() => {
if (!active || !description) {
setDelayedActive(false);
return;
}
const timeout = window.setTimeout(() => setDelayedActive(true), 200);
return () => window.clearTimeout(timeout);
}, [active, description]);
if (!description) return children;
return (
<Tooltip delayDuration={0} open={active && delayedActive} onOpenChange={() => {}}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
{active && delayedActive ? (
<TooltipContent
side="right"
sideOffset={8}
className="max-w-xs text-left transition-none data-[starting-style]:opacity-100 data-[starting-style]:scale-100 data-[ending-style]:opacity-100 data-[ending-style]:scale-100"
>
<p className="typography-meta whitespace-pre-wrap">{description}</p>
</TooltipContent>
) : null}
</Tooltip>
);
}
@@ -89,7 +89,7 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
<Icon name="add-circle" className={cn(iconSizeClass, 'text-current')} /> <Icon name="add-circle" className={cn(iconSizeClass, 'text-current')} />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start"> <DropdownMenuContent side="top" align="start">
<DropdownMenuItem <DropdownMenuItem
onSelect={() => { onSelect={() => {
requestAnimationFrame(handlePickLocalFiles); requestAnimationFrame(handlePickLocalFiles);
@@ -2,164 +2,381 @@
* Context chips above the composer. * Context chips above the composer.
* *
* Each chip stands for context that will be attached to the next message but * Each chip stands for context that will be attached to the next message but
* is not part of its text: review comments left in a diff, captured dev-server * is not part of its text: review comments left in a diff, preview
* logs, preview annotations, terminal selections. They are shown so the user * annotations, terminal selections, PR context, chat quotes. Hovering (or
* knows what is riding along and can drop any of it before sending. * tapping) a chip opens a stacked preview of its items above the composer,
* where a comment the user wrote can be edited in place and any item removed
* before sending.
*/ */
import React from 'react'; import React from 'react';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
import type { IconName } from '@/components/icon/icons';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import type { InlineCommentDraft, InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore'; import { getRuntimeKey } from '@/lib/runtime-switch';
import {
EMPTY_INLINE_COMMENT_DRAFTS,
getInlineCommentDraftKey,
useInlineCommentDraftStore,
type InlineCommentDraft,
type InlineCommentDraftTarget,
type InlineCommentSource,
} from '@/stores/useInlineCommentDraftStore';
import type { Theme } from '@/types/theme'; import type { Theme } from '@/types/theme';
export interface ComposerContextChipsProps { export interface ComposerContextChipsProps {
/** Terminal selections, which show their own label and line range. */
terminalDrafts: readonly InlineCommentDraft[];
reviewCount: number;
prCommentCount: number;
prCheckCount: number;
previewConsoleCount: number;
previewAnnotationCount: number;
draftTarget: InlineCommentDraftTarget | null; draftTarget: InlineCommentDraftTarget | null;
onRemoveDraft: (target: InlineCommentDraftTarget, draftId: string) => void;
onRemoveReviewDrafts: () => void;
onRemovePreviewDrafts: (source: 'preview-console' | 'preview-annotation' | 'pr-comment' | 'pr-check') => void;
colors: Theme['colors']; colors: Theme['colors'];
} }
/** A chip showing how many items of one kind are attached, with a clear action. */ /** Chip groups: every terminal selection is its own chip; the rest group by kind. */
function CountChip(props: { type ChipGroup = {
key: string;
icon: IconName;
iconClassName?: string;
label: string; label: string;
count: number; count: number;
removeLabel: string; drafts: InlineCommentDraft[];
};
const REVIEW_SOURCES: readonly InlineCommentSource[] = ['diff', 'file', 'plan', 'file-quote'];
/** Sources whose drafts carry a user-written comment that can be edited. */
const editableSource = (source: InlineCommentSource): boolean => source !== 'terminal';
/** Captured code/output kinds read better monospaced; quoted prose does not. */
const monoSource = (source: InlineCommentSource): boolean =>
source !== 'chat-quote' && source !== 'preview-annotation' && source !== 'file-quote';
const basename = (path: string): string => {
const segments = path.split('/').filter(Boolean);
return segments[segments.length - 1] ?? path;
};
const ENTRY_ACTION_CLASS = 'inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]';
const ENTRY_LABEL_CLASS = 'text-[10px] font-medium uppercase tracking-wide text-[var(--surface-mutedForeground)] opacity-60';
const DraftPreviewEntry: React.FC<{
draft: InlineCommentDraft;
index: number;
title: string;
editing: boolean;
onStartEdit: () => void;
onEndEdit: () => void;
onRemove: () => void; onRemove: () => void;
colors: Theme['colors']; onSaveComment: ((text: string) => void) | null;
icon?: React.ReactNode; }> = ({ draft, index, title, editing, onStartEdit, onEndEdit, onRemove, onSaveComment }) => {
}) {
return (
<div
className="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1"
style={{
backgroundColor: props.colors?.surface?.elevated,
borderColor: props.colors?.interactive?.border,
}}
>
{props.icon}
<span className="text-xs font-medium text-muted-foreground">{props.label}</span>
<span className="text-xs font-semibold" style={{ color: props.colors?.status?.info }}>
{props.count}
</span>
<button
type="button"
className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground hover:bg-interactive-hover hover:text-foreground"
style={{ minHeight: 0, minWidth: 0 }}
onClick={props.onRemove}
aria-label={props.removeLabel}
title={props.removeLabel}
>
<Icon name="close" className="h-3 w-3" />
</button>
</div>
);
}
export function ComposerContextChips(props: ComposerContextChipsProps) {
const { t } = useI18n(); const { t } = useI18n();
const { const [editText, setEditText] = React.useState(draft.text);
terminalDrafts, const editRef = React.useRef<HTMLTextAreaElement>(null);
reviewCount,
prCommentCount, React.useEffect(() => {
prCheckCount, if (!editing) return;
previewConsoleCount, setEditText(draft.text);
previewAnnotationCount, queueMicrotask(() => {
draftTarget, const element = editRef.current;
onRemoveDraft, if (element) {
onRemoveReviewDrafts, element.focus();
onRemovePreviewDrafts, element.setSelectionRange(element.value.length, element.value.length);
colors, }
} = props; });
// The draft text at edit start is the baseline; later store updates are
// our own saves.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editing]);
const commitEdit = () => {
if (onSaveComment && editText !== draft.text) {
onSaveComment(editText);
}
onEndEdit();
};
const cancelEdit = () => {
setEditText(draft.text);
onEndEdit();
};
// Keep focus in the textarea while a header button is pressed: without
// this the textarea's blur commits first, the header re-renders under the
// pointer, and the click lands on the button that replaced the pressed one
// (save punches through to edit, cancel to remove).
const keepEditorFocus = (event: React.PointerEvent) => {
if (editing) event.preventDefault();
};
return ( return (
<div className="flex flex-wrap items-center gap-2 pb-2"> <div>
{terminalDrafts.map((draft) => ( <div className="flex items-center gap-1.5 px-3 py-1.5"
<div style={{ backgroundColor: 'color-mix(in srgb, var(--surface-mutedForeground) 8%, transparent)' }}>
key={draft.id} <span className="text-xs font-medium text-[var(--surface-mutedForeground)]">{index + 1}.</span>
className="inline-flex max-w-full items-center gap-1.5 rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2.5 py-1" <span className="min-w-0 flex-1 truncate text-xs font-medium text-[var(--surface-foreground)]" title={title}>
title={draft.code} {title}
> </span>
<Icon name="terminal" className="h-3.5 w-3.5" /> {onSaveComment ? (
<span className="truncate text-xs font-medium text-[var(--surface-mutedForeground)]">
{t('chat.chatInput.terminalContext', {
terminal: draft.fileLabel,
start: draft.startLine,
end: draft.endLine,
})}
</span>
<button <button
type="button" type="button"
className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]" className={ENTRY_ACTION_CLASS}
onClick={() => draftTarget && onRemoveDraft(draftTarget, draft.id)} style={{ minHeight: 0, minWidth: 0 }}
aria-label={t('chat.chatInput.terminalContextRemove')} onPointerDown={keepEditorFocus}
title={t('chat.chatInput.terminalContextRemove')} onClick={editing ? commitEdit : onStartEdit}
aria-label={t('chat.chatInput.contextPreview.edit')}
title={t('chat.chatInput.contextPreview.edit')}
> >
<Icon name="close" className="h-3 w-3" /> <Icon name={editing ? 'check' : 'pencil'} className="h-3 w-3" />
</button> </button>
) : null}
<button
type="button"
className={ENTRY_ACTION_CLASS}
style={{ minHeight: 0, minWidth: 0 }}
onPointerDown={keepEditorFocus}
onClick={editing ? cancelEdit : onRemove}
aria-label={t('chat.chatInput.contextPreview.remove')}
title={t('chat.chatInput.contextPreview.remove')}
>
<Icon name="close" className="h-3 w-3" />
</button>
</div>
<div className="space-y-2 px-3 py-2">
{draft.code.trim() ? (
<div>
<div className={ENTRY_LABEL_CLASS}>{t('chat.chatInput.contextPreview.selectedLabel')}</div>
<div
className={
monoSource(draft.source)
? 'mt-0.5 whitespace-pre-wrap break-words font-mono text-xs text-[var(--surface-foreground)]'
: 'mt-0.5 whitespace-pre-wrap break-words text-sm text-[var(--surface-foreground)]'
}
>
{draft.code}
</div>
</div>
) : null}
{onSaveComment && (editing || draft.text.trim()) ? (
<div>
<div className={ENTRY_LABEL_CLASS}>{t('chat.chatInput.contextPreview.commentLabel')}</div>
{editing ? (
<textarea
ref={editRef}
rows={2}
value={editText}
onChange={(event) => setEditText(event.target.value)}
onBlur={commitEdit}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
commitEdit();
} else if (event.key === 'Escape') {
event.preventDefault();
setEditText(draft.text);
onEndEdit();
}
}}
placeholder={t('chat.textSelection.comment.placeholder')}
className="mt-0.5 w-full resize-none rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-2 py-1 text-sm text-[var(--surface-foreground)] outline-none placeholder:text-[var(--surface-mutedForeground)]"
style={{ minHeight: 0 }}
/>
) : (
<div className="mt-0.5 whitespace-pre-wrap break-words text-sm text-[var(--surface-foreground)]">{draft.text}</div>
)}
</div>
) : null}
</div>
</div>
);
};
export function ComposerContextChips({ draftTarget, colors }: ComposerContextChipsProps) {
const { t } = useI18n();
const draftKey = draftTarget
? getInlineCommentDraftKey(getRuntimeKey(), draftTarget.directory, draftTarget.sessionKey)
: null;
const drafts = useInlineCommentDraftStore(
React.useCallback(
(state) => (draftKey ? state.drafts[draftKey] ?? EMPTY_INLINE_COMMENT_DRAFTS : EMPTY_INLINE_COMMENT_DRAFTS),
[draftKey],
),
);
const removeDraft = useInlineCommentDraftStore((state) => state.removeDraft);
const updateDraft = useInlineCommentDraftStore((state) => state.updateDraft);
const [openGroupKey, setOpenGroupKey] = React.useState<string | null>(null);
const [editingDraftId, setEditingDraftId] = React.useState<string | null>(null);
const editingRef = React.useRef<string | null>(null);
editingRef.current = editingDraftId;
const containerRef = React.useRef<HTMLDivElement>(null);
const closeTimerRef = React.useRef<number | null>(null);
const cancelClose = React.useCallback(() => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
// Hover-away close. Suspended while a comment is being edited: entering or
// leaving edit mode reflows the panel under the pointer, and a synthetic
// mouseleave from that reflow must not tear the editor down.
const scheduleClose = React.useCallback(() => {
if (editingRef.current) return;
cancelClose();
closeTimerRef.current = window.setTimeout(() => {
closeTimerRef.current = null;
setOpenGroupKey(null);
}, 150);
}, [cancelClose]);
React.useEffect(() => cancelClose, [cancelClose]);
// Clicking outside the chips + panel closes the preview even when a reflow
// swallowed the mouseleave (e.g. right after finishing an edit).
React.useEffect(() => {
if (!openGroupKey) return;
const handlePointerDown = (event: PointerEvent) => {
// SAFETY: a pointer event target inside the document is always a
// Node; `contains` only needs that.
if (containerRef.current?.contains(event.target as Node)) return;
setOpenGroupKey(null);
setEditingDraftId(null);
};
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [openGroupKey]);
const titleFor = React.useCallback((draft: InlineCommentDraft): string => {
switch (draft.source) {
case 'terminal':
return t('chat.chatInput.terminalContext', {
terminal: draft.fileLabel,
start: draft.startLine,
end: draft.endLine,
});
case 'preview-annotation':
return t('chat.message.context.browserAnnotation', { page: draft.fileLabel });
case 'pr-comment':
return t('chat.message.context.prComment', { label: draft.fileLabel });
case 'pr-check':
return t('chat.message.context.prCheck', { label: draft.fileLabel });
case 'chat-quote':
return t('chat.message.context.chatQuote');
case 'file-quote':
return draft.startLine > 0 && draft.endLine > 0
? (draft.startLine === draft.endLine
? t('chat.message.context.codeCommentLine', { file: basename(draft.fileLabel), line: draft.startLine })
: t('chat.message.context.codeComment', { file: basename(draft.fileLabel), start: draft.startLine, end: draft.endLine }))
: t('chat.message.context.fileQuote', { file: basename(draft.fileLabel) });
default:
return draft.startLine === draft.endLine
? t('chat.message.context.codeCommentLine', { file: basename(draft.fileLabel), line: draft.startLine })
: t('chat.message.context.codeComment', { file: basename(draft.fileLabel), start: draft.startLine, end: draft.endLine });
}
}, [t]);
const groups = React.useMemo<ChipGroup[]>(() => {
const result: ChipGroup[] = [];
const byKind = (
key: string,
icon: IconName,
label: string,
match: (draft: InlineCommentDraft) => boolean,
iconClassName?: string,
) => {
const matched = drafts.filter(match);
if (matched.length > 0) {
result.push({ key, icon, iconClassName, label, count: matched.length, drafts: matched });
}
};
for (const draft of drafts) {
if (draft.source !== 'terminal') continue;
result.push({
key: `terminal-${draft.id}`,
icon: 'terminal',
label: t('chat.chatInput.terminalContext', {
terminal: draft.fileLabel,
start: draft.startLine,
end: draft.endLine,
}),
count: 0,
drafts: [draft],
});
}
byKind('review', 'chat-1', t('chat.chatInput.reviewComments'), (draft) => REVIEW_SOURCES.includes(draft.source));
byKind('pr-comment', 'git-pull-request', t('chat.chatInput.prCommentContext'), (draft) => draft.source === 'pr-comment');
byKind('pr-check', 'close-circle', t('chat.chatInput.prCheckContext'), (draft) => draft.source === 'pr-check', 'text-[var(--status-error)]');
byKind('chat-quote', 'chat-1', t('chat.chatInput.chatQuoteContext'), (draft) => draft.source === 'chat-quote');
byKind('annotation', 'global', t('chat.chatInput.previewAnnotations'), (draft) => draft.source === 'preview-annotation');
return result;
}, [drafts, t]);
React.useEffect(() => {
if (openGroupKey && !groups.some((group) => group.key === openGroupKey)) {
setOpenGroupKey(null);
setEditingDraftId(null);
}
}, [groups, openGroupKey]);
if (!draftTarget || drafts.length === 0) return null;
const openGroup = openGroupKey ? groups.find((group) => group.key === openGroupKey) ?? null : null;
return (
<div className="relative" ref={containerRef}>
{openGroup ? (
<div
className="oc-glass-popover absolute bottom-full left-0 z-30 mb-1.5 w-full max-w-[480px] overflow-hidden rounded-xl border border-[var(--interactive-border)] shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]"
onMouseEnter={cancelClose}
onMouseLeave={scheduleClose}
>
<div className="max-h-[min(50vh,420px)] divide-y divide-[var(--interactive-border)] overflow-y-auto">
{openGroup.drafts.map((draft, index) => (
<DraftPreviewEntry
key={draft.id}
draft={draft}
index={index}
title={titleFor(draft)}
editing={editingDraftId === draft.id}
onStartEdit={() => setEditingDraftId(draft.id)}
onEndEdit={() => setEditingDraftId((current) => (current === draft.id ? null : current))}
onRemove={() => removeDraft(draftTarget, draft.id)}
onSaveComment={editableSource(draft.source)
? (text) => updateDraft(draftTarget, draft.id, { text })
: null}
/>
))}
</div>
</div> </div>
))}
{reviewCount > 0 ? (
<CountChip
label={t('chat.chatInput.reviewComments')}
count={reviewCount}
removeLabel={t('chat.chatInput.reviewCommentsRemove')}
onRemove={onRemoveReviewDrafts}
colors={colors}
/>
) : null}
{prCommentCount > 0 ? (
<CountChip
label={t('chat.chatInput.prCommentContext')}
count={prCommentCount}
removeLabel={t('chat.chatInput.prCommentContextRemove')}
onRemove={() => onRemovePreviewDrafts('pr-comment')}
colors={colors}
icon={<Icon name="git-pull-request" className="h-3.5 w-3.5 text-muted-foreground" />}
/>
) : null}
{prCheckCount > 0 ? (
<CountChip
label={t('chat.chatInput.prCheckContext')}
count={prCheckCount}
removeLabel={t('chat.chatInput.prCheckContextRemove')}
onRemove={() => onRemovePreviewDrafts('pr-check')}
colors={colors}
icon={<Icon name="close-circle" className="h-3.5 w-3.5 text-[var(--status-error)]" />}
/>
) : null}
{previewConsoleCount > 0 ? (
<CountChip
label={t('chat.chatInput.devServerLogs')}
count={previewConsoleCount}
removeLabel={t('chat.chatInput.devServerLogsRemove')}
onRemove={() => onRemovePreviewDrafts('preview-console')}
colors={colors}
/>
) : null}
{previewAnnotationCount > 0 ? (
<CountChip
label={t('chat.chatInput.previewAnnotations')}
count={previewAnnotationCount}
removeLabel={t('chat.chatInput.previewContextRemove')}
onRemove={() => onRemovePreviewDrafts('preview-annotation')}
colors={colors}
/>
) : null} ) : null}
<div className="flex flex-wrap items-center gap-2 pb-2">
{groups.map((group) => (
<button
key={group.key}
type="button"
className="inline-flex max-w-full items-center gap-1.5 rounded-xl border px-2.5 py-1 text-left"
style={{
backgroundColor: colors?.surface?.elevated,
borderColor: colors?.interactive?.border,
}}
onMouseEnter={() => {
cancelClose();
setOpenGroupKey(group.key);
}}
onMouseLeave={scheduleClose}
onClick={() => {
if (editingRef.current) return;
setOpenGroupKey((current) => (current === group.key ? null : group.key));
}}
aria-expanded={openGroupKey === group.key}
>
<Icon name={group.icon} className={`h-3.5 w-3.5 shrink-0 text-muted-foreground ${group.iconClassName ?? ''}`} />
<span className="truncate text-xs font-medium text-muted-foreground">{group.label}</span>
{group.count > 0 ? (
<span className="text-xs font-semibold" style={{ color: colors?.status?.info }}>
{group.count}
</span>
) : null}
</button>
))}
</div>
</div> </div>
); );
} }
@@ -12,6 +12,7 @@ import React from 'react';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { shouldDismissDropdown } from '@/components/ui/dropdown-navigation';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -23,8 +24,10 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta'; import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator'; import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
import { useKeybind } from '@/hooks/useKeybind';
import type { Theme } from '@/types/theme'; import type { Theme } from '@/types/theme';
import { normalizePath } from '../attachments/filePaths'; import { normalizePath } from '../attachments/filePaths';
import { getProjectDisplayLabel, type DraftTargetProject } from '../state/useDraftTarget'; import { getProjectDisplayLabel, type DraftTargetProject } from '../state/useDraftTarget';
@@ -57,7 +60,9 @@ const getProjectIconColor = (projectColor?: string | null): string | undefined =
function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme: Theme }) { function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme: Theme }) {
const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null; const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const iconColor = getProjectIconColor(project.color); const iconColor = getProjectIconColor(project.color);
const fallbackIcon = projectIconName ? ( const fallbackIcon = project.kind === 'chat' ? (
<Icon name="chat-4" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" />
) : projectIconName ? (
<Icon name={projectIconName} className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} /> <Icon name={projectIconName} className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
) : ( ) : (
<Icon name="folder" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined} /> <Icon name="folder" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined} />
@@ -103,25 +108,61 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
onDirectoryChange, onDirectoryChange,
theme, theme,
} = props; } = props;
const [openPicker, setOpenPicker] = React.useState<'project' | 'worktree' | null>(null);
const projectTriggerRef = React.useRef<HTMLButtonElement>(null);
const worktreeTriggerRef = React.useRef<HTMLButtonElement>(null);
const handlePickerKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
if (openPicker === null || !shouldDismissDropdown(event)) return;
event.preventDefault();
event.stopPropagation();
setOpenPicker(null);
};
useKeybind('open_draft_project_picker', () => {
projectTriggerRef.current?.focus();
setOpenPicker('project');
});
useKeybind('open_draft_worktree_picker', () => {
if (!showBranchSelector) return false;
worktreeTriggerRef.current?.focus();
setOpenPicker('worktree');
});
const handleProjectChange = (projectId: string) => {
onProjectChange(projectId);
setOpenPicker(null);
};
const handleDirectoryChange = (directory: string) => {
onDirectoryChange(directory);
setOpenPicker(null);
};
return ( return (
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 px-0.5"> <div className="mb-1.5 flex min-w-0 items-center gap-1.5 px-0.5">
<Select <Select
value={selectedProject.id} value={selectedProject.id}
onValueChange={onProjectChange} open={openPicker === 'project'}
onOpenChange={(open) => setOpenPicker(open ? 'project' : null)}
onValueChange={handleProjectChange}
disableGlobalShortcuts
> >
<SelectTrigger <SelectTrigger
ref={projectTriggerRef}
onKeyDown={handlePickerKeyDown}
size="sm" size="sm"
className="h-7 min-w-0 w-fit max-w-[42vw] sm:max-w-[18rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent" className="h-7 min-w-0 w-fit max-w-[42vw] sm:max-w-[18rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
> >
<SelectValue> <SelectValue>
{<ProjectLabel project={selectedProject} theme={theme} />} {selectedProject.kind === 'chat'
? <span className="truncate">{t('chat.chatInput.chooseProject')}</span>
: <ProjectLabel project={selectedProject} theme={theme} />}
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent fitContent> <SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain fitContent onKeyDown={handlePickerKeyDown}>
{projects.map((project) => ( {projects.map((project) => (
<SelectItem key={project.id} value={project.id} className="max-w-[24rem] truncate"> <SelectItem key={project.id} value={project.id} showSelectedBackground={false} className="max-w-[24rem] truncate">
{<ProjectLabel project={project} theme={theme} />} <ProjectLabel project={project} theme={theme} />
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -130,9 +171,14 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
{showBranchSelector ? ( {showBranchSelector ? (
<Select <Select
value={selectedDirectory ?? branchItems[0]?.value ?? normalizePath(selectedProject.path) ?? ''} value={selectedDirectory ?? branchItems[0]?.value ?? normalizePath(selectedProject.path) ?? ''}
onValueChange={onDirectoryChange} open={openPicker === 'worktree'}
onOpenChange={(open) => setOpenPicker(open ? 'worktree' : null)}
onValueChange={handleDirectoryChange}
disableGlobalShortcuts
> >
<SelectTrigger <SelectTrigger
ref={worktreeTriggerRef}
onKeyDown={handlePickerKeyDown}
size="sm" size="sm"
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent" className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
> >
@@ -140,11 +186,11 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
{selectedBranchLabel ?? t('chat.chatInput.branch')} {selectedBranchLabel ?? t('chat.chatInput.branch')}
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent className="w-max min-w-48"> <SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48" onKeyDown={handlePickerKeyDown}>
{projectRootBranchOption ? ( {projectRootBranchOption ? (
<SelectGroup> <SelectGroup>
<SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel> <SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel>
<SelectItem key={projectRootBranchOption.value} value={projectRootBranchOption.value} className="max-w-[24rem] truncate"> <SelectItem key={projectRootBranchOption.value} value={projectRootBranchOption.value} showSelectedBackground={false} className="max-w-[24rem] truncate">
{projectRootBranchOption.label} {projectRootBranchOption.label}
</SelectItem> </SelectItem>
</SelectGroup> </SelectGroup>
@@ -163,13 +209,13 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
</button> </button>
</div> </div>
{worktreeBranchOptions.map((option) => ( {worktreeBranchOptions.map((option) => (
<SelectItem key={option.value} value={option.value} className="max-w-[24rem] truncate"> <SelectItem key={option.value} value={option.value} showSelectedBackground={false} className="max-w-[24rem] truncate">
{option.pending ? '⏳ ' : ''}{option.label} {option.pending ? '⏳ ' : ''}{option.label}
</SelectItem> </SelectItem>
))} ))}
</SelectGroup> </SelectGroup>
{selectedDirectory && !selectedBranchIsKnown ? ( {selectedDirectory && !selectedBranchIsKnown ? (
<SelectItem value={selectedDirectory} className="max-w-[24rem] truncate"> <SelectItem value={selectedDirectory} showSelectedBackground={false} className="max-w-[24rem] truncate">
{selectedBranchLabel} {selectedBranchLabel}
</SelectItem> </SelectItem>
) : null} ) : null}
@@ -195,7 +241,9 @@ export function MobileDraftTargetTriggers(
className="inline-flex h-7 min-w-0 max-w-[42vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]" className="inline-flex h-7 min-w-0 max-w-[42vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]"
onClick={() => onOpenPicker('project')} onClick={() => onOpenPicker('project')}
> >
{<ProjectLabel project={selectedProject} theme={theme} />} {selectedProject.kind === 'chat'
? <span className="truncate">{t('chat.chatInput.chooseProject')}</span>
: <ProjectLabel project={selectedProject} theme={theme} />}
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" /> <Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button> </button>
{showBranchSelector ? ( {showBranchSelector ? (
@@ -258,13 +306,7 @@ export function MobileDraftTargetSheets(
className="h-9" className="h-9"
/> />
<div className="flex flex-col"> <div className="flex flex-col">
{projects {rankByQuery(projects, query, (project) => [getProjectDisplayLabel(project), project.path])
.filter((project) => {
const needle = query.trim().toLowerCase();
if (!needle) return true;
return getProjectDisplayLabel(project).toLowerCase().includes(needle)
|| project.path.toLowerCase().includes(needle);
})
.map((project) => ( .map((project) => (
<button <button
key={project.id} key={project.id}
@@ -275,7 +317,7 @@ export function MobileDraftTargetSheets(
onOpenPickerChange(null); onOpenPickerChange(null);
}} }}
> >
<span className="min-w-0 flex-1">{<ProjectLabel project={project} theme={theme} />}</span> <span className="min-w-0 flex-1"><ProjectLabel project={project} theme={theme} /></span>
{project.id === selectedProject.id ? ( {project.id === selectedProject.id ? (
<Icon name="check" className="h-4 w-4 flex-shrink-0 text-muted-foreground" /> <Icon name="check" className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
) : null} ) : null}
@@ -298,8 +340,7 @@ export function MobileDraftTargetSheets(
/> />
<div className="flex flex-col"> <div className="flex flex-col">
{(() => { {(() => {
const needle = query.trim().toLowerCase(); const matches = (label: string) => matchesRankQuery([label], query);
const matches = (label: string) => !needle || label.toLowerCase().includes(needle);
const selectedValue = selectedDirectory const selectedValue = selectedDirectory
?? branchItems[0]?.value ?? branchItems[0]?.value
?? normalizePath(selectedProject.path) ?? normalizePath(selectedProject.path)
@@ -343,8 +384,7 @@ export function MobileDraftTargetSheets(
{t('chat.chatInput.worktreeNew')} {t('chat.chatInput.worktreeNew')}
</button> </button>
</div> </div>
{worktreeBranchOptions {rankByQuery(worktreeBranchOptions, query, (option) => [option.label])
.filter((option) => matches(option.label))
.map((option) => renderRow(option.value, `${option.pending ? '⏳ ' : ''}${option.label}`))} .map((option) => renderRow(option.value, `${option.pending ? '⏳ ' : ''}${option.label}`))}
{selectedDirectory && !selectedBranchIsKnown && matches(selectedBranchLabel ?? '') {selectedDirectory && !selectedBranchIsKnown && matches(selectedBranchLabel ?? '')
? renderRow(selectedDirectory, selectedBranchLabel, 'unknown-current') ? renderRow(selectedDirectory, selectedBranchLabel, 'unknown-current')
@@ -5,7 +5,12 @@ import React from 'react';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { cn, isMacOS } from '@/lib/utils'; import {
formatShortcutForDisplay,
getEffectiveShortcutCombo,
} from '@/lib/shortcuts';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
type FocusModeButtonProps = { type FocusModeButtonProps = {
footerIconButtonClass: string; footerIconButtonClass: string;
@@ -17,6 +22,12 @@ type FocusModeButtonProps = {
export const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButtonProps) { export const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButtonProps) {
const { footerIconButtonClass, iconSizeClass, isExpandedInput, onToggle } = props; const { footerIconButtonClass, iconSizeClass, isExpandedInput, onToggle } = props;
const { t } = useI18n(); const { t } = useI18n();
const expandInputShortcutOverride = useUIStore((state) => state.shortcutOverrides.expand_input);
const expandInputCombo = getEffectiveShortcutCombo(
'expand_input',
expandInputShortcutOverride === undefined ? undefined : { expand_input: expandInputShortcutOverride },
);
const shortcut = expandInputCombo ? formatShortcutForDisplay(expandInputCombo) : null;
return ( return (
<Tooltip> <Tooltip>
@@ -43,9 +54,7 @@ export const FocusModeButton = React.memo(function FocusModeButton(props: FocusM
<TooltipContent side="top" sideOffset={8}> <TooltipContent side="top" sideOffset={8}>
<div className="flex flex-col gap-0.5 text-center"> <div className="flex flex-col gap-0.5 text-center">
<span>{t('chat.chatInput.focusMode.label')}</span> <span>{t('chat.chatInput.focusMode.label')}</span>
<span className="font-mono opacity-60"> {shortcut ? <span className="font-mono opacity-60">{shortcut}</span> : null}
{isMacOS() ? '⌘⇧E' : 'Ctrl+Shift+E'}
</span>
</div> </div>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -84,7 +84,8 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
className="flex h-11 min-w-0 flex-1 items-center gap-x-0.5 rounded-full border border-border/80 pl-2 pr-1 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]" data-mobile-composer-pill="true"
className="flex h-11 min-w-0 flex-1 items-center gap-x-0.5 rounded-full border border-border/80 pl-2 pr-1 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]"
style={{ backgroundColor: currentTheme?.colors?.surface?.subtle }} style={{ backgroundColor: currentTheme?.colors?.surface?.subtle }}
> >
<ComposerAttachmentControls <ComposerAttachmentControls
@@ -0,0 +1,53 @@
import { describe, expect, test } from 'bun:test';
import { mentionServerQuery, rankFileMentionResults, tokenizeMentionQuery } from './fileMentionResults';
const hit = (relativePath: string) => {
const name = relativePath.split('/').filter(Boolean).pop() ?? relativePath;
return {
name,
path: `/root/${relativePath}`,
relativePath,
extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined,
};
};
describe('tokenizeMentionQuery', () => {
test('normalizes leading ./ and slashes and splits on whitespace', () => {
expect(tokenizeMentionQuery('./Solo Team')).toEqual(['solo', 'team']);
expect(tokenizeMentionQuery(' ')).toEqual([]);
});
});
describe('mentionServerQuery', () => {
test('uses the longest token for the server search', () => {
expect(mentionServerQuery('team solo-is-a')).toBe('solo-is-a');
expect(mentionServerQuery('')).toBe('');
});
});
describe('rankFileMentionResults', () => {
test('ranks files and directories together by match quality, not by category', () => {
const files = [hit('solo-is-a-team-size/index.md'), hit('software-developer/index.md')];
const directories = [hit('machine-learning/tensorflow/'), hit('solo-is-a-team-size/')];
const ranked = rankFileMentionResults(files, directories, 'solo');
const paths = ranked.map((entry) => entry.relativePath);
expect(paths.slice(0, 2)).toEqual(['solo-is-a-team-size/', 'solo-is-a-team-size/index.md']);
expect(paths).not.toContain('machine-learning/tensorflow/');
});
test('multi-token queries match tokens in any order across the path', () => {
const files = [hit('solo-is-a-team-size/index.md'), hit('software-developer/index.md')];
const ranked = rankFileMentionResults(files, [], 'team solo');
expect(ranked.map((entry) => entry.relativePath)).toEqual(['solo-is-a-team-size/index.md']);
});
test('tags each result with its kind', () => {
const ranked = rankFileMentionResults([hit('a/readme.md')], [hit('a/')], 'a');
expect(ranked.find((entry) => entry.relativePath === 'a/')?.kind).toBe('directory');
expect(ranked.find((entry) => entry.relativePath === 'a/readme.md')?.kind).toBe('file');
});
});
@@ -0,0 +1,60 @@
import { scoreByFuzzyQuery } from '@/lib/search/fuzzySearch';
import type { ProjectFileSearchHit } from '@/lib/opencode/client';
export type FileMentionHit = ProjectFileSearchHit & { kind: 'file' | 'directory' };
export const tokenizeMentionQuery = (query: string): string[] =>
(query ?? '')
.trim()
.replace(/^\.\//, '')
.replace(/^\/+/, '')
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
/**
* The opencode file search takes a single term, so multi-word queries send the
* most selective (longest) token and the remaining tokens filter client-side.
*/
export const mentionServerQuery = (query: string): string => {
const tokens = tokenizeMentionQuery(query);
if (tokens.length === 0) {
return '';
}
return tokens.reduce((longest, token) => (token.length > longest.length ? token : longest));
};
/**
* Merge directory and file hits into one list ranked by match quality against
* the full relative path. Multi-token queries require every token to appear
* somewhere in the path, in any order.
*/
export function rankFileMentionResults(
files: ProjectFileSearchHit[],
directories: ProjectFileSearchHit[],
query: string,
limit = 20,
): FileMentionHit[] {
const merged: FileMentionHit[] = [
...directories.map((hit) => ({ ...hit, kind: 'directory' as const })),
...files.map((hit) => ({ ...hit, kind: 'file' as const })),
];
const tokens = tokenizeMentionQuery(query);
if (tokens.length === 0) {
return merged.slice(0, limit);
}
const pathOf = (hit: FileMentionHit) => hit.relativePath || hit.name;
const candidates = tokens.length === 1
? merged
: merged.filter((hit) => {
const haystack = pathOf(hit).toLowerCase();
return tokens.every((token) => haystack.includes(token));
});
const primary = tokens.reduce((longest, token) => (token.length > longest.length ? token : longest));
return scoreByFuzzyQuery(candidates, primary, pathOf, { limit, threshold: 0.4 }).map(
(scored) => scored.item,
);
}
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { fileReferenceExists } from './fileReferenceStat';
const originalFetch = globalThis.fetch;
const calls: Array<{ url: string; headers: Headers }> = [];
const stubFetchWith = (respond: () => Response) => {
calls.length = 0;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input instanceof Request ? input.url : input.toString();
const headers = new Headers(init?.headers);
calls.push({ url, headers });
return respond();
// SAFETY: the stub preserves the fetch signature; every caller in this
// file restores globalThis.fetch in afterEach.
}) as typeof fetch;
};
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe('fileReferenceExists directory scoping (issue 3019)', () => {
test('sends the session directory on the stat probe', async () => {
stubFetchWith(() => new Response(JSON.stringify({ path: '/repo-b/src/index.ts', isFile: true, size: 12 }), { status: 200 }));
const exists = await fileReferenceExists('/repo-b/src/index.ts', '/repo-b');
expect(exists).toBe(true);
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe('/api/fs/stat?path=%2Frepo-b%2Fsrc%2Findex.ts&optional=true');
expect(calls[0].headers.get('x-opencode-directory')).toBe('/repo-b');
});
test('treats a workspace rejection under one directory as unknown under another directory', async () => {
// Directory A resolves the workspace on the server (the browsed
// lastDirectory), so the probe for a path under B is rejected with 400
// and resolves false. The same path probed under B itself must issue a
// fresh request rather than reuse A's cached rejection.
stubFetchWith(() => {
const directoryHint = calls[calls.length - 1]?.headers.get('x-opencode-directory') ?? null;
if (directoryHint !== '/repo-b') {
return new Response(JSON.stringify({ error: 'Path is outside of active workspace' }), { status: 400 });
}
return new Response(JSON.stringify({ path: '/repo-b/lib/main.ts', isFile: true, size: 12 }), { status: 200 });
});
const rejectedUnderA = await fileReferenceExists('/repo-b/lib/main.ts', '/repo-a');
const acceptedUnderB = await fileReferenceExists('/repo-b/lib/main.ts', '/repo-b');
expect(rejectedUnderA).toBe(false);
expect(acceptedUnderB).toBe(true);
expect(calls).toHaveLength(2);
});
test('serves a repeated probe under the same directory from the cache', async () => {
stubFetchWith(() => new Response(JSON.stringify({ path: '/repo-c/lib.ts', isFile: true, size: 4 }), { status: 200 }));
await fileReferenceExists('/repo-c/lib.ts', '/repo-c');
const warm = await fileReferenceExists('/repo-c/lib.ts', '/repo-c');
expect(warm).toBe(true);
expect(calls).toHaveLength(1);
});
});
@@ -0,0 +1,80 @@
import { isVSCodeRuntime } from '@/lib/desktop';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { normalizeReferencePath } from './fileReferenceParser';
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>();
let activeFileReferenceStatCount = 0;
const pendingFileReferenceStats: Array<() => void> = [];
const getFileReferenceStatCacheMax = (): number => (
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_STAT_CACHE_MAX : FILE_REFERENCE_STAT_CACHE_MAX
);
// NUL cannot occur in a real path, so a directory-qualified key cannot collide
// with a differently scoped entry.
const statCacheKey = (directory: string, normalizedPath: string): string => `${directory}\u0000${normalizedPath}`;
export const fileReferenceExists = (resolvedPath: string, effectiveDirectory: string): Promise<boolean> => {
const normalizedPath = normalizeReferencePath(resolvedPath);
if (!normalizedPath) {
return Promise.resolve(false);
}
const cacheKey = statCacheKey(effectiveDirectory, normalizedPath);
const cached = FILE_REFERENCE_STAT_CACHE.get(cacheKey);
if (cached) {
FILE_REFERENCE_STAT_CACHE.delete(cacheKey);
FILE_REFERENCE_STAT_CACHE.set(cacheKey, cached);
return cached;
}
const request = new Promise<boolean>((resolve) => {
const run = () => {
activeFileReferenceStatCount += 1;
void runtimeFetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}&optional=true`, {
method: 'GET',
cache: 'no-store',
// The stat route resolves the workspace from this header. Without it
// the server falls back to the browsed lastDirectory, which rejects
// session-local files with 400 whenever the two directories differ.
headers: effectiveDirectory ? { 'x-opencode-directory': effectiveDirectory } : undefined,
})
.then(async (response) => {
if (!response.ok) {
resolve(false);
return;
}
const payload = await response.json().catch(() => null) as { exists?: unknown } | null;
resolve(payload?.exists !== false);
})
.catch(() => resolve(false))
.finally(() => {
activeFileReferenceStatCount = Math.max(0, activeFileReferenceStatCount - 1);
pendingFileReferenceStats.shift()?.();
});
};
if (activeFileReferenceStatCount < FILE_REFERENCE_STAT_CONCURRENCY) {
run();
return;
}
pendingFileReferenceStats.push(run);
});
const maxCacheEntries = getFileReferenceStatCacheMax();
while (FILE_REFERENCE_STAT_CACHE.size >= maxCacheEntries) {
const oldest = FILE_REFERENCE_STAT_CACHE.keys().next().value;
if (typeof oldest !== 'string') {
break;
}
FILE_REFERENCE_STAT_CACHE.delete(oldest);
}
FILE_REFERENCE_STAT_CACHE.set(cacheKey, request);
return request;
};
@@ -0,0 +1,227 @@
import { describe, expect, test } from 'bun:test';
import {
CHAT_LIST_ANCHOR_OFFSET,
getAnchoredTurnMetrics,
getRowBottom,
resolveChatListAnchoredEndSpace,
resolveTimelineIsAtEnd,
type TimelineListMeasurementState,
} from './timelineScrollAnchoring';
const buildState = ({
positions,
sizes,
scroll = 0,
scrollLength = 700,
}: {
readonly positions: readonly number[];
readonly sizes: readonly number[];
readonly scroll?: number;
readonly scrollLength?: number;
}): TimelineListMeasurementState => ({
data: positions.map((_, index) => index),
scroll,
scrollLength,
positionAtIndex: (index) => positions[index],
sizeAtIndex: (index) => sizes[index],
});
describe('getRowBottom', () => {
test('measures row bottoms from list row position and size', () => {
const state = buildState({ positions: [0, 120], sizes: [80, 40] });
expect(getRowBottom(state, 1)).toBe(160);
});
test('returns null for unmeasured rows', () => {
const state = buildState({ positions: [0], sizes: [80] });
expect(getRowBottom(state, 5)).toBeNull();
});
test('treats a zero-height row as one pixel tall', () => {
const state = buildState({ positions: [0, 120], sizes: [120, 0] });
expect(getRowBottom(state, 1)).toBe(121);
});
});
describe('getAnchoredTurnMetrics', () => {
test('returns null for an empty timeline', () => {
const state = buildState({ positions: [], sizes: [] });
expect(getAnchoredTurnMetrics({
state,
anchorIndex: 0,
composerOverlayHeight: 180,
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
})).toBeNull();
});
test('treats the active turn as fitting when it fits above the composer', () => {
const state = buildState({
positions: [0, 300, 460],
sizes: [240, 80, 140],
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.turnHeight).toBe(300);
expect(metrics?.usableViewportHeight).toBe(564);
expect(metrics?.overflowsUsableViewport).toBe(false);
expect(metrics?.targetScrollToRevealEnd).toBe(36);
expect(metrics?.scrollDeltaToRevealEnd).toBe(36);
});
test('targets the real row end instead of any temporary reserved tail', () => {
const state = buildState({
positions: [0, 1720, 1880],
sizes: [1600, 80, 120],
scroll: 1900,
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.lastBottom).toBe(2000);
expect(metrics?.targetScrollToRevealEnd).toBe(1436);
expect(metrics?.scrollDeltaToRevealEnd).toBe(0);
});
test('reports overflow only for the current anchored turn', () => {
const state = buildState({
positions: [0, 900, 1180],
sizes: [800, 220, 300],
scroll: 900,
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.turnHeight).toBe(580);
expect(metrics?.usableViewportHeight).toBe(564);
expect(metrics?.overflowsUsableViewport).toBe(true);
});
test('returns the minimal positive scroll delta needed to reveal the turn end', () => {
const state = buildState({
positions: [0, 900, 1180],
sizes: [800, 220, 360],
scroll: 900,
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.lastBottom).toBe(1540);
expect(metrics?.visibleUsableBottom).toBe(1464);
expect(metrics?.scrollDeltaToRevealEnd).toBe(76);
});
test('subtracts composer height from usable viewport height', () => {
const state = buildState({
positions: [0, 300],
sizes: [120, 470],
scrollLength: 700,
});
const withoutComposer = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 0,
anchorOffset: 16,
});
const withComposer = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 220,
anchorOffset: 16,
});
expect(withoutComposer?.overflowsUsableViewport).toBe(false);
expect(withComposer?.overflowsUsableViewport).toBe(true);
});
test('clamps an out-of-range anchor index to the last row', () => {
const state = buildState({
positions: [0, 300],
sizes: [240, 80],
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 99,
composerOverlayHeight: 0,
anchorOffset: 16,
});
expect(metrics?.anchorTop).toBe(300);
expect(metrics?.turnHeight).toBe(80);
});
});
describe('resolveTimelineIsAtEnd', () => {
test('uses a tight distance band against the full content length', () => {
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1400, scrollLength: 600 })).toBe(true);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1365, scrollLength: 600 })).toBe(true);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1300, scrollLength: 600 })).toBe(false);
});
test('falls back to the list flags when distances are unavailable', () => {
expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true);
expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true);
});
test('reports nothing without a state', () => {
expect(resolveTimelineIsAtEnd(undefined)).toBe(undefined);
});
});
describe('resolveChatListAnchoredEndSpace', () => {
const rows = [{ id: 'a' }, { id: 'b' }, { id: 'a' }];
test('returns nothing when no anchor is set', () => {
expect(resolveChatListAnchoredEndSpace(rows, null, (row) => row.id)).toBe(undefined);
});
test('returns nothing when the anchor is not in the list', () => {
expect(resolveChatListAnchoredEndSpace(rows, 'z', (row) => row.id)).toBe(undefined);
});
test('resolves the last occurrence so a resent message anchors to its live row', () => {
expect(resolveChatListAnchoredEndSpace(rows, 'a', (row) => row.id)).toEqual({
anchorIndex: 2,
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
});
});
test('honours an explicit anchor offset', () => {
expect(resolveChatListAnchoredEndSpace(rows, 'b', (row) => row.id, { anchorOffset: 40 })).toEqual({
anchorIndex: 1,
anchorOffset: 40,
});
});
});
@@ -0,0 +1,167 @@
// Anchored-turn scroll geometry for the chat timeline.
//
// The timeline has three mutually exclusive scroll modes:
//
// • `following-end` — stay pinned to the live edge as content grows.
// • `anchoring-new-turn` — the just-sent user message is parked near the TOP
// of the viewport and the reply streams into reserved space below it. The
// viewport does NOT move until the turn outgrows the usable viewport.
// • `free-scrolling` — the user took over; nothing moves the scroll
// position until they opt back in.
//
// This module is pure geometry: it reads measurements from the virtualized
// list and answers "how far, if at all, must we scroll to reveal the end of
// the anchored turn". Keeping it free of DOM and React makes the mode machine
// testable without a renderer.
//
// "Usable viewport" is the visible height minus the composer overlay (the
// composer floats over the list) minus the anchor offset, so a turn is only
// considered overflowing when it genuinely cannot be read.
export type TimelineScrollMode = 'following-end' | 'anchoring-new-turn' | 'free-scrolling';
// Distance from the top of the viewport at which an anchored user message
// parks. Small enough to read as "at the top", large enough not to collide
// with the timeline's top fade.
export const CHAT_LIST_ANCHOR_OFFSET = 16;
export interface TimelineListMeasurementState {
readonly data: readonly unknown[];
readonly scroll: number;
readonly scrollLength: number;
readonly positionAtIndex: (index: number) => number | undefined;
readonly sizeAtIndex: (index: number) => number | undefined;
}
export interface AnchoredTurnMetrics {
readonly anchorTop: number;
readonly lastBottom: number;
readonly turnHeight: number;
readonly usableViewportHeight: number;
readonly visibleUsableBottom: number;
readonly overflowsUsableViewport: boolean;
readonly targetScrollToRevealEnd: number;
readonly scrollDeltaToRevealEnd: number;
}
export const getRowBottom = (
state: TimelineListMeasurementState,
index: number,
): number | null => {
const top = state.positionAtIndex(index);
const height = state.sizeAtIndex(index);
if (
typeof top !== 'number'
|| typeof height !== 'number'
|| !Number.isFinite(top)
|| !Number.isFinite(height)
) {
return null;
}
// Rows measured at zero height would make an anchored turn look empty and
// suppress the reveal scroll; treat them as one pixel tall instead.
return top + Math.max(1, height);
};
export const getAnchoredTurnMetrics = ({
state,
anchorIndex,
composerOverlayHeight,
anchorOffset,
}: {
readonly state: TimelineListMeasurementState;
readonly anchorIndex: number;
readonly composerOverlayHeight: number;
readonly anchorOffset: number;
}): AnchoredTurnMetrics | null => {
if (state.data.length === 0) return null;
const boundedAnchorIndex = Math.max(0, Math.min(anchorIndex, state.data.length - 1));
const anchorTop = state.positionAtIndex(boundedAnchorIndex);
// The LAST row bottom, not the content length: the reserved anchored end
// space lives past it, and targeting that reserved tail would scroll the
// real content off the top.
const lastBottom = getRowBottom(state, state.data.length - 1);
if (typeof anchorTop !== 'number' || !Number.isFinite(anchorTop) || lastBottom === null) {
return null;
}
const usableViewportHeight = Math.max(
0,
state.scrollLength - composerOverlayHeight - anchorOffset,
);
const turnHeight = Math.max(0, lastBottom - anchorTop);
const visibleUsableBottom = state.scroll + usableViewportHeight;
const targetScrollToRevealEnd = Math.max(0, lastBottom - usableViewportHeight);
// Never negative: revealing the end must not scroll the timeline backwards.
const scrollDeltaToRevealEnd = Math.max(0, targetScrollToRevealEnd - state.scroll);
return {
anchorTop,
lastBottom,
turnHeight,
usableViewportHeight,
visibleUsableBottom,
overflowsUsableViewport: turnHeight > usableViewportHeight,
targetScrollToRevealEnd,
scrollDeltaToRevealEnd,
};
};
// "At the end" for follow purposes is a tight band, not the list's isNearEnd
// (half a viewport): that band hid the scroll-to-bottom pill and re-armed
// follow while the user had genuinely scrolled away, yanking them back on the
// next stream chunk. Distance is measured against the full content length —
// reserved anchored end space included — so a parked anchored turn counts as
// the live edge.
export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40;
export const resolveTimelineIsAtEnd = (
state: {
readonly contentLength?: number;
readonly scroll?: number;
readonly scrollLength?: number;
readonly isNearEnd?: boolean;
readonly isAtEnd?: boolean;
} | undefined,
): boolean | undefined => {
if (!state) return undefined;
const { contentLength, scroll, scrollLength } = state;
if (
typeof contentLength === 'number'
&& typeof scroll === 'number'
&& typeof scrollLength === 'number'
&& Number.isFinite(contentLength)
) {
return contentLength - (scroll + scrollLength) <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;
}
return state.isNearEnd ?? state.isAtEnd;
};
export interface ChatListAnchoredEndSpace {
readonly anchorIndex: number;
readonly anchorOffset: number;
}
// Finds the anchored row from the BACK of the list: a retried or re-sent
// message id can appear more than once, and the live one is always the last.
export const resolveChatListAnchoredEndSpace = <Item, AnchorId>(
items: readonly Item[],
anchorId: AnchorId | null,
getAnchorId: (item: Item) => AnchorId | null,
options: { readonly anchorOffset?: number } = {},
): ChatListAnchoredEndSpace | undefined => {
if (anchorId === null) return undefined;
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (item !== undefined && getAnchorId(item) === anchorId) {
return {
anchorIndex: index,
anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET,
};
}
}
return undefined;
};
@@ -0,0 +1,39 @@
import { describe, expect, test } from 'bun:test';
import { commitStreamedText } from './streamTextCommit';
describe('commitStreamedText', () => {
test('holds an incomplete short paragraph entirely', () => {
expect(commitStreamedText('An unfinished thought abo')).toBe('');
});
test('commits up to the last complete line', () => {
expect(commitStreamedText('First paragraph.\n\nSecond par')).toBe('First paragraph.\n\n');
});
test('reveals code fences line by line', () => {
const text = '```py\nprint("a")\nprint("b';
expect(commitStreamedText(text)).toBe('```py\nprint("a")\n');
});
test('releases a long held paragraph at the last sentence boundary', () => {
const sentence = 'A finished sentence lives here. ';
const text = sentence.repeat(12) + 'and an unfinished trail';
expect(commitStreamedText(text)).toBe(sentence.repeat(12));
});
test('falls back to the last word boundary without sentences', () => {
const words = 'word '.repeat(70);
const text = words + 'unfinishe';
expect(commitStreamedText(text)).toBe(words);
});
test('keeps unbreakable runs intact rather than splitting them', () => {
const run = 'x'.repeat(400);
expect(commitStreamedText(run)).toBe(run);
});
test('empty input stays empty', () => {
expect(commitStreamedText('')).toBe('');
});
});
@@ -0,0 +1,47 @@
// Block-level streaming reveal.
//
// Token-by-token streaming mutates the trailing paragraph in place on every
// tick: words rewrap, the last line jitters, and the reader's eye fights the
// motion. Committing only up to the last COMPLETE line keeps every rendered
// block immutable once it appears — prose arrives a paragraph at a time (a
// markdown paragraph is one logical line), code fences reveal line by line,
// tables row by row — and the only remaining motion is the follow scroll.
//
// A paragraph with no newline for a long stretch must not stall the stream,
// so once the held tail outgrows a threshold it is committed at the last
// sentence boundary (falling back to the last word boundary).
const HOLD_MAX_CHARS = 320;
const SENTENCE_END = /[.!?…][)"'»”’]?\s/g;
export const commitStreamedText = (text: string): string => {
if (text.length === 0) return text;
const lastNewline = text.lastIndexOf('\n');
const committed = lastNewline === -1 ? '' : text.slice(0, lastNewline + 1);
const held = text.slice(committed.length);
if (held.length <= HOLD_MAX_CHARS) {
return committed;
}
// The held paragraph got long: release it up to the last finished
// sentence so the block still never mutates mid-sentence.
let lastSentenceEnd = -1;
for (const match of held.matchAll(SENTENCE_END)) {
lastSentenceEnd = match.index + match[0].length;
}
if (lastSentenceEnd > 0) {
return committed + held.slice(0, lastSentenceEnd);
}
// No sentence boundary either (a URL, a very long token run): release up
// to the last word boundary, keeping only the incomplete word held.
const lastSpace = held.lastIndexOf(' ');
if (lastSpace > 0) {
return committed + held.slice(0, lastSpace + 1);
}
return text;
};
@@ -64,8 +64,7 @@ describe('buildLiveStreamingEntry', () => {
const entry = turnEntry(assistant); const entry = turnEntry(assistant);
const next = buildLiveStreamingEntry(entry, { const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_other', livePartsByMessageId: { assistant_other: [textPart('part_live', 'live')] },
liveParts: [textPart('part_live', 'live')],
showTextJustificationActivity: true, showTextJustificationActivity: true,
showTurnChangedFiles: false, showTurnChangedFiles: false,
}); });
@@ -79,8 +78,7 @@ describe('buildLiveStreamingEntry', () => {
const liveParts = [reasoningPart('part_1_live', 'thinking')]; const liveParts = [reasoningPart('part_1_live', 'thinking')];
const next = buildLiveStreamingEntry(entry, { const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_1', livePartsByMessageId: { assistant_1: liveParts },
liveParts,
showTextJustificationActivity: true, showTextJustificationActivity: true,
showTurnChangedFiles: false, showTurnChangedFiles: false,
}); });
@@ -102,8 +100,7 @@ describe('buildLiveStreamingEntry', () => {
const liveParts = [textPart('part_1_live', 'live')]; const liveParts = [textPart('part_1_live', 'live')];
const next = buildLiveStreamingEntry(entry, { const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_1', livePartsByMessageId: { assistant_1: liveParts },
liveParts,
showTextJustificationActivity: false, showTextJustificationActivity: false,
showTurnChangedFiles: false, showTurnChangedFiles: false,
}); });
@@ -121,8 +118,7 @@ describe('buildLiveStreamingEntry', () => {
const synthetic = syntheticTextPart('part_synthetic', 'hidden while streaming'); const synthetic = syntheticTextPart('part_synthetic', 'hidden while streaming');
const next = buildLiveStreamingEntry(entry, { const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_1', livePartsByMessageId: { assistant_1: [synthetic, visible] },
liveParts: [synthetic, visible],
showTextJustificationActivity: true, showTextJustificationActivity: true,
showTurnChangedFiles: false, showTurnChangedFiles: false,
}); });
@@ -131,4 +127,39 @@ describe('buildLiveStreamingEntry', () => {
if (next.kind !== 'turn') return; if (next.kind !== 'turn') return;
expect(next.turn.assistantMessages[0]?.parts).toEqual([visible]); expect(next.turn.assistantMessages[0]?.parts).toEqual([visible]);
}); });
test('keeps a finished step message on its live parts after the stream moves on', () => {
const finished = message('assistant_1', 'assistant', 'user_1', []);
const streaming = message('assistant_2', 'assistant', 'user_1', []);
const entry = turnEntry(finished);
if (entry.kind !== 'turn') return;
entry.turn.assistantMessageIds = ['assistant_1', 'assistant_2'];
entry.turn.assistantMessages = [finished, streaming];
const finishedLive = [textPart('part_tool_done', 'tool output')];
const streamingLive = [textPart('part_streaming', 'streaming')];
const next = buildLiveStreamingEntry(entry, {
livePartsByMessageId: { assistant_1: finishedLive, assistant_2: streamingLive },
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
expect(next.kind).toBe('turn');
if (next.kind !== 'turn') return;
expect(next.turn.assistantMessages[0]?.parts).toEqual(finishedLive);
expect(next.turn.assistantMessages[1]?.parts).toEqual(streamingLive);
});
test('never erases record parts with an empty live array', () => {
const assistant = message('assistant_1', 'assistant', 'user_1', [textPart('part_1', 'kept')]);
const entry = turnEntry(assistant);
const next = buildLiveStreamingEntry(entry, {
livePartsByMessageId: { assistant_1: [] },
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
expect(next).toBe(entry);
});
}); });
@@ -15,8 +15,13 @@ export type StreamingTailEntry =
| { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean }; | { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean };
type BuildLiveStreamingEntryOptions = { type BuildLiveStreamingEntryOptions = {
activeStreamingMessageId: string | null | undefined; // Live parts for EVERY message of the streaming tail, not only the one
liveParts: Part[]; // currently streaming: when the stream moves to the next step message, the
// previous message's base record can still lag behind the part store, and
// rendering it from that stale snapshot briefly drops its completed tool
// parts — remounting them (and replaying their reveal animation) once the
// record catches up.
livePartsByMessageId: Readonly<Record<string, Part[]>>;
showTextJustificationActivity: boolean; showTextJustificationActivity: boolean;
showTurnChangedFiles: boolean; showTurnChangedFiles: boolean;
mergeHiddenUserTurns?: { planModeEnabled: boolean }; mergeHiddenUserTurns?: { planModeEnabled: boolean };
@@ -24,10 +29,12 @@ type BuildLiveStreamingEntryOptions = {
const withLiveParts = ( const withLiveParts = (
message: ChatMessageEntry, message: ChatMessageEntry,
activeStreamingMessageId: string, livePartsByMessageId: Readonly<Record<string, Part[]>>,
liveParts: Part[],
): ChatMessageEntry => { ): ChatMessageEntry => {
if (message.info.id !== activeStreamingMessageId || message.parts === liveParts) { const liveParts = livePartsByMessageId[message.info.id];
// An empty live array is ambiguous — the store may simply not have loaded
// this message's parts — and must never erase parts the record does have.
if (!liveParts || liveParts.length === 0 || message.parts === liveParts) {
return message; return message;
} }
@@ -41,13 +48,10 @@ export const buildLiveStreamingEntry = <TEntry extends StreamingTailEntry>(
entry: TEntry, entry: TEntry,
options: BuildLiveStreamingEntryOptions, options: BuildLiveStreamingEntryOptions,
): TEntry => { ): TEntry => {
const activeStreamingMessageId = options.activeStreamingMessageId; const livePartsByMessageId = options.livePartsByMessageId;
if (!activeStreamingMessageId) {
return entry;
}
if (entry.kind === 'ungrouped') { if (entry.kind === 'ungrouped') {
const message = withLiveParts(entry.message, activeStreamingMessageId, options.liveParts); const message = withLiveParts(entry.message, livePartsByMessageId);
if (message === entry.message) { if (message === entry.message) {
return entry; return entry;
} }
@@ -59,7 +63,7 @@ export const buildLiveStreamingEntry = <TEntry extends StreamingTailEntry>(
let changed = false; let changed = false;
const assistantMessages = entry.turn.assistantMessages.map((message) => { const assistantMessages = entry.turn.assistantMessages.map((message) => {
const next = withLiveParts(message, activeStreamingMessageId, options.liveParts); const next = withLiveParts(message, livePartsByMessageId);
if (next !== message) { if (next !== message) {
changed = true; changed = true;
} }
@@ -43,28 +43,30 @@ export type DecorateContext = {
onPreviewLoopback?: (url: string) => void; onPreviewLoopback?: (url: string) => void;
}; };
// Reference the app's icon sprite (injected into <body> by the shared Icon
// component) so DOM-built controls use the same themed icons as the rest of
// the app. Sprite symbols are registered under `#oc-<name>`.
const spriteIcon = (name: IconName): string =>
`<svg class="remixicon size-3.5" viewBox="0 0 24 24" aria-hidden="true"><use href="#oc-${name}"></use></svg>`;
const ICONS = { const ICONS = {
copy: spriteIcon('file-copy'), copy: 'file-copy',
check: spriteIcon('check'), check: 'check',
download: spriteIcon('download'), download: 'download',
zoomIn: spriteIcon('add'), zoomIn: 'add',
zoomOut: spriteIcon('subtract'), zoomOut: 'subtract',
fit: spriteIcon('refresh'), fit: 'refresh',
textWrap: spriteIcon('text-wrap'), textWrap: 'text-wrap',
image: spriteIcon('file-image'), image: 'file-image',
} as const; } as const satisfies Record<string, IconName>;
const ICON_BTN_CLASS = const ICON_BTN_CLASS =
'p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--interactive-focus-ring)]'; 'p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--interactive-focus-ring)]';
const setIconHtml = (el: Element, html: string): void => { const setIcon = (el: Element, icon: keyof typeof ICONS): void => {
el.innerHTML = html; const iconName = ICONS[icon];
const svg = el.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'remixicon size-3.5');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('aria-hidden', 'true');
const use = el.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'use');
use.setAttribute('href', `#oc-${iconName}`);
svg.appendChild(use);
el.replaceChildren(svg);
}; };
const decorateImageLabels = (root: HTMLElement): void => { const decorateImageLabels = (root: HTMLElement): void => {
@@ -74,7 +76,7 @@ const decorateImageLabels = (root: HTMLElement): void => {
icon.className = 'inline-flex shrink-0'; icon.className = 'inline-flex shrink-0';
icon.setAttribute('aria-hidden', 'true'); icon.setAttribute('aria-hidden', 'true');
icon.setAttribute('data-openchamber-markdown-image-label-icon', 'true'); icon.setAttribute('data-openchamber-markdown-image-label-icon', 'true');
setIconHtml(icon, ICONS.image); setIcon(icon, 'image');
label.prepend(icon); label.prepend(icon);
} }
}; };
@@ -86,7 +88,7 @@ const makeIconButton = (icon: keyof typeof ICONS, title: string, slot: string):
button.setAttribute('data-md-action', slot); button.setAttribute('data-md-action', slot);
button.setAttribute('title', title); button.setAttribute('title', title);
button.setAttribute('aria-label', title); button.setAttribute('aria-label', title);
setIconHtml(button, ICONS[icon]); setIcon(button, icon);
return button; return button;
}; };
@@ -131,6 +133,9 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
const code = pre.querySelector<HTMLElement>(':scope > code'); const code = pre.querySelector<HTMLElement>(':scope > code');
if (!code || code.hasAttribute('data-md-code-lines')) return; if (!code || code.hasAttribute('data-md-code-lines')) return;
// The real gutter takes over the reserved footprint.
pre.removeAttribute('data-md-gutter-reserved');
const text = code.textContent ?? ''; const text = code.textContent ?? '';
const hasTrailingNewline = text.endsWith('\n'); const hasTrailingNewline = text.endsWith('\n');
const lines = hasTrailingNewline ? text.slice(0, -1).split('\n') : text.split('\n'); const lines = hasTrailingNewline ? text.slice(0, -1).split('\n') : text.split('\n');
@@ -196,11 +201,11 @@ export const applyMarkdownCodeBlockWrapState = (root: HTMLElement, enabled: bool
}; };
const flashCopied = (button: HTMLButtonElement, copiedTitle: string, restore: keyof typeof ICONS, restoreTitle: string): void => { const flashCopied = (button: HTMLButtonElement, copiedTitle: string, restore: keyof typeof ICONS, restoreTitle: string): void => {
setIconHtml(button, ICONS.check); setIcon(button, 'check');
button.setAttribute('title', copiedTitle); button.setAttribute('title', copiedTitle);
button.setAttribute('aria-label', copiedTitle); button.setAttribute('aria-label', copiedTitle);
window.setTimeout(() => { window.setTimeout(() => {
setIconHtml(button, ICONS[restore]); setIcon(button, restore);
button.setAttribute('title', restoreTitle); button.setAttribute('title', restoreTitle);
button.setAttribute('aria-label', restoreTitle); button.setAttribute('aria-label', restoreTitle);
}, 2000); }, 2000);
@@ -263,7 +268,15 @@ const decorateCodeBlocks = (root: HTMLElement, ctx: DecorateContext): void => {
pre.style.margin = '0'; pre.style.margin = '0';
pre.style.background = 'transparent'; pre.style.background = 'transparent';
pre.classList.add('min-w-0', 'w-full', 'flex-1'); pre.classList.add('min-w-0', 'w-full', 'flex-1');
if (!ctx.deferCodeLineNumberSync) layoutCodeLines(pre); if (!ctx.deferCodeLineNumberSync) {
layoutCodeLines(pre);
} else {
// Streaming defers the per-line gutter markup, but the gutter's
// horizontal footprint is reserved immediately — otherwise the
// end-of-stream decorate pass shifts every code line right by the
// gutter column and the finished message visibly jumps.
pre.setAttribute('data-md-gutter-reserved', '');
}
body.appendChild(pre); body.appendChild(pre);
wrapper.appendChild(header); wrapper.appendChild(header);
wrapper.appendChild(body); wrapper.appendChild(body);
@@ -492,7 +505,7 @@ const decorateLinks = (root: HTMLElement, ctx: DecorateContext): void => {
preview.setAttribute('data-md-url', href); preview.setAttribute('data-md-url', href);
preview.setAttribute('title', ctx.labels.previewTitle); preview.setAttribute('title', ctx.labels.previewTitle);
preview.setAttribute('aria-label', ctx.labels.previewLabel); preview.setAttribute('aria-label', ctx.labels.previewLabel);
setIconHtml(preview, ICONS.download); setIcon(preview, 'download');
anchor.parentNode?.insertBefore(preview, anchor.nextSibling); anchor.parentNode?.insertBefore(preview, anchor.nextSibling);
} }
} }
@@ -554,7 +567,12 @@ export const attachMarkdownInteractions = (
if (action === 'copy-code') { if (action === 'copy-code') {
const code = actionEl.closest('[data-component="markdown-code"]')?.querySelector('code'); const code = actionEl.closest('[data-component="markdown-code"]')?.querySelector('code');
const text = code ? getMarkdownCodeText(code) : ''; const text = code ? getMarkdownCodeText(code) : '';
if (text) void copyTextToClipboard(text).then(() => flashCopied(actionEl as HTMLButtonElement, ctx.labels.copied, 'copy', ctx.labels.copy)); if (text) {
actionEl.setAttribute('data-md-copy-pending', '');
void copyTextToClipboard(text)
.then(() => flashCopied(actionEl as HTMLButtonElement, ctx.labels.copied, 'copy', ctx.labels.copy))
.finally(() => actionEl.removeAttribute('data-md-copy-pending'));
}
return; return;
} }
@@ -0,0 +1,104 @@
import { describe, expect, test } from 'bun:test';
import { Window } from 'happy-dom';
import { DetachedMarkdownDomCache, type DetachedMarkdownDom } from './detachedMarkdownDomCache';
Object.assign(globalThis, { document: new Window().document });
const keyFor = ({ scope, id, locale, directory }: DetachedMarkdownDom) => ({ scope, id, locale, directory });
const createEntry = (
document: Document,
sessionId: string,
messageId: string,
partId: string,
): DetachedMarkdownDom => {
const fragment = document.createDocumentFragment();
const node = document.createElement('p');
node.textContent = `${messageId}:${partId}`;
fragment.appendChild(node);
return {
scope: `runtime:${sessionId}`,
id: `${messageId}:${partId}`,
locale: 'en',
directory: '/repo-a',
fragment,
};
};
describe('DetachedMarkdownDomCache', () => {
test('consumes the original DOM fragment once and rejects another locale', () => {
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
const entry = createEntry(document, 'session-a', 'message-a', 'part-a');
const originalNode = entry.fragment.firstChild;
cache.store(entry);
expect(cache.take({ ...keyFor(entry), locale: 'zh' })).toBeNull();
cache.store(entry);
const restored = cache.take(keyFor(entry));
expect(restored?.firstChild).toBe(originalNode);
expect(cache.take(keyFor(entry))).toBeNull();
});
test('bounds entries per session and evicts the least recently used session', () => {
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
cache.store(createEntry(document, 'session-a', 'message-1', 'part'));
cache.store(createEntry(document, 'session-a', 'message-2', 'part'));
cache.store(createEntry(document, 'session-a', 'message-3', 'part'));
cache.store(createEntry(document, 'session-b', 'message-4', 'part'));
cache.store(createEntry(document, 'session-c', 'message-5', 'part'));
expect(cache.stats()).toEqual({ sessions: 2, entries: 2 });
expect(cache.take({
scope: 'runtime:session-a',
id: 'message-2:part',
locale: 'en',
directory: '/repo-a',
})).toBeNull();
expect(cache.take({
scope: 'runtime:session-c',
id: 'message-5:part',
locale: 'en',
directory: '/repo-a',
})).not.toBeNull();
});
test('isolates identities by runtime and replaces an identity without growing stats', () => {
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
const first = createEntry(document, 'session', 'message', 'part');
const replacement = createEntry(document, 'session', 'message', 'part');
const replacementNode = replacement.fragment.firstChild;
const otherRuntime = createEntry(document, 'other-runtime-session', 'message', 'part');
cache.store(first);
cache.store(replacement);
cache.store(otherRuntime);
expect(cache.stats()).toEqual({ sessions: 2, entries: 2 });
expect(cache.take(keyFor(otherRuntime))).not.toBeNull();
expect(cache.take(keyFor(replacement))?.firstChild).toBe(replacementNode);
});
test('does not restore file-link DOM under another directory', () => {
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
const entry = createEntry(document, 'session', 'message', 'part');
cache.store(entry);
expect(cache.take({ ...keyFor(entry), directory: '/repo-b' })).toBeNull();
});
test('refreshes session LRU and clears all entries', () => {
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
const sessionA = createEntry(document, 'session-a', 'message-a', 'part');
const sessionB = createEntry(document, 'session-b', 'message-b', 'part');
const sessionC = createEntry(document, 'session-c', 'message-c', 'part');
cache.store(sessionA);
cache.store(sessionB);
cache.store(sessionA);
cache.store(sessionC);
expect(cache.take(keyFor(sessionB))).toBeNull();
cache.clear();
expect(cache.stats()).toEqual({ sessions: 0, entries: 0 });
});
});
@@ -0,0 +1,124 @@
export type DetachedMarkdownDomKey = {
scope: string;
id: string;
locale: string;
directory: string;
};
export type DetachedMarkdownDom = DetachedMarkdownDomKey & {
// The fragment owns the original nodes. take() consumes it once by moving
// those nodes back into a renderer; nothing is cloned or serialized.
fragment: DocumentFragment;
};
export type DetachedMarkdownDomCacheStats = {
sessions: number;
entries: number;
};
// Holds detached, fully decorated Markdown DOM. The cache is intentionally
// small: it accelerates recent-session and reverse-scroll remounts without
// retaining whole session trees or depending on browser-specific byte guesses.
type DetachedMarkdownDomCacheLimits = {
maxSessions: number;
maxEntriesPerSession: number;
};
type SessionCache = Map<string, DetachedMarkdownDom>;
const DEFAULT_LIMITS: DetachedMarkdownDomCacheLimits = {
// Eight buckets cover a broader recent-session working set without
// coupling eviction to React commit or microtask timing.
maxSessions: 8,
maxEntriesPerSession: 4,
};
export class DetachedMarkdownDomCache {
private readonly maxSessions: number;
private readonly maxEntriesPerSession: number;
private readonly sessions = new Map<string, SessionCache>();
constructor(limits: DetachedMarkdownDomCacheLimits = DEFAULT_LIMITS) {
this.maxSessions = Math.max(1, limits.maxSessions);
this.maxEntriesPerSession = Math.max(1, limits.maxEntriesPerSession);
}
store(entry: DetachedMarkdownDom): void {
const sessionKey = entry.scope;
const entryKey = entry.id;
let session = this.sessions.get(sessionKey);
if (session === undefined) {
session = new Map();
this.sessions.set(sessionKey, session);
} else {
this.refreshSession(sessionKey, session);
}
// A part has one DOM version inside its authoritative runtime/session.
session.delete(entryKey);
session.set(entryKey, entry);
while (session.size > this.maxEntriesPerSession) {
this.removeOldestEntry(session);
}
while (this.sessions.size > this.maxSessions) {
this.removeOldestSession();
}
}
take(key: DetachedMarkdownDomKey): DocumentFragment | null {
const sessionKey = key.scope;
const session = this.sessions.get(sessionKey);
if (!session) return null;
const entryKey = key.id;
this.refreshSession(sessionKey, session);
const entry = session.get(entryKey);
if (entry === undefined) return null;
// A mismatched probe (different locale or directory for the same part)
// must not destroy the entry — the matching renderer may still come for
// it. Only a real hit transfers ownership out of the cache.
if (entry.locale !== key.locale || entry.directory !== key.directory) return null;
// A fragment is a move-only resource; taking it removes cache ownership.
session.delete(entryKey);
if (session.size === 0) this.sessions.delete(sessionKey);
return entry.fragment;
}
clear(): void {
this.sessions.clear();
}
stats(): DetachedMarkdownDomCacheStats {
let entries = 0;
for (const session of this.sessions.values()) {
entries += session.size;
}
return {
sessions: this.sessions.size,
entries,
};
}
private refreshSession(sessionKey: string, session: SessionCache): void {
this.sessions.delete(sessionKey);
this.sessions.set(sessionKey, session);
}
private removeOldestEntry(session: SessionCache): void {
const oldestKey = session.keys().next().value;
if (oldestKey === undefined) return;
session.delete(oldestKey);
}
private removeOldestSession(): void {
const oldestKey = this.sessions.keys().next().value;
if (oldestKey === undefined) return;
this.sessions.delete(oldestKey);
}
}
export const detachedMarkdownDomCache = new DetachedMarkdownDomCache();
@@ -1,4 +1,5 @@
import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url'; import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url';
import { isVSCodeRuntime } from '@/stores/utils/vscodeRuntime';
import { import {
contentFingerprint, contentFingerprint,
estimateTokenRunsBytes, estimateTokenRunsBytes,
@@ -46,6 +47,8 @@ const resultCache = new HighlightResultCache<CachedHighlight>({
const inflight = new Map<string, Promise<CachedHighlight | null>>(); const inflight = new Map<string, Promise<CachedHighlight | null>>();
let worker: Worker | undefined; let worker: Worker | undefined;
let workerCreation: Promise<Worker | undefined> | undefined;
let workerObjectUrl: string | undefined;
let nextId = 0; let nextId = 0;
const pending = new Map<number, PendingResolver>(); const pending = new Map<number, PendingResolver>();
// Theme names whose full definition we've already shipped to the live worker, so // Theme names whose full definition we've already shipped to the live worker, so
@@ -71,31 +74,56 @@ const failAll = (): void => {
inflight.clear(); inflight.clear();
worker?.terminate(); worker?.terminate();
worker = undefined; worker = undefined;
workerCreation = undefined;
if (workerObjectUrl) {
URL.revokeObjectURL(workerObjectUrl);
workerObjectUrl = undefined;
}
}; };
const getWorker = (): Worker | undefined => { const createWorker = async (): Promise<Worker | undefined> => {
if (worker) return worker;
if (typeof window === 'undefined' || typeof Worker === 'undefined') return undefined; if (typeof window === 'undefined' || typeof Worker === 'undefined') return undefined;
try { try {
worker = new Worker(MarkdownShikiWorkerUrl, { type: 'module' }); let workerUrl = MarkdownShikiWorkerUrl;
if (isVSCodeRuntime(null)) {
const response = await fetch(workerUrl);
if (!response.ok) throw new Error(`Shiki worker request failed with ${response.status}`);
workerObjectUrl = URL.createObjectURL(await response.blob());
workerUrl = workerObjectUrl;
}
const instance = new Worker(workerUrl, { type: 'module' });
worker = instance;
instance.onmessage = (event: MessageEvent<MarkdownWorkerResponse>) => {
const resolve = pending.get(event.data.id);
if (!resolve) return;
pending.delete(event.data.id);
resolve(event.data);
};
instance.onerror = failAll;
instance.onmessageerror = failAll;
instance.postMessage({ type: 'init' } satisfies MarkdownWorkerRequest);
return instance;
} catch (err) { } catch (err) {
if (workerObjectUrl) {
URL.revokeObjectURL(workerObjectUrl);
workerObjectUrl = undefined;
}
console.error('Failed to create Shiki worker:', err); console.error('Failed to create Shiki worker:', err);
return undefined; return undefined;
} }
worker.onmessage = (event: MessageEvent<MarkdownWorkerResponse>) => {
const resolve = pending.get(event.data.id);
if (!resolve) return;
pending.delete(event.data.id);
resolve(event.data);
};
worker.onerror = failAll;
worker.onmessageerror = failAll;
worker.postMessage({ type: 'init' } satisfies MarkdownWorkerRequest);
return worker;
}; };
const request = (payload: (id: number) => MarkdownWorkerRequest): Promise<MarkdownWorkerResponse | null> => { const getWorker = async (): Promise<Worker | undefined> => {
const instance = getWorker(); if (worker) return worker;
workerCreation ??= createWorker().finally(() => {
workerCreation = undefined;
});
return workerCreation;
};
const request = async (payload: (id: number) => MarkdownWorkerRequest): Promise<MarkdownWorkerResponse | null> => {
const instance = await getWorker();
if (!instance) return Promise.resolve(null); if (!instance) return Promise.resolve(null);
const id = ++nextId; const id = ++nextId;
return new Promise<MarkdownWorkerResponse | null>((resolve) => { return new Promise<MarkdownWorkerResponse | null>((resolve) => {
@@ -167,6 +195,12 @@ export const highlightLinesInWorker = async (code: string, lang: string): Promis
return result?.type === 'highlightLines' ? result.lines : null; return result?.type === 'highlightLines' ? result.lines : null;
}; };
/** Return an already-tokenized line result without scheduling a worker request. */
export const getCachedHighlightedLines = (code: string, lang: string): string[] | null => {
const cached = resultCache.get(cacheKeyFor('highlightLines', lang, code));
return cached?.type === 'highlightLines' ? cached.lines : null;
};
/** /**
* Tokenize `code` with the given resolved TextMate theme and return per-line * Tokenize `code` with the given resolved TextMate theme and return per-line
* styled runs with offsets for building CodeMirror decorations that match the * styled runs with offsets for building CodeMirror decorations that match the
@@ -1,10 +1,43 @@
import { describe, expect, mock, test } from 'bun:test'; import { describe, expect, mock, test } from 'bun:test';
type SanitizeAttribute = {
attrName: string;
attrValue: string;
forceKeepAttr?: boolean;
};
class TestAnchorElement {
target = '';
setAttribute(name: string, value: string): void {
if (name === 'target') this.target = value;
}
}
const sanitizeHooks: {
uponSanitizeAttribute?: (node: unknown, data: SanitizeAttribute) => void;
afterSanitizeAttributes?: (node: unknown) => void;
} = {};
Object.assign(globalThis, {
window: {},
HTMLAnchorElement: TestAnchorElement,
});
mock.module('dompurify', () => ({ mock.module('dompurify', () => ({
default: { default: {
isSupported: true, isSupported: true,
addHook: () => undefined, addHook: (name: keyof typeof sanitizeHooks, hook: never) => {
sanitize: (html: string) => html, sanitizeHooks[name] = hook;
},
sanitize: (html: string) => html.replace(/ href="([^"]*)"/g, (attribute, href: string) => {
const anchor = new TestAnchorElement();
const data: SanitizeAttribute = { attrName: 'href', attrValue: href };
sanitizeHooks.uponSanitizeAttribute?.(anchor, data);
sanitizeHooks.afterSanitizeAttributes?.(anchor);
return data.forceKeepAttr || /^(?:https?|mailto|tel):/i.test(href) ? attribute : '';
}),
}, },
})); }));
mock.module('./markdown-worker', () => ({ mock.module('./markdown-worker', () => ({
@@ -16,7 +49,10 @@ import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from '
const { const {
__markdownImageCandidateCacheForTests, __markdownImageCandidateCacheForTests,
extractMarkdownImageCandidates, extractMarkdownImageCandidates,
getCachedMarkdownBlocks,
renderMarkdownBlocks,
renderMarkdownSync, renderMarkdownSync,
resetMarkdownHtmlCacheForTests,
} = await import('./markdownCore'); } = await import('./markdownCore');
const { resolveMarkdownImageSource } = await import('./markdownImageAssets'); const { resolveMarkdownImageSource } = await import('./markdownImageAssets');
@@ -40,6 +76,62 @@ describe('markdown sanitization', () => {
expect(isLocalFileUrl('file://remote-host/share/report.html')).toBe(false); expect(isLocalFileUrl('file://remote-host/share/report.html')).toBe(false);
expect(isLocalFileUrl('javascript:alert(1)')).toBe(false); expect(isLocalFileUrl('javascript:alert(1)')).toBe(false);
}); });
test('keeps app and local file links while stripping blocked schemes', () => {
const html = renderMarkdownSync([
'[app](obsidian://open?vault=Notebook)',
'[file](file:///workspace/notes.md)',
'[script](javascript:alert(1))',
'[diagnostic](ms-msdt:/id%20PCWDiagnostic)',
].join('\n\n'), 'inline');
expect(html).toContain('href="obsidian://open?vault=Notebook"');
expect(html).toContain('href="file:///workspace/notes.md"');
expect(html).not.toContain('href="javascript:alert(1)"');
expect(html).not.toContain('href="ms-msdt:/id%20PCWDiagnostic"');
});
});
describe('Markdown block cache reads', () => {
test('returns all settled blocks synchronously after a full cache hit', async () => {
resetMarkdownHtmlCacheForTests();
const text = '**cached** settled markdown';
expect(getCachedMarkdownBlocks(text)).toBeNull();
const rendered = await renderMarkdownBlocks(text, false);
expect(getCachedMarkdownBlocks(text)).toEqual(rendered);
});
test('returns null for a cold or partial settled miss', async () => {
resetMarkdownHtmlCacheForTests();
const first = 'first settled block';
const changed = 'first settled block\n\nsecond settled block';
await renderMarkdownBlocks(first, false);
expect(getCachedMarkdownBlocks(changed)).toBeNull();
});
test('keeps image mode identity out of the settled full hit', async () => {
resetMarkdownHtmlCacheForTests();
const text = '![image](https://example.test/image.png)';
await renderMarkdownBlocks(text, false, 'inline');
expect(getCachedMarkdownBlocks(text, 'label')).toBeNull();
expect(getCachedMarkdownBlocks(text, 'inline')).not.toBeNull();
});
test('does not treat streaming live-cache entries as settled full hits', async () => {
resetMarkdownHtmlCacheForTests();
const text = 'streaming markdown';
await renderMarkdownBlocks(text, true);
expect(getCachedMarkdownBlocks(text)).toBeNull();
});
}); });
describe('Markdown images', () => { describe('Markdown images', () => {
@@ -3,6 +3,7 @@ import remend from 'remend';
import katex from 'katex'; import katex from 'katex';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks'; import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks';
import { isAppLinkUrl } from '@/lib/url';
import { isVSCodeRuntime } from '@/lib/desktop'; import { isVSCodeRuntime } from '@/lib/desktop';
import { contentFingerprint, HighlightResultCache, utf16Bytes } from './highlightResultCache'; import { contentFingerprint, HighlightResultCache, utf16Bytes } from './highlightResultCache';
import { highlightCodeInWorker } from './markdown-worker'; import { highlightCodeInWorker } from './markdown-worker';
@@ -177,9 +178,11 @@ type MarkdownBlock = {
raw: string; raw: string;
src: string; src: string;
mode: 'full' | 'live'; mode: 'full' | 'live';
// When false, skip syntax highlighting for this block. Set for the actively // When false, skip syntax highlighting for this block. Block-level commit
// streaming open code fence so we don't re-tokenize a growing block ~40x/sec // feeds the open fence whole lines at the throttle cadence (<=10/sec), so a
// (O(n^2)); it highlights once the fence closes and becomes a stable block. // partial fence highlights too and streamed code arrives colored; only a
// very large open fence falls back to plain text until it closes, keeping
// the repeated worker re-tokenization bounded.
highlight: boolean; highlight: boolean;
}; };
@@ -200,6 +203,11 @@ const hasOpenFence = (raw: string): boolean => {
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last); return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last);
}; };
// Above this, re-highlighting the still-open fence on every committed line
// costs more than the colored preview is worth; the block highlights in one
// pass when the fence closes.
const OPEN_FENCE_HIGHLIGHT_LINE_LIMIT = 300;
const heal = (text: string): string => { const heal = (text: string): string => {
try { try {
return remend(text, { linkMode: 'text-only' }); return remend(text, { linkMode: 'text-only' });
@@ -249,11 +257,13 @@ const streamBlocks = (text: string, live: boolean): MarkdownBlock[] => {
const raw = token.raw ?? ''; const raw = token.raw ?? '';
const isLast = i === tail; const isLast = i === tail;
const openFence = token.type === 'code' && hasOpenFence(raw); const openFence = token.type === 'code' && hasOpenFence(raw);
const openFenceHighlight = openFence
&& raw.split('\n').length <= OPEN_FENCE_HIGHLIGHT_LINE_LIMIT;
blocks.push({ blocks.push({
raw, raw,
src: openFence ? raw : heal(raw), src: openFence ? raw : heal(raw),
mode: isLast ? 'live' : 'full', mode: isLast ? 'live' : 'full',
highlight: !openFence, highlight: !openFence || openFenceHighlight,
}); });
} }
@@ -472,7 +482,10 @@ const ensureSanitizeHook = (): void => {
sanitizeHookInstalled = true; sanitizeHookInstalled = true;
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => { DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
if (!(node instanceof HTMLAnchorElement) || data.attrName !== 'href') return; if (!(node instanceof HTMLAnchorElement) || data.attrName !== 'href') return;
if (isLocalFileUrl(data.attrValue)) data.forceKeepAttr = true; // DOMPurify's default URI policy strips custom application schemes
// (obsidian://, vscode://, ...). Keep them for anchors; dangerous schemes
// stay excluded via isAppLinkUrl and clicks go through confirmation.
if (isLocalFileUrl(data.attrValue) || isAppLinkUrl(data.attrValue)) data.forceKeepAttr = true;
}); });
DOMPurify.addHook('afterSanitizeAttributes', (node) => { DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (!(node instanceof HTMLAnchorElement)) return; if (!(node instanceof HTMLAnchorElement)) return;
@@ -544,6 +557,29 @@ export const __markdownBlockCacheSizesForTests = (): { full: number; live: numbe
live: liveBlockCache.size, live: liveBlockCache.size,
}); });
/**
* Read a settled render synchronously when every block is already in the full
* cache. Cache reads retain the existing LRU `get` semantics and do not insert
* or expand either cache.
*/
export const getCachedMarkdownBlocks = (
text: string,
imageMode: MarkdownImageMode = 'inline',
): RenderedBlock[] | null => {
if (!text) return [];
const blocks = streamBlocks(text, false);
const rendered: RenderedBlock[] = [];
for (const block of blocks) {
const contentHash = contentFingerprint(block.raw);
const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight, imageMode);
const html = fullBlockCache.get(id);
if (html === undefined) return null;
rendered.push({ id, html });
}
return rendered;
};
const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise<string> => { const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise<string> => {
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser; const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
const parsed = await Promise.resolve(parser.parse(block.src)); const parsed = await Promise.resolve(parser.parse(block.src));
@@ -561,7 +597,10 @@ const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): P
* is synchronous (marked is not configured `async`), so this never blocks on a * is synchronous (marked is not configured `async`), so this never blocks on a
* worker round-trip. * worker round-trip.
*/ */
export const renderMarkdownSync = (text: string, imageMode: MarkdownImageMode = 'inline'): string => { export const renderMarkdownSync = (
text: string,
imageMode: MarkdownImageMode = 'inline',
): string => {
if (!text) return ''; if (!text) return '';
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser; const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
const parsed = parser.parse(text) as string; const parsed = parser.parse(text) as string;
@@ -22,6 +22,18 @@ type MermaidViewerController = {
cleanup: () => void; cleanup: () => void;
}; };
type InternalMermaidViewerController = MermaidViewerController & {
viewport: HTMLElement;
fitToViewport: (viewport: MermaidViewport) => void;
};
type MermaidViewerRegistryState = {
container: HTMLElement;
controllers: Map<HTMLElement, InternalMermaidViewerController>;
signatures: Map<HTMLElement, string>;
disposed: boolean;
};
type MermaidSvgBoundsSource = { type MermaidSvgBoundsSource = {
viewBox?: string | null; viewBox?: string | null;
width?: string | number | null; width?: string | number | null;
@@ -36,13 +48,8 @@ type MermaidViewerSignatureSource = MermaidSvgBoundsSource & {
const isPositiveFinite = (value: number): boolean => Number.isFinite(value) && value > 0; const isPositiveFinite = (value: number): boolean => Number.isFinite(value) && value > 0;
const parseSvgNumber = (value: string | number | null | undefined): number | null => { const parseSvgNumber = (value: string | number | null | undefined): number | null => {
if (typeof value === 'number') { if (value === null || value === undefined) return null;
return isPositiveFinite(value) ? value : null; const match = String(value).trim().match(/^([+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:[eE][+-]?\d+)?)(?:px)?$/);
}
if (typeof value !== 'string') {
return null;
}
const match = value.trim().match(/^([+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:[eE][+-]?\d+)?)(?:px)?$/);
if (!match) { if (!match) {
return null; return null;
} }
@@ -233,10 +240,14 @@ export const zoomMermaidViewBoxAtPoint = ({
}; };
const controllerByBlock = new WeakMap<HTMLElement, MermaidViewerController>(); const controllerByBlock = new WeakMap<HTMLElement, MermaidViewerController>();
const controllerByViewport = new WeakMap<HTMLElement, InternalMermaidViewerController>();
export const getMermaidViewerController = (block: Element | null): MermaidViewerController | null => ( const activeControllers = new Set<InternalMermaidViewerController>();
block instanceof HTMLElement ? controllerByBlock.get(block) ?? null : null const pendingRegistries = new Set<MermaidViewerRegistryState>();
); // Controllers are non-essential for the static SVG. Initialize all renderers
// from one post-presentation batch so geometry reads precede every SVG write.
let sharedResizeObserver: ResizeObserver | null = null;
let pendingRegistryFlushFrame: number | null = null;
let pendingResizeFrame: number | null = null;
const getSvgViewport = (block: HTMLElement): HTMLElement | null => ( const getSvgViewport = (block: HTMLElement): HTMLElement | null => (
block.querySelector<HTMLElement>('[data-markdown="mermaid-viewport"]') block.querySelector<HTMLElement>('[data-markdown="mermaid-viewport"]')
@@ -272,7 +283,62 @@ const isPanExcludedTarget = (target: EventTarget | null): boolean => (
target instanceof Element && Boolean(target.closest('button, a, [role="button"]')) target instanceof Element && Boolean(target.closest('button, a, [role="button"]'))
); );
const createMermaidViewerController = (block: HTMLElement): MermaidViewerController | null => { const fitControllers = (controllers: readonly InternalMermaidViewerController[]): void => {
const viewportSizes = controllers.map((controller) => getViewportSize(controller.viewport));
controllers.forEach((controller, index) => {
const viewport = viewportSizes[index];
if (viewport) controller.fitToViewport(viewport);
});
};
const scheduleActiveControllerFit = (): void => {
if (pendingResizeFrame !== null || activeControllers.size === 0) return;
pendingResizeFrame = window.requestAnimationFrame(() => {
pendingResizeFrame = null;
fitControllers(Array.from(activeControllers));
});
};
const ensureSharedResizeObserver = (): ResizeObserver | null => {
if (sharedResizeObserver) return sharedResizeObserver;
const ResizeObserverConstructor = globalThis.ResizeObserver;
if (!ResizeObserverConstructor) return null;
sharedResizeObserver = new ResizeObserverConstructor((entries) => {
for (const entry of entries) {
if (!(entry.target instanceof HTMLElement)) continue;
controllerByViewport.get(entry.target)?.fitToViewport({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
}
});
return sharedResizeObserver;
};
const registerController = (controller: InternalMermaidViewerController): void => {
if (activeControllers.has(controller)) return;
const wasEmpty = activeControllers.size === 0;
activeControllers.add(controller);
controllerByViewport.set(controller.viewport, controller);
ensureSharedResizeObserver()?.observe(controller.viewport);
if (wasEmpty) window.addEventListener('resize', scheduleActiveControllerFit);
};
const unregisterController = (controller: InternalMermaidViewerController): void => {
if (!activeControllers.delete(controller)) return;
sharedResizeObserver?.unobserve(controller.viewport);
controllerByViewport.delete(controller.viewport);
if (activeControllers.size > 0) return;
sharedResizeObserver?.disconnect();
sharedResizeObserver = null;
window.removeEventListener('resize', scheduleActiveControllerFit);
if (pendingResizeFrame !== null) {
window.cancelAnimationFrame(pendingResizeFrame);
pendingResizeFrame = null;
}
};
const createMermaidViewerController = (block: HTMLElement): InternalMermaidViewerController | null => {
const viewport = getSvgViewport(block); const viewport = getSvgViewport(block);
const svg = block.querySelector<SVGSVGElement>('[data-markdown="mermaid"] svg'); const svg = block.querySelector<SVGSVGElement>('[data-markdown="mermaid"] svg');
if (!viewport || !svg) { if (!viewport || !svg) {
@@ -301,8 +367,12 @@ const createMermaidViewerController = (block: HTMLElement): MermaidViewerControl
svg.removeAttribute('height'); svg.removeAttribute('height');
}; };
const fitToViewport = (size: MermaidViewport): void => {
applyViewBox(fitMermaidViewBox(contentBox, size));
};
const fit = (): void => { const fit = (): void => {
applyViewBox(fitMermaidViewBox(contentBox, getViewportSize(viewport))); fitToViewport(getViewportSize(viewport));
}; };
const zoomAt = (pointer: MermaidPoint, zoomFactor: number): void => { const zoomAt = (pointer: MermaidPoint, zoomFactor: number): void => {
@@ -390,32 +460,24 @@ const createMermaidViewerController = (block: HTMLElement): MermaidViewerControl
} }
}; };
const onResize = (): void => {
fit();
};
viewport.addEventListener('wheel', onWheel, { passive: false }); viewport.addEventListener('wheel', onWheel, { passive: false });
viewport.addEventListener('pointerdown', onPointerDown); viewport.addEventListener('pointerdown', onPointerDown);
viewport.addEventListener('pointermove', onPointerMove); viewport.addEventListener('pointermove', onPointerMove);
viewport.addEventListener('pointerup', stopPan); viewport.addEventListener('pointerup', stopPan);
viewport.addEventListener('pointercancel', stopPan); viewport.addEventListener('pointercancel', stopPan);
window.addEventListener('resize', onResize); const controller: InternalMermaidViewerController = {
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(onResize); viewport,
observer?.observe(viewport);
fit();
return {
zoomIn, zoomIn,
zoomOut, zoomOut,
fit, fit,
fitToViewport,
cleanup: () => { cleanup: () => {
unregisterController(controller);
viewport.removeEventListener('wheel', onWheel); viewport.removeEventListener('wheel', onWheel);
viewport.removeEventListener('pointerdown', onPointerDown); viewport.removeEventListener('pointerdown', onPointerDown);
viewport.removeEventListener('pointermove', onPointerMove); viewport.removeEventListener('pointermove', onPointerMove);
viewport.removeEventListener('pointerup', stopPan); viewport.removeEventListener('pointerup', stopPan);
viewport.removeEventListener('pointercancel', stopPan); viewport.removeEventListener('pointercancel', stopPan);
window.removeEventListener('resize', onResize);
observer?.disconnect();
if (clearClickSuppressionTimer !== null) { if (clearClickSuppressionTimer !== null) {
window.clearTimeout(clearClickSuppressionTimer); window.clearTimeout(clearClickSuppressionTimer);
} }
@@ -424,42 +486,97 @@ const createMermaidViewerController = (block: HTMLElement): MermaidViewerControl
controllerByBlock.delete(block); controllerByBlock.delete(block);
}, },
}; };
return controller;
}; };
export const createMermaidViewerRegistry = (container: HTMLElement): { refresh: () => void; cleanup: () => void } => { const removeStaleControllers = (state: MermaidViewerRegistryState): void => {
const controllers = new Map<HTMLElement, MermaidViewerController>(); for (const [block, controller] of state.controllers) {
const signatures = new Map<HTMLElement, string>(); const signature = getBlockViewerSignature(block);
if (!state.container.contains(block) || signature !== state.signatures.get(block)) {
const refresh = (): void => { controller.cleanup();
for (const [block, controller] of Array.from(controllers.entries())) { state.controllers.delete(block);
const signature = getBlockViewerSignature(block); state.signatures.delete(block);
if (!container.contains(block) || signature !== signatures.get(block)) {
controller.cleanup();
controllers.delete(block);
signatures.delete(block);
}
} }
}
};
for (const block of Array.from(container.querySelectorAll<HTMLElement>(MERMAID_BLOCK_SELECTOR))) { const collectNewControllers = (state: MermaidViewerRegistryState): InternalMermaidViewerController[] => {
if (controllers.has(block) || block.querySelector('[data-markdown="mermaid"] svg') === null) { if (state.disposed) return [];
continue; const newControllers: InternalMermaidViewerController[] = [];
} for (const block of Array.from(state.container.querySelectorAll<HTMLElement>(MERMAID_BLOCK_SELECTOR))) {
const controller = createMermaidViewerController(block); if (state.controllers.has(block) || block.querySelector('[data-markdown="mermaid"] svg') === null) continue;
if (!controller) { const controller = createMermaidViewerController(block);
continue; if (!controller) continue;
} state.controllers.set(block, controller);
controllers.set(block, controller); state.signatures.set(block, getBlockViewerSignature(block));
signatures.set(block, getBlockViewerSignature(block)); controllerByBlock.set(block, controller);
controllerByBlock.set(block, controller); newControllers.push(controller);
} }
return newControllers;
};
const flushPendingRegistries = (): void => {
const registries = Array.from(pendingRegistries);
pendingRegistries.clear();
const newControllers: InternalMermaidViewerController[] = [];
for (const state of registries) {
if (state.disposed) continue;
removeStaleControllers(state);
newControllers.push(...collectNewControllers(state));
}
fitControllers(newControllers);
for (const controller of newControllers) registerController(controller);
};
const schedulePendingRegistryFlush = (): void => {
if (pendingRegistryFlushFrame !== null) return;
pendingRegistryFlushFrame = window.requestAnimationFrame(() => {
pendingRegistryFlushFrame = null;
pendingRegistryFlushFrame = window.requestAnimationFrame(() => {
pendingRegistryFlushFrame = null;
flushPendingRegistries();
});
});
};
const scheduleRegistryRefresh = (state: MermaidViewerRegistryState): void => {
if (state.disposed) return;
removeStaleControllers(state);
pendingRegistries.add(state);
schedulePendingRegistryFlush();
};
export const getMermaidViewerController = (block: Element | null): MermaidViewerController | null => {
if (!(block instanceof HTMLElement)) return null;
const existing = controllerByBlock.get(block);
if (existing) return existing;
for (const state of pendingRegistries) {
if (!state.container.contains(block)) continue;
flushPendingRegistries();
return controllerByBlock.get(block) ?? null;
}
return null;
};
export const createMermaidViewerRegistry = (container: HTMLElement) => {
const state: MermaidViewerRegistryState = {
container,
controllers: new Map(),
signatures: new Map(),
disposed: false,
}; };
const refresh = (): void => scheduleRegistryRefresh(state);
const cleanup = (): void => { const cleanup = (): void => {
for (const controller of controllers.values()) { state.disposed = true;
pendingRegistries.delete(state);
for (const controller of state.controllers.values()) {
controller.cleanup(); controller.cleanup();
} }
controllers.clear(); state.controllers.clear();
signatures.clear(); state.signatures.clear();
}; };
refresh(); refresh();

Some files were not shown because too many files have changed in this diff Show More