Merge branch 'main' into pr2997-integration

This commit is contained in:
Bohdan Triapitsyn
2026-08-26 02:40:48 +03:00
311 changed files with 18149 additions and 9328 deletions
+35
View File
@@ -4,6 +4,41 @@ 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.
- 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).
- 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 ## [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). - **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).
+19 -5
View File
@@ -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,6 +167,7 @@
"@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.21", "@opencode-ai/sdk": "1.18.21",
"@pierre/diffs": "1.3.0-beta.6", "@pierre/diffs": "1.3.0-beta.6",
@@ -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,7 +240,7 @@
}, },
"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.21", "@opencode-ai/sdk": "1.18.21",
@@ -261,7 +263,7 @@
}, },
"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",
}, },
@@ -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=="],
@@ -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=="],
+9 -1
View File
@@ -1240,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
@@ -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,
+2
View File
@@ -43,6 +43,7 @@
"@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.21", "@opencode-ai/sdk": "1.18.21",
"@pierre/diffs": "1.3.0-beta.6", "@pierre/diffs": "1.3.0-beta.6",
@@ -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",
-2
View File
@@ -626,7 +626,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);
}; };
@@ -675,7 +674,6 @@ function App({ apis }: AppProps) {
? detail.projectId.trim() ? detail.projectId.trim()
: null; : null;
const hasProjectTarget = Boolean(directory || projectId); const hasProjectTarget = Boolean(directory || projectId);
useUIStore.getState().setActiveMainTab('chat');
useUIStore.getState().setSessionSwitcherOpen(false); useUIStore.getState().setSessionSwitcherOpen(false);
useSessionUIStore.getState().openNewSessionDraft({ useSessionUIStore.getState().openNewSessionDraft({
target: hasProjectTarget ? 'project' : 'chat', target: hasProjectTarget ? 'project' : 'chat',
@@ -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) => {
@@ -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';
@@ -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);
} }
@@ -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());
}; };
+248 -117
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';
@@ -151,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;
@@ -167,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;
@@ -190,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,
@@ -315,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}
@@ -411,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
@@ -478,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>
); );
@@ -797,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);
@@ -889,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,
}); });
@@ -920,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,
@@ -956,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
@@ -1004,8 +1091,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
return; return;
} }
const { activeMainTab } = useUIStore.getState(); if (hasBlockingChatOverlay()) {
if (activeMainTab !== 'chat' || hasBlockingChatOverlay()) {
return; return;
} }
@@ -1070,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);
@@ -1083,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;
} }
@@ -1095,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;
@@ -1190,7 +1285,11 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
return <DraftWelcome exiting={draftPresentationExiting} />; return <DraftWelcome exiting={draftPresentationExiting} />;
} }
if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) { const showHydrationSkeleton = isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking;
if (showHydrationSkeleton) {
hydrationRevealKeyRef.current = currentSessionKey ?? currentSessionId ?? null;
}
if (showHydrationSkeleton) {
if (sessionMessageLoadState.status === 'error') { if (sessionMessageLoadState.status === 'error') {
return ( return (
<div className="flex min-h-0 flex-1 items-center justify-center px-6"> <div className="flex min-h-0 flex-1 items-center justify-center px-6">
@@ -1210,6 +1309,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
return ( return (
<div <div
data-chat-hydration-skeleton=""
className={cn( className={cn(
'relative min-h-0', 'relative min-h-0',
isDesktopExpandedInput ? 'pointer-events-none absolute inset-0 opacity-0' : 'flex-1', isDesktopExpandedInput ? 'pointer-events-none absolute inset-0 opacity-0' : 'flex-1',
@@ -1264,21 +1364,24 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
currentSessionKey={currentSessionKey ?? 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}
@@ -1313,10 +1416,37 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
)} )}
> >
{!draftLayoutVisible && !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 ? ( {promptReadOnly ? (
<ReadOnlyPromptBanner /> <ReadOnlyPromptBanner />
@@ -1324,6 +1454,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
<ChatInput <ChatInput
active={active} active={active}
scrollToBottom={scrollToBottomOnSend} scrollToBottom={scrollToBottomOnSend}
scrollToLatest={resumeToLatestInstant}
draftPresentationExiting={draftPresentationExiting} draftPresentationExiting={draftPresentationExiting}
/> />
)} )}
+31 -82
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';
@@ -49,7 +48,7 @@ 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';
@@ -221,11 +220,15 @@ 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; draftPresentationExiting?: boolean;
} }
@@ -244,6 +247,7 @@ const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity |
const ChatInputComponent: React.FC<ChatInputProps> = ({ const ChatInputComponent: React.FC<ChatInputProps> = ({
onOpenSettings, onOpenSettings,
scrollToBottom, scrollToBottom,
scrollToLatest,
active = true, active = true,
draftPresentationExiting = false, draftPresentationExiting = false,
}) => { }) => {
@@ -753,55 +757,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
[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.
@@ -930,12 +887,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
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, {
@@ -949,6 +903,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
} : 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.
@@ -961,7 +921,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
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);
@@ -1140,9 +1100,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
} }
// 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)
: []; : [];
@@ -1157,9 +1119,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
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) => {
@@ -1172,8 +1136,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
}, },
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,
}); });
@@ -1338,6 +1300,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
...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,
@@ -1356,14 +1323,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
} }
}; };
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
@@ -2663,16 +2622,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
<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}
@@ -2706,9 +2656,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
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
+2 -151
View File
@@ -14,7 +14,6 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useChatSurfaceMode } from './useChatSurfaceMode'; 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';
@@ -132,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;
@@ -148,8 +145,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
message, message,
previousMessage, previousMessage,
nextMessage, nextMessage,
onContentChange,
animationHandlers,
turnGroupingContext, turnGroupingContext,
assistantHeaderMessageId, assistantHeaderMessageId,
isInActiveTurn = false, isInActiveTurn = false,
@@ -705,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) {
@@ -855,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) => {
@@ -906,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;
@@ -1075,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}
@@ -1089,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}
/> />
@@ -1112,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}
@@ -1126,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}
/> />
@@ -1159,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}
@@ -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>
);
};
@@ -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,8 @@ 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'; import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
type FileInfo = ProjectFileSearchHit; type FileInfo = ProjectFileSearchHit;
@@ -94,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) => {
@@ -124,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) => {
@@ -152,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;
} }
@@ -167,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',
@@ -178,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) {
@@ -210,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;
} }
@@ -225,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(() => {
@@ -261,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;
@@ -332,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];
@@ -376,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;
} }
@@ -400,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();
@@ -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);
});
});
});
@@ -20,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 {
@@ -45,6 +50,8 @@ import {
} from './fileReferenceParser'; } from './fileReferenceParser';
import { fileReferenceExists } from './fileReferenceStat'; 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();
@@ -345,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
@@ -398,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);
} }
@@ -526,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, {
@@ -659,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);
@@ -743,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;
@@ -750,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) {
@@ -771,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
@@ -780,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();
@@ -810,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, {
@@ -838,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) {
@@ -856,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;
@@ -943,6 +1114,33 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
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}`}`;
@@ -950,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 = (
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';
@@ -528,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(() => {
@@ -580,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)
@@ -2342,9 +2309,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</TooltipTrigger> </TooltipTrigger>
<DropdownMenuContent <DropdownMenuContent
side="top" side="top"
className="w-[min(380px,calc(100vw-2rem))] p-0 flex flex-col" 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">
@@ -2401,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)}
/> />
@@ -2734,7 +2704,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
</TooltipTrigger> </TooltipTrigger>
<DropdownMenuContent side="top" 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" />
@@ -2750,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 && (
<> <>
@@ -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} />;
}; };
+17 -317
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,189 +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
// This row must land exactly where the assistant turn footer (mt-2 // The row renders inside the composer-anchored overlay, which owns the
// inside the message) appears when the turn completes. Measured against // distance to the input and the horizontal column (the same ones the
// the live DOM: the gap ABOVE already matches (message pb-2 = footer // scroll-to-bottom pill uses).
// mt-2 = 8px), but the chat is bottom-anchored and the finished message
// carries ~12px more structure BELOW its footer than this row has — so
// the swap used to lift the line up. mb-6 (24px) reserves that space
// under this row instead (verified: row top 636 == footer top 636).
className={cn("mb-6", !hasLeftAccessory && "chat-column")}
style={STATUS_ROW_CONTAINER_STYLE} style={STATUS_ROW_CONTAINER_STYLE}
> >
{/* h-8 matches the turn footer's real row height: its h-8 action {/* h-8 matches the turn footer's real row height: its h-8 action
buttons define the footer line, with the meta text centered in it. */} buttons define the footer line, with the meta text centered in it. */}
<div className={cn("flex items-center justify-between gap-2 h-8", hasLeftAccessory && "px-0.5")}> {/* The glass chip lives here, not on the container: the root above is
{/* Left: Abort status | Working placeholder | leftAccessory */} an inline-size query container, whose width ignores its children
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}> a shrink-to-fit wrapper around it always collapsed to zero. */}
{showAssistantStatus && showAbortStatus ? ( <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}
@@ -340,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>
); );
@@ -26,9 +26,6 @@ import { QuestionCard } from '../QuestionCard';
const IDLE_SESSION_STATUS = { type: 'idle' as const }; const IDLE_SESSION_STATUS = { type: 'idle' as const };
/** Stable no-op so ChatMessage memoization keeps working in the read-only peek. */
const NOOP_CONTENT_CHANGE = (): void => {};
/** /**
* The `/btw` peek panel. * The `/btw` peek panel.
* *
@@ -233,7 +230,7 @@ const BtwFrame: React.FC<{
role="dialog" role="dialog"
aria-label="btw" aria-label="btw"
> >
<div className="oc-glass-popover oc-glass-floating w-full overflow-hidden rounded-xl"> <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"> <div className="flex items-center gap-2 px-3 py-1.5">
{onTitleClick ? ( {onTitleClick ? (
<button <button
@@ -446,7 +443,6 @@ const BtwMessages: React.FC<{
message={record} message={record}
previousMessage={data.messageRecords[index - 1]} previousMessage={data.messageRecords[index - 1]}
nextMessage={data.messageRecords[index + 1]} nextMessage={data.messageRecords[index + 1]}
onContentChange={NOOP_CONTENT_CHANGE}
isInActiveTurn={index === data.messageRecords.length - 1} isInActiveTurn={index === data.messageRecords.length - 1}
activeStreamingPhase={ activeStreamingPhase={
record.info.id === data.streamingMessageId ? data.activeStreamingPhase : null record.info.id === data.streamingMessageId ? data.activeStreamingPhase : null
@@ -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;
@@ -124,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
@@ -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);
@@ -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>
); );
} }
@@ -23,6 +23,7 @@ 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 type { Theme } from '@/types/theme'; import type { Theme } from '@/types/theme';
@@ -122,7 +123,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
: <ProjectLabel project={selectedProject} theme={theme} />} : <ProjectLabel project={selectedProject} theme={theme} />}
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} fitContent> <SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain fitContent>
{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} className="max-w-[24rem] truncate">
<ProjectLabel project={project} theme={theme} /> <ProjectLabel project={project} theme={theme} />
@@ -144,7 +145,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
{selectedBranchLabel ?? t('chat.chatInput.branch')} {selectedBranchLabel ?? t('chat.chatInput.branch')}
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} className="w-max min-w-48"> <SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48">
{projectRootBranchOption ? ( {projectRootBranchOption ? (
<SelectGroup> <SelectGroup>
<SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel> <SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel>
@@ -264,13 +265,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}
@@ -304,8 +299,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)
@@ -349,8 +343,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')
@@ -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,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) => {
@@ -49,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');
@@ -90,6 +93,47 @@ describe('markdown sanitization', () => {
}); });
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', () => {
test('renders assistant images as icon-ready text without loading the source', () => { test('renders assistant images as icon-ready text without loading the source', () => {
const html = renderMarkdownSync([ const html = renderMarkdownSync([
@@ -178,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;
}; };
@@ -201,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' });
@@ -250,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,
}); });
} }
@@ -548,10 +557,30 @@ export const __markdownBlockCacheSizesForTests = (): { full: number; live: numbe
live: liveBlockCache.size, live: liveBlockCache.size,
}); });
const parseBlock = async ( /**
block: MarkdownBlock, * Read a settled render synchronously when every block is already in the full
imageMode: MarkdownImageMode, * cache. Cache reads retain the existing LRU `get` semantics and do not insert
): Promise<string> => { * 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 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));
const withMath = renderMathExpressions(parsed); const withMath = renderMathExpressions(parsed);
@@ -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();
@@ -19,7 +19,6 @@ import { SaveProjectPlanDialog } from '@/components/session/SaveProjectPlanDialo
import { ForkSessionDialog, type ForkSessionExecution } from '@/components/session/ForkSessionDialog'; import { ForkSessionDialog, type ForkSessionExecution } from '@/components/session/ForkSessionDialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ArrowsMerge } from '@/components/icons/ArrowsMerge'; import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { MarkdownImageGallery, SimpleMarkdownRenderer } from '../MarkdownRenderer'; import { MarkdownImageGallery, SimpleMarkdownRenderer } from '../MarkdownRenderer';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -419,20 +418,16 @@ interface MessageBodyProps {
onShowPopup: (content: ToolPopupContent) => void; onShowPopup: (content: ToolPopupContent) => void;
streamPhase: StreamPhase; streamPhase: StreamPhase;
allowAnimation: boolean; allowAnimation: boolean;
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
shouldShowHeader?: boolean; shouldShowHeader?: boolean;
hasTextContent?: boolean; hasTextContent?: boolean;
onCopyMessage?: () => void | boolean | Promise<void | boolean>; onCopyMessage?: () => void | boolean | Promise<void | boolean>;
copiedMessage?: boolean; copiedMessage?: boolean;
onAuxiliaryContentComplete?: () => void;
showReasoningTraces?: boolean; showReasoningTraces?: boolean;
agentMention?: AgentMentionInfo; agentMention?: AgentMentionInfo;
turnGroupingContext?: TurnGroupingContext; turnGroupingContext?: TurnGroupingContext;
onRevert?: () => void; onRevert?: () => void;
onFork?: () => void; onFork?: () => void;
errorMessage?: string; errorMessage?: string;
errorVariant?: 'error' | 'info';
userActionsMode?: 'inline' | 'external-content' | 'external-actions'; userActionsMode?: 'inline' | 'external-content' | 'external-actions';
stickyUserHeaderEnabled?: boolean; stickyUserHeaderEnabled?: boolean;
reviewTransferDirection?: ReviewTransferDirection | null; reviewTransferDirection?: ReviewTransferDirection | null;
@@ -490,6 +485,20 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
const [copyHintVisible, setCopyHintVisible] = React.useState(false); const [copyHintVisible, setCopyHintVisible] = React.useState(false);
const copyHintTimeoutRef = React.useRef<number | null>(null); const copyHintTimeoutRef = React.useRef<number | null>(null);
// One expanded state for the whole message: text parts and context cards
// collapse and expand together, with a single collapse control up here
// instead of one per part.
const collapsibleUserMessages = useUIStore((state) => state.collapsibleUserMessages);
const [messageExpanded, setMessageExpanded] = React.useState(false);
const expandMessage = React.useCallback(() => setMessageExpanded(true), []);
const collapseMessage = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
setMessageExpanded(false);
}, []);
React.useEffect(() => {
if (!collapsibleUserMessages) setMessageExpanded(false);
}, [collapsibleUserMessages]);
const userContentParts = React.useMemo(() => { const userContentParts = React.useMemo(() => {
return parts.filter((part) => { return parts.filter((part) => {
if (part.type === 'text') { if (part.type === 'text') {
@@ -717,6 +726,16 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
style={CONTAIN_LAYOUT_STYLE} style={CONTAIN_LAYOUT_STYLE}
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined} onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
> >
{collapsibleUserMessages && messageExpanded && (
<button
type="button"
onClick={collapseMessage}
className="absolute top-0 right-0 z-10 flex items-center justify-center rounded-sm bg-[var(--surface-elevated)] p-0.5 text-[var(--surface-mutedForeground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
aria-label={t('chat.message.userText.collapseAria')}
>
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
</button>
)}
<div <div
className={cn( className={cn(
'leading-relaxed text-foreground/90 text-base overflow-x-hidden', 'leading-relaxed text-foreground/90 text-base overflow-x-hidden',
@@ -761,6 +780,8 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
messageId={messageId} messageId={messageId}
isMobile={isMobile} isMobile={isMobile}
agentMention={mentionForPart} agentMention={mentionForPart}
messageExpanded={messageExpanded}
onExpandMessage={expandMessage}
/> />
</React.Fragment> </React.Fragment>
); );
@@ -1087,14 +1108,11 @@ const AssistantMessageBody = React.memo(({
onShowPopup, onShowPopup,
streamPhase: _streamPhase, streamPhase: _streamPhase,
allowAnimation: _allowAnimation, allowAnimation: _allowAnimation,
onContentChange,
hasTextContent = false, hasTextContent = false,
onCopyMessage, onCopyMessage,
onAuxiliaryContentComplete,
showReasoningTraces = false, showReasoningTraces = false,
turnGroupingContext, turnGroupingContext,
errorMessage, errorMessage,
errorVariant = 'error',
reviewTransferDirection = null, reviewTransferDirection = null,
contextPinned, contextPinned,
contextPinPending, contextPinPending,
@@ -1399,50 +1417,6 @@ const AssistantMessageBody = React.memo(({
|| (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized)); || (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized));
const shouldHoldReasoning = awaitingMessageCompletion || shouldHoldForReasoning; const shouldHoldReasoning = awaitingMessageCompletion || shouldHoldForReasoning;
const hasAuxiliaryContent = hasTools || reasoningParts.length > 0;
const isTextlessAssistantMessage = assistantTextParts.length === 0;
const auxiliaryContentComplete = hasAuxiliaryContent && isTextlessAssistantMessage && !shouldHoldTools && !shouldHoldReasoning && allToolsFinalized && reasoningComplete;
const auxiliaryCompletionAnnouncedRef = React.useRef(false);
const soloReasoningScrollTriggeredRef = React.useRef(false);
React.useEffect(() => {
soloReasoningScrollTriggeredRef.current = false;
}, [messageId]);
React.useEffect(() => {
if (!auxiliaryContentComplete) {
auxiliaryCompletionAnnouncedRef.current = false;
return;
}
if (auxiliaryCompletionAnnouncedRef.current) {
return;
}
auxiliaryCompletionAnnouncedRef.current = true;
onAuxiliaryContentComplete?.();
}, [auxiliaryContentComplete, onAuxiliaryContentComplete]);
React.useEffect(() => {
if (awaitingMessageCompletion) {
soloReasoningScrollTriggeredRef.current = false;
return;
}
if (hasTools) {
soloReasoningScrollTriggeredRef.current = false;
return;
}
if (reasoningParts.length === 0) {
return;
}
if (shouldHoldReasoning || !reasoningComplete) {
return;
}
if (soloReasoningScrollTriggeredRef.current) {
return;
}
soloReasoningScrollTriggeredRef.current = true;
onContentChange?.('structural');
}, [awaitingMessageCompletion, hasTools, onContentChange, reasoningComplete, reasoningParts.length, shouldHoldReasoning]);
const hasCopyableText = Boolean(hasTextContent) && !awaitingMessageCompletion; const hasCopyableText = Boolean(hasTextContent) && !awaitingMessageCompletion;
const handleForkClick = React.useCallback( const handleForkClick = React.useCallback(
@@ -1704,7 +1678,6 @@ const AssistantMessageBody = React.memo(({
const shouldDeferSortedInlineText = isSortedRenderMode && !hasStopFinish; const shouldDeferSortedInlineText = isSortedRenderMode && !hasStopFinish;
const showErrorMessage = Boolean(errorMessage); const showErrorMessage = Boolean(errorMessage);
const errorIconName = errorVariant === 'info' ? 'information' : 'error-warning';
const isPeekSurface = chatSurfaceMode === 'peek'; const isPeekSurface = chatSurfaceMode === 'peek';
const shouldShowMessageActions = hasCopyableText && !isPeekSurface; const shouldShowMessageActions = hasCopyableText && !isPeekSurface;
const shouldShowTurnFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage)) && !isPeekSurface; const shouldShowTurnFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage)) && !isPeekSurface;
@@ -1798,7 +1771,6 @@ const AssistantMessageBody = React.memo(({
expandedTools={expandedTools} expandedTools={expandedTools}
onToggleTool={onToggleTool} onToggleTool={onToggleTool}
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
onContentChange={onContentChange}
streamPhase={effectiveStreamPhase} streamPhase={effectiveStreamPhase}
showHeader={true} showHeader={true}
animateRows={animateActivityRows} animateRows={animateActivityRows}
@@ -1875,7 +1847,6 @@ const AssistantMessageBody = React.memo(({
messageId={messageId} messageId={messageId}
streamPhase={effectiveStreamPhase} streamPhase={effectiveStreamPhase}
chatRenderMode={chatRenderMode} chatRenderMode={chatRenderMode}
onContentChange={onContentChange}
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
/> />
</div> </div>
@@ -1910,7 +1881,6 @@ const AssistantMessageBody = React.memo(({
messageId={messageId} messageId={messageId}
streamPhase={effectiveStreamPhase} streamPhase={effectiveStreamPhase}
chatRenderMode={chatRenderMode} chatRenderMode={chatRenderMode}
onContentChange={onContentChange}
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
/> />
); );
@@ -1922,7 +1892,6 @@ const AssistantMessageBody = React.memo(({
part={part} part={part}
messageId={messageId} messageId={messageId}
streamPhase={effectiveStreamPhase} streamPhase={effectiveStreamPhase}
onContentChange={onContentChange}
/> />
); );
} }
@@ -1966,7 +1935,6 @@ const AssistantMessageBody = React.memo(({
onToggle={onToggleTool} onToggle={onToggleTool}
isMobile={isMobile} isMobile={isMobile}
alwaysShowActions={alwaysShowMessageActions} alwaysShowActions={alwaysShowMessageActions}
onContentChange={onContentChange}
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
animateTailText={animatedToolIdsLookup.has(toolPart.id)} animateTailText={animatedToolIdsLookup.has(toolPart.id)}
/> />
@@ -2038,7 +2006,6 @@ const AssistantMessageBody = React.memo(({
messageActionButtons, messageActionButtons,
renderJustificationActions, renderJustificationActions,
sessionId, sessionId,
onContentChange,
onShowPopup, onShowPopup,
onToggleTool, onToggleTool,
shouldRenderActivityGroup, shouldRenderActivityGroup,
@@ -2217,17 +2184,9 @@ const AssistantMessageBody = React.memo(({
{renderedParts} {renderedParts}
{showErrorMessage && ( {showErrorMessage && (
<FadeInOnReveal key="assistant-error"> <FadeInOnReveal key="assistant-error">
<div className={cn( <div className="group/assistant-text relative mt-3 max-w-full break-words rounded-2xl border border-[var(--status-info-border)] bg-[var(--status-info-background)] px-4 py-3 text-base leading-relaxed">
'group/assistant-text relative mt-3 p-3 rounded-lg border break-words max-w-full', <div className="flex items-center gap-3">
errorVariant === 'info' <Icon name="information" className="size-4 shrink-0 text-[var(--status-info)]" />
? 'bg-[var(--status-info-background)] border-[var(--status-info-border)]'
: 'bg-[var(--status-error-background)] border-[var(--status-error-border)]',
)}>
<div className="flex items-center gap-2">
<Icon name={errorIconName} className={cn(
'h-4 w-4 shrink-0',
errorVariant === 'info' ? 'text-[var(--status-info)]' : 'text-[var(--status-error)]',
)} />
<div className="min-w-0 flex-1 break-words"> <div className="min-w-0 flex-1 break-words">
<SimpleMarkdownRenderer <SimpleMarkdownRenderer
content={errorMessage ?? ''} content={errorMessage ?? ''}
@@ -1,12 +1,12 @@
import React from 'react'; import React from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
import { useSessions } from '@/sync/sync-context'; import { useSessions } from '@/sync/sync-context';
import { useInputStore } from '@/sync/input-store'; import { useInputStore } from '@/sync/input-store';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { copyTextToClipboard } from '@/lib/clipboard';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { PROJECT_NOTE_BODY_MAX_LENGTH } from '@/lib/projectContextApi'; import { PROJECT_NOTE_BODY_MAX_LENGTH } from '@/lib/projectContextApi';
@@ -18,6 +18,7 @@ import { isVSCodeRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown'; import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown';
import { focusChatInput } from '@/components/chat/composer/editor/dom'; import { focusChatInput } from '@/components/chat/composer/editor/dom';
import { collectSelectionOverlayRects } from '@/lib/selectionOverlayRects';
interface TextSelectionMenuProps { interface TextSelectionMenuProps {
containerRef: React.RefObject<HTMLElement | null>; containerRef: React.RefObject<HTMLElement | null>;
@@ -33,6 +34,8 @@ interface SelectionPayload {
plainText: string; plainText: string;
markdownText: string; markdownText: string;
rect: DOMRect; rect: DOMRect;
messageId: string | null;
range: Range;
} }
const normalizeDistilledInsight = (insight: string): string => ( const normalizeDistilledInsight = (insight: string): string => (
@@ -46,6 +49,54 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false }); const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
const [selectedText, setSelectedText] = React.useState(''); const [selectedText, setSelectedText] = React.useState('');
const [selectedTextMarkdown, setSelectedTextMarkdown] = React.useState(''); const [selectedTextMarkdown, setSelectedTextMarkdown] = React.useState('');
const [selectedMessageId, setSelectedMessageId] = React.useState<string | null>(null);
const [commentMode, setCommentMode] = React.useState(false);
const commentModeRef = React.useRef(false);
const [commentText, setCommentText] = React.useState('');
const commentInputRef = React.useRef<HTMLTextAreaElement>(null);
// While the comment input owns focus the native selection is gone, so the
// quoted fragment is repainted with our own overlay rectangles. Raw
// Range.getClientRects() mixes block-container boxes with text boxes and
// the translucent overlaps paint double-dark bands, so the rects are taken
// from the text nodes only and merged into one strip per visual line.
const [commentRects, setCommentRects] = React.useState<DOMRect[] | null>(null);
const updateCommentRects = React.useCallback(() => {
const range = pendingSelectionRef.current?.range;
if (!range) {
setCommentRects(null);
return;
}
setCommentRects(collectSelectionOverlayRects(range));
}, []);
React.useEffect(() => {
if (!commentMode) return;
let frame: number | null = null;
const scheduleUpdate = () => {
if (frame !== null) return;
frame = window.requestAnimationFrame(() => {
frame = null;
updateCommentRects();
});
};
document.addEventListener('scroll', scheduleUpdate, { capture: true, passive: true });
window.addEventListener('resize', scheduleUpdate);
return () => {
if (frame !== null) window.cancelAnimationFrame(frame);
document.removeEventListener('scroll', scheduleUpdate, { capture: true });
window.removeEventListener('resize', scheduleUpdate);
};
}, [commentMode, updateCommentRects]);
// Grow the comment box with its content, up to five lines.
const resizeCommentInput = React.useCallback(() => {
const element = commentInputRef.current;
if (!element) return;
element.style.height = 'auto';
element.style.height = `${Math.min(element.scrollHeight, 120)}px`;
}, []);
const isDraggingRef = React.useRef(false); const isDraggingRef = React.useRef(false);
const [isOpening, setIsOpening] = React.useState(false); const [isOpening, setIsOpening] = React.useState(false);
const [isAddingToNotes, setIsAddingToNotes] = React.useState(false); const [isAddingToNotes, setIsAddingToNotes] = React.useState(false);
@@ -57,6 +108,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const isMenuVisibleRef = React.useRef(false); const isMenuVisibleRef = React.useRef(false);
const createSession = useSessionUIStore((state) => state.createSession); const createSession = useSessionUIStore((state) => state.createSession);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
const addContextDraft = useInlineCommentDraftStore((state) => state.addDraft);
const setPendingInputText = useInputStore((state) => state.setPendingInputText); const setPendingInputText = useInputStore((state) => state.setPendingInputText);
const isMobile = useUIStore((state) => state.isMobile); const isMobile = useUIStore((state) => state.isMobile);
const projects = useProjectsStore((state) => state.projects); const projects = useProjectsStore((state) => state.projects);
@@ -64,6 +117,39 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const effectiveDirectory = useEffectiveDirectory(); const effectiveDirectory = useEffectiveDirectory();
const sessions = useSessions(); const sessions = useSessions();
// Mobile: the comment bar is rendered inside the composer form (its
// positioning context), so it inherits the runtime's own keyboard handling
// — browser viewport resizing and Capacitor choreography alike. This effect
// only centers it on the composer pill in the form's local coordinates; no
// viewport math, which Safari's keyboard handling reliably breaks for
// fixed elements.
React.useEffect(() => {
if (!commentMode || !isMobile) return;
const update = () => {
const element = menuRef.current;
const host = element?.offsetParent;
if (!element || !host) return;
const pill = document.querySelector('[data-mobile-composer-pill="true"]')
?? document.querySelector('[data-chat-input="true"]');
const pillRect = pill?.getBoundingClientRect();
if (!pillRect || pillRect.height <= 0) return;
const hostRect = host.getBoundingClientRect();
element.style.top = `${pillRect.top - hostRect.top + (pillRect.height - element.offsetHeight) / 2}px`;
element.style.left = `${pillRect.left - hostRect.left}px`;
element.style.width = `${pillRect.width}px`;
element.style.bottom = 'auto';
};
update();
const raf = window.requestAnimationFrame(update);
// The composer relayouts with its own transitions and timeouts that emit
// no event; a light poll keeps the overlay glued to the pill.
const poll = window.setInterval(update, 200);
return () => {
window.cancelAnimationFrame(raf);
window.clearInterval(poll);
};
}, [commentMode, isMobile]);
React.useEffect(() => { React.useEffect(() => {
isMenuVisibleRef.current = position.show; isMenuVisibleRef.current = position.show;
}, [position.show]); }, [position.show]);
@@ -83,6 +169,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const hideMenu = React.useCallback(() => { const hideMenu = React.useCallback(() => {
pendingSelectionRef.current = null; pendingSelectionRef.current = null;
setCommentRects(null);
if (!isMenuVisibleRef.current) { if (!isMenuVisibleRef.current) {
return; return;
@@ -97,6 +184,10 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
setPosition((prev) => ({ ...prev, show: false })); setPosition((prev) => ({ ...prev, show: false }));
setSelectedText(''); setSelectedText('');
setSelectedTextMarkdown(''); setSelectedTextMarkdown('');
setSelectedMessageId(null);
setCommentMode(false);
commentModeRef.current = false;
setCommentText('');
isMenuVisibleRef.current = false; isMenuVisibleRef.current = false;
}, []); }, []);
@@ -121,7 +212,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const showMenu = React.useCallback(() => { const showMenu = React.useCallback(() => {
if (!pendingSelectionRef.current) return; if (!pendingSelectionRef.current) return;
const { plainText, markdownText, rect } = pendingSelectionRef.current; const { plainText, markdownText, rect, messageId } = pendingSelectionRef.current;
const shouldAnimateIn = !position.show; const shouldAnimateIn = !position.show;
// Position menu above the selection // Position menu above the selection
@@ -132,6 +223,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
setSelectedText(plainText); setSelectedText(plainText);
setSelectedTextMarkdown(markdownText); setSelectedTextMarkdown(markdownText);
setSelectedMessageId(messageId);
setPosition({ setPosition({
x: menuX, x: menuX,
y: menuY, y: menuY,
@@ -168,6 +260,25 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
})); }));
}, [getDesktopClampedX, isMobile, position.show]); }, [getDesktopClampedX, isMobile, position.show]);
// The desktop popup hangs above its anchor, so a tall comment box near the
// top of the chat can climb over the app header. On the desktop shell the
// header is a window drag zone, which makes the overlapped part of the
// textarea untouchable, so the popup is pushed down until its top edge stays
// inside the chat container.
React.useLayoutEffect(() => {
if (!position.show || isMobile || !menuRef.current) {
return;
}
const container = containerRef.current;
const minTop = (container ? container.getBoundingClientRect().top : 0) + 4;
const menuTop = menuRef.current.getBoundingClientRect().top;
if (menuTop < minTop) {
const delta = minTop - menuTop;
setPosition((prev) => ({ ...prev, y: prev.y + delta }));
}
}, [containerRef, isMobile, position.show, position.y, commentMode, commentText]);
React.useEffect(() => { React.useEffect(() => {
if (!position.show || isMobile) { if (!position.show || isMobile) {
return; return;
@@ -187,6 +298,11 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
}, [getDesktopClampedX, isMobile, position.show]); }, [getDesktopClampedX, isMobile, position.show]);
const handleSelectionChange = React.useCallback(() => { const handleSelectionChange = React.useCallback(() => {
// While the comment input is open, clicking or typing in it collapses the
// text selection; the captured quote must survive that.
if (commentModeRef.current) {
return;
}
const selection = window.getSelection(); const selection = window.getSelection();
const container = containerRef.current; const container = containerRef.current;
@@ -221,10 +337,15 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const rect = range.getBoundingClientRect(); const rect = range.getBoundingClientRect();
// Store the selection but don't show menu yet if dragging // Store the selection but don't show menu yet if dragging
const anchorElement = range.commonAncestorContainer instanceof Element
? range.commonAncestorContainer
: range.commonAncestorContainer.parentElement;
pendingSelectionRef.current = { pendingSelectionRef.current = {
plainText: text, plainText: text,
markdownText: rangeToMarkdown(range, text), markdownText: rangeToMarkdown(range, text),
rect, rect,
messageId: anchorElement?.closest('[data-message-id]')?.getAttribute('data-message-id') ?? null,
range: range.cloneRange(),
}; };
// Only show menu if we're not currently dragging // Only show menu if we're not currently dragging
@@ -238,7 +359,12 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
if (!container) return; if (!container) return;
// Track when dragging starts // Track when dragging starts
const handleMouseDown = () => { const handleMouseDown = (event: MouseEvent) => {
// SAFETY: a MouseEvent target inside the document is always a Node;
// `contains` only needs that.
if (commentModeRef.current && menuRef.current?.contains(event.target as Node)) {
return;
}
isDraggingRef.current = true; isDraggingRef.current = true;
hideMenu(); hideMenu();
}; };
@@ -254,6 +380,11 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
// Small delay to ensure selection is finalized // Small delay to ensure selection is finalized
mouseUpTimeoutRef.current = window.setTimeout(() => { mouseUpTimeoutRef.current = window.setTimeout(() => {
mouseUpTimeoutRef.current = null; mouseUpTimeoutRef.current = null;
// The click that opened the comment input cleared the selection on
// purpose; the input must survive this deferred check.
if (commentModeRef.current) {
return;
}
const selection = window.getSelection(); const selection = window.getSelection();
if (selection && selection.toString().trim()) { if (selection && selection.toString().trim()) {
showMenu(); showMenu();
@@ -275,7 +406,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
if ( if (
menuRef.current && menuRef.current &&
!menuRef.current.contains(e.target as Node) && !menuRef.current.contains(e.target as Node) &&
!window.getSelection()?.toString().trim() (commentModeRef.current || !window.getSelection()?.toString().trim())
) { ) {
hideMenu(); hideMenu();
} }
@@ -310,6 +441,38 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
}); });
}, [selectedTextMarkdown, setPendingInputText, hideMenu]); }, [selectedTextMarkdown, setPendingInputText, hideMenu]);
const handleOpenComment = React.useCallback(() => {
if (!selectedTextMarkdown) return;
setCommentMode(true);
commentModeRef.current = true;
updateCommentRects();
window.getSelection()?.removeAllRanges();
queueMicrotask(() => {
commentInputRef.current?.focus();
});
}, [selectedTextMarkdown, updateCommentRects]);
const handleAttachComment = React.useCallback(() => {
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
if (!selectedTextMarkdown || !sessionKey || !effectiveDirectory) {
hideMenu();
return;
}
addContextDraft({ directory: effectiveDirectory, sessionKey }, {
source: 'chat-quote',
fileLabel: selectedMessageId ?? '',
startLine: 1,
endLine: 1,
code: selectedTextMarkdown,
language: '',
text: commentText.trim(),
});
hideMenu();
queueMicrotask(() => {
focusChatInput();
});
}, [addContextDraft, commentText, currentSessionId, effectiveDirectory, hideMenu, newSessionDraftOpen, selectedMessageId, selectedTextMarkdown]);
const handleCreateNewSession = React.useCallback(async () => { const handleCreateNewSession = React.useCallback(async () => {
if (!selectedText) return; if (!selectedText) return;
@@ -322,18 +485,6 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
window.getSelection()?.removeAllRanges(); window.getSelection()?.removeAllRanges();
}, [selectedText, createSession, setPendingInputText, hideMenu]); }, [selectedText, createSession, setPendingInputText, hideMenu]);
const handleCopy = React.useCallback(async () => {
if (!selectedText) return;
const result = await copyTextToClipboard(selectedText);
if (!result.ok) {
console.error('Failed to copy:', result.error);
}
hideMenu();
window.getSelection()?.removeAllRanges();
}, [selectedText, hideMenu]);
const currentSession = React.useMemo(() => { const currentSession = React.useMemo(() => {
if (!currentSessionId) { if (!currentSessionId) {
return null; return null;
@@ -390,15 +541,110 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
if (!position.show) return null; if (!position.show) return null;
const commentHighlightOverlay = commentMode && commentRects && commentRects.length > 0
? createPortal(
<div className="pointer-events-none fixed inset-0 z-[5]">
{commentRects.map((rect, index) => (
<div
key={index}
className="oc-chat-comment-rect absolute"
style={{ left: rect.left, top: rect.top, width: rect.width, height: rect.height }}
/>
))}
</div>,
document.body,
)
: null;
const commentInput = (
<div
className={cn(
'oc-glass-popover flex items-end gap-2 rounded-3xl border border-[var(--interactive-border)]',
'pl-4 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]',
'py-1 pr-1',
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
)}
>
<textarea
ref={commentInputRef}
rows={1}
value={commentText}
onChange={(event) => {
setCommentText(event.target.value);
resizeCommentInput();
}}
onKeyDown={(event) => {
// Desktop: Enter attaches, Shift+Enter breaks the line. Mobile
// keyboards use Enter for line breaks; attaching is the button's job.
if (event.key === 'Enter' && !event.shiftKey && !isMobile) {
event.preventDefault();
handleAttachComment();
} else if (event.key === 'Escape') {
event.preventDefault();
hideMenu();
}
}}
placeholder={t('chat.textSelection.comment.placeholder')}
className={cn(
'flex-1 resize-none bg-transparent text-sm leading-5 text-[var(--surface-foreground)] outline-none placeholder:text-[var(--surface-mutedForeground)] placeholder:opacity-60',
// The width cap sizes the floating desktop pill; on mobile the pill
// spans the bottom bar and the cap would strand slack space to the
// right of the attach button.
isMobile ? 'w-full min-w-0 py-1.5 text-base leading-6' : 'w-64 max-w-[70vw] py-1.5'
)}
style={{ minHeight: 0, height: 'auto' }}
/>
<button
type="button"
onClick={handleAttachComment}
className={cn(
'mb-0.5 flex shrink-0 items-center justify-center rounded-full bg-[var(--primary-base)] text-[var(--primary-foreground)] hover:opacity-90 transition-opacity duration-150',
isMobile ? 'h-9 w-9' : 'h-8 w-8'
)}
aria-label={t('chat.textSelection.comment.attach')}
title={t('chat.textSelection.comment.attach')}
>
<Icon name="attachment-2" className="h-4 w-4" />
</button>
</div>
);
// Mobile: Show as a bar at the bottom of the screen, above the keyboard // Mobile: Show as a bar at the bottom of the screen, above the keyboard
if (isMobile) { if (isMobile) {
if (commentMode) {
// Overlay the comment input onto the composer pill: rendering into the
// composer form (position: relative) inherits the runtime's keyboard
// handling in both browser and Capacitor; the centering effect above
// glues it to the pill in the form's local coordinates.
const composerHost = document.querySelector('form.oc-mobile-composer');
const bar = (
<div
ref={menuRef}
className={cn(
'z-50',
composerHost
? 'absolute inset-x-0 bottom-[var(--oc-safe-area-bottom-visual,0.5rem)]'
: 'oc-chat-comment-bar fixed left-3 right-3 mx-auto max-w-[420px]',
)}
>
{commentInput}
</div>
);
return (
<>
{commentHighlightOverlay}
{createPortal(bar, composerHost ?? document.body)}
</>
);
}
return createPortal( return createPortal(
<div <div
ref={menuRef} ref={menuRef}
className={cn( className={cn(
'fixed left-3 right-3 bottom-0 z-50 mx-auto max-w-[420px]', 'fixed left-3 right-3 bottom-0 z-50 mx-auto max-w-[420px]',
'rounded-2xl border border-[var(--interactive-border)]', 'oc-glass-popover rounded-2xl border border-[var(--interactive-border)]',
'bg-[var(--surface-elevated)] p-2 shadow-lg', 'p-2 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]',
'safe-area-bottom', 'safe-area-bottom',
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]', 'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0' isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
@@ -408,6 +654,22 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
}} }}
> >
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<button
onClick={handleOpenComment}
className={cn(
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
'text-sm font-medium leading-tight',
'bg-[var(--surface-muted)] text-[var(--surface-foreground)]',
'active:opacity-80',
'transition-opacity duration-150'
)}
title={t('chat.textSelection.title.commentOnSelection')}
type="button"
>
<Icon name="chat-1" className="h-5 w-5 flex-shrink-0" />
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.comment')}</span>
</button>
<button <button
onClick={handleAddToChat} onClick={handleAddToChat}
className={cn( className={cn(
@@ -421,7 +683,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
type="button" type="button"
> >
<Icon name="add" className="h-5 w-5 flex-shrink-0" /> <Icon name="add" className="h-5 w-5 flex-shrink-0" />
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.addToChat')}</span> <span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.addToInput')}</span>
</button> </button>
<button <button
@@ -440,22 +702,6 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.newSession')}</span> <span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.newSession')}</span>
</button> </button>
<button
onClick={handleCopy}
className={cn(
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
'text-sm font-medium leading-tight',
'bg-[var(--surface-muted)] text-[var(--surface-foreground)]',
'active:opacity-80',
'transition-opacity duration-150'
)}
title={t('chat.textSelection.actions.copy')}
type="button"
>
<Icon name="file-copy" className="h-5 w-5 flex-shrink-0" />
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.copy')}</span>
</button>
{!isVSCodeRuntime() ? ( {!isVSCodeRuntime() ? (
<button <button
onClick={handleAddToNotes} onClick={handleAddToNotes}
@@ -484,80 +730,97 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
return createPortal( return createPortal(
<div <div
ref={menuRef} ref={menuRef}
className="fixed z-50" className="app-region-no-drag fixed z-50"
style={{ style={{
left: position.x, left: position.x,
top: position.y, top: position.y,
transform: 'translate(-50%, -100%)', transform: 'translate(-50%, -100%)',
}} }}
> >
<div {commentMode ? (<>{commentHighlightOverlay}{commentInput}</>) : (
className={cn( <div
'flex items-center gap-1 whitespace-nowrap',
'rounded-lg border border-[var(--interactive-border)]',
'bg-[var(--surface-elevated)] shadow-none',
'px-1.5 py-1',
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
)}
>
<button
onClick={handleAddToChat}
className={cn( className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md', 'flex items-center whitespace-nowrap',
'text-sm font-medium', 'oc-glass-popover rounded-full border border-[var(--interactive-border)]',
'text-[var(--surface-foreground)]', 'shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]',
'hover:bg-[var(--interactive-hover)]', 'p-1',
'transition-colors duration-150' 'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
)} )}
title={t('chat.textSelection.title.addToCurrentChat')}
type="button"
> >
<Icon name="add" className="h-4 w-4" /> <button
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToChat')}</span> onClick={handleOpenComment}
</button> className={cn(
'px-3.5 py-1.5 rounded-full',
'text-sm font-medium',
'text-[var(--surface-foreground)]',
'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150'
)}
title={t('chat.textSelection.title.commentOnSelection')}
type="button"
>
{t('chat.textSelection.actions.comment')}
</button>
<div className="w-px h-4 bg-[var(--interactive-border)]" /> <div className="mx-0.5 h-5 w-px shrink-0 bg-[var(--interactive-border)]" />
<button <button
onClick={handleCreateNewSession} onClick={handleAddToChat}
className={cn( className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md', 'px-3.5 py-1.5 rounded-full',
'text-sm font-medium', 'text-sm font-medium',
'text-[var(--surface-foreground)]', 'text-[var(--surface-foreground)]',
'hover:bg-[var(--interactive-hover)]', 'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150' 'transition-colors duration-150'
)} )}
title={t('chat.textSelection.title.newSessionWithSelection')} title={t('chat.textSelection.title.addToCurrentChat')}
type="button" type="button"
> >
<Icon name="chat-new" className="h-4 w-4" /> {t('chat.textSelection.actions.addToInput')}
<span className="whitespace-nowrap">{t('chat.textSelection.actions.newSession')}</span> </button>
</button>
{!isVSCodeRuntime() ? ( <div className="mx-0.5 h-5 w-px shrink-0 bg-[var(--interactive-border)]" />
<>
<div className="w-px h-4 bg-[var(--interactive-border)]" />
<button <button
onClick={handleAddToNotes} onClick={handleCreateNewSession}
disabled={isAddingToNotes} className={cn(
className={cn( 'px-3.5 py-1.5 rounded-full',
'flex items-center gap-1.5 px-2 py-1 rounded-md', 'text-sm font-medium',
'text-sm font-medium', 'text-[var(--surface-foreground)]',
'text-[var(--surface-foreground)]', 'hover:bg-[var(--interactive-hover)]',
'hover:bg-[var(--interactive-hover)] disabled:opacity-60 disabled:cursor-not-allowed', 'transition-colors duration-150'
'transition-colors duration-150' )}
)} title={t('chat.textSelection.title.newSessionWithSelection')}
title={t('chat.textSelection.title.saveInsightToNotes')} type="button"
type="button" >
> {t('chat.textSelection.actions.newSession')}
{isAddingToNotes ? <Icon name="loader-4" className="h-4 w-4 animate-spin" /> : <Icon name="booklet" className="h-4 w-4" />} </button>
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToNotes')}</span>
</button> {!isVSCodeRuntime() ? (
</> <>
) : null} <div className="mx-0.5 h-5 w-px shrink-0 bg-[var(--interactive-border)]" />
</div>
<button
onClick={handleAddToNotes}
disabled={isAddingToNotes}
className={cn(
'flex items-center gap-1.5 px-3.5 py-1.5 rounded-full',
'text-sm font-medium',
'text-[var(--surface-foreground)]',
'hover:bg-[var(--interactive-hover)] disabled:opacity-60 disabled:cursor-not-allowed',
'transition-colors duration-150'
)}
title={t('chat.textSelection.title.saveInsightToNotes')}
type="button"
>
{isAddingToNotes ? <Icon name="loader-4" className="h-4 w-4 animate-spin" /> : null}
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToNotes')}</span>
</button>
</>
) : null}
</div>
)}
</div>, </div>,
document.body document.body
); );
@@ -1,4 +1,5 @@
import type { Part } from '@opencode-ai/sdk/v2'; import type { Part } from '@opencode-ai/sdk/v2';
import { readContextPart } from '@/lib/messages/contextParts';
const GITHUB_ISSUE_CONTEXT_PREFIX = 'GitHub issue context (JSON)'; const GITHUB_ISSUE_CONTEXT_PREFIX = 'GitHub issue context (JSON)';
const GITHUB_PR_CONTEXT_PREFIX = 'GitHub pull request context (JSON)'; const GITHUB_PR_CONTEXT_PREFIX = 'GitHub pull request context (JSON)';
@@ -96,6 +97,7 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
const synthetic = (part as { synthetic?: boolean }).synthetic === true; const synthetic = (part as { synthetic?: boolean }).synthetic === true;
if (!synthetic) return true; if (!synthetic) return true;
if (part.type !== 'text') return false; if (part.type !== 'text') return false;
if (readContextPart(part)) return true;
const text = (part as { text?: unknown }).text; const text = (part as { text?: unknown }).text;
if (typeof text !== 'string') { if (typeof text !== 'string') {
return false; return false;
@@ -116,6 +118,27 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
const synthetic = rawPart.synthetic === true; const synthetic = rawPart.synthetic === true;
if (synthetic) { if (synthetic) {
const contextPayload = readContextPart(part);
if (contextPayload?.kind === 'github-issue' || contextPayload?.kind === 'github-pr') {
// SAFETY: same display-only file-part shape the legacy
// buildGitHubAttachmentPart produces; consumed by
// FileAttachment, which matches on the mime type.
return {
type: 'file',
mime: contextPayload.kind === 'github-issue'
? 'application/vnd.github.issue-link'
: 'application/vnd.github.pull-request-link',
filename: contextPayload.kind === 'github-issue'
? `Issue #${contextPayload.number}: ${contextPayload.title}`
: `PR #${contextPayload.number}: ${contextPayload.title}`,
url: contextPayload.url,
} as Part;
}
if (contextPayload) {
// Other context kinds render through UserContextPart.
return part;
}
// Legacy messages: sniff the pre-metadata text format.
const attachmentPart = buildGitHubAttachmentPart(text); const attachmentPart = buildGitHubAttachmentPart(text);
if (attachmentPart) { if (attachmentPart) {
return attachmentPart; return attachmentPart;
@@ -1,4 +1,5 @@
import type { Part } from '@opencode-ai/sdk/v2'; import type { Part } from '@opencode-ai/sdk/v2';
import { readContextPart } from '@/lib/messages/contextParts';
type PartWithText = Part & { text?: string; content?: string; value?: string }; type PartWithText = Part & { text?: string; content?: string; value?: string };
@@ -54,6 +55,13 @@ export const filterVisibleParts = (parts: Part[], options: VisibleFilterOptions
} }
} }
// User-attached context (inline comments, terminal selections, and
// such) is synthetic transport-wise but is user content: it renders
// as a context block and must survive alongside regular text.
if (isSynthetic && readContextPart(part)) {
return true;
}
// Only filter out synthetic parts if there are non-synthetic parts present // Only filter out synthetic parts if there are non-synthetic parts present
// Otherwise, show synthetic parts so the message is displayed // Otherwise, show synthetic parts so the message is displayed
if (isSynthetic && hasNonSynthetic) { if (isSynthetic && hasNonSynthetic) {
@@ -2,7 +2,6 @@ import React from 'react';
import type { Part } from '@opencode-ai/sdk/v2'; import type { Part } from '@opencode-ai/sdk/v2';
import { MarkdownRenderer } from '../../MarkdownRenderer'; import { MarkdownRenderer } from '../../MarkdownRenderer';
import type { StreamPhase, ToolPopupContent } from '../types'; import type { StreamPhase, ToolPopupContent } from '../types';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle'; import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility'; import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility';
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug'; import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
@@ -17,7 +16,6 @@ interface AssistantTextPartProps {
messageId: string; messageId: string;
streamPhase: StreamPhase; streamPhase: StreamPhase;
chatRenderMode?: 'sorted' | 'live'; chatRenderMode?: 'sorted' | 'live';
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
onShowPopup?: (content: ToolPopupContent) => void; onShowPopup?: (content: ToolPopupContent) => void;
} }
@@ -121,6 +121,14 @@ Why: only navigation tools use the compact static path; all other tools need obs
## Quick map of files in this folder ## Quick map of files in this folder
- Text: `AssistantTextPart.tsx`, `UserTextPart.tsx` - Text: `AssistantTextPart.tsx`, `UserTextPart.tsx`
- User-attached context (inline code comments, terminal selections, browser
annotations, PR comments/checks): `UserContextPart.tsx`. `UserTextPart`
routes to it when the part's metadata carries an `openchamberContext`
payload (see `lib/messages/contextParts.ts`, which owns both the send-time
builder and the read-back parser). Linked GitHub issues/PRs are instead
converted to link file-parts in `normalizeUserDisplayParts.ts`. Legacy
pre-metadata messages still render via text sniffing (`<terminal_context>`
blocks, `GitHub issue context (JSON)` prefixes).
- Tools: `ToolPart.tsx`, `ToolPartDiffPreview.tsx`, `PlainDiffFallback.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx` - Tools: `ToolPart.tsx`, `ToolPartDiffPreview.tsx`, `PlainDiffFallback.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx`
- Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx` - Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx`
- Status/placeholders: `WorkingPlaceholder.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`, `BusyDots.tsx` - Status/placeholders: `WorkingPlaceholder.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`, `BusyDots.tsx`
@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import type { Part } from '@opencode-ai/sdk/v2'; import type { Part } from '@opencode-ai/sdk/v2';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { ReasoningTimelineBlock } from './ReasoningPart'; import { ReasoningTimelineBlock } from './ReasoningPart';
@@ -22,14 +21,12 @@ const cleanJustificationText = (text: string): string => {
interface JustificationBlockProps { interface JustificationBlockProps {
part: Part; part: Part;
messageId: string; messageId: string;
onContentChange?: (reason?: ContentChangeReason) => void;
actions?: React.ReactNode; actions?: React.ReactNode;
} }
const JustificationBlock: React.FC<JustificationBlockProps> = ({ const JustificationBlock: React.FC<JustificationBlockProps> = ({
part, part,
messageId, messageId,
onContentChange,
actions, actions,
}) => { }) => {
const chatRenderMode = useUIStore((state) => state.chatRenderMode); const chatRenderMode = useUIStore((state) => state.chatRenderMode);
@@ -47,7 +44,6 @@ const JustificationBlock: React.FC<JustificationBlockProps> = ({
<ReasoningTimelineBlock <ReasoningTimelineBlock
text={textContent} text={textContent}
variant="justification" variant="justification"
onContentChange={onContentChange}
blockId={part.id || `${messageId}-justification`} blockId={part.id || `${messageId}-justification`}
time={time} time={time}
showDuration={chatRenderMode !== 'sorted'} showDuration={chatRenderMode !== 'sorted'}
@@ -4,7 +4,6 @@ import { cn } from '@/lib/utils';
import type { TurnActivityRecord as TurnActivityPart } from '../../lib/turns/types'; import type { TurnActivityRecord as TurnActivityPart } from '../../lib/turns/types';
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2'; import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
import type { StreamPhase } from '../types'; import type { StreamPhase } from '../types';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import type { ToolPopupContent } from '../types'; import type { ToolPopupContent } from '../types';
import ToolPart from './ToolPart'; import ToolPart from './ToolPart';
import { MinDurationShineText } from './MinDurationShineText'; import { MinDurationShineText } from './MinDurationShineText';
@@ -40,7 +39,6 @@ interface ProgressiveGroupProps {
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;
@@ -376,9 +374,7 @@ interface ExpandableToolRowProps {
isMobile: boolean; isMobile: boolean;
onToggleTool: (toolId: string) => void; onToggleTool: (toolId: string) => void;
onShowPopup: (content: ToolPopupContent) => void; onShowPopup: (content: ToolPopupContent) => void;
onContentChange?: (reason?: ContentChangeReason) => void;
animateTailText: boolean; animateTailText: boolean;
animateRows: boolean;
} }
const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({ const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({
@@ -387,9 +383,7 @@ const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({
isMobile, isMobile,
onToggleTool, onToggleTool,
onShowPopup, onShowPopup,
onContentChange,
animateTailText, animateTailText,
animateRows,
}) => { }) => {
const handleToggle = React.useCallback(() => { const handleToggle = React.useCallback(() => {
onToggleTool(activity.id); onToggleTool(activity.id);
@@ -401,23 +395,22 @@ const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({
isExpanded={isExpanded} isExpanded={isExpanded}
onToggle={handleToggle} onToggle={handleToggle}
isMobile={isMobile} isMobile={isMobile}
onContentChange={onContentChange}
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
animateTailText={animateTailText} animateTailText={animateTailText}
/> />
); );
const maybeWrapped = animateTailText ? ( // Wrappers are unconditional: a conditional wrapper changes the element
<ToolRevealOnMount animate={true} wipe> // type at this position when animateTailText/animateRows flip (message
{content} // completion), remounting the tool subtree and replaying the reveal wipe.
</ToolRevealOnMount> // Both wrappers are inert with animation off.
) : content; return (
<FadeInOnReveal>
if (!animateRows) { <ToolRevealOnMount animate={animateTailText} wipe>
return maybeWrapped; {content}
} </ToolRevealOnMount>
</FadeInOnReveal>
return <FadeInOnReveal>{maybeWrapped}</FadeInOnReveal>; );
}; };
const MemoExpandableToolRow = React.memo(ExpandableToolRow, (prev, next) => { const MemoExpandableToolRow = React.memo(ExpandableToolRow, (prev, next) => {
@@ -425,9 +418,7 @@ const MemoExpandableToolRow = React.memo(ExpandableToolRow, (prev, next) => {
&& prev.isMobile === next.isMobile && prev.isMobile === next.isMobile
&& prev.onToggleTool === next.onToggleTool && prev.onToggleTool === next.onToggleTool
&& prev.onShowPopup === next.onShowPopup && prev.onShowPopup === next.onShowPopup
&& prev.onContentChange === next.onContentChange
&& prev.animateTailText === next.animateTailText && prev.animateTailText === next.animateTailText
&& prev.animateRows === next.animateRows
&& prev.activity.id === next.activity.id && prev.activity.id === next.activity.id
&& prev.activity.kind === next.activity.kind && prev.activity.kind === next.activity.kind
&& prev.activity.endedAt === next.activity.endedAt && prev.activity.endedAt === next.activity.endedAt
@@ -438,14 +429,12 @@ interface StaticGroupedToolRowProps {
toolName: string; toolName: string;
activities: TurnActivityPart[]; activities: TurnActivityPart[];
animateTailText: boolean; animateTailText: boolean;
animateRows: boolean;
} }
const StaticGroupedToolRow: React.FC<StaticGroupedToolRowProps> = ({ const StaticGroupedToolRow: React.FC<StaticGroupedToolRowProps> = ({
toolName, toolName,
activities, activities,
animateTailText, animateTailText,
animateRows,
}) => { }) => {
const content = ( const content = (
<StaticToolRow <StaticToolRow
@@ -455,23 +444,22 @@ const StaticGroupedToolRow: React.FC<StaticGroupedToolRowProps> = ({
/> />
); );
const maybeWrapped = animateTailText ? ( // Wrappers are unconditional: a conditional wrapper changes the element
<ToolRevealOnMount animate={true} wipe> // type at this position when animateTailText/animateRows flip (message
{content} // completion), remounting the tool subtree and replaying the reveal wipe.
</ToolRevealOnMount> // Both wrappers are inert with animation off.
) : content; return (
<FadeInOnReveal>
if (!animateRows) { <ToolRevealOnMount animate={animateTailText} wipe>
return maybeWrapped; {content}
} </ToolRevealOnMount>
</FadeInOnReveal>
return <FadeInOnReveal>{maybeWrapped}</FadeInOnReveal>; );
}; };
const MemoStaticGroupedToolRow = React.memo(StaticGroupedToolRow, (prev, next) => { const MemoStaticGroupedToolRow = React.memo(StaticGroupedToolRow, (prev, next) => {
return prev.toolName === next.toolName return prev.toolName === next.toolName
&& prev.animateTailText === next.animateTailText && prev.animateTailText === next.animateTailText
&& prev.animateRows === next.animateRows
&& areActivityListsEqual(prev.activities, next.activities); && areActivityListsEqual(prev.activities, next.activities);
}); });
@@ -795,9 +783,8 @@ export const StaticToolRow = React.memo(StaticToolRowInner, (prev, next) => {
/** /**
* Inline reasoning text block rendered as dimmed italic markdown. * Inline reasoning text block rendered as dimmed italic markdown.
*/ */
const InlineReasoningBlock = React.memo(({ activity, onContentChange, streamPhase }: { const InlineReasoningBlock = React.memo(({ activity, streamPhase }: {
activity: TurnActivityPart; activity: TurnActivityPart;
onContentChange?: (reason?: ContentChangeReason) => void;
streamPhase: StreamPhase; streamPhase: StreamPhase;
}) => { }) => {
return ( return (
@@ -805,7 +792,6 @@ const InlineReasoningBlock = React.memo(({ activity, onContentChange, streamPhas
part={activity.part} part={activity.part}
messageId={activity.messageId} messageId={activity.messageId}
streamPhase={streamPhase} streamPhase={streamPhase}
onContentChange={onContentChange}
/> />
); );
}); });
@@ -813,16 +799,14 @@ const InlineReasoningBlock = React.memo(({ activity, onContentChange, streamPhas
/** /**
* Inline justification text block rendered as normal assistant text between tools. * Inline justification text block rendered as normal assistant text between tools.
*/ */
const InlineJustificationBlock = React.memo(({ activity, onContentChange, actions }: { const InlineJustificationBlock = React.memo(({ activity, actions }: {
activity: TurnActivityPart; activity: TurnActivityPart;
onContentChange?: (reason?: ContentChangeReason) => void;
actions?: React.ReactNode; actions?: React.ReactNode;
}) => { }) => {
return ( return (
<JustificationBlock <JustificationBlock
part={activity.part} part={activity.part}
messageId={activity.messageId} messageId={activity.messageId}
onContentChange={onContentChange}
actions={actions} actions={actions}
/> />
); );
@@ -837,7 +821,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
expandedTools, expandedTools,
onToggleTool, onToggleTool,
onShowPopup, onShowPopup,
onContentChange,
streamPhase, streamPhase,
showHeader, showHeader,
animateRows = true, animateRows = true,
@@ -898,7 +881,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
<InlineReasoningBlock <InlineReasoningBlock
activity={row.activity} activity={row.activity}
streamPhase={streamPhase} streamPhase={streamPhase}
onContentChange={onContentChange}
/> />
</> </>
); );
@@ -909,7 +891,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
<> <>
<InlineJustificationBlock <InlineJustificationBlock
activity={row.activity} activity={row.activity}
onContentChange={onContentChange}
actions={renderJustificationActions?.(row.activity)} actions={renderJustificationActions?.(row.activity)}
/> />
</> </>
@@ -924,9 +905,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
isMobile={isMobile} isMobile={isMobile}
onToggleTool={onToggleTool} onToggleTool={onToggleTool}
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
onContentChange={onContentChange}
animateTailText={Boolean(animatedToolIds?.has(row.activity.id))} animateTailText={Boolean(animatedToolIds?.has(row.activity.id))}
animateRows={animateRows}
/> />
); );
@@ -937,7 +916,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
toolName={row.toolName} toolName={row.toolName}
activities={row.activities} activities={row.activities}
animateTailText={row.activities.some((activity) => animatedToolIds?.has(activity.id))} animateTailText={row.activities.some((activity) => animatedToolIds?.has(activity.id))}
animateRows={animateRows}
/> />
); );
@@ -950,9 +928,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
isMobile={isMobile} isMobile={isMobile}
onToggleTool={onToggleTool} onToggleTool={onToggleTool}
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
onContentChange={onContentChange}
animateTailText={Boolean(animatedToolIds?.has(row.activity.id))} animateTailText={Boolean(animatedToolIds?.has(row.activity.id))}
animateRows={animateRows}
/> />
); );
@@ -2,7 +2,6 @@ import React from 'react';
import { animate, type AnimationPlaybackControls } from 'motion'; import { animate, type AnimationPlaybackControls } from 'motion';
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 type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
import { BusyDots } from './BusyDots'; import { BusyDots } from './BusyDots';
@@ -10,6 +9,7 @@ import { useI18n } from '@/lib/i18n';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { MarkdownRenderer } from '../../MarkdownRenderer'; import { MarkdownRenderer } from '../../MarkdownRenderer';
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle'; import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
import { commitStreamedText } from '../../lib/streamTextCommit';
import type { StreamPhase } from '../types'; import type { StreamPhase } from '../types';
const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-5 sm:!leading-6 tracking-normal'; const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-5 sm:!leading-6 tracking-normal';
@@ -81,7 +81,6 @@ const getReasoningSummary = (text: string): string => {
type ReasoningTimelineBlockProps = { type ReasoningTimelineBlockProps = {
text: string; text: string;
variant: ReasoningVariant; variant: ReasoningVariant;
onContentChange?: (reason?: ContentChangeReason) => void;
blockId: string; blockId: string;
time?: { start?: number; end?: number }; time?: { start?: number; end?: number };
showDuration?: boolean; showDuration?: boolean;
@@ -99,7 +98,6 @@ type ExpansionState = {
export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
text, text,
variant, variant,
onContentChange,
blockId, blockId,
time, time,
isStreaming = false, isStreaming = false,
@@ -123,11 +121,6 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
const contentRef = React.useRef<HTMLDivElement>(null); const contentRef = React.useRef<HTMLDivElement>(null);
const contentAnimationRef = React.useRef<AnimationPlaybackControls | null>(null); const contentAnimationRef = React.useRef<AnimationPlaybackControls | null>(null);
const contentMountedRef = React.useRef(false); const contentMountedRef = React.useRef(false);
// Stable handle to onContentChange so the height-animation layout effect can
// signal auto-follow without taking onContentChange as a dependency (which
// would risk re-running — and thus restarting — the animation on re-render).
const onContentChangeRef = React.useRef(onContentChange);
onContentChangeRef.current = onContentChange;
const summary = React.useMemo(() => getReasoningSummary(text), [text]); const summary = React.useMemo(() => getReasoningSummary(text), [text]);
const toggleAriaLabel = isExpanded const toggleAriaLabel = isExpanded
@@ -137,8 +130,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
const handleToggle = React.useCallback(() => { const handleToggle = React.useCallback(() => {
setShouldRenderExpandedContent(true); setShouldRenderExpandedContent(true);
setExpansion({ expanded: !isExpanded, source: 'user' }); setExpansion({ expanded: !isExpanded, source: 'user' });
onContentChange?.('structural'); }, [isExpanded]);
}, [isExpanded, onContentChange]);
const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => { const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
@@ -159,13 +151,6 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
}); });
}, [canAutoExpand]); }, [canAutoExpand]);
React.useEffect(() => {
if (text.trim().length === 0) {
return;
}
onContentChange?.('structural');
}, [onContentChange, text]);
React.useEffect(() => { React.useEffect(() => {
if (isExpanded || isStreaming) { if (isExpanded || isStreaming) {
setShouldRenderExpandedContent(true); setShouldRenderExpandedContent(true);
@@ -239,11 +224,6 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
element.style.height = '0px'; element.style.height = '0px';
} else { } else {
element.style.height = `${element.scrollHeight}px`; element.style.height = `${element.scrollHeight}px`;
// Only the COLLAPSE animation needs the guard: it shrinks the
// timeline and the trailing async scroll events can be misread as a
// user scroll-away. Expansion grows the timeline and re-pins cleanly,
// and guarding it caused a faint scroll fight while thinking streams.
onContentChangeRef.current?.('animation');
} }
const animation = animate( const animation = animate(
@@ -436,14 +416,12 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
type ReasoningPartProps = { type ReasoningPartProps = {
part: Part; part: Part;
onContentChange?: (reason?: ContentChangeReason) => void;
messageId: string; messageId: string;
streamPhase?: StreamPhase; streamPhase?: StreamPhase;
}; };
const ReasoningPart = React.memo(({ const ReasoningPart = React.memo(({
part, part,
onContentChange,
messageId, messageId,
streamPhase, streamPhase,
}: ReasoningPartProps) => { }: ReasoningPartProps) => {
@@ -454,11 +432,14 @@ const ReasoningPart = React.memo(({
const time = partWithText.time; const time = partWithText.time;
const canBeStreaming = streamPhase === undefined || streamPhase !== 'completed'; const canBeStreaming = streamPhase === undefined || streamPhase !== 'completed';
const isStreaming = chatRenderMode === 'live' && canBeStreaming && typeof time?.end !== 'number'; const isStreaming = chatRenderMode === 'live' && canBeStreaming && typeof time?.end !== 'number';
const throttledText = useStreamingTextThrottle({ const throttledTextRaw = useStreamingTextThrottle({
text: textContent, text: textContent,
isStreaming, isStreaming,
identityKey: `${messageId}:${part.id ?? 'reasoning'}`, identityKey: `${messageId}:${part.id ?? 'reasoning'}`,
}); });
// Same block-level reveal as assistant text: a shown reasoning paragraph
// never mutates in place.
const throttledText = isStreaming ? commitStreamedText(throttledTextRaw) : throttledTextRaw;
// Show reasoning even if time.end isn't set yet (during streaming) // Show reasoning even if time.end isn't set yet (during streaming)
// Only hide if there's no text content // Only hide if there's no text content
@@ -470,7 +451,6 @@ const ReasoningPart = React.memo(({
<ReasoningTimelineBlock <ReasoningTimelineBlock
text={throttledText} text={throttledText}
variant="thinking" variant="thinking"
onContentChange={onContentChange}
blockId={part.id || `${messageId}-reasoning`} blockId={part.id || `${messageId}-reasoning`}
time={time} time={time}
isStreaming={isStreaming} isStreaming={isStreaming}
@@ -20,7 +20,6 @@ import { toast } from '@/components/ui';
import { Text } from '@/components/ui/text'; import { Text } from '@/components/ui/text';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyTextToClipboard } from '@/lib/clipboard';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import type { ToolPopupContent } from '../types'; import type { ToolPopupContent } from '../types';
import { PlainDiffFallback } from './PlainDiffFallback'; import { PlainDiffFallback } from './PlainDiffFallback';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
@@ -82,7 +81,6 @@ interface ToolPartProps {
onToggle: (toolId: string) => void; onToggle: (toolId: string) => void;
isMobile: boolean; isMobile: boolean;
alwaysShowActions?: boolean; alwaysShowActions?: boolean;
onContentChange?: (reason?: ContentChangeReason) => void;
onShowPopup?: (content: ToolPopupContent) => void; onShowPopup?: (content: ToolPopupContent) => void;
animateTailText?: boolean; animateTailText?: boolean;
} }
@@ -1684,7 +1682,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
isExpanded, isExpanded,
onToggle, onToggle,
isMobile, isMobile,
onContentChange,
onShowPopup, onShowPopup,
animateTailText = true, animateTailText = true,
}) => { }) => {
@@ -1754,10 +1751,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
}); });
}, [currentDirectory, input, isFinalized, isSuccessfullyFinalized, metadata, normalizedPartTool]); }, [currentDirectory, input, isFinalized, isSuccessfullyFinalized, metadata, normalizedPartTool]);
const shouldNotifyStructuralChange = isFinalized || isTaskTool;
const onContentChangeRef = React.useRef(onContentChange);
onContentChangeRef.current = onContentChange;
const expandedContentRef = React.useRef<HTMLDivElement>(null); const expandedContentRef = React.useRef<HTMLDivElement>(null);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
@@ -1772,11 +1765,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
element.style.height = isExpanded ? 'auto' : '0px'; element.style.height = isExpanded ? 'auto' : '0px';
element.style.overflow = isExpanded ? 'visible' : 'hidden'; element.style.overflow = isExpanded ? 'visible' : 'hidden';
}, [isExpanded, isTaskTool]);
if (shouldNotifyStructuralChange) {
onContentChangeRef.current?.('structural');
}
}, [isExpanded, isTaskTool, shouldNotifyStructuralChange]);
const partMetadata = (part as unknown as { metadata?: unknown }).metadata; const partMetadata = (part as unknown as { metadata?: unknown }).metadata;
const time = stateWithData.time; const time = stateWithData.time;
@@ -1934,26 +1923,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
} }
return metadataTaskSummaryEntries; return metadataTaskSummaryEntries;
}, [childSessionTaskSummaryEntries, metadataTaskSummaryEntries]); }, [childSessionTaskSummaryEntries, metadataTaskSummaryEntries]);
const taskSummaryRenderSignature = React.useMemo(() => {
return taskSummaryEntries.map(getTaskSummaryEntryRenderSignature).join('\u0000');
}, [taskSummaryEntries]);
const lastTaskSummaryRenderSignatureRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!isTaskTool) {
lastTaskSummaryRenderSignatureRef.current = null;
return;
}
const previous = lastTaskSummaryRenderSignatureRef.current;
lastTaskSummaryRenderSignatureRef.current = taskSummaryRenderSignature;
if (previous === null || previous === taskSummaryRenderSignature || taskSummaryEntries.length === 0) {
return;
}
onContentChangeRef.current?.('structural');
}, [isTaskTool, taskSummaryEntries.length, taskSummaryRenderSignature]);
const diffStats = React.useMemo(() => { const diffStats = React.useMemo(() => {
return (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch') return (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch')
? parseDiffStats(metadata) ? parseDiffStats(metadata)
@@ -2351,7 +2320,6 @@ export default React.memo(ToolPart, (prev, next) => {
&& prev.isExpanded === next.isExpanded && prev.isExpanded === next.isExpanded
&& prev.isMobile === next.isMobile && prev.isMobile === next.isMobile
&& prev.alwaysShowActions === next.alwaysShowActions && prev.alwaysShowActions === next.alwaysShowActions
&& prev.onContentChange === next.onContentChange
&& prev.onShowPopup === next.onShowPopup && prev.onShowPopup === next.onShowPopup
&& prev.animateTailText === next.animateTailText; && prev.animateTailText === next.animateTailText;
}); });
@@ -0,0 +1,193 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import type { IconName } from '@/components/icon/icons';
import { useI18n } from '@/lib/i18n';
import type { ContextPartPayload } from '@/lib/messages/contextParts';
import { cn } from '@/lib/utils';
/**
* A context item attached to a user message: an inline code comment, a
* terminal selection, a browser annotation, or GitHub PR context.
*
* The quoted material renders as a messenger-style reply: a source caption and
* the quote behind a plain left bar, in muted text, clamped to a few lines
* (click toggles the full quote). The user's comment follows below as regular
* message text, so the pair reads as "a reply to this quote" instead of a
* boxed widget inside the bubble.
*/
const ContextCard: React.FC<{
icon: IconName;
summary: string;
/** Full untruncated context, shown on hover. */
title?: string;
body: string;
text: string;
/** Render the quote in the code font (code, terminal output, CI logs). */
mono?: boolean;
/**
* Message-level collapse: with collapsible messages on, the whole user
* message (text parts and cards alike) shares one expanded state, so a
* collapsed card is a two-line preview and a click asks the message to
* expand instead of toggling anything of its own.
*/
collapsed?: boolean;
onExpand?: () => void;
}> = ({ icon, summary, title, body, text, mono, collapsed, onExpand }) => {
const [expanded, setExpanded] = React.useState(false);
const hasBody = body.trim().length > 0;
const hasText = text.trim().length > 0;
if (collapsed) {
// One line per attachment: the source caption, and the user's comment
// after it when there is one ("Quoted from an earlier message: thanks,
// that settles it"). Attachments without a comment (terminal output
// and the like) collapse to the caption alone.
const comment = text.trim();
return (
<div
className="my-1 flex min-w-0 max-w-full cursor-pointer items-center gap-1.5 border-l-2 border-[var(--interactive-border)] pl-3 text-xs text-[var(--surface-mutedForeground)]"
onClick={onExpand}
title={title}
>
<Icon name={icon} className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">
{comment.length > 0 ? `${summary}: ` : summary}
{comment.length > 0 ? (
<span className="text-sm text-[var(--surface-foreground)]">{comment}</span>
) : null}
</span>
</div>
);
}
return (
<div className="my-1.5 min-w-0 max-w-full">
<div
className={cn('min-w-0 border-l-2 border-[var(--interactive-border)] pl-3', hasBody && 'cursor-pointer')}
onClick={hasBody ? () => setExpanded((value) => !value) : undefined}
title={title}
>
<div className="flex items-center gap-1.5 text-xs text-[var(--surface-mutedForeground)]">
<Icon name={icon} className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{summary}</span>
</div>
{hasBody ? (
<div
className={cn(
'mt-1 whitespace-pre-wrap break-words text-[var(--surface-mutedForeground)]',
mono ? 'font-mono text-xs leading-5' : 'text-sm',
!expanded && 'line-clamp-4'
)}
>
{body}
</div>
) : null}
</div>
{hasText ? (
<div className="mt-1.5 whitespace-pre-wrap break-words font-sans text-sm text-[var(--surface-foreground)]">{text}</div>
) : null}
</div>
);
};
const basename = (path: string): string => {
const segments = path.split('/').filter(Boolean);
return segments[segments.length - 1] ?? path;
};
const UserContextPart: React.FC<{
payload: ContextPartPayload;
/** Message-level collapse state, shared with the text parts. */
collapsed?: boolean;
onExpand?: () => void;
}> = ({ payload, collapsed, onExpand }) => {
const { t } = useI18n();
const shared = { collapsed, onExpand };
switch (payload.kind) {
case 'code-comment': {
const file = basename(payload.fileLabel);
const summary = payload.startLine === payload.endLine
? t('chat.message.context.codeCommentLine', { file, line: payload.startLine })
: t('chat.message.context.codeComment', { file, start: payload.startLine, end: payload.endLine });
const fullTitle = payload.startLine === payload.endLine
? t('chat.message.context.codeCommentLine', { file: payload.fileLabel, line: payload.startLine })
: t('chat.message.context.codeComment', { file: payload.fileLabel, start: payload.startLine, end: payload.endLine });
return <ContextCard icon="chat-1" summary={summary} title={fullTitle} body={payload.code} text={payload.text} mono {...shared} />;
}
case 'terminal':
return (
<ContextCard
icon="terminal"
summary={t('chat.message.terminalContext', {
terminal: payload.terminalLabel,
start: payload.startLine,
end: payload.endLine,
})}
body={payload.output}
text=""
mono
{...shared}
/>
);
case 'browser-annotation':
return (
<ContextCard
icon="global"
summary={t('chat.message.context.browserAnnotation', { page: payload.pageUrl })}
title={payload.pageUrl}
body={payload.prompt}
text={payload.text}
{...shared}
/>
);
case 'pr-comment':
return (
<ContextCard
icon="git-pull-request"
summary={t('chat.message.context.prComment', { label: payload.label })}
body={payload.body}
text={payload.text}
{...shared}
/>
);
case 'pr-check':
return (
<ContextCard
icon="close-circle"
summary={t('chat.message.context.prCheck', { label: payload.label })}
body={payload.output}
text={payload.text}
mono
{...shared}
/>
);
case 'file-quote': {
const file = basename(payload.fileLabel);
const summary = payload.startLine != null && payload.endLine != null
? (payload.startLine === payload.endLine
? t('chat.message.context.codeCommentLine', { file, line: payload.startLine })
: t('chat.message.context.codeComment', { file, start: payload.startLine, end: payload.endLine }))
: t('chat.message.context.fileQuote', { file });
return <ContextCard icon="chat-1" summary={summary} title={payload.fileLabel} body={payload.quote} text={payload.text} {...shared} />;
}
case 'chat-quote':
return (
<ContextCard
icon="chat-1"
summary={t('chat.message.context.chatQuote')}
body={payload.quote}
text={payload.text}
{...shared}
/>
);
case 'github-issue':
case 'github-pr':
// Rendered as link attachments by normalizeUserDisplayParts.
return null;
}
};
export default React.memo(UserContextPart);
@@ -15,6 +15,8 @@ import {
} from '@/lib/messages/inlineMessageLinks'; } from '@/lib/messages/inlineMessageLinks';
import { prepareUserMarkdownContent, SKILL_TOKEN_PATTERN } from './userTextPartContent'; import { prepareUserMarkdownContent, SKILL_TOKEN_PATTERN } from './userTextPartContent';
import { extractTerminalContexts } from '@/lib/messages/terminalContext'; import { extractTerminalContexts } from '@/lib/messages/terminalContext';
import { readContextPart } from '@/lib/messages/contextParts';
import UserContextPart from './UserContextPart';
type PartWithText = Part & { text?: string; content?: string; value?: string }; type PartWithText = Part & { text?: string; content?: string; value?: string };
@@ -23,13 +25,26 @@ type UserTextPartProps = {
messageId: string; messageId: string;
isMobile: boolean; isMobile: boolean;
agentMention?: AgentMentionInfo; agentMention?: AgentMentionInfo;
/**
* Message-level collapse: when provided, all parts of the user message
* share one expanded state owned by the message body, expanding any part
* expands the whole message, and the message body renders the single
* collapse control. When absent the part collapses on its own (legacy
* single-part behavior).
*/
messageExpanded?: boolean;
onExpandMessage?: () => void;
}; };
const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => { const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => {
return mode === 'markdown' ? 'markdown' : 'plain'; return mode === 'markdown' ? 'markdown' : 'plain';
}; };
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention }) => { const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention, messageExpanded, onExpandMessage }) => {
// Structured context (inline comments, terminal selections, annotations,
// PR context) renders as a dedicated block instead of raw prompt text.
const contextPayload = React.useMemo(() => readContextPart(part), [part]);
const partWithText = part as PartWithText; const partWithText = part as PartWithText;
const rawText = partWithText.text; const rawText = partWithText.text;
const serializedText = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || ''; const serializedText = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
@@ -45,7 +60,9 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
const effectiveDirectory = useEffectiveDirectory(); const effectiveDirectory = useEffectiveDirectory();
const { t } = useI18n(); const { t } = useI18n();
const normalizedRenderingMode = normalizeUserMessageRenderingMode(userMessageRenderingMode); const normalizedRenderingMode = normalizeUserMessageRenderingMode(userMessageRenderingMode);
const isCollapsed = collapsibleUserMessages && !isExpanded; const isControlled = messageExpanded !== undefined;
const effectiveExpanded = messageExpanded ?? isExpanded;
const isCollapsed = collapsibleUserMessages && !effectiveExpanded;
const textRef = React.useRef<HTMLDivElement>(null); const textRef = React.useRef<HTMLDivElement>(null);
const skillByName = React.useMemo(() => new Map(skills.map((skill) => [skill.name, skill])), [skills]); const skillByName = React.useMemo(() => new Map(skills.map((skill) => [skill.name, skill])), [skills]);
@@ -72,7 +89,7 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
React.useEffect(() => { React.useEffect(() => {
const el = textRef.current; const el = textRef.current;
if (!el) return; if (!el) return;
if (!collapsibleUserMessages || isExpanded) return; if (!collapsibleUserMessages || effectiveExpanded) return;
const checkTruncation = () => { const checkTruncation = () => {
setIsTruncated(el.scrollHeight > el.clientHeight); setIsTruncated(el.scrollHeight > el.clientHeight);
@@ -112,7 +129,7 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
mutationObserver.disconnect(); mutationObserver.disconnect();
resizeObserver.disconnect(); resizeObserver.disconnect();
}; };
}, [collapsibleUserMessages, textContent, isExpanded]); }, [collapsibleUserMessages, textContent, effectiveExpanded]);
React.useEffect(() => { React.useEffect(() => {
if (!collapsibleUserMessages) { if (!collapsibleUserMessages) {
@@ -145,11 +162,15 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
// Measure at click time instead of trusting the observed flag: whether // Measure at click time instead of trusting the observed flag: whether
// the text is clipped right now is what decides if expanding does // the text is clipped right now is what decides if expanding does
// anything, and the flag can still be catching up on a fresh message. // anything, and the flag can still be catching up on a fresh message.
if (collapsibleUserMessages && !isExpanded && element.scrollHeight > element.clientHeight) { if (collapsibleUserMessages && !effectiveExpanded && element.scrollHeight > element.clientHeight) {
setIsTruncated(true); setIsTruncated(true);
setIsExpanded(true); if (isControlled) {
onExpandMessage?.();
} else {
setIsExpanded(true);
}
} }
}, [collapsibleUserMessages, hasActiveSelectionInElement, isExpanded, openSkill]); }, [collapsibleUserMessages, effectiveExpanded, hasActiveSelectionInElement, isControlled, onExpandMessage, openSkill]);
const handleCollapse = React.useCallback((event: React.MouseEvent) => { const handleCollapse = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation(); event.stopPropagation();
@@ -224,13 +245,23 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
}); });
}, [agentMention, openSkill, skillByName, textContent]); }, [agentMention, openSkill, skillByName, textContent]);
if (contextPayload) {
return (
<UserContextPart
payload={contextPayload}
collapsed={isCollapsed}
onExpand={isControlled ? onExpandMessage : () => setIsExpanded(true)}
/>
);
}
if ((!textContent || textContent.trim().length === 0) && terminalContextState.contexts.length === 0) { if ((!textContent || textContent.trim().length === 0) && terminalContextState.contexts.length === 0) {
return null; return null;
} }
return ( return (
<div className="relative" key={part.id || `${messageId}-user-text`}> <div className="relative" key={part.id || `${messageId}-user-text`}>
{collapsibleUserMessages && isExpanded && ( {collapsibleUserMessages && !isControlled && isExpanded && (
<button <button
type="button" type="button"
onClick={handleCollapse} onClick={handleCollapse}
@@ -243,10 +274,10 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
<div <div
className={cn( className={cn(
"break-words font-sans typography-markdown-body", "break-words font-sans typography-markdown-body",
isExpanded && "pb-3", !isControlled && isExpanded && "pb-3",
normalizedRenderingMode === 'plain' && 'whitespace-pre-wrap', normalizedRenderingMode === 'plain' && 'whitespace-pre-wrap',
isCollapsed && "line-clamp-2", isCollapsed && "line-clamp-2",
collapsibleUserMessages && isTruncated && !isExpanded && "cursor-pointer" collapsibleUserMessages && isTruncated && !effectiveExpanded && "cursor-pointer"
)} )}
ref={textRef} ref={textRef}
onClick={handleClick} onClick={handleClick}
@@ -229,12 +229,11 @@ export function WorkingPlaceholder({
return ( return (
<div <div
// Styled to mirror the turn footer's model row (text-sm, // Full muted-foreground, matching the scroll-to-bottom pill's status
// muted-foreground/60, no left inset): when the turn completes this row // text: the row and the pill hand off to each other in the same spot
// disappears and the footer appears in the same visual spot, so the two // and must read as one element changing chrome.
// must read as the same line swapping its text.
className={ className={
'flex h-full items-center text-muted-foreground/60' 'flex h-full items-center text-muted-foreground'
} }
role="status" role="status"
aria-live={displayedPermission ? 'assertive' : 'polite'} aria-live={displayedPermission ? 'assertive' : 'polite'}
@@ -1,9 +1,16 @@
import { commitStreamedText } from '../../lib/streamTextCommit';
export const resolveAssistantDisplayText = (input: { export const resolveAssistantDisplayText = (input: {
textContent: string; textContent: string;
throttledTextContent: string; throttledTextContent: string;
isStreaming: boolean; isStreaming: boolean;
}): string => { }): string => {
return input.isStreaming ? input.throttledTextContent : input.textContent; // While streaming, reveal whole blocks only: rendering stops at the last
// complete line so a shown paragraph never mutates in place. The held
// tail lands with the next line break (or the finalize pass).
return input.isStreaming
? commitStreamedText(input.throttledTextContent)
: input.textContent;
}; };
export const shouldRenderAssistantText = (input: { export const shouldRenderAssistantText = (input: {
@@ -56,6 +56,12 @@ export const getToolIcon = (toolName: string) => {
if (tool === 'openchamber') { if (tool === 'openchamber') {
return <Icon name="openchamber" className={iconClass} />; return <Icon name="openchamber" 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} />;
}
if (tool === 'openchamber_web') { if (tool === 'openchamber_web') {
return <Icon name="global" className={iconClass} />; return <Icon name="global" className={iconClass} />;
} }
@@ -35,7 +35,11 @@ export const useMobileAutocompleteMaxHeight = (
// The popup's bottom edge is its anchor (composer top) and does not // The popup's bottom edge is its anchor (composer top) and does not
// depend on its current height. // depend on its current height.
const available = Math.max(0, Math.floor(el.getBoundingClientRect().bottom - boundaryTop - 8)); const available = Math.max(0, Math.floor(el.getBoundingClientRect().bottom - boundaryTop - 8));
const next = available < normalMaxHeight ? available : undefined; // Floor: browser keyboard panning can put the anchor above the
// measured boundary for a frame (or for the whole pan), which
// would collapse the popup to zero height. A short popup that
// slightly overlaps the header beats an invisible one.
const next = available < normalMaxHeight ? Math.max(120, available) : undefined;
setMaxHeight((prev) => (prev === next ? prev : next)); setMaxHeight((prev) => (prev === next ? prev : next));
}; };
measure(); measure();
@@ -0,0 +1,258 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
import { focusChatInput } from '@/components/chat/composer/editor/dom';
import { collectSelectionOverlayRects } from '@/lib/selectionOverlayRects';
import { InlineCommentInput } from './InlineCommentInput';
interface FilePreviewCommentMenuProps {
containerRef: React.RefObject<HTMLElement | null>;
filePath: string;
/** Raw file source, used to locate the selected fragment's line range. */
fileContent: string;
}
/**
* Comment-on-selection for read-only file previews (rendered markdown and the
* like). Selecting text shows a small Comment pill above the selection;
* choosing it opens the shared comment input, and attaching stores a
* file-quote context draft carrying the selected fragment (with a best-effort
* source line range) plus the user's comment.
*/
export function FilePreviewCommentMenu({ containerRef, filePath, fileContent }: FilePreviewCommentMenuProps) {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
const effectiveDirectory = useEffectiveDirectory();
const addDraft = useInlineCommentDraftStore((state) => state.addDraft);
const [anchor, setAnchor] = React.useState<{ x: number; y: number } | null>(null);
const [selectedText, setSelectedText] = React.useState('');
const [commentMode, setCommentMode] = React.useState(false);
const commentModeRef = React.useRef(false);
const menuRef = React.useRef<HTMLDivElement>(null);
const selectionRangeRef = React.useRef<Range | null>(null);
// The selected fragment stays visibly highlighted while the comment input
// owns focus, same as chat quote comments.
const [highlightRects, setHighlightRects] = React.useState<DOMRect[] | null>(null);
const hide = React.useCallback(() => {
setAnchor(null);
setSelectedText('');
setCommentMode(false);
commentModeRef.current = false;
selectionRangeRef.current = null;
setHighlightRects(null);
}, []);
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleSelectionChange = () => {
if (commentModeRef.current) return;
const selection = window.getSelection();
const text = selection?.toString().trim() ?? '';
if (!selection || !text || selection.rangeCount === 0) {
hide();
return;
}
const range = selection.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) {
hide();
return;
}
const rect = range.getBoundingClientRect();
selectionRangeRef.current = range.cloneRange();
setSelectedText(text);
setAnchor({ x: rect.left + rect.width / 2, y: rect.top - 10 });
};
const handlePointerDown = (event: PointerEvent) => {
// SAFETY: a pointer event target inside the document is always a Node;
// `contains` only needs that.
if (menuRef.current?.contains(event.target as Node)) return;
if (commentModeRef.current) {
hide();
}
};
document.addEventListener('selectionchange', handleSelectionChange);
document.addEventListener('pointerdown', handlePointerDown);
return () => {
document.removeEventListener('selectionchange', handleSelectionChange);
document.removeEventListener('pointerdown', handlePointerDown);
};
}, [containerRef, hide]);
/**
* Best-effort mapping of the rendered-text selection back to source lines:
* an exact match of the fragment (or its first line) in the raw content
* yields a range; markdown syntax usually breaks the match, in which case
* the draft simply carries no line numbers.
*/
const resolveLineRange = React.useCallback((fragment: string): { start: number; end: number } | null => {
const lineAt = (index: number): number => fileContent.slice(0, index).split('\n').length;
// Inline markdown syntax (emphasis markers and such) breaks long matches,
// so each anchor line is tried at decreasing lengths.
const candidatesFor = (line: string): string[] => {
const trimmed = line.trim();
return [trimmed, trimmed.slice(0, 32), trimmed.split(' ').slice(0, 4).join(' ')]
.filter((candidate) => candidate.length >= 8);
};
const locate = (line: string, from: number): number => {
for (const candidate of candidatesFor(line)) {
const index = fileContent.indexOf(candidate, from);
if (index >= 0) return index;
}
return -1;
};
const exact = fragment.length >= 8 ? fileContent.indexOf(fragment) : -1;
if (exact >= 0) {
const start = lineAt(exact);
return { start, end: start + fragment.split('\n').length - 1 };
}
const fragmentLines = fragment.split('\n').map((line) => line.trim()).filter(Boolean);
if (fragmentLines.length === 0) return null;
const startIndex = locate(fragmentLines[0], 0);
if (startIndex < 0) return null;
const start = lineAt(startIndex);
if (fragmentLines.length === 1) return { start, end: start };
const endIndex = locate(fragmentLines[fragmentLines.length - 1], startIndex);
// A partially located multi-line fragment gets no range rather than a
// misleading single-line one.
if (endIndex < 0) return null;
return { start, end: Math.max(start, lineAt(endIndex)) };
}, [fileContent]);
const updateHighlightRects = React.useCallback(() => {
const range = selectionRangeRef.current;
if (!range) {
setHighlightRects(null);
return;
}
setHighlightRects(collectSelectionOverlayRects(range));
}, []);
React.useEffect(() => {
if (!commentMode) return;
let frame: number | null = null;
const scheduleUpdate = () => {
if (frame !== null) return;
frame = window.requestAnimationFrame(() => {
frame = null;
updateHighlightRects();
});
};
document.addEventListener('scroll', scheduleUpdate, { capture: true, passive: true });
window.addEventListener('resize', scheduleUpdate);
return () => {
if (frame !== null) window.cancelAnimationFrame(frame);
document.removeEventListener('scroll', scheduleUpdate, { capture: true });
window.removeEventListener('resize', scheduleUpdate);
};
}, [commentMode, updateHighlightRects]);
const openComment = React.useCallback(() => {
if (!selectedText) return;
setCommentMode(true);
commentModeRef.current = true;
updateHighlightRects();
window.getSelection()?.removeAllRanges();
}, [selectedText, updateHighlightRects]);
const saveComment = React.useCallback((text: string) => {
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
if (!selectedText || !sessionKey || !effectiveDirectory) {
hide();
return;
}
const lineRange = resolveLineRange(selectedText);
addDraft({ directory: effectiveDirectory, sessionKey }, {
source: 'file-quote',
fileLabel: filePath,
startLine: lineRange?.start ?? 0,
endLine: lineRange?.end ?? 0,
code: selectedText,
language: '',
text: text.trim(),
});
hide();
queueMicrotask(() => {
focusChatInput();
});
}, [addDraft, currentSessionId, effectiveDirectory, filePath, hide, newSessionDraftOpen, resolveLineRange, selectedText]);
if (!anchor) return null;
const lineRange = commentMode ? resolveLineRange(selectedText) : null;
return createPortal(
<div
ref={menuRef}
className="fixed z-50"
style={{
left: anchor.x,
top: anchor.y,
transform: 'translate(-50%, -100%)',
}}
>
{commentMode && highlightRects && highlightRects.length > 0
? createPortal(
<div className="pointer-events-none fixed inset-0 z-40">
{highlightRects.map((rect, index) => (
<div
key={index}
className="oc-chat-comment-rect absolute"
style={{ left: rect.left, top: rect.top, width: rect.width, height: rect.height }}
/>
))}
</div>,
document.body,
)
: null}
{commentMode ? (
<div className="w-[min(420px,80vw)]">
<InlineCommentInput
fileLabel={filePath}
lineRange={lineRange ? { start: lineRange.start, end: lineRange.end } : undefined}
onSave={saveComment}
onCancel={hide}
/>
</div>
) : (
<div
className={cn(
'flex items-center whitespace-nowrap',
'oc-glass-popover rounded-full border border-[var(--interactive-border)]',
'shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]',
'p-1',
)}
>
<button
type="button"
onClick={openComment}
className={cn(
'px-3.5 py-1.5 rounded-full',
'text-sm font-medium',
'text-[var(--surface-foreground)]',
'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150'
)}
title={t('chat.textSelection.title.commentOnSelection')}
>
{t('chat.textSelection.actions.comment')}
</button>
</div>
)}
</div>,
document.body,
);
}
@@ -1,17 +1,9 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore'; import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Icon } from "@/components/icon/Icon"; import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
interface InlineCommentCardProps { interface InlineCommentCardProps {
@@ -22,6 +14,14 @@ interface InlineCommentCardProps {
maxWidth?: number; maxWidth?: number;
} }
const HEADER_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)]';
/**
* A saved inline comment shown under its lines in the diff/editor. Styled to
* match the composer's context preview entries: a muted header band naming
* the file and range with direct edit/remove actions, and the comment text
* below.
*/
export function InlineCommentCard({ export function InlineCommentCard({
draft, draft,
onEdit, onEdit,
@@ -30,115 +30,90 @@ export function InlineCommentCard({
maxWidth, maxWidth,
}: InlineCommentCardProps) { }: InlineCommentCardProps) {
const { t } = useI18n(); const { t } = useI18n();
const themeContext = useOptionalThemeSystem();
const currentTheme = themeContext?.currentTheme;
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); const draftText = draft.text;
const draftText = typeof draft.text === 'string' ? draft.text : '';
// Check if content is long enough to warrant collapsing (rough estimate) // Check if content is long enough to warrant collapsing (rough estimate)
// In a real app we might measure line height, but length check is a good proxy for now
const isLongContent = draftText.length > 150 || draftText.split('\n').length > 3; const isLongContent = draftText.length > 150 || draftText.split('\n').length > 3;
return ( return (
<ContextMenu open={isContextMenuOpen} onOpenChange={setIsContextMenuOpen}> <div
<ContextMenuTrigger className={cn(
render={ 'w-full max-w-[min(100%,calc(var(--oc-context-panel-width,100vw)-var(--oc-editor-gutter-width,0px)))] overflow-hidden rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] shadow-none',
<div className
className={cn( )}
"rounded-lg border shadow-none w-full max-w-[min(100%,calc(var(--oc-context-panel-width,100vw)-var(--oc-editor-gutter-width,0px)))] overflow-hidden transition-all duration-200", style={{
className maxWidth: maxWidth ? `${Math.max(200, Math.floor(maxWidth))}px` : undefined,
)} }}
style={{ data-comment-card="true"
backgroundColor: currentTheme?.colors?.surface?.elevated, >
borderColor: currentTheme?.colors?.interactive?.border, <div
maxWidth: maxWidth ? `${Math.max(200, Math.floor(maxWidth))}px` : undefined, className="flex items-center gap-1.5 px-3 py-1.5"
}} style={{ backgroundColor: 'color-mix(in srgb, var(--surface-mutedForeground) 8%, transparent)' }}
data-comment-card="true"
onContextMenu={(event) => {
event.preventDefault();
setIsContextMenuOpen(true);
}}
/>
}
> >
<div className="flex items-start justify-between gap-2 p-3"> <span className="min-w-0 max-w-[200px] truncate text-xs font-medium text-[var(--surface-foreground)]" title={draft.fileLabel}>
<div className="flex-1 min-w-0"> {draft.fileLabel}
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground mb-1.5"> </span>
<span className="truncate max-w-[200px]" title={draft.fileLabel}> <span className="text-xs text-[var(--surface-mutedForeground)]"></span>
{draft.fileLabel} <span className="min-w-0 flex-1 truncate text-xs text-[var(--surface-mutedForeground)]">
</span> {t('inlineComment.range.lines', { start: draft.startLine, end: draft.endLine })}
<span></span> {draft.side ? ` (${draft.side})` : ''}
<span>{t('inlineComment.range.lines', { start: draft.startLine, end: draft.endLine })}</span> </span>
{draft.side && <span>({draft.side})</span>} <button
type="button"
className={HEADER_ACTION_CLASS}
style={{ minHeight: 0, minWidth: 0 }}
onClick={onEdit}
aria-label={t('inlineComment.actions.editComment')}
title={t('inlineComment.actions.editComment')}
>
<Icon name="pencil" className="h-3 w-3" />
</button>
<button
type="button"
className={HEADER_ACTION_CLASS}
style={{ minHeight: 0, minWidth: 0 }}
onClick={onDelete}
aria-label={t('inlineComment.actions.deleteComment')}
title={t('inlineComment.actions.deleteComment')}
>
<Icon name="close" className="h-3 w-3" />
</button>
</div>
<div className="px-3 py-2">
<Collapsible open={isOpen || !isLongContent} onOpenChange={setIsOpen}>
<div className={cn('whitespace-pre-wrap break-words text-sm leading-relaxed text-[var(--surface-foreground)]', !isOpen && isLongContent && 'line-clamp-3')}>
{draftText}
</div> </div>
<Collapsible open={isOpen || !isLongContent} onOpenChange={setIsOpen}> {isLongContent && (
<div className={cn("text-sm whitespace-pre-wrap break-words leading-relaxed", !isOpen && isLongContent && "line-clamp-3")}> <CollapsibleTrigger asChild>
{draftText} <Button
</div> variant="ghost"
size="sm"
className="mt-1 h-6 w-full justify-start px-0 text-xs text-muted-foreground hover:text-foreground"
>
{isOpen ? (
<>
<Icon name="arrow-up-s" className="mr-1 size-3" />
{t('inlineComment.actions.showLess')}
</>
) : (
<>
<Icon name="arrow-down-s" className="mr-1 size-3" />
{t('inlineComment.actions.showMore')}
</>
)}
</Button>
</CollapsibleTrigger>
)}
{isLongContent && ( <CollapsibleContent>
<CollapsibleTrigger asChild> {/* Used for animation purposes if we want to animate height */}
<Button </CollapsibleContent>
variant="ghost" </Collapsible>
size="sm"
className="h-6 px-0 mt-1 text-xs text-muted-foreground hover:text-foreground w-full justify-start"
>
{isOpen ? (
<>
<Icon name="arrow-up-s" className="size-3 mr-1" />
{t('inlineComment.actions.showLess')}
</>
) : (
<>
<Icon name="arrow-down-s" className="size-3 mr-1" />
{t('inlineComment.actions.showMore')}
</>
)}
</Button>
</CollapsibleTrigger>
)}
<CollapsibleContent>
{/* Used for animation purposes if we want to animate height */}
</CollapsibleContent>
</Collapsible>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 -mr-1 text-muted-foreground hover:text-foreground"
>
<Icon name="more" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>
<Icon name="edit" className="size-4 mr-2" />
{t('inlineComment.actions.editComment')}
</DropdownMenuItem>
<DropdownMenuItem onClick={onDelete} className="text-destructive">
<Icon name="delete-bin" className="size-4 mr-2" />
{t('inlineComment.actions.deleteComment')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</ContextMenuTrigger> </div>
<ContextMenuContent>
<ContextMenuItem onClick={onEdit}>
<Icon name="edit" className="size-4 mr-2" />
{t('inlineComment.actions.editComment')}
</ContextMenuItem>
<ContextMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
<Icon name="delete-bin" className="size-4 mr-2" />
{t('inlineComment.actions.deleteComment')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
); );
} }
@@ -1,8 +1,6 @@
import React, { useRef, useEffect } from 'react'; import React, { useRef, useEffect } from 'react';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Icon } from '@/components/icon/Icon';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
@@ -18,6 +16,12 @@ export interface InlineCommentInputProps {
maxWidth?: number; maxWidth?: number;
} }
/**
* The comment editor shown under selected diff/editor lines. Styled as the
* same pill used by chat quote comments and browser annotations: a rounded
* auto-growing textarea with a round attach button, and a muted context line
* above naming the file and range.
*/
export function InlineCommentInput({ export function InlineCommentInput({
initialText = '', initialText = '',
onTextChange, onTextChange,
@@ -30,15 +34,22 @@ export function InlineCommentInput({
maxWidth, maxWidth,
}: InlineCommentInputProps) { }: InlineCommentInputProps) {
const { t } = useI18n(); const { t } = useI18n();
const themeContext = useOptionalThemeSystem();
const currentTheme = themeContext?.currentTheme;
const { isMobile } = useDeviceInfo(); const { isMobile } = useDeviceInfo();
const [text, setText] = React.useState(initialText); const [text, setText] = React.useState(initialText);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
void isEditing;
const handleTextChange = (value: string) => { const handleTextChange = (value: string) => {
setText(value); setText(value);
onTextChange?.(value); onTextChange?.(value);
resizeTextarea();
};
const resizeTextarea = () => {
const element = textareaRef.current;
if (!element) return;
element.style.height = 'auto';
element.style.height = `${Math.min(element.scrollHeight, 120)}px`;
}; };
// Stable range snapshot to prevent race with selection clearing // Stable range snapshot to prevent race with selection clearing
@@ -62,8 +73,9 @@ export function InlineCommentInput({
useEffect(() => { useEffect(() => {
const textarea = textareaRef.current; const textarea = textareaRef.current;
if (!textarea) return; if (!textarea) return;
resizeTextarea();
const scrollContainer = textarea.closest('.overlay-scrollbar-container') as HTMLElement | null; const scrollContainer = textarea.closest<HTMLElement>('.overlay-scrollbar-container');
const prevScrollTop = scrollContainer?.scrollTop ?? window.scrollY; const prevScrollTop = scrollContainer?.scrollTop ?? window.scrollY;
const prevScrollLeft = scrollContainer?.scrollLeft ?? window.scrollX; const prevScrollLeft = scrollContainer?.scrollLeft ?? window.scrollX;
@@ -100,12 +112,18 @@ export function InlineCommentInput({
}); });
}, [isMobile]); }, [isMobile]);
const save = () => {
if (text.trim()) {
onSave(text, normalizeRange(stableRangeRef.current));
}
};
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
// As the placeholder promises: Cmd/Ctrl+Enter attaches, plain Enter
// breaks the line, Escape cancels.
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
if (text.trim()) { save();
onSave(text, normalizeRange(stableRangeRef.current));
}
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
onCancel(); onCancel();
@@ -115,75 +133,61 @@ export function InlineCommentInput({
const handleSaveClick = (e: React.MouseEvent | React.TouchEvent | React.PointerEvent) => { const handleSaveClick = (e: React.MouseEvent | React.TouchEvent | React.PointerEvent) => {
// Stop propagation to prevent parent selection clearing before save // Stop propagation to prevent parent selection clearing before save
e.stopPropagation(); e.stopPropagation();
if (text.trim()) { save();
onSave(text, normalizeRange(stableRangeRef.current));
}
}; };
return ( return (
<div <div
className={cn( className={cn(
"rounded-lg border shadow-none w-full max-w-[min(100%,calc(var(--oc-context-panel-width,100vw)-var(--oc-editor-gutter-width,0px)))] overflow-hidden animate-in fade-in zoom-in-95 duration-200", 'w-full max-w-[min(100%,calc(var(--oc-context-panel-width,100vw)-var(--oc-editor-gutter-width,0px)))] animate-in fade-in zoom-in-95 duration-200',
className className
)} )}
style={{ style={{
backgroundColor: currentTheme?.colors?.surface?.elevated,
borderColor: currentTheme?.colors?.interactive?.border,
maxWidth: maxWidth ? `${Math.max(200, Math.floor(maxWidth))}px` : undefined, maxWidth: maxWidth ? `${Math.max(200, Math.floor(maxWidth))}px` : undefined,
}} }}
data-comment-input="true" data-comment-input="true"
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()} onTouchStart={(e) => e.stopPropagation()}
> >
<div className="p-3"> <div className="oc-glass-popover rounded-xl border border-[var(--interactive-border)] shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]">
{(fileLabel || lineRange) && ( {(fileLabel || displayRange) ? (
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground mb-2"> <div className="flex items-center gap-2 px-3 pt-2 text-xs font-medium text-[var(--surface-mutedForeground)] opacity-60">
{fileLabel && <span className="truncate max-w-[200px]">{fileLabel}</span>} {fileLabel ? <span className="max-w-[200px] truncate">{fileLabel}</span> : null}
{fileLabel && lineRange && <span></span>} {fileLabel && displayRange ? <span></span> : null}
{displayRange && ( {displayRange ? (
<span> <span>{t('inlineComment.range.lines', { start: displayRange.start, end: displayRange.end })}</span>
{t('inlineComment.range.lines', { start: displayRange.start, end: displayRange.end })} ) : null}
</span>
)}
</div> </div>
)} ) : null}
<div className="flex items-end gap-2 py-1 pl-3 pr-1">
<Textarea <textarea
simple
ref={textareaRef} ref={textareaRef}
rows={1}
value={text} value={text}
onChange={(e) => handleTextChange(e.target.value)} onChange={(e) => handleTextChange(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={isMobile ? t('inlineComment.input.placeholderShort') : t('inlineComment.input.placeholder')} placeholder={isMobile ? t('inlineComment.input.placeholderShort') : t('inlineComment.input.placeholder')}
outerClassName="rounded-[var(--radius-xl)] bg-[var(--surface-subtle)] ring-1 ring-inset ring-border/60 focus-within:ring-2 focus-within:ring-[var(--interactive-focus-ring)]" className={cn(
className="min-h-[80px] px-3 py-2.5 text-sm resize-y" 'min-w-0 flex-1 resize-none bg-transparent text-sm leading-5 text-[var(--surface-foreground)] outline-none placeholder:text-[var(--surface-mutedForeground)] placeholder:opacity-60',
isMobile ? 'py-1.5 text-base leading-6' : 'py-1.5'
)}
style={{ minHeight: 0, height: 'auto' }}
/> />
<button
<div className="flex items-center justify-end gap-2 mt-3"> type="button"
<Button onClick={handleSaveClick}
variant="ghost" onPointerDown={(e) => e.stopPropagation()}
size="sm" onTouchStart={(e) => e.stopPropagation()}
onClick={onCancel} disabled={!text.trim()}
onPointerDown={(e) => e.stopPropagation()} className={cn(
onTouchStart={(e) => e.stopPropagation()} 'mb-0.5 flex shrink-0 items-center justify-center rounded-full bg-[var(--primary-base)] text-[var(--primary-foreground)] transition-opacity duration-150 hover:opacity-90 disabled:opacity-40',
className="h-8 text-muted-foreground hover:text-foreground" isMobile ? 'h-9 w-9' : 'h-8 w-8'
> )}
{t('inlineComment.actions.cancel')} aria-label={t('inlineComment.actions.comment')}
</Button> title={t('inlineComment.actions.comment')}
<Button >
size="sm" <Icon name="attachment-2" className="h-4 w-4" />
onClick={handleSaveClick} </button>
onPointerDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
disabled={!text.trim()}
className="h-8 min-w-[80px]"
style={{
backgroundColor: currentTheme?.colors?.status?.success,
color: currentTheme?.colors?.status?.successForeground,
}}
>
{isEditing ? t('inlineComment.actions.save') : t('inlineComment.actions.comment')}
</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -4,3 +4,4 @@ export * from './useInlineCommentController';
export * from './CodeMirrorCommentWidgets'; export * from './CodeMirrorCommentWidgets';
export * from './PierreDiffCommentUtils'; export * from './PierreDiffCommentUtils';
export * from './PierreDiffCommentOverlays'; export * from './PierreDiffCommentOverlays';
export * from './FilePreviewCommentMenu';
@@ -2,6 +2,8 @@
// Do not edit manually. Run the script to update. // Do not edit manually. Run the script to update.
export const iconSpriteData = { export const iconSpriteData = {
"linear": `<g transform="translate(1.5 1.5) scale(0.21)"><path fill="currentColor" d="M1.22541 61.5228c-.2225-.9485.90748-1.5459 1.59638-.857L39.3342 97.1782c.6889.6889.0915 1.8189-.857 1.5964C20.0515 94.4522 5.54779 79.9485 1.22541 61.5228ZM.00189135 46.8891c-.01764375.2833.08887215.5599.28957165.7606L52.3503 99.7085c.2007.2007.4773.3075.7606.2896 2.3692-.1476 4.6938-.46 6.9624-.9259.7645-.157 1.0301-1.0963.4782-1.6481L2.57595 39.4485c-.55186-.5519-1.49117-.2863-1.648174.4782-.465915 2.2686-.77832 4.5932-.92588465 6.9624ZM4.21093 29.7054c-.16649.3738-.08169.8106.20765 1.1l64.77602 64.776c.2894.2894.7262.3742 1.1.2077 1.7861-.7956 3.5171-1.6927 5.1855-2.684.5521-.328.6373-1.0867.1832-1.5407L8.43566 24.3367c-.45409-.4541-1.21271-.3689-1.54074.1832-.99132 1.6686-1.88843 3.3994-2.68399 5.1855ZM12.6587 18.074c-.3701-.3701-.393-.9637-.0443-1.3541C21.7795 6.45931 35.1114 0 49.9519 0 77.5927 0 100 22.4073 100 50.0481c0 14.8405-6.4593 28.1724-16.7199 37.3375-.3903.3487-.984.3258-1.3542-.0443L12.6587 18.074Z"/></g>`,
"cloudflare": `<g transform="translate(0.2 0.2) scale(0.18)"><path fill="currentColor" d="M87.295 89.022c.763-2.617.472-5.015-.8-6.796-1.163-1.635-3.125-2.58-5.488-2.689l-44.737-.581c-.291 0-.545-.145-.691-.363s-.182-.509-.109-.8c.145-.436.581-.763 1.054-.8l45.137-.581c5.342-.254 11.157-4.579 13.192-9.885l2.58-6.723c.109-.291.145-.581.073-.872-2.906-13.158-14.644-22.97-28.672-22.97-12.938 0-23.913 8.359-27.838 19.952a13.35 13.35 0 0 0-9.267-2.58c-6.215.618-11.193 5.597-11.811 11.811-.145 1.599-.036 3.162.327 4.615C10.104 70.051 2 78.337 2 88.549c0 .909.073 1.817.182 2.726a.895.895 0 0 0 .872.763h82.57c.472 0 .909-.327 1.054-.8l.617-2.216z"/><path fill="currentColor" d="M101.542 60.275c-.4 0-.836 0-1.236.036-.291 0-.545.218-.654.509l-1.744 6.069c-.763 2.617-.472 5.015.8 6.796 1.163 1.635 3.125 2.58 5.488 2.689l9.522.581c.291 0 .545.145.691.363.145.218.182.545.109.8-.145.436-.581.763-1.054.8l-9.924.582c-5.379.254-11.157 4.579-13.192 9.885l-.727 1.853c-.145.363.109.727.509.727h34.089c.4 0 .763-.254.872-.654.581-2.108.909-4.325.909-6.614 0-13.447-10.975-24.422-24.458-24.422"/></g>`,
"add": `<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z" fill="currentColor"/>`, "add": `<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z" fill="currentColor"/>`,
"add-circle": `<path d="M11 11V7H13V11H17V13H13V17H11V13H7V11H11ZM12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20Z" fill="currentColor"/>`, "add-circle": `<path d="M11 11V7H13V11H17V13H13V17H11V13H7V11H11ZM12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20Z" fill="currentColor"/>`,
"ai-agent": `<path d="M12 2C17.5228 2 22 6.47715 22 12C22 14.7096 20.9205 17.1697 19.1709 18.9697C17.3551 20.8376 14.8124 22 12 22C9.18756 22 6.64488 20.8376 4.8291 18.9697C3.07949 17.1697 2 14.7096 2 12C2 6.47715 6.47715 2 12 2ZM12 16C10.0022 16 8.20124 16.8375 6.9248 18.1816C8.30642 19.3175 10.0724 20 12 20C13.9274 20 15.6927 19.3173 17.0742 18.1816C15.7978 16.8377 13.9975 16 12 16ZM12 4C7.58172 4 4 7.58172 4 12C4 13.7701 4.57462 15.4044 5.54785 16.7295C7.1822 15.0483 9.46797 14 12 14C14.5318 14 16.8169 15.0485 18.4512 16.7295C19.4246 15.4043 20 13.7703 20 12C20 7.58172 16.4183 4 12 4ZM11.5293 5.31934C11.7058 4.89329 12.2943 4.89329 12.4707 5.31934L12.7236 5.93066C13.1556 6.97343 13.9615 7.80622 14.9746 8.25684L15.6924 8.5752C16.1029 8.75796 16.1028 9.35627 15.6924 9.53906L14.9326 9.87695C13.9448 10.3163 13.1534 11.1193 12.7139 12.1279L12.4668 12.6934C12.2864 13.1074 11.7137 13.1074 11.5332 12.6934L11.2871 12.1279C10.8476 11.1193 10.0552 10.3163 9.06738 9.87695L8.30762 9.53906C7.89719 9.35628 7.89717 8.75795 8.30762 8.5752L9.02539 8.25684C10.0385 7.80623 10.8445 6.97345 11.2764 5.93066L11.5293 5.31934Z" fill="currentColor"/>`, "ai-agent": `<path d="M12 2C17.5228 2 22 6.47715 22 12C22 14.7096 20.9205 17.1697 19.1709 18.9697C17.3551 20.8376 14.8124 22 12 22C9.18756 22 6.64488 20.8376 4.8291 18.9697C3.07949 17.1697 2 14.7096 2 12C2 6.47715 6.47715 2 12 2ZM12 16C10.0022 16 8.20124 16.8375 6.9248 18.1816C8.30642 19.3175 10.0724 20 12 20C13.9274 20 15.6927 19.3173 17.0742 18.1816C15.7978 16.8377 13.9975 16 12 16ZM12 4C7.58172 4 4 7.58172 4 12C4 13.7701 4.57462 15.4044 5.54785 16.7295C7.1822 15.0483 9.46797 14 12 14C14.5318 14 16.8169 15.0485 18.4512 16.7295C19.4246 15.4043 20 13.7703 20 12C20 7.58172 16.4183 4 12 4ZM11.5293 5.31934C11.7058 4.89329 12.2943 4.89329 12.4707 5.31934L12.7236 5.93066C13.1556 6.97343 13.9615 7.80622 14.9746 8.25684L15.6924 8.5752C16.1029 8.75796 16.1028 9.35627 15.6924 9.53906L14.9326 9.87695C13.9448 10.3163 13.1534 11.1193 12.7139 12.1279L12.4668 12.6934C12.2864 13.1074 11.7137 13.1074 11.5332 12.6934L11.2871 12.1279C10.8476 11.1193 10.0552 10.3163 9.06738 9.87695L8.30762 9.53906C7.89719 9.35628 7.89717 8.75795 8.30762 8.5752L9.02539 8.25684C10.0385 7.80623 10.8445 6.97345 11.2764 5.93066L11.5293 5.31934Z" fill="currentColor"/>`,
File diff suppressed because it is too large Load Diff
+72 -401
View File
@@ -1,10 +1,8 @@
import React, { useRef, useEffect } from 'react'; import React from 'react';
import { animate, motion, useMotionValue } from 'motion/react';
import { Header } from './Header'; import { Header } from './Header';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { SidebarTopBar } from './SidebarTopBar'; import { SidebarTopBar } from './SidebarTopBar';
import { TitlebarLeftControls } from './TitlebarLeftControls'; import { TitlebarLeftControls } from './TitlebarLeftControls';
import { ProjectContextPanel } from './RightSidebarTabs';
import { ContextPanel } from './ContextPanel'; import { ContextPanel } from './ContextPanel';
import { ContextPanelRail } from './ContextPanelRail'; import { ContextPanelRail } from './ContextPanelRail';
import { ErrorBoundary } from '../ui/ErrorBoundary'; import { ErrorBoundary } from '../ui/ErrorBoundary';
@@ -18,8 +16,6 @@ import { ArchiveView } from '@/components/views/ArchiveView';
import { WorktreesView } from '@/components/views/WorktreesView'; import { WorktreesView } from '@/components/views/WorktreesView';
import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider'; import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider';
import { MultiRunLauncher } from '@/components/multirun'; import { MultiRunLauncher } from '@/components/multirun';
import { TerminalView } from '@/components/views/TerminalView';
import { DrawerProvider } from '@/contexts/DrawerContext';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -27,27 +23,22 @@ import { useUpdatePolling } from '@/hooks/useUpdatePolling';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import { useSessionListSync } from '@/components/session/sidebar/list/useSessionListSync';
import { ChatView } from '@/components/views/ChatView'; import { ChatView } from '@/components/views/ChatView';
// Keep TerminalView eager: the bottom dock reserves its height immediately, so
// suspending here leaves a large blank panel on slower machines.
// Other heavy views stay on-demand to reduce initial bundle parse time:
// DiffView/FilesView pull the CodeMirror and @pierre/diffs stacks into the
// startup graph when imported statically.
const PlanView = lazyWithChunkRecovery(() => import('@/components/views/PlanView').then(m => ({ default: m.PlanView })));
const GitView = lazyWithChunkRecovery(() => import('@/components/views/GitView').then(m => ({ default: m.GitView })));
const DiffView = lazyWithChunkRecovery(() => import('@/components/views/DiffView').then(m => ({ default: m.DiffView })));
const FilesView = lazyWithChunkRecovery(() => import('@/components/views/FilesView').then(m => ({ default: m.FilesView })));
const DiagramView = lazyWithChunkRecovery(() => import('@/components/views/DiagramView').then(m => ({ default: m.DiagramView })));
const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow }))); const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow })));
/**
* Desktop-surface layout: the chat owns the main area, and every other
* surface (git, diff, files, terminal, ...) opens in the ContextPanel via the
* rail. Phone-sized viewports run the separate MobileApp shell a viewport
* crossing the threshold reloads into it (see watchHostedSurfaceViewport).
*/
export const MainLayout: React.FC = () => { export const MainLayout: React.FC = () => {
useSessionListSync({ isVSCode: false });
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen); const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
const activeSurface = useUIStore((state) => state.activeSurface);
const setIsMobile = useUIStore((state) => state.setIsMobile); const setIsMobile = useUIStore((state) => state.setIsMobile);
const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen);
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen); const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
// Mount the windowed settings dialog only after its first open: rendering // Mount the windowed settings dialog only after its first open: rendering
@@ -67,10 +58,9 @@ export const MainLayout: React.FC = () => {
const isScheduledTasksPageOpen = useUIStore((state) => state.isScheduledTasksDialogOpen); const isScheduledTasksPageOpen = useUIStore((state) => state.isScheduledTasksDialogOpen);
const isArchivePageOpen = useUIStore((state) => state.isArchivePageOpen); const isArchivePageOpen = useUIStore((state) => state.isArchivePageOpen);
const worktreesPageProjectId = useUIStore((state) => state.worktreesPageProjectId); const worktreesPageProjectId = useUIStore((state) => state.worktreesPageProjectId);
// Any full-page surface replacing the chat area. While open, the chat and // Any full-page surface replacing the chat area. While open, the chat is
// secondary views are fully hidden (not just covered) so none of their // fully hidden (not just covered) so none of its floating chrome bleeds
// floating chrome bleeds through, and selecting a session / draft / main // through, and selecting a session or draft anywhere closes the surface.
// tab anywhere closes the surface.
const isSurfacePageOpen = isScheduledTasksPageOpen || isArchivePageOpen || Boolean(worktreesPageProjectId) || isMultiRunLauncherOpen; const isSurfacePageOpen = isScheduledTasksPageOpen || isArchivePageOpen || Boolean(worktreesPageProjectId) || isMultiRunLauncherOpen;
React.useEffect(() => { React.useEffect(() => {
@@ -82,166 +72,11 @@ export const MainLayout: React.FC = () => {
const draftOpened = Boolean(state.newSessionDraft?.open) && state.newSessionDraft !== prev.newSessionDraft; const draftOpened = Boolean(state.newSessionDraft?.open) && state.newSessionDraft !== prev.newSessionDraft;
if (sessionSelected || draftOpened) closeSurfacePages(); if (sessionSelected || draftOpened) closeSurfacePages();
}); });
const unsubscribeTab = useUIStore.subscribe((state, prev) => {
if (state.activeSurface !== prev.activeSurface) closeSurfacePages();
});
return () => { return () => {
unsubscribeSession(); unsubscribeSession();
unsubscribeTab();
}; };
}, []); }, []);
const { isMobile } = useDeviceInfo(); const { isMobile } = useDeviceInfo();
const mobilePanelsResetRef = React.useRef(false);
// Mobile drawer state
const [mobileLeftDrawerOpen, setMobileLeftDrawerOpen] = React.useState(false);
const [mobileRightSidebarOpen, setMobileRightSidebarOpen] = React.useState(false);
const [mobileLeftDrawerVisible, setMobileLeftDrawerVisible] = React.useState(false);
const [mobileRightDrawerVisible, setMobileRightDrawerVisible] = React.useState(false);
const setMobileSessionPanelOpen = React.useCallback((open: boolean) => {
setMobileLeftDrawerOpen(open);
useUIStore.getState().setSessionSwitcherOpen(open);
}, []);
const initialDrawerWidthRef = React.useRef(typeof window === 'undefined' ? 0 : window.innerWidth);
// Left drawer motion value
const leftDrawerX = useMotionValue(-initialDrawerWidthRef.current);
const leftDrawerWidth = useRef(0);
// Right drawer motion value
const rightDrawerX = useMotionValue(initialDrawerWidthRef.current);
const rightDrawerWidth = useRef(0);
// Compute drawer width
useEffect(() => {
if (isMobile) {
leftDrawerWidth.current = window.innerWidth;
rightDrawerWidth.current = window.innerWidth;
}
}, [isMobile]);
// Sync left drawer state and motion value
useEffect(() => {
if (!isMobile) {
setMobileLeftDrawerVisible(false);
return;
}
if (mobileLeftDrawerOpen) {
setMobileLeftDrawerVisible(true);
}
animate(leftDrawerX, mobileLeftDrawerOpen ? 0 : -leftDrawerWidth.current, {
type: 'spring',
stiffness: 400,
damping: 35,
mass: 0.8,
});
}, [mobileLeftDrawerOpen, isMobile, leftDrawerX]);
// Sync right drawer state and motion value
useEffect(() => {
if (!isMobile) {
setMobileRightDrawerVisible(false);
return;
}
if (mobileRightSidebarOpen) {
setMobileRightDrawerVisible(true);
}
animate(rightDrawerX, mobileRightSidebarOpen ? 0 : rightDrawerWidth.current, {
type: 'spring',
stiffness: 400,
damping: 35,
mass: 0.8,
});
}, [isMobile, mobileRightSidebarOpen, rightDrawerX]);
useEffect(() => {
if (!isMobile) return;
return leftDrawerX.on('change', (value) => {
const width = leftDrawerWidth.current || initialDrawerWidthRef.current;
const visible = mobileLeftDrawerOpen || value > -width + 0.5;
setMobileLeftDrawerVisible((previous) => previous === visible ? previous : visible);
});
}, [isMobile, leftDrawerX, mobileLeftDrawerOpen]);
useEffect(() => {
if (!isMobile) return;
return rightDrawerX.on('change', (value) => {
const width = rightDrawerWidth.current || initialDrawerWidthRef.current;
const visible = mobileRightSidebarOpen || value < width - 0.5;
setMobileRightDrawerVisible((previous) => previous === visible ? previous : visible);
});
}, [isMobile, mobileRightSidebarOpen, rightDrawerX]);
// Sync session switcher close events to left drawer.
useEffect(() => {
if (isMobile && !isSessionSwitcherOpen && mobileLeftDrawerOpen) {
setMobileSessionPanelOpen(false);
}
}, [isSessionSwitcherOpen, isMobile, mobileLeftDrawerOpen, setMobileSessionPanelOpen]);
useEffect(() => {
if (!isMobile) {
mobilePanelsResetRef.current = false;
return;
}
if (mobilePanelsResetRef.current) {
return;
}
mobilePanelsResetRef.current = true;
setMobileSessionPanelOpen(false);
setMobileRightSidebarOpen(false);
}, [isMobile, setMobileSessionPanelOpen]);
useEffect(() => {
if (!isMobile || activeSurface !== 'chat' || mobileLeftDrawerOpen || mobileRightSidebarOpen || isSettingsDialogOpen) {
return;
}
let disposed = false;
let timeoutId: number | undefined;
const scheduleDraftOpen = (delayMs: number) => {
timeoutId = window.setTimeout(() => {
if (disposed) {
return;
}
const sessionState = useSessionUIStore.getState();
const uiState = useUIStore.getState();
if (uiState.activeMainTab !== 'chat' || uiState.isSettingsDialogOpen || sessionState.currentSessionId || sessionState.newSessionDraft?.open) {
return;
}
if (sessionState.isLoading) {
scheduleDraftOpen(250);
return;
}
sessionState.openNewSessionDraft({ automatic: true });
}, delayMs);
};
scheduleDraftOpen(500);
return () => {
disposed = true;
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
};
}, [activeSurface, isMobile, isSettingsDialogOpen, mobileLeftDrawerOpen, mobileRightSidebarOpen]);
// Ensure mobile drawers are closed when opening full-screen settings
useEffect(() => {
if (!isMobile || !isSettingsDialogOpen) {
return;
}
setMobileSessionPanelOpen(false);
setMobileRightSidebarOpen(false);
}, [isMobile, isSettingsDialogOpen, setMobileSessionPanelOpen]);
useUpdatePolling(); useUpdatePolling();
@@ -252,247 +87,83 @@ export const MainLayout: React.FC = () => {
} }
}, [isMobile, setIsMobile]); }, [isMobile, setIsMobile]);
const handleToggleMobileRightDrawer = React.useCallback(() => {
if (mobileLeftDrawerOpen) {
setMobileSessionPanelOpen(false);
}
setMobileRightSidebarOpen(!mobileRightSidebarOpen);
}, [mobileLeftDrawerOpen, mobileRightSidebarOpen, setMobileSessionPanelOpen]);
const secondaryView = React.useMemo(() => {
// Desktop surfaces live in the context panel; the only full-view
// overlays left there are the terminal (promoted by project actions)
// and the diagram viewer. Mobile keeps the full tab set.
if (!isMobile && activeSurface !== 'terminal' && activeSurface !== 'diagram') {
return null;
}
switch (activeSurface) {
case 'plan':
return <React.Suspense fallback={null}><PlanView /></React.Suspense>;
case 'git':
return <React.Suspense fallback={null}><GitView isActive={!mobileRightSidebarOpen} /></React.Suspense>;
case 'diff':
return <React.Suspense fallback={null}><DiffView /></React.Suspense>;
case 'terminal':
return <TerminalView />;
case 'files':
return <React.Suspense fallback={null}><FilesView /></React.Suspense>;
case 'context':
return <React.Suspense fallback={null}><ProjectContextPanel /></React.Suspense>;
case 'diagram':
return <React.Suspense fallback={null}><DiagramView /></React.Suspense>;
default:
return null;
}
}, [activeSurface, isMobile, mobileRightSidebarOpen]);
const isChatActive = activeSurface === 'chat';
return ( return (
<DiffWorkerProvider> <DiffWorkerProvider>
<div <div
data-page-scroll-lock="true" data-page-scroll-lock="true"
className={cn( className="main-content-safe-area relative flex h-[100dvh] bg-background"
'main-content-safe-area',
isMobile ? 'flex h-[100dvh] flex-col' : 'relative flex h-[100dvh]',
'bg-background'
)}
> >
<CommandPalette /> <CommandPalette />
<HelpDialog /> <HelpDialog />
<OpenCodeStatusDialog /> <OpenCodeStatusDialog />
<SessionDialogs /> <SessionDialogs />
{isMobile ? ( {/* Persistent top-left controls (toggle + project actions) that
<DrawerProvider value={{ stay put while the sidebar/header animate beneath them. */}
leftDrawerOpen: mobileLeftDrawerOpen, <TitlebarLeftControls />
rightDrawerOpen: mobileRightSidebarOpen, {/* Full-height Sidebar beside [Header above (chat | RightSidebar)] */}
toggleLeftDrawer: () => { <div className="flex flex-1 overflow-hidden" data-page-scroll-lock="true">
const nextOpen = !mobileLeftDrawerOpen; <Sidebar
if (mobileRightSidebarOpen) { isOpen={isSidebarOpen}
setMobileRightSidebarOpen(false); isMobile={isMobile}
} className="border-border"
setMobileSessionPanelOpen(nextOpen); topBar={<SidebarTopBar />}
},
toggleRightDrawer: handleToggleMobileRightDrawer,
leftDrawerX,
rightDrawerX,
leftDrawerWidth,
rightDrawerWidth,
setMobileLeftDrawerOpen: setMobileSessionPanelOpen,
setRightSidebarOpen: setMobileRightSidebarOpen,
}}>
{/* Mobile: header + drawer mode */}
{!isSettingsDialogOpen && <Header
onToggleLeftDrawer={() => {
const nextOpen = !mobileLeftDrawerOpen;
if (mobileRightSidebarOpen) {
setMobileRightSidebarOpen(false);
}
setMobileSessionPanelOpen(nextOpen);
}}
onToggleRightDrawer={() => {
handleToggleMobileRightDrawer();
}}
leftDrawerOpen={mobileLeftDrawerOpen}
rightDrawerOpen={mobileRightSidebarOpen}
/>}
{/* Main content area (fixed) */}
<div
data-page-scroll-lock="true"
className={cn(
'flex flex-1 overflow-hidden relative',
isSettingsDialogOpen && 'hidden'
)}
> >
<main className="w-full h-full overflow-hidden bg-background relative" data-page-scroll-lock="true"> <SessionSidebar isVisible={isSidebarOpen} />
<div className={cn('absolute inset-0', (!isChatActive || isSurfacePageOpen) && 'invisible')}> </Sidebar>
<ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen && !isSurfacePageOpen} /></ErrorBoundary> <div className="relative flex flex-1 min-w-0 flex-col overflow-hidden bg-background" data-page-scroll-lock="true">
</div> <Header />
{secondaryView && ( <div className="relative flex flex-1 min-h-0 overflow-hidden bg-background" data-page-scroll-lock="true">
<div className={cn('absolute inset-0', isSurfacePageOpen && 'invisible')}> <div className="relative flex flex-1 min-w-0 flex-col overflow-hidden border-t border-border bg-background" data-page-scroll-lock="true">
<ErrorBoundary>{secondaryView}</ErrorBoundary> <div className="flex flex-1 min-h-0 overflow-hidden" data-page-scroll-lock="true">
</div> {/* Holds the chat and the context panel together, so its
)} width does not move when the context panel opens. The
{isMultiRunLauncherOpen && ( work-status panel measures this rather than the chat,
<div className="absolute inset-0 z-10 bg-background"> which the context panel animates. */}
<ErrorBoundary> <div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden" data-page-scroll-lock="true" data-chat-area="true">
<MultiRunLauncher <main className="flex-1 overflow-hidden bg-background relative" data-page-scroll-lock="true">
initialPrompt={multiRunLauncherPrefillPrompt} <div className={cn('absolute inset-0', isSurfacePageOpen && 'invisible')}>
onCreated={() => setMultiRunLauncherOpen(false)} <ErrorBoundary><ChatView active={!isSettingsDialogOpen && !isSurfacePageOpen} /></ErrorBoundary>
onCancel={() => setMultiRunLauncherOpen(false)} </div>
/> {isMultiRunLauncherOpen && (
</ErrorBoundary> <div className="absolute inset-0 z-10 bg-background">
</div> <ErrorBoundary>
)} {/* isWindowed: the app Header already shows the surface
<ErrorBoundary><ScheduledTasksDialog /></ErrorBoundary> title, so skip the launcher's own title bar. */}
<ErrorBoundary><ArchiveView /></ErrorBoundary> <MultiRunLauncher
<ErrorBoundary><WorktreesView /></ErrorBoundary> isWindowed
{/* Always mount SessionSidebar on mobile to match desktop behavior. initialPrompt={multiRunLauncherPrefillPrompt}
Conditional mount (mobileLeftDrawerVisible && ...) caused a onCreated={() => setMultiRunLauncherOpen(false)}
data-loading cascade on every drawer open: paginated sessions onCancel={() => setMultiRunLauncherOpen(false)}
fetch, worktree discovery, repo status, PR status, and 10+ memo />
recomputations. On Android PWA this manifested as a >10s delay </ErrorBoundary>
before the drawer became interactive (issue #1695). Visibility is
controlled by the leftDrawerX transform (off-screen when closed).
The invisible class matters when fully hidden: leftDrawerWidth is
not recomputed on resize/rotation, so a closed drawer translated by
the old width could otherwise peek into the viewport; it also keeps
the off-screen sidebar out of the tab order and skips painting it. */}
<motion.div
className={cn(
'absolute inset-0 z-20 bg-sidebar',
!mobileLeftDrawerVisible && 'pointer-events-none invisible',
)}
data-page-scroll-lock="true"
style={{ x: leftDrawerX }}
aria-hidden={!mobileLeftDrawerOpen}
>
<ErrorBoundary>
<SessionSidebar mobileVariant isVisible={mobileLeftDrawerVisible} />
</ErrorBoundary>
</motion.div>
{mobileRightDrawerVisible && (
<motion.div className="absolute inset-0 z-20 bg-sidebar" data-page-scroll-lock="true" style={{ x: rightDrawerX }} aria-hidden={!mobileRightSidebarOpen}>
<ErrorBoundary>
<React.Suspense fallback={null}><GitView isActive={mobileRightSidebarOpen} /></React.Suspense>
</ErrorBoundary>
</motion.div>
)}
</main>
</div>
{/* Mobile settings: full screen */}
{isSettingsDialogOpen && (
<div
className="absolute inset-0 z-10 bg-background"
style={{ paddingTop: 'var(--oc-safe-area-top, 0px)' }}
>
<ErrorBoundary>
<React.Suspense fallback={null}>
<SettingsView onClose={() => setSettingsDialogOpen(false)} />
</React.Suspense>
</ErrorBoundary>
</div>
)}
</DrawerProvider>
) : (
<>
{/* Persistent top-left controls (toggle + project actions) that
stay put while the sidebar/header animate beneath them. */}
<TitlebarLeftControls />
{/* Desktop: full-height Sidebar beside [Header above (chat | RightSidebar)] */}
<div className="flex flex-1 overflow-hidden" data-page-scroll-lock="true">
<Sidebar
isOpen={isSidebarOpen}
isMobile={isMobile}
className="border-border"
topBar={<SidebarTopBar />}
>
<SessionSidebar isVisible={isSidebarOpen} />
</Sidebar>
<div className="relative flex flex-1 min-w-0 flex-col overflow-hidden bg-background" data-page-scroll-lock="true">
<Header />
<div className="relative flex flex-1 min-h-0 overflow-hidden bg-background" data-page-scroll-lock="true">
<div className="relative flex flex-1 min-w-0 flex-col overflow-hidden border-t border-border bg-background" data-page-scroll-lock="true">
<div className="flex flex-1 min-h-0 overflow-hidden" data-page-scroll-lock="true">
{/* Holds the chat and the context panel together, so its
width does not move when the context panel opens. The
work-status panel measures this rather than the chat,
which the context panel animates. */}
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden" data-page-scroll-lock="true" data-chat-area="true">
<main className="flex-1 overflow-hidden bg-background relative" data-page-scroll-lock="true">
<div className={cn('absolute inset-0', (!isChatActive || isSurfacePageOpen) && 'invisible')}>
<ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen && !isSurfacePageOpen} /></ErrorBoundary>
</div> </div>
{secondaryView && ( )}
<div className={cn('absolute inset-0', isSurfacePageOpen && 'invisible')}> <ErrorBoundary><ScheduledTasksDialog /></ErrorBoundary>
<ErrorBoundary>{secondaryView}</ErrorBoundary> <ErrorBoundary><ArchiveView /></ErrorBoundary>
</div> <ErrorBoundary><WorktreesView /></ErrorBoundary>
)} </main>
{isMultiRunLauncherOpen && ( <ContextPanel />
<div className="absolute inset-0 z-10 bg-background">
<ErrorBoundary>
{/* isWindowed: the app Header already shows the surface
title, so skip the launcher's own title bar. */}
<MultiRunLauncher
isWindowed
initialPrompt={multiRunLauncherPrefillPrompt}
onCreated={() => setMultiRunLauncherOpen(false)}
onCancel={() => setMultiRunLauncherOpen(false)}
/>
</ErrorBoundary>
</div>
)}
<ErrorBoundary><ScheduledTasksDialog /></ErrorBoundary>
<ErrorBoundary><ArchiveView /></ErrorBoundary>
<ErrorBoundary><WorktreesView /></ErrorBoundary>
</main>
<ContextPanel />
</div>
</div> </div>
</div> </div>
<div className="border-t border-border" data-page-scroll-lock="true"> </div>
<ErrorBoundary><ContextPanelRail /></ErrorBoundary> <div className="border-t border-border" data-page-scroll-lock="true">
</div> <ErrorBoundary><ContextPanelRail /></ErrorBoundary>
</div> </div>
</div> </div>
</div> </div>
</div>
{/* Desktop settings: windowed dialog with blur */} {/* Settings: windowed dialog with blur */}
{settingsWindowMounted ? ( {settingsWindowMounted ? (
<React.Suspense fallback={null}> <React.Suspense fallback={null}>
<SettingsWindow <SettingsWindow
open={isSettingsDialogOpen} open={isSettingsDialogOpen}
onOpenChange={setSettingsDialogOpen} onOpenChange={setSettingsDialogOpen}
/> />
</React.Suspense> </React.Suspense>
) : null} ) : null}
</> </div>
)} </DiffWorkerProvider>
</div>
</DiffWorkerProvider>
); );
}; };
@@ -0,0 +1,426 @@
import React from 'react';
import {
DndContext,
MouseSensor,
TouchSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
type Modifier,
} from '@dnd-kit/core';
import {
SortableContext,
horizontalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS as DndCSS } from '@dnd-kit/utilities';
import { ContextMenu } from '@base-ui/react/context-menu';
import type { Session } from '@opencode-ai/sdk/v2';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass } from '@/components/ui/dropdown-menu.styles';
import { Icon } from '@/components/icon/Icon';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { useSessionTabsStore } from '@/stores/useSessionTabsStore';
import { closeSessionTabAndActivateNeighbour } from '@/lib/sessionTabs';
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useGlobalSessionStatus } from '@/sync/sync-context';
import { useSessionUnseenCount } from '@/sync/notification-store';
const restrictToXAxis: Modifier = ({ transform }) => ({ ...transform, y: 0 });
type SessionTab = { id: string; session: Session };
export type SessionTabMenuComponents = {
Item: React.ComponentType<{
className?: string;
disabled?: boolean;
onClick?: React.MouseEventHandler;
children?: React.ReactNode;
}>;
Separator: React.ComponentType<{ className?: string }>;
};
export type SessionTabMenuArgs = {
session: Session;
isActive: boolean;
select: () => void;
closeOtherTabs: () => void;
/** Menu primitives for the surface the menu opens in (dropdown or context menu). */
components: SessionTabMenuComponents;
};
const dropdownComponents: SessionTabMenuComponents = {
Item: DropdownMenuItem,
Separator: DropdownMenuSeparator,
};
const contextComponents: SessionTabMenuComponents = {
Item: ({ className, ...props }) => (
<ContextMenu.Item className={cn(dropdownMenuItemClass, className)} {...props} />
),
Separator: ({ className, ...props }) => (
<ContextMenu.Separator className={cn(dropdownMenuSeparatorClass, className)} {...props} />
),
};
/**
* One tab, active or not. The tab drags to reorder; the menu and close
* controls sit in a hover-revealed overlay at the tab's end (menu first,
* close after it). One session menu supplied by the header via
* `renderMenu` backs both the "..." dropdown and the right-click context
* menu, which opens under the cursor without changing the active tab. The
* dropdown's anchor overlay stays mounted through the close animation so the
* popup never flashes detached. While the active tab is renaming, the
* overlay is suppressed entirely only the rename controls show.
*/
const SessionTabItem: React.FC<{
tab: SessionTab;
isActive: boolean;
suppressControls: boolean;
onSelect: (tab: SessionTab) => void;
onClose: (id: string) => void;
renderMenu: (args: SessionTabMenuArgs) => React.ReactNode;
closeOtherTabs: (id: string) => void;
onMenuOpenChangeComplete?: (open: boolean) => void;
children?: React.ReactNode;
}> = ({ tab, isActive, suppressControls, onSelect, onClose, renderMenu, closeOtherTabs, onMenuOpenChangeComplete, children }) => {
const { t } = useI18n();
const [menuOpen, setMenuOpen] = React.useState(false);
// Keeps the overlay (the dropdown's anchor) mounted through the close animation.
const [menuVisible, setMenuVisible] = React.useState(false);
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.id });
const title = tab.session.title?.trim() || t('sessions.sidebar.session.untitled');
const overlayVisible = !suppressControls && (menuOpen || menuVisible);
// Session state for the dot and the hover tooltip.
const sessionStatus = useGlobalSessionStatus(tab.id);
const isStreaming = sessionStatus?.type === 'busy' || sessionStatus?.type === 'retry';
const unseenCount = useSessionUnseenCount(tab.id);
const showUnread = unseenCount > 0 && !isActive && !isStreaming;
const showDot = isStreaming || showUnread;
const dotLabel = isStreaming
? t('sessions.sidebar.session.status.active')
: t('sessions.sidebar.session.status.unread');
const menuArgsFor = (components: SessionTabMenuComponents): SessionTabMenuArgs => ({
session: tab.session,
isActive,
select: () => onSelect(tab),
closeOtherTabs: () => closeOtherTabs(tab.id),
components,
});
return (
<div
ref={setNodeRef}
style={{ transform: DndCSS.Translate.toString(transform), transition }}
className={cn('session-tab-slot flex h-7 w-44 shrink-0 touch-none', isDragging && 'z-10 opacity-60')}
data-active={isActive ? 'true' : 'false'}
{...(isActive ? { 'data-active-session-tab': true } : {})}
{...attributes}
{...listeners}
>
<ContextMenu.Root
open={contextMenuOpen}
onOpenChange={setContextMenuOpen}
onOpenChangeComplete={(open) => onMenuOpenChangeComplete?.(open)}
>
<ContextMenu.Trigger
render={(triggerProps) => (
<div
{...triggerProps}
role="tab"
aria-selected={isActive}
tabIndex={isActive ? undefined : 0}
onClick={isActive ? undefined : () => onSelect(tab)}
onKeyDown={isActive ? undefined : (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelect(tab);
}
}}
onAuxClick={(event) => {
if (event.button === 1) {
event.preventDefault();
onClose(tab.id);
}
}}
data-controls-open={overlayVisible ? 'true' : 'false'}
className={cn(
'session-tab group/session-tab relative flex h-7 w-full min-w-0 select-none items-center rounded-md px-2',
'transition-colors duration-75',
isActive
? 'bg-interactive-selection'
: cn(
'cursor-pointer text-muted-foreground hover:bg-interactive-hover hover:text-foreground',
overlayVisible && 'bg-interactive-hover text-foreground',
),
)}
>
<div className={cn(
'flex min-w-0 flex-1 items-center',
!suppressControls && 'group-hover/session-tab:pr-10',
overlayVisible && 'pr-10',
)}
>
<div className={cn(
'min-w-0 flex-1 overflow-hidden whitespace-nowrap',
!suppressControls && 'session-tab-title',
)}
>
{isActive ? children : (
<span className="text-[13px] font-medium leading-4">{title}</span>
)}
</div>
{showDot ? (
<span
className={cn(
'ml-1.5 h-1.5 w-1.5 shrink-0 rounded-full',
isStreaming ? 'bg-primary' : 'bg-[var(--status-info)]',
!suppressControls && 'group-hover/session-tab:opacity-0',
overlayVisible && 'opacity-0',
)}
aria-label={dotLabel}
/>
) : null}
</div>
{!suppressControls ? (
<div
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
className={cn(
'absolute right-1 top-1/2 hidden -translate-y-1/2 items-center gap-0.5',
'opacity-0 transition-opacity duration-150',
'group-hover/session-tab:flex group-hover/session-tab:opacity-100',
overlayVisible && 'flex opacity-100',
)}
>
<DropdownMenu
open={menuOpen}
onOpenChange={(open) => {
setMenuOpen(open);
if (open) setMenuVisible(true);
}}
onOpenChangeComplete={(open) => {
if (!open) setMenuVisible(false);
onMenuOpenChangeComplete?.(open);
}}
>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={t('header.sessionTabs.tabMenuAria')}
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground"
>
<Icon name="more" className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[190px]">
{renderMenu(menuArgsFor(dropdownComponents))}
</DropdownMenuContent>
</DropdownMenu>
<button
type="button"
aria-label={t('header.sessionTabs.closeTab')}
onClick={() => onClose(tab.id)}
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground"
>
<Icon name="close" className="size-4" />
</button>
</div>
) : null}
</div>
)}
/>
<ContextMenu.Portal>
<ContextMenu.Positioner className="app-region-no-drag z-50">
<ContextMenu.Popup
data-slot="dropdown-menu-content"
style={{ color: 'var(--surface-elevated-foreground)' }}
className={cn(dropdownMenuPopupClass, 'min-w-[190px]')}
>
{renderMenu(menuArgsFor(contextComponents))}
</ContextMenu.Popup>
</ContextMenu.Positioner>
</ContextMenu.Portal>
</ContextMenu.Root>
</div>
);
};
/**
* The header's horizontal working set of sessions (web/desktop only).
*
* Every session the user opens joins the strip once; the tab whose session is
* current renders `children` the header's title/rename block inside a
* selected pill. Closing a tab only removes it from the strip; closing the
* active one activates its neighbour. Ids whose session has not loaded (or
* was archived/deleted) stay in the store but do not render, so a partial
* session list never destroys the working set.
*/
export const SessionTabsStrip: React.FC<{
/** Menu items for one tab's session, supplied by the header. */
renderMenu: (args: SessionTabMenuArgs) => React.ReactNode;
/** Fires when a tab menu finishes opening/closing (deferred rename hook). */
onMenuOpenChangeComplete?: (open: boolean) => void;
/** While the active tab renames, its hover controls stay hidden. */
suppressActiveTabControls?: boolean;
children: React.ReactNode;
}> = ({ renderMenu, onMenuOpenChangeComplete, suppressActiveTabControls = false, children }) => {
const { t } = useI18n();
const tabIds = useSessionTabsStore((state) => state.tabIds);
const ensureTab = useSessionTabsStore((state) => state.ensureTab);
const closeOtherTabs = useSessionTabsStore((state) => state.closeOtherTabs);
const reorderTabs = useSessionTabsStore((state) => state.reorderTabs);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const activeSessions = useGlobalSessionsStore((state) => state.activeSessions);
// Opening a session anywhere (sidebar, palette, deep link) adds its tab.
React.useEffect(() => {
if (currentSessionId) ensureTab(currentSessionId);
}, [currentSessionId, ensureTab]);
const sessionsById = React.useMemo(() => {
const map = new Map<string, Session>();
for (const session of activeSessions) map.set(session.id, session);
return map;
}, [activeSessions]);
// Only tabs with a known live session render; unknown ids stay stored.
const tabs = React.useMemo<SessionTab[]>(() => {
const list: SessionTab[] = [];
for (const id of tabIds) {
const session = sessionsById.get(id);
if (session) list.push({ id, session });
}
return list;
}, [tabIds, sessionsById]);
const handleSelect = React.useCallback((tab: SessionTab) => {
setCurrentSession(tab.id, resolveGlobalSessionDirectory(tab.session));
}, [setCurrentSession]);
const handleClose = React.useCallback((id: string) => {
closeSessionTabAndActivateNeighbour(id);
}, []);
const handleCloseOthers = React.useCallback((id: string) => {
closeOtherTabs(id);
if (currentSessionId && currentSessionId !== id) {
const kept = tabs.find((tab) => tab.id === id);
if (kept) handleSelect(kept);
}
}, [closeOtherTabs, currentSessionId, handleSelect, tabs]);
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }),
);
const handleDragEnd = React.useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
reorderTabs(String(active.id), String(over.id));
}
}, [reorderTabs]);
// Soft fade at the edges while more tabs hide behind them.
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const [edges, setEdges] = React.useState({ left: false, right: false });
const updateEdges = React.useCallback(() => {
const node = scrollRef.current;
if (!node) return;
const left = node.scrollLeft > 2;
const right = node.scrollLeft + node.clientWidth < node.scrollWidth - 2;
setEdges((prev) => (prev.left === left && prev.right === right ? prev : { left, right }));
}, []);
React.useEffect(() => {
updateEdges();
const node = scrollRef.current;
if (!node || !globalThis.ResizeObserver) return;
const observer = new ResizeObserver(updateEdges);
observer.observe(node);
return () => observer.disconnect();
}, [updateEdges, tabs.length]);
// Keep the active tab in view when it changes.
React.useEffect(() => {
scrollRef.current
?.querySelector('[data-active-session-tab]')
?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}, [currentSessionId]);
const maskImage = edges.left && edges.right
? 'linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent)'
: edges.left
? 'linear-gradient(to right, transparent, black 24px)'
: edges.right
? 'linear-gradient(to right, black calc(100% - 24px), transparent)'
: undefined;
const tabIdsInOrder = React.useMemo(() => tabs.map((tab) => tab.id), [tabs]);
// A brand-new draft (no session yet) shows as a transient active pill after
// the tabs; it becomes a real tab once the first message creates the session.
const showDraftPill = !currentSessionId || !tabs.some((tab) => tab.id === currentSessionId);
return (
<div className="app-region-no-drag flex h-full min-w-0 flex-1 items-center" role="tablist" aria-label={t('header.sessionTabs.stripAria')}>
<div
ref={scrollRef}
onScroll={updateEdges}
className="session-tabs-scroll flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto overscroll-x-contain"
style={maskImage ? { maskImage, WebkitMaskImage: maskImage } : undefined}
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToXAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext items={tabIdsInOrder} strategy={horizontalListSortingStrategy}>
{tabs.map((tab) => (
<SessionTabItem
key={tab.id}
tab={tab}
isActive={tab.id === currentSessionId}
suppressControls={tab.id === currentSessionId && suppressActiveTabControls}
onSelect={handleSelect}
onClose={handleClose}
renderMenu={renderMenu}
closeOtherTabs={handleCloseOthers}
onMenuOpenChangeComplete={onMenuOpenChangeComplete}
>
{tab.id === currentSessionId ? children : null}
</SessionTabItem>
))}
</SortableContext>
</DndContext>
{showDraftPill ? (
<div
role="tab"
aria-selected
className="session-tab-slot flex h-7 w-44 shrink-0 items-center rounded-md bg-interactive-selection px-2"
data-active="true"
>
<div className="min-w-0 flex-1">{children}</div>
</div>
) : null}
</div>
</div>
);
};
@@ -41,6 +41,7 @@ import type { Session } from '@opencode-ai/sdk/v2';
import type { UsageWindow } from '@/types'; import type { UsageWindow } from '@/types';
import type { SessionContextUsage } from '@/stores/types/sessionTypes'; import type { SessionContextUsage } from '@/stores/types/sessionTypes';
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore'; import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
import { useSessionListSync } from '@/components/session/sidebar/list/useSessionListSync';
const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView }))); const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
@@ -55,10 +56,10 @@ const formatTime = (timestamp: number | null, timeFormatPreference: TimeFormatPr
// Width threshold for mobile vs desktop layout in settings // Width threshold for mobile vs desktop layout in settings
const MOBILE_WIDTH_THRESHOLD = 550; const MOBILE_WIDTH_THRESHOLD = 550;
// Width threshold for expanded layout (sidebar + chat side by side)
const EXPANDED_LAYOUT_THRESHOLD = 1400;
// Sessions sidebar width in expanded layout // Sessions sidebar width in expanded layout
const SESSIONS_SIDEBAR_WIDTH = 280; const SESSIONS_SIDEBAR_WIDTH = 280;
// Keep enough room for the chat after adding the persistent sessions sidebar.
const EXPANDED_LAYOUT_THRESHOLD = SESSIONS_SIDEBAR_WIDTH + 520;
const SESSIONS_SIDEBAR_MIN_WIDTH = Math.round(SESSIONS_SIDEBAR_WIDTH * 0.7); const SESSIONS_SIDEBAR_MIN_WIDTH = Math.round(SESSIONS_SIDEBAR_WIDTH * 0.7);
const SESSIONS_SIDEBAR_MAX_WIDTH = 520; const SESSIONS_SIDEBAR_MAX_WIDTH = 520;
@@ -526,8 +527,11 @@ export const VSCodeLayout: React.FC = () => {
} }
}, [usesExpandedLayout, currentView, viewMode]); }, [usesExpandedLayout, currentView, viewMode]);
useSessionListSync({ isVSCode: true });
return ( return (
<div ref={containerRef} className="h-full w-full bg-background text-foreground flex flex-col"> <>
<div ref={containerRef} className="h-full w-full bg-background text-foreground flex flex-col">
{viewMode === 'editor' ? ( {viewMode === 'editor' ? (
// Editor mode: just chat, no sidebar // Editor mode: just chat, no sidebar
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
@@ -639,7 +643,8 @@ export const VSCodeLayout: React.FC = () => {
</> </>
)} )}
<SessionDialogs /> <SessionDialogs />
</div> </div>
</>
); );
}; };
@@ -12,6 +12,7 @@ import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy }
import { CSS as DndCSS } from '@dnd-kit/utilities'; import { CSS as DndCSS } from '@dnd-kit/utilities';
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 { matchesRankQuery } from '@/lib/search/fuzzySearch';
import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -455,18 +456,18 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
return hiddenModels.some((hidden) => hidden.providerID === providerID && hidden.modelID === modelID); return hiddenModels.some((hidden) => hidden.providerID === providerID && hidden.modelID === modelID);
}, [hiddenModels]); }, [hiddenModels]);
const matchesQuery = React.useCallback((modelName: string, providerName: string) => { const matchesQuery = React.useCallback(
const query = searchQuery.trim().toLowerCase(); (modelName: string, providerName: string, modelID?: string) =>
if (!query) return true; matchesRankQuery([modelName, modelID, providerName], searchQuery),
return modelName.toLowerCase().includes(query) || providerName.toLowerCase().includes(query); [searchQuery],
}, [searchQuery]); );
const filteredFavorites = React.useMemo(() => favoriteModels.filter(({ model, providerID, modelID }) => { const filteredFavorites = React.useMemo(() => favoriteModels.filter(({ model, providerID, modelID }) => {
if (allowedProviderSet && !allowedProviderSet.has(providerID)) return false; if (allowedProviderSet && !allowedProviderSet.has(providerID)) return false;
if (isModelAllowed && !isModelAllowed(providerID, modelID)) return false; if (isModelAllowed && !isModelAllowed(providerID, modelID)) return false;
if (isHidden(providerID, modelID)) return false; if (isHidden(providerID, modelID)) return false;
const providerName = providerById.get(providerID)?.name || providerID; const providerName = providerById.get(providerID)?.name || providerID;
return matchesQuery(getModelDisplayName(model), providerName); return matchesQuery(getModelDisplayName(model), providerName, modelID);
}), [allowedProviderSet, favoriteModels, isHidden, isModelAllowed, matchesQuery, providerById]); }), [allowedProviderSet, favoriteModels, isHidden, isModelAllowed, matchesQuery, providerById]);
const filteredRecents = React.useMemo(() => recentModels.filter(({ model, providerID, modelID }) => { const filteredRecents = React.useMemo(() => recentModels.filter(({ model, providerID, modelID }) => {
@@ -474,7 +475,7 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
if (isModelAllowed && !isModelAllowed(providerID, modelID)) return false; if (isModelAllowed && !isModelAllowed(providerID, modelID)) return false;
if (isHidden(providerID, modelID)) return false; if (isHidden(providerID, modelID)) return false;
const providerName = providerById.get(providerID)?.name || providerID; const providerName = providerById.get(providerID)?.name || providerID;
return matchesQuery(getModelDisplayName(model), providerName); return matchesQuery(getModelDisplayName(model), providerName, modelID);
}), [allowedProviderSet, isHidden, isModelAllowed, matchesQuery, providerById, recentModels]); }), [allowedProviderSet, isHidden, isModelAllowed, matchesQuery, providerById, recentModels]);
const orderedProviders = React.useMemo(() => { const orderedProviders = React.useMemo(() => {
@@ -495,7 +496,7 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
const modelID = typeof model.id === 'string' ? model.id : ''; const modelID = typeof model.id === 'string' ? model.id : '';
if (!modelID || isHidden(provider.id, modelID)) return false; if (!modelID || isHidden(provider.id, modelID)) return false;
if (isModelAllowed && !isModelAllowed(provider.id, modelID)) return false; if (isModelAllowed && !isModelAllowed(provider.id, modelID)) return false;
return matchesQuery(getModelDisplayName(model), provider.name || provider.id); return matchesQuery(getModelDisplayName(model), provider.name || provider.id, modelID);
}); });
return { ...provider, models: filteredModels }; return { ...provider, models: filteredModels };
}) })
@@ -544,8 +545,9 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
? Math.min(STICKY_FADE_MIN_SIZE + scroller.scrollTop, STICKY_FADE_MAX_SIZE) ? Math.min(STICKY_FADE_MIN_SIZE + scroller.scrollTop, STICKY_FADE_MAX_SIZE)
: 0; : 0;
stickyFadeSizeRef.current = fadeSize; stickyFadeSizeRef.current = fadeSize;
scroller.style.setProperty('--scroll-shadow-top-size', `${fadeSize}px`); const fadeRoot = scroller.closest<HTMLElement>('.oc-sticky-fade-root');
scroller.style.setProperty( fadeRoot?.style.setProperty('--scroll-shadow-top-size', `${fadeSize}px`);
fadeRoot?.style.setProperty(
'--scroll-shadow-top-clear-size', '--scroll-shadow-top-clear-size',
`${Math.min(Math.max(fadeSize - 8, 0), STICKY_FADE_CLEAR_MAX_SIZE)}px`, `${Math.min(Math.max(fadeSize - 8, 0), STICKY_FADE_CLEAR_MAX_SIZE)}px`,
); );
@@ -875,24 +877,23 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
<div <div
className="oc-sticky-fade-root relative flex min-h-0 flex-1" className="oc-sticky-fade-root relative flex min-h-0 flex-1"
// SAFETY: these custom properties configure the viewport-owned edge fade.
style={stickyHeaders ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : undefined}
onPointerDownCapture={stickyHeaders ? blockStickyFadeInteraction : undefined} onPointerDownCapture={stickyHeaders ? blockStickyFadeInteraction : undefined}
onClickCapture={stickyHeaders ? blockStickyFadeInteraction : undefined} onClickCapture={stickyHeaders ? blockStickyFadeInteraction : undefined}
onContextMenuCapture={stickyHeaders ? blockStickyFadeInteraction : undefined} onContextMenuCapture={stickyHeaders ? blockStickyFadeInteraction : undefined}
> >
<ScrollableOverlay <ScrollableOverlay
ref={scrollRef} ref={scrollRef}
useScrollShadow={stickyHeaders} useScrollShadow={stickyHeaders}
hideBottomScrollShadow hideBottomScrollShadow
scrollShadowSize={12} scrollShadowSize={12}
outerClassName={maxHeightClassName} outerClassName={maxHeightClassName}
className="oc-sticky-fade-scroller overlay-scrollbar-target--no-gutter" className="overlay-scrollbar-target--no-gutter"
style={{ style={maxHeightStyle}
...(stickyHeaders ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : {}), onScroll={stickyHeaders ? (event) => syncStickyFade(event.currentTarget) : undefined}
...maxHeightStyle, >
}} <div className="px-1">
onScroll={stickyHeaders ? (event) => syncStickyFade(event.currentTarget) : undefined}
>
<div className="px-1">
{includeNotSelected ? ( {includeNotSelected ? (
<> <>
<button <button
@@ -963,16 +964,16 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
</div> </div>
)) ))
)} )}
</div> </div>
</ScrollableOverlay> </ScrollableOverlay>
{stickyHeaders && leadingSectionKey ? ( {stickyHeaders && leadingSectionKey ? (
<div <div
className="oc-sticky-fade-overlay pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center gap-2 px-3 py-1.5 typography-micro font-semibold uppercase tracking-wider text-muted-foreground" className="oc-sticky-fade-overlay pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center gap-2 px-3 py-1.5 typography-micro font-semibold uppercase tracking-wider text-muted-foreground"
aria-hidden="true" aria-hidden="true"
> >
{renderSectionIdentity(leadingSectionKey)} {renderSectionIdentity(leadingSectionKey)}
</div> </div>
) : null} ) : null}
</div> </div>
<div className="px-3 pt-1 pb-1.5 border-t border-border/40 typography-micro text-muted-foreground"> <div className="px-3 pt-1 pb-1.5 border-t border-border/40 typography-micro text-muted-foreground">
@@ -152,7 +152,7 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
// Find the nearest dialog or overflow ancestor to constrain within // Find the nearest dialog or overflow ancestor to constrain within
let container: HTMLElement | null = triggerRef.current.parentElement; let container: HTMLElement | null = triggerRef.current.parentElement;
while (container) { while (container) {
if (container.getAttribute('role') === 'dialog' || container.hasAttribute('data-scroll-shadow')) { if (container.matches('[role="dialog"], [data-scroll-shadow-scroller]')) {
break; break;
} }
const style = getComputedStyle(container); const style = getComputedStyle(container);
@@ -152,8 +152,8 @@ const GeneralSectionContent: React.FC = () => {
{!isVSCode && <OpenChamberToolsSettings />} {!isVSCode && <OpenChamberToolsSettings />}
<OpenChamberVisualSettings visibleSettings={[ <OpenChamberVisualSettings visibleSettings={[
'fileEditorKeymap', 'fileEditorKeymap',
...(!isVSCode ? ['sessionTabs' as const] : []),
'autoSaveEnabled', 'autoSaveEnabled',
'expandedEditorToolbar',
...(!isVSCode ? ['terminalQuickKeys' as const] : []), ...(!isVSCode ? ['terminalQuickKeys' as const] : []),
...(!isVSCode ? ['terminalShell' as const] : []), ...(!isVSCode ? ['terminalShell' as const] : []),
...(!isVSCode ? ['terminalLoginShell' as const] : []), ...(!isVSCode ? ['terminalLoginShell' as const] : []),
@@ -32,7 +32,6 @@ import { CODE_FONT_OPTIONS, DEFAULT_MONO_FONT, DEFAULT_UI_FONT, UI_FONT_OPTIONS,
import { useI18n, type Locale } from '@/lib/i18n'; import { useI18n, type Locale } from '@/lib/i18n';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { normalizeMobileKeyboardMode, supportsMobileKeyboardResizeContent, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; import { normalizeMobileKeyboardMode, supportsMobileKeyboardResizeContent, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import { getStoredMobileLayoutPreference, setStoredMobileLayoutPreference, type MobileLayoutPreference } from '@/lib/mobileLayoutPreference';
import { import {
setDirectoryShowHidden, setDirectoryShowHidden,
useDirectoryShowHidden, useDirectoryShowHidden,
@@ -151,17 +150,6 @@ const MOBILE_KEYBOARD_MODE_OPTIONS: Option<MobileKeyboardMode>[] = [
}, },
]; ];
const MOBILE_LAYOUT_OPTIONS: Array<{ value: MobileLayoutPreference; labelKey: string }> = [
{
value: 'default',
labelKey: 'settings.openchamber.visual.option.mobileLayout.default',
},
{
value: 'new',
labelKey: 'settings.openchamber.visual.option.mobileLayout.new',
},
];
type PwaInstallNameWindow = Window & { type PwaInstallNameWindow = Window & {
__OPENCHAMBER_SET_PWA_INSTALL_NAME__?: (value: string) => string; __OPENCHAMBER_SET_PWA_INSTALL_NAME__?: (value: string) => string;
__OPENCHAMBER_SET_PWA_ORIENTATION__?: (value: 'system' | 'portrait' | 'landscape') => 'system' | 'portrait' | 'landscape'; __OPENCHAMBER_SET_PWA_ORIENTATION__?: (value: 'system' | 'portrait' | 'landscape') => 'system' | 'portrait' | 'landscape';
@@ -278,7 +266,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
return mode === 'markdown' ? 'markdown' : 'plain'; return mode === 'markdown' ? 'markdown' : 'plain';
}; };
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar' | 'autoSaveEnabled'; type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'autoSaveEnabled' | 'sessionTabs';
const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [ const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [
{ id: 'left', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsLeft' }, { id: 'left', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsLeft' },
@@ -314,6 +302,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const sessionGoalDefaultBudget = useUIStore(state => state.sessionGoalDefaultBudget); const sessionGoalDefaultBudget = useUIStore(state => state.sessionGoalDefaultBudget);
const setSessionGoalDefaultBudget = useUIStore(state => state.setSessionGoalDefaultBudget); const setSessionGoalDefaultBudget = useUIStore(state => state.setSessionGoalDefaultBudget);
const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces); const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces);
const streamingAutoFollowEnabled = useUIStore(state => state.streamingAutoFollowEnabled);
const setStreamingAutoFollowEnabled = useUIStore(state => state.setStreamingAutoFollowEnabled);
const collapsibleThinkingBlocks = useUIStore(state => state.collapsibleThinkingBlocks); const collapsibleThinkingBlocks = useUIStore(state => state.collapsibleThinkingBlocks);
const setCollapsibleThinkingBlocks = useUIStore(state => state.setCollapsibleThinkingBlocks); const setCollapsibleThinkingBlocks = useUIStore(state => state.setCollapsibleThinkingBlocks);
@@ -327,8 +317,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const promptNavigatorEnabled = useUIStore(state => state.promptNavigatorEnabled); const promptNavigatorEnabled = useUIStore(state => state.promptNavigatorEnabled);
const setStickyUserHeader = useUIStore(state => state.setStickyUserHeader); const setStickyUserHeader = useUIStore(state => state.setStickyUserHeader);
const setPromptNavigatorEnabled = useUIStore(state => state.setPromptNavigatorEnabled); const setPromptNavigatorEnabled = useUIStore(state => state.setPromptNavigatorEnabled);
const expandedEditorToolbar = useUIStore(state => state.expandedEditorToolbar);
const setExpandedEditorToolbar = useUIStore(state => state.setExpandedEditorToolbar);
const autoSaveEnabled = useUIStore(state => state.autoSaveEnabled); const autoSaveEnabled = useUIStore(state => state.autoSaveEnabled);
const setAutoSaveEnabled = useUIStore(state => state.setAutoSaveEnabled); const setAutoSaveEnabled = useUIStore(state => state.setAutoSaveEnabled);
const wideChatLayoutEnabled = useUIStore(state => state.wideChatLayoutEnabled); const wideChatLayoutEnabled = useUIStore(state => state.wideChatLayoutEnabled);
@@ -362,6 +350,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const diffLayoutPreference = useUIStore(state => state.diffLayoutPreference); const diffLayoutPreference = useUIStore(state => state.diffLayoutPreference);
const setDiffLayoutPreference = useUIStore(state => state.setDiffLayoutPreference); const setDiffLayoutPreference = useUIStore(state => state.setDiffLayoutPreference);
const showTerminalQuickKeysOnDesktop = useUIStore(state => state.showTerminalQuickKeysOnDesktop); const showTerminalQuickKeysOnDesktop = useUIStore(state => state.showTerminalQuickKeysOnDesktop);
const sessionTabsEnabled = useUIStore(state => state.sessionTabsEnabled);
const setSessionTabsEnabled = useUIStore(state => state.setSessionTabsEnabled);
const setShowTerminalQuickKeysOnDesktop = useUIStore(state => state.setShowTerminalQuickKeysOnDesktop); const setShowTerminalQuickKeysOnDesktop = useUIStore(state => state.setShowTerminalQuickKeysOnDesktop);
const fileEditorKeymap = useUIStore(state => state.fileEditorKeymap); const fileEditorKeymap = useUIStore(state => state.fileEditorKeymap);
const setFileEditorKeymap = useUIStore(state => state.setFileEditorKeymap); const setFileEditorKeymap = useUIStore(state => state.setFileEditorKeymap);
@@ -510,11 +500,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
void updateDesktopSettings({ draftStartersVisible: enabled }); void updateDesktopSettings({ draftStartersVisible: enabled });
}, [setDraftStartersVisible]); }, [setDraftStartersVisible]);
const handleExpandedEditorToolbarChange = React.useCallback((enabled: boolean) => {
setExpandedEditorToolbar(enabled);
void updateDesktopSettings({ expandedEditorToolbar: enabled });
}, [setExpandedEditorToolbar]);
const handleCollapsibleUserMessagesChange = React.useCallback((enabled: boolean) => { const handleCollapsibleUserMessagesChange = React.useCallback((enabled: boolean) => {
setCollapsibleUserMessages(enabled); setCollapsibleUserMessages(enabled);
void updateDesktopSettings({ collapsibleUserMessages: enabled }); void updateDesktopSettings({ collapsibleUserMessages: enabled });
@@ -629,12 +614,11 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const hasThemeSettings = shouldShow('theme') && !isVSCode; const hasThemeSettings = shouldShow('theme') && !isVSCode;
const showWindowControlsPositionSetting = shouldShow('windowControlsPosition') && showWindowControlsPosition; const showWindowControlsPositionSetting = shouldShow('windowControlsPosition') && showWindowControlsPosition;
const hasLocalizationSettings = shouldShow('theme') || shouldShow('timeFormat') || shouldShow('weekStart'); const hasLocalizationSettings = shouldShow('theme') || shouldShow('timeFormat') || shouldShow('weekStart');
const showMobileLayoutSetting = isMobile && isWebRuntime() && !isDesktopShell() && !isVSCode;
const hasAppearanceSettings = isVSCode const hasAppearanceSettings = isVSCode
? hasLocalizationSettings ? hasLocalizationSettings
: (shouldShow('theme') || showWindowControlsPositionSetting || showMobileLayoutSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart')); : (shouldShow('theme') || showWindowControlsPositionSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart'));
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('editorFontSize') || shouldShow('spacing') || (shouldShow('inputBarOffset') && isMobile); const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('editorFontSize') || shouldShow('spacing') || (shouldShow('inputBarOffset') && isMobile);
const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || ((shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode) || shouldShow('fileEditorKeymap') || shouldShow('autoSaveEnabled') || (shouldShow('expandedEditorToolbar') && !isVSCode); const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || ((shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode) || shouldShow('fileEditorKeymap') || shouldShow('autoSaveEnabled') || (shouldShow('sessionTabs') && !isVSCode && !isMobile);
const hasBehaviorSettings = shouldShow('mermaidRendering') const hasBehaviorSettings = shouldShow('mermaidRendering')
|| (shouldShow('sessionGoal') && !isVSCode) || (shouldShow('sessionGoal') && !isVSCode)
|| shouldShow('userMessageRendering') || shouldShow('userMessageRendering')
@@ -723,7 +707,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
? [...terminalLoginShells.filter((shell) => shell !== terminalShell), terminalShell] ? [...terminalLoginShells.filter((shell) => shell !== terminalShell), terminalShell]
: terminalLoginShells.filter((shell) => shell !== terminalShell)); : terminalLoginShells.filter((shell) => shell !== terminalShell));
}; };
const [mobileLayoutPreference, setMobileLayoutPreference] = React.useState<MobileLayoutPreference>(() => getStoredMobileLayoutPreference());
const [pwaInstallName, setPwaInstallName] = React.useState(''); const [pwaInstallName, setPwaInstallName] = React.useState('');
const [pwaOrientation, setPwaOrientation] = React.useState<'system' | 'portrait' | 'landscape'>('system'); const [pwaOrientation, setPwaOrientation] = React.useState<'system' | 'portrait' | 'landscape'>('system');
const selectedTimeFormatLabel = React.useMemo(() => { const selectedTimeFormatLabel = React.useMemo(() => {
@@ -743,16 +726,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
return option ? tUnsafe(option.labelKey) : undefined; return option ? tUnsafe(option.labelKey) : undefined;
}, [mobileKeyboardMode, tUnsafe]); }, [mobileKeyboardMode, tUnsafe]);
const handleMobileLayoutPreferenceChange = React.useCallback((value: MobileLayoutPreference) => {
if (value === mobileLayoutPreference) {
return;
}
setMobileLayoutPreference(value);
setStoredMobileLayoutPreference(value);
window.location.reload();
}, [mobileLayoutPreference]);
const applyPwaInstallName = React.useCallback(async (value: string) => { const applyPwaInstallName = React.useCallback(async (value: string) => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return; return;
@@ -877,21 +850,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
))} ))}
</SettingsRadioGroup> </SettingsRadioGroup>
{showMobileLayoutSetting && (
<SettingsInset>
<SettingsStackedField label={t('settings.openchamber.visual.section.mobileLayout')}>
<SettingsChipGroup
value={mobileLayoutPreference}
options={MOBILE_LAYOUT_OPTIONS.map((option) => ({
value: option.value,
label: tUnsafe(option.labelKey),
}))}
onChange={handleMobileLayoutPreferenceChange}
aria-label={t('settings.openchamber.visual.section.mobileLayout')}
/>
</SettingsStackedField>
</SettingsInset>
)}
</div> </div>
<div className={SETTINGS_FIELDS_STACK_CLASS}> <div className={SETTINGS_FIELDS_STACK_CLASS}>
@@ -1486,25 +1444,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
settingsItem="appearance.auto-save-enabled" settingsItem="appearance.auto-save-enabled"
/> />
)} )}
{shouldShow('expandedEditorToolbar') && !isVSCode && (
<SettingsCheckboxRow
checked={expandedEditorToolbar}
onChange={handleExpandedEditorToolbarChange}
label={t('settings.openchamber.visual.field.expandedEditorToolbar')}
ariaLabel={t('settings.openchamber.visual.field.expandedEditorToolbarAria')}
settingsItem="appearance.expanded-editor-toolbar"
/>
)}
{shouldShow('terminalQuickKeys') && !isMobile && (
<SettingsCheckboxRow
checked={showTerminalQuickKeysOnDesktop}
onChange={setShowTerminalQuickKeysOnDesktop}
label={t('settings.openchamber.visual.field.terminalQuickKeys')}
ariaLabel={t('settings.openchamber.visual.field.terminalQuickKeysAria')}
settingsItem="appearance.terminal-quick-keys"
info={t('settings.openchamber.visual.field.terminalQuickKeysTooltip')}
/>
)}
{showTerminalShellSetting && ( {showTerminalShellSetting && (
<SettingsStackedField <SettingsStackedField
label={t('settings.openchamber.visual.field.terminalShell')} label={t('settings.openchamber.visual.field.terminalShell')}
@@ -1534,7 +1473,31 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
settingsItem="appearance.terminal-login-shell" settingsItem="appearance.terminal-login-shell"
/> />
)} )}
{shouldShow('terminalQuickKeys') && !isMobile && (
<SettingsCheckboxRow
checked={showTerminalQuickKeysOnDesktop}
onChange={setShowTerminalQuickKeysOnDesktop}
label={t('settings.openchamber.visual.field.terminalQuickKeys')}
ariaLabel={t('settings.openchamber.visual.field.terminalQuickKeysAria')}
settingsItem="appearance.terminal-quick-keys"
info={t('settings.openchamber.visual.field.terminalQuickKeysTooltip')}
/>
)}
</div> </div>
{shouldShow('sessionTabs') && !isVSCode && !isMobile && (
<SettingsControlGroup
title={t('settings.openchamber.visual.field.sessionTabsGroup')}
settingsItem="appearance.session-tabs"
>
<SettingsCheckboxRow
checked={sessionTabsEnabled}
onChange={setSessionTabsEnabled}
label={t('settings.openchamber.visual.field.sessionTabs')}
ariaLabel={t('settings.openchamber.visual.field.sessionTabsAria')}
info={t('settings.openchamber.visual.field.sessionTabsInfo')}
/>
</SettingsControlGroup>
)}
</SettingsSection> </SettingsSection>
)} )}
@@ -1878,6 +1841,20 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
)} )}
</SettingsSection> </SettingsSection>
)} )}
<SettingsSection
title={t('settings.openchamber.visual.section.streaming')}
settingsItem="chat.streaming"
contentClassName={SETTINGS_OPTION_STACK_CLASS}
>
<SettingsCheckboxRow
checked={streamingAutoFollowEnabled}
onChange={setStreamingAutoFollowEnabled}
label={t('settings.openchamber.visual.field.streamingAutoFollow')}
ariaLabel={t('settings.openchamber.visual.field.streamingAutoFollowAria')}
info={t('settings.openchamber.visual.field.streamingAutoFollowInfo')}
settingsItem="chat.streaming-auto-follow"
/>
</SettingsSection>
{(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || (shouldShow('promptNavigatorEnabled') && !isVSCode) || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('codeBlockLineWrap')) && ( {(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || (shouldShow('promptNavigatorEnabled') && !isVSCode) || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('codeBlockLineWrap')) && (
<SettingsSection <SettingsSection
@@ -1,3 +1,4 @@
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
import React from 'react'; import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout'; import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
@@ -603,15 +604,9 @@ export const ProvidersPage: React.FC = () => {
</div> </div>
<ScrollableOverlay outerClassName="max-h-[240px]" className="p-1"> <ScrollableOverlay outerClassName="max-h-[240px]" className="p-1">
{(() => { {(() => {
const query = providerSearchQuery.toLowerCase();
const customLabel = t('settings.providers.page.custom.optionLabel'); const customLabel = t('settings.providers.page.custom.optionLabel');
const customMatches = !query const customMatches = matchesRankQuery([customLabel, 'other', 'custom'], providerSearchQuery);
|| customLabel.toLowerCase().includes(query) const filtered = rankByQuery(unconnectedProviders, providerSearchQuery, (p) => [p.name || p.id, p.id]);
|| 'other'.includes(query)
|| 'custom'.includes(query);
const filtered = unconnectedProviders.filter(p => {
return (p.name || p.id).toLowerCase().includes(query) || p.id.toLowerCase().includes(query);
});
if (filtered.length === 0 && !customMatches) { if (filtered.length === 0 && !customMatches) {
return <p className="py-4 text-center typography-meta text-muted-foreground">{t('settings.providers.page.connect.noProvidersFound')}</p>; return <p className="py-4 text-center typography-meta text-muted-foreground">{t('settings.providers.page.connect.noProvidersFound')}</p>;
} }
@@ -792,13 +787,10 @@ export const ProvidersPage: React.FC = () => {
? t('settings.providers.page.auth.useReconnectHint') ? t('settings.providers.page.auth.useReconnectHint')
: t('settings.providers.page.auth.incompleteHint'); : t('settings.providers.page.auth.incompleteHint');
const filteredModels = providerModels.filter((model) => { const filteredModels = rankByQuery(providerModels, modelQuery, (model) => [
const name = typeof model?.name === 'string' ? model.name : ''; typeof model?.name === 'string' ? model.name : '',
const id = typeof model?.id === 'string' ? model.id : ''; typeof model?.id === 'string' ? model.id : '',
const query = modelQuery.trim().toLowerCase(); ]);
if (!query) return true;
return name.toLowerCase().includes(query) || id.toLowerCase().includes(query);
});
if (isCustomEditMode && isEditableCustomProvider && editingCustomFormInitial) { if (isCustomEditMode && isEditableCustomProvider && editingCustomFormInitial) {
return ( return (
@@ -1,3 +1,4 @@
import { rankByQuery } from '@/lib/search/fuzzySearch';
import React from 'react'; import React from 'react';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -1255,13 +1256,10 @@ export const RemoteInstancesPage: React.FC = () => {
[createImportedInstance], [createImportedInstance],
); );
const filteredImportCandidates = React.useMemo(() => { const filteredImportCandidates = React.useMemo(
const query = sshHostSearch.trim().toLowerCase(); () => rankByQuery(importCandidates, sshHostSearch, (candidate) => [candidate.host, candidate.sshCommand]),
if (!query) return importCandidates; [importCandidates, sshHostSearch],
return importCandidates.filter((candidate) => { );
return candidate.host.toLowerCase().includes(query) || candidate.sshCommand.toLowerCase().includes(query);
});
}, [importCandidates, sshHostSearch]);
// Opening a ready instance means pointing this window at the forwarded local // Opening a ready instance means pointing this window at the forwarded local
// URL — the same navigation the host switcher performs after its own connect. // URL — the same navigation the host switcher performs after its own connect.
@@ -1,3 +1,4 @@
import { rankByQuery } from '@/lib/search/fuzzySearch';
import React from 'react'; import React from 'react';
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
@@ -265,14 +266,12 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
const isSearching = search.trim().length > 0; const isSearching = search.trim().length > 0;
const filtered = React.useMemo(() => { const filtered = React.useMemo(() => {
const q = search.trim().toLowerCase();
const matches = (item: SkillsCatalogItem) =>
item.skillName.toLowerCase().includes(q)
|| (item.description || '').toLowerCase().includes(q)
|| (item.frontmatterName || '').toLowerCase().includes(q);
if (isSearching) { if (isSearching) {
return sources.flatMap((src) => (itemsBySource[src.id] || []).filter(matches)); return rankByQuery(
sources.flatMap((src) => itemsBySource[src.id] || []),
search,
(item) => [item.skillName, item.frontmatterName, item.description],
);
} }
if (!selectedSourceId) { if (!selectedSourceId) {
return []; return [];
@@ -148,7 +148,6 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
const homeDirectory = useDirectoryStore((s) => s.homeDirectory); const homeDirectory = useDirectoryStore((s) => s.homeDirectory);
const projects = useProjectsStore((s) => s.projects); const projects = useProjectsStore((s) => s.projects);
const addProject = useProjectsStore((s) => s.addProject); const addProject = useProjectsStore((s) => s.addProject);
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen); const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen);
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const gitIdentityProfiles = useGitIdentitiesStore((s) => s.profiles); const gitIdentityProfiles = useGitIdentitiesStore((s) => s.profiles);
@@ -411,11 +410,10 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
}, [onOpenChange]); }, [onOpenChange]);
const openProjectDraft = React.useCallback((projectId: string, projectPath: string) => { const openProjectDraft = React.useCallback((projectId: string, projectPath: string) => {
setActiveMainTab('chat');
if (isMobile) setSessionSwitcherOpen(false); if (isMobile) setSessionSwitcherOpen(false);
openNewSessionDraft({ selectedProjectId: projectId, directoryOverride: projectPath }); openNewSessionDraft({ selectedProjectId: projectId, directoryOverride: projectPath });
handleClose(); handleClose();
}, [handleClose, isMobile, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]); }, [handleClose, isMobile, openNewSessionDraft, setSessionSwitcherOpen]);
const handleQuickAdd = React.useCallback((event: React.MouseEvent, path: string) => { const handleQuickAdd = React.useCallback((event: React.MouseEvent, path: string) => {
event.stopPropagation(); event.stopPropagation();
@@ -368,13 +368,9 @@ export function ScheduledTasksDialog() {
return; return;
} }
setOpen(false); setOpen(false);
if (isMobile) { useFilesViewTabsStore.getState().setSelectedPath(selectedProject.path, task.loopFile, { allowOutsideRoot: true });
useFilesViewTabsStore.getState().setSelectedPath(selectedProject.path, task.loopFile, { allowOutsideRoot: true });
useUIStore.getState().setActiveMainTab('files');
return;
}
useUIStore.getState().openContextFile(selectedProject.path, task.loopFile); useUIStore.getState().openContextFile(selectedProject.path, task.loopFile);
}, [isMobile, selectedProject?.path, setOpen]); }, [selectedProject?.path, setOpen]);
const handleRunNow = React.useCallback(async (task: ScheduledTask) => { const handleRunNow = React.useCallback(async (task: ScheduledTask) => {
if (!selectedProjectID) { if (!selectedProjectID) {
@@ -397,8 +393,7 @@ export function ScheduledTasksDialog() {
// this surface (MainLayout closes surfaces on session selection). // this surface (MainLayout closes surfaces on session selection).
const project = projects.find((entry) => entry.id === selectedProjectID); const project = projects.find((entry) => entry.id === selectedProjectID);
useSessionUIStore.getState().setCurrentSession(sessionId, project?.path ?? null); useSessionUIStore.getState().setCurrentSession(sessionId, project?.path ?? null);
useUIStore.getState().setActiveMainTab('chat'); }
}
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.runFailed')); toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.runFailed'));
} finally { } finally {
@@ -4,9 +4,8 @@ import type { SessionFolder } from '@/stores/useSessionFoldersStore';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
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 type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sidebar/sessionNodeItemUtils'; import { CollapsedActivityIndicator } from './sidebar/sessions/collapsedActivityIndicator';
import { CollapsedActivityIndicator } from './sidebar/collapsedActivityIndicator'; import type { CollapsedActivityState } from './sidebar/sessions/collapsedActivityState';
import type { CollapsedActivityState } from './sidebar/collapsedActivityState';
interface SessionFolderItemProps<TSessionNode> { interface SessionFolderItemProps<TSessionNode> {
folder: SessionFolder; folder: SessionFolder;
@@ -24,23 +23,7 @@ interface SessionFolderItemProps<TSessionNode> {
onToggle: () => void; onToggle: () => void;
onRename: (name: string) => void; onRename: (name: string) => void;
onDelete: () => void; onDelete: () => void;
renderSessionNode: ( children?: React.ReactNode;
node: TSessionNode,
depth?: number,
groupDir?: string | null,
projectId?: string | null,
archivedBucket?: boolean,
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
renderContext?: 'project' | 'recent',
renderExtras?: SessionNodeChildRenderExtras,
) => React.ReactNode;
/**
* Returns the precomputed per-row render extras for a given node. The
* group precomputes subtree-contains lookups once, then resolves a
* per-node structure key here so SessionNodeItem's React.memo comparator
* can answer with a single string compare instead of a recursive walk.
*/
getRenderExtras?: (node: TSessionNode) => SessionNodeRenderExtras<TSessionNode> | undefined;
groupDirectory?: string | null; groupDirectory?: string | null;
projectId?: string | null; projectId?: string | null;
mobileVariant?: boolean; mobileVariant?: boolean;
@@ -74,10 +57,7 @@ const SessionFolderItemBase = <TSessionNode,>({
onToggle, onToggle,
onRename, onRename,
onDelete, onDelete,
renderSessionNode, children,
getRenderExtras,
groupDirectory,
projectId,
mobileVariant = false, mobileVariant = false,
alwaysShowActions = mobileVariant, alwaysShowActions = mobileVariant,
isRenaming = false, isRenaming = false,
@@ -97,6 +77,7 @@ const SessionFolderItemBase = <TSessionNode,>({
const [localDraft, setLocalDraft] = React.useState(''); const [localDraft, setLocalDraft] = React.useState('');
const inputRef = React.useRef<HTMLInputElement | null>(null); const inputRef = React.useRef<HTMLInputElement | null>(null);
const renaming = isRenaming || localRenaming; const renaming = isRenaming || localRenaming;
const draft = isRenaming ? renameDraft : localDraft; const draft = isRenaming ? renameDraft : localDraft;
@@ -167,6 +148,7 @@ const SessionFolderItemBase = <TSessionNode,>({
isDropTarget && 'bg-primary/10 ring-1 ring-inset ring-primary/30', isDropTarget && 'bg-primary/10 ring-1 ring-inset ring-primary/30',
)} )}
onClick={renaming ? undefined : (event) => { onClick={renaming ? undefined : (event) => {
// SAFETY: this handler is attached to the div rendered directly above.
(event.currentTarget as HTMLElement).blur(); (event.currentTarget as HTMLElement).blur();
onToggle(); onToggle();
}} }}
@@ -346,11 +328,7 @@ const SessionFolderItemBase = <TSessionNode,>({
{subFolderItems} {subFolderItems}
{/* Then sessions */} {/* Then sessions */}
{sessions.length > 0 ? ( {sessions.length > 0 ? (
<div className="pl-3"> children
{sessions.map((node) =>
renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null, archivedBucket, undefined, 'project', getRenderExtras?.(node)),
)}
</div>
) : !subFolderItems ? ( ) : !subFolderItems ? (
<div className="py-1 pl-1.5 text-left typography-micro text-muted-foreground/70"> <div className="py-1 pl-1.5 text-left typography-micro text-muted-foreground/70">
{t('sessions.sidebar.folderItem.emptyFolder')} {t('sessions.sidebar.folderItem.emptyFolder')}
@@ -362,6 +340,9 @@ const SessionFolderItemBase = <TSessionNode,>({
); );
}; };
export const SessionFolderItem = React.memo(SessionFolderItemBase) as <TSessionNode>( export const SessionFolderItem = (
/* SAFETY: React.memo preserves the generic component's props and return type. */
React.memo(SessionFolderItemBase) as <TSessionNode>(
props: SessionFolderItemProps<TSessionNode>, props: SessionFolderItemProps<TSessionNode>,
) => React.ReactElement; ) => React.ReactElement
);
File diff suppressed because it is too large Load Diff
@@ -11,7 +11,7 @@ import { Icon } from '@/components/icon/Icon';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useGlobalSessionStatus } from '@/sync/sync-context'; import { useGlobalSessionStatus } from '@/sync/sync-context';
import { useSessionUnseenCount } from '@/sync/notification-store'; import { useSessionUnseenCount } from '@/sync/notification-store';
import { useSwitcherItems, type SwitcherItem } from '@/components/session/sidebar/hooks/useSwitcherItems'; import { useSwitcherItems, type SwitcherItem } from '@/components/session/sidebar/shell/useSwitcherItems';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { formatSessionCompactDateLabel } from './sidebar/utils'; import { formatSessionCompactDateLabel } from './sidebar/utils';
@@ -71,14 +71,12 @@ type SwitcherContentProps = {
function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentProps): React.ReactElement { function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentProps): React.ReactElement {
const items = useSwitcherItems(true, { scopeProjectId }); const items = useSwitcherItems(true, { scopeProjectId });
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const { t } = useI18n(); const { t } = useI18n();
const handleNewSession = React.useCallback(() => { const handleNewSession = React.useCallback(() => {
setActiveMainTab('chat');
onSelect(); onSelect();
openNewSessionDraft(); openNewSessionDraft();
}, [onSelect, openNewSessionDraft, setActiveMainTab]); }, [onSelect, openNewSessionDraft]);
const [expandedParents, setExpandedParents] = React.useState<Set<string>>(new Set()); const [expandedParents, setExpandedParents] = React.useState<Set<string>>(new Set());
const toggleParent = React.useCallback((sessionId: string) => { const toggleParent = React.useCallback((sessionId: string) => {
@@ -1,3 +1,4 @@
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import React from 'react'; import React from 'react';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
@@ -186,13 +187,10 @@ export const MemorySection: React.FC<{
}; };
}, [markViewed, viewKey]); }, [markViewed, viewKey]);
const visibleEntries = React.useMemo(() => { const visibleEntries = React.useMemo(
const needle = query.trim().toLowerCase(); () => entries.filter((entry) => matchesRankQuery([entry.title, entry.body], query)),
if (!needle) return entries; [entries, query],
return entries.filter((entry) => ( );
entry.title.toLowerCase().includes(needle) || entry.body.toLowerCase().includes(needle)
));
}, [entries, query]);
const handleDelete = React.useCallback(async (memoryId: string) => { const handleDelete = React.useCallback(async (memoryId: string) => {
if (!await deleteEntry(scope, memoryId)) { if (!await deleteEntry(scope, memoryId)) {
@@ -1,3 +1,4 @@
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import React from 'react'; import React from 'react';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
@@ -178,11 +179,10 @@ export const NotesSection: React.FC<{
const saveNoteBody = useProjectContextStore((state) => state.saveNoteBody); const saveNoteBody = useProjectContextStore((state) => state.saveNoteBody);
const deleteNote = useProjectContextStore((state) => state.deleteNote); const deleteNote = useProjectContextStore((state) => state.deleteNote);
const visibleNotes = React.useMemo(() => { const visibleNotes = React.useMemo(
const needle = query.trim().toLowerCase(); () => notes.filter((note) => matchesRankQuery([note.body], query)),
if (!needle) return notes; [notes, query],
return notes.filter((note) => note.body.toLowerCase().includes(needle)); );
}, [notes, query]);
// The store keeps the failure reason; without passing it through, every // The store keeps the failure reason; without passing it through, every
// failure looks identical to the user and tells them nothing about the cause. // failure looks identical to the user and tells them nothing about the cause.
@@ -1,3 +1,4 @@
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import React from 'react'; import React from 'react';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
@@ -146,11 +147,10 @@ export const PlansSection: React.FC<{
[onTogglePinned, projectRef, t] [onTogglePinned, projectRef, t]
); );
const visiblePlans = React.useMemo(() => { const visiblePlans = React.useMemo(
const needle = query.trim().toLowerCase(); () => plans.filter((plan) => matchesRankQuery([plan.title], query)),
if (!needle) return plans; [plans, query],
return plans.filter((plan) => plan.title.toLowerCase().includes(needle)); );
}, [plans, query]);
const handleOpenPlan = React.useCallback( const handleOpenPlan = React.useCallback(
(plan: ProjectPlanLink) => { (plan: ProjectPlanLink) => {
@@ -135,6 +135,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
const globalMemory = useAgentMemoryStore((state) => state.global); const globalMemory = useAgentMemoryStore((state) => state.global);
const projectMemory = useAgentMemoryStore((state) => state.project); const projectMemory = useAgentMemoryStore((state) => state.project);
const isMobile = useUIStore((state) => state.isMobile);
const storedTab = useUIStore((state) => state.projectContextTab); const storedTab = useUIStore((state) => state.projectContextTab);
const setStoredTab = useUIStore((state) => state.setProjectContextTab); const setStoredTab = useUIStore((state) => state.setProjectContextTab);
const requestedTab = TAB_ORDER.includes(storedTab as ProjectContextTab) const requestedTab = TAB_ORDER.includes(storedTab as ProjectContextTab)
@@ -413,6 +414,42 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
</div> </div>
{/* Mobile: a half-width panel has no room for a side column, so the
sections become the same pill strip the mobile drawer's surface
tabs use the active pill carries the label, the rest collapse to
icon and count. */}
{isMobile ? (
<nav
className="flex flex-shrink-0 items-center gap-1.5 overflow-x-auto px-3 pb-2"
aria-label={t('rightSidebar.contextNotesTodo.sections.label')}
>
{sections.map((section) => {
const isActive = activeTab === section.id;
return (
<button
key={section.id}
type="button"
onClick={() => setStoredTab(section.id)}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex flex-shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
isActive
? 'border-transparent bg-interactive-active text-foreground'
: 'border-[var(--interactive-border)] text-muted-foreground',
)}
>
<Icon name={section.icon} className="h-4 w-4 flex-shrink-0" />
{isActive ? (
<span className="whitespace-nowrap typography-meta">{section.label}</span>
) : null}
<span className="typography-micro text-muted-foreground">{section.count}</span>
</button>
);
})}
</nav>
) : null}
{/* Content first, sidebar on the right the same order and the same {/* Content first, sidebar on the right the same order and the same
drag-to-resize edge the files surface uses, so the two panels do not drag-to-resize edge the files surface uses, so the two panels do not
disagree about where navigation lives. */} disagree about where navigation lives. */}
@@ -472,6 +509,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
) : null} ) : null}
</div> </div>
{isMobile ? null : (
<nav <nav
className="relative flex flex-shrink-0 flex-col gap-0.5 overflow-y-auto border-l border-[var(--interactive-border)] p-2" className="relative flex flex-shrink-0 flex-col gap-0.5 overflow-y-auto border-l border-[var(--interactive-border)] p-2"
style={{ width: `${sidebarWidth}px` }} style={{ width: `${sidebarWidth}px` }}
@@ -514,6 +552,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
); );
})} })}
</nav> </nav>
)}
</div> </div>
<TodoSendDialog <TodoSendDialog
@@ -1,3 +1,4 @@
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import React from 'react'; import React from 'react';
import { import {
DndContext, DndContext,
@@ -183,11 +184,10 @@ export const TodosSection: React.FC<{
const completedTodoCount = todos.reduce((count, todo) => count + (todo.completed ? 1 : 0), 0); const completedTodoCount = todos.reduce((count, todo) => count + (todo.completed ? 1 : 0), 0);
// Filtering is display-only: every handler above still edits the full list, // Filtering is display-only: every handler above still edits the full list,
// so reordering or clearing while a filter is active cannot drop hidden items. // so reordering or clearing while a filter is active cannot drop hidden items.
const visibleTodos = React.useMemo(() => { const visibleTodos = React.useMemo(
const needle = query.trim().toLowerCase(); () => todos.filter((todo) => matchesRankQuery([todo.text], query)),
if (!needle) return todos; [query, todos],
return todos.filter((todo) => todo.text.toLowerCase().includes(needle)); );
}, [query, todos]);
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@@ -44,13 +44,11 @@ export const useProjectTodoSend = (options: {
const sendMessage = useSessionUIStore((state) => state.sendMessage); const sendMessage = useSessionUIStore((state) => state.sendMessage);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const setPendingInputText = useInputStore((state) => state.setPendingInputText); const setPendingInputText = useInputStore((state) => state.setPendingInputText);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const routeToChat = React.useCallback(() => { const routeToChat = React.useCallback(() => {
setActiveMainTab('chat');
setSessionSwitcherOpen(false); setSessionSwitcherOpen(false);
}, [setActiveMainTab, setSessionSwitcherOpen]); }, [setSessionSwitcherOpen]);
const sendToCurrentSession = React.useCallback( const sendToCurrentSession = React.useCallback(
(todoText: string) => { (todoText: string) => {
@@ -1,89 +1,37 @@
# Session Sidebar Documentation # Session Sidebar
## Refactor result Sidebar code is organized by the business object it owns. Shared contracts are
kept at this root in `types.ts` and `utils.tsx`.
- `SessionSidebar.tsx` now acts mainly as orchestration; core logic moved to focused hooks/components. - `shell/` owns sidebar chrome, navigation, search, confirmations, and switcher effects.
- Layout (web/desktop): top navigation (`SidebarNav`: New session, Scheduled, Multi-run, Archive), then the `recent` zone, then one zone per project with a **flat** session list. There is no rendered worktree grouping level. - `list/` owns global-first session collection, directory bootstrap demand,
- **Two grouping display modes** (`useSessionDisplayStore.sessionGroupingMode`, toggled in the view dropdown): `'by-worktree'` (default) renders the worktree-grouped `sectionsForRender` with slim PR-aware branch sub-headers inside each project zone; `'flat'` renders `flatSectionsForRender` — one merged non-archived group per project (`id: 'flat'`, `folderScopes` listing every contributing scope) with per-row branch markers. Both derive from the same `projectSections` data layer, which alone feeds bootstrap demand planning and PR polling. layout-owned synchronization, authoritative cleanup, and nearby-session prefetch.
- **Project display is independent from grouping.** `'all'` keeps every project zone; `'single'` is web/desktop/PWA-only and renders one selected project under the always-present Chats section. Its project header is a non-collapsible picker ordered by the current project sort. Recent and collapse/expand-all controls are hidden without changing their persisted preferences. Opening a materialized project session updates the picker from the session's confirmed directory; changing only a draft target does not. In `'single'` + `'flat'`, active sessions reveal in batches of 20. `'single'` + `'by-worktree'` retains the ordinary per-group limits. Project display mode, session grouping, project sort, and the Recent preference are server-backed shared settings with the hydrated browser store as the migration/failure cache. The selected single project and sticky-header preference remain device-local. - `projects/` owns project zones, grouping, ordering, scroller behavior, project
- When sticky zone headers are enabled, project headers are sticky "zone" bands (`SortableProjectItem`); on a vibrant desktop the scrolling content fades behind an unmasked, non-interactive copy of the stuck icon/title without painting a background. The transparent fade zone blocks interaction with obscured rows. The `recent` section uses the same overlay while it is the leading sticky header. Collapsed projects show an aggregated busy/unseen indicator (`ProjectAggregateStatusIndicator`), derived from the live status index and notification store scoped to the project's directories. view state, repository state, and worktree presentation.
- **Activity is a dot plus a counter, never a spinner.** The row's left gutter shows a static dot — primary while the session runs (`busy`/`retry`), info while it is unread — and the metadata slot on the right swaps the goal/branch/date group for the elapsed time of the turn (`SessionActivityDuration`, ticking once per second). The readout takes the dot's color in each state — primary while running, info once it is waiting to be read — so the pair reads as one indicator. A running spinner repainted a composited layer per row every frame for the whole turn; the counter conveys the same "something is happening" at 1 fps. The counter follows the unread marker's lifetime exactly: it survives the turn ending, disappears when the session is read, and never lingers on the session being watched (which is marked read as it goes idle). Aggregate indicators for collapsed groups, folders, and projects show the dot only — a group may hold several running turns, so a single counter would have nothing to count. The same treatment applies to the mobile sessions sheet and session switcher rows. The worktree-move indicator stays a spinner: it marks a short user-initiated operation, not a session state. - `sessions/` owns session rows, row actions, expansion, ownership, and activity indicators.
- Session rows have a single layout (former `minimal`); the `default`/`minimal` display mode was removed (`session-display-mode` store v4 migration drops the key). Rows show an inline branch label (from `node.worktree` or recent's `secondaryMeta`) when the session lives outside the project root, and bold titles while unread. - `recent/` owns Recent and managed Chats activity projections.
- Folders render **flat** after the loose sessions: nested folders keep `parentId` in the data model but display at one level with a "Parent / Child" path label (`SessionFolderItem.displayName`); collapsing a folder hides its whole subtree. Folder actions resolve their owning scope per folder entry (folders from multiple worktree scopes can coexist under one project). - `folders/` owns folder DnD, bulk actions, archived folders, and folder UI.
- Archived sessions are not shown in the web/desktop sidebar; the Archive page (`ArchiveView`, `useUIStore.isArchivePageOpen`) replaces the old toggle. VS Code keeps inline archived buckets behind `showArchivedSessions` (compact webview has no page surfaces). Restore (unarchive) is available per session (row context menu, Archive page row) and in bulk (selection bar) and writes `time.archived = 0` — the server cannot clear the field over HTTP, so the global session cache splits active/archived client-side (see "Restore (unarchive) contract" in `sync/DOCUMENTATION.md`).
- Scheduled tasks (`ScheduledTasksDialog`, now a full-page surface on web/desktop) and per-project worktree management (`WorktreesView`, opened from the project menu) render as overlays inside `<main>` in `MainLayout`; the sidebar no longer mounts them.
- Group-level PR-status polling/indicators and worktree-group drag-to-reorder were removed together with the worktree grouping level; `oc.sessions.groupOrder` is no longer read or written. Worktree PR/branch context lives in the Worktrees surface.
- Root session menus can quickly create a worktree from the session directory's current branch and move the full session subtree there while idle.
- Managed Chats never offer the worktree-move action in either the sidebar row menu or the active-session header menu because their directories are not project repositories.
- Managed Chats use the shared Chats root as their folder scope. Their activity section renders the normal folder tree, and sessions created from a Chats folder are assigned back to that root-scoped folder after their date/session directory materializes. Per-session folder scopes created by older builds remain visible for compatibility.
- An empty Chats section says that there are no chats yet; it never reuses the project/workspace empty message.
- The New session keyboard command inherits the active materialized session directory. Explicit sidebar entry points, including the top New session row and the Chats `+`, open a fresh managed Chat draft instead.
- The new-worktree keyboard command is a silent no-op while a managed Chat draft is open. It must not retarget that draft to the active project or show a Git/worktree error because Chats never participate in worktrees.
- Directory loading is demand-driven: the sidebar publishes one complete priority plan for all known project/worktree directories, while the sync layer owns bounded execution.
- When multiple configured projects are checkouts of the same Git repository, exactly one project owns the shared worktree topology: the configured canonical primary root when present, otherwise the first configured source for that repository. Any worktree path that is also a configured project is omitted from subordinate worktree groups, so every directory has one sidebar location while remaining part of bootstrap demand.
## VS Code grouping `MainLayout` and `VSCodeLayout` call `useSessionListSync({ isVSCode })`
unconditionally. The hook publishes complete directory bootstrap demand,
refreshes newly added topology, coalesces control events, and performs
authoritative cleanup. Root-level `useGlobalSessionsPolling` remains the only
initial and 45-second global poller. `useSessionListSync` must not create a
second global polling lifecycle.
- VS Code uses the **same grouped project tree** as web/desktop (project headers + folders + pinned-first ordering), not a separate flat list. Each open VS Code workspace folder is a project header. The global sessions cache is the complete source for active and archived
- VS Code groups strictly **by open workspace**: `useSessionGrouping` funnels every non-archived session into the project's root group and emits **no per-worktree subgroups** (worktrees aren't registered in VS Code). `getSessionsForProject` buckets sessions to a workspace by exact directory match, so only sessions whose directory is an open workspace folder appear. coverage. Initialized directory stores only supply sessions missing from that
- VS Code passes `hideDirectoryControls` (clean workspace headers, no worktree/close chrome) and no longer passes `showOnlyMainWorkspace`/`sharedSessionsOnly`. Folders and pinning therefore work natively, scoped to the workspace root. cache. Live busy and retry state comes from `global-session-status`, never from
the global cache or persisted history. A failed global or directory fetch keeps
existing data; it is never treated as an authoritative empty list.
## File summaries Web and desktop show managed Chats before optional Recent activity. Chats use
their shared managed root for folders and never expose worktree actions. Project
display can be all projects or one selected project. VS Code excludes worktrees
and managed Chats, while retaining its workspace-scoped grouped list and inline
archived buckets.
### Components Directory demand always includes known project roots and worktrees. Visibility
only changes priority. Row mounts must not start bootstrap work. Selection and
- `SidebarHeader.tsx`: Top header UI for add-project, session search, selection mode, project sort, and the display menu (recent toggle, collapse/expand all). activity subscriptions stay session-scoped so a structural list update does not
- A successful add/create/clone from the project-directory dialog transitions to a new-session draft targeted at that project, matching the project's `+` action; changing project metadata alone must not leave the visible session or draft on a different directory. make every row observe unrelated streaming updates.
- `SidebarNav.tsx`: Text navigation rows above the tree (New session, Scheduled, Multi-run, Archive); hidden in VS Code.
- `SidebarActivitySections.tsx`: Global top section renderer for OpenChamber-managed `chats` followed by optional project-only `recent` sessions, styled as zone headers. The desktop sticky identity overlay follows the activity header whose sentinel has crossed the scroller edge, so a small scroll cannot relabel Chats as Recent.
- `SidebarFooter.tsx`: Static footer with icon-only settings, shortcuts, and about actions.
- `SidebarProjectsList.tsx`: Main scrollable renderer for project zones and their flat/archived groups plus empty/search states; owns project drag-to-reorder.
- `SessionGroupSection.tsx`: Renders one flat (or archived) group: sessions first, then flat folder entries with path labels, show-more batching, and explicit loading/error/retry state for empty groups. Archived buckets (VS Code) virtualize past 50 rows.
- `SessionNodeItem.tsx`: Renders one session row/tree node with a single-line layout, inline branch label, indicators, menu actions, and nested children. Pending-question counts stay per-session while expanded and roll up hidden descendants from their owning directory stores while collapsed. Rows do not initiate directory bootstrap on mount.
- `collapsedActivityIndicator.tsx`: Aggregate busy/unseen dot for collapsed groups and folders.
- `ConfirmDialogs.tsx`: Shared confirm dialog wrappers for session delete and folder delete flows.
- `sortableItems.tsx`: DnD sortable wrapper for project ordering plus the sticky zone-band project header and its action affordances.
- `sessionFolderDnd.tsx`: Folder/session DnD scope and wrappers for dropping/moving sessions into folders.
- `sessionOwnership.ts`: Resolves session directories once into shared project/worktree ownership and folder-scope indexes.
### Hooks
- `hooks/useSessionActions.ts`: Centralizes session row actions (select/open, rename, share/unshare, archive/delete, confirmations).
- `hooks/useSessionSearchEffects.ts`: Handles search open/close UX and input focus behavior.
- `hooks/useSessionPrefetch.ts`: Publishes directory-aware nearby/active session prefetch demand to the shared message loader. Recent may prefetch across projects without substituting the current directory.
- `hooks/useSessionGrouping.ts`: Builds grouped session structures and search text/filter helpers.
- `hooks/useSessionSidebarSections.ts`: Composes final per-project sections and group search metadata for rendering.
- `hooks/useProjectSessionSelection.ts`: Resolves active/current project-session selection logic and session-directory context.
- `hooks/useArchivedAutoFolders.ts`: Maintains archived auto-folder structure and assignment behavior.
- `hooks/useSidebarPersistence.ts`: Persists sidebar UI state (expanded/collapsed/pinned/group order/active session) to storage + desktop settings.
- `hooks/useProjectRepoStatus.ts`: Tracks per-project git-repo state and root branch metadata.
- `hooks/useProjectSessionLists.ts`: Reads live and archived project buckets from the shared ownership index.
- `hooks/useAuthoritativeSessionCleanup.ts`: Establishes the first complete active+archived list as a non-destructive baseline, then cleans persisted state only for sessions omitted by a later authoritative snapshot.
- `hooks/useStickyProjectHeaders.ts`: Tracks which project headers are sticky/stuck via `IntersectionObserver`.
### Types and utilities
- `types.ts`: Shared sidebar types (`SessionNode`, `SessionGroup`, summary/search metadata).
- `activitySections.ts`: Persisted top-section storage/helpers for the current `recent` session list.
- `sessionBootstrapDemands.ts`: Builds the deduplicated directory demand plan. Selected directories rank above active projects, expanded groups, visible collapsed groups, and background/collapsed projects.
- `utils.tsx`: Shared sidebar utilities (path normalization, dedupe, archived scope keys, project relation checks, text highlight, labels, compact/default date formatting). Shared session ranking lives in `sync/session-ordering.ts`.
## Loading rules
- Always publish every known project root and worktree directory. Collapse/visibility changes priority only; they do not opt a directory out of authoritative refresh.
- Current directory and selected-session directory are `selected` demand and therefore run first.
- Expanded projects/worktrees outrank merely visible and background groups.
- The sync scheduler deduplicates, promotes, retries, and limits work. Sidebar components must not reproduce that lifecycle with mount effects.
- Hide speculative work when the sidebar/chat surface is hidden: message prefetch, Git/PR enrichment and subscriptions, search listeners, sticky-header observation, and archived-folder derivation stop. The session row tree unmounts so row-owned status, permission, unseen, and viewport subscriptions do no background work. The outer sidebar remains mounted, preserving UI state and authoritative directory refresh for an immediate reopen; deferred derived work reruns from current state when visibility returns.
- The sidebar does not subscribe its whole tree to the cross-directory live-session aggregate. Global create/structural/lifecycle snapshots drive rendered session metadata; the cached sync index only fills sessions not yet present globally and provides refresh fallback data. Row activity continues to come from the session-keyed live status index.
- Session selection does not invalidate the sidebar orchestration component. Each mounted row selects only whether its own session ID is active, while parent expansion, project selection memory, and neighbor prefetch run in small effect-only subscribers.
- Parent expansion is exclusively manual. Selecting or navigating to a subsession never expands its parent automatically. Project/worktree and `recent` trees use independent persisted context keys and receive separate stable projections, so expansion changes in one context neither invalidate nor change the other. The persisted storage key remains `v3`; older state mixed contexts and is not migrated into this contract.
- Folder membership may contain both a parent session and its descendants. Rendering treats only the highest assigned ancestors as folder roots because their normal session trees already include assigned descendants; persisted membership remains unchanged for cleanup and move semantics.
- Sidebar selection holds the clicked row's viewport position across navigation-driven sidebar updates. Wheel or touch input cancels the hold immediately, so programmatic compensation never fights intentional scrolling.
- Global session subscriptions are structural: create/delete, title, share, archive, directory, parent, and slug changes invalidate the tree. Recency-only `time.updated` changes do not trigger a rebuild. The separate lifecycle rank invalidates ordering only on `settled ↔ active` transitions, with root sessions ranked among roots and child sessions only among siblings of the same parent.
- CLI/server-created sessions use the low-frequency OpenChamber control event stream to refresh only the created session directory. The same event retriggers bounded worktree discovery so a newly created external worktree gains ownership without a view reload; it does not re-enable broad session or streaming subscriptions.
- Recent membership includes active root sessions immediately even when their last committed `time.updated` falls outside the 48-hour window. Children and archived sessions remain excluded, and inactive roots remain timestamp-based. The active-ID subscription is disabled while the sidebar is hidden and ignores retry/status detail changes, avoiding streaming-frequency rerenders.
- Structural updates rebuild grouped nodes only for projects whose local sessions, worktrees, repository state, or branch changed; unchanged project sections preserve references so memoized group/session descendants skip the update wave.
- Empty successful lists, unresolved loads, and failed loads are separate UI states. Failed groups expose Retry and retain prior data.
- Directory permission failures remain visible even when stale sessions are retained. Flat groups inspect every represented root/worktree directory; local Desktop may open the native picker for the exact failed directory, while other runtimes keep the ordinary Retry action.
- Pins and folder assignments are not pruned from the first startup snapshot or from optimistic mutations. Confirmed local deletion and routed external deletion clean immediately; a later authoritative omission after an established baseline covers missed external delete events.
@@ -1,41 +0,0 @@
import type { Session } from '@opencode-ai/sdk/v2';
const RECENT_SESSION_MAX_AGE_MS = 48 * 60 * 60 * 1000;
const isSubtaskSession = (session: Session): boolean => {
return Boolean((session as Session & { parentID?: string | null }).parentID);
};
const isArchivedSession = (session: Session): boolean => {
return Boolean(session.time?.archived);
};
const getSessionUpdatedAt = (session: Session): number => {
const updated = session.time?.updated;
const created = session.time?.created;
if (typeof updated === 'number' && Number.isFinite(updated)) {
return updated;
}
if (typeof created === 'number' && Number.isFinite(created)) {
return created;
}
return 0;
};
// Recent contains non-archived root sessions that are active now or were
// updated within the retention window. The caller applies shared lifecycle
// ordering after this membership filter; batching ("Show more") handles long
// windows in the UI.
export const deriveRecentSessions = (
sessions: Session[],
activeSessionIds: ReadonlySet<string>,
now = Date.now(),
): Session[] => {
const minUpdatedAt = now - RECENT_SESSION_MAX_AGE_MS;
return sessions.filter((session) => {
if (isArchivedSession(session) || isSubtaskSession(session)) {
return false;
}
return activeSessionIds.has(session.id) || getSessionUpdatedAt(session) >= minUpdatedAt;
});
};
@@ -1,57 +0,0 @@
import type { Session } from '@opencode-ai/sdk/v2';
import type { SessionNode } from './types';
export type CollapsedActivityState = 'active' | 'unread' | null;
export const mergeCollapsedActivityStates = (
current: CollapsedActivityState,
next: CollapsedActivityState,
): CollapsedActivityState => {
if (current === 'active' || next === 'active') return 'active';
if (current === 'unread' || next === 'unread') return 'unread';
return null;
};
const getSessionNodeActivityState = (
node: SessionNode,
activeSessionIds: Set<string>,
unreadSessionIds: Set<string>,
includeUnreadSubtasks: boolean,
): CollapsedActivityState => {
if (activeSessionIds.has(node.session.id)) {
return 'active';
}
let state: CollapsedActivityState = null;
const isSubtask = Boolean((node.session as Session & { parentID?: string | null }).parentID);
if (unreadSessionIds.has(node.session.id) && (includeUnreadSubtasks || !isSubtask)) {
state = 'unread';
}
for (const child of node.children) {
state = mergeCollapsedActivityStates(
state,
getSessionNodeActivityState(child, activeSessionIds, unreadSessionIds, includeUnreadSubtasks),
);
if (state === 'active') return state;
}
return state;
};
export const getSessionNodesActivityState = (
nodes: SessionNode[],
activeSessionIds: Set<string>,
unreadSessionIds: Set<string>,
includeUnreadSubtasks: boolean,
): CollapsedActivityState => {
let state: CollapsedActivityState = null;
for (const node of nodes) {
state = mergeCollapsedActivityStates(
state,
getSessionNodeActivityState(node, activeSessionIds, unreadSessionIds, includeUnreadSubtasks),
);
if (state === 'active') return state;
}
return state;
};
@@ -0,0 +1,79 @@
import { describe, expect, mock, test } from 'bun:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { I18nProvider } from '@/lib/i18n';
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
import { installHookTestDom } from '../test-utils/testDom';
type BulkActionCapture = {
onCreateFolderAndMove: () => void;
};
let bulkActionCapture: BulkActionCapture | null = null;
mock.module('./BulkActionBar', () => ({
BulkActionBar: (props: BulkActionCapture) => {
bulkActionCapture = props;
return null;
},
}));
mock.module('./ConfirmDialogs', () => ({
BulkSessionDeleteConfirmDialog: () => null,
}));
const { SessionBulkActions } = await import('./SessionBulkActions');
describe('SessionBulkActions public behavior', () => {
test('moves the selected sessions into a newly created folder while a row edit is active', async () => {
const dom = installHookTestDom();
const root = createRoot(dom.container);
const originalFolders = useSessionFoldersStore.getState();
const originalSelection = useSessionMultiSelectStore.getState();
const cssDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'CSS');
const renameRequests: Array<{ scopeKey: string; folder: { id: string; name: string } }> = [];
const moved: Array<{ scopeKey: string; folderId: string; ids: string[] }> = [];
useSessionFoldersStore.setState({
foldersMap: {},
addSessionsToFolder: (scopeKey, folderId, ids) => moved.push({ scopeKey, folderId, ids }),
});
useSessionMultiSelectStore.setState({
enabled: true,
selectedIds: new Set(['session-a']),
scopeKey: 'project-a',
anchorId: 'session-a',
});
Object.defineProperty(globalThis, 'CSS', {
configurable: true,
value: { escape: (value: string) => value },
});
try {
await act(async () => root.render(
<I18nProvider>
<SessionBulkActions
getFolderScopesForProject={() => [{ scopeKey: '/workspace', directory: '/workspace' }]}
isInlineEditing
startFolderRename={(scopeKey, folder) => renameRequests.push({ scopeKey, folder })}
/>
</I18nProvider>,
));
expect(bulkActionCapture).not.toBeNull();
await act(async () => bulkActionCapture?.onCreateFolderAndMove());
const createdFolder = useSessionFoldersStore.getState().foldersMap['/workspace']?.[0];
expect(createdFolder?.name).toBe('New folder');
expect(renameRequests).toEqual([{ scopeKey: '/workspace', folder: createdFolder }]);
expect(moved).toEqual([{ scopeKey: '/workspace', folderId: createdFolder?.id ?? '', ids: ['session-a'] }]);
} finally {
await act(async () => root.unmount());
useSessionFoldersStore.setState(originalFolders, true);
useSessionMultiSelectStore.setState(originalSelection, true);
if (cssDescriptor) Object.defineProperty(globalThis, 'CSS', cssDescriptor);
else Reflect.deleteProperty(globalThis, 'CSS');
bulkActionCapture = null;
dom.restore();
}
});
});
@@ -0,0 +1,67 @@
import React from 'react';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
import { BulkActionBar } from './BulkActionBar';
import { BulkSessionDeleteConfirmDialog, type BulkDeleteSessionsConfirmState } from '../shell/ConfirmDialogs';
import { useSidebarBulkActions } from './useSidebarBulkActions';
type Props = {
getFolderScopesForProject: (projectId: string) => readonly { scopeKey: string; directory: string | null }[];
isInlineEditing: boolean;
startFolderRename: (scopeKey: string, folder: { id: string; name: string }) => void;
};
/** Owns the sidebar selection projection and its destructive confirmation. */
export function SessionBulkActions({ getFolderScopesForProject, isInlineEditing, startFolderRename }: Props): React.ReactNode {
const [bulkDeleteConfirm, setBulkDeleteConfirm] = React.useState<BulkDeleteSessionsConfirmState>(null);
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
const foldersMap = useSessionFoldersStore((state) => state.foldersMap);
const createFolder = useSessionFoldersStore((state) => state.createFolder);
const addSessionsToFolder = useSessionFoldersStore((state) => state.addSessionsToFolder);
const removeSessionsFromFolders = useSessionFoldersStore((state) => state.removeSessionsFromFolders);
const archiveSessions = useSessionUIStore((state) => state.archiveSessions);
const unarchiveSessions = useSessionUIStore((state) => state.unarchiveSessions);
const deleteSessions = useSessionUIStore((state) => state.deleteSessions);
const bulk = useSidebarBulkActions({
isInlineEditing,
showDeletionDialog,
foldersMap,
getFolderScopesForProject,
addSessionsToFolder,
removeSessionsFromFolders,
createFolderAndStartRename: (scopeKey) => {
const folder = createFolder(scopeKey, 'New folder');
startFolderRename(scopeKey, folder);
return folder;
},
archiveSessions,
unarchiveSessions,
deleteSessions,
setBulkDeleteConfirm,
});
return <>
{bulk.selectionModeEnabled && bulk.hasSelection ? <BulkActionBar
selectedCount={bulk.selectedIdsSize}
scopeKey={bulk.derivedSelectionScope}
scopeFolders={bulk.bulkScopeFolders}
archivedBucket={bulk.bulkScopeIsArchived}
onMoveToFolder={bulk.handleBulkMoveToFolder}
onCreateFolderAndMove={bulk.handleBulkCreateFolderAndMove}
onRemoveFromFolder={bulk.handleBulkRemoveFromFolder}
canRemoveFromFolder={bulk.bulkCanRemoveFromFolder}
onRestore={bulk.handleBulkRestore}
onDelete={bulk.handleBulkDelete}
onDone={bulk.handleExitSelectionMode}
/> : null}
<BulkSessionDeleteConfirmDialog
value={bulkDeleteConfirm}
setValue={setBulkDeleteConfirm}
showDeletionDialog={showDeletionDialog}
setShowDeletionDialog={setShowDeletionDialog}
onConfirm={bulk.confirmBulkDelete}
/>
</>;
}
@@ -0,0 +1,58 @@
import { describe, expect, mock, test } from 'bun:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { installHookTestDom } from '../test-utils/testDom';
type DragEnd = (event: {
active: { data: { current: { type: string; sessionId: string } } };
over: { data: { current: { type: string; folderId: string } } } | null;
}) => void;
let handleDragEnd: DragEnd | null = null;
mock.module('@dnd-kit/core', () => ({
DndContext: ({ children, onDragEnd }: { children: React.ReactNode; onDragEnd: DragEnd }) => {
handleDragEnd = onDragEnd;
return <>{children}</>;
},
DragOverlay: ({ children }: { children: React.ReactNode }) => <>{children}</>,
PointerSensor: class {},
closestCenter: () => null,
useSensor: () => null,
useSensors: () => [],
useDraggable: () => ({ attributes: {}, listeners: {}, setNodeRef: () => undefined, isDragging: false }),
useDroppable: () => ({ setNodeRef: () => undefined, isOver: false }),
}));
const { SessionFolderDndScope } = await import('./sessionFolderDnd');
describe('SessionFolderDndScope public behavior', () => {
test('routes a session-folder drop without depending on row edit or menu state', async () => {
const dom = installHookTestDom();
const root = createRoot(dom.container);
const drops: Array<{ sessionId: string; folderId: string }> = [];
try {
await act(async () => root.render(
<SessionFolderDndScope
scopeKey="/workspace"
hasFolders
onSessionDroppedOnFolder={(sessionId, folderId) => drops.push({ sessionId, folderId })}
>
{null}
</SessionFolderDndScope>,
));
expect(handleDragEnd).not.toBeNull();
await act(async () => handleDragEnd?.({
active: { data: { current: { type: 'session', sessionId: 'session-a' } } },
over: { data: { current: { type: 'folder', folderId: 'folder-a' } } },
}));
expect(drops).toEqual([{ sessionId: 'session-a', folderId: 'folder-a' }]);
} finally {
await act(async () => root.unmount());
handleDragEnd = null;
dom.restore();
}
});
});

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