From 69b76d909be20b2c6ff22ffa625afa9fc4b0fe3b Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 10 Jun 2026 16:47:50 +0800 Subject: [PATCH 01/31] feat(composer): add Tiptap rich-text composer foundation (Phase 0) Groundwork for replacing the plain-textarea message input with a Tiptap v3 rich-text composer (inline reference badges + live WYSIWYG Markdown). Phase 0 is the de-risk spike: deps, a standalone RichComposer, and the Markdown round-trip / IME-submit logic later phases build on. Not yet wired into message-input (that is Phase 3). - deps: @tiptap/{core,pm,react,starter-kit,suggestion,markdown, extension-placeholder} pinned exact 3.26.0 (peers require lockstep) - composer/editor-config.ts: shared StarterKit + Placeholder + Markdown set - composer/rich-composer.tsx: forwardRef editor w/ imperative handle, immediatelyRender:false (static-export safe), IME-safe Enter-to-submit - composer/submit-key.ts: pure Enter decision (IME / code block / list) - globals.css: scoped .codeg-composer prose + placeholder styles - test-setup.ts: jsdom polyfills so ProseMirror mounts headless in tests - tests: 39 (markdown round-trip incl. CJK, submit-key, component smoke) Verified: pnpm test (1069), eslint, build (static export) all green. Reviewed by Codex (APPROVED). Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 7 + pnpm-lock.yaml | 518 ++++++++++++++++++ src/app/globals.css | 86 +++ src/components/chat/composer/editor-config.ts | 41 ++ .../chat/composer/markdown-round-trip.test.ts | 147 +++++ .../chat/composer/rich-composer.test.tsx | 76 +++ .../chat/composer/rich-composer.tsx | 195 +++++++ .../chat/composer/submit-key.test.ts | 84 +++ src/components/chat/composer/submit-key.ts | 52 ++ src/test-setup.ts | 27 + 10 files changed, 1233 insertions(+) create mode 100644 src/components/chat/composer/editor-config.ts create mode 100644 src/components/chat/composer/markdown-round-trip.test.ts create mode 100644 src/components/chat/composer/rich-composer.test.tsx create mode 100644 src/components/chat/composer/rich-composer.tsx create mode 100644 src/components/chat/composer/submit-key.test.ts create mode 100644 src/components/chat/composer/submit-key.ts diff --git a/package.json b/package.json index b349f0328..21e4a0665 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,13 @@ "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.0", "@tauri-apps/plugin-window-state": "~2.4.1", + "@tiptap/core": "3.26.0", + "@tiptap/extension-placeholder": "3.26.0", + "@tiptap/markdown": "3.26.0", + "@tiptap/pm": "3.26.0", + "@tiptap/react": "3.26.0", + "@tiptap/starter-kit": "3.26.0", + "@tiptap/suggestion": "3.26.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-ligatures": "^0.10.0", "@xterm/addon-web-links": "^0.12.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index adaa8a61a..5062fecde 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,27 @@ importers: '@tauri-apps/plugin-window-state': specifier: ~2.4.1 version: 2.4.1 + '@tiptap/core': + specifier: 3.26.0 + version: 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/extension-placeholder': + specifier: 3.26.0 + version: 3.26.0(@tiptap/extensions@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) + '@tiptap/markdown': + specifier: 3.26.0 + version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + '@tiptap/pm': + specifier: 3.26.0 + version: 3.26.0 + '@tiptap/react': + specifier: 3.26.0 + version: 3.26.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tiptap/starter-kit': + specifier: 3.26.0 + version: 3.26.0 + '@tiptap/suggestion': + specifier: 3.26.0 + version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) '@xterm/addon-fit': specifier: ^0.11.0 version: 0.11.0 @@ -2406,6 +2427,172 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@tiptap/core@3.26.0': + resolution: {integrity: sha512-7jTed/RirIVsp+lLdLvGzGqF3EBGpnGHGYKOwz6t28V2BIJLAFdUhfEVdWie7xPxQNWK0TP+fPlsqZS0vxfHBg==} + peerDependencies: + '@tiptap/pm': 3.26.0 + + '@tiptap/extension-blockquote@3.26.0': + resolution: {integrity: sha512-57accpka9affjiJRjP2LMNCDJDTMjTvO23RJCxtP43sp9cTIZ7YZnyDfRxCINTRBNK0X4o4w2+emOLyRwsk3CA==} + peerDependencies: + '@tiptap/core': 3.26.0 + + '@tiptap/extension-bold@3.26.0': + resolution: {integrity: sha512-j6CzTMofcGJ5iMoUgDRQpM0FkG00jBID3aKqs+UBbgtzLgtG/CI/91tMFv0XPC30LeFA895qYgvGZtHdejZhiQ==} + peerDependencies: + '@tiptap/core': 3.26.0 + + '@tiptap/extension-bubble-menu@3.26.0': + resolution: {integrity: sha512-H2E3Hp0lV79jQV8YGtdDJkXkUalXZeYzKCx+vCZlDpb2ChS7/rNT9YY7poRA1NlJLUO0DH1wbAnFhx9KZMUx5g==} + peerDependencies: + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 + + '@tiptap/extension-bullet-list@3.26.0': + resolution: {integrity: sha512-Jv7BX+kBB2wUIvO/NhuUjv+T3kAed2Tjr664fgQ2zKT6X69jKIkYuCCedrIHuOyaOQ+SBDuH9h51wYv/E97QgQ==} + peerDependencies: + '@tiptap/extension-list': 3.26.0 + + '@tiptap/extension-code-block@3.26.0': + resolution: {integrity: sha512-WPN9iZ3UjeDD2ckDzSs9tleibXv0cLj7j575NxuvjhwZTehYGNeYDSUTi+6DQUG6bKbhGg9Wcei5H0131vvJHg==} + peerDependencies: + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 + + '@tiptap/extension-code@3.26.0': + resolution: {integrity: sha512-VJYcV6rvjnENRTroOi9tDcHWW6G0pmCoRETwatlbgfDzuCmkTOwVwQjeJCXOVMMLNPzNiXZzibsRCUt+Azq/jw==} + peerDependencies: + '@tiptap/core': 3.26.0 + + '@tiptap/extension-document@3.26.0': + resolution: {integrity: sha512-Xhd6DCjaxCN4otQNvV6qra+XuoIjk6Vyjm87E5xn5Y/BMw7UGAG7LTkk3C2IEvxKrVZwJjalfxEqdHOgXQzVfw==} + peerDependencies: + '@tiptap/core': 3.26.0 + + '@tiptap/extension-dropcursor@3.26.0': + resolution: {integrity: sha512-rhAtp5J/YVDUCUIc5T7b0XY9dLeuI72JgOr53w0QQc0VA0uwbfTn7sx0LI9PDCE9uwmDH8H3snVRZRnAvlM8oA==} + peerDependencies: + '@tiptap/extensions': 3.26.0 + + '@tiptap/extension-floating-menu@3.26.0': + resolution: {integrity: sha512-reQ77NRYAOP7iPudsNbzLBuBTdL2aGxZzjccUFmE2lNdmwP23n9A/JhkuUhshVBs/6IozvahI+smG3Bnea0TCQ==} + peerDependencies: + '@floating-ui/dom': ^1.0.0 + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 + + '@tiptap/extension-gapcursor@3.26.0': + resolution: {integrity: sha512-SIe68SDwx2fozt/XKG0FhCwzz/yRN6Bvo4D5TqvfDg6NK3PQb1DS4GN9PilmJqbY+kXryuiWEEJOWi7HpO8SuQ==} + peerDependencies: + '@tiptap/extensions': 3.26.0 + + '@tiptap/extension-hard-break@3.26.0': + resolution: {integrity: sha512-baXvv/rtOTVd2Axjb7Zbb41Y9Qmy3U2fP7EHqLuhViqGxVX8LwQtP0PHUXEZkPokbBpRez10+dmOlvvsYFKAZQ==} + peerDependencies: + '@tiptap/core': 3.26.0 + + '@tiptap/extension-heading@3.26.0': + resolution: {integrity: sha512-qenEQEgzE5FjQay/H6iKOnwIt6DPO27cS+v0mGhXmrL1MjrNER4X0ZkATJbVd0WA6ffsAGaP44NKYDworGeidw==} + peerDependencies: + '@tiptap/core': 3.26.0 + + '@tiptap/extension-horizontal-rule@3.26.0': + resolution: {integrity: sha512-a+N/C4wkQV+/8x4ShdoiC2JdTW3Tw84C5cAloYLFMeaWmRa2me9ACSI+zo0SO9bbH9RJwsoRp7eaxBbk27eF1Q==} + peerDependencies: + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 + + '@tiptap/extension-italic@3.26.0': + resolution: {integrity: sha512-s8oFpH+0xmhvY19f452/2dExO3p1tjxh761g6cg4irwEUNUEAJKF2VLcjiaeOhNJ+pmnQYxb+VSkwkXvO+7vHQ==} + peerDependencies: + '@tiptap/core': 3.26.0 + + '@tiptap/extension-link@3.26.0': + resolution: {integrity: sha512-FA/d157aBxyvZFvsdc5eSu46tmHWXebAsqOQSvivOMyw+deBb00VlMsf+iD2J8+sekjbMYwx/hvbsu+xUoX43Q==} + peerDependencies: + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 + + '@tiptap/extension-list-item@3.26.0': + resolution: {integrity: sha512-MccGyj9HY4fkl04eIiFoTCkr8067Jku/VVdJNtRWW104Spx43C/7V2zpbxPvpcDhq3dW384fDxYXfpnb186xLg==} + peerDependencies: + '@tiptap/extension-list': 3.26.0 + + '@tiptap/extension-list-keymap@3.26.0': + resolution: {integrity: sha512-oBcj6qaNrRHQ+N0+pDuOVAQa4Nx9r8Cm5ANvyM2lTpoy60sOLOizuVvcvw1andVxbSrsZ1N/Sk+RZWyv1uoWyQ==} + peerDependencies: + '@tiptap/extension-list': 3.26.0 + + '@tiptap/extension-list@3.26.0': + resolution: {integrity: sha512-EM8woyHDNKLEQ+lWUEoDtA4KrwP6fei/mYX1NxseMzKHHo7LFecx7wk6sovAXZrUvdML/yFBihgiMiO5VIsfkg==} + peerDependencies: + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 + + '@tiptap/extension-ordered-list@3.26.0': + resolution: {integrity: sha512-ItLdFlcMsJz2vhbs1PcUfcN7nzVqGBOwPeCrrWxjrgscp+K3JoOGD+HhVVpBACOMwivUrlh8Ry5Ohvues2nOeA==} + peerDependencies: + '@tiptap/extension-list': 3.26.0 + + '@tiptap/extension-paragraph@3.26.0': + resolution: {integrity: sha512-h8fYLikg4qN39IghQ1y9g+zzUsgxBpDi5YS3IZbWoxWYYx1YqLL8nAvOiPr7Us14aQ0TjA2/xY7zqmyf29rX1A==} + peerDependencies: + '@tiptap/core': 3.26.0 + + '@tiptap/extension-placeholder@3.26.0': + resolution: {integrity: sha512-q9RsGWmL0xEwrMK5Wx53eMdZlkA3J1cqloIc69rEugjOCjoExEkfF4e2nC9YK49qGxJZBKKksw1Ij7oCWEyu+g==} + peerDependencies: + '@tiptap/extensions': 3.26.0 + + '@tiptap/extension-strike@3.26.0': + resolution: {integrity: sha512-jUll3Pqhq7u1JKvO0B6USW/bmVmUsO6sRcxo/d5tXqLhS0tWAobOGoGU2IgwXnQDSjf+vF73RYD5tRGDLkRC9Q==} + peerDependencies: + '@tiptap/core': 3.26.0 + + '@tiptap/extension-text@3.26.0': + resolution: {integrity: sha512-yZXdevp3/8omGbb40Z52VfvID+tsRNhPQ1GNUToD56XSr2BjdJyAzAb9rWGgDKgVMUPLgJ26yT0O278RFqOKhA==} + peerDependencies: + '@tiptap/core': 3.26.0 + + '@tiptap/extension-underline@3.26.0': + resolution: {integrity: sha512-LlVkivH5cBwov/EMD8BL7ZRcU6YcadiSVIffLW1hyalw9YfhaFzoLxjtWhL7jiU/n2Kg+9dXSZxmV2hTeTwyrQ==} + peerDependencies: + '@tiptap/core': 3.26.0 + + '@tiptap/extensions@3.26.0': + resolution: {integrity: sha512-4wajuqnO2X0+LVvsBjW/xk3/tmdb16bNL939QhicAay4YYqXITeV2v3XJsryzmG4L5GkK1yLxvRGk4aLoxWrnA==} + peerDependencies: + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 + + '@tiptap/markdown@3.26.0': + resolution: {integrity: sha512-jg5xrwl1gTXUl5JA3+g8YYfhOzplM9CVecwKZeFtlYtPLyxLCmIDvqV/vULoGu57HwtY4819nNpMZwY6jBNtrw==} + peerDependencies: + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 + + '@tiptap/pm@3.26.0': + resolution: {integrity: sha512-q4RDeWwVrhOL0jJCGRgGxLSdjOYwzQ4h2InURZVhC66433ipcHd6f3bqSOhcXZ4r0sFmMNsuF7aZmUntjWLc7w==} + + '@tiptap/react@3.26.0': + resolution: {integrity: sha512-NLPAG6tk4/AsfOsUNsbGqdgIHuGsD4A/hlYriozuo+LCAAduuluhzsL/MEHZXtFT4GXUOlCdaEqNCOrMuz/zaw==} + peerDependencies: + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tiptap/starter-kit@3.26.0': + resolution: {integrity: sha512-o34EtMfqtBaljdmeElZsRG/067oGx9Zcq+j2GWo71KlZe22ga/ALexeTf1c+ETsjCxSTKR6eyQ4RZvz/2JpYfg==} + + '@tiptap/suggestion@3.26.0': + resolution: {integrity: sha512-3jxBvjmfooQroR0eCw61kSgr+g90KFVD3dM5bANhpQbCpCYRydp/iXDQmpoPHjv8FpeU5JZgcnJ3R9vhjeIM2A==} + peerDependencies: + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 + '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} @@ -2576,6 +2763,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} @@ -3801,6 +3991,10 @@ packages: fast-diff@1.3.0: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-equals@5.4.0: + resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} + engines: {node: '>=6.0.0'} + fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -4599,6 +4793,9 @@ packages: linkify-it@3.0.3: resolution: {integrity: sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==} + linkifyjs@4.3.3: + resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -5116,6 +5313,9 @@ packages: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} + orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} @@ -5295,6 +5495,45 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + prosemirror-changeset@2.4.1: + resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==} + + prosemirror-commands@1.7.1: + resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} + + prosemirror-dropcursor@1.8.2: + resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==} + + prosemirror-gapcursor@1.4.1: + resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==} + + prosemirror-history@1.5.0: + resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} + + prosemirror-inputrules@1.5.1: + resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} + + prosemirror-keymap@1.2.3: + resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} + + prosemirror-model@1.25.7: + resolution: {integrity: sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug==} + + prosemirror-schema-list@1.5.1: + resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} + + prosemirror-state@1.4.4: + resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} + + prosemirror-tables@1.8.5: + resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} + + prosemirror-transform@1.12.0: + resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} + + prosemirror-view@1.41.8: + resolution: {integrity: sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -5521,6 +5760,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} @@ -6193,6 +6435,9 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -8362,6 +8607,193 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 + '@tiptap/core@3.26.0(@tiptap/pm@3.26.0)': + dependencies: + '@tiptap/pm': 3.26.0 + + '@tiptap/extension-blockquote@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + + '@tiptap/extension-bold@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + + '@tiptap/extension-bubble-menu@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': + dependencies: + '@floating-ui/dom': 1.7.5 + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 + optional: true + + '@tiptap/extension-bullet-list@3.26.0(@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/extension-list': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + + '@tiptap/extension-code-block@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 + + '@tiptap/extension-code@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + + '@tiptap/extension-document@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + + '@tiptap/extension-dropcursor@3.26.0(@tiptap/extensions@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/extensions': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + + '@tiptap/extension-floating-menu@3.26.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': + dependencies: + '@floating-ui/dom': 1.7.5 + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 + optional: true + + '@tiptap/extension-gapcursor@3.26.0(@tiptap/extensions@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/extensions': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + + '@tiptap/extension-hard-break@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + + '@tiptap/extension-heading@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + + '@tiptap/extension-horizontal-rule@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 + + '@tiptap/extension-italic@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + + '@tiptap/extension-link@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 + linkifyjs: 4.3.3 + + '@tiptap/extension-list-item@3.26.0(@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/extension-list': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + + '@tiptap/extension-list-keymap@3.26.0(@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/extension-list': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + + '@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 + + '@tiptap/extension-ordered-list@3.26.0(@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/extension-list': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + + '@tiptap/extension-paragraph@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + + '@tiptap/extension-placeholder@3.26.0(@tiptap/extensions@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/extensions': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + + '@tiptap/extension-strike@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + + '@tiptap/extension-text@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + + '@tiptap/extension-underline@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + + '@tiptap/extensions@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 + + '@tiptap/markdown@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 + marked: 17.0.1 + + '@tiptap/pm@3.26.0': + dependencies: + prosemirror-changeset: 2.4.1 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-gapcursor: 1.4.1 + prosemirror-history: 1.5.0 + prosemirror-inputrules: 1.5.1 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.7 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + + '@tiptap/react@3.26.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 + '@types/react': 19.2.13 + '@types/react-dom': 19.2.3(@types/react@19.2.13) + '@types/use-sync-external-store': 0.0.6 + fast-equals: 5.4.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@tiptap/extension-bubble-menu': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + '@tiptap/extension-floating-menu': 3.26.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + transitivePeerDependencies: + - '@floating-ui/dom' + + '@tiptap/starter-kit@3.26.0': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/extension-blockquote': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-bold': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-bullet-list': 3.26.0(@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) + '@tiptap/extension-code': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-code-block': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + '@tiptap/extension-document': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-dropcursor': 3.26.0(@tiptap/extensions@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) + '@tiptap/extension-gapcursor': 3.26.0(@tiptap/extensions@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) + '@tiptap/extension-hard-break': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-heading': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-horizontal-rule': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + '@tiptap/extension-italic': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-link': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + '@tiptap/extension-list': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + '@tiptap/extension-list-item': 3.26.0(@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) + '@tiptap/extension-list-keymap': 3.26.0(@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) + '@tiptap/extension-ordered-list': 3.26.0(@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) + '@tiptap/extension-paragraph': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-strike': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-text': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-underline': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extensions': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 + + '@tiptap/suggestion@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -8565,6 +8997,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/use-sync-external-store@0.0.6': {} + '@types/validate-npm-package-name@4.0.2': {} '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3)': @@ -10009,6 +10443,8 @@ snapshots: fast-diff@1.3.0: {} + fast-equals@5.4.0: {} + fast-glob@3.3.1: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -10850,6 +11286,8 @@ snapshots: dependencies: uc.micro: 1.0.6 + linkifyjs@4.3.3: {} + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -11629,6 +12067,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.2 + orderedmap@2.1.1: {} + outvariant@1.4.3: {} overlayscrollbars-react@0.5.6(overlayscrollbars@2.15.1)(react@19.2.4): @@ -11792,6 +12232,80 @@ snapshots: property-information@7.1.0: {} + prosemirror-changeset@2.4.1: + dependencies: + prosemirror-transform: 1.12.0 + + prosemirror-commands@1.7.1: + dependencies: + prosemirror-model: 1.25.7 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-dropcursor@1.8.2: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + + prosemirror-gapcursor@1.4.1: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.7 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.8 + + prosemirror-history@1.5.0: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + rope-sequence: 1.3.4 + + prosemirror-inputrules@1.5.1: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-keymap@1.2.3: + dependencies: + prosemirror-state: 1.4.4 + w3c-keyname: 2.2.8 + + prosemirror-model@1.25.7: + dependencies: + orderedmap: 2.1.1 + + prosemirror-schema-list@1.5.1: + dependencies: + prosemirror-model: 1.25.7 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-state@1.4.4: + dependencies: + prosemirror-model: 1.25.7 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + + prosemirror-tables@1.8.5: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.7 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + + prosemirror-transform@1.12.0: + dependencies: + prosemirror-model: 1.25.7 + + prosemirror-view@1.41.8: + dependencies: + prosemirror-model: 1.25.7 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -12147,6 +12661,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.4 fsevents: 2.3.3 + rope-sequence@1.3.4: {} + roughjs@4.6.6: dependencies: hachure-fill: 0.5.2 @@ -12971,6 +13487,8 @@ snapshots: vscode-uri@3.1.0: {} + w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 diff --git a/src/app/globals.css b/src/app/globals.css index fa1b08d4c..c053a2239 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1457,3 +1457,89 @@ background-color: rgba(248, 81, 73, 0.35); } } + +/* ───────────────── Rich-text composer (Tiptap) ───────────────── + Live WYSIWYG Markdown styling for the message composer. Scoped to + `.codeg-composer` so it never leaks into Streamdown message rendering. */ +.codeg-composer .ProseMirror { + outline: none; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.55; +} + +/* Tight block rhythm — a chat input, not a document. */ +.codeg-composer .ProseMirror > * { + margin: 0; +} +.codeg-composer .ProseMirror > * + * { + margin-top: 0.5rem; +} + +.codeg-composer .ProseMirror :is(h1, h2, h3, h4) { + font-weight: 600; + line-height: 1.3; +} +.codeg-composer .ProseMirror h1 { + font-size: 1.25rem; +} +.codeg-composer .ProseMirror h2 { + font-size: 1.125rem; +} +.codeg-composer .ProseMirror :is(h3, h4) { + font-size: 1rem; +} + +.codeg-composer .ProseMirror :is(ul, ol) { + padding-left: 1.25rem; +} +.codeg-composer .ProseMirror ul { + list-style: disc; +} +.codeg-composer .ProseMirror ol { + list-style: decimal; +} +.codeg-composer .ProseMirror li > p { + margin: 0; +} + +.codeg-composer .ProseMirror blockquote { + border-left: 2px solid var(--border); + padding-left: 0.75rem; + color: var(--muted-foreground); +} + +.codeg-composer .ProseMirror code { + font-family: + var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.85em; + background: var(--muted); + padding: 0.1em 0.3em; + border-radius: 0.25rem; +} +.codeg-composer .ProseMirror pre { + background: var(--muted); + border-radius: 0.5rem; + padding: 0.6rem 0.75rem; + overflow-x: auto; +} +.codeg-composer .ProseMirror pre code { + background: transparent; + padding: 0; + font-size: 0.8125rem; +} + +.codeg-composer .ProseMirror a { + color: var(--primary); + text-decoration: underline; + text-underline-offset: 2px; +} + +/* Placeholder painted by @tiptap/extension-placeholder on the empty doc. */ +.codeg-composer .ProseMirror p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + color: var(--muted-foreground); + float: left; + height: 0; + pointer-events: none; +} diff --git a/src/components/chat/composer/editor-config.ts b/src/components/chat/composer/editor-config.ts new file mode 100644 index 000000000..c139e444b --- /dev/null +++ b/src/components/chat/composer/editor-config.ts @@ -0,0 +1,41 @@ +import type { Extensions } from "@tiptap/core" +import { Markdown } from "@tiptap/markdown" +import { Placeholder } from "@tiptap/extension-placeholder" +import StarterKit from "@tiptap/starter-kit" + +/** + * Options for the shared composer extension set. Kept intentionally small for + * Phase 0; trigger/suggestion + reference-node extensions are layered on in + * later phases via additional entries to {@link buildComposerExtensions}. + */ +export interface ComposerExtensionOptions { + /** Placeholder shown when the document is empty. */ + placeholder?: string +} + +/** + * Build the Tiptap extension set powering the rich-text composer. + * + * Shared by the live editor ({@link "./rich-composer".RichComposer}) and the + * headless editor used in tests, so the Markdown round-trip exercised by tests + * matches what users actually type. + * + * StarterKit (v3) already bundles paragraph/heading/lists/bold/italic/strike/ + * code/codeBlock/blockquote/link/history/hardBreak and the relevant input + * rules, which gives us live WYSIWYG Markdown. `Markdown` adds + * `editor.getMarkdown()` / `editor.markdown.parse()` for serialization. + */ +export function buildComposerExtensions( + options: ComposerExtensionOptions = {} +): Extensions { + return [ + StarterKit, + Placeholder.configure({ + placeholder: options.placeholder ?? "", + // Only paint the placeholder while the editor is editable so a disabled + // composer reads as empty rather than as a hint. + showOnlyWhenEditable: true, + }), + Markdown, + ] +} diff --git a/src/components/chat/composer/markdown-round-trip.test.ts b/src/components/chat/composer/markdown-round-trip.test.ts new file mode 100644 index 000000000..1da37bddb --- /dev/null +++ b/src/components/chat/composer/markdown-round-trip.test.ts @@ -0,0 +1,147 @@ +import { Editor } from "@tiptap/core" +import type { JSONContent } from "@tiptap/core" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { buildComposerExtensions } from "./editor-config" + +/** + * Headless editor sharing the exact extension set the live composer uses, so + * these round-trips reflect what users actually type. This is the most reliable + * automated de-risk for Phase 0 (IME / auto-grow need a real browser). + */ +function makeEditor(): Editor { + return new Editor({ extensions: buildComposerExtensions() }) +} + +/** The Markdown manager is always present (Markdown extension is always loaded). */ +function markdown(editor: Editor) { + if (!editor.markdown) { + throw new Error("Markdown extension not loaded") + } + return editor.markdown +} + +function parse(editor: Editor, md: string): JSONContent { + return markdown(editor).parse(md) +} + +function serialize(editor: Editor, doc: JSONContent): string { + return markdown(editor).serialize(doc) +} + +/** Collect every mark type name appearing anywhere in the doc tree. */ +function markNames(node: JSONContent): Set { + const names = new Set() + const walk = (n: JSONContent) => { + n.marks?.forEach((m) => names.add(m.type)) + n.content?.forEach(walk) + } + walk(node) + return names +} + +/** Collect every node type name appearing anywhere in the doc tree. */ +function nodeNames(node: JSONContent): Set { + const names = new Set() + const walk = (n: JSONContent) => { + if (n.type) names.add(n.type) + n.content?.forEach(walk) + } + walk(node) + return names +} + +describe("composer markdown engine", () => { + let editor: Editor + + beforeEach(() => { + editor = makeEditor() + }) + + afterEach(() => { + editor?.destroy() + }) + + describe("parse produces the expected structure", () => { + it("parses bold", () => { + expect(markNames(parse(editor, "a **b** c"))).toContain("bold") + }) + + it("parses italic", () => { + expect(markNames(parse(editor, "a *b* c"))).toContain("italic") + }) + + it("parses inline code", () => { + expect(markNames(parse(editor, "use `x` here"))).toContain("code") + }) + + it("parses a heading with the right level", () => { + const doc = parse(editor, "## Title") + const heading = doc.content?.find((n) => n.type === "heading") + expect(heading).toBeDefined() + expect(heading?.attrs?.level).toBe(2) + }) + + it("parses a bullet list", () => { + expect(nodeNames(parse(editor, "- one\n- two"))).toContain("bulletList") + }) + + it("parses an ordered list", () => { + expect(nodeNames(parse(editor, "1. one\n2. two"))).toContain( + "orderedList" + ) + }) + + it("parses a blockquote", () => { + expect(nodeNames(parse(editor, "> quoted"))).toContain("blockquote") + }) + + it("parses a fenced code block", () => { + expect(nodeNames(parse(editor, "```\nconst x = 1\n```"))).toContain( + "codeBlock" + ) + }) + }) + + describe("serialize(parse(md)) is stable (idempotent)", () => { + // Markdown has many equivalent spellings, so we assert stability of the + // serializer's canonical form rather than byte-equality with the input. + it.each([ + ["bold", "a **b** c"], + ["italic", "a *b* c"], + ["inline code", "use `x` here"], + ["heading", "## Title"], + ["bullet list", "- one\n- two"], + ["ordered list", "1. one\n2. two"], + ["blockquote", "> quoted"], + ["code block", "```\nconst x = 1\n```"], + ["mixed", "# Title\n\nSome **bold** and `code`.\n\n- a\n- b\n\n> note"], + ["cjk", "你好,**世界**,这是 `代码`。"], + ])("%s", (_name, md) => { + const once = serialize(editor, parse(editor, md)) + const twice = serialize(editor, parse(editor, once)) + expect(twice).toBe(once) + expect(once.length).toBeGreaterThan(0) + }) + }) + + describe("editor.getMarkdown() reflects typed content", () => { + it("serializes inserted markdown content back to markdown", () => { + editor.commands.setContent("Hello **world**", { contentType: "markdown" }) + const md = editor.getMarkdown() + expect(md).toContain("**world**") + }) + + it("round-trips a heading through getMarkdown", () => { + editor.commands.setContent("# Heading", { contentType: "markdown" }) + expect(editor.getMarkdown().trim()).toBe("# Heading") + }) + + it("preserves CJK text", () => { + editor.commands.setContent("发送给智能体的消息", { + contentType: "markdown", + }) + expect(editor.getMarkdown()).toContain("发送给智能体的消息") + }) + }) +}) diff --git a/src/components/chat/composer/rich-composer.test.tsx b/src/components/chat/composer/rich-composer.test.tsx new file mode 100644 index 000000000..76068b9b1 --- /dev/null +++ b/src/components/chat/composer/rich-composer.test.tsx @@ -0,0 +1,76 @@ +import { act, render, waitFor } from "@testing-library/react" +import { createRef } from "react" +import { describe, expect, it, vi } from "vitest" + +import { RichComposer, type RichComposerHandle } from "./rich-composer" + +/** Wait until the editor has mounted (immediatelyRender:false makes it async). */ +async function mount(props: React.ComponentProps = {}) { + const ref = createRef() + const result = render() + await waitFor(() => expect(ref.current?.getEditor()).not.toBeNull()) + return { ref, ...result } +} + +describe("RichComposer", () => { + it("mounts and reports an empty document via the handle", async () => { + const { ref } = await mount() + expect(ref.current?.isEmpty()).toBe(true) + expect(ref.current?.getMarkdown()).toBe("") + }) + + it("paints the placeholder on the empty document", async () => { + const { ref, container } = await mount({ placeholder: "Ask anything" }) + expect(ref.current).not.toBeNull() + expect( + container.querySelector('[data-placeholder="Ask anything"]') + ).not.toBeNull() + }) + + it("exposes an accessible multiline textbox", async () => { + const { container } = await mount({ ariaLabel: "Message" }) + const textbox = container.querySelector('[role="textbox"]') + expect(textbox).not.toBeNull() + expect(textbox).toHaveAttribute("aria-multiline", "true") + expect(textbox).toHaveAttribute("aria-label", "Message") + }) + + it("round-trips markdown through the handle and notifies onChange", async () => { + const onChange = vi.fn() + const { ref } = await mount({ onChange }) + + act(() => { + ref.current?.setMarkdown("hello **world**") + }) + + expect(ref.current?.getMarkdown()).toContain("**world**") + expect(ref.current?.isEmpty()).toBe(false) + expect(onChange).toHaveBeenCalled() + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1] + expect(lastCall?.[0]).toContain("**world**") + + act(() => { + ref.current?.clear() + }) + expect(ref.current?.isEmpty()).toBe(true) + }) + + it("preserves CJK content through the handle", async () => { + const { ref } = await mount() + act(() => { + ref.current?.setMarkdown("发送给智能体的消息") + }) + expect(ref.current?.getMarkdown()).toContain("发送给智能体的消息") + }) + + it("initializes from defaultMarkdown without firing onChange", async () => { + const onChange = vi.fn() + const { ref } = await mount({ + defaultMarkdown: "# Heading", + onChange, + }) + expect(ref.current?.getMarkdown().trim()).toBe("# Heading") + // onCreate sets content with emitUpdate:false → no spurious change events. + expect(onChange).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/chat/composer/rich-composer.tsx b/src/components/chat/composer/rich-composer.tsx new file mode 100644 index 000000000..ad75a0723 --- /dev/null +++ b/src/components/chat/composer/rich-composer.tsx @@ -0,0 +1,195 @@ +"use client" + +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + type CSSProperties, +} from "react" +import { type Editor } from "@tiptap/core" +import { EditorContent, useEditor } from "@tiptap/react" + +import { cn } from "@/lib/utils" + +import { buildComposerExtensions } from "./editor-config" +import { shouldSubmitOnEnter } from "./submit-key" + +/** + * Imperative handle exposed to the parent (e.g. the message input that owns + * attachments, queue and send orchestration). The parent reads/writes Markdown + * and controls focus without re-rendering the editor. + */ +export interface RichComposerHandle { + /** Serialize the current document to Markdown. */ + getMarkdown: () => string + /** Replace the whole document from a Markdown string. */ + setMarkdown: (markdown: string) => void + /** Clear the document. */ + clear: () => void + /** Focus the editor at the end of the document. */ + focus: () => void + /** Whether the document is empty (no text, no nodes). */ + isEmpty: () => boolean + /** Escape hatch to the underlying editor (null until initialized). */ + getEditor: () => Editor | null +} + +export interface RichComposerProps { + /** Initial content, parsed as Markdown. Applied once on creation. */ + defaultMarkdown?: string + placeholder?: string + autoFocus?: boolean + disabled?: boolean + /** Accessible label for the editing surface. */ + ariaLabel?: string + /** Outer wrapper className (host controls border/ring/max-height). */ + className?: string + /** Inline style for the outer wrapper (e.g. max-height). */ + style?: CSSProperties + /** + * Fires on every document change with the serialized Markdown. Serialization + * runs once per keystroke *only when a handler is attached* (the call is + * skipped entirely otherwise). Callers that persist drafts must debounce — + * the Phase 3 draft layer owns that. + */ + onChange?: (markdown: string) => void + /** + * Submit intent: Enter without Shift, while not composing (IME-safe) and not + * inside a code block. The host decides what "submit" means. + */ + onSubmit?: () => void + onFocus?: () => void + onBlur?: () => void +} + +/** + * Phase 0 rich-text composer: a Tiptap editor with live WYSIWYG Markdown and + * IME-safe Enter-to-submit. Reference badges and the unified `@` panel are + * layered on in later phases; this component is the foundation that de-risks + * IME, auto-grow and Markdown round-trip. + */ +export const RichComposer = forwardRef( + function RichComposer( + { + defaultMarkdown, + placeholder, + autoFocus, + disabled, + ariaLabel, + className, + style, + onChange, + onSubmit, + onFocus, + onBlur, + }, + ref + ) { + // Keep callbacks in refs so the editor (and its keymap) is created once and + // never torn down just because a parent re-renders with new closures. + const onChangeRef = useRef(onChange) + const onSubmitRef = useRef(onSubmit) + const onFocusRef = useRef(onFocus) + const onBlurRef = useRef(onBlur) + useEffect(() => { + onChangeRef.current = onChange + onSubmitRef.current = onSubmit + onFocusRef.current = onFocus + onBlurRef.current = onBlur + }) + + const editor = useEditor({ + // Static export / SSR safety: never render on the server. + immediatelyRender: false, + extensions: buildComposerExtensions({ placeholder }), + editable: !disabled, + autofocus: autoFocus ? "end" : false, + editorProps: { + attributes: { + class: "codeg-composer-content", + role: "textbox", + "aria-multiline": "true", + ...(ariaLabel ? { "aria-label": ariaLabel } : {}), + }, + handleKeyDown: (view, event) => { + // Only Enter is special; let everything else fall through cheaply. + if (event.key !== "Enter") return false + // Resolve structural context: code blocks and list items keep Enter + // (newline / list split) instead of submitting. + const { $from } = view.state.selection + let inCodeBlock = $from.parent.type.spec.code === true + let inList = false + for (let depth = $from.depth; depth > 0; depth--) { + const name = $from.node(depth).type.name + if (name === "codeBlock") inCodeBlock = true + if (name === "listItem" || name === "taskItem") inList = true + } + const submit = shouldSubmitOnEnter( + { + key: event.key, + shiftKey: event.shiftKey, + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + isComposing: event.isComposing, + keyCode: (event as { keyCode?: number }).keyCode ?? 0, + }, + { composing: view.composing, inCodeBlock, inList } + ) + if (submit && onSubmitRef.current) { + onSubmitRef.current() + return true + } + return false + }, + }, + onCreate: ({ editor }) => { + if (defaultMarkdown) { + editor.commands.setContent(defaultMarkdown, { + contentType: "markdown", + emitUpdate: false, + }) + } + }, + onUpdate: ({ editor }) => { + onChangeRef.current?.(editor.getMarkdown()) + }, + onFocus: () => onFocusRef.current?.(), + onBlur: () => onBlurRef.current?.(), + }) + + // Reflect disabled changes onto the live editor. Pass emitUpdate=false so + // toggling editability never fires onUpdate/onChange without a real edit. + useEffect(() => { + editor?.setEditable(!disabled, false) + }, [editor, disabled]) + + useImperativeHandle( + ref, + (): RichComposerHandle => ({ + getMarkdown: () => editor?.getMarkdown() ?? "", + setMarkdown: (markdown) => + editor?.commands.setContent(markdown, { contentType: "markdown" }), + clear: () => editor?.commands.clearContent(true), + focus: () => editor?.commands.focus("end"), + isEmpty: () => editor?.isEmpty ?? true, + getEditor: () => editor ?? null, + }), + [editor] + ) + + return ( +
+ +
+ ) + } +) diff --git a/src/components/chat/composer/submit-key.test.ts b/src/components/chat/composer/submit-key.test.ts new file mode 100644 index 000000000..8c34e6f00 --- /dev/null +++ b/src/components/chat/composer/submit-key.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest" + +import { + shouldSubmitOnEnter, + type SubmitKeyContext, + type SubmitKeyEvent, +} from "./submit-key" + +const plainEnter: SubmitKeyEvent = { + key: "Enter", + shiftKey: false, + altKey: false, + ctrlKey: false, + metaKey: false, + isComposing: false, + keyCode: 13, +} + +const topLevel: SubmitKeyContext = { + composing: false, + inCodeBlock: false, + inList: false, +} + +describe("shouldSubmitOnEnter", () => { + it("submits on a plain Enter at the top level", () => { + expect(shouldSubmitOnEnter(plainEnter, topLevel)).toBe(true) + }) + + it("ignores non-Enter keys", () => { + expect(shouldSubmitOnEnter({ ...plainEnter, key: "a" }, topLevel)).toBe( + false + ) + }) + + it.each([ + ["Shift", { shiftKey: true }], + ["Alt", { altKey: true }], + ["Ctrl", { ctrlKey: true }], + ["Meta", { metaKey: true }], + ])("does not submit with the %s modifier (newline / shortcut)", (_n, mod) => { + expect(shouldSubmitOnEnter({ ...plainEnter, ...mod }, topLevel)).toBe(false) + }) + + describe("IME guard", () => { + it("does not submit while event.isComposing", () => { + expect( + shouldSubmitOnEnter({ ...plainEnter, isComposing: true }, topLevel) + ).toBe(false) + }) + + it("does not submit on the legacy keyCode 229 sentinel", () => { + expect( + shouldSubmitOnEnter({ ...plainEnter, keyCode: 229 }, topLevel) + ).toBe(false) + }) + + it("does not submit while view.composing", () => { + expect( + shouldSubmitOnEnter(plainEnter, { ...topLevel, composing: true }) + ).toBe(false) + }) + }) + + describe("structural Enter", () => { + it("does not submit inside a code block", () => { + expect( + shouldSubmitOnEnter(plainEnter, { ...topLevel, inCodeBlock: true }) + ).toBe(false) + }) + + it("does not submit inside a list item", () => { + expect( + shouldSubmitOnEnter(plainEnter, { ...topLevel, inList: true }) + ).toBe(false) + }) + }) + + it("submits on Enter immediately after composition ends (no IME flags set)", () => { + // Post-composition Enter: isComposing false, keyCode normal, view not + // composing — this is a genuine submit, not a candidate confirmation. + expect(shouldSubmitOnEnter(plainEnter, topLevel)).toBe(true) + }) +}) diff --git a/src/components/chat/composer/submit-key.ts b/src/components/chat/composer/submit-key.ts new file mode 100644 index 000000000..9808228b3 --- /dev/null +++ b/src/components/chat/composer/submit-key.ts @@ -0,0 +1,52 @@ +/** + * Pure keyboard-decision logic for the composer, extracted so the IME / code + * block / list precedence can be unit-tested exhaustively without driving a + * real ProseMirror view (jsdom can't emulate IME composition reliably). + */ + +/** The subset of a keydown event the submit decision depends on. */ +export interface SubmitKeyEvent { + key: string + shiftKey: boolean + altKey: boolean + ctrlKey: boolean + metaKey: boolean + /** Standard DOM flag: a composition (IME) is in flight. */ + isComposing: boolean + /** Legacy 229 sentinel some IMEs report on the composition-confirming key. */ + keyCode: number +} + +/** Editor context that overrides plain Enter-to-submit with structural Enter. */ +export interface SubmitKeyContext { + /** ProseMirror `view.composing` — composition in flight (second IME signal). */ + composing: boolean + /** Caret is inside a code block → Enter inserts a newline. */ + inCodeBlock: boolean + /** Caret is inside a list item → Enter creates/exits the list item. */ + inList: boolean +} + +/** + * Decide whether a keydown should trigger submit. Returns `true` only for a + * plain Enter (no modifiers), while not composing, and not inside a code block + * or list. In every other case the editor keeps its default behavior (newline / + * list split / IME confirm). + */ +export function shouldSubmitOnEnter( + event: SubmitKeyEvent, + context: SubmitKeyContext +): boolean { + if (event.key !== "Enter") return false + if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) { + return false + } + // IME guard: never submit while a composition is in flight. The Enter that + // confirms a CJK candidate reports isComposing / keyCode 229 / view.composing. + if (event.isComposing || event.keyCode === 229 || context.composing) { + return false + } + // Structural Enter inside code blocks and lists (per the composer design). + if (context.inCodeBlock || context.inList) return false + return true +} diff --git a/src/test-setup.ts b/src/test-setup.ts index b9e762299..ae8719eab 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -1 +1,28 @@ import "@testing-library/jest-dom/vitest" + +// jsdom doesn't implement a few layout APIs that ProseMirror's EditorView +// touches on mount (used by Tiptap-based editors such as the message composer). +// Polyfill them as no-ops so headless/component editor tests can construct a +// view. Only defined when missing, so real browsers/environments are untouched. +if (typeof document !== "undefined" && !document.elementFromPoint) { + document.elementFromPoint = () => null +} +if (typeof Range !== "undefined") { + Range.prototype.getClientRects ??= () => + ({ + length: 0, + item: () => null, + [Symbol.iterator]: function* () {}, + }) as unknown as DOMRectList + Range.prototype.getBoundingClientRect ??= () => + ({ + top: 0, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + x: 0, + y: 0, + }) as DOMRect +} From b7932b0e32c78acb81bcbe13dd7ecece545574da Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 10 Jun 2026 20:37:06 +0800 Subject: [PATCH 02/31] feat(composer): add inline reference node + badge rendering (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generic inline `reference` ProseMirror atom node rendering any of five kinds (file/agent/session/commit/skill) as a non-editable React badge, with an `insertReference` command and injection-safe Markdown serialization. Not yet wired into message-input (Phase 3); the `@` panel that populates references is Phase 2. - composer/types.ts: ReferenceKind / ReferenceMeta / ReferenceAttrs - composer/reference-text.ts: referenceToMarkdown — `[label](uri)` for file/session/commit, `@label` for agent, `/id` for skill; escapes all inline-significant punctuation, angle-wraps + escapes link destinations, and code-spans URL/email-like free text so a crafted label/uri cannot inject a second link, autolink, image or emphasis - composer/nodes/reference-node.ts: atom node (data-* HTML round-trip, renderMarkdown, insertReference); validates ref-type + allowlists uri schemes (file:/codeg:) on untrusted HTML parse - composer/nodes/reference-view.tsx + badges/reference-badge.tsx: node view + presentational chip (AgentIcon/lucide, session status dot) - editor-config.ts: register Reference; globals.css: .codeg-reference styles - tests: 76 composer (reference-text incl. adversarial injection, parse-based one-link/zero-link, paste hardening, node insert/markdown/HTML) Verified: pnpm test (1106), eslint, tsc --noEmit, build (static export) green. Reviewed by Codex (APPROVED after 4 rounds hardening Markdown injection). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/globals.css | 11 ++ .../chat/composer/badges/reference-badge.tsx | 82 ++++++++ src/components/chat/composer/editor-config.ts | 9 +- .../chat/composer/markdown-round-trip.test.ts | 74 ++++++++ .../composer/nodes/reference-node.test.tsx | 175 ++++++++++++++++++ .../chat/composer/nodes/reference-node.ts | 134 ++++++++++++++ .../chat/composer/nodes/reference-view.tsx | 21 +++ .../chat/composer/reference-text.test.ts | 154 +++++++++++++++ .../chat/composer/reference-text.ts | 99 ++++++++++ .../chat/composer/rich-composer.test.tsx | 6 +- src/components/chat/composer/types.ts | 66 +++++++ 11 files changed, 827 insertions(+), 4 deletions(-) create mode 100644 src/components/chat/composer/badges/reference-badge.tsx create mode 100644 src/components/chat/composer/nodes/reference-node.test.tsx create mode 100644 src/components/chat/composer/nodes/reference-node.ts create mode 100644 src/components/chat/composer/nodes/reference-view.tsx create mode 100644 src/components/chat/composer/reference-text.test.ts create mode 100644 src/components/chat/composer/reference-text.ts create mode 100644 src/components/chat/composer/types.ts diff --git a/src/app/globals.css b/src/app/globals.css index c053a2239..6503bac3a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1543,3 +1543,14 @@ height: 0; pointer-events: none; } + +/* Inline reference badges (atom node view). */ +.codeg-composer .codeg-reference { + cursor: default; + user-select: none; + white-space: normal; +} +.codeg-composer .codeg-reference.ProseMirror-selectednode [data-reference-badge] { + outline: 2px solid var(--ring); + outline-offset: 1px; +} diff --git a/src/components/chat/composer/badges/reference-badge.tsx b/src/components/chat/composer/badges/reference-badge.tsx new file mode 100644 index 000000000..ec7b0cc55 --- /dev/null +++ b/src/components/chat/composer/badges/reference-badge.tsx @@ -0,0 +1,82 @@ +import { Bot, FileText, Folder, GitCommit, Hash, Sparkles } from "lucide-react" + +import { AgentIcon } from "@/components/agent-icon" +import { + STATUS_COLORS, + type AgentType, + type ConversationStatus, +} from "@/lib/types" +import { cn } from "@/lib/utils" + +import type { ReferenceAttrs } from "../types" + +const ICON_CLASS = "size-3.5 shrink-0" + +function ReferenceIcon({ data }: { data: ReferenceAttrs }) { + const meta = data.meta + switch (data.refType) { + case "file": + return meta?.fileKind === "dir" ? ( + + ) : ( + + ) + case "agent": { + const agentType = meta?.agentType ?? (data.id as AgentType) + return agentType ? ( + + ) : ( + + ) + } + case "session": + return meta?.agentType ? ( + + ) : ( + + ) + case "commit": + return + case "skill": + return + default: + return null + } +} + +export interface ReferenceBadgeProps { + data: ReferenceAttrs + className?: string +} + +/** + * Presentational inline chip for a reference. Shared by the editor node view and + * (later) message-transcript rendering. Purely visual — no editor coupling. + */ +export function ReferenceBadge({ data, className }: ReferenceBadgeProps) { + const statusColor = + data.refType === "session" && data.meta?.status + ? STATUS_COLORS[data.meta.status as ConversationStatus] + : undefined + + return ( + + + {data.label || data.id} + {statusColor && ( + + )} + + ) +} diff --git a/src/components/chat/composer/editor-config.ts b/src/components/chat/composer/editor-config.ts index c139e444b..0f6a317de 100644 --- a/src/components/chat/composer/editor-config.ts +++ b/src/components/chat/composer/editor-config.ts @@ -3,10 +3,12 @@ import { Markdown } from "@tiptap/markdown" import { Placeholder } from "@tiptap/extension-placeholder" import StarterKit from "@tiptap/starter-kit" +import { Reference } from "./nodes/reference-node" + /** - * Options for the shared composer extension set. Kept intentionally small for - * Phase 0; trigger/suggestion + reference-node extensions are layered on in - * later phases via additional entries to {@link buildComposerExtensions}. + * Options for the shared composer extension set. The `@`/`/` suggestion + * extensions are layered on in Phase 2 via additional entries to + * {@link buildComposerExtensions}. */ export interface ComposerExtensionOptions { /** Placeholder shown when the document is empty. */ @@ -37,5 +39,6 @@ export function buildComposerExtensions( showOnlyWhenEditable: true, }), Markdown, + Reference, ] } diff --git a/src/components/chat/composer/markdown-round-trip.test.ts b/src/components/chat/composer/markdown-round-trip.test.ts index 1da37bddb..5e32bdc7d 100644 --- a/src/components/chat/composer/markdown-round-trip.test.ts +++ b/src/components/chat/composer/markdown-round-trip.test.ts @@ -3,6 +3,7 @@ import type { JSONContent } from "@tiptap/core" import { afterEach, beforeEach, describe, expect, it } from "vitest" import { buildComposerExtensions } from "./editor-config" +import { referenceToMarkdown } from "./reference-text" /** * Headless editor sharing the exact extension set the live composer uses, so @@ -144,4 +145,77 @@ describe("composer markdown engine", () => { expect(editor.getMarkdown()).toContain("发送给智能体的消息") }) }) + + describe("reference serialization is injection-safe", () => { + function countLinks(node: JSONContent): number { + let count = 0 + const walk = (n: JSONContent) => { + if (n.marks?.some((m) => m.type === "link")) count += 1 + n.content?.forEach(walk) + } + walk(node) + return count + } + + it("a crafted reference uri yields exactly one link when re-parsed", () => { + // If the destination weren't fully escaped, the parser would split this + // into a second `[pwn](http://evil)` link. + const md = referenceToMarkdown({ + refType: "file", + id: "", + label: "f", + uri: "file:///a/\\> [pwn](http://evil)", + meta: null, + }) + expect(countLinks(parse(editor, md))).toBe(1) + }) + + it.each([ + ["bracket breakout", "a](http://evil) x"], + ["inline-link injection", "a[foo](http://evil)"], + ["autolink injection", "see now"], + ])( + "a crafted reference label (%s) yields exactly one link when re-parsed", + (_name, label) => { + const md = referenceToMarkdown({ + refType: "session", + id: "1", + label, + uri: "codeg://session/1", + meta: null, + }) + expect(countLinks(parse(editor, md))).toBe(1) + } + ) + + it.each([ + ["bare url", "http://evil.com"], + ["www", "www.evil.com"], + ["email", "user@evil.com"], + ["image-shaped", "![x](http://evil)"], + ])( + "a no-uri reference label (%s) produces no link when re-parsed", + (_name, label) => { + const md = referenceToMarkdown({ + refType: "file", + id: "", + label, + uri: null, + meta: null, + }) + expect(countLinks(parse(editor, md))).toBe(0) + } + ) + + it("a URL-like agent label produces no link when re-parsed", () => { + const md = referenceToMarkdown({ + refType: "agent", + id: "x", + label: "http://evil.com", + uri: null, + meta: null, + }) + expect(countLinks(parse(editor, md))).toBe(0) + }) + }) }) diff --git a/src/components/chat/composer/nodes/reference-node.test.tsx b/src/components/chat/composer/nodes/reference-node.test.tsx new file mode 100644 index 000000000..44c5fad37 --- /dev/null +++ b/src/components/chat/composer/nodes/reference-node.test.tsx @@ -0,0 +1,175 @@ +import { generateJSON, type Editor, type JSONContent } from "@tiptap/core" +import { act, render, waitFor } from "@testing-library/react" +import { createRef } from "react" +import { describe, expect, it } from "vitest" + +import { buildComposerExtensions } from "../editor-config" +import { RichComposer, type RichComposerHandle } from "../rich-composer" +import type { ReferenceAttrs } from "../types" + +async function mountEditor() { + const ref = createRef() + const result = render() + // Generous timeout: editor construction can be slow under parallel worker + // CPU contention. + await waitFor(() => expect(ref.current?.getEditor()).not.toBeNull(), { + timeout: 5000, + }) + return { ref, ...result } +} + +function editorOf(ref: React.RefObject): Editor { + const editor = ref.current?.getEditor() + if (!editor) throw new Error("editor not mounted") + return editor +} + +function findReference(doc: JSONContent): JSONContent | undefined { + if (doc.type === "reference") return doc + for (const child of doc.content ?? []) { + const found = findReference(child) + if (found) return found + } + return undefined +} + +const fileRef: ReferenceAttrs = { + refType: "file", + id: "src/app.ts", + label: "app.ts", + uri: "file:///repo/src/app.ts", + meta: { fileKind: "file" }, +} +const agentRef: ReferenceAttrs = { + refType: "agent", + id: "claude_code", + label: "Claude Code", + uri: null, + meta: { agentType: "claude_code" }, +} +const sessionRef: ReferenceAttrs = { + refType: "session", + id: "123", + label: "Login refactor", + uri: "codeg://session/123", + meta: { agentType: "codex", status: "in_progress" }, +} +const commitRef: ReferenceAttrs = { + refType: "commit", + id: "abc1234def", + label: "abc1234", + uri: "codeg://commit/repo@abc1234def", + meta: { message: "fix login", shortHash: "abc1234" }, +} +const skillRef: ReferenceAttrs = { + refType: "skill", + id: "code-review", + label: "code-review", + uri: null, + meta: { scope: "project" }, +} + +describe("Reference node", () => { + it("inserts a reference node carrying the given attrs", async () => { + const { ref } = await mountEditor() + const editor = editorOf(ref) + act(() => { + editor.commands.insertReference(fileRef) + }) + const node = findReference(editor.getJSON()) + expect(node).toBeDefined() + expect(node?.attrs).toMatchObject({ + refType: "file", + id: "src/app.ts", + label: "app.ts", + uri: "file:///repo/src/app.ts", + }) + }) + + it.each([ + ["file", fileRef, "[app.ts](file:///repo/src/app.ts)"], + ["agent", agentRef, "@Claude Code"], + ["session", sessionRef, "[Login refactor](codeg://session/123)"], + ["commit", commitRef, "[abc1234](codeg://commit/repo@abc1234def)"], + ["skill", skillRef, "/code-review"], + ])( + "serializes a %s reference to its markdown token", + async (_n, attrs, expected) => { + const { ref, unmount } = await mountEditor() + const editor = editorOf(ref) + act(() => { + editor.commands.insertReference(attrs as ReferenceAttrs) + }) + expect(editor.getMarkdown()).toContain(expected as string) + unmount() + } + ) + + it("renders a badge with an icon and label in the editor DOM", async () => { + const { ref, container } = await mountEditor() + const editor = editorOf(ref) + act(() => { + editor.commands.insertReference(agentRef) + }) + const badge = await waitFor( + () => { + const el = container.querySelector( + '[data-reference-badge][data-ref-type="agent"]' + ) + expect(el).not.toBeNull() + return el as HTMLElement + }, + { timeout: 5000 } + ) + expect(badge.textContent).toContain("Claude Code") + expect(badge.querySelector("svg")).not.toBeNull() + }) + + it("round-trips through HTML (renderHTML → parseHTML)", async () => { + const { ref } = await mountEditor() + const editor = editorOf(ref) + act(() => { + editor.commands.insertReference(commitRef) + }) + const html = editor.getHTML() + expect(html).toContain("data-reference") + + // Re-parse the serialized HTML through the schema (copy/paste path). + const json = generateJSON(html, buildComposerExtensions()) + const node = findReference(json) + expect(node?.attrs).toMatchObject({ + refType: "commit", + id: "abc1234def", + uri: "codeg://commit/repo@abc1234def", + }) + expect(node?.attrs?.meta).toMatchObject({ shortHash: "abc1234" }) + }) + + describe("untrusted HTML parse hardening", () => { + it("drops a reference uri with a disallowed scheme", () => { + const html = + '

' + const node = findReference(generateJSON(html, buildComposerExtensions())) + expect(node).toBeDefined() + expect(node?.attrs?.uri).toBeNull() + }) + + it("keeps an allowed file:// uri", () => { + const html = + '

' + const node = findReference(generateJSON(html, buildComposerExtensions())) + expect(node?.attrs?.uri).toBe("file:///repo/x.ts") + }) + + it("coerces an unknown ref type to file", () => { + const html = + '

' + const node = findReference(generateJSON(html, buildComposerExtensions())) + expect(node?.attrs?.refType).toBe("file") + }) + }) +}) diff --git a/src/components/chat/composer/nodes/reference-node.ts b/src/components/chat/composer/nodes/reference-node.ts new file mode 100644 index 000000000..79e22c960 --- /dev/null +++ b/src/components/chat/composer/nodes/reference-node.ts @@ -0,0 +1,134 @@ +import { mergeAttributes, Node, type JSONContent } from "@tiptap/core" +import { ReactNodeViewRenderer } from "@tiptap/react" + +import { referenceToMarkdown } from "../reference-text" +import { + REFERENCE_KINDS, + type ReferenceAttrs, + type ReferenceKind, + type ReferenceMeta, +} from "../types" +import { ReferenceView } from "./reference-view" + +const NODE_NAME = "reference" + +/** Coerce a parsed (possibly pasted) value to a known kind, defaulting to file. */ +function parseRefType(raw: string | null): ReferenceKind { + return REFERENCE_KINDS.includes(raw as ReferenceKind) + ? (raw as ReferenceKind) + : "file" +} + +// Only schemes the composer itself emits. Reference URIs parsed from pasted +// HTML are an untrusted input, so anything else (javascript:, data:, http:, …) +// is dropped to null rather than carried into a ResourceLink on send. +const ALLOWED_URI_SCHEMES = ["file:", "codeg:"] + +/** Keep a parsed reference URI only if it uses a scheme the composer emits. */ +function parseUri(raw: string | null): string | null { + if (!raw) return null + const lower = raw.toLowerCase() + return ALLOWED_URI_SCHEMES.some((scheme) => lower.startsWith(scheme)) + ? raw + : null +} + +declare module "@tiptap/core" { + interface Commands { + reference: { + /** Insert an inline reference badge (file/agent/session/commit/skill). */ + insertReference: (attrs: ReferenceAttrs) => ReturnType + } + } +} + +function parseMeta(raw: string | null): ReferenceMeta | null { + if (!raw) return null + try { + return JSON.parse(raw) as ReferenceMeta + } catch { + return null + } +} + +/** + * Inline atom node that embeds a reference (file/agent/session/commit/skill) as + * a single, non-editable badge. One generic node keyed on `refType` keeps the + * schema and serialization centralized; the badge switches on `refType`. + * + * - `renderMarkdown` → human-readable token (see {@link referenceToMarkdown}). + * - `parseHTML`/`renderHTML` carry `data-*` attrs so copy/paste and HTML-based + * round-trips reconstruct the node. + */ +export const Reference = Node.create({ + name: NODE_NAME, + group: "inline", + inline: true, + atom: true, + selectable: true, + draggable: false, + + addAttributes() { + return { + refType: { + default: "file" as ReferenceKind, + parseHTML: (el) => parseRefType(el.getAttribute("data-ref-type")), + renderHTML: (attrs) => ({ "data-ref-type": attrs.refType }), + }, + id: { + default: "", + parseHTML: (el) => el.getAttribute("data-ref-id") ?? "", + renderHTML: (attrs) => ({ "data-ref-id": attrs.id }), + }, + label: { + default: "", + parseHTML: (el) => el.getAttribute("data-label") ?? "", + renderHTML: (attrs) => ({ "data-label": attrs.label }), + }, + uri: { + default: null, + parseHTML: (el) => parseUri(el.getAttribute("data-uri")), + renderHTML: (attrs) => (attrs.uri ? { "data-uri": attrs.uri } : {}), + }, + meta: { + default: null, + parseHTML: (el) => parseMeta(el.getAttribute("data-meta")), + renderHTML: (attrs) => + attrs.meta ? { "data-meta": JSON.stringify(attrs.meta) } : {}, + }, + } + }, + + parseHTML() { + return [{ tag: "span[data-reference]" }] + }, + + renderHTML({ node, HTMLAttributes }) { + return [ + "span", + mergeAttributes(HTMLAttributes, { "data-reference": "" }), + referenceToMarkdown(node.attrs as ReferenceAttrs), + ] + }, + + renderText({ node }) { + return referenceToMarkdown(node.attrs as ReferenceAttrs) + }, + + renderMarkdown(node: JSONContent) { + return referenceToMarkdown(node.attrs as ReferenceAttrs) + }, + + addNodeView() { + return ReactNodeViewRenderer(ReferenceView) + }, + + addCommands() { + return { + insertReference: + (attrs: ReferenceAttrs) => + ({ commands }) => + commands.insertContent({ type: NODE_NAME, attrs }), + } + }, +}) diff --git a/src/components/chat/composer/nodes/reference-view.tsx b/src/components/chat/composer/nodes/reference-view.tsx new file mode 100644 index 000000000..e10bb08a5 --- /dev/null +++ b/src/components/chat/composer/nodes/reference-view.tsx @@ -0,0 +1,21 @@ +import { NodeViewWrapper, type ReactNodeViewProps } from "@tiptap/react" + +import { ReferenceBadge } from "../badges/reference-badge" +import type { ReferenceAttrs } from "../types" + +/** + * React node view for the `reference` atom. Renders the inline badge and marks + * the surface non-editable so the caret treats the whole reference as one unit. + */ +export function ReferenceView({ node }: ReactNodeViewProps) { + const attrs = node.attrs as ReferenceAttrs + return ( + + + + ) +} diff --git a/src/components/chat/composer/reference-text.test.ts b/src/components/chat/composer/reference-text.test.ts new file mode 100644 index 000000000..3cab33065 --- /dev/null +++ b/src/components/chat/composer/reference-text.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest" + +import { referenceToMarkdown } from "./reference-text" +import type { ReferenceAttrs } from "./types" + +function ref(partial: Partial): ReferenceAttrs { + return { + refType: "file", + id: "", + label: "", + uri: null, + meta: null, + ...partial, + } +} + +describe("referenceToMarkdown", () => { + it("renders a file as a markdown link to its file:// uri", () => { + expect( + referenceToMarkdown( + ref({ refType: "file", label: "app.ts", uri: "file:///repo/app.ts" }) + ) + ).toBe("[app.ts](file:///repo/app.ts)") + }) + + it("renders a session as a markdown link to its codeg:// uri", () => { + expect( + referenceToMarkdown( + ref({ refType: "session", label: "Login", uri: "codeg://session/123" }) + ) + ).toBe("[Login](codeg://session/123)") + }) + + it("renders a commit as a markdown link", () => { + expect( + referenceToMarkdown( + ref({ + refType: "commit", + label: "abc1234", + uri: "codeg://commit/repo@abc1234def", + }) + ) + ).toBe("[abc1234](codeg://commit/repo@abc1234def)") + }) + + it("renders an agent as @label (no uri)", () => { + expect( + referenceToMarkdown( + ref({ refType: "agent", id: "claude_code", label: "Claude Code" }) + ) + ).toBe("@Claude Code") + }) + + it("renders a skill as a /invocation token from its id", () => { + expect( + referenceToMarkdown(ref({ refType: "skill", id: "code-review" })) + ).toBe("/code-review") + }) + + it("uses the stable skill id for invocation, not the display label", () => { + expect( + referenceToMarkdown( + ref({ refType: "skill", id: "code-review", label: "Code Review" }) + ) + ).toBe("/code-review") + }) + + it("neutralizes a skill with no id (no broken /command)", () => { + expect( + referenceToMarkdown( + ref({ refType: "skill", id: "", label: "Code Review" }) + ) + ).toBe("") + }) + + describe("markdown injection is neutralized", () => { + it("escapes brackets and parens in link text so a label cannot break out", () => { + expect( + referenceToMarkdown( + ref({ + refType: "session", + label: "a](http://evil) x", + uri: "codeg://session/1", + }) + ) + ).toBe("[a\\]\\(http://evil\\) x](codeg://session/1)") + }) + + it("escapes backticks in link text", () => { + expect( + referenceToMarkdown( + ref({ refType: "session", label: "a`b", uri: "codeg://session/2" }) + ) + ).toBe("[a\\`b](codeg://session/2)") + }) + + it("angle-wraps a destination containing spaces or parentheses", () => { + expect( + referenceToMarkdown( + ref({ refType: "file", label: "f", uri: "file:///a/b (1).ts" }) + ) + ).toBe("[f]()") + }) + + it("escapes backslashes inside an angle-wrapped destination", () => { + // A literal "\>" must not become escaped-backslash + closing ">", which + // would end the destination early and allow a second link to be injected. + // "\" -> "\\" and ">" -> "\>" ⇒ "\\\>". + expect( + referenceToMarkdown( + ref({ refType: "file", label: "f", uri: "file:///a/\\> x" }) + ) + ).toBe("[f]( x>)") + }) + + it("collapses newlines in a label to a single space", () => { + expect( + referenceToMarkdown(ref({ refType: "agent", label: "line1\nline2" })) + ).toBe("@line1 line2") + }) + + it("escapes brackets in an agent label", () => { + expect(referenceToMarkdown(ref({ refType: "agent", label: "a]b" }))).toBe( + "@a\\]b" + ) + }) + + it("code-spans a URL-like agent label so it cannot autolink", () => { + expect( + referenceToMarkdown(ref({ refType: "agent", label: "http://evil" })) + ).toBe("@`http://evil`") + }) + + it("code-spans a URL-like no-uri fallback label", () => { + expect( + referenceToMarkdown( + ref({ refType: "file", label: "www.evil.com", uri: null }) + ) + ).toBe("`www.evil.com`") + }) + }) + + it("falls back to the bare label when a uri type has no uri", () => { + expect( + referenceToMarkdown(ref({ refType: "file", label: "app.ts", uri: null })) + ).toBe("app.ts") + }) + + it("falls back to id when label is empty", () => { + expect( + referenceToMarkdown(ref({ refType: "agent", id: "codex", label: "" })) + ).toBe("@codex") + }) +}) diff --git a/src/components/chat/composer/reference-text.ts b/src/components/chat/composer/reference-text.ts new file mode 100644 index 000000000..9bdcbf1d1 --- /dev/null +++ b/src/components/chat/composer/reference-text.ts @@ -0,0 +1,99 @@ +import type { ReferenceAttrs } from "./types" + +/** Collapse newline runs to a single space so a reference stays one inline token. */ +function collapseNewlines(text: string): string { + return text.replace(/\s*[\r\n]+\s*/g, " ") +} + +/** + * Escape text emitted as raw inline Markdown (Tiptap inserts a custom + * `renderMarkdown` result verbatim). Backslash-escapes every inline-significant + * ASCII punctuation char so a crafted label cannot inject Markdown structure + * (links `[]()`, autolinks `<>`, code spans `` ` ``, emphasis `* _`, + * strikethrough `~`, or escapes `\`). + */ +function escapeMarkdownText(text: string): string { + return text.replace(/[\\`*_~[\]()<>]/g, "\\$&") +} + +// GFM extended autolinks fire on bare URLs / `www.` / emails even when the +// structural punctuation above is escaped, so backslash-escaping is not enough +// for free-standing text (it is enough inside `[...]` link text, where GFM does +// not nest links). Detect those triggers and render such text as a code span, +// which never autolinks and reproduces the text literally. +const AUTOLINK_TRIGGER = /(?:https?|ftp|mailto):|www\.|@/i + +/** Wrap text in a Markdown code span with a fence long enough to be literal. */ +function toInlineCode(text: string): string { + const runs = text.match(/`+/g) + const longest = runs ? Math.max(...runs.map((run) => run.length)) : 0 + const fence = "`".repeat(longest + 1) + // Per CommonMark, a code span beginning/ending with a backtick or space needs + // a padding space (which the renderer strips back off). + const pad = /^[`\s]|[`\s]$/.test(text) ? " " : "" + return `${fence}${pad}${text}${pad}${fence}` +} + +/** + * Render free-standing inline text (agent label, no-URI fallback) safely: + * code-span it when it could trigger a GFM autolink, otherwise escape the + * inline-significant punctuation. Normal labels are unaffected. + */ +function inlineText(text: string): string { + const flat = collapseNewlines(text) + return AUTOLINK_TRIGGER.test(flat) + ? toInlineCode(flat) + : escapeMarkdownText(flat) +} + +/** + * Render a Markdown link destination safely. URIs containing spaces, + * parentheses, angle brackets or backslashes (e.g. `file:///a/b (1).ts` or a + * Windows `file:///C:\dir\`) are wrapped in `<…>` so a `)` or trailing `\` + * can't terminate / escape the link early. Inside `<…>` CommonMark still + * interprets backslash escapes, so `\`, `<` and `>` are all escaped; newlines + * are stripped. Clean URLs stay bare. + */ +function escapeLinkDestination(uri: string): string { + const cleaned = uri.replace(/[\r\n]+/g, "") + return /[\s()<>\\]/.test(cleaned) + ? `<${cleaned.replace(/[\\<>]/g, "\\$&")}>` + : cleaned +} + +/** + * Canonical human-readable Markdown text for a reference. Used by the node's + * `renderMarkdown` (so `editor.getMarkdown()` and Markdown drafts read well) and + * reused by Phase 3 send serialization. + * + * References with a URI render as a Markdown link `[label](uri)` — matching how + * the backend's `user_blocks_from_prompt` already folds ResourceLinks into + * `[name](uri)`. Agents render as `@label`. Skills render as the `/id` + * invocation token (the stable id, never the possibly-localized display label). + * Every interpolated label/uri is escaped — and free-standing URL/email-like + * text is code-spanned — so a crafted reference cannot inject Markdown + * structure (a second link, an autolink, an image, emphasis, …) into the prompt. + */ +export function referenceToMarkdown(attrs: ReferenceAttrs): string { + switch (attrs.refType) { + case "agent": + return `@${inlineText(attrs.label || attrs.id)}` + case "skill": { + // Invocation token: the stable id is what the agent executes. The label + // (possibly localized / containing spaces) is never used; an empty id is + // neutralized to nothing rather than emitting a broken `/command`. + const token = collapseNewlines(attrs.id).trim() + return token ? `/${token}` : "" + } + case "file": + case "session": + case "commit": { + const text = collapseNewlines(attrs.label || attrs.id) + return attrs.uri + ? `[${escapeMarkdownText(text)}](${escapeLinkDestination(attrs.uri)})` + : inlineText(text) + } + default: + return inlineText(attrs.label || attrs.id) + } +} diff --git a/src/components/chat/composer/rich-composer.test.tsx b/src/components/chat/composer/rich-composer.test.tsx index 76068b9b1..f5aaedcf7 100644 --- a/src/components/chat/composer/rich-composer.test.tsx +++ b/src/components/chat/composer/rich-composer.test.tsx @@ -8,7 +8,11 @@ import { RichComposer, type RichComposerHandle } from "./rich-composer" async function mount(props: React.ComponentProps = {}) { const ref = createRef() const result = render() - await waitFor(() => expect(ref.current?.getEditor()).not.toBeNull()) + // Generous timeout: editor construction (ProseMirror + React node view) can + // be slow under parallel worker CPU contention. + await waitFor(() => expect(ref.current?.getEditor()).not.toBeNull(), { + timeout: 5000, + }) return { ref, ...result } } diff --git a/src/components/chat/composer/types.ts b/src/components/chat/composer/types.ts new file mode 100644 index 000000000..54978f40c --- /dev/null +++ b/src/components/chat/composer/types.ts @@ -0,0 +1,66 @@ +import type { AgentType } from "@/lib/types" + +/** The five kinds of inline reference the composer can embed. */ +export type ReferenceKind = "file" | "agent" | "session" | "commit" | "skill" + +export const REFERENCE_KINDS: readonly ReferenceKind[] = [ + "file", + "agent", + "session", + "commit", + "skill", +] + +/** + * Type-specific render hints carried alongside a reference. All fields are + * optional — the badge reads only what its `refType` needs, and serialization + * never depends on `meta`. + */ +export interface ReferenceMeta { + /** file: whether the entry is a directory. */ + fileKind?: "file" | "dir" + /** agent/session: agent type, drives the icon. */ + agentType?: AgentType + /** agent: whether the agent is currently available. */ + available?: boolean + /** session: conversation status (drives the status dot). */ + status?: string + /** session: git branch. */ + branch?: string | null + /** commit: short hash for display. */ + shortHash?: string + /** commit: first line of the commit message. */ + message?: string + /** commit: author name. */ + author?: string + /** commit: whether the commit is pushed upstream. */ + pushed?: boolean | null + /** skill: "global" | "project" scope. */ + scope?: string + /** skill: category grouping. */ + category?: string + /** skill: lucide icon name. */ + icon?: string | null +} + +/** + * The attribute payload stored on a `reference` ProseMirror node. Mirrors the + * data the `@` panel collects per source and the badge renders. + */ +export interface ReferenceAttrs { + refType: ReferenceKind + /** + * Stable identity: file relative path / agent_type / session id / + * commit full hash / skill id. + */ + id: string + /** Human-readable display label. */ + label: string + /** + * Serialization URI (`file://…` / `codeg://…`) used when sending, or null for + * agents and skills which serialize to plain text. + */ + uri: string | null + /** Type-specific render hints; see {@link ReferenceMeta}. */ + meta: ReferenceMeta | null +} From d00aa737388bc9a89f4cf6532d4beeca31305d5b Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 10 Jun 2026 21:45:26 +0800 Subject: [PATCH 03/31] feat(composer): add unified @ mention panel (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire @tiptap/suggestion (trigger @) to a React popup that owns data, rendering and insertion via a state bridge — the plugin lives in ProseMirror, the panel lives in the component tree (where data hooks work). Tested against a referenceSearch provider with mock data; real data sources are Phase 3. - suggestion/types.ts: SuggestionItem/SuggestionGroup/ReferenceSearch (async, abortable)/SuggestionState/SuggestionPopupHandle - suggestion/adapters.ts: pure source→reference adapters (file/agent/session/ commit/skill/expert); pathToFileUri percent-encodes per segment (matches message-input's toFileUri) - suggestion/mention-suggestion.ts: MentionSuggestion extension; inert items/ command (React owns them); allow-guard skips IME composition + code blocks; render() bridges lifecycle to a MentionController - suggestion/suggestion-popup.tsx: grouped keyboard-navigable popup, debounced + abortable fetch, stale-result guard (only fresh results selectable), portal-positioned at the caret, mousedown-preventDefault to keep focus - rich-composer.tsx: referenceSearch prop; plugin always installed but inert until enabled (runtime-gated via ref); Enter defers to an open panel; insert via deleteRange+insertReference; closeMention calls exitSuggestion; dismisses on referenceSearch removal mid-open - editor-config: optional mentionController; reference-badge: export ReferenceIcon; test-setup: scrollIntoView jsdom polyfill - tests: +31 (adapters, popup keyboard/click/escape/stale, integration: @-trigger insert, Enter-no-submit-while-open, Escape dismiss, no-search-no- panel, mid-open disable) Verified: pnpm test (1133), eslint, build (static export), git diff --check all green. Reviewed by Codex (APPROVED after hardening: file-uri encoding, always-install gating, stale-result guard, plugin-exit on close). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../chat/composer/badges/reference-badge.tsx | 2 +- src/components/chat/composer/editor-config.ts | 22 +- .../composer/rich-composer-mention.test.tsx | 157 ++++++++++++ .../chat/composer/rich-composer.tsx | 116 ++++++++- .../chat/composer/suggestion/adapters.test.ts | 177 +++++++++++++ .../chat/composer/suggestion/adapters.ts | 149 +++++++++++ .../composer/suggestion/mention-suggestion.ts | 81 ++++++ .../suggestion/suggestion-popup.test.tsx | 163 ++++++++++++ .../composer/suggestion/suggestion-popup.tsx | 234 ++++++++++++++++++ .../chat/composer/suggestion/types.ts | 48 ++++ src/test-setup.ts | 5 + 11 files changed, 1144 insertions(+), 10 deletions(-) create mode 100644 src/components/chat/composer/rich-composer-mention.test.tsx create mode 100644 src/components/chat/composer/suggestion/adapters.test.ts create mode 100644 src/components/chat/composer/suggestion/adapters.ts create mode 100644 src/components/chat/composer/suggestion/mention-suggestion.ts create mode 100644 src/components/chat/composer/suggestion/suggestion-popup.test.tsx create mode 100644 src/components/chat/composer/suggestion/suggestion-popup.tsx create mode 100644 src/components/chat/composer/suggestion/types.ts diff --git a/src/components/chat/composer/badges/reference-badge.tsx b/src/components/chat/composer/badges/reference-badge.tsx index ec7b0cc55..1b1ea7b42 100644 --- a/src/components/chat/composer/badges/reference-badge.tsx +++ b/src/components/chat/composer/badges/reference-badge.tsx @@ -12,7 +12,7 @@ import type { ReferenceAttrs } from "../types" const ICON_CLASS = "size-3.5 shrink-0" -function ReferenceIcon({ data }: { data: ReferenceAttrs }) { +export function ReferenceIcon({ data }: { data: ReferenceAttrs }) { const meta = data.meta switch (data.refType) { case "file": diff --git a/src/components/chat/composer/editor-config.ts b/src/components/chat/composer/editor-config.ts index 0f6a317de..de6c9f677 100644 --- a/src/components/chat/composer/editor-config.ts +++ b/src/components/chat/composer/editor-config.ts @@ -4,15 +4,23 @@ import { Placeholder } from "@tiptap/extension-placeholder" import StarterKit from "@tiptap/starter-kit" import { Reference } from "./nodes/reference-node" +import { + MentionSuggestion, + type MentionController, +} from "./suggestion/mention-suggestion" /** - * Options for the shared composer extension set. The `@`/`/` suggestion - * extensions are layered on in Phase 2 via additional entries to - * {@link buildComposerExtensions}. + * Options for the shared composer extension set. */ export interface ComposerExtensionOptions { /** Placeholder shown when the document is empty. */ placeholder?: string + /** + * When provided, enables the unified `@` mention panel: the suggestion plugin + * forwards lifecycle/keys to this controller, whose React popup owns data and + * insertion. + */ + mentionController?: MentionController } /** @@ -30,7 +38,7 @@ export interface ComposerExtensionOptions { export function buildComposerExtensions( options: ComposerExtensionOptions = {} ): Extensions { - return [ + const extensions: Extensions = [ StarterKit, Placeholder.configure({ placeholder: options.placeholder ?? "", @@ -41,4 +49,10 @@ export function buildComposerExtensions( Markdown, Reference, ] + if (options.mentionController) { + extensions.push( + MentionSuggestion.configure({ controller: options.mentionController }) + ) + } + return extensions } diff --git a/src/components/chat/composer/rich-composer-mention.test.tsx b/src/components/chat/composer/rich-composer-mention.test.tsx new file mode 100644 index 000000000..0740d704b --- /dev/null +++ b/src/components/chat/composer/rich-composer-mention.test.tsx @@ -0,0 +1,157 @@ +import { act, render, screen, waitFor } from "@testing-library/react" +import type { JSONContent } from "@tiptap/core" +import { createRef } from "react" +import { describe, expect, it, vi } from "vitest" + +import { RichComposer, type RichComposerHandle } from "./rich-composer" +import type { ReferenceSearch } from "./suggestion/types" + +const search: ReferenceSearch = () => [ + { + kind: "file", + label: "Files", + items: [ + { + reference: { + refType: "file", + id: "src/app.ts", + label: "app.ts", + uri: "file:///repo/src/app.ts", + meta: { fileKind: "file" }, + }, + detail: "src/app.ts", + }, + ], + }, +] + +function findReference(doc: JSONContent): JSONContent | undefined { + if (doc.type === "reference") return doc + for (const child of doc.content ?? []) { + const found = findReference(child) + if (found) return found + } + return undefined +} + +async function mount(onSubmit?: () => void) { + const ref = createRef() + render( + + ) + await waitFor(() => expect(ref.current?.getEditor()).not.toBeNull(), { + timeout: 5000, + }) + const editor = ref.current?.getEditor() + if (!editor) throw new Error("editor not mounted") + return { ref, editor } +} + +describe("RichComposer @ mention integration", () => { + it("opens the panel on @ and inserts the chosen reference", async () => { + const { editor } = await mount() + act(() => { + editor.commands.insertContent("@app") + }) + const row = await screen.findByText("app.ts", {}, { timeout: 5000 }) + act(() => { + row.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true, cancelable: true }) + ) + }) + await waitFor(() => { + const node = findReference(editor.getJSON()) + expect(node?.attrs).toMatchObject({ refType: "file", id: "src/app.ts" }) + }) + // The "@app" trigger text is gone, replaced by the badge. + expect(editor.getText()).not.toContain("@app") + }) + + it("does not submit on Enter while the panel is open", async () => { + const onSubmit = vi.fn() + const { editor } = await mount(onSubmit) + act(() => { + editor.commands.insertContent("@app") + }) + await screen.findByText("app.ts", {}, { timeout: 5000 }) + act(() => { + ;(editor.view.dom as HTMLElement).dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + }) + ) + }) + expect(onSubmit).not.toHaveBeenCalled() + }) + + it("dismisses the panel on Escape", async () => { + const { editor } = await mount() + act(() => { + editor.commands.insertContent("@app") + }) + await screen.findByText("app.ts", {}, { timeout: 5000 }) + act(() => { + ;(editor.view.dom as HTMLElement).dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }) + ) + }) + await waitFor(() => expect(screen.queryByText("app.ts")).toBeNull()) + }) + + it("dismisses the panel and restores submit when referenceSearch is removed mid-open", async () => { + const onSubmit = vi.fn() + const ref = createRef() + const { rerender } = render( + + ) + await waitFor(() => expect(ref.current?.getEditor()).not.toBeNull(), { + timeout: 5000, + }) + const editor = ref.current?.getEditor() + if (!editor) throw new Error("editor not mounted") + act(() => { + editor.commands.insertContent("@app") + }) + await screen.findByText("app.ts", {}, { timeout: 5000 }) + + // Disable mentions while the panel is open. + rerender() + await waitFor(() => + expect(screen.queryByTestId("mention-popup")).toBeNull() + ) + + // Enter now submits normally — panel + plugin state were cleared. + act(() => { + ;(editor.view.dom as HTMLElement).dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + }) + ) + }) + expect(onSubmit).toHaveBeenCalled() + }) + + it("does not open a panel when referenceSearch is not provided", async () => { + const ref = createRef() + render() + await waitFor(() => expect(ref.current?.getEditor()).not.toBeNull(), { + timeout: 5000, + }) + const editor = ref.current?.getEditor() + if (!editor) throw new Error("editor not mounted") + act(() => { + editor.commands.insertContent("@app") + }) + // Plugin is installed but inert without referenceSearch: no popup ever. + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(screen.queryByTestId("mention-popup")).toBeNull() + }) +}) diff --git a/src/components/chat/composer/rich-composer.tsx b/src/components/chat/composer/rich-composer.tsx index ad75a0723..50b655bae 100644 --- a/src/components/chat/composer/rich-composer.tsx +++ b/src/components/chat/composer/rich-composer.tsx @@ -2,18 +2,29 @@ import { forwardRef, + useCallback, useEffect, useImperativeHandle, + useMemo, useRef, + useState, type CSSProperties, } from "react" import { type Editor } from "@tiptap/core" import { EditorContent, useEditor } from "@tiptap/react" +import { exitSuggestion } from "@tiptap/suggestion" import { cn } from "@/lib/utils" import { buildComposerExtensions } from "./editor-config" import { shouldSubmitOnEnter } from "./submit-key" +import type { + MentionController, + MentionRenderState, +} from "./suggestion/mention-suggestion" +import { SuggestionPopup } from "./suggestion/suggestion-popup" +import type { ReferenceSearch, SuggestionPopupHandle } from "./suggestion/types" +import type { ReferenceAttrs } from "./types" /** * Imperative handle exposed to the parent (e.g. the message input that owns @@ -61,13 +72,20 @@ export interface RichComposerProps { onSubmit?: () => void onFocus?: () => void onBlur?: () => void + /** + * Enables the unified `@` mention panel. Resolves the typed query into + * grouped suggestions (files/agents/sessions/commits/skills). MUST be + * referentially stable (memoize it) — it is a dependency of the panel's fetch + * effect. Omit to disable mentions. + */ + referenceSearch?: ReferenceSearch } /** - * Phase 0 rich-text composer: a Tiptap editor with live WYSIWYG Markdown and - * IME-safe Enter-to-submit. Reference badges and the unified `@` panel are - * layered on in later phases; this component is the foundation that de-risks - * IME, auto-grow and Markdown round-trip. + * Rich-text composer: a Tiptap editor with live WYSIWYG Markdown, IME-safe + * Enter-to-submit, inline reference badges, and an optional unified `@` mention + * panel (enabled by `referenceSearch`). Not yet wired into message-input — that + * integration (drafts, attachments, real data sources) is Phase 3. */ export const RichComposer = forwardRef( function RichComposer( @@ -83,6 +101,7 @@ export const RichComposer = forwardRef( onSubmit, onFocus, onBlur, + referenceSearch, }, ref ) { @@ -92,17 +111,62 @@ export const RichComposer = forwardRef( const onSubmitRef = useRef(onSubmit) const onFocusRef = useRef(onFocus) const onBlurRef = useRef(onBlur) + // Latest referenceSearch, read at event time so the mention plugin (always + // installed) is gated on whether mentions are currently enabled — robust to + // the prop being added/removed after the editor is created once. + const referenceSearchRef = useRef(referenceSearch) useEffect(() => { onChangeRef.current = onChange onSubmitRef.current = onSubmit onFocusRef.current = onFocus onBlurRef.current = onBlur + referenceSearchRef.current = referenceSearch }) + // ── Unified `@` mention panel state bridge ── + // The suggestion plugin lives in ProseMirror; its lifecycle is bridged to + // this React state so the popup can render in-tree (where data hooks work). + const [mentionState, setMentionState] = useState( + null + ) + // Mirrors `mentionState != null` for synchronous reads inside handleKeyDown + // (so Enter defers to the panel without waiting for a re-render). + const mentionOpenRef = useRef(false) + const popupRef = useRef(null) + // Stable controller created once (refs/setState are stable), so the editor + // is built a single time with it. + const mentionController = useMemo( + () => ({ + onStart: (mention) => { + // Inert unless mentions are enabled (no referenceSearch → no panel). + if (!referenceSearchRef.current) return + mentionOpenRef.current = true + setMentionState(mention) + }, + onUpdate: (mention) => { + if (!referenceSearchRef.current) return + setMentionState(mention) + }, + onExit: () => { + mentionOpenRef.current = false + setMentionState(null) + }, + onKeyDown: (event) => popupRef.current?.onKeyDown(event) ?? false, + }), + [] + ) + const editor = useEditor({ // Static export / SSR safety: never render on the server. immediatelyRender: false, - extensions: buildComposerExtensions({ placeholder }), + // The mention plugin is always installed (the editor is created once); + // it stays inert until `referenceSearch` is set (checked at runtime in the + // controller). `mentionController` (stable, from useMemo) captures refs + // but only dereferences them inside event-time callbacks, never during + // render — the React Compiler lint can't prove that. Mirrors Tiptap's own + // React suggestion pattern (render() → component.ref.onKeyDown). + // eslint-disable-next-line react-hooks/refs + extensions: buildComposerExtensions({ placeholder, mentionController }), editable: !disabled, autofocus: autoFocus ? "end" : false, editorProps: { @@ -115,6 +179,9 @@ export const RichComposer = forwardRef( handleKeyDown: (view, event) => { // Only Enter is special; let everything else fall through cheaply. if (event.key !== "Enter") return false + // While the `@` panel is open it owns Enter (select / close); never + // submit. Checked synchronously via a ref to beat the re-render. + if (mentionOpenRef.current) return false // Resolve structural context: code blocks and list items keep Enter // (newline / list split) instead of submitting. const { $from } = view.state.selection @@ -179,6 +246,36 @@ export const RichComposer = forwardRef( [editor] ) + const closeMention = useCallback(() => { + mentionOpenRef.current = false + setMentionState(null) + // Also dismiss the Tiptap suggestion plugin so its state can't stay active + // while React thinks the panel is closed (onExit will also fire). + const view = editor?.view + if (view) exitSuggestion(view) + }, [editor]) + + // If mentions get disabled while a panel is open, actively dismiss it so the + // editor's Enter handling and the plugin state return to normal (the popup + // also unmounts via the render guard below). + useEffect(() => { + if (!referenceSearch && mentionOpenRef.current) closeMention() + }, [referenceSearch, closeMention]) + + const handleReferenceSelect = useCallback( + (reference: ReferenceAttrs, range: { from: number; to: number }) => { + editor + ?.chain() + .focus() + .deleteRange(range) + .insertReference(reference) + .insertContent(" ") + .run() + closeMention() + }, + [editor, closeMention] + ) + return (
( editor={editor} className="codeg-composer-scroll min-h-0 flex-1 overflow-y-auto px-3 py-2 text-base md:text-sm" /> + {referenceSearch && mentionState && ( + + )}
) } diff --git a/src/components/chat/composer/suggestion/adapters.test.ts b/src/components/chat/composer/suggestion/adapters.test.ts new file mode 100644 index 000000000..9d6cda254 --- /dev/null +++ b/src/components/chat/composer/suggestion/adapters.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "vitest" + +import type { FlatFileEntry } from "@/hooks/use-file-tree" +import type { + AcpAgentInfo, + AgentSkillItem, + DbConversationSummary, + ExpertListItem, + GitLogEntry, +} from "@/lib/types" + +import { + agentToSuggestion, + commitToSuggestion, + expertToSuggestion, + fileToSuggestion, + pathToFileUri, + sessionToSuggestion, + skillToSuggestion, +} from "./adapters" + +describe("pathToFileUri", () => { + it("builds a triple-slash uri for a posix path", () => { + expect(pathToFileUri("/repo/src/app.ts")).toBe("file:///repo/src/app.ts") + }) + it("normalizes Windows backslashes and encodes the drive segment", () => { + expect(pathToFileUri("C:\\repo\\app.ts")).toBe("file:///C%3A/repo/app.ts") + }) + it("percent-encodes spaces, # and ? within segments (not the separators)", () => { + expect(pathToFileUri("/a/b c#d?e.ts")).toBe("file:///a/b%20c%23d%3Fe.ts") + }) +}) + +describe("fileToSuggestion", () => { + const entry: FlatFileEntry = { + name: "app.ts", + relativePath: "src/app.ts", + kind: "file", + lowerPath: "src/app.ts", + lowerName: "app.ts", + } + it("maps to a file reference with a joined file:// uri", () => { + const item = fileToSuggestion(entry, "/repo") + expect(item.reference).toMatchObject({ + refType: "file", + id: "src/app.ts", + label: "app.ts", + uri: "file:///repo/src/app.ts", + meta: { fileKind: "file" }, + }) + expect(item.detail).toBe("src/app.ts") + }) + it("does not double a separator when the root has a trailing slash", () => { + expect(fileToSuggestion(entry, "/repo/").reference.uri).toBe( + "file:///repo/src/app.ts" + ) + }) +}) + +describe("agentToSuggestion", () => { + it("maps to an agent reference with no uri", () => { + const agent = { + agent_type: "claude_code", + name: "Claude Code", + description: "Anthropic CLI", + available: true, + } as AcpAgentInfo + const item = agentToSuggestion(agent) + expect(item.reference).toMatchObject({ + refType: "agent", + id: "claude_code", + label: "Claude Code", + uri: null, + meta: { agentType: "claude_code", available: true }, + }) + }) +}) + +describe("sessionToSuggestion", () => { + const base = { + id: 123, + agent_type: "codex", + status: "in_progress", + git_branch: "main", + } as DbConversationSummary + it("maps to a session reference with a codeg uri", () => { + const item = sessionToSuggestion({ ...base, title: "Login refactor" }) + expect(item.reference).toMatchObject({ + refType: "session", + id: "123", + label: "Login refactor", + uri: "codeg://session/123", + meta: { agentType: "codex", status: "in_progress", branch: "main" }, + }) + }) + it("falls back to #id when the title is empty", () => { + expect(sessionToSuggestion({ ...base, title: null }).reference.label).toBe( + "#123" + ) + }) +}) + +describe("commitToSuggestion", () => { + it("maps to a commit reference with an encoded repo key", () => { + const entry = { + hash: "abc1234", + full_hash: "abc1234def5678", + author: "Jane", + date: "2026-06-10", + message: "fix login", + files: [], + pushed: true, + } as GitLogEntry + const item = commitToSuggestion(entry, "/repo with space") + expect(item.reference).toMatchObject({ + refType: "commit", + id: "abc1234def5678", + label: "abc1234", + uri: "codeg://commit/%2Frepo%20with%20space@abc1234def5678", + meta: { shortHash: "abc1234", message: "fix login", pushed: true }, + }) + }) +}) + +describe("skillToSuggestion", () => { + it("maps a user/project skill to a skill reference", () => { + const skill = { + id: "code-review", + name: "Code Review", + scope: "project", + layout: "markdown_file", + path: "/skills/code-review.md", + description: "Review the diff", + read_only: false, + } as AgentSkillItem + expect(skillToSuggestion(skill).reference).toMatchObject({ + refType: "skill", + id: "code-review", + label: "Code Review", + uri: null, + meta: { scope: "project" }, + }) + }) +}) + +describe("expertToSuggestion", () => { + const expert: ExpertListItem = { + metadata: { + id: "deep-research", + category: "research", + icon: "Sparkles", + sort_order: 1, + display_name: { en: "Deep Research", "zh-CN": "深度研究" }, + description: { en: "Research deeply", "zh-CN": "深入研究" }, + bundled_hash: "x", + }, + installed_centrally: true, + user_modified: false, + central_path: "/experts/deep-research", + } + it("uses the localized display name", () => { + expect(expertToSuggestion(expert, "zh-CN").reference.label).toBe("深度研究") + }) + it("falls back to English then id when the locale is missing", () => { + expect(expertToSuggestion(expert, "ja").reference.label).toBe( + "Deep Research" + ) + }) + it("maps to a skill reference (experts invoke as /id)", () => { + expect(expertToSuggestion(expert, "en").reference).toMatchObject({ + refType: "skill", + id: "deep-research", + uri: null, + meta: { scope: "expert", category: "research", icon: "Sparkles" }, + }) + }) +}) diff --git a/src/components/chat/composer/suggestion/adapters.ts b/src/components/chat/composer/suggestion/adapters.ts new file mode 100644 index 000000000..9f07492f7 --- /dev/null +++ b/src/components/chat/composer/suggestion/adapters.ts @@ -0,0 +1,149 @@ +import type { FlatFileEntry } from "@/hooks/use-file-tree" +import { + AGENT_LABELS, + type AcpAgentInfo, + type AgentSkillItem, + type DbConversationSummary, + type ExpertListItem, + type GitLogEntry, +} from "@/lib/types" + +import type { SuggestionItem } from "./types" + +/** + * Build a `file://` URI from an absolute path (POSIX or Windows), percent- + * encoding each path segment so spaces / `#` / `?` / `%` can't corrupt the URI. + * Mirrors `toFileUri` in message-input.tsx. + */ +export function pathToFileUri(absolutePath: string): string { + const normalized = absolutePath.replace(/\\/g, "/") + const encoded = normalized.split("/").map(encodeURIComponent).join("/") + return normalized.startsWith("/") ? `file://${encoded}` : `file:///${encoded}` +} + +function joinPath(root: string, relative: string): string { + const left = root.replace(/[/\\]+$/, "") + const right = relative.replace(/^[/\\]+/, "") + return left ? `${left}/${right}` : right +} + +/** Workspace file → file reference (uri built from the workspace root). */ +export function fileToSuggestion( + entry: FlatFileEntry, + workspaceRoot: string +): SuggestionItem { + return { + reference: { + refType: "file", + id: entry.relativePath, + label: entry.name, + uri: pathToFileUri(joinPath(workspaceRoot, entry.relativePath)), + meta: { fileKind: entry.kind }, + }, + detail: entry.relativePath, + keywords: entry.relativePath, + } +} + +/** ACP agent → agent reference (no uri; serializes to `@label`). */ +export function agentToSuggestion(agent: AcpAgentInfo): SuggestionItem { + return { + reference: { + refType: "agent", + id: agent.agent_type, + label: agent.name || AGENT_LABELS[agent.agent_type], + uri: null, + meta: { agentType: agent.agent_type, available: agent.available }, + }, + detail: agent.description || null, + keywords: agent.agent_type, + } +} + +/** Conversation → session reference (`codeg://session/`). */ +export function sessionToSuggestion( + conversation: DbConversationSummary +): SuggestionItem { + const label = conversation.title?.trim() || `#${conversation.id}` + return { + reference: { + refType: "session", + id: String(conversation.id), + label, + uri: `codeg://session/${conversation.id}`, + meta: { + agentType: conversation.agent_type, + status: conversation.status, + branch: conversation.git_branch, + }, + }, + detail: conversation.git_branch || conversation.status, + keywords: `${label} ${conversation.agent_type}`, + } +} + +/** + * Git commit → commit reference (`codeg://commit/@`). + * `repoKey` identifies the repository (e.g. its path) and is URI-encoded. + */ +export function commitToSuggestion( + entry: GitLogEntry, + repoKey: string +): SuggestionItem { + return { + reference: { + refType: "commit", + id: entry.full_hash, + label: entry.hash, + uri: `codeg://commit/${encodeURIComponent(repoKey)}@${entry.full_hash}`, + meta: { + shortHash: entry.hash, + message: entry.message, + author: entry.author, + pushed: entry.pushed, + }, + }, + detail: entry.message, + keywords: `${entry.hash} ${entry.message} ${entry.author}`, + } +} + +/** User/project skill → skill reference (serializes to `/id`). */ +export function skillToSuggestion(skill: AgentSkillItem): SuggestionItem { + return { + reference: { + refType: "skill", + id: skill.id, + label: skill.name, + uri: null, + meta: { scope: skill.scope, icon: null }, + }, + detail: skill.description, + keywords: `${skill.id} ${skill.name}`, + } +} + +/** Built-in expert → skill reference, with the localized display name. */ +export function expertToSuggestion( + expert: ExpertListItem, + locale: string +): SuggestionItem { + const { metadata } = expert + const label = + metadata.display_name[locale] ?? metadata.display_name.en ?? metadata.id + return { + reference: { + refType: "skill", + id: metadata.id, + label, + uri: null, + meta: { + scope: "expert", + category: metadata.category, + icon: metadata.icon, + }, + }, + detail: metadata.description[locale] ?? metadata.description.en ?? null, + keywords: `${metadata.id} ${label} ${metadata.category}`, + } +} diff --git a/src/components/chat/composer/suggestion/mention-suggestion.ts b/src/components/chat/composer/suggestion/mention-suggestion.ts new file mode 100644 index 000000000..174e90c6d --- /dev/null +++ b/src/components/chat/composer/suggestion/mention-suggestion.ts @@ -0,0 +1,81 @@ +import { Extension } from "@tiptap/core" +import Suggestion, { type SuggestionProps } from "@tiptap/suggestion" + +/** Live render state the plugin pushes to React while the `@` panel is open. */ +export interface MentionRenderState { + query: string + /** Document range covering `@` + query, replaced when a row is chosen. */ + range: { from: number; to: number } + /** Caret rect (viewport coords) for positioning the popup, if known. */ + clientRect: DOMRect | null +} + +/** + * Callbacks the React layer supplies so the suggestion plugin can drive a React + * popup that lives in the editor's component tree (where data hooks work). The + * plugin owns trigger detection; React owns data + rendering + insertion. + */ +export interface MentionController { + onStart: (state: MentionRenderState) => void + onUpdate: (state: MentionRenderState) => void + onExit: () => void + /** Forwarded keydown; return true if the popup consumed it. */ + onKeyDown: (event: KeyboardEvent) => boolean +} + +export interface MentionSuggestionOptions { + controller: MentionController +} + +const NOOP_CONTROLLER: MentionController = { + onStart: () => {}, + onUpdate: () => {}, + onExit: () => {}, + onKeyDown: () => false, +} + +function toRenderState(props: SuggestionProps): MentionRenderState { + return { + query: props.query, + range: props.range, + clientRect: props.clientRect?.() ?? null, + } +} + +/** + * Tiptap extension wiring `@tiptap/suggestion` (trigger `@`) to a + * {@link MentionController}. Data fetching, rendering and insertion are handled + * by the controller's React popup, so the plugin's own `items`/`command` are + * intentionally inert. + */ +export const MentionSuggestion = Extension.create({ + name: "mentionSuggestion", + + addOptions() { + return { controller: NOOP_CONTROLLER } + }, + + addProseMirrorPlugins() { + const controller = this.options.controller + return [ + Suggestion({ + editor: this.editor, + char: "@", + allowSpaces: false, + items: () => [], + command: () => {}, + // Don't trigger mid-IME-composition or inside code blocks. + allow: ({ editor, state }) => { + if (editor.view.composing) return false + return !state.selection.$from.parent.type.spec.code + }, + render: () => ({ + onStart: (props) => controller.onStart(toRenderState(props)), + onUpdate: (props) => controller.onUpdate(toRenderState(props)), + onExit: () => controller.onExit(), + onKeyDown: (props) => controller.onKeyDown(props.event), + }), + }), + ] + }, +}) diff --git a/src/components/chat/composer/suggestion/suggestion-popup.test.tsx b/src/components/chat/composer/suggestion/suggestion-popup.test.tsx new file mode 100644 index 000000000..47ef2f672 --- /dev/null +++ b/src/components/chat/composer/suggestion/suggestion-popup.test.tsx @@ -0,0 +1,163 @@ +import { act, render, screen } from "@testing-library/react" +import { createRef } from "react" +import { describe, expect, it, vi } from "vitest" + +import { SuggestionPopup } from "./suggestion-popup" +import type { + ReferenceSearch, + SuggestionGroup, + SuggestionPopupHandle, +} from "./types" + +// Distinct, non-colliding text: a row's label must differ from its detail and +// from the agent icon's ("Codex") so findByText is unambiguous. +const fileRef = { + refType: "file" as const, + id: "alpha.md", + label: "alpha.md", + uri: "file:///docs/alpha.md", + meta: null, +} +const agentRef = { + refType: "agent" as const, + id: "codex", + label: "Codex Helper", + uri: null, + meta: { agentType: "codex" as const }, +} + +const groups: SuggestionGroup[] = [ + { + kind: "file", + label: "Files", + items: [{ reference: fileRef, detail: "docs/alpha.md" }], + }, + { kind: "agent", label: "Agents", items: [{ reference: agentRef }] }, +] + +const search: ReferenceSearch = () => groups +const emptySearch: ReferenceSearch = () => [] + +const state = { query: "a", range: { from: 1, to: 3 }, clientRect: null } + +function mountPopup( + overrides: Partial<Parameters<typeof SuggestionPopup>[0]> = {} +) { + const ref = createRef<SuggestionPopupHandle>() + const onSelect = vi.fn() + const onClose = vi.fn() + render( + <SuggestionPopup + ref={ref} + state={state} + search={search} + onSelect={onSelect} + onClose={onClose} + {...overrides} + /> + ) + return { ref, onSelect, onClose } +} + +function key(name: string): KeyboardEvent { + return { key: name } as KeyboardEvent +} + +describe("SuggestionPopup", () => { + it("renders grouped results from the search provider", async () => { + mountPopup() + expect(await screen.findByText("alpha.md")).toBeInTheDocument() + expect(screen.getByText("Files")).toBeInTheDocument() + expect(screen.getByText("Agents")).toBeInTheDocument() + expect(screen.getByText("Codex Helper")).toBeInTheDocument() + }) + + it("shows an empty state when there are no matches", async () => { + mountPopup({ search: emptySearch, emptyLabel: "Nothing" }) + expect(await screen.findByText("Nothing")).toBeInTheDocument() + }) + + it("selects the highlighted row on Enter (default = first)", async () => { + const { ref, onSelect } = mountPopup() + await screen.findByText("alpha.md") + act(() => { + expect(ref.current?.onKeyDown(key("Enter"))).toBe(true) + }) + expect(onSelect).toHaveBeenCalledWith(fileRef, state.range) + }) + + it("moves the selection with ArrowDown before selecting", async () => { + const { ref, onSelect } = mountPopup() + await screen.findByText("Codex Helper") + act(() => ref.current?.onKeyDown(key("ArrowDown"))) + act(() => ref.current?.onKeyDown(key("Enter"))) + expect(onSelect).toHaveBeenCalledWith(agentRef, state.range) + }) + + it("wraps the selection with ArrowUp from the first row", async () => { + const { ref, onSelect } = mountPopup() + await screen.findByText("Codex Helper") + act(() => ref.current?.onKeyDown(key("ArrowUp"))) + act(() => ref.current?.onKeyDown(key("Enter"))) + expect(onSelect).toHaveBeenCalledWith(agentRef, state.range) + }) + + it("closes on Escape and reports the key as consumed", async () => { + const { ref, onClose } = mountPopup() + await screen.findByText("alpha.md") + let consumed = false + act(() => { + consumed = ref.current?.onKeyDown(key("Escape")) ?? false + }) + expect(consumed).toBe(true) + expect(onClose).toHaveBeenCalled() + }) + + it("does not consume unrelated keys", async () => { + const { ref } = mountPopup() + await screen.findByText("alpha.md") + expect(ref.current?.onKeyDown(key("x"))).toBe(false) + }) + + it("does not select stale results after the query changes", async () => { + const ref = createRef<SuggestionPopupHandle>() + const onSelect = vi.fn() + const view = (query: string, to: number) => ( + <SuggestionPopup + ref={ref} + state={{ query, range: { from: 1, to }, clientRect: null }} + search={search} + onSelect={onSelect} + onClose={vi.fn()} + loadingLabel="Loading" + /> + ) + const { rerender } = render(view("a", 2)) + await screen.findByText("alpha.md") // fresh results for "a" + + // Query advances; the shown results now answer the *previous* query. + rerender(view("ab", 3)) + expect(screen.queryByText("alpha.md")).toBeNull() + expect(screen.getByText("Loading")).toBeInTheDocument() + + act(() => ref.current?.onKeyDown(key("Enter"))) + expect(onSelect).not.toHaveBeenCalled() + }) + + it("selects on click (mousedown) and prevents default to keep editor focus", async () => { + const { onSelect } = mountPopup() + const label = await screen.findByText("alpha.md") + const button = label.closest("button") + expect(button).not.toBeNull() + const event = new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + }) + act(() => { + button?.dispatchEvent(event) + }) + expect(onSelect).toHaveBeenCalledWith(fileRef, state.range) + // preventDefault keeps focus in the editor rather than the popup button. + expect(event.defaultPrevented).toBe(true) + }) +}) diff --git a/src/components/chat/composer/suggestion/suggestion-popup.tsx b/src/components/chat/composer/suggestion/suggestion-popup.tsx new file mode 100644 index 000000000..b819e2ead --- /dev/null +++ b/src/components/chat/composer/suggestion/suggestion-popup.tsx @@ -0,0 +1,234 @@ +"use client" + +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react" +import { createPortal } from "react-dom" + +import { cn } from "@/lib/utils" + +import { ReferenceIcon } from "../badges/reference-badge" +import type { ReferenceAttrs } from "../types" +import type { MentionRenderState } from "./mention-suggestion" +import type { + ReferenceSearch, + SuggestionGroup, + SuggestionPopupHandle, +} from "./types" + +const FETCH_DEBOUNCE_MS = 150 + +export interface SuggestionPopupProps { + /** Live trigger state (query/range/caret rect). */ + state: MentionRenderState + /** Resolves the query into grouped suggestions. Must be referentially stable. */ + search: ReferenceSearch + /** Insert the chosen reference, replacing the trigger range. */ + onSelect: ( + reference: ReferenceAttrs, + range: { from: number; to: number } + ) => void + /** Dismiss the panel without inserting. */ + onClose: () => void + emptyLabel?: string + loadingLabel?: string +} + +interface FlatRow { + item: SuggestionGroup["items"][number] + groupIndex: number +} + +/** + * The unified `@` panel: grouped, keyboard-navigable suggestions positioned at + * the caret. Keys are forwarded from the suggestion plugin via the imperative + * handle (the editor keeps DOM focus), so selection is tracked manually rather + * than relying on focus-based libraries. + */ +export const SuggestionPopup = forwardRef< + SuggestionPopupHandle, + SuggestionPopupProps +>(function SuggestionPopup( + { + state, + search, + onSelect, + onClose, + emptyLabel = "No matches", + loadingLabel = "Searching…", + }, + ref +) { + // Results are tagged with the query they answer. While that tag doesn't match + // the live query (initial mount, or mid-debounce after the query changed) the + // panel is "stale": it shows loading and nothing is selectable, so Enter can + // never insert a row from a previous query. + const [result, setResult] = useState<{ + // null until the first fetch resolves, so results read as "stale" + // (and the panel shows loading) before any search has answered. + query: string | null + groups: SuggestionGroup[] + }>({ query: null, groups: [] }) + const [selectedIndex, setSelectedIndex] = useState(0) + const listRef = useRef<HTMLDivElement>(null) + const stale = result.query !== state.query + + // Debounced, abortable fetch on every query change. All state updates run + // inside the (async) timer callback, never synchronously in the effect body. + useEffect(() => { + const abort = new AbortController() + let active = true + const timer = setTimeout(() => { + Promise.resolve(search(state.query, abort.signal)) + .then((groups) => { + if (!active || abort.signal.aborted) return + setResult({ query: state.query, groups }) + setSelectedIndex(0) + }) + .catch(() => { + if (!active || abort.signal.aborted) return + setResult({ query: state.query, groups: [] }) + setSelectedIndex(0) + }) + }, FETCH_DEBOUNCE_MS) + return () => { + active = false + abort.abort() + clearTimeout(timer) + } + }, [state.query, search]) + + // Only fresh results are selectable; selection resets to 0 on each fetch. + const flat = useMemo<FlatRow[]>( + () => + stale + ? [] + : result.groups.flatMap((group, groupIndex) => + group.items.map((item) => ({ item, groupIndex })) + ), + [stale, result.groups] + ) + + // Scroll the active row into view. + useEffect(() => { + listRef.current + ?.querySelector('[data-active="true"]') + ?.scrollIntoView({ block: "nearest" }) + }, [selectedIndex]) + + useImperativeHandle( + ref, + (): SuggestionPopupHandle => ({ + onKeyDown: (event) => { + switch (event.key) { + case "ArrowDown": + if (flat.length > 0) { + setSelectedIndex((index) => (index + 1) % flat.length) + } + return true + case "ArrowUp": + if (flat.length > 0) { + setSelectedIndex( + (index) => (index - 1 + flat.length) % flat.length + ) + } + return true + case "Enter": + case "Tab": { + const chosen = flat[selectedIndex] + if (chosen) onSelect(chosen.item.reference, state.range) + // No fresh row (still loading, or no matches): consume the key + // without inserting or submitting. Escape dismisses the panel. + return true + } + case "Escape": + onClose() + return true + default: + return false + } + }, + }), + [flat, selectedIndex, onSelect, onClose, state.range] + ) + + const rect = state.clientRect + let rowIndex = -1 + + return createPortal( + <div + style={{ + position: "fixed", + left: rect?.left ?? 0, + top: rect?.top ?? 0, + zIndex: 50, + }} + > + <div + ref={listRef} + data-testid="mention-popup" + className="absolute bottom-full left-0 mb-1 max-h-72 w-80 overflow-y-auto rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-lg" + > + {stale ? ( + <div className="px-2 py-3 text-sm text-muted-foreground"> + {loadingLabel} + </div> + ) : flat.length === 0 ? ( + <div className="px-2 py-3 text-sm text-muted-foreground"> + {emptyLabel} + </div> + ) : ( + result.groups.map((group) => + group.items.length === 0 ? null : ( + <div key={group.kind} className="py-0.5"> + <div className="px-2 py-1 text-xs font-medium text-muted-foreground"> + {group.label} + </div> + {group.items.map((item) => { + rowIndex += 1 + const active = rowIndex === selectedIndex + const index = rowIndex + return ( + <button + key={`${group.kind}:${item.reference.id}`} + type="button" + data-active={active} + className={cn( + "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm", + active + ? "bg-accent text-accent-foreground" + : "hover:bg-accent/50" + )} + onMouseDown={(event) => { + // Keep editor focus; insert on click. + event.preventDefault() + onSelect(item.reference, state.range) + }} + onMouseEnter={() => setSelectedIndex(index)} + > + <ReferenceIcon data={item.reference} /> + <span className="flex-1 truncate"> + {item.reference.label || item.reference.id} + </span> + {item.detail && ( + <span className="max-w-[10rem] truncate text-xs text-muted-foreground"> + {item.detail} + </span> + )} + </button> + ) + })} + </div> + ) + ) + )} + </div> + </div>, + document.body + ) +}) diff --git a/src/components/chat/composer/suggestion/types.ts b/src/components/chat/composer/suggestion/types.ts new file mode 100644 index 000000000..02397a3f5 --- /dev/null +++ b/src/components/chat/composer/suggestion/types.ts @@ -0,0 +1,48 @@ +import type { ReferenceAttrs, ReferenceKind } from "../types" + +/** One selectable row: the reference to insert plus display hints. */ +export interface SuggestionItem { + /** The reference inserted when this row is chosen. */ + reference: ReferenceAttrs + /** Secondary line under the label (path, branch, commit message, …). */ + detail?: string | null + /** Extra text matched against the query, in addition to the label. */ + keywords?: string +} + +/** A labeled group of suggestions, one per reference kind / data source. */ +export interface SuggestionGroup { + kind: ReferenceKind + /** Display heading for the group. */ + label: string + items: SuggestionItem[] +} + +/** + * Resolves the `@` query into grouped suggestions. Async so an implementation + * can hit the file tree / conversations / git log / skills APIs. The optional + * AbortSignal is aborted when a newer query supersedes this one. + * + * Phase 2 ships the panel against this interface; Phase 3 supplies the real + * implementation (wired to the live data hooks) when the composer replaces the + * textarea in message-input. + */ +export type ReferenceSearch = ( + query: string, + signal?: AbortSignal +) => SuggestionGroup[] | Promise<SuggestionGroup[]> + +/** State the suggestion plugin pushes to React while the `@` panel is open. */ +export interface SuggestionState { + query: string + /** Document range covering the trigger char + query, replaced on select. */ + range: { from: number; to: number } + /** Caret rect for positioning the popup (viewport coords), if known. */ + clientRect: DOMRect | null +} + +/** Imperative surface the popup exposes so forwarded key events can drive it. */ +export interface SuggestionPopupHandle { + /** Returns true if the popup consumed the key (caller should preventDefault). */ + onKeyDown: (event: KeyboardEvent) => boolean +} diff --git a/src/test-setup.ts b/src/test-setup.ts index ae8719eab..46014e980 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -7,6 +7,11 @@ import "@testing-library/jest-dom/vitest" if (typeof document !== "undefined" && !document.elementFromPoint) { document.elementFromPoint = () => null } +if (typeof Element !== "undefined") { + // jsdom doesn't implement scrollIntoView; the composer's suggestion popup + // calls it to keep the active row visible. + Element.prototype.scrollIntoView ??= () => {} +} if (typeof Range !== "undefined") { Range.prototype.getClientRects ??= () => ({ From 46b416abfb315cbe9fc279e3d5a2ea411085634f Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Thu, 11 Jun 2026 11:19:59 +0800 Subject: [PATCH 04/31] feat(composer): add send/restore serializers and configurable key handling (Phase 3a) P3a foundation for wiring RichComposer into message-input (pure, unit-tested; no production wiring yet beyond a type extraction): - to-prompt-blocks: docToPromptBlocks lifts file references (file:// only) to trailing ResourceLink blocks and drops them from the prose; session/commit/ agent/skill stay inline as text. Zero wire/display regression vs the textarea. - from-prompt-blocks: blocksToRestoredDraft inverse for queue-edit (file:/codeg: uris -> badges, image/embedded/other -> attachments). - submit-key: decideComposerKey generalizes shouldSubmitOnEnter to configurable send/newline bindings (IME + bare-Enter-in-code/list precedence). - rich-composer: configurable submit/newline shortcuts, isExternalMenuOpen defer, onPasteFiles forwarding, getJSON/insertMarkdownAtCursor/insertReference handle. - message-input: extract InputAttachment types to message-input-attachments.ts. Gates: 1205 tests, eslint clean, build OK. Codex-reviewed APPROVED (fixed file:// scope check, free-form key bindings, onSubmit-absent fall-through). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../chat/composer/from-prompt-blocks.test.ts | 259 ++++++++++++++++++ .../chat/composer/from-prompt-blocks.ts | 171 ++++++++++++ .../chat/composer/rich-composer.test.tsx | 99 +++++++ .../chat/composer/rich-composer.tsx | 138 ++++++++-- .../chat/composer/submit-key.test.ts | 92 +++++++ src/components/chat/composer/submit-key.ts | 50 ++++ .../chat/composer/to-prompt-blocks.test.ts | 223 +++++++++++++++ .../chat/composer/to-prompt-blocks.ts | 104 +++++++ .../chat/message-input-attachments.ts | 41 +++ src/components/chat/message-input.tsx | 27 +- 10 files changed, 1159 insertions(+), 45 deletions(-) create mode 100644 src/components/chat/composer/from-prompt-blocks.test.ts create mode 100644 src/components/chat/composer/from-prompt-blocks.ts create mode 100644 src/components/chat/composer/to-prompt-blocks.test.ts create mode 100644 src/components/chat/composer/to-prompt-blocks.ts create mode 100644 src/components/chat/message-input-attachments.ts diff --git a/src/components/chat/composer/from-prompt-blocks.test.ts b/src/components/chat/composer/from-prompt-blocks.test.ts new file mode 100644 index 000000000..d6ba9b2f2 --- /dev/null +++ b/src/components/chat/composer/from-prompt-blocks.test.ts @@ -0,0 +1,259 @@ +import { Editor } from "@tiptap/core" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import type { PromptInputBlock } from "@/lib/types" + +import { buildComposerExtensions } from "./editor-config" +import { + blocksToRestoredDraft, + parseReferenceUri, + type RestoreSegment, +} from "./from-prompt-blocks" +import { docToPromptBlocks } from "./to-prompt-blocks" +import type { ReferenceAttrs } from "./types" + +function counter(): () => string { + let n = 0 + return () => `id-${n++}` +} + +function refSegments(segments: RestoreSegment[]): ReferenceAttrs[] { + return segments + .filter( + (s): s is Extract<RestoreSegment, { kind: "reference" }> => + s.kind === "reference" + ) + .map((s) => s.attrs) +} + +describe("blocksToRestoredDraft", () => { + it("restores a text block as a markdown segment", () => { + const { segments, attachments } = blocksToRestoredDraft( + [{ type: "text", text: "hello **world**" }], + counter() + ) + expect(segments).toEqual([{ kind: "markdown", text: "hello **world**" }]) + expect(attachments).toEqual([]) + }) + + it("skips a blank text block", () => { + const { segments } = blocksToRestoredDraft( + [{ type: "text", text: " " }], + counter() + ) + expect(segments).toEqual([]) + }) + + it("restores a file resource_link as a file reference badge", () => { + const { segments, attachments } = blocksToRestoredDraft( + [ + { + type: "resource_link", + uri: "file:///repo/src/app.ts", + name: "app.ts", + mime_type: null, + description: null, + }, + ], + counter() + ) + expect(attachments).toEqual([]) + expect(segments).toEqual([ + { + kind: "reference", + attrs: { + refType: "file", + id: "app.ts", + label: "app.ts", + uri: "file:///repo/src/app.ts", + meta: { fileKind: "file" }, + }, + }, + ]) + }) + + it("restores a codeg session link as a session reference", () => { + const { segments } = blocksToRestoredDraft( + [ + { + type: "resource_link", + uri: "codeg://session/123", + name: "Login refactor", + mime_type: null, + description: null, + }, + ], + counter() + ) + expect(refSegments(segments)[0]).toMatchObject({ + refType: "session", + id: "123", + label: "Login refactor", + uri: "codeg://session/123", + }) + }) + + it("restores a codeg commit link as a commit reference (hash after @)", () => { + const { segments } = blocksToRestoredDraft( + [ + { + type: "resource_link", + uri: "codeg://commit/%2Frepo%20a@abc1234def5678", + name: "abc1234", + mime_type: null, + description: null, + }, + ], + counter() + ) + expect(refSegments(segments)[0]).toMatchObject({ + refType: "commit", + id: "abc1234def5678", + label: "abc1234", + meta: { shortHash: "abc1234" }, + }) + }) + + it("restores a non-composer resource_link as a link attachment", () => { + const { segments, attachments } = blocksToRestoredDraft( + [ + { + type: "resource_link", + uri: "data:text/plain;base64,xxx", + name: "note.txt", + mime_type: "text/plain", + description: null, + }, + ], + counter() + ) + expect(segments).toEqual([]) + expect(attachments).toEqual([ + { + id: "id-0", + type: "resource", + kind: "link", + uri: "data:text/plain;base64,xxx", + name: "note.txt", + mimeType: "text/plain", + }, + ]) + }) + + it("restores an embedded resource as an embedded attachment", () => { + const { attachments } = blocksToRestoredDraft( + [ + { + type: "resource", + uri: "clipboard://snippet.ts", + mime_type: "text/typescript", + text: "const x = 1", + blob: null, + }, + ], + counter() + ) + expect(attachments[0]).toMatchObject({ + type: "resource", + kind: "embedded", + uri: "clipboard://snippet.ts", + name: "snippet.ts", + mimeType: "text/typescript", + text: "const x = 1", + }) + }) + + it("restores an image block, deriving a name", () => { + const withUri = blocksToRestoredDraft( + [ + { + type: "image", + data: "AAAA", + mime_type: "image/png", + uri: "file:///a/shot.png", + }, + ], + counter() + ) + expect(withUri.attachments[0]).toMatchObject({ + type: "image", + data: "AAAA", + name: "shot.png", + mimeType: "image/png", + }) + const noUri = blocksToRestoredDraft( + [{ type: "image", data: "AAAA", mime_type: "image/jpeg" }], + counter() + ) + expect(noUri.attachments[0]).toMatchObject({ name: "image.jpeg" }) + }) + + it("preserves order across mixed blocks", () => { + const blocks: PromptInputBlock[] = [ + { type: "text", text: "see" }, + { + type: "resource_link", + uri: "file:///a.ts", + name: "a.ts", + mime_type: null, + description: null, + }, + { type: "text", text: "and" }, + ] + const { segments } = blocksToRestoredDraft(blocks, counter()) + expect(segments.map((s) => s.kind)).toEqual([ + "markdown", + "reference", + "markdown", + ]) + }) +}) + +describe("parseReferenceUri", () => { + it("returns null for unknown schemes", () => { + expect(parseReferenceUri("https://example.com", "x")).toBeNull() + expect(parseReferenceUri("data:text/plain,abc", "x")).toBeNull() + }) + it("falls back to the basename when name is empty", () => { + expect(parseReferenceUri("file:///repo/deep/name.ts", "")?.label).toBe( + "name.ts" + ) + }) +}) + +describe("round-trip with docToPromptBlocks", () => { + let editor: Editor + beforeEach(() => { + editor = new Editor({ extensions: buildComposerExtensions() }) + }) + afterEach(() => { + editor?.destroy() + }) + + it("a file reference survives send → restore as a badge", () => { + editor + .chain() + .insertContent("see ") + .insertReference({ + refType: "file", + id: "src/app.ts", + label: "app.ts", + uri: "file:///repo/src/app.ts", + meta: null, + }) + .insertContent(" please") + .run() + + const blocks = docToPromptBlocks(editor) + const { segments, attachments } = blocksToRestoredDraft(blocks, counter()) + + expect(attachments).toEqual([]) + const md = segments.find((s) => s.kind === "markdown") + expect(md && md.kind === "markdown" && md.text).toContain("see") + expect(refSegments(segments)[0]).toMatchObject({ + refType: "file", + uri: "file:///repo/src/app.ts", + label: "app.ts", + }) + }) +}) diff --git a/src/components/chat/composer/from-prompt-blocks.ts b/src/components/chat/composer/from-prompt-blocks.ts new file mode 100644 index 000000000..921a2ed73 --- /dev/null +++ b/src/components/chat/composer/from-prompt-blocks.ts @@ -0,0 +1,171 @@ +import type { PromptInputBlock } from "@/lib/types" +import { randomUUID } from "@/lib/utils" + +import type { InputAttachment } from "../message-input-attachments" +import type { ReferenceAttrs } from "./types" + +/** + * Restore serialization (inverse of {@link "./to-prompt-blocks".docToPromptBlocks}): + * turn a sent `PromptInputBlock[]` back into editor content + attachments, so a + * queued message can be re-opened for editing with its badges and attachments + * intact. + * + * The split mirrors the send rule: + * - `text` blocks → markdown segments replayed into the editor (inline + * session/commit/agent/skill references that were serialized *as text* come + * back as their text form — only **file** references were structured blocks, + * so only they round-trip to badges). + * - `resource_link` blocks whose uri is a composer scheme (`file:` / `codeg:`) + * → reference badge segments. + * - everything else (`image`, embedded `resource`, non-composer `resource_link`) + * → out-of-band attachments. + * + * The host replays `segments` in order against a live editor (markdown via + * `insertMarkdownAtCursor`, references via `insertReference`) and sets + * `attachments`. Pure and deterministic given an injected `makeId`. + */ +export type RestoreSegment = + | { kind: "markdown"; text: string } + | { kind: "reference"; attrs: ReferenceAttrs } + +export interface RestoredDraft { + segments: RestoreSegment[] + attachments: InputAttachment[] +} + +export function blocksToRestoredDraft( + blocks: PromptInputBlock[], + makeId: () => string = randomUUID +): RestoredDraft { + const segments: RestoreSegment[] = [] + const attachments: InputAttachment[] = [] + + for (const block of blocks) { + switch (block.type) { + case "text": { + if (block.text.trim().length > 0) { + segments.push({ kind: "markdown", text: block.text }) + } + break + } + case "resource_link": { + const attrs = parseReferenceUri(block.uri, block.name) + if (attrs) { + segments.push({ kind: "reference", attrs }) + } else { + attachments.push({ + id: makeId(), + type: "resource", + kind: "link", + uri: block.uri, + name: block.name, + mimeType: block.mime_type ?? null, + }) + } + break + } + case "resource": { + attachments.push({ + id: makeId(), + type: "resource", + kind: "embedded", + uri: block.uri, + name: fileBaseName(block.uri) || block.uri, + mimeType: block.mime_type ?? null, + text: block.text ?? null, + blob: block.blob ?? null, + }) + break + } + case "image": { + attachments.push({ + id: makeId(), + type: "image", + data: block.data, + uri: block.uri ?? null, + name: imageName(block), + mimeType: block.mime_type, + }) + break + } + } + } + + return { segments, attachments } +} + +// Schemes the composer emits as structured references (mirror reference-node.ts). +const SESSION_URI = /^codeg:\/\/session\/(.+)$/i +const COMMIT_URI = /^codeg:\/\/commit\/.*@(.+)$/i + +/** + * Parse a sent resource uri back into a reference, or null when it isn't a + * composer reference scheme (in which case it's restored as an attachment). + */ +export function parseReferenceUri( + uri: string, + name: string +): ReferenceAttrs | null { + const lower = uri.toLowerCase() + + if (lower.startsWith("file:")) { + const base = fileBaseName(uri) + return { + refType: "file", + id: base || uri, + label: name || base || uri, + uri, + meta: { fileKind: "file" }, + } + } + + const session = uri.match(SESSION_URI) + if (session) { + const id = session[1] + return { + refType: "session", + id, + label: name || `#${id}`, + uri, + meta: null, + } + } + + const commit = uri.match(COMMIT_URI) + if (commit) { + const hash = commit[1] + const shortHash = hash.slice(0, 7) + return { + refType: "commit", + id: hash, + label: name || shortHash, + uri, + meta: { shortHash }, + } + } + + return null +} + +/** Best-effort basename of a `file://` (or any path-shaped) uri. */ +function fileBaseName(uri: string): string { + const path = uri.replace(/^[a-z]+:\/+/i, "") + const last = path.split("/").filter(Boolean).pop() ?? "" + try { + return decodeURIComponent(last) + } catch { + return last + } +} + +/** Derive a display name for an image block (mirrors the transcript adapter). */ +function imageName( + block: Extract<PromptInputBlock, { type: "image" }> +): string { + if (block.uri && block.uri.trim().length > 0) { + const base = fileBaseName(block.uri) + if (base) return base + } + const ext = block.mime_type.split("/")[1]?.split("+")[0] ?? "image" + return `image.${ext}` +} diff --git a/src/components/chat/composer/rich-composer.test.tsx b/src/components/chat/composer/rich-composer.test.tsx index f5aaedcf7..6b6354b02 100644 --- a/src/components/chat/composer/rich-composer.test.tsx +++ b/src/components/chat/composer/rich-composer.test.tsx @@ -78,3 +78,102 @@ describe("RichComposer", () => { expect(onChange).not.toHaveBeenCalled() }) }) + +function dispatchKey( + ref: React.RefObject<RichComposerHandle | null>, + init: KeyboardEventInit +) { + const dom = ref.current?.getEditor()?.view.dom as HTMLElement + act(() => { + dom.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }) + ) + }) +} + +describe("RichComposer imperative inserts (Phase 3)", () => { + it("inserts markdown at the cursor", async () => { + const { ref } = await mount() + act(() => ref.current?.insertMarkdownAtCursor("hello **world**")) + expect(ref.current?.getMarkdown()).toContain("**world**") + }) + + it("inserts a reference badge and exposes it via getJSON", async () => { + const { ref } = await mount() + act(() => + ref.current?.insertReference({ + refType: "file", + id: "a.ts", + label: "a.ts", + uri: "file:///a.ts", + meta: null, + }) + ) + expect(JSON.stringify(ref.current?.getJSON())).toContain( + '"type":"reference"' + ) + }) +}) + +describe("RichComposer configurable submit / newline (Phase 3)", () => { + it("submits on a plain Enter by default", async () => { + const onSubmit = vi.fn() + const { ref } = await mount({ onSubmit }) + dispatchKey(ref, { key: "Enter" }) + expect(onSubmit).toHaveBeenCalledTimes(1) + }) + + it("treats Enter as a newline when submitShortcut is mod+enter", async () => { + const onSubmit = vi.fn() + const { ref } = await mount({ onSubmit, submitShortcut: "mod+enter" }) + dispatchKey(ref, { key: "Enter" }) + expect(onSubmit).not.toHaveBeenCalled() + dispatchKey(ref, { key: "Enter", metaKey: true }) + expect(onSubmit).toHaveBeenCalledTimes(1) + }) + + it("inserts a hard break on Shift+Enter without submitting", async () => { + const onSubmit = vi.fn() + const { ref } = await mount({ onSubmit }) + act(() => ref.current?.focus()) + dispatchKey(ref, { key: "Enter", shiftKey: true }) + expect(onSubmit).not.toHaveBeenCalled() + expect(JSON.stringify(ref.current?.getJSON())).toContain( + '"type":"hardBreak"' + ) + }) + + it("does not submit while an external menu is open", async () => { + const onSubmit = vi.fn() + const { ref } = await mount({ onSubmit, isExternalMenuOpen: true }) + dispatchKey(ref, { key: "Enter" }) + expect(onSubmit).not.toHaveBeenCalled() + }) + + it("submits on a custom non-Enter binding (Tab)", async () => { + const onSubmit = vi.fn() + const { ref } = await mount({ onSubmit, submitShortcut: "tab" }) + dispatchKey(ref, { key: "Tab" }) + expect(onSubmit).toHaveBeenCalledTimes(1) + }) + + it("breaks on a custom newline binding (Shift+Tab) without submitting", async () => { + const onSubmit = vi.fn() + const { ref } = await mount({ onSubmit, newlineShortcut: "shift+tab" }) + act(() => ref.current?.focus()) + dispatchKey(ref, { key: "Tab", shiftKey: true }) + expect(onSubmit).not.toHaveBeenCalled() + expect(JSON.stringify(ref.current?.getJSON())).toContain( + '"type":"hardBreak"' + ) + }) + + it("does not swallow Enter when no onSubmit handler is provided", async () => { + const { ref } = await mount() + act(() => ref.current?.setMarkdown("hello")) + act(() => ref.current?.focus()) + dispatchKey(ref, { key: "Enter" }) + // Enter fell through to the editor default (paragraph split), not swallowed. + expect(ref.current?.getJSON().content?.length).toBeGreaterThanOrEqual(2) + }) +}) diff --git a/src/components/chat/composer/rich-composer.tsx b/src/components/chat/composer/rich-composer.tsx index 50b655bae..7eaab213f 100644 --- a/src/components/chat/composer/rich-composer.tsx +++ b/src/components/chat/composer/rich-composer.tsx @@ -10,14 +10,14 @@ import { useState, type CSSProperties, } from "react" -import { type Editor } from "@tiptap/core" +import { type Editor, type JSONContent } from "@tiptap/core" import { EditorContent, useEditor } from "@tiptap/react" import { exitSuggestion } from "@tiptap/suggestion" import { cn } from "@/lib/utils" import { buildComposerExtensions } from "./editor-config" -import { shouldSubmitOnEnter } from "./submit-key" +import { decideComposerKey } from "./submit-key" import type { MentionController, MentionRenderState, @@ -42,6 +42,12 @@ export interface RichComposerHandle { focus: () => void /** Whether the document is empty (no text, no nodes). */ isEmpty: () => boolean + /** Serialize the current document to Tiptap JSON (for draft persistence). */ + getJSON: () => JSONContent + /** Insert Markdown at the current selection (quick messages, appended text). */ + insertMarkdownAtCursor: (markdown: string) => void + /** Insert an inline reference badge at the current selection. */ + insertReference: (attrs: ReferenceAttrs) => void /** Escape hatch to the underlying editor (null until initialized). */ getEditor: () => Editor | null } @@ -66,8 +72,9 @@ export interface RichComposerProps { */ onChange?: (markdown: string) => void /** - * Submit intent: Enter without Shift, while not composing (IME-safe) and not - * inside a code block. The host decides what "submit" means. + * Submit intent: fired when the `submitShortcut` binding is pressed while not + * composing (IME-safe) and not on a structural bare Enter (code block / list). + * The host decides what "submit" means. */ onSubmit?: () => void onFocus?: () => void @@ -79,6 +86,25 @@ export interface RichComposerProps { * effect. Omit to disable mentions. */ referenceSearch?: ReferenceSearch + /** + * Key binding (matchShortcutEvent form) that sends the message. Default + * `"enter"`. When set to a non-Enter binding, a plain Enter inserts a newline. + */ + submitShortcut?: string + /** Key binding that inserts a line break instead of sending. Default `"shift+enter"`. */ + newlineShortcut?: string + /** + * When true, an external (parent-driven) menu — e.g. the `/` runtime command + * list — owns navigation/confirm keys, so the composer never submits or breaks + * while it is open. The internal `@` panel does not need this flag. + */ + isExternalMenuOpen?: boolean + /** + * Called on paste before the editor handles it. Return true when the paste was + * consumed out-of-band (e.g. an image/file became an attachment) so the editor + * does not also insert it as text. + */ + onPasteFiles?: (event: ClipboardEvent) => boolean } /** @@ -102,6 +128,10 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>( onFocus, onBlur, referenceSearch, + submitShortcut, + newlineShortcut, + isExternalMenuOpen, + onPasteFiles, }, ref ) { @@ -115,12 +145,23 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>( // installed) is gated on whether mentions are currently enabled — robust to // the prop being added/removed after the editor is created once. const referenceSearchRef = useRef(referenceSearch) + const submitShortcutRef = useRef(submitShortcut) + const newlineShortcutRef = useRef(newlineShortcut) + const isExternalMenuOpenRef = useRef(isExternalMenuOpen) + const onPasteFilesRef = useRef(onPasteFiles) + // The live editor, captured for command access inside editorProps handlers + // (which are created before `editor` is assigned in this closure). + const editorInstanceRef = useRef<Editor | null>(null) useEffect(() => { onChangeRef.current = onChange onSubmitRef.current = onSubmit onFocusRef.current = onFocus onBlurRef.current = onBlur referenceSearchRef.current = referenceSearch + submitShortcutRef.current = submitShortcut + newlineShortcutRef.current = newlineShortcut + isExternalMenuOpenRef.current = isExternalMenuOpen + onPasteFilesRef.current = onPasteFiles }) // ── Unified `@` mention panel state bridge ── @@ -177,13 +218,43 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>( ...(ariaLabel ? { "aria-label": ariaLabel } : {}), }, handleKeyDown: (view, event) => { - // Only Enter is special; let everything else fall through cheaply. - if (event.key !== "Enter") return false - // While the `@` panel is open it owns Enter (select / close); never - // submit. Checked synchronously via a ref to beat the re-render. - if (mentionOpenRef.current) return false - // Resolve structural context: code blocks and list items keep Enter - // (newline / list split) instead of submitting. + // A panel/menu owns its navigation/confirm keys while open: the `@` + // panel (internal, ref-tracked) and any external parent-driven menu + // (e.g. `/` runtime commands). Never submit or break while one is open. + if (mentionOpenRef.current || isExternalMenuOpenRef.current) { + return false + } + // Bindings are free-form (Enter, Shift+Enter, Mod+Enter, Tab, …), so + // we can't pre-filter by key. Instead, run a cheap first pass with no + // structural context: if neither binding matches it's plain typing — + // bail before resolving the (slightly costlier) editor structure. + // (A bare Enter still matches the default submit binding here, so we + // never wrongly skip it; the code-block/list carve-out is applied in + // the second pass below, which only narrows the result.) + const keyEvent = { + key: event.key, + shiftKey: event.shiftKey, + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + isComposing: event.isComposing, + keyCode: (event as { keyCode?: number }).keyCode ?? 0, + } + const bindings = { + submit: submitShortcutRef.current ?? "enter", + newline: newlineShortcutRef.current ?? "shift+enter", + } + if ( + decideComposerKey( + keyEvent, + { composing: view.composing, inCodeBlock: false, inList: false }, + bindings + ) === null + ) { + return false + } + // A binding matched (or a bare Enter needing the structural carve-out): + // resolve code-block / list context and decide for real. const { $from } = view.state.selection let inCodeBlock = $from.parent.type.spec.code === true let inList = false @@ -192,26 +263,33 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>( if (name === "codeBlock") inCodeBlock = true if (name === "listItem" || name === "taskItem") inList = true } - const submit = shouldSubmitOnEnter( - { - key: event.key, - shiftKey: event.shiftKey, - altKey: event.altKey, - ctrlKey: event.ctrlKey, - metaKey: event.metaKey, - isComposing: event.isComposing, - keyCode: (event as { keyCode?: number }).keyCode ?? 0, - }, - { composing: view.composing, inCodeBlock, inList } + const action = decideComposerKey( + keyEvent, + { composing: view.composing, inCodeBlock, inList }, + bindings ) - if (submit && onSubmitRef.current) { + if (action === "submit") { + // Only consume the key once a handler actually runs; otherwise let + // the editor apply its default (e.g. Enter splits the paragraph). + if (!onSubmitRef.current) return false onSubmitRef.current() return true } + if (action === "newline") { + const ed = editorInstanceRef.current + if (!ed) return false + // Code blocks take a literal newline; everywhere else a hard break. + if (inCodeBlock) ed.commands.insertContent("\n") + else ed.commands.setHardBreak() + return true + } return false }, + handlePaste: (_view, event) => + onPasteFilesRef.current?.(event) === true, }, onCreate: ({ editor }) => { + editorInstanceRef.current = editor if (defaultMarkdown) { editor.commands.setContent(defaultMarkdown, { contentType: "markdown", @@ -219,6 +297,9 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>( }) } }, + onDestroy: () => { + editorInstanceRef.current = null + }, onUpdate: ({ editor }) => { onChangeRef.current?.(editor.getMarkdown()) }, @@ -241,6 +322,17 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>( clear: () => editor?.commands.clearContent(true), focus: () => editor?.commands.focus("end"), isEmpty: () => editor?.isEmpty ?? true, + getJSON: () => editor?.getJSON() ?? { type: "doc", content: [] }, + insertMarkdownAtCursor: (markdown) => { + editor + ?.chain() + .focus() + .insertContent(markdown, { contentType: "markdown" }) + .run() + }, + insertReference: (attrs) => { + editor?.chain().focus().insertReference(attrs).run() + }, getEditor: () => editor ?? null, }), [editor] diff --git a/src/components/chat/composer/submit-key.test.ts b/src/components/chat/composer/submit-key.test.ts index 8c34e6f00..e362eb18b 100644 --- a/src/components/chat/composer/submit-key.test.ts +++ b/src/components/chat/composer/submit-key.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest" import { + decideComposerKey, shouldSubmitOnEnter, + type ComposerKeyBindings, type SubmitKeyContext, type SubmitKeyEvent, } from "./submit-key" @@ -82,3 +84,93 @@ describe("shouldSubmitOnEnter", () => { expect(shouldSubmitOnEnter(plainEnter, topLevel)).toBe(true) }) }) + +describe("decideComposerKey", () => { + const DEFAULT: ComposerKeyBindings = { + submit: "enter", + newline: "shift+enter", + } + const SWAPPED: ComposerKeyBindings = { submit: "mod+enter", newline: "enter" } + + describe("default bindings (enter / shift+enter)", () => { + it("submits on a plain Enter at the top level", () => { + expect(decideComposerKey(plainEnter, topLevel, DEFAULT)).toBe("submit") + }) + + it("inserts a newline on Shift+Enter", () => { + expect( + decideComposerKey({ ...plainEnter, shiftKey: true }, topLevel, DEFAULT) + ).toBe("newline") + }) + + it("keeps the editor default for a bare Enter in a code block", () => { + expect( + decideComposerKey( + plainEnter, + { ...topLevel, inCodeBlock: true }, + DEFAULT + ) + ).toBeNull() + }) + + it("keeps the editor default for a bare Enter in a list", () => { + expect( + decideComposerKey(plainEnter, { ...topLevel, inList: true }, DEFAULT) + ).toBeNull() + }) + + it("does nothing for an unbound modified Enter (mod+enter)", () => { + expect( + decideComposerKey({ ...plainEnter, metaKey: true }, topLevel, DEFAULT) + ).toBeNull() + }) + + it.each([ + ["isComposing", { ...plainEnter, isComposing: true }, topLevel], + ["keyCode 229", { ...plainEnter, keyCode: 229 }, topLevel], + ["view.composing", plainEnter, { ...topLevel, composing: true }], + ] as const)("never acts mid-composition (%s)", (_n, event, context) => { + expect(decideComposerKey(event, context, DEFAULT)).toBeNull() + }) + }) + + describe("swapped bindings (mod+enter submits, enter = newline)", () => { + it.each([ + ["meta", { metaKey: true }], + ["ctrl", { ctrlKey: true }], + ])("submits on %s+Enter", (_n, mod) => { + expect( + decideComposerKey({ ...plainEnter, ...mod }, topLevel, SWAPPED) + ).toBe("submit") + }) + + it("treats a plain Enter as a newline", () => { + expect(decideComposerKey(plainEnter, topLevel, SWAPPED)).toBe("newline") + }) + + it("still keeps the structural default for a bare Enter in a list", () => { + expect( + decideComposerKey(plainEnter, { ...topLevel, inList: true }, SWAPPED) + ).toBeNull() + }) + + it("submits on Mod+Enter even inside a code block (not a bare Enter)", () => { + expect( + decideComposerKey( + { ...plainEnter, metaKey: true }, + { ...topLevel, inCodeBlock: true }, + SWAPPED + ) + ).toBe("submit") + }) + }) + + it("prefers submit over newline when both bindings match", () => { + expect( + decideComposerKey(plainEnter, topLevel, { + submit: "enter", + newline: "enter", + }) + ).toBe("submit") + }) +}) diff --git a/src/components/chat/composer/submit-key.ts b/src/components/chat/composer/submit-key.ts index 9808228b3..960b96820 100644 --- a/src/components/chat/composer/submit-key.ts +++ b/src/components/chat/composer/submit-key.ts @@ -4,6 +4,8 @@ * real ProseMirror view (jsdom can't emulate IME composition reliably). */ +import { matchShortcutEvent } from "@/lib/keyboard-shortcuts" + /** The subset of a keydown event the submit decision depends on. */ export interface SubmitKeyEvent { key: string @@ -50,3 +52,51 @@ export function shouldSubmitOnEnter( if (context.inCodeBlock || context.inList) return false return true } + +/** Configurable submit / newline key bindings (matchShortcutEvent strings). */ +export interface ComposerKeyBindings { + /** Binding that sends the message. Default `"enter"`. */ + submit: string + /** Binding that inserts a line break instead of sending. Default `"shift+enter"`. */ + newline: string +} + +/** What a keydown should do in the composer, or `null` to keep the editor default. */ +export type ComposerKeyAction = "submit" | "newline" | null + +/** + * Generalizes {@link shouldSubmitOnEnter} to the user-configurable submit / + * newline bindings (`send_message` / `newline_in_message`). Pure, so the + * precedence is unit-testable without a live view. + * + * Precedence: + * 1. Never act mid-IME-composition (the CJK candidate-confirming Enter). + * 2. A *bare* Enter inside a code block or list keeps ProseMirror's structural + * default (newline / list split) — it is never hijacked into submit or a + * forced break, regardless of the bindings. + * 3. The submit binding wins over the newline binding when both match. + * + * The newline binding is resolved explicitly (rather than deferring to the + * editor keymap) because bindings are free-form and may not correspond to a key + * ProseMirror binds. + */ +export function decideComposerKey( + event: SubmitKeyEvent, + context: SubmitKeyContext, + bindings: ComposerKeyBindings +): ComposerKeyAction { + if (event.isComposing || event.keyCode === 229 || context.composing) { + return null + } + const bareEnter = + event.key === "Enter" && + !event.shiftKey && + !event.altKey && + !event.ctrlKey && + !event.metaKey + if (bareEnter && (context.inCodeBlock || context.inList)) return null + + if (matchShortcutEvent(event, bindings.submit)) return "submit" + if (matchShortcutEvent(event, bindings.newline)) return "newline" + return null +} diff --git a/src/components/chat/composer/to-prompt-blocks.test.ts b/src/components/chat/composer/to-prompt-blocks.test.ts new file mode 100644 index 000000000..ed10baab1 --- /dev/null +++ b/src/components/chat/composer/to-prompt-blocks.test.ts @@ -0,0 +1,223 @@ +import { Editor } from "@tiptap/core" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import type { PromptInputBlock } from "@/lib/types" + +import { buildComposerExtensions } from "./editor-config" +import { docToPromptBlocks } from "./to-prompt-blocks" +import type { ReferenceAttrs } from "./types" + +function ref( + partial: Partial<ReferenceAttrs> & { refType: ReferenceAttrs["refType"] } +): ReferenceAttrs { + return { id: "", label: "", uri: null, meta: null, ...partial } +} + +/** Find the single text block (asserts exactly one exists). */ +function textBlock(blocks: PromptInputBlock[]): string { + const texts = blocks.filter((b) => b.type === "text") + expect(texts).toHaveLength(1) + return (texts[0] as Extract<PromptInputBlock, { type: "text" }>).text +} + +function links( + blocks: PromptInputBlock[] +): Extract<PromptInputBlock, { type: "resource_link" }>[] { + return blocks.filter( + (b): b is Extract<PromptInputBlock, { type: "resource_link" }> => + b.type === "resource_link" + ) +} + +describe("docToPromptBlocks", () => { + let editor: Editor + + beforeEach(() => { + editor = new Editor({ extensions: buildComposerExtensions() }) + }) + + afterEach(() => { + editor?.destroy() + }) + + it("serializes plain prose to a single text block", () => { + editor.commands.setContent("hello **world**", { contentType: "markdown" }) + const blocks = docToPromptBlocks(editor) + expect(blocks).toHaveLength(1) + expect(textBlock(blocks)).toContain("**world**") + }) + + it("returns no blocks for an empty document", () => { + expect(docToPromptBlocks(editor)).toEqual([]) + }) + + it("keeps an agent reference inline as text (no resource_link)", () => { + editor + .chain() + .insertContent("ask ") + .insertReference(ref({ refType: "agent", id: "codex", label: "Codex" })) + .run() + const blocks = docToPromptBlocks(editor) + expect(links(blocks)).toHaveLength(0) + expect(textBlock(blocks)).toContain("@Codex") + }) + + it("keeps a skill reference inline as the /id token", () => { + editor.commands.insertReference( + ref({ refType: "skill", id: "code-review", label: "Code Review" }) + ) + const blocks = docToPromptBlocks(editor) + expect(links(blocks)).toHaveLength(0) + expect(textBlock(blocks)).toContain("/code-review") + }) + + it("keeps a session reference inline as a codeg:// link (no resource_link)", () => { + editor + .chain() + .insertContent("see ") + .insertReference( + ref({ + refType: "session", + id: "1", + label: "Login refactor", + uri: "codeg://session/1", + }) + ) + .run() + const blocks = docToPromptBlocks(editor) + expect(links(blocks)).toHaveLength(0) + expect(textBlock(blocks)).toContain("codeg://session/1") + }) + + it("keeps a commit reference inline as a codeg:// link (no resource_link)", () => { + editor.commands.insertReference( + ref({ + refType: "commit", + id: "abc1234def", + label: "abc1234", + uri: "codeg://commit/%2Frepo@abc1234def", + }) + ) + const blocks = docToPromptBlocks(editor) + expect(links(blocks)).toHaveLength(0) + expect(textBlock(blocks)).toContain("codeg://commit/") + }) + + it("does not lift a file-typed reference carrying a non-file (codeg) uri", () => { + // A pasted/forged node could be refType "file" with a codeg: uri (the node's + // parseHTML allow-list permits codeg:). It must stay inline, never become an + // ACP resource_link with a non-fetchable uri. + editor.commands.insertReference( + ref({ + refType: "file", + id: "x", + label: "x", + uri: "codeg://session/9", + }) + ) + const blocks = docToPromptBlocks(editor) + expect(links(blocks)).toHaveLength(0) + expect(textBlock(blocks)).toContain("codeg://session/9") + }) + + it("lifts a file reference to a trailing resource_link and drops it from the prose", () => { + editor + .chain() + .insertContent("see ") + .insertReference( + ref({ + refType: "file", + id: "src/app.ts", + label: "app.ts", + uri: "file:///repo/src/app.ts", + }) + ) + .insertContent(" please") + .run() + const blocks = docToPromptBlocks(editor) + const text = textBlock(blocks) + expect(text).toContain("see") + expect(text).toContain("please") + expect(text).not.toContain("file://") + expect(text).not.toContain("app.ts") + expect(links(blocks)).toEqual([ + { + type: "resource_link", + uri: "file:///repo/src/app.ts", + name: "app.ts", + mime_type: null, + description: null, + }, + ]) + }) + + it("emits a file-only document as just the resource_link (no empty text block)", () => { + editor.commands.insertReference( + ref({ + refType: "file", + id: "a.ts", + label: "a.ts", + uri: "file:///repo/a.ts", + }) + ) + const blocks = docToPromptBlocks(editor) + expect(blocks).toHaveLength(1) + expect(blocks[0]).toMatchObject({ + type: "resource_link", + uri: "file:///repo/a.ts", + }) + }) + + it("preserves document order across multiple file references", () => { + editor + .chain() + .insertContent("a ") + .insertReference( + ref({ + refType: "file", + id: "1", + label: "one.ts", + uri: "file:///one.ts", + }) + ) + .insertContent(" b ") + .insertReference( + ref({ + refType: "file", + id: "2", + label: "two.ts", + uri: "file:///two.ts", + }) + ) + .run() + const uris = links(docToPromptBlocks(editor)).map((l) => l.uri) + expect(uris).toEqual(["file:///one.ts", "file:///two.ts"]) + }) + + it("falls back to the uri basename when a file reference has no label", () => { + editor.commands.insertReference( + ref({ + refType: "file", + id: "", + label: "", + uri: "file:///repo/deep/name.ts", + }) + ) + expect(links(docToPromptBlocks(editor))[0].name).toBe("name.ts") + }) + + it("preserves marks in prose alongside a lifted file reference", () => { + editor + .chain() + .insertContent("look at ") + .insertContent({ type: "text", marks: [{ type: "bold" }], text: "this" }) + .insertContent(" ") + .insertReference( + ref({ refType: "file", id: "x", label: "x.ts", uri: "file:///x.ts" }) + ) + .run() + const blocks = docToPromptBlocks(editor) + expect(textBlock(blocks)).toContain("**this**") + expect(links(blocks)).toHaveLength(1) + }) +}) diff --git a/src/components/chat/composer/to-prompt-blocks.ts b/src/components/chat/composer/to-prompt-blocks.ts new file mode 100644 index 000000000..6f920ad5f --- /dev/null +++ b/src/components/chat/composer/to-prompt-blocks.ts @@ -0,0 +1,104 @@ +import type { Editor, JSONContent } from "@tiptap/core" + +import type { PromptInputBlock } from "@/lib/types" + +import type { ReferenceAttrs } from "./types" + +/** + * Send serialization: turn the composer document into the prose + reference + * portion of a `PromptInputBlock[]`. (Out-of-band image/resource attachments are + * appended by the host's `buildDraft`; this function owns only the editor doc.) + * + * Per-refType rule — see the P3 design: + * - **file** references carry a `file://` uri and become first-class + * `resource_link` blocks (agent-readable resources), matching the pre-existing + * `@`-file behavior exactly: they are removed from the prose and appended as + * trailing ResourceLinks in document order. The backend folds each back to a + * `[name](uri)` link (`user_blocks_from_prompt`) and the transcript renders it + * as a chip — identical to today. + * - **session / commit** references (a `codeg://` uri the agent can't fetch) and + * **agent / skill** references (no uri) stay *inline* as text, rendered by the + * node's own `renderMarkdown` (see {@link "../reference-text".referenceToMarkdown}). + * + * Removing files from the prose (rather than splitting the text around them) + * keeps each text run a single block — no mid-paragraph fragmentation, no + * boundary-whitespace loss — so a sentence like "see <file> please" renders on + * one line with the file as a chip, exactly as the plain-textarea input did. + */ +export function docToPromptBlocks(editor: Editor): PromptInputBlock[] { + const doc = editor.getJSON() + const files: ReferenceAttrs[] = [] + const stripped = stripFileReferences(doc, files) + + const blocks: PromptInputBlock[] = [] + const text = serializeMarkdown(editor, stripped).trim() + if (text) blocks.push({ type: "text", text }) + for (const file of files) blocks.push(fileResourceLink(file)) + return blocks +} + +/** A reference node that should become a `resource_link` block: a file reference + * carrying a `file://` uri. The uri scheme is checked (not just refType) because + * the reference node's parseHTML allow-list also permits `codeg:` uris, so a + * pasted/forged `file`-typed node could carry a non-fetchable `codeg://` uri — + * those must stay inline as text, never be lifted to an ACP ResourceLink. */ +function isFileReference(node: JSONContent): boolean { + return ( + node.type === "reference" && + node.attrs?.refType === "file" && + typeof node.attrs?.uri === "string" && + node.attrs.uri.toLowerCase().startsWith("file://") + ) +} + +/** + * Deep-clone `node`, dropping every file reference from the inline content and + * collecting the originals into `files` in document order. Non-file references + * are left intact so they serialize inline. Dropping (rather than replacing with + * placeholder text) leaves the surrounding prose untouched; any incidental + * double space collapses on render and is harmless to the agent. + */ +function stripFileReferences( + node: JSONContent, + files: ReferenceAttrs[] +): JSONContent { + if (!node.content) return node + const content: JSONContent[] = [] + for (const child of node.content) { + if (isFileReference(child)) { + files.push(child.attrs as ReferenceAttrs) + continue + } + content.push(stripFileReferences(child, files)) + } + return { ...node, content } +} + +function fileResourceLink(attrs: ReferenceAttrs): PromptInputBlock { + const uri = attrs.uri as string + const name = attrs.label.trim() || fileBaseName(uri) || attrs.id || uri + return { + type: "resource_link", + uri, + name, + mime_type: null, + description: null, + } +} + +/** Best-effort basename of a `file://` uri, for a ResourceLink that lost its label. */ +function fileBaseName(uri: string): string { + const path = uri.replace(/^file:\/+/i, "") + const last = path.split("/").filter(Boolean).pop() ?? "" + try { + return decodeURIComponent(last) + } catch { + return last + } +} + +/** The Markdown manager is always present (the Markdown extension is always loaded). */ +function serializeMarkdown(editor: Editor, doc: JSONContent): string { + if (!editor.markdown) throw new Error("Markdown extension not loaded") + return editor.markdown.serialize(doc) +} diff --git a/src/components/chat/message-input-attachments.ts b/src/components/chat/message-input-attachments.ts new file mode 100644 index 000000000..4ce17fb2d --- /dev/null +++ b/src/components/chat/message-input-attachments.ts @@ -0,0 +1,41 @@ +/** + * Shared attachment value types for the message input. + * + * Extracted from `message-input.tsx` so the host component and the composer's + * send/restore serializers ({@link "./composer/to-prompt-blocks"} / + * {@link "./composer/from-prompt-blocks"}) all agree on one definition rather + * than re-declaring structurally-compatible copies. + * + * An attachment is content the user adds *out of band* of the prose — pasted / + * dragged / uploaded / picked images and files. Inline references typed via the + * `@` panel are NOT attachments; they live in the editor document as reference + * badges. Both fold into the outgoing `PromptInputBlock[]` at send time. + */ + +/** A file/resource attachment (a `file://` link, an uploaded blob, or an + * embedded text/binary resource). */ +export interface ResourceInputAttachment { + id: string + type: "resource" + /** `link` → sent as a ResourceLink (uri only); `embedded` → sent as a Resource + * carrying inline `text`/`blob`. */ + kind: "link" | "embedded" + uri: string + name: string + mimeType: string | null + text?: string | null + blob?: string | null +} + +/** An image attachment, held as base64 (no data-URI prefix). `uri` is the + * `file://` origin when added from a native path, else null. */ +export interface ImageInputAttachment { + id: string + type: "image" + data: string + uri: string | null + name: string + mimeType: string +} + +export type InputAttachment = ResourceInputAttachment | ImageInputAttachment diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 2f478b6c9..e7fd2fa6c 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -111,6 +111,11 @@ import { loadMessageInputDraft, saveMessageInputDraft, } from "@/lib/message-input-draft" +import type { + ImageInputAttachment, + InputAttachment, + ResourceInputAttachment, +} from "./message-input-attachments" interface MessageInputProps { onSend: (draft: PromptDraft, modeId?: string | null) => void @@ -152,28 +157,6 @@ interface MessageInputProps { feedbackAddDisabled?: boolean } -interface ResourceInputAttachment { - id: string - type: "resource" - kind: "link" | "embedded" - uri: string - name: string - mimeType: string | null - text?: string | null - blob?: string | null -} - -interface ImageInputAttachment { - id: string - type: "image" - data: string - uri: string | null - name: string - mimeType: string -} - -type InputAttachment = ResourceInputAttachment | ImageInputAttachment - const MIME_BY_EXT: Record<string, string> = { txt: "text/plain", md: "text/markdown", From a9dcfc7af39284e44e958f68327cd311a3c65dc2 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Thu, 11 Jun 2026 12:40:43 +0800 Subject: [PATCH 05/31] feat(composer): add draft v2 (Tiptap doc) persistence with v1 migration (Phase 3b) Persist the composer's Tiptap document as a v2 draft alongside the legacy v1 text draft, reusing the existing in-memory cache + requestIdleCallback batch writer + pagehide/visibilitychange flush. - loadMessageInputDraftV2 prefers the v2 doc (validated by isTiptapDoc), falls back to a v1 text draft returned as {kind:"legacyMarkdown"} for the host to hydrate, else null. - saveMessageInputDraftV2 caches the doc immediately (v2 wins in-session) and schedules a durable write; the legacy v1 draft is retired only once the v2 write actually succeeds, so a deferred write that later fails cannot lose the draft. - isTiptapDoc rejects arrays / partial payloads ({doc:{}}) so corrupt v2 data never reaches the editor. - clearMessageInputDraftV2 drops both the v2 and legacy v1 entries. Codex-reviewed (APPROVED). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- src/lib/message-input-draft.test.ts | 108 ++++++++++++++++++++++++ src/lib/message-input-draft.ts | 125 +++++++++++++++++++++++++++- 2 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 src/lib/message-input-draft.test.ts diff --git a/src/lib/message-input-draft.test.ts b/src/lib/message-input-draft.test.ts new file mode 100644 index 000000000..5e7f89083 --- /dev/null +++ b/src/lib/message-input-draft.test.ts @@ -0,0 +1,108 @@ +import type { JSONContent } from "@tiptap/core" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { + clearMessageInputDraftV2, + loadMessageInputDraftV2, + saveMessageInputDraft, + saveMessageInputDraftV2, +} from "./message-input-draft" + +const DOC: JSONContent = { + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: "hello" }] }], +} + +const V1 = (k: string) => `codeg:message-input-draft:v1:${k}` +const V2 = (k: string) => `codeg:message-input-draft:v2:${k}` + +beforeEach(() => { + localStorage.clear() +}) + +describe("message-input-draft v2", () => { + it("round-trips a document through save/load", () => { + saveMessageInputDraftV2("k-roundtrip", DOC) + expect(loadMessageInputDraftV2("k-roundtrip")).toEqual({ + kind: "doc", + doc: DOC, + }) + }) + + it("persists the document to localStorage under the v2 key", () => { + saveMessageInputDraftV2("k-persist", DOC) + const raw = localStorage.getItem(V2("k-persist")) + expect(raw).not.toBeNull() + expect(JSON.parse(raw as string)).toEqual({ doc: DOC }) + }) + + it("returns null when there is no draft", () => { + expect(loadMessageInputDraftV2("k-empty")).toBeNull() + }) + + it("falls back to a legacy v1 text draft (migration read)", () => { + saveMessageInputDraft("k-legacy", "draft text") + expect(loadMessageInputDraftV2("k-legacy")).toEqual({ + kind: "legacyMarkdown", + markdown: "draft text", + }) + }) + + it("does not delete the legacy v1 draft on read (unedited migration survives)", () => { + saveMessageInputDraft("k-keep", "still here") + loadMessageInputDraftV2("k-keep") + expect(localStorage.getItem(V1("k-keep"))).not.toBeNull() + }) + + it("a saved v2 document supersedes and removes the legacy v1 draft", () => { + saveMessageInputDraft("k-mig", "old text") + expect(loadMessageInputDraftV2("k-mig")).toMatchObject({ + kind: "legacyMarkdown", + }) + saveMessageInputDraftV2("k-mig", DOC) + expect(loadMessageInputDraftV2("k-mig")).toEqual({ kind: "doc", doc: DOC }) + expect(localStorage.getItem(V1("k-mig"))).toBeNull() + }) + + it("clearMessageInputDraftV2 removes both the v2 and legacy v1 entries", () => { + saveMessageInputDraft("k-clear", "legacy") + saveMessageInputDraftV2("k-clear", DOC) + clearMessageInputDraftV2("k-clear") + expect(loadMessageInputDraftV2("k-clear")).toBeNull() + expect(localStorage.getItem(V2("k-clear"))).toBeNull() + expect(localStorage.getItem(V1("k-clear"))).toBeNull() + }) + + it("keeps the legacy v1 draft when the v2 write fails (not retired early)", () => { + saveMessageInputDraft("k-fail", "legacy survives") + const setItem = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation((key: string) => { + if (key.startsWith("codeg:message-input-draft:v2:")) { + throw new Error("quota exceeded") + } + }) + try { + // Flushes synchronously in jsdom; the v2 setItem throws. + saveMessageInputDraftV2("k-fail", DOC) + } finally { + setItem.mockRestore() + } + // v1 is still on disk because the v2 write never succeeded. + expect(localStorage.getItem(V1("k-fail"))).not.toBeNull() + }) + + it("ignores a corrupt v2 payload and falls back to the legacy v1 draft", () => { + localStorage.setItem(V2("k-corrupt"), JSON.stringify({ doc: {} })) + saveMessageInputDraft("k-corrupt", "fallback") + expect(loadMessageInputDraftV2("k-corrupt")).toEqual({ + kind: "legacyMarkdown", + markdown: "fallback", + }) + }) + + it("ignores a non-object/array v2 payload and returns null without a v1 draft", () => { + localStorage.setItem(V2("k-corrupt2"), JSON.stringify({ doc: [1, 2] })) + expect(loadMessageInputDraftV2("k-corrupt2")).toBeNull() + }) +}) diff --git a/src/lib/message-input-draft.ts b/src/lib/message-input-draft.ts index be808c135..6ee37936b 100644 --- a/src/lib/message-input-draft.ts +++ b/src/lib/message-input-draft.ts @@ -1,12 +1,23 @@ "use client" +import type { JSONContent } from "@tiptap/core" + interface PersistedDraftState { text: string } +/** v2 draft payload: the composer's Tiptap document (preserves reference badges, + * which a Markdown round-trip would downgrade to plain links). */ +interface PersistedDraftStateV2 { + doc: JSONContent +} + const STORAGE_PREFIX = "codeg:message-input-draft:v1" +const STORAGE_PREFIX_V2 = "codeg:message-input-draft:v2" const draftTextCache = new Map<string, string>() +const draftDocCache = new Map<string, JSONContent>() const pendingPersistDrafts = new Map<string, string>() +const pendingPersistDocs = new Map<string, JSONContent>() let idlePersistHandle: number | null = null let persistenceListenersBound = false @@ -14,18 +25,37 @@ function storageKeyForDraftKey(draftKey: string): string { return `${STORAGE_PREFIX}:${draftKey}` } +function storageKeyForDraftKeyV2(draftKey: string): string { + return `${STORAGE_PREFIX_V2}:${draftKey}` +} + +/** A persisted v2 payload's `doc` is only trusted when it is a ProseMirror doc + * root (a non-array object whose `type` is "doc"); anything else (corrupt or + * partial payload, array, …) is rejected so we fall back to v1 / null rather + * than hand garbage to the editor. */ +function isTiptapDoc(value: unknown): value is JSONContent { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + (value as { type?: unknown }).type === "doc" + ) +} + function flushPendingDraftPersistence(): void { if (typeof window === "undefined") return - if (pendingPersistDrafts.size === 0) { + if (pendingPersistDrafts.size === 0 && pendingPersistDocs.size === 0) { idlePersistHandle = null return } - const entries = Array.from(pendingPersistDrafts.entries()) + const textEntries = Array.from(pendingPersistDrafts.entries()) pendingPersistDrafts.clear() + const docEntries = Array.from(pendingPersistDocs.entries()) + pendingPersistDocs.clear() idlePersistHandle = null - for (const [draftKey, text] of entries) { + for (const [draftKey, text] of textEntries) { try { localStorage.setItem( storageKeyForDraftKey(draftKey), @@ -35,6 +65,21 @@ function flushPendingDraftPersistence(): void { // Ignore storage quota/permission failures. } } + for (const [draftKey, doc] of docEntries) { + let persisted = false + try { + localStorage.setItem( + storageKeyForDraftKeyV2(draftKey), + JSON.stringify({ doc } satisfies PersistedDraftStateV2) + ) + persisted = true + } catch { + // Keep the legacy v1 draft as a fallback when the v2 write fails + // (quota / permission / serialization), so the draft is not lost. + } + // Only retire the legacy v1 draft once the v2 document is durably written. + if (persisted) clearMessageInputDraft(draftKey) + } } function cancelScheduledDraftPersistence(): void { @@ -132,3 +177,77 @@ export function clearMessageInputDraft(draftKey: string): void { /* ignore */ } } + +/** + * Result of loading a v2 draft: a parsed composer document, a legacy v1 Markdown + * string to hydrate via `setMarkdown` (migration), or null when no draft exists. + */ +export type LoadedDraftV2 = + | { kind: "doc"; doc: JSONContent } + | { kind: "legacyMarkdown"; markdown: string } + | null + +/** + * Load the persisted composer draft for a key. Prefers the v2 document; falls + * back to a v1 text draft (returned as `legacyMarkdown` for the host to hydrate + * as Markdown), or null. The v1 draft is left in place on read — it is only + * cleared once a v2 document is actually saved ({@link saveMessageInputDraftV2}), + * so an unedited migration is never lost. + */ +export function loadMessageInputDraftV2(draftKey: string): LoadedDraftV2 { + const cached = draftDocCache.get(draftKey) + if (cached) return { kind: "doc", doc: cached } + + if (typeof window !== "undefined") { + try { + const raw = localStorage.getItem(storageKeyForDraftKeyV2(draftKey)) + if (raw) { + const parsed = JSON.parse(raw) as Partial<PersistedDraftStateV2> + if (isTiptapDoc(parsed?.doc)) { + draftDocCache.set(draftKey, parsed.doc) + return { kind: "doc", doc: parsed.doc } + } + } + } catch { + // Fall through to the legacy v1 draft. + } + } + + const legacy = loadMessageInputDraft(draftKey) + if (legacy != null && legacy.length > 0) { + return { kind: "legacyMarkdown", markdown: legacy } + } + return null +} + +/** + * Persist the composer document for a key (v2). The host calls this only for a + * non-empty document and {@link clearMessageInputDraftV2} otherwise. The in-memory + * `draftDocCache` makes the v2 document win immediately; any legacy v1 text draft + * is only removed once the v2 write is durably flushed (see the flush path), so a + * deferred write that later fails cannot lose the draft. + */ +export function saveMessageInputDraftV2( + draftKey: string, + doc: JSONContent +): void { + draftDocCache.set(draftKey, doc) + if (typeof window === "undefined") return + + pendingPersistDocs.set(draftKey, doc) + scheduleDraftPersistence() +} + +export function clearMessageInputDraftV2(draftKey: string): void { + draftDocCache.delete(draftKey) + pendingPersistDocs.delete(draftKey) + // Also drop any legacy v1 draft so a cleared composer stays cleared. + clearMessageInputDraft(draftKey) + if (typeof window === "undefined") return + + try { + localStorage.removeItem(storageKeyForDraftKeyV2(draftKey)) + } catch { + /* ignore */ + } +} From c8e4f43658a4c358ec7eeafb4042f44586c18e9f Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Thu, 11 Jun 2026 13:48:56 +0800 Subject: [PATCH 06/31] feat(composer): add useReferenceSearch data-source provider for the @ panel (Phase 3c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose the live data sources (file tree, ACP agents, conversations, git log, skills, experts) into one referentially-stable ReferenceSearch for the composer's unified @ mention panel. - buildReferenceGroups (pure): filter/adapt/order into the fixed file → agent → session → commit → skill groups, each capped at 50, skills+experts deduped by id. No workspace path → file/commit groups stay empty (graceful R8). - The returned search is an empty-dep useCallback reading every source from a ref, so a background refresh of any hook (e.g. agents reloading on focus) never changes its identity — the open panel keeps its results and selection. - Sessions/commits are fetched lazily on the first @, key-cached, and awaited so the first open is populated without a keystroke; window focus busts the caches. A rejected fetch is NOT cached (the entry is cleared) so the next @ retries. - Folder-switch safety: the post-await freshness guard discards a stale result when the workspace path changed mid-fetch. pathRef/enabledRef are mirrored in a commit-synchronous layout effect so a stale git-log resolving in the post-commit/pre-passive-effect window can't leak the old folder's commits. Codex-reviewed (APPROVED) across three rounds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../composer/use-reference-search.test.ts | 562 ++++++++++++++++++ .../chat/composer/use-reference-search.ts | 377 ++++++++++++ 2 files changed, 939 insertions(+) create mode 100644 src/components/chat/composer/use-reference-search.test.ts create mode 100644 src/components/chat/composer/use-reference-search.ts diff --git a/src/components/chat/composer/use-reference-search.test.ts b/src/components/chat/composer/use-reference-search.test.ts new file mode 100644 index 000000000..82045d490 --- /dev/null +++ b/src/components/chat/composer/use-reference-search.test.ts @@ -0,0 +1,562 @@ +import { act, renderHook } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import type { FlatFileEntry } from "@/hooks/use-file-tree" +import type { + AcpAgentInfo, + AgentSkillItem, + DbConversationSummary, + ExpertListItem, + GitLogEntry, +} from "@/lib/types" + +import type { ReferenceKind } from "./types" +import type { SuggestionGroup } from "./suggestion/types" +import { + buildReferenceGroups, + DEFAULT_GROUP_LABELS, + useReferenceSearch, + type ReferenceSearchSources, +} from "./use-reference-search" + +// --- fixtures --------------------------------------------------------------- + +function makeFile( + relativePath: string, + kind: "file" | "dir" = "file" +): FlatFileEntry { + const name = relativePath.split("/").pop() ?? relativePath + return { + name, + relativePath, + kind, + lowerPath: relativePath.toLowerCase(), + lowerName: name.toLowerCase(), + } +} + +function makeAgent( + agentType: string, + over: { name?: string; description?: string } = {} +): AcpAgentInfo { + return { + agent_type: agentType, + name: over.name ?? agentType, + description: over.description ?? "", + available: true, + sort_order: 0, + } as unknown as AcpAgentInfo +} + +function makeConversation(id: number, title: string): DbConversationSummary { + return { + id, + title, + agent_type: "claude_code", + status: "idle", + git_branch: null, + } as unknown as DbConversationSummary +} + +function makeCommit( + hash: string, + message = "msg", + author = "Dev" +): GitLogEntry { + return { + hash, + full_hash: `${hash}0000`, + author, + date: "2026-01-01", + message, + files: [], + pushed: false, + } +} + +function makeSkill(id: string, name: string): AgentSkillItem { + return { + id, + name, + scope: "project", + description: `${name} skill`, + } as unknown as AgentSkillItem +} + +function makeExpert( + id: string, + displayName: Record<string, string>, + over: { category?: string } = {} +): ExpertListItem { + return { + metadata: { + id, + category: over.category ?? "review", + icon: null, + sort_order: 0, + display_name: displayName, + description: { en: `${id} description` }, + bundled_hash: "hash", + }, + installed_centrally: true, + user_modified: false, + central_path: "/experts/x", + } +} + +function emptySources( + over: Partial<ReferenceSearchSources> = {} +): ReferenceSearchSources { + return { + files: [], + workspaceRoot: null, + agents: [], + sessions: [], + commits: [], + repoKey: null, + skills: [], + builtInExperts: [], + agentExperts: [], + locale: "en", + ...over, + } +} + +const itemsOf = (groups: SuggestionGroup[], kind: ReferenceKind) => + groups.find((g) => g.kind === kind)?.items ?? [] + +// --- pure builder ----------------------------------------------------------- + +describe("buildReferenceGroups", () => { + it("returns the five groups in a fixed order", () => { + const groups = buildReferenceGroups("", emptySources()) + expect(groups.map((g) => g.kind)).toEqual([ + "file", + "agent", + "session", + "commit", + "skill", + ]) + }) + + it("keeps every group present (empty groups are not dropped)", () => { + const groups = buildReferenceGroups("", emptySources()) + expect(groups).toHaveLength(5) + expect(groups.every((g) => g.items.length === 0)).toBe(true) + }) + + it("defaults the group headings to the English labels", () => { + const groups = buildReferenceGroups("", emptySources()) + expect(groups.map((g) => g.label)).toEqual([ + DEFAULT_GROUP_LABELS.file, + DEFAULT_GROUP_LABELS.agent, + DEFAULT_GROUP_LABELS.session, + DEFAULT_GROUP_LABELS.commit, + DEFAULT_GROUP_LABELS.skill, + ]) + }) + + it("accepts injected (localized) group headings", () => { + const labels = { + file: "文件", + agent: "智能体", + session: "会话", + commit: "提交", + skill: "技能", + } + const groups = buildReferenceGroups("", emptySources(), labels) + expect(itemsOf(groups, "file")).toBeDefined() + expect(groups.find((g) => g.kind === "agent")?.label).toBe("智能体") + }) + + it("adapts files into file:// references rooted at the workspace", () => { + const groups = buildReferenceGroups( + "", + emptySources({ + files: [makeFile("a.ts"), makeFile("src/app.ts")], + workspaceRoot: "/repo", + }) + ) + const files = itemsOf(groups, "file") + expect(files).toHaveLength(2) + expect(files.map((f) => f.reference.uri)).toEqual([ + "file:///repo/a.ts", + "file:///repo/src/app.ts", + ]) + }) + + it("filters files by name or relative path, case-insensitively", () => { + const groups = buildReferenceGroups( + "APP", + emptySources({ + files: [makeFile("a.ts"), makeFile("src/App.tsx")], + workspaceRoot: "/repo", + }) + ) + const files = itemsOf(groups, "file") + expect(files).toHaveLength(1) + expect(files[0].reference.id).toBe("src/App.tsx") + }) + + it("omits the file group when there is no workspace root (R8)", () => { + const groups = buildReferenceGroups( + "", + emptySources({ files: [makeFile("a.ts")], workspaceRoot: null }) + ) + expect(itemsOf(groups, "file")).toHaveLength(0) + }) + + it("filters agents by name / type / description", () => { + const groups = buildReferenceGroups( + "codex", + emptySources({ + agents: [ + makeAgent("codex", { name: "Codex" }), + makeAgent("gemini", { name: "Gemini" }), + ], + }) + ) + const agents = itemsOf(groups, "agent") + expect(agents).toHaveLength(1) + expect(agents[0].reference.id).toBe("codex") + }) + + it("adapts sessions into codeg://session references", () => { + const groups = buildReferenceGroups( + "login", + emptySources({ + sessions: [ + makeConversation(7, "Login refactor"), + makeConversation(8, "Sidebar perf"), + ], + }) + ) + const sessions = itemsOf(groups, "session") + expect(sessions).toHaveLength(1) + expect(sessions[0].reference.uri).toBe("codeg://session/7") + }) + + it("omits the commit group when there is no repoKey (R8)", () => { + const groups = buildReferenceGroups( + "", + emptySources({ commits: [makeCommit("abc1234")], repoKey: null }) + ) + expect(itemsOf(groups, "commit")).toHaveLength(0) + }) + + it("adapts commits and filters by hash / message / author", () => { + const groups = buildReferenceGroups( + "bugfix", + emptySources({ + commits: [ + makeCommit("abc1234", "bugfix: crash"), + makeCommit("def5678", "feature"), + ], + repoKey: "/repo", + }) + ) + const commits = itemsOf(groups, "commit") + expect(commits).toHaveLength(1) + expect(commits[0].reference.uri).toBe("codeg://commit/%2Frepo@abc12340000") + }) + + it("merges skills + experts into one group and dedupes by id (skill wins)", () => { + const groups = buildReferenceGroups( + "", + emptySources({ + skills: [makeSkill("dup", "Skill Dup"), makeSkill("only-skill", "S")], + builtInExperts: [makeExpert("dup", { en: "Expert Dup" })], + agentExperts: [makeExpert("agent-only", { en: "Agent Expert" })], + }) + ) + const skills = itemsOf(groups, "skill") + expect(skills.map((s) => s.reference.id)).toEqual([ + "dup", + "only-skill", + "agent-only", + ]) + // The first occurrence (the project skill) wins the dedupe. + expect(skills[0].reference.label).toBe("Skill Dup") + }) + + it("localizes expert labels by the provided locale", () => { + const groups = buildReferenceGroups( + "", + emptySources({ + builtInExperts: [ + makeExpert("reviewer", { en: "Reviewer", "zh-CN": "评审员" }), + ], + locale: "zh-CN", + }) + ) + expect(itemsOf(groups, "skill")[0].reference.label).toBe("评审员") + }) + + it("caps each group at 50 items", () => { + const files = Array.from({ length: 60 }, (_, i) => makeFile(`f${i}.ts`)) + const groups = buildReferenceGroups( + "", + emptySources({ files, workspaceRoot: "/repo" }) + ) + expect(itemsOf(groups, "file")).toHaveLength(50) + }) + + it("returns everything for an empty query (whitespace-trimmed)", () => { + const groups = buildReferenceGroups( + " ", + emptySources({ + agents: [makeAgent("codex"), makeAgent("gemini")], + }) + ) + expect(itemsOf(groups, "agent")).toHaveLength(2) + }) +}) + +// --- hook -------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + agents: [] as AcpAgentInfo[], + files: { allFiles: [] as FlatFileEntry[], loaded: false }, + skills: [] as AgentSkillItem[], + builtInExperts: [] as ExpertListItem[], + agentExperts: [] as ExpertListItem[], + listAllConversations: vi.fn(), + gitLog: vi.fn(), +})) + +vi.mock("next-intl", () => ({ useLocale: () => "en" })) +vi.mock("@/hooks/use-file-tree", () => ({ + useFileTree: () => ({ + allFiles: mocks.files.allFiles, + loaded: mocks.files.loaded, + loading: false, + reset: () => {}, + }), +})) +vi.mock("@/hooks/use-acp-agents", () => ({ + useAcpAgents: () => ({ agents: mocks.agents, fresh: true, refresh: vi.fn() }), +})) +vi.mock("@/hooks/use-agent-skills", () => ({ + useAgentSkills: () => mocks.skills, +})) +vi.mock("@/hooks/use-built-in-experts", () => ({ + useBuiltInExperts: () => mocks.builtInExperts, +})) +vi.mock("@/hooks/use-agent-experts", () => ({ + useAgentExperts: () => mocks.agentExperts, +})) +vi.mock("@/lib/api", () => ({ + listAllConversations: (...args: unknown[]) => + mocks.listAllConversations(...args), + gitLog: (...args: unknown[]) => mocks.gitLog(...args), +})) + +describe("useReferenceSearch", () => { + beforeEach(() => { + mocks.agents = [] + mocks.files = { allFiles: [], loaded: false } + mocks.skills = [] + mocks.builtInExperts = [] + mocks.agentExperts = [] + mocks.listAllConversations.mockReset().mockResolvedValue([]) + mocks.gitLog + .mockReset() + .mockResolvedValue({ entries: [], has_upstream: false }) + }) + + it("returns a referentially stable search across data-source updates (R7)", async () => { + mocks.agents = [makeAgent("codex", { name: "Codex" })] + const { result, rerender } = renderHook( + (props: { enabled: boolean }) => useReferenceSearch(props), + { initialProps: { enabled: true } } + ) + const first = result.current + + // A background refresh swaps the agents array reference. + mocks.agents = [ + makeAgent("codex", { name: "Codex" }), + makeAgent("gemini", { name: "Gemini" }), + ] + rerender({ enabled: true }) + + // Identity is unchanged — the popup will not re-fetch or reset selection… + expect(result.current).toBe(first) + // …yet the stable function reads the freshest data through its refs. + let groups!: SuggestionGroup[] + await act(async () => { + groups = (await result.current("")) as SuggestionGroup[] + }) + expect(itemsOf(groups, "agent")).toHaveLength(2) + }) + + it("lazily fetches and awaits sessions + commits on the first search", async () => { + mocks.listAllConversations.mockResolvedValue([ + makeConversation(7, "Login refactor"), + ]) + mocks.gitLog.mockResolvedValue({ + entries: [makeCommit("abc1234", "fix")], + has_upstream: false, + }) + mocks.files = { allFiles: [makeFile("a.ts")], loaded: true } + + const { result } = renderHook(() => + useReferenceSearch({ defaultPath: "/repo", enabled: true }) + ) + let groups!: SuggestionGroup[] + await act(async () => { + groups = (await result.current("")) as SuggestionGroup[] + }) + + expect(mocks.listAllConversations).toHaveBeenCalledTimes(1) + expect(mocks.gitLog).toHaveBeenCalledWith("/repo", 100) + expect(itemsOf(groups, "session")).toHaveLength(1) + expect(itemsOf(groups, "commit")).toHaveLength(1) + expect(itemsOf(groups, "file")).toHaveLength(1) + }) + + it("reuses the cached network promises across repeated searches", async () => { + const { result } = renderHook(() => + useReferenceSearch({ defaultPath: "/repo", enabled: true }) + ) + await act(async () => { + await result.current("") + await result.current("a") + await result.current("ab") + }) + expect(mocks.listAllConversations).toHaveBeenCalledTimes(1) + expect(mocks.gitLog).toHaveBeenCalledTimes(1) + }) + + it("resolves to no groups and touches no network when disabled", async () => { + const { result } = renderHook(() => + useReferenceSearch({ defaultPath: "/repo", enabled: false }) + ) + let groups!: SuggestionGroup[] + await act(async () => { + groups = (await result.current("x")) as SuggestionGroup[] + }) + expect(groups).toEqual([]) + expect(mocks.listAllConversations).not.toHaveBeenCalled() + expect(mocks.gitLog).not.toHaveBeenCalled() + }) + + it("returns no groups when the query is aborted mid-fetch", async () => { + mocks.listAllConversations.mockImplementation( + () => + new Promise((resolve) => + setTimeout(() => resolve([makeConversation(1, "x")]), 10) + ) + ) + const { result } = renderHook(() => + useReferenceSearch({ defaultPath: "/repo", enabled: true }) + ) + let groups!: SuggestionGroup[] + await act(async () => { + const controller = new AbortController() + const pending = result.current("", controller.signal) + controller.abort() + groups = (await pending) as SuggestionGroup[] + }) + expect(groups).toEqual([]) + }) + + it("degrades gracefully with no workspace path: agents/skills resolve, files/commits stay empty (R8)", async () => { + mocks.agents = [makeAgent("codex", { name: "Codex" })] + mocks.builtInExperts = [makeExpert("reviewer", { en: "Reviewer" })] + mocks.files = { allFiles: [makeFile("a.ts")], loaded: true } + + const { result } = renderHook(() => useReferenceSearch({ enabled: true })) + let groups!: SuggestionGroup[] + await act(async () => { + groups = (await result.current("")) as SuggestionGroup[] + }) + + expect(itemsOf(groups, "file")).toHaveLength(0) + expect(itemsOf(groups, "commit")).toHaveLength(0) + expect(itemsOf(groups, "agent")).toHaveLength(1) + expect(itemsOf(groups, "skill")).toHaveLength(1) + expect(mocks.gitLog).not.toHaveBeenCalled() + }) + + it("does not leak the previous folder's commits when defaultPath changes mid-fetch", async () => { + // git-log for repo A hangs until we resolve it by hand; repo B resolves + // immediately. We switch folders before A resolves. + let resolveA!: (value: { + entries: GitLogEntry[] + has_upstream: boolean + }) => void + mocks.gitLog.mockImplementation((repoPath: string) => { + if (repoPath === "/repoA") { + return new Promise((resolve) => { + resolveA = resolve + }) + } + return Promise.resolve({ + entries: [makeCommit("bbb", "repo B commit")], + has_upstream: false, + }) + }) + + const { result, rerender } = renderHook( + (props: { defaultPath: string }) => + useReferenceSearch({ defaultPath: props.defaultPath, enabled: true }), + { initialProps: { defaultPath: "/repoA" } } + ) + + // Start a search that hangs on gitLog("/repoA"). + let pending!: SuggestionGroup[] | Promise<SuggestionGroup[]> + await act(async () => { + pending = result.current("") + }) + + // The composer switches to repo B; commit flushes the ref mirror (pathRef → + // "/repoB") before the stale repo A fetch resolves. + // + // NOTE: jsdom + RTL `act()` flush BOTH layout and passive effects at their + // boundaries, so a unit test cannot reproduce the real-browser window where + // a passive effect lags behind a macrotask that resolves the stale promise. + // This asserts the guard CONTRACT (a folder switch discards the stale + // result); the production fix that closes the timing window is the + // commit-synchronous layout-effect mirror of pathRef in the hook. + await act(async () => { + rerender({ defaultPath: "/repoB" }) + }) + + let groups!: SuggestionGroup[] + await act(async () => { + resolveA({ + entries: [makeCommit("aaa", "repo A commit")], + has_upstream: false, + }) + groups = (await pending) as SuggestionGroup[] + }) + + // The stale invocation must not render repo A commits into repo B's panel; + // it bails so the next keystroke re-queries the current folder. + expect(groups).toEqual([]) + }) + + it("retries a lazy fetch after it rejects (a failure is never cached)", async () => { + mocks.listAllConversations + .mockRejectedValueOnce(new Error("transient")) + .mockResolvedValueOnce([makeConversation(1, "Recovered")]) + + const { result } = renderHook(() => useReferenceSearch({ enabled: true })) + + let first!: SuggestionGroup[] + await act(async () => { + first = (await result.current("")) as SuggestionGroup[] + }) + // First fetch rejected → the session group is empty, but the others render. + expect(itemsOf(first, "session")).toHaveLength(0) + + let second!: SuggestionGroup[] + await act(async () => { + second = (await result.current("")) as SuggestionGroup[] + }) + // The failure was not cached, so the second `@` issues a fresh request… + expect(mocks.listAllConversations).toHaveBeenCalledTimes(2) + // …which succeeds and populates the group. + expect(itemsOf(second, "session")).toHaveLength(1) + }) +}) diff --git a/src/components/chat/composer/use-reference-search.ts b/src/components/chat/composer/use-reference-search.ts new file mode 100644 index 000000000..1fefaf47e --- /dev/null +++ b/src/components/chat/composer/use-reference-search.ts @@ -0,0 +1,377 @@ +"use client" + +import { useCallback, useEffect, useLayoutEffect, useRef } from "react" +import { useLocale } from "next-intl" + +import { useAcpAgents } from "@/hooks/use-acp-agents" +import { useAgentExperts } from "@/hooks/use-agent-experts" +import { useAgentSkills } from "@/hooks/use-agent-skills" +import { useBuiltInExperts } from "@/hooks/use-built-in-experts" +import { useFileTree, type FlatFileEntry } from "@/hooks/use-file-tree" +import { gitLog, listAllConversations } from "@/lib/api" +import type { + AcpAgentInfo, + AgentType, + DbConversationSummary, + ExpertListItem, + GitLogEntry, +} from "@/lib/types" + +import { + agentToSuggestion, + commitToSuggestion, + expertToSuggestion, + fileToSuggestion, + sessionToSuggestion, + skillToSuggestion, +} from "./suggestion/adapters" +import type { + ReferenceSearch, + SuggestionGroup, + SuggestionItem, +} from "./suggestion/types" +import type { AgentSkillItem } from "@/lib/types" + +// Commit-synchronous on the client (so the guard-critical refs are updated +// during commit, before any later macrotask/microtask can resolve a stale +// in-flight fetch), but a no-op-safe passive effect during the static-export +// prerender where `useLayoutEffect` would warn. +const useIsomorphicLayoutEffect = + typeof window !== "undefined" ? useLayoutEffect : useEffect + +/** Max rows surfaced per group (mirrors the textarea `@` menu's file cap). */ +const MAX_PER_GROUP = 50 +/** How many commits the git-log group pulls (client-filtered down from here). */ +const GIT_LOG_LIMIT = 100 +const EMPTY_COMMITS: Promise<GitLogEntry[]> = Promise.resolve([]) + +/** Display headings for each group; injected so the host can localize them. */ +export interface ReferenceGroupLabels { + file: string + agent: string + session: string + commit: string + skill: string +} + +/** + * English fallbacks, matching the suggestion popup's `emptyLabel`/`loadingLabel` + * convention (the host passes localized strings at the integration layer). + */ +export const DEFAULT_GROUP_LABELS: ReferenceGroupLabels = { + file: "Files", + agent: "Agents", + session: "Sessions", + commit: "Commits", + skill: "Skills", +} + +/** Raw, already-loaded data the pure group builder turns into suggestions. */ +export interface ReferenceSearchSources { + files: FlatFileEntry[] + /** Workspace root the `files` were loaded under; null disables the group. */ + workspaceRoot: string | null + agents: AcpAgentInfo[] + sessions: DbConversationSummary[] + commits: GitLogEntry[] + /** Repo identity for commit URIs; null disables the commit group. */ + repoKey: string | null + skills: AgentSkillItem[] + builtInExperts: ExpertListItem[] + agentExperts: ExpertListItem[] + locale: string +} + +/** Case-insensitive substring match against an adapted item's searchable text. */ +function suggestionMatches(item: SuggestionItem, lowerQuery: string): boolean { + if (!lowerQuery) return true + const ref = item.reference + return ( + ref.label.toLowerCase().includes(lowerQuery) || + ref.id.toLowerCase().includes(lowerQuery) || + (item.keywords ?? "").toLowerCase().includes(lowerQuery) || + (item.detail ?? "").toLowerCase().includes(lowerQuery) + ) +} + +/** + * Pure: filter + adapt the raw sources into the fixed-order grouped suggestions + * the `@` panel renders (files → agents → sessions → commits → skills). Each + * group is independently capped at {@link MAX_PER_GROUP}; empty groups are kept + * (the popup hides them) so the order is always stable. Extracted from the hook + * so the matching/ordering/dedup logic is testable without React. + */ +export function buildReferenceGroups( + query: string, + sources: ReferenceSearchSources, + labels: ReferenceGroupLabels = DEFAULT_GROUP_LABELS +): SuggestionGroup[] { + const q = query.trim().toLowerCase() + + // Files: filter the (potentially large) list on its pre-lowered fields before + // paying to adapt the survivors. + const fileItems: SuggestionItem[] = [] + const root = sources.workspaceRoot + if (root) { + for (const entry of sources.files) { + if (q && !entry.lowerName.includes(q) && !entry.lowerPath.includes(q)) { + continue + } + fileItems.push(fileToSuggestion(entry, root)) + if (fileItems.length >= MAX_PER_GROUP) break + } + } + + const agentItems = sources.agents + .map(agentToSuggestion) + .filter((item) => suggestionMatches(item, q)) + .slice(0, MAX_PER_GROUP) + + const sessionItems = sources.sessions + .map(sessionToSuggestion) + .filter((item) => suggestionMatches(item, q)) + .slice(0, MAX_PER_GROUP) + + const commitItems: SuggestionItem[] = [] + if (sources.repoKey) { + const repoKey = sources.repoKey + for (const entry of sources.commits) { + const item = commitToSuggestion(entry, repoKey) + if (!suggestionMatches(item, q)) continue + commitItems.push(item) + if (commitItems.length >= MAX_PER_GROUP) break + } + } + + // Skills + built-in experts + agent-linked experts share one group. An expert + // can surface from more than one source, so dedupe by reference id (skill id), + // keeping the first occurrence, before filtering. + const skillItems: SuggestionItem[] = [] + const seenSkillIds = new Set<string>() + const skillCandidates: SuggestionItem[] = [ + ...sources.skills.map(skillToSuggestion), + ...sources.builtInExperts.map((e) => expertToSuggestion(e, sources.locale)), + ...sources.agentExperts.map((e) => expertToSuggestion(e, sources.locale)), + ] + for (const item of skillCandidates) { + if (seenSkillIds.has(item.reference.id)) continue + seenSkillIds.add(item.reference.id) + if (!suggestionMatches(item, q)) continue + skillItems.push(item) + if (skillItems.length >= MAX_PER_GROUP) break + } + + return [ + { kind: "file", label: labels.file, items: fileItems }, + { kind: "agent", label: labels.agent, items: agentItems }, + { kind: "session", label: labels.session, items: sessionItems }, + { kind: "commit", label: labels.commit, items: commitItems }, + { kind: "skill", label: labels.skill, items: skillItems }, + ] +} + +export interface UseReferenceSearchOptions { + /** + * Workspace root for the file + commit groups (and the commit `repoKey`). + * When empty/null those two groups stay empty while agents/sessions/skills + * still resolve, so a brand-new draft tab degrades gracefully (R8). + */ + defaultPath?: string | null + /** Active agent type, scoping the skill + expert lists. */ + agentType?: AgentType | null + /** + * Gates loading. When false the search resolves to empty groups and the file + * tree is never fetched — let the host pre-warm only the active composer. + */ + enabled?: boolean + /** Localized group headings; English fallbacks when omitted. */ + labels?: ReferenceGroupLabels +} + +/** + * Compose the live data sources (file tree, ACP agents, conversations, git log, + * skills, experts) into a single {@link ReferenceSearch} for the composer's `@` + * panel. + * + * Referential stability is the contract: the suggestion popup re-runs its fetch + * whenever the `search` identity changes (`suggestion-popup.tsx`), so the + * returned function is an empty-dependency `useCallback` that reads every source + * from a ref. A background refresh of any source (e.g. the agent list reloading + * on window focus) updates the refs but leaves `search` identity untouched — the + * open panel keeps its results and the user's selection (R7). + * + * Files/agents/skills/experts are hook-loaded (and pre-warmed via `enabled`). + * Sessions and the git log are fetched lazily on the first `@`, key-cached in a + * ref, and awaited by `search` so the first open is populated without an extra + * keystroke; window focus busts those caches so they stay fresh. + */ +export function useReferenceSearch({ + defaultPath, + agentType = null, + enabled = true, + labels, +}: UseReferenceSearchOptions): ReferenceSearch { + const path = defaultPath || null + const locale = useLocale() + + const { allFiles, loaded } = useFileTree({ + folderPath: path ?? undefined, + enabled, + }) + const { agents } = useAcpAgents() + const skills = useAgentSkills(agentType, path) + const builtInExperts = useBuiltInExperts() + const agentExperts = useAgentExperts(agentType) + + // Mirror every changing source into a ref so `search` can stay identity-stable + // (see the doc comment). Initialized from the first render so the refs are + // sane even before the sync effect below runs. + const filesRef = useRef<{ root: string | null; files: FlatFileEntry[] }>({ + root: null, + files: [], + }) + const agentsRef = useRef(agents) + const skillsRef = useRef(skills) + const builtInExpertsRef = useRef(builtInExperts) + const agentExpertsRef = useRef(agentExperts) + const localeRef = useRef(locale) + const pathRef = useRef(path) + const enabledRef = useRef(enabled) + const labelsRef = useRef(labels) + + // `pathRef` and `enabledRef` gate the post-await freshness check in `search`, + // so they must reflect the *committed* folder/enabled state synchronously at + // commit — a passive effect can lag behind a stale in-flight fetch that + // resolves in the post-commit / pre-effect window, leaking the old folder's + // commits into the new panel. A layout effect (not a render-phase write) keeps + // them commit-accurate without updating from an uncommitted transition render. + useIsomorphicLayoutEffect(() => { + pathRef.current = path + enabledRef.current = enabled + }, [path, enabled]) + + useEffect(() => { + // Only expose files once the tree has loaded for the *current* path, so the + // search never joins the current workspace root onto a previous folder's + // relative paths during a folder switch. + filesRef.current = + loaded && path + ? { root: path, files: allFiles } + : { root: null, files: [] } + agentsRef.current = agents + skillsRef.current = skills + builtInExpertsRef.current = builtInExperts + agentExpertsRef.current = agentExperts + localeRef.current = locale + labelsRef.current = labels + }, [ + allFiles, + loaded, + path, + agents, + skills, + builtInExperts, + agentExperts, + locale, + labels, + ]) + + // Lazily-fetched network sources, key-cached so repeat searches reuse the + // in-flight/resolved promise while a folder switch refetches. + const sessionsRef = useRef<{ + key: string + promise: Promise<DbConversationSummary[]> + } | null>(null) + const commitsRef = useRef<{ + key: string + promise: Promise<GitLogEntry[]> + } | null>(null) + + // Bust the lazy caches when the window regains focus so a session created in + // another window (or new commits) show up on the next `@` — matching the + // focus-refresh idiom of the other data hooks, without per-keystroke fetches. + useEffect(() => { + const onFocus = () => { + sessionsRef.current = null + commitsRef.current = null + } + window.addEventListener("focus", onFocus) + return () => window.removeEventListener("focus", onFocus) + }, []) + + return useCallback<ReferenceSearch>(async (query, signal) => { + if (!enabledRef.current) return [] + + const path = pathRef.current + + // Lazy session fetch. On rejection the cache entry is cleared (not cached as + // an empty result) so the next `@` retries instead of wedging on `[]`. + const sessionsKey = "all" + let sessionsEntry = sessionsRef.current + if (sessionsEntry?.key !== sessionsKey) { + const created: NonNullable<typeof sessionsRef.current> = { + key: sessionsKey, + promise: listAllConversations().catch(() => { + if (sessionsRef.current === created) sessionsRef.current = null + return [] as DbConversationSummary[] + }), + } + sessionsRef.current = created + sessionsEntry = created + } + + // Lazy git-log fetch, keyed by path with the same retry-on-rejection policy. + let commitsPromise = EMPTY_COMMITS + if (path) { + let commitsEntry = commitsRef.current + if (commitsEntry?.key !== path) { + const created: NonNullable<typeof commitsRef.current> = { + key: path, + promise: gitLog(path, GIT_LOG_LIMIT) + .then((result) => result.entries) + .catch(() => { + if (commitsRef.current === created) commitsRef.current = null + return [] as GitLogEntry[] + }), + } + commitsRef.current = created + commitsEntry = created + } + commitsPromise = commitsEntry.promise + } else { + commitsRef.current = null + } + + const [sessions, commits] = await Promise.all([ + sessionsEntry.promise, + commitsPromise, + ]) + // Discard this result if it can no longer be trusted for the live panel: a + // newer query aborted us, the composer was disabled, or the workspace folder + // changed while the network fetch was in flight (the popup only aborts on a + // query change, so a folder switch would otherwise leak the old repo's + // commits — built against `path` — into the new folder's panel). The next + // keystroke re-runs the search against the current folder. + if (signal?.aborted || !enabledRef.current || pathRef.current !== path) { + return [] + } + + const fileState = filesRef.current + return buildReferenceGroups( + query, + { + files: fileState.files, + workspaceRoot: fileState.root, + agents: agentsRef.current, + sessions, + commits, + repoKey: path, + skills: skillsRef.current, + builtInExperts: builtInExpertsRef.current, + agentExperts: agentExpertsRef.current, + locale: localeRef.current, + }, + labelsRef.current ?? DEFAULT_GROUP_LABELS + ) + }, []) +} From a16f9e4e61a28656ca67958019e154ad24296bb3 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Thu, 11 Jun 2026 15:02:49 +0800 Subject: [PATCH 07/31] feat(composer): replace the message-input textarea with the Tiptap RichComposer (Phase 3d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the chat composer's plain <textarea> for the rich-text RichComposer: inline reference badges (file/agent/session/commit/skill), live Markdown, and the unified @ mention panel — wiring in the Phase 3a–3c building blocks. - Send: buildDraft now serializes the editor via docToPromptBlocks (file mentions → first-class resource_link; agent/session/commit/skill → inline text), then appends the existing attachment blocks. No agent/display regression; displayText = getMarkdown().trim(). - Drafts: v2 JSON persistence, debounced 300ms; hydrate on RichComposer.onReady (v2 doc via setDoc, legacy v1 via setMarkdown); queue-edit payloads via a guarded re-hydration effect. - `/` (commands) + `$` (Codex skills) menu is now editor-driven: caret-based detection (skips code), inline filtering, token replacement via the editor, and key routing through RichComposer.onExternalMenuKeyDown (ProseMirror's DOM handler fires before a host capture handler could). The `@` textarea path (FileMentionMenu / useFileTree / handleAtSelect / handleTextChange) is removed. - Expert prefix, quick messages, append-text event, focus, and paste-as- attachment all migrated to editor commands. The editor stays editable while `disabled` (agent busy) so enqueue/queue-edit still work — the send is gated in handleSend, matching the old textarea. New RichComposer primitives: setDoc(JSONContent), onReady, onExternalMenuKeyDown. New composer-commands.ts (isComposerEmpty — whitespace-only is unsendable but a badge-only doc is sendable; applyExpertPrefix — keeps the prefix ahead of a block marker). Tests: composer-commands (11), RichComposer setDoc (2), a MessageInput mount smoke test. Codex-reviewed (APPROVED). NOTE: CJK IME and live multi-agent send (plan risks R1–R8) remain manual real-device QA — jsdom can't simulate IME. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../chat/composer/composer-commands.test.ts | 102 ++ .../chat/composer/composer-commands.ts | 78 ++ .../chat/composer/rich-composer.test.tsx | 34 + .../chat/composer/rich-composer.tsx | 46 +- src/components/chat/message-input.test.tsx | 82 ++ src/components/chat/message-input.tsx | 949 +++++++----------- 6 files changed, 720 insertions(+), 571 deletions(-) create mode 100644 src/components/chat/composer/composer-commands.test.ts create mode 100644 src/components/chat/composer/composer-commands.ts create mode 100644 src/components/chat/message-input.test.tsx diff --git a/src/components/chat/composer/composer-commands.test.ts b/src/components/chat/composer/composer-commands.test.ts new file mode 100644 index 000000000..e2fc5a3bf --- /dev/null +++ b/src/components/chat/composer/composer-commands.test.ts @@ -0,0 +1,102 @@ +import { Editor } from "@tiptap/core" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { applyExpertPrefix, isComposerEmpty } from "./composer-commands" +import { buildComposerExtensions } from "./editor-config" + +describe("isComposerEmpty", () => { + let editor: Editor + + beforeEach(() => { + editor = new Editor({ extensions: buildComposerExtensions() }) + }) + afterEach(() => editor?.destroy()) + + it("is true for an empty document", () => { + expect(isComposerEmpty(editor)).toBe(true) + }) + + it("is false once there is real text", () => { + editor.commands.setContent("hello", { contentType: "markdown" }) + expect(isComposerEmpty(editor)).toBe(false) + }) + + it("is true for a whitespace-only document (regression: send stays disabled)", () => { + editor.commands.insertContent(" ") + expect(editor.isEmpty).toBe(false) // ProseMirror itself reports non-empty… + expect(isComposerEmpty(editor)).toBe(true) // …but there's nothing to send. + }) + + it("is false for a document holding only a reference badge", () => { + editor.commands.insertReference({ + refType: "file", + id: "a.ts", + label: "a.ts", + uri: "file:///a.ts", + meta: null, + }) + expect(editor.isEmpty).toBe(false) + expect(isComposerEmpty(editor)).toBe(false) + }) +}) + +describe("applyExpertPrefix", () => { + let editor: Editor + + beforeEach(() => { + editor = new Editor({ extensions: buildComposerExtensions() }) + }) + afterEach(() => editor?.destroy()) + + it("prepends the prefix to an empty document", () => { + applyExpertPrefix(editor, "/", "reviewer", new Set()) + expect(editor.getMarkdown().trimStart()).toMatch(/^\/reviewer\b/) + }) + + it("prepends the prefix in front of existing prose", () => { + editor.commands.setContent("look at this", { contentType: "markdown" }) + applyExpertPrefix(editor, "/", "reviewer", new Set()) + expect(editor.getMarkdown().trimStart()).toMatch(/^\/reviewer look at this/) + }) + + it("replaces an existing known expert prefix instead of stacking", () => { + editor.commands.setContent("/old keep this", { contentType: "markdown" }) + applyExpertPrefix(editor, "/", "reviewer", new Set(["old"])) + const md = editor.getMarkdown() + expect(md.trimStart()).toMatch(/^\/reviewer keep this/) + expect(md).not.toContain("old") + }) + + it("does NOT replace a leading token that isn't a known expert", () => { + editor.commands.setContent("/unknown keep", { contentType: "markdown" }) + applyExpertPrefix(editor, "/", "reviewer", new Set(["old"])) + const md = editor.getMarkdown() + expect(md.trimStart()).toMatch(/^\/reviewer /) + expect(md).toContain("/unknown") + }) + + it("keeps the prefix ahead of a heading's Markdown marker (regression)", () => { + // First block is a heading: inserting inline at pos 1 would serialize as + // `# /reviewer Title` (marker first). The prefix must lead the message. + editor.commands.setContent("# Title", { contentType: "markdown" }) + applyExpertPrefix(editor, "/", "reviewer", new Set()) + const md = editor.getMarkdown() + expect(md.trimStart()).toMatch(/^\/reviewer/) + expect(md).toContain("# Title") + expect(md.indexOf("/reviewer")).toBeLessThan(md.indexOf("# Title")) + }) + + it("keeps the prefix ahead of a list's Markdown marker", () => { + editor.commands.setContent("- one\n- two", { contentType: "markdown" }) + applyExpertPrefix(editor, "/", "reviewer", new Set()) + const md = editor.getMarkdown() + expect(md.trimStart()).toMatch(/^\/reviewer/) + expect(md.indexOf("/reviewer")).toBeLessThan(md.indexOf("one")) + }) + + it("supports the Codex `$` prefix", () => { + editor.commands.setContent("ship it", { contentType: "markdown" }) + applyExpertPrefix(editor, "$", "deploy", new Set()) + expect(editor.getMarkdown().trimStart()).toMatch(/^\$deploy ship it/) + }) +}) diff --git a/src/components/chat/composer/composer-commands.ts b/src/components/chat/composer/composer-commands.ts new file mode 100644 index 000000000..97bb36058 --- /dev/null +++ b/src/components/chat/composer/composer-commands.ts @@ -0,0 +1,78 @@ +import type { Editor } from "@tiptap/core" + +/** + * Whether the composer has nothing sendable. Stricter than `editor.isEmpty`, + * which is false for a whitespace-only document (the legacy textarea gated the + * send button on `text.trim()`), but still treats a document holding only an + * inline reference badge (e.g. an `@file` mention with no prose) as sendable. + */ +export function isComposerEmpty(editor: Editor): boolean { + if (editor.isEmpty) return true + if (editor.getText().trim().length > 0) return false + let hasReference = false + editor.state.doc.descendants((node) => { + if (hasReference) return false + if (node.type.name === "reference") { + hasReference = true + return false + } + return true + }) + return !hasReference +} + +/** + * Inject `prefix + expertId + " "` as the leading token of the message — experts + * are whole-turn directives the agent inspects first, so they go at the very + * front, never at the caret. + * + * The prefix must be the FIRST token of the *serialized* Markdown. Inserting + * inline at position 1 only achieves that when the first block is a paragraph; + * for a heading/list/quote/code block the Markdown marker (`# `, `- `, `> `, …) + * would serialize before the prefix, so a fresh paragraph is prepended instead. + * When the first block is a paragraph already carrying an expert prefix (from a + * prior click), it is replaced rather than stacked — the agent only honors the + * first directive. + */ +export function applyExpertPrefix( + editor: Editor, + prefix: string, + expertId: string, + knownExpertIds: ReadonlySet<string> +): void { + const insertion = `${prefix}${expertId} ` + const first = editor.state.doc.firstChild + + if (first && first.type.name !== "paragraph") { + editor + .chain() + .focus() + .insertContentAt(0, { + type: "paragraph", + content: [{ type: "text", text: insertion }], + }) + .setTextSelection(insertion.length + 1) + .run() + return + } + + const leading = first + ? first.textBetween(0, Math.min(first.content.size, 80), undefined, " ") + : "" + const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + const existing = leading.match( + new RegExp(`^${escapedPrefix}([A-Za-z0-9_-]+)\\s`) + ) + const replaceLen = + existing && knownExpertIds.has(existing[1]) ? existing[0].length : 0 + + // Position 1 is just inside the first block (after its opening boundary). + let chain = editor.chain().focus() + if (replaceLen > 0) { + chain = chain.deleteRange({ from: 1, to: 1 + replaceLen }) + } + chain + .insertContentAt(1, insertion) + .setTextSelection(1 + insertion.length) + .run() +} diff --git a/src/components/chat/composer/rich-composer.test.tsx b/src/components/chat/composer/rich-composer.test.tsx index 6b6354b02..dfc619445 100644 --- a/src/components/chat/composer/rich-composer.test.tsx +++ b/src/components/chat/composer/rich-composer.test.tsx @@ -113,6 +113,40 @@ describe("RichComposer imperative inserts (Phase 3)", () => { '"type":"reference"' ) }) + + it("hydrates the document from a Tiptap JSON doc via setDoc", async () => { + const { ref } = await mount() + act(() => + ref.current?.setDoc({ + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "from json" }] }, + ], + }) + ) + expect(ref.current?.getMarkdown()).toContain("from json") + expect(ref.current?.isEmpty()).toBe(false) + }) + + it("preserves a reference badge through a getJSON → setDoc round-trip", async () => { + const { ref } = await mount() + act(() => + ref.current?.insertReference({ + refType: "file", + id: "a.ts", + label: "a.ts", + uri: "file:///a.ts", + meta: null, + }) + ) + const doc = ref.current!.getJSON() + act(() => ref.current?.clear()) + expect(ref.current?.isEmpty()).toBe(true) + act(() => ref.current?.setDoc(doc)) + expect(JSON.stringify(ref.current?.getJSON())).toContain( + '"type":"reference"' + ) + }) }) describe("RichComposer configurable submit / newline (Phase 3)", () => { diff --git a/src/components/chat/composer/rich-composer.tsx b/src/components/chat/composer/rich-composer.tsx index 7eaab213f..6e21967d1 100644 --- a/src/components/chat/composer/rich-composer.tsx +++ b/src/components/chat/composer/rich-composer.tsx @@ -36,6 +36,12 @@ export interface RichComposerHandle { getMarkdown: () => string /** Replace the whole document from a Markdown string. */ setMarkdown: (markdown: string) => void + /** + * Replace the whole document from a Tiptap JSON doc — used to hydrate a v2 + * draft or a queue-edit payload, preserving reference badges that a Markdown + * round-trip would downgrade to plain links. + */ + setDoc: (doc: JSONContent) => void /** Clear the document. */ clear: () => void /** Focus the editor at the end of the document. */ @@ -79,6 +85,12 @@ export interface RichComposerProps { onSubmit?: () => void onFocus?: () => void onBlur?: () => void + /** + * Fired once the (async, `immediatelyRender:false`) editor has mounted and any + * `defaultMarkdown` has been applied. The host uses this to hydrate a draft / + * queue-edit document via the imperative handle, which isn't usable earlier. + */ + onReady?: () => void /** * Enables the unified `@` mention panel. Resolves the typed query into * grouped suggestions (files/agents/sessions/commits/skills). MUST be @@ -99,6 +111,14 @@ export interface RichComposerProps { * while it is open. The internal `@` panel does not need this flag. */ isExternalMenuOpen?: boolean + /** + * Called for every keydown while `isExternalMenuOpen` is true, BEFORE the + * editor acts. ProseMirror's DOM handler fires before a host capture handler + * could, so menu navigation has to be routed here. Return true for keys the + * menu consumed (Arrow/Enter/Tab/Escape) so the editor does nothing; return + * false (e.g. a letter that filters the list) to let normal editing proceed. + */ + onExternalMenuKeyDown?: (event: KeyboardEvent) => boolean /** * Called on paste before the editor handles it. Return true when the paste was * consumed out-of-band (e.g. an image/file became an attachment) so the editor @@ -127,10 +147,12 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>( onSubmit, onFocus, onBlur, + onReady, referenceSearch, submitShortcut, newlineShortcut, isExternalMenuOpen, + onExternalMenuKeyDown, onPasteFiles, }, ref @@ -141,6 +163,7 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>( const onSubmitRef = useRef(onSubmit) const onFocusRef = useRef(onFocus) const onBlurRef = useRef(onBlur) + const onReadyRef = useRef(onReady) // Latest referenceSearch, read at event time so the mention plugin (always // installed) is gated on whether mentions are currently enabled — robust to // the prop being added/removed after the editor is created once. @@ -148,6 +171,7 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>( const submitShortcutRef = useRef(submitShortcut) const newlineShortcutRef = useRef(newlineShortcut) const isExternalMenuOpenRef = useRef(isExternalMenuOpen) + const onExternalMenuKeyDownRef = useRef(onExternalMenuKeyDown) const onPasteFilesRef = useRef(onPasteFiles) // The live editor, captured for command access inside editorProps handlers // (which are created before `editor` is assigned in this closure). @@ -157,10 +181,12 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>( onSubmitRef.current = onSubmit onFocusRef.current = onFocus onBlurRef.current = onBlur + onReadyRef.current = onReady referenceSearchRef.current = referenceSearch submitShortcutRef.current = submitShortcut newlineShortcutRef.current = newlineShortcut isExternalMenuOpenRef.current = isExternalMenuOpen + onExternalMenuKeyDownRef.current = onExternalMenuKeyDown onPasteFilesRef.current = onPasteFiles }) @@ -218,11 +244,17 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>( ...(ariaLabel ? { "aria-label": ariaLabel } : {}), }, handleKeyDown: (view, event) => { - // A panel/menu owns its navigation/confirm keys while open: the `@` - // panel (internal, ref-tracked) and any external parent-driven menu - // (e.g. `/` runtime commands). Never submit or break while one is open. - if (mentionOpenRef.current || isExternalMenuOpenRef.current) { - return false + // The internal `@` panel's suggestion plugin owns its navigation keys; + // never submit/break while it is open. + if (mentionOpenRef.current) return false + // An external (host) menu — e.g. the `/` runtime command list — gets + // first refusal on keys while open. ProseMirror's DOM handler runs + // before any host capture handler could, so routing happens here: the + // host returns true for keys it consumed (Arrow/Enter/Tab/Escape) so + // the editor does nothing, or false (e.g. a letter that filters the + // list) to let the inline token keep growing. + if (isExternalMenuOpenRef.current) { + return onExternalMenuKeyDownRef.current?.(event) ?? false } // Bindings are free-form (Enter, Shift+Enter, Mod+Enter, Tab, …), so // we can't pre-filter by key. Instead, run a cheap first pass with no @@ -296,6 +328,9 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>( emitUpdate: false, }) } + // The imperative handle is now usable; let the host hydrate a draft / + // queue-edit document that a Markdown `defaultMarkdown` can't represent. + onReadyRef.current?.() }, onDestroy: () => { editorInstanceRef.current = null @@ -319,6 +354,7 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>( getMarkdown: () => editor?.getMarkdown() ?? "", setMarkdown: (markdown) => editor?.commands.setContent(markdown, { contentType: "markdown" }), + setDoc: (doc) => editor?.commands.setContent(doc), clear: () => editor?.commands.clearContent(true), focus: () => editor?.commands.focus("end"), isEmpty: () => editor?.isEmpty ?? true, diff --git a/src/components/chat/message-input.test.tsx b/src/components/chat/message-input.test.tsx new file mode 100644 index 000000000..a8a87a196 --- /dev/null +++ b/src/components/chat/message-input.test.tsx @@ -0,0 +1,82 @@ +import { render, waitFor, cleanup } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { afterEach, describe, expect, it, vi } from "vitest" + +// Mock the data hooks / platform so MessageInput mounts without hitting the +// backend. The reference-search provider and slash sources are all empty: this +// is a wiring smoke test (does the RichComposer-based input mount and reflect +// empty/send state), not a data test. +vi.mock("@/hooks/use-shortcut-settings", () => ({ + useShortcutSettings: () => ({ + shortcuts: { send_message: "enter", newline_in_message: "shift+enter" }, + }), +})) +vi.mock("@/hooks/use-built-in-experts", () => ({ useBuiltInExperts: () => [] })) +vi.mock("@/hooks/use-agent-experts", () => ({ useAgentExperts: () => [] })) +vi.mock("@/hooks/use-agent-skills", () => ({ useAgentSkills: () => [] })) +vi.mock("@/components/chat/composer/use-reference-search", () => ({ + useReferenceSearch: () => async () => [], +})) +vi.mock("@/components/chat/conversation-context-bar", () => ({ + ConversationContextBar: ({ + extraContent, + }: { + extraContent?: React.ReactNode + }) => <div data-testid="ctx-bar">{extraContent}</div>, + ConversationFolderBranchPicker: () => null, + useConversationFolderBranchPickerVisible: () => false, +})) +vi.mock("@/lib/platform", () => ({ + isDesktop: () => false, + openFileDialog: vi.fn(), +})) +vi.mock("@/lib/transport", () => ({ + getActiveRemoteConnectionId: () => null, +})) + +import enMessages from "@/i18n/messages/en.json" +import type { PromptCapabilitiesInfo } from "@/lib/types" + +import { MessageInput } from "./message-input" + +const CAPS: PromptCapabilitiesInfo = { + image: true, + audio: false, + embedded_context: true, +} + +function renderInput( + props: Partial<React.ComponentProps<typeof MessageInput>> +) { + return render( + <NextIntlClientProvider locale="en" messages={enMessages}> + <MessageInput onSend={vi.fn()} promptCapabilities={CAPS} {...props} /> + </NextIntlClientProvider> + ) +} + +describe("MessageInput (RichComposer integration)", () => { + afterEach(() => cleanup()) + + it("mounts and renders the rich-text composer surface", async () => { + const { container } = renderInput({}) + await waitFor( + () => expect(container.querySelector('[role="textbox"]')).not.toBeNull(), + { timeout: 5000 } + ) + const textbox = container.querySelector('[role="textbox"]') + expect(textbox).toHaveAttribute("aria-multiline", "true") + }) + + it("disables Send while the composer is empty and has no attachments", async () => { + const { container } = renderInput({}) + await waitFor(() => + expect(container.querySelector('[role="textbox"]')).not.toBeNull() + ) + const sendButton = container.querySelector<HTMLButtonElement>( + `button[title="${enMessages.Folder.chat.messageInput.send}"]` + ) + expect(sendButton).not.toBeNull() + expect(sendButton).toBeDisabled() + }) +}) diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index e7fd2fa6c..7100fbca0 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -5,7 +5,6 @@ import { isDesktop } from "@/lib/platform" import Image from "next/image" import { useLocale, useTranslations } from "next-intl" import { Button } from "@/components/ui/button" -import { Textarea } from "@/components/ui/textarea" import { BookOpenText, Check, @@ -45,7 +44,6 @@ import { clipboardHasText, imageFilesFromClipboardApi, } from "@/lib/clipboard-images" -import { matchShortcutEvent } from "@/lib/keyboard-shortcuts" import { useShortcutSettings } from "@/hooks/use-shortcut-settings" import { readFileBase64, @@ -99,18 +97,25 @@ import { getExpertIcon, pickExpertLocalized, } from "@/components/chat/experts-command-menu" -import { FileMentionMenu } from "@/components/chat/file-mention-menu" import { DropdownRadioItemContent } from "@/components/chat/dropdown-radio-item-content" -import { useFileTree } from "@/hooks/use-file-tree" import { useBuiltInExperts } from "@/hooks/use-built-in-experts" import { useAgentExperts } from "@/hooks/use-agent-experts" import { useAgentSkills } from "@/hooks/use-agent-skills" -import { joinFsPath } from "@/lib/path-utils" import { - clearMessageInputDraft, - loadMessageInputDraft, - saveMessageInputDraft, + clearMessageInputDraftV2, + loadMessageInputDraftV2, + saveMessageInputDraftV2, } from "@/lib/message-input-draft" +import { + RichComposer, + type RichComposerHandle, +} from "@/components/chat/composer/rich-composer" +import { docToPromptBlocks } from "@/components/chat/composer/to-prompt-blocks" +import { + applyExpertPrefix, + isComposerEmpty, +} from "@/components/chat/composer/composer-commands" +import { useReferenceSearch } from "@/components/chat/composer/use-reference-search" import type { ImageInputAttachment, InputAttachment, @@ -461,10 +466,13 @@ export function MessageInput({ const { shortcuts } = useShortcutSettings() const effectiveDraftStorageKey = draftStorageKey ?? null const resolvedPlaceholder = placeholder ?? t("askAnything") - const [text, setText] = useState(() => { - if (!effectiveDraftStorageKey) return "" - return loadMessageInputDraft(effectiveDraftStorageKey) ?? "" - }) + const editorRef = useRef<RichComposerHandle>(null) + // The editor owns the content now; this mirror of its empty state drives the + // send button and `hasSendableContent`. + const [composerEmpty, setComposerEmpty] = useState(true) + // Flips true once the RichComposer's async (immediatelyRender:false) editor has + // mounted, so the hydration effect can use the imperative handle. + const [composerReady, setComposerReady] = useState(false) const [attachments, setAttachments] = useState<InputAttachment[]>([]) const [isDragActive, setIsDragActive] = useState(false) const [quickMessages, setQuickMessages] = useState<QuickMessage[]>([]) @@ -473,45 +481,26 @@ export function MessageInput({ null ) const containerRef = useRef<HTMLDivElement>(null) - const textareaRef = useRef<HTMLTextAreaElement>(null) const lastDomDropAtRef = useRef(0) - const composingRef = useRef(false) - const cursorPosRef = useRef<number | null>(null) - const textRef = useRef(text) const disabledRef = useRef(disabled) const isPromptingRef = useRef(isPrompting) + const hydratedRef = useRef(false) + // Tracks the last queue-edit payload hydrated, so a re-edit of the *same* item + // doesn't clobber the user's in-progress changes. + const prevEditingDraftRef = useRef<string | null>(null) + const dragActiveRef = useRef(false) + // Bridge so the early `onChange` handler can call the editor-driven slash + // detection that is defined further down (after the slash state). + const detectSlashTriggerRef = useRef<(() => void) | null>(null) + const canAttachImages = promptCapabilities.image useEffect(() => { if (isActive && !disabled && !isPrompting) { requestAnimationFrame(() => { - textareaRef.current?.focus() + editorRef.current?.focus() }) } }, [isActive, disabled, isPrompting]) - const dragActiveRef = useRef(false) - const canAttachImages = promptCapabilities.image - - useEffect(() => { - textRef.current = text - }, [text]) - - // `field-sizing-content` 触发的尺寸调整发生在浏览器布局阶段,原生 caret- - // into-view 滚动赶不上,导致光标停在末尾时新行被裁在可视区外。用 rAF 等 - // 到本帧所有同步 `setSelectionRange` 调用之后再判断光标位置——程序化插入 - // 路径(换行快捷键、快捷消息、斜杠命令等)都先 `setText` 再 rAF 设光标, - // 这里同样走 rAF 才能保证光标已经落到末尾。 - useEffect(() => { - const ta = textareaRef.current - if (!ta) return - const id = requestAnimationFrame(() => { - const el = textareaRef.current - if (!el) return - if ((el.selectionStart ?? 0) >= el.value.length) { - el.scrollTop = el.scrollHeight - } - }) - return () => cancelAnimationFrame(id) - }, [text]) useEffect(() => { disabledRef.current = disabled @@ -521,8 +510,74 @@ export function MessageInput({ isPromptingRef.current = isPrompting }, [isPrompting]) - // Load external draft text when editing a queue item - const prevEditingDraftRef = useRef<string | null>(null) + // Live data sources for the unified `@` mention panel. Pre-warmed only while + // this composer is the active one (`enabled`). Referentially stable. + const referenceSearch = useReferenceSearch({ + defaultPath: defaultPath ?? null, + agentType: agentType ?? null, + enabled: isActive, + }) + + // Debounced v2 draft persistence. We snapshot the Tiptap *document* (JSON, not + // Markdown) ~300ms after the last change so inline reference badges survive a + // reload — a Markdown round-trip would downgrade them to plain links. + const draftSaveTimerRef = useRef<number | null>(null) + const scheduleDraftSave = useCallback(() => { + if (typeof window === "undefined") return + if (!effectiveDraftStorageKey || isEditingQueueItem) return + if (draftSaveTimerRef.current != null) { + window.clearTimeout(draftSaveTimerRef.current) + } + draftSaveTimerRef.current = window.setTimeout(() => { + draftSaveTimerRef.current = null + const ed = editorRef.current + if (!ed || !effectiveDraftStorageKey) return + if (ed.isEmpty()) { + clearMessageInputDraftV2(effectiveDraftStorageKey) + } else { + saveMessageInputDraftV2(effectiveDraftStorageKey, ed.getJSON()) + } + }, 300) + }, [effectiveDraftStorageKey, isEditingQueueItem]) + + useEffect(() => { + return () => { + if (draftSaveTimerRef.current != null && typeof window !== "undefined") { + window.clearTimeout(draftSaveTimerRef.current) + } + } + }, []) + + // One-time hydration once the editor is ready: a queue-edit payload, else a v2 + // draft document (or a legacy v1 Markdown draft migrated forward). Guarded so + // it never re-runs and clobbers later user edits. + useEffect(() => { + if (!composerReady || hydratedRef.current) return + hydratedRef.current = true + const ed = editorRef.current + if (!ed) return + if (isEditingQueueItem && editingDraftText != null) { + ed.setMarkdown(editingDraftText) + prevEditingDraftRef.current = editingDraftText + } else if (effectiveDraftStorageKey) { + const loaded = loadMessageInputDraftV2(effectiveDraftStorageKey) + if (loaded?.kind === "doc") { + ed.setDoc(loaded.doc) + } else if (loaded?.kind === "legacyMarkdown") { + ed.setMarkdown(loaded.markdown) + } + } + const editor = ed.getEditor() + setComposerEmpty(editor ? isComposerEmpty(editor) : true) + }, [ + composerReady, + isEditingQueueItem, + editingDraftText, + effectiveDraftStorageKey, + ]) + + // Re-hydrate when the user (re)edits a *different* queue item after the + // initial mount hydration above. useEffect(() => { if ( isEditingQueueItem && @@ -530,9 +585,11 @@ export function MessageInput({ editingDraftText !== prevEditingDraftRef.current ) { prevEditingDraftRef.current = editingDraftText - setText(editingDraftText) + editorRef.current?.setMarkdown(editingDraftText) + const editor = editorRef.current?.getEditor() + setComposerEmpty(editor ? isComposerEmpty(editor) : true) requestAnimationFrame(() => { - textareaRef.current?.focus() + editorRef.current?.focus() }) } else if (!isEditingQueueItem) { prevEditingDraftRef.current = null @@ -545,10 +602,20 @@ export function MessageInput({ setIsDragActive(next) }, []) - useEffect(() => { - if (!effectiveDraftStorageKey || isEditingQueueItem) return - saveMessageInputDraft(effectiveDraftStorageKey, text) - }, [effectiveDraftStorageKey, text, isEditingQueueItem]) + const syncComposerEmpty = useCallback(() => { + const ed = editorRef.current?.getEditor() + setComposerEmpty(ed ? isComposerEmpty(ed) : true) + }, []) + + const handleComposerChange = useCallback(() => { + syncComposerEmpty() + scheduleDraftSave() + detectSlashTriggerRef.current?.() + }, [syncComposerEmpty, scheduleDraftSave]) + + const handleComposerReady = useCallback(() => { + setComposerReady(true) + }, []) const availableModes = useMemo(() => modes ?? [], [modes]) const availableConfigOptions = useMemo( @@ -602,7 +669,7 @@ export function MessageInput({ [attachments] ) const hasAttachments = attachments.length > 0 - const hasSendableContent = text.trim().length > 0 || hasAttachments + const hasSendableContent = !composerEmpty || hasAttachments // ── Slash command autocomplete ── // @@ -614,15 +681,13 @@ export function MessageInput({ // command set is very small. const [slashMenuOpen, setSlashMenuOpen] = useState(false) const [slashSelectedIndex, setSlashSelectedIndex] = useState(0) - // Byte offset of the `/` or `$` character that opened the menu. Tracking the - // position lets the user invoke a slash command mid-text (e.g. after typing - // prose) and only replace the slash token on selection, leaving surrounding - // content intact. - const [slashTriggerPos, setSlashTriggerPos] = useState<number | null>(null) - const slashTriggerPosRef = useRef<number | null>(null) - useEffect(() => { - slashTriggerPosRef.current = slashTriggerPos - }, [slashTriggerPos]) + // The trigger char (`/` for agent commands, `$` for Codex skills) and the + // typed filter token, both derived from the editor caret by + // `detectSlashTrigger` rather than from a raw string offset. + const [slashTriggerChar, setSlashTriggerChar] = useState<"/" | "$" | null>( + null + ) + const [slashFilter, setSlashFilter] = useState("") const slashCommands = useMemo( () => (availableCommands ?? []).filter((cmd) => !expertIdSet.has(cmd.name)), [availableCommands, expertIdSet] @@ -659,34 +724,20 @@ export function MessageInput({ }) return () => cancelAnimationFrame(id) }, [slashDropdownOpen]) - const slashFilterText = useMemo(() => { - if (!slashMenuOpen || slashTriggerPos == null) return "" - const trigger = text[slashTriggerPos] - if (trigger !== "/" && trigger !== "$") return "" - const afterTrigger = text.slice(slashTriggerPos + 1) - const endIdx = afterTrigger.search(/\s/) - return endIdx === -1 ? afterTrigger : afterTrigger.slice(0, endIdx) - }, [slashMenuOpen, text, slashTriggerPos]) const filteredSlashCommands = useMemo(() => { - if (!slashMenuOpen || slashCommands.length === 0 || slashTriggerPos == null) - return [] - if (text[slashTriggerPos] !== "/") return [] - const filter = slashFilterText.toLowerCase() + if (!slashMenuOpen || slashCommands.length === 0) return [] + if (slashTriggerChar !== "/") return [] + const filter = slashFilter.toLowerCase() return slashCommands.filter((cmd) => cmd.name.toLowerCase().includes(filter) ) - }, [slashMenuOpen, slashCommands, text, slashTriggerPos, slashFilterText]) + }, [slashMenuOpen, slashCommands, slashTriggerChar, slashFilter]) const filteredSlashSkills = useMemo(() => { // Skills autocomplete is Codex-only and triggered by `$`. if (agentType !== "codex") return [] - if ( - !slashMenuOpen || - nonExpertSkills.length === 0 || - slashTriggerPos == null - ) - return [] - if (text[slashTriggerPos] !== "$") return [] - const filter = slashFilterText.toLowerCase() + if (!slashMenuOpen || nonExpertSkills.length === 0) return [] + if (slashTriggerChar !== "$") return [] + const filter = slashFilter.toLowerCase() if (!filter) return nonExpertSkills const nameMatches: typeof nonExpertSkills = [] const idOnlyMatches: typeof nonExpertSkills = [] @@ -698,14 +749,7 @@ export function MessageInput({ } } return [...nameMatches, ...idOnlyMatches] - }, [ - slashMenuOpen, - nonExpertSkills, - text, - agentType, - slashTriggerPos, - slashFilterText, - ]) + }, [slashMenuOpen, nonExpertSkills, agentType, slashTriggerChar, slashFilter]) const slashAutocompleteCount = filteredSlashCommands.length + filteredSlashSkills.length @@ -744,36 +788,50 @@ export function MessageInput({ } }, [slashMenuOpen, slashSelectedIndex, slashAutocompleteCount]) - // ── @ file mention autocomplete ── - const [atMenuOpen, setAtMenuOpen] = useState(false) - const [atSelectedIndex, setAtSelectedIndex] = useState(0) - const [atTriggerPos, setAtTriggerPos] = useState<number | null>(null) - const [atFileTreeEnabled, setAtFileTreeEnabled] = useState(false) - - const { allFiles: atAllFiles } = useFileTree({ - folderPath: defaultPath, - enabled: atFileTreeEnabled, - }) - - const filteredAtFiles = useMemo(() => { - if (!atMenuOpen || atTriggerPos == null) return [] - // Extract the query after "@" up to the next space or end of text - const afterAt = text.slice(atTriggerPos + 1) - const spaceIdx = afterAt.indexOf(" ") - const filter = - spaceIdx === -1 - ? afterAt.toLowerCase() - : afterAt.slice(0, spaceIdx).toLowerCase() - if (!filter) return atAllFiles.slice(0, 50) - const matched: typeof atAllFiles = [] - for (const f of atAllFiles) { - if (f.lowerName.includes(filter) || f.lowerPath.includes(filter)) { - matched.push(f) - if (matched.length >= 50) break - } + // ── Editor-driven `/` (commands) and `$` (Codex skills) trigger detection ── + // The `@` mention panel is now owned by RichComposer; this only handles the + // runtime-command menus. We inspect the text before the collapsed caret in the + // current block: a `/` (any agent) or `$` (Codex) at the start or right after + // whitespace, and not inside inline code / a code block, opens the menu. + const detectSlashTrigger = useCallback(() => { + const editor = editorRef.current?.getEditor() + const hasSlashSource = + slashCommands.length > 0 || + availableExperts.length > 0 || + nonExpertSkills.length > 0 + const close = () => { + setSlashMenuOpen(false) + setSlashTriggerChar(null) } - return matched - }, [atMenuOpen, atTriggerPos, text, atAllFiles]) + if (!editor || !hasSlashSource) return close() + const { selection } = editor.state + if (!selection.empty) return close() + if (editor.isActive("code") || editor.isActive("codeBlock")) return close() + const { $from } = selection + const before = $from.parent.textBetween( + 0, + $from.parentOffset, + undefined, + " " + ) + const regex = + agentType === "codex" ? /(^|\s)([/$])(\S*)$/ : /(^|\s)(\/)(\S*)$/ + const match = before.match(regex) + if (!match) return close() + setSlashTriggerChar(match[2] as "/" | "$") + setSlashFilter(match[3]) + setSlashSelectedIndex(0) + setSlashMenuOpen(true) + }, [ + slashCommands.length, + availableExperts.length, + nonExpertSkills.length, + agentType, + ]) + + useEffect(() => { + detectSlashTriggerRef.current = detectSlashTrigger + }, [detectSlashTrigger]) const appendResourceLinks = useCallback( ( @@ -1273,16 +1331,17 @@ export function MessageInput({ [appendFilesAsResources, appendImageAttachments, canAttachImages] ) - const handlePaste = useCallback( - (event: React.ClipboardEvent<HTMLTextAreaElement>) => { - if (disabled) return + // Routed from RichComposer's `onPasteFiles`. Returns true when the paste was + // consumed as an attachment (so the editor doesn't also insert it as text). + const handlePasteFiles = useCallback( + (event: ClipboardEvent): boolean => { + if (disabled) return false const files = filesFromClipboard(event.clipboardData) if (files.length > 0) { - event.preventDefault() void appendFilesFromInput(files).catch((error) => { console.error("[MessageInput] paste files failed:", error) }) - return + return true } // Linux/Tauri (WebKitGTK) fallback: screenshot tools (e.g. WeChat) write @@ -1292,9 +1351,7 @@ export function MessageInput({ // (mirroring `filesFromClipboard`) so copying a spreadsheet cell or rich // web content isn't hijacked into an image attachment. Kept synchronous // so `imageFilesFromClipboardApi` runs inside the paste user gesture. - // No `preventDefault()`: the default paste of a textless clipboard is a - // no-op anyway, and it can't be cancelled after the async boundary. - if (clipboardHasText(event.clipboardData)) return + if (clipboardHasText(event.clipboardData)) return false void imageFilesFromClipboardApi() .then((imageFiles) => { if (imageFiles.length === 0) return @@ -1303,6 +1360,8 @@ export function MessageInput({ .catch((error) => { console.error("[MessageInput] clipboard image paste failed:", error) }) + // The default paste of a textless clipboard is a no-op, so don't claim it. + return false }, [appendFilesFromInput, disabled] ) @@ -1322,285 +1381,105 @@ export function MessageInput({ [onModeChange] ) - const handleSlashSearchChange = useCallback( - (e: React.ChangeEvent<HTMLInputElement>) => { - const pos = slashTriggerPosRef.current - const current = textRef.current - if (pos == null || pos < 0 || pos >= current.length) return - const trigger = current[pos] - if (trigger !== "/" && trigger !== "$") return - const afterTrigger = current.slice(pos + 1) - const endIdx = afterTrigger.search(/\s/) - const tokenEnd = endIdx === -1 ? current.length : pos + 1 + endIdx - const before = current.slice(0, pos + 1) - const rest = current.slice(tokenEnd) - const sanitized = e.target.value.replace(/\s+/g, "") - setText(before + sanitized + rest) - setSlashSelectedIndex(0) - }, - [] - ) - - const handleSlashSelect = useCallback((cmd: AvailableCommandInfo) => { - const pos = slashTriggerPosRef.current - const current = textRef.current - const insertion = `/${cmd.name}` - if ( - pos == null || - pos < 0 || - pos >= current.length || - current[pos] !== "/" - ) { - // Fallback path: no tracked trigger (shouldn't normally happen). Behave - // like the legacy wholesale-replace so slash commands still work. - setText(`${insertion} `) - setSlashMenuOpen(false) - setSlashTriggerPos(null) - return - } - const before = current.slice(0, pos) - const afterSlash = current.slice(pos + 1) - const tokenMatch = afterSlash.match(/^\S*/) - const tokenLen = tokenMatch ? tokenMatch[0].length : 0 - const rest = afterSlash.slice(tokenLen) - const needsSpace = !/^\s/.test(rest) - const newText = before + insertion + (needsSpace ? " " : "") + rest - setText(newText) + // Close the runtime-command menu and clear the trigger. + const closeSlashMenu = useCallback(() => { setSlashMenuOpen(false) - setSlashTriggerPos(null) - requestAnimationFrame(() => { - const ta = textareaRef.current - if (ta) { - ta.focus() - const newPos = before.length + insertion.length + (needsSpace ? 1 : 0) - ta.setSelectionRange(newPos, newPos) - } - }) + setSlashTriggerChar(null) }, []) - const handleSlashPopoverSelect = useCallback((cmd: AvailableCommandInfo) => { - const pos = cursorPosRef.current ?? textRef.current.length - const before = textRef.current.slice(0, pos) - const after = textRef.current.slice(pos) - const needsSpace = pos > 0 && !/\s$/.test(before) - const insertion = `${needsSpace ? " " : ""}/${cmd.name} ` - const newText = before + insertion + after - setText(newText) - requestAnimationFrame(() => { - const ta = textareaRef.current - if (ta) { - ta.focus() - const newPos = pos + insertion.length - ta.setSelectionRange(newPos, newPos) - } - }) - }, []) - - const handleSkillAutocompleteSelect = useCallback( - (skill: AgentSkillItem) => { - // Codex uses `$<id>`, other agents use `/<id>` — matching the prefix - // that triggered the autocomplete list. - const pos = slashTriggerPosRef.current - const current = textRef.current - const triggerChar = expertPrefix.length === 1 ? expertPrefix : "$" - const insertion = `${expertPrefix}${skill.id}` - if ( - pos == null || - pos < 0 || - pos >= current.length || - current[pos] !== triggerChar - ) { - setText(`${insertion} `) - setSlashMenuOpen(false) - setSlashTriggerPos(null) + // Replace the live `/`-or-`$` token immediately before the caret with + // `insertion` (+ a trailing space unless one already follows), then close the + // menu. Used by both the command (`/`) and Codex-skill (`$`) selections. + const replaceTriggerToken = useCallback( + (insertion: string) => { + const editor = editorRef.current?.getEditor() + if (!editor) return + const { $from } = editor.state.selection + const before = $from.parent.textBetween( + 0, + $from.parentOffset, + undefined, + " " + ) + const match = before.match(/(^|\s)([/$])(\S*)$/) + const charAfter = + $from.parentOffset < $from.parent.content.size + ? $from.parent.textBetween( + $from.parentOffset, + $from.parentOffset + 1, + undefined, + " " + ) + : "" + const suffix = charAfter && /\s/.test(charAfter) ? "" : " " + if (!match) { + // No live trigger token (shouldn't normally happen) — insert at caret. + editor.chain().focus().insertContent(`${insertion}${suffix}`).run() + closeSlashMenu() return } - const before = current.slice(0, pos) - const afterTrigger = current.slice(pos + 1) - const tokenMatch = afterTrigger.match(/^\S*/) - const tokenLen = tokenMatch ? tokenMatch[0].length : 0 - const rest = afterTrigger.slice(tokenLen) - const needsSpace = !/^\s/.test(rest) - const newText = before + insertion + (needsSpace ? " " : "") + rest - setText(newText) - setSlashMenuOpen(false) - setSlashTriggerPos(null) - requestAnimationFrame(() => { - const ta = textareaRef.current - if (ta) { - ta.focus() - const newPos = before.length + insertion.length + (needsSpace ? 1 : 0) - ta.setSelectionRange(newPos, newPos) - } - }) + const tokenLen = match[2].length + match[3].length + const from = $from.pos - tokenLen + editor + .chain() + .focus() + .deleteRange({ from, to: $from.pos }) + .insertContent(`${insertion}${suffix}`) + .run() + closeSlashMenu() }, - [expertPrefix] + [closeSlashMenu] ) - const handleSlashSearchKeyDown = useCallback( - (e: React.KeyboardEvent<HTMLInputElement>) => { - const total = filteredSlashCommands.length + filteredSlashSkills.length - if (e.key === "ArrowDown") { - e.preventDefault() - if (total === 0) return - setSlashSelectedIndex((i) => (i < total - 1 ? i + 1 : 0)) - return - } - if (e.key === "ArrowUp") { - e.preventDefault() - if (total === 0) return - setSlashSelectedIndex((i) => (i > 0 ? i - 1 : total - 1)) - return - } - if (e.key === "Enter" || e.key === "Tab") { - if (total === 0) return - e.preventDefault() - if (slashSelectedIndex < filteredSlashCommands.length) { - handleSlashSelect(filteredSlashCommands[slashSelectedIndex]) - } else { - const skillIndex = slashSelectedIndex - filteredSlashCommands.length - const skill = filteredSlashSkills[skillIndex] - if (skill) handleSkillAutocompleteSelect(skill) - } - return - } - if (e.key === "Escape") { - e.preventDefault() - setSlashMenuOpen(false) - setSlashTriggerPos(null) - requestAnimationFrame(() => textareaRef.current?.focus()) - } + const handleSlashSelect = useCallback( + (cmd: AvailableCommandInfo) => { + replaceTriggerToken(`/${cmd.name}`) }, - [ - filteredSlashCommands, - filteredSlashSkills, - slashSelectedIndex, - handleSlashSelect, - handleSkillAutocompleteSelect, - ] + [replaceTriggerToken] ) - // Experts always inject `prefix + expert-id ` at the very front of the - // input, never at the cursor. The expert skill is a whole-turn directive - // that the agent inspects first, so prepending keeps semantics unambiguous - // regardless of what the user has already typed. If another expert prefix - // is already at the front (from a prior click), replace it instead of - // stacking — the agent only honors the first command, so a stacked prefix - // would silently drop the earlier choice. - const handleExpertPopoverSelect = useCallback( - (expert: ExpertListItem) => { - const current = textRef.current - const insertion = `${expertPrefix}${expert.metadata.id} ` - const escapedPrefix = expertPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") - const existingPrefix = current.match( - new RegExp(`^${escapedPrefix}([A-Za-z0-9_-]+)\\s`) - ) - let base = current - if (existingPrefix && expertIdSet.has(existingPrefix[1])) { - base = current.slice(existingPrefix[0].length) - } - const newText = base.length === 0 ? insertion : insertion + base - setText(newText) - requestAnimationFrame(() => { - const ta = textareaRef.current - if (ta) { - ta.focus() - // Place the caret just after the inserted prefix so the user can - // start (or continue) typing context for the expert. - const pos = insertion.length - ta.setSelectionRange(pos, pos) - } - }) + // Codex uses `$<id>`, other agents `/<id>` — matching the trigger prefix. + const handleSkillAutocompleteSelect = useCallback( + (skill: AgentSkillItem) => { + replaceTriggerToken(`${expertPrefix}${skill.id}`) }, - [expertIdSet, expertPrefix] + [replaceTriggerToken, expertPrefix] ) - const atTriggerPosRef = useRef(atTriggerPos) - useEffect(() => { - atTriggerPosRef.current = atTriggerPos - }, [atTriggerPos]) - - const handleAtSelect = useCallback( - (entry: { relativePath: string }) => { - const pos = atTriggerPosRef.current - if (!defaultPath || pos == null) return - - // Remove the @... token from text - const current = textRef.current - const beforeAt = current.slice(0, pos) - const afterAt = current.slice(pos) - const spaceIdx = afterAt.indexOf(" ", 1) - const afterToken = spaceIdx === -1 ? "" : afterAt.slice(spaceIdx) - setText(beforeAt + afterToken) - - // Attach the file - const absPath = joinFsPath(defaultPath, entry.relativePath) - appendResourceAttachments([absPath]) - - setAtMenuOpen(false) - setAtTriggerPos(null) - - requestAnimationFrame(() => textareaRef.current?.focus()) - }, - [defaultPath, appendResourceAttachments] - ) + // The "+" → Slash commands picker inserts at the current caret (no trigger + // token to replace), adding a leading space if the caret isn't at a boundary. + const handleSlashPopoverSelect = useCallback((cmd: AvailableCommandInfo) => { + const editor = editorRef.current?.getEditor() + if (!editor) return + const { $from } = editor.state.selection + const charBefore = + $from.parentOffset > 0 + ? $from.parent.textBetween( + $from.parentOffset - 1, + $from.parentOffset, + undefined, + " " + ) + : "" + const needsSpace = charBefore !== "" && !/\s/.test(charBefore) + editor + .chain() + .focus() + .insertContent(`${needsSpace ? " " : ""}/${cmd.name} `) + .run() + }, []) - const handleTextChange = useCallback( - (e: React.ChangeEvent<HTMLTextAreaElement>) => { - const value = e.target.value - setText(value) - - const cursorPos = e.target.selectionStart ?? value.length - const beforeCursor = value.slice(0, cursorPos) - - // Slash command detection. Allow the trigger at the very start of the - // input or immediately after whitespace, so users can still invoke a - // command after typing surrounding prose. Any of agent commands, - // agent-enabled experts, or (for Codex) skills can satisfy the prompt, - // so open the menu whenever at least one is available. - const hasSlashSource = - slashCommands.length > 0 || - availableExperts.length > 0 || - nonExpertSkills.length > 0 - if (hasSlashSource) { - const slashRegex = - agentType === "codex" ? /(^|\s)([/$])(\S*)$/ : /(^|\s)(\/)(\S*)$/ - const slashMatch = beforeCursor.match(slashRegex) - if (slashMatch) { - const triggerPos = - beforeCursor.length - slashMatch[0].length + slashMatch[1].length - setSlashTriggerPos(triggerPos) - setSlashSelectedIndex(0) - setSlashMenuOpen(true) - setAtMenuOpen(false) - return - } - } - setSlashMenuOpen(false) - setSlashTriggerPos(null) - - // @ file mention detection (at any cursor position) - if (defaultPath) { - const atMatch = beforeCursor.match(/(^|[\s])@([^\s]*)$/) - if (atMatch) { - const atPos = - beforeCursor.length - atMatch[0].length + atMatch[1].length - setAtTriggerPos(atPos) - setAtSelectedIndex(0) - setAtMenuOpen(true) - setAtFileTreeEnabled(true) - return - } - } - setAtMenuOpen(false) + // Experts always inject `prefix + expert-id ` at the very front of the input, + // never at the cursor — the expert skill is a whole-turn directive the agent + // inspects first. If an expert prefix is already at the front (from a prior + // click), replace it instead of stacking (the agent only honors the first). + const handleExpertPopoverSelect = useCallback( + (expert: ExpertListItem) => { + const editor = editorRef.current?.getEditor() + if (!editor) return + applyExpertPrefix(editor, expertPrefix, expert.metadata.id, expertIdSet) }, - [ - slashCommands.length, - availableExperts.length, - nonExpertSkills.length, - defaultPath, - agentType, - ] + [expertIdSet, expertPrefix] ) const handlePickFiles = useCallback(async () => { @@ -1663,7 +1542,8 @@ export function MessageInput({ const handleAddMenuOpenChange = useCallback( (open: boolean) => { if (!open) return - cursorPosRef.current = textareaRef.current?.selectionStart ?? null + // The editor keeps its selection while the menu is open, so a quick + // message inserts back at the same caret without tracking an offset. loadQuickMessages().catch((error) => { console.error("[MessageInput] quick messages refresh failed:", error) }) @@ -1672,21 +1552,8 @@ export function MessageInput({ ) const handleQuickMessageSelect = useCallback((message: QuickMessage) => { - const insertion = message.content - if (!insertion) return - const current = textRef.current - const rawPos = cursorPosRef.current ?? current.length - const pos = Math.max(0, Math.min(rawPos, current.length)) - const before = current.slice(0, pos) - const after = current.slice(pos) - setText(before + insertion + after) - requestAnimationFrame(() => { - const ta = textareaRef.current - if (!ta) return - ta.focus() - const newPos = pos + insertion.length - ta.setSelectionRange(newPos, newPos) - }) + if (!message.content) return + editorRef.current?.insertMarkdownAtCursor(message.content) }, []) useEffect(() => { @@ -1713,13 +1580,17 @@ export function MessageInput({ if (!customEvent.detail) return if (customEvent.detail.tabId !== attachmentTabId) return const appendText = customEvent.detail.text - setText((prev) => { - if (prev.length === 0) return appendText - return prev.endsWith(" ") ? prev + appendText : prev + " " + appendText - }) - requestAnimationFrame(() => { - textareaRef.current?.focus() - }) + const editor = editorRef.current?.getEditor() + if (!editor) return + // Append at the very end, separated by a space when the document isn't + // empty (and doesn't already end in whitespace). + const ed = editorRef.current + const needsSpace = ed != null && !ed.isEmpty() + editor + .chain() + .focus("end") + .insertContent(`${needsSpace ? " " : ""}${appendText}`) + .run() } window.addEventListener(APPEND_TEXT_TO_SESSION_EVENT, handleAppendText) @@ -1855,13 +1726,14 @@ export function MessageInput({ }, []) const buildDraft = useCallback((): PromptDraft | null => { - const trimmed = textRef.current.trim() - if (!trimmed && attachments.length === 0) return null + const editor = editorRef.current?.getEditor() + // Inline badges + prose → text/resource_link blocks (file mentions become + // first-class ResourceLinks; agent/session/commit/skill stay inline text). + const blocks: PromptInputBlock[] = editor ? docToPromptBlocks(editor) : [] + const displayMarkdown = editorRef.current?.getMarkdown().trim() ?? "" + + if (blocks.length === 0 && attachments.length === 0) return null - const blocks: PromptInputBlock[] = [] - if (trimmed) { - blocks.push({ type: "text", text: trimmed }) - } for (const attachment of attachments) { if (attachment.type === "resource") { if (attachment.kind === "link") { @@ -1892,38 +1764,48 @@ export function MessageInput({ } const displayText = - trimmed || + displayMarkdown || `Attached ${attachments.length} attachment${attachments.length > 1 ? "s" : ""}` return { blocks, displayText } }, [attachments]) + // Clear the editor + attachments after a send / enqueue / save. + const resetComposer = useCallback(() => { + editorRef.current?.clear() + setComposerEmpty(true) + setAttachments([]) + closeSlashMenu() + }, [closeSlashMenu]) + const handleSend = useCallback(() => { + // The editor stays editable while `disabled` (the agent is busy) so the user + // can keep typing, but a plain send is blocked — only enqueue / queue-edit + // save go through. Mirrors the legacy textarea's keydown guard. + if (disabled && !isPrompting && !isEditingQueueItem) return const draft = buildDraft() if (!draft) return // Edit mode: save back to queue item if (isEditingQueueItem && onSaveQueueEdit) { onSaveQueueEdit(draft) - setText("") - setAttachments([]) + resetComposer() return } // Prompting mode: enqueue instead of sending if (isPrompting && onEnqueue) { onEnqueue(draft, showModeSelector ? effectiveModeId : null) - setText("") - setAttachments([]) + resetComposer() return } onSend(draft, showModeSelector ? effectiveModeId : null) if (effectiveDraftStorageKey) { - clearMessageInputDraft(effectiveDraftStorageKey) + clearMessageInputDraftV2(effectiveDraftStorageKey) } - setText("") - setAttachments([]) + resetComposer() }, [ + disabled, buildDraft, isEditingQueueItem, isPrompting, @@ -1933,6 +1815,7 @@ export function MessageInput({ effectiveModeId, showModeSelector, effectiveDraftStorageKey, + resetComposer, ]) const handleForkSendClick = useCallback(() => { @@ -1945,136 +1828,89 @@ export function MessageInput({ // failure) the parent re-queues the draft, so it is never lost. onForkSend(draft, showModeSelector ? effectiveModeId : null) if (effectiveDraftStorageKey) { - clearMessageInputDraft(effectiveDraftStorageKey) + clearMessageInputDraftV2(effectiveDraftStorageKey) } - setText("") - setAttachments([]) + resetComposer() }, [ onForkSend, buildDraft, effectiveModeId, showModeSelector, effectiveDraftStorageKey, + resetComposer, ]) - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if ( - e.nativeEvent.isComposing || - composingRef.current || - e.key === "Process" || - e.keyCode === 229 - ) { - return + // Navigation/confirm/escape keys for the `/` (commands) and `$` (Codex skills) + // runtime menu, routed from inside the editor (RichComposer.onExternalMenuKeyDown) + // because ProseMirror's DOM handler fires before a host capture handler could. + // Returns true for keys the menu consumed; false (e.g. a letter that filters) + // lets normal editing proceed. + const handleExternalMenuKeyDown = useCallback( + (event: KeyboardEvent): boolean => { + if (event.isComposing) return false + if (!slashMenuOpen || slashAutocompleteCount === 0) return false + if (event.key === "ArrowDown") { + setSlashSelectedIndex((i) => + i < slashAutocompleteCount - 1 ? i + 1 : 0 + ) + return true } - - if (slashMenuOpen && slashAutocompleteCount > 0) { - if (e.key === "ArrowDown") { - e.preventDefault() - setSlashSelectedIndex((i) => - i < slashAutocompleteCount - 1 ? i + 1 : 0 - ) - return - } - if (e.key === "ArrowUp") { - e.preventDefault() - setSlashSelectedIndex((i) => - i > 0 ? i - 1 : slashAutocompleteCount - 1 - ) - return - } - if (e.key === "Enter" || e.key === "Tab") { - e.preventDefault() - // The merged list is [commands, skills]. - if (slashSelectedIndex < filteredSlashCommands.length) { - handleSlashSelect(filteredSlashCommands[slashSelectedIndex]) - } else { - const skillIndex = slashSelectedIndex - filteredSlashCommands.length - const skill = filteredSlashSkills[skillIndex] - if (skill) { - handleSkillAutocompleteSelect(skill) - } - } - return - } - if (e.key === "Escape") { - e.preventDefault() - setSlashMenuOpen(false) - setSlashTriggerPos(null) - return - } + if (event.key === "ArrowUp") { + setSlashSelectedIndex((i) => + i > 0 ? i - 1 : slashAutocompleteCount - 1 + ) + return true } - - if (atMenuOpen && filteredAtFiles.length > 0) { - if (e.key === "ArrowDown") { - e.preventDefault() - setAtSelectedIndex((i) => - i < filteredAtFiles.length - 1 ? i + 1 : 0 - ) - return - } - if (e.key === "ArrowUp") { - e.preventDefault() - setAtSelectedIndex((i) => - i > 0 ? i - 1 : filteredAtFiles.length - 1 - ) - return - } - if (e.key === "Enter" || e.key === "Tab") { - e.preventDefault() - handleAtSelect(filteredAtFiles[atSelectedIndex]) - return - } - if (e.key === "Escape") { - e.preventDefault() - setAtMenuOpen(false) - return + if (event.key === "Enter" || event.key === "Tab") { + // The merged list is [commands, skills]. + if (slashSelectedIndex < filteredSlashCommands.length) { + handleSlashSelect(filteredSlashCommands[slashSelectedIndex]) + } else { + const skill = + filteredSlashSkills[ + slashSelectedIndex - filteredSlashCommands.length + ] + if (skill) handleSkillAutocompleteSelect(skill) } + return true } - - if (isEditingQueueItem && e.key === "Escape") { - e.preventDefault() - onCancelQueueEdit?.() - return - } - - if (matchShortcutEvent(e, shortcuts.send_message)) { - e.preventDefault() - if (!disabled || isPrompting || isEditingQueueItem) handleSend() - } else if (matchShortcutEvent(e, shortcuts.newline_in_message)) { - e.preventDefault() - const textarea = e.currentTarget as HTMLTextAreaElement - const start = textarea.selectionStart - const end = textarea.selectionEnd - const value = textarea.value - const newValue = value.substring(0, start) + "\n" + value.substring(end) - setText(newValue) - requestAnimationFrame(() => { - textarea.selectionStart = textarea.selectionEnd = start + 1 - }) + if (event.key === "Escape") { + closeSlashMenu() + return true } + return false }, [ - disabled, - isPrompting, - isEditingQueueItem, - onCancelQueueEdit, - handleSend, - shortcuts, slashMenuOpen, slashAutocompleteCount, + slashSelectedIndex, filteredSlashCommands, filteredSlashSkills, - slashSelectedIndex, handleSlashSelect, handleSkillAutocompleteSelect, - atMenuOpen, - filteredAtFiles, - atSelectedIndex, - handleAtSelect, + closeSlashMenu, ] ) + // Escape cancels a queue edit. ProseMirror doesn't consume Escape, so it + // bubbles up to this container handler. Skipped while the slash menu is open + // (the editor handles that Escape to close the menu first). + const handleContainerKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.nativeEvent.isComposing) return + if ( + isEditingQueueItem && + e.key === "Escape" && + !slashMenuOpen && + onCancelQueueEdit + ) { + e.preventDefault() + onCancelQueueEdit() + } + }, + [isEditingQueueItem, slashMenuOpen, onCancelQueueEdit] + ) + const handleContainerDragOver = useCallback( (event: React.DragEvent<HTMLDivElement>) => { if (!hasDragFiles(event.dataTransfer)) return @@ -2249,27 +2085,15 @@ export function MessageInput({ <div ref={containerRef} className="relative" + onKeyDown={handleContainerKeyDown} onDragOver={handleContainerDragOver} onDragLeave={handleContainerDragLeave} onDrop={handleContainerDrop} > {slashMenuOpen && slashAutocompleteCount > 0 && ( <div className="absolute bottom-full left-0 right-0 mb-1 z-50 flex max-h-[min(16rem,40dvh)] flex-col overflow-hidden rounded-xl border border-border bg-popover shadow-lg"> - <div className="flex shrink-0 items-center gap-2 border-b border-border/60 px-3 py-2"> - <Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> - <input - type="text" - role="searchbox" - aria-label={t("slashSearchPlaceholder")} - value={slashFilterText} - onChange={handleSlashSearchChange} - onKeyDown={handleSlashSearchKeyDown} - placeholder={t("slashSearchPlaceholder")} - className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" - autoComplete="off" - spellCheck={false} - /> - </div> + {/* No search box: the user types the filter inline after `/` (like the + `@` panel); navigation is routed from the editor's keydown. */} <div ref={slashMenuListRef} className="flex-1 overflow-y-auto p-1"> {filteredSlashCommands.map((cmd, i) => ( <button @@ -2327,13 +2151,6 @@ export function MessageInput({ </div> </div> )} - {atMenuOpen && filteredAtFiles.length > 0 && ( - <FileMentionMenu - files={filteredAtFiles} - selectedIndex={atSelectedIndex} - onSelect={handleAtSelect} - /> - )} <div className={cn( folderBranchPickerAttached @@ -2414,18 +2231,22 @@ export function MessageInput({ </> } /> - <Textarea - ref={textareaRef} - value={text} - onChange={handleTextChange} - onKeyDown={handleKeyDown} - onCompositionStart={() => (composingRef.current = true)} - onCompositionEnd={() => (composingRef.current = false)} - onPaste={handlePaste} - onFocus={onFocus} + <RichComposer + ref={editorRef} placeholder={resolvedPlaceholder} - className="min-h-0 flex-1 overflow-y-auto rounded-none border-0 bg-transparent text-base md:text-sm shadow-none focus-visible:border-0 focus-visible:ring-0" + ariaLabel={resolvedPlaceholder} autoFocus={autoFocus} + referenceSearch={referenceSearch} + onChange={handleComposerChange} + onReady={handleComposerReady} + onSubmit={handleSend} + onFocus={onFocus} + onPasteFiles={handlePasteFiles} + submitShortcut={shortcuts.send_message} + newlineShortcut={shortcuts.newline_in_message} + isExternalMenuOpen={slashMenuOpen && slashAutocompleteCount > 0} + onExternalMenuKeyDown={handleExternalMenuKeyDown} + className="min-h-0 flex-1" /> <div className="flex shrink-0 items-end justify-between gap-1 px-2 pb-2"> <div className="flex min-w-0 items-end gap-1"> @@ -2605,10 +2426,6 @@ export function MessageInput({ > <DropdownMenuSubTrigger disabled={slashCommands.length === 0} - onPointerDown={() => { - cursorPosRef.current = - textareaRef.current?.selectionStart ?? null - }} > <Command className="size-4" /> {t("slashCommands")} From 391e3626118e7034a75434a232aa697e27b501c4 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Thu, 11 Jun 2026 15:27:22 +0800 Subject: [PATCH 08/31] feat(composer): restore queued-message badges + attachments on edit (Phase 3e) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-trip a queued message's full draft.blocks into the composer when editing it, so inline reference badges and attachments come back — not just the display text (the old textarea path lost attachments on edit). - composer-commands.ts: restoreBlocksIntoEditor(editor, blocks) replays a sent PromptInputBlock[] (inverse of docToPromptBlocks) — clears the editor, replays prose + reference badges in order, and returns the out-of-band attachments (images / embedded resources / non-composer links) for the host to set. - message-input.tsx: new editingItemId + editingDraftblocks props. The two queue-edit hydration paths restore from blocks when present (else fall back to setMarkdown(editingDraftText)). Re-hydration is keyed on the queue item id, not the display text, so switching between two attachment-only items (which share "Attached 1 attachment") still reloads. - editingDraftBlocks threaded conversation-detail-panel (editingQueueDraftBlocks memo over the editing item's draft.blocks) → conversation-shell → chat-input. +5 restore tests (16 in composer-commands.test.ts). Completes the P3 composer rich-text work (3a–3e). Codex-reviewed (APPROVED). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- src/components/chat/chat-input.tsx | 5 ++ .../chat/composer/composer-commands.test.ts | 80 ++++++++++++++++++- .../chat/composer/composer-commands.ts | 28 +++++++ src/components/chat/conversation-shell.tsx | 4 + src/components/chat/message-input.tsx | 55 ++++++++++--- .../conversation-detail-panel.tsx | 9 +++ 6 files changed, 167 insertions(+), 14 deletions(-) diff --git a/src/components/chat/chat-input.tsx b/src/components/chat/chat-input.tsx index db7283253..81574d386 100644 --- a/src/components/chat/chat-input.tsx +++ b/src/components/chat/chat-input.tsx @@ -7,6 +7,7 @@ import type { ConnectionStatus, PromptCapabilitiesInfo, PromptDraft, + PromptInputBlock, SessionConfigOptionInfo, SessionModeInfo, AvailableCommandInfo, @@ -43,6 +44,7 @@ interface ChatInputProps { onQueueDelete?: (id: string) => void editingItemId?: string | null editingDraftText?: string | null + editingDraftBlocks?: PromptInputBlock[] | null isEditingQueueItem?: boolean onSaveQueueEdit?: (draft: PromptDraft) => void onCancelQueueEdit?: () => void @@ -79,6 +81,7 @@ export const ChatInput = memo(function ChatInput({ onQueueDelete, editingItemId, editingDraftText, + editingDraftBlocks, isEditingQueueItem, onSaveQueueEdit, onCancelQueueEdit, @@ -130,7 +133,9 @@ export const ChatInput = memo(function ChatInput({ draftStorageKey={draftStorageKey} isActive={isActive} onEnqueue={onEnqueue} + editingItemId={editingItemId} editingDraftText={editingDraftText} + editingDraftBlocks={editingDraftBlocks} isEditingQueueItem={isEditingQueueItem} onSaveQueueEdit={onSaveQueueEdit} onCancelQueueEdit={onCancelQueueEdit} diff --git a/src/components/chat/composer/composer-commands.test.ts b/src/components/chat/composer/composer-commands.test.ts index e2fc5a3bf..46c1b2c97 100644 --- a/src/components/chat/composer/composer-commands.test.ts +++ b/src/components/chat/composer/composer-commands.test.ts @@ -1,7 +1,13 @@ import { Editor } from "@tiptap/core" import { afterEach, beforeEach, describe, expect, it } from "vitest" -import { applyExpertPrefix, isComposerEmpty } from "./composer-commands" +import type { PromptInputBlock } from "@/lib/types" + +import { + applyExpertPrefix, + isComposerEmpty, + restoreBlocksIntoEditor, +} from "./composer-commands" import { buildComposerExtensions } from "./editor-config" describe("isComposerEmpty", () => { @@ -100,3 +106,75 @@ describe("applyExpertPrefix", () => { expect(editor.getMarkdown().trimStart()).toMatch(/^\$deploy ship it/) }) }) + +describe("restoreBlocksIntoEditor", () => { + let editor: Editor + + beforeEach(() => { + editor = new Editor({ extensions: buildComposerExtensions() }) + }) + afterEach(() => editor?.destroy()) + + it("restores prose from a text block (no attachments)", () => { + const blocks: PromptInputBlock[] = [ + { type: "text", text: "hello **world**" }, + ] + const attachments = restoreBlocksIntoEditor(editor, blocks) + expect(editor.getMarkdown()).toContain("**world**") + expect(attachments).toEqual([]) + }) + + it("restores a file resource_link as a reference badge", () => { + const blocks: PromptInputBlock[] = [ + { type: "text", text: "see" }, + { + type: "resource_link", + uri: "file:///repo/app.ts", + name: "app.ts", + mime_type: null, + description: null, + }, + ] + const attachments = restoreBlocksIntoEditor(editor, blocks) + expect(JSON.stringify(editor.getJSON())).toContain('"type":"reference"') + expect(editor.getMarkdown()).toContain("see") + expect(attachments).toEqual([]) + }) + + it("restores a non-composer resource_link as an attachment, not a badge", () => { + const blocks: PromptInputBlock[] = [ + { + type: "resource_link", + uri: "https://example.com/x.pdf", + name: "x.pdf", + mime_type: "application/pdf", + description: null, + }, + ] + const attachments = restoreBlocksIntoEditor(editor, blocks) + expect(JSON.stringify(editor.getJSON())).not.toContain('"type":"reference"') + expect(attachments).toHaveLength(1) + expect(attachments[0]).toMatchObject({ + type: "resource", + kind: "link", + uri: "https://example.com/x.pdf", + }) + }) + + it("returns image blocks as attachments (not editor content)", () => { + const blocks: PromptInputBlock[] = [ + { type: "image", data: "BASE64", mime_type: "image/png", uri: null }, + ] + const attachments = restoreBlocksIntoEditor(editor, blocks) + expect(attachments).toHaveLength(1) + expect(attachments[0]).toMatchObject({ type: "image", data: "BASE64" }) + }) + + it("clears any prior content before restoring", () => { + editor.commands.setContent("stale draft", { contentType: "markdown" }) + restoreBlocksIntoEditor(editor, [{ type: "text", text: "fresh" }]) + const md = editor.getMarkdown() + expect(md).toContain("fresh") + expect(md).not.toContain("stale") + }) +}) diff --git a/src/components/chat/composer/composer-commands.ts b/src/components/chat/composer/composer-commands.ts index 97bb36058..b79676ab3 100644 --- a/src/components/chat/composer/composer-commands.ts +++ b/src/components/chat/composer/composer-commands.ts @@ -1,5 +1,10 @@ import type { Editor } from "@tiptap/core" +import type { PromptInputBlock } from "@/lib/types" + +import type { InputAttachment } from "../message-input-attachments" +import { blocksToRestoredDraft } from "./from-prompt-blocks" + /** * Whether the composer has nothing sendable. Stricter than `editor.isEmpty`, * which is false for a whitespace-only document (the legacy textarea gated the @@ -76,3 +81,26 @@ export function applyExpertPrefix( .setTextSelection(1 + insertion.length) .run() } + +/** + * Replay a previously-sent `PromptInputBlock[]` (a queued message's draft) back + * into the editor: prose + reference badges in order, returning the out-of-band + * attachments (images / embedded resources / non-composer links) for the host to + * set. Inverse of `docToPromptBlocks` for the queue-edit round-trip. The editor + * is cleared first so this fully replaces the current content. + */ +export function restoreBlocksIntoEditor( + editor: Editor, + blocks: PromptInputBlock[] +): InputAttachment[] { + const { segments, attachments } = blocksToRestoredDraft(blocks) + let chain = editor.chain().clearContent() + for (const segment of segments) { + chain = + segment.kind === "markdown" + ? chain.insertContent(segment.text, { contentType: "markdown" }) + : chain.insertReference(segment.attrs) + } + chain.focus("end").run() + return attachments +} diff --git a/src/components/chat/conversation-shell.tsx b/src/components/chat/conversation-shell.tsx index 4a214fded..9ccfde28e 100644 --- a/src/components/chat/conversation-shell.tsx +++ b/src/components/chat/conversation-shell.tsx @@ -6,6 +6,7 @@ import type { PendingQuestionState, PromptCapabilitiesInfo, PromptDraft, + PromptInputBlock, QuestionAnswer, SessionConfigOptionInfo, SessionModeInfo, @@ -74,6 +75,7 @@ interface ConversationShellProps { onQueueDelete?: (id: string) => void editingItemId?: string | null editingDraftText?: string | null + editingDraftBlocks?: PromptInputBlock[] | null isEditingQueueItem?: boolean onSaveQueueEdit?: (draft: PromptDraft) => void onCancelQueueEdit?: () => void @@ -125,6 +127,7 @@ export function ConversationShell({ onQueueDelete, editingItemId, editingDraftText, + editingDraftBlocks, isEditingQueueItem, onSaveQueueEdit, onCancelQueueEdit, @@ -248,6 +251,7 @@ export function ConversationShell({ onQueueDelete={onQueueDelete} editingItemId={editingItemId} editingDraftText={editingDraftText} + editingDraftBlocks={editingDraftBlocks} isEditingQueueItem={isEditingQueueItem} onSaveQueueEdit={onSaveQueueEdit} onCancelQueueEdit={onCancelQueueEdit} diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 7100fbca0..b47475e5e 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -114,6 +114,7 @@ import { docToPromptBlocks } from "@/components/chat/composer/to-prompt-blocks" import { applyExpertPrefix, isComposerEmpty, + restoreBlocksIntoEditor, } from "@/components/chat/composer/composer-commands" import { useReferenceSearch } from "@/components/chat/composer/use-reference-search" import type { @@ -146,7 +147,16 @@ interface MessageInputProps { draftStorageKey?: string | null isActive?: boolean onEnqueue?: (draft: PromptDraft, modeId: string | null) => void + /** Id of the queue item being edited — the stable key for (re)hydration, so + * switching between two items with identical display text still reloads. */ + editingItemId?: string | null editingDraftText?: string | null + /** + * The queued message's full `draft.blocks`, when editing a queue item. Lets + * the composer restore inline reference badges + attachments (not just text); + * falls back to {@link editingDraftText} when absent. + */ + editingDraftBlocks?: PromptInputBlock[] | null isEditingQueueItem?: boolean onSaveQueueEdit?: (draft: PromptDraft) => void onCancelQueueEdit?: () => void @@ -355,7 +365,9 @@ export function MessageInput({ draftStorageKey, isActive = false, onEnqueue, + editingItemId, editingDraftText, + editingDraftBlocks, isEditingQueueItem = false, onSaveQueueEdit, onCancelQueueEdit, @@ -485,9 +497,10 @@ export function MessageInput({ const disabledRef = useRef(disabled) const isPromptingRef = useRef(isPrompting) const hydratedRef = useRef(false) - // Tracks the last queue-edit payload hydrated, so a re-edit of the *same* item - // doesn't clobber the user's in-progress changes. - const prevEditingDraftRef = useRef<string | null>(null) + // Tracks the last queue-item id hydrated, so a re-edit of the *same* item + // doesn't clobber the user's in-progress changes — keyed on id, not display + // text (two attachment-only items share the text "Attached 1 attachment"). + const prevEditingItemIdRef = useRef<string | null>(null) const dragActiveRef = useRef(false) // Bridge so the early `onChange` handler can call the editor-driven slash // detection that is defined further down (after the slash state). @@ -556,9 +569,18 @@ export function MessageInput({ hydratedRef.current = true const ed = editorRef.current if (!ed) return - if (isEditingQueueItem && editingDraftText != null) { - ed.setMarkdown(editingDraftText) - prevEditingDraftRef.current = editingDraftText + if ( + isEditingQueueItem && + (editingDraftBlocks != null || editingDraftText != null) + ) { + const editor = ed.getEditor() + if (editingDraftBlocks && editingDraftBlocks.length > 0 && editor) { + // Full fidelity: restore inline badges + attachments from the blocks. + setAttachments(restoreBlocksIntoEditor(editor, editingDraftBlocks)) + } else if (editingDraftText != null) { + ed.setMarkdown(editingDraftText) + } + prevEditingItemIdRef.current = editingItemId ?? null } else if (effectiveDraftStorageKey) { const loaded = loadMessageInputDraftV2(effectiveDraftStorageKey) if (loaded?.kind === "doc") { @@ -572,29 +594,36 @@ export function MessageInput({ }, [ composerReady, isEditingQueueItem, + editingItemId, editingDraftText, + editingDraftBlocks, effectiveDraftStorageKey, ]) // Re-hydrate when the user (re)edits a *different* queue item after the - // initial mount hydration above. + // initial mount hydration above. Keyed on the item id (not display text) so + // switching between two items with identical text still reloads. useEffect(() => { if ( isEditingQueueItem && - editingDraftText != null && - editingDraftText !== prevEditingDraftRef.current + editingItemId != null && + editingItemId !== prevEditingItemIdRef.current ) { - prevEditingDraftRef.current = editingDraftText - editorRef.current?.setMarkdown(editingDraftText) + prevEditingItemIdRef.current = editingItemId const editor = editorRef.current?.getEditor() + if (editingDraftBlocks && editingDraftBlocks.length > 0 && editor) { + setAttachments(restoreBlocksIntoEditor(editor, editingDraftBlocks)) + } else if (editingDraftText != null) { + editorRef.current?.setMarkdown(editingDraftText) + } setComposerEmpty(editor ? isComposerEmpty(editor) : true) requestAnimationFrame(() => { editorRef.current?.focus() }) } else if (!isEditingQueueItem) { - prevEditingDraftRef.current = null + prevEditingItemIdRef.current = null } - }, [isEditingQueueItem, editingDraftText]) + }, [isEditingQueueItem, editingItemId, editingDraftText, editingDraftBlocks]) const setDragActiveIfChanged = useCallback((next: boolean) => { if (dragActiveRef.current === next) return diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 5311e7983..28091ab07 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -1019,6 +1019,14 @@ const ConversationTabView = memo(function ConversationTabView({ return item?.draft.displayText ?? null }, [mqEditingItemId, msgQueue]) + // The editing item's full blocks, so the composer can restore inline badges + + // attachments (not just the display text) when re-opening a queued message. + const editingQueueDraftBlocks = useMemo(() => { + if (!mqEditingItemId) return null + const item = msgQueue.find((m) => m.id === mqEditingItemId) + return item?.draft.blocks ?? null + }, [mqEditingItemId, msgQueue]) + const handleQueueEdit = useCallback( (id: string) => { mqStartEditing(id) @@ -1161,6 +1169,7 @@ const ConversationTabView = memo(function ConversationTabView({ onQueueDelete={mqRemove} editingItemId={mqEditingItemId} editingDraftText={editingQueueDraftText} + editingDraftBlocks={editingQueueDraftBlocks} isEditingQueueItem={mqEditingItemId != null} onSaveQueueEdit={handleSaveQueueEdit} onCancelQueueEdit={handleQueueCancelEdit} From 9a99d8dc12f3590b15ae95aa3dccf22571dd1864 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Thu, 11 Jun 2026 16:48:03 +0800 Subject: [PATCH 09/31] feat(composer): viewport-aware positioning for the @ mention panel (Phase 4a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @ panel was anchored at the caret with a fixed wrapper + `absolute bottom-full` and no viewport clamping: it overflowed the right edge near the window edge, and was clipped off-screen above when the caret sat near the top (short window, split view, inline queue-edit). clientRect===null fell to (0,0). - Add pure, unit-tested `placeMentionPopup()` — horizontal clamp + above/below flip (prefers above; falls back to the larger side) + null-caret fallback. - Measure the rendered panel in a layout effect and position via the helper; the wrapper stays hidden until measured so there is no (0,0) flash. - Cap the panel to the viewport (minus the 8px edge margins) so it always fits and scrolls internally rather than overflowing on small windows. - Thread Tiptap's live `clientRect` getter through the render state so the panel re-anchors on window resize, editor scroll, and page scroll (capture phase) instead of reusing a stale snapshot. Codex-reviewed (APPROVED, independently re-ran the gate). Gate: 1271 tests, eslint clean, build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../composer/suggestion/mention-suggestion.ts | 12 +- .../suggestion/popup-position.test.ts | 109 ++++++++++++++++++ .../composer/suggestion/popup-position.ts | 95 +++++++++++++++ .../suggestion/suggestion-popup.test.tsx | 101 +++++++++++++++- .../composer/suggestion/suggestion-popup.tsx | 60 +++++++++- 5 files changed, 367 insertions(+), 10 deletions(-) create mode 100644 src/components/chat/composer/suggestion/popup-position.test.ts create mode 100644 src/components/chat/composer/suggestion/popup-position.ts diff --git a/src/components/chat/composer/suggestion/mention-suggestion.ts b/src/components/chat/composer/suggestion/mention-suggestion.ts index 174e90c6d..6627b7970 100644 --- a/src/components/chat/composer/suggestion/mention-suggestion.ts +++ b/src/components/chat/composer/suggestion/mention-suggestion.ts @@ -6,8 +6,13 @@ export interface MentionRenderState { query: string /** Document range covering `@` + query, replaced when a row is chosen. */ range: { from: number; to: number } - /** Caret rect (viewport coords) for positioning the popup, if known. */ - clientRect: DOMRect | null + /** + * Live caret-rect getter (viewport coords), or null if unknown. Call it at + * position time — not once at trigger time — so the popup re-anchors to the + * current caret after a window resize, editor scroll, or page scroll while it + * is open. + */ + getClientRect: (() => DOMRect | null) | null } /** @@ -38,7 +43,8 @@ function toRenderState(props: SuggestionProps): MentionRenderState { return { query: props.query, range: props.range, - clientRect: props.clientRect?.() ?? null, + // Keep the getter itself (not a snapshot) so reposition reads live coords. + getClientRect: props.clientRect ?? null, } } diff --git a/src/components/chat/composer/suggestion/popup-position.test.ts b/src/components/chat/composer/suggestion/popup-position.test.ts new file mode 100644 index 000000000..d8f76430d --- /dev/null +++ b/src/components/chat/composer/suggestion/popup-position.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest" + +import { placeMentionPopup } from "./popup-position" + +const SIZE = { width: 320, height: 288 } +const VIEWPORT = { width: 1280, height: 800 } +// Defaults the helper uses: margin=8, gap=4. + +describe("placeMentionPopup", () => { + it("anchors the left edge at the caret when there is room", () => { + const pos = placeMentionPopup( + { left: 100, top: 600, bottom: 620 }, + SIZE, + VIEWPORT + ) + expect(pos.left).toBe(100) + }) + + it("clamps the left edge so the panel stays inside the right margin", () => { + // caret far right: 1200 + 320 would overflow the 1280 viewport. + const pos = placeMentionPopup( + { left: 1200, top: 600, bottom: 620 }, + SIZE, + VIEWPORT + ) + // max left = 1280 - 320 - 8 = 952 + expect(pos.left).toBe(952) + }) + + it("clamps the left edge to the left margin for a negative caret x", () => { + const pos = placeMentionPopup( + { left: -50, top: 600, bottom: 620 }, + SIZE, + VIEWPORT + ) + expect(pos.left).toBe(8) + }) + + it("pins to the left margin when the panel is wider than the viewport", () => { + const pos = placeMentionPopup( + { left: 100, top: 300, bottom: 320 }, + { width: 600, height: 200 }, + { width: 400, height: 800 } + ) + expect(pos.left).toBe(8) + }) + + it("places the panel above the caret when there is room (composer at bottom)", () => { + const pos = placeMentionPopup( + { left: 100, top: 600, bottom: 620 }, + SIZE, + VIEWPORT + ) + expect(pos.placement).toBe("above") + // top = caret.top - gap - height = 600 - 4 - 288 = 308 + expect(pos.top).toBe(308) + }) + + it("flips below the caret when there is not enough room above", () => { + // caret near the top: only 50px above, panel needs 288+4. + const pos = placeMentionPopup( + { left: 100, top: 50, bottom: 70 }, + SIZE, + VIEWPORT + ) + expect(pos.placement).toBe("below") + // top = caret.bottom + gap = 70 + 4 = 74 + expect(pos.top).toBe(74) + }) + + it("falls back to the side with more room when neither fully fits", () => { + // Short viewport: nothing fits. caret.top=120 (roomAbove≈112), + // caret.bottom=140 in a 300-tall viewport (roomBelow≈152) → below wins. + const pos = placeMentionPopup({ left: 100, top: 120, bottom: 140 }, SIZE, { + width: 1280, + height: 300, + }) + expect(pos.placement).toBe("below") + // top clamps so the panel doesn't leave the viewport bottom: + // min(140+4, 300-288-8)=min(144,4)=4 → max(8,4)=8 + expect(pos.top).toBe(8) + }) + + it("prefers above when both sides are equally cramped", () => { + // Symmetric: caret centered in a viewport too short for either side. + const pos = placeMentionPopup({ left: 100, top: 150, bottom: 150 }, SIZE, { + width: 1280, + height: 300, + }) + expect(pos.placement).toBe("above") + }) + + it("pins to the top-left corner when the caret rect is null", () => { + const pos = placeMentionPopup(null, SIZE, VIEWPORT) + expect(pos).toEqual({ left: 8, top: 8, placement: "below" }) + }) + + it("respects custom margin and gap options", () => { + const pos = placeMentionPopup( + { left: 100, top: 600, bottom: 620 }, + SIZE, + VIEWPORT, + { margin: 16, gap: 10 } + ) + // above: top = 600 - 10 - 288 = 302 + expect(pos.top).toBe(302) + expect(pos.left).toBe(100) + }) +}) diff --git a/src/components/chat/composer/suggestion/popup-position.ts b/src/components/chat/composer/suggestion/popup-position.ts new file mode 100644 index 000000000..61e94784b --- /dev/null +++ b/src/components/chat/composer/suggestion/popup-position.ts @@ -0,0 +1,95 @@ +/** A caret bounding rect in viewport coordinates (the subset we need). */ +export interface CaretRect { + left: number + top: number + bottom: number +} + +export interface PopupSize { + width: number + height: number +} + +export interface Viewport { + width: number + height: number +} + +export type PopupPlacement = "above" | "below" + +export interface PopupPosition { + /** Viewport x for the panel's left edge. */ + left: number + /** Viewport y for the panel's top edge. */ + top: number + /** Which side of the caret the panel landed on. */ + placement: PopupPlacement +} + +export interface PlaceMentionPopupOptions { + /** Padding kept between the panel and each viewport edge. Default 8. */ + margin?: number + /** Gap between the caret and the panel's near edge. Default 4. */ + gap?: number +} + +const DEFAULT_MARGIN = 8 +const DEFAULT_GAP = 4 + +function clamp(value: number, min: number, max: number): number { + // A degenerate band (panel larger than the space between the margins) makes + // max < min; pin to min (the leading margin) rather than returning NaN-ish + // ordering. The panel then overflows the far edge but scrolls internally. + if (max < min) return min + if (value < min) return min + if (value > max) return max + return value +} + +/** + * Compute a viewport-clamped position for the caret-anchored `@` mention popup. + * + * Pure (no DOM access) so it is unit-testable; the component measures the panel + * and the viewport, then calls this. + * + * - **Horizontal**: the panel's left edge follows the caret but is clamped so + * the whole panel stays within `margin` of both viewport edges. + * - **Vertical**: the panel prefers to sit *above* the caret (the composer + * usually hugs the screen bottom). It flips *below* when there isn't room + * above, and falls back to whichever side has more room when neither fully + * fits — always clamped so the panel never leaves the viewport. + * - **No caret** (rare IME states where `clientRect` is null): pin to the + * top-left corner, still clamped. + */ +export function placeMentionPopup( + caret: CaretRect | null, + size: PopupSize, + viewport: Viewport, + options: PlaceMentionPopupOptions = {} +): PopupPosition { + const margin = options.margin ?? DEFAULT_MARGIN + const gap = options.gap ?? DEFAULT_GAP + + if (!caret) { + return { left: margin, top: margin, placement: "below" } + } + + const left = clamp(caret.left, margin, viewport.width - size.width - margin) + + const roomAbove = caret.top - margin + const roomBelow = viewport.height - caret.bottom - margin + const needed = size.height + gap + + let placement: PopupPlacement + if (roomAbove >= needed) placement = "above" + else if (roomBelow >= needed) placement = "below" + // Neither side fully fits: take the side with more room. + else placement = roomAbove >= roomBelow ? "above" : "below" + + const rawTop = + placement === "above" ? caret.top - gap - size.height : caret.bottom + gap + // Final clamp so even the "more room" fallback can't leave the viewport. + const top = clamp(rawTop, margin, viewport.height - size.height - margin) + + return { left, top, placement } +} diff --git a/src/components/chat/composer/suggestion/suggestion-popup.test.tsx b/src/components/chat/composer/suggestion/suggestion-popup.test.tsx index 47ef2f672..7a98c6c3e 100644 --- a/src/components/chat/composer/suggestion/suggestion-popup.test.tsx +++ b/src/components/chat/composer/suggestion/suggestion-popup.test.tsx @@ -1,6 +1,6 @@ import { act, render, screen } from "@testing-library/react" import { createRef } from "react" -import { describe, expect, it, vi } from "vitest" +import { afterEach, describe, expect, it, vi } from "vitest" import { SuggestionPopup } from "./suggestion-popup" import type { @@ -38,7 +38,11 @@ const groups: SuggestionGroup[] = [ const search: ReferenceSearch = () => groups const emptySearch: ReferenceSearch = () => [] -const state = { query: "a", range: { from: 1, to: 3 }, clientRect: null } +const state = { + query: "a", + range: { from: 1, to: 3 }, + getClientRect: () => null, +} function mountPopup( overrides: Partial<Parameters<typeof SuggestionPopup>[0]> = {} @@ -64,6 +68,10 @@ function key(name: string): KeyboardEvent { } describe("SuggestionPopup", () => { + afterEach(() => { + vi.restoreAllMocks() + }) + it("renders grouped results from the search provider", async () => { mountPopup() expect(await screen.findByText("alpha.md")).toBeInTheDocument() @@ -125,7 +133,7 @@ describe("SuggestionPopup", () => { const view = (query: string, to: number) => ( <SuggestionPopup ref={ref} - state={{ query, range: { from: 1, to }, clientRect: null }} + state={{ query, range: { from: 1, to }, getClientRect: () => null }} search={search} onSelect={onSelect} onClose={vi.fn()} @@ -160,4 +168,91 @@ describe("SuggestionPopup", () => { // preventDefault keeps focus in the editor rather than the popup button. expect(event.defaultPrevented).toBe(true) }) + + it("positions and reveals the caret-anchored panel once measured", async () => { + render( + <SuggestionPopup + ref={createRef<SuggestionPopupHandle>()} + state={{ + query: "a", + range: { from: 1, to: 3 }, + getClientRect: () => + ({ left: 100, top: 600, bottom: 620 }) as DOMRect, + }} + search={search} + onSelect={vi.fn()} + onClose={vi.fn()} + /> + ) + await screen.findByText("alpha.md") + const container = screen.getByTestId("mention-popup") + .parentElement as HTMLElement + // The layout effect measured the panel and clamped/flipped it into view. + expect(container.style.visibility).toBe("visible") + expect(container.style.position).toBe("fixed") + expect(container.dataset.placement).toBeTruthy() + }) + + it("clamps the rendered panel coordinates into the viewport", async () => { + // A real (nonzero) panel size lets the viewport clamp actually bite. + vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ + width: 320, + height: 288, + } as DOMRect) + render( + <SuggestionPopup + ref={createRef<SuggestionPopupHandle>()} + state={{ + query: "a", + range: { from: 1, to: 3 }, + // Caret hard against the right edge of the jsdom 1024px viewport. + getClientRect: () => + ({ left: 1000, top: 600, bottom: 620 }) as DOMRect, + }} + search={search} + onSelect={vi.fn()} + onClose={vi.fn()} + /> + ) + await screen.findByText("alpha.md") + const container = screen.getByTestId("mention-popup") + .parentElement as HTMLElement + // left clamps to 1024 - 320 - 8 = 696 (not the raw caret x of 1000). + expect(container.style.left).toBe("696px") + // Room above (600px) fits → placed above: 600 - 4 - 288 = 308. + expect(container.style.top).toBe("308px") + expect(container.dataset.placement).toBe("above") + }) + + it("re-anchors to the live caret rect on resize (not a stale snapshot)", async () => { + vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ + width: 320, + height: 288, + } as DOMRect) + let caretLeft = 100 + const getClientRect = vi.fn( + () => ({ left: caretLeft, top: 600, bottom: 620 }) as DOMRect + ) + render( + <SuggestionPopup + ref={createRef<SuggestionPopupHandle>()} + state={{ query: "a", range: { from: 1, to: 3 }, getClientRect }} + search={search} + onSelect={vi.fn()} + onClose={vi.fn()} + /> + ) + await screen.findByText("alpha.md") + const container = screen.getByTestId("mention-popup") + .parentElement as HTMLElement + expect(container.style.left).toBe("100px") + // The caret reflows; a resize must re-read the live getter, not a snapshot. + const before = getClientRect.mock.calls.length + caretLeft = 300 + act(() => { + window.dispatchEvent(new Event("resize")) + }) + expect(getClientRect.mock.calls.length).toBeGreaterThan(before) + expect(container.style.left).toBe("300px") + }) }) diff --git a/src/components/chat/composer/suggestion/suggestion-popup.tsx b/src/components/chat/composer/suggestion/suggestion-popup.tsx index b819e2ead..974080479 100644 --- a/src/components/chat/composer/suggestion/suggestion-popup.tsx +++ b/src/components/chat/composer/suggestion/suggestion-popup.tsx @@ -4,6 +4,7 @@ import { forwardRef, useEffect, useImperativeHandle, + useLayoutEffect, useMemo, useRef, useState, @@ -15,6 +16,7 @@ import { cn } from "@/lib/utils" import { ReferenceIcon } from "../badges/reference-badge" import type { ReferenceAttrs } from "../types" import type { MentionRenderState } from "./mention-suggestion" +import { placeMentionPopup } from "./popup-position" import type { ReferenceSearch, SuggestionGroup, @@ -23,6 +25,12 @@ import type { const FETCH_DEBOUNCE_MS = 150 +// Commit-synchronous in the browser so the panel is positioned before paint (no +// flash at a stale spot); a no-op-safe passive effect during the static-export +// prerender where `useLayoutEffect` would warn. +const useIsomorphicLayoutEffect = + typeof window !== "undefined" ? useLayoutEffect : useEffect + export interface SuggestionPopupProps { /** Live trigger state (query/range/caret rect). */ state: MentionRenderState @@ -75,6 +83,11 @@ export const SuggestionPopup = forwardRef< groups: SuggestionGroup[] }>({ query: null, groups: [] }) const [selectedIndex, setSelectedIndex] = useState(0) + const [pos, setPos] = useState<{ + left: number + top: number + placement: "above" | "below" + } | null>(null) const listRef = useRef<HTMLDivElement>(null) const stale = result.query !== state.query @@ -121,6 +134,40 @@ export const SuggestionPopup = forwardRef< ?.scrollIntoView({ block: "nearest" }) }, [selectedIndex]) + // Position the caret-anchored panel within the viewport. Measure the rendered + // panel (a `visibility:hidden` box still has layout), read the *live* caret + // rect, then clamp/flip via the pure helper. A layout effect runs before + // paint, so the panel never flashes at a wrong spot. `state` is a fresh object + // each keystroke and the height tracks `stale`/`flat.length`, so this + // re-anchors as the caret moves and results load; resize + capture-phase + // scroll listeners re-anchor on window resize, editor scroll, or page scroll + // while the panel is open (the caret getter returns fresh coords each call). + useIsomorphicLayoutEffect(() => { + if (typeof window === "undefined") return + const reposition = () => { + const panel = listRef.current + if (!panel) return + const rect = panel.getBoundingClientRect() + const caret = state.getClientRect?.() ?? null + setPos( + placeMentionPopup( + caret + ? { left: caret.left, top: caret.top, bottom: caret.bottom } + : null, + { width: rect.width, height: rect.height }, + { width: window.innerWidth, height: window.innerHeight } + ) + ) + } + reposition() + window.addEventListener("resize", reposition) + window.addEventListener("scroll", reposition, true) + return () => { + window.removeEventListener("resize", reposition) + window.removeEventListener("scroll", reposition, true) + } + }, [state, stale, flat.length]) + useImperativeHandle( ref, (): SuggestionPopupHandle => ({ @@ -157,22 +204,27 @@ export const SuggestionPopup = forwardRef< [flat, selectedIndex, onSelect, onClose, state.range] ) - const rect = state.clientRect let rowIndex = -1 return createPortal( <div style={{ position: "fixed", - left: rect?.left ?? 0, - top: rect?.top ?? 0, + left: pos?.left ?? 0, + top: pos?.top ?? 0, + // Hidden until the first measure positions it (avoids a flash at 0,0). + visibility: pos ? "visible" : "hidden", zIndex: 50, }} + data-placement={pos?.placement} > <div ref={listRef} data-testid="mention-popup" - className="absolute bottom-full left-0 mb-1 max-h-72 w-80 overflow-y-auto rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-lg" + // Cap to the viewport (minus the 8px×2 edge margin = 1rem) so the panel + // can always fit on small windows and scroll internally rather than + // overflowing — the positioner clamps placement, this bounds the size. + className="max-h-[min(18rem,calc(100dvh_-_1rem))] w-80 max-w-[calc(100vw_-_1rem)] overflow-y-auto rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-lg" > {stale ? ( <div className="px-2 py-3 text-sm text-muted-foreground"> From c3c8567393b852e73e232c51bae8e2726fd50cab Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Thu, 11 Jun 2026 17:49:55 +0800 Subject: [PATCH 10/31] feat(composer): accessibility for the @ mention panel and reference badges (Phase 4b) The `@` panel was a bare div+button list with no listbox semantics, no live region, and the editor exposed no combobox relationship; inline reference badges had only a `title`. Screen-reader users got keyboard nav but no announcements. - Panel: the suggestions container is a `role="listbox"` (id `mention-listbox`, aria-label) that owns ONLY option/group children; each row is a `role="option"` with `aria-selected` and a stable id; loading/empty status sits outside the listbox; an sr-only `role="status"` live region announces loading / "N results" / empty. - Editor combobox wiring: while the panel is open the contentEditable gets `aria-autocomplete="list"`, `aria-controls`, and `aria-activedescendant` (mirrored from the active option); all cleared on close / select / disable. Role stays "textbox" (the recognized textbox-autocomplete pattern). - Reference badge: `role="img"` + `aria-label="<type>: <label>"` gives the inline atom a reliable computed accessible name. `ReferenceIcon` is now decorative (aria-hidden) at the source, so AgentIcon's titled <svg> no longer leaks into option/badge names. Codex-reviewed (APPROVED after 3 rounds: added aria-autocomplete, role=img, listbox-owns-only-options, decorative icon). Gate: 1278 tests, eslint clean, build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../chat/composer/badges/reference-badge.tsx | 41 +++-- .../composer/nodes/reference-node.test.tsx | 5 + .../composer/rich-composer-mention.test.tsx | 42 +++++ .../chat/composer/rich-composer.tsx | 38 ++++- .../suggestion/suggestion-popup.test.tsx | 71 ++++++++- .../composer/suggestion/suggestion-popup.tsx | 147 ++++++++++++------ 6 files changed, 287 insertions(+), 57 deletions(-) diff --git a/src/components/chat/composer/badges/reference-badge.tsx b/src/components/chat/composer/badges/reference-badge.tsx index 1b1ea7b42..a9611502c 100644 --- a/src/components/chat/composer/badges/reference-badge.tsx +++ b/src/components/chat/composer/badges/reference-badge.tsx @@ -1,4 +1,5 @@ import { Bot, FileText, Folder, GitCommit, Hash, Sparkles } from "lucide-react" +import type { ReactNode } from "react" import { AgentIcon } from "@/components/agent-icon" import { @@ -14,34 +15,50 @@ const ICON_CLASS = "size-3.5 shrink-0" export function ReferenceIcon({ data }: { data: ReferenceAttrs }) { const meta = data.meta + let icon: ReactNode = null switch (data.refType) { case "file": - return meta?.fileKind === "dir" ? ( - <Folder className={ICON_CLASS} /> - ) : ( - <FileText className={ICON_CLASS} /> - ) + icon = + meta?.fileKind === "dir" ? ( + <Folder className={ICON_CLASS} /> + ) : ( + <FileText className={ICON_CLASS} /> + ) + break case "agent": { const agentType = meta?.agentType ?? (data.id as AgentType) - return agentType ? ( + icon = agentType ? ( <AgentIcon agentType={agentType} className={ICON_CLASS} /> ) : ( <Bot className={ICON_CLASS} /> ) + break } case "session": - return meta?.agentType ? ( + icon = meta?.agentType ? ( <AgentIcon agentType={meta.agentType} className={ICON_CLASS} /> ) : ( <Hash className={ICON_CLASS} /> ) + break case "commit": - return <GitCommit className={ICON_CLASS} /> + icon = <GitCommit className={ICON_CLASS} /> + break case "skill": - return <Sparkles className={ICON_CLASS} /> + icon = <Sparkles className={ICON_CLASS} /> + break default: return null } + // Decorative wherever it appears (popup option, badge): the accessible name + // comes from the adjacent label (or the badge's own role="img" name), so hide + // it — otherwise AgentIcon's titled <svg> leaks into the option name (e.g. + // "Codex Codex Helper"). + return ( + <span aria-hidden="true" className="inline-flex shrink-0"> + {icon} + </span> + ) } export interface ReferenceBadgeProps { @@ -64,6 +81,12 @@ export function ReferenceBadge({ data, className }: ReferenceBadgeProps) { data-reference-badge="" data-ref-type={data.refType} title={data.uri ?? data.label} + // The badge is an inline contentEditable=false atom. `role="img"` makes it + // a single named unit so `aria-label` is a reliable accessible name (a + // bare span's aria-label is not), and collapses the decorative icon — + // including AgentIcon's titled <svg> — into that one name. + role="img" + aria-label={`${data.refType}: ${data.label || data.id}`} className={cn( "inline-flex max-w-[18rem] items-center gap-1 rounded-md border border-border/60 bg-muted/60 px-1.5 py-px align-baseline text-[0.85em] leading-snug text-foreground", className diff --git a/src/components/chat/composer/nodes/reference-node.test.tsx b/src/components/chat/composer/nodes/reference-node.test.tsx index 44c5fad37..cc31175a6 100644 --- a/src/components/chat/composer/nodes/reference-node.test.tsx +++ b/src/components/chat/composer/nodes/reference-node.test.tsx @@ -123,6 +123,11 @@ describe("Reference node", () => { ) expect(badge.textContent).toContain("Claude Code") expect(badge.querySelector("svg")).not.toBeNull() + // The inline atom exposes a *computed* accessible name via role="img" + + // aria-label (a bare span's aria-label would be unreliable); the decorative + // icon collapses into that single name. + expect(badge).toHaveAttribute("role", "img") + expect(badge).toHaveAccessibleName("agent: Claude Code") }) it("round-trips through HTML (renderHTML → parseHTML)", async () => { diff --git a/src/components/chat/composer/rich-composer-mention.test.tsx b/src/components/chat/composer/rich-composer-mention.test.tsx index 0740d704b..0021d70ab 100644 --- a/src/components/chat/composer/rich-composer-mention.test.tsx +++ b/src/components/chat/composer/rich-composer-mention.test.tsx @@ -65,6 +65,43 @@ describe("RichComposer @ mention integration", () => { }) // The "@app" trigger text is gone, replaced by the badge. expect(editor.getText()).not.toContain("@app") + // Selecting closes the panel and clears the combobox ARIA on the editor. + const dom = editor.view.dom as HTMLElement + await waitFor(() => { + expect(dom.hasAttribute("aria-controls")).toBe(false) + expect(dom.hasAttribute("aria-activedescendant")).toBe(false) + expect(dom.hasAttribute("aria-autocomplete")).toBe(false) + }) + }) + + it("wires the editor's combobox ARIA while the panel is open, clears it on Escape", async () => { + const { editor } = await mount() + const dom = editor.view.dom as HTMLElement + expect(dom.getAttribute("role")).toBe("textbox") + expect(dom.hasAttribute("aria-controls")).toBe(false) + act(() => { + editor.commands.insertContent("@app") + }) + await screen.findByText("app.ts", {}, { timeout: 5000 }) + await waitFor(() => { + expect(dom.getAttribute("aria-controls")).toBe("mention-listbox") + expect(dom.getAttribute("aria-autocomplete")).toBe("list") + expect(dom.getAttribute("aria-activedescendant")).toBe("mention-option-0") + }) + act(() => { + dom.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }) + ) + }) + await waitFor(() => { + expect(dom.hasAttribute("aria-controls")).toBe(false) + expect(dom.hasAttribute("aria-autocomplete")).toBe(false) + expect(dom.hasAttribute("aria-activedescendant")).toBe(false) + }) }) it("does not submit on Enter while the panel is open", async () => { @@ -125,6 +162,11 @@ describe("RichComposer @ mention integration", () => { await waitFor(() => expect(screen.queryByTestId("mention-popup")).toBeNull() ) + // Disabling also clears the combobox ARIA on the editor. + const dom = editor.view.dom as HTMLElement + expect(dom.hasAttribute("aria-controls")).toBe(false) + expect(dom.hasAttribute("aria-activedescendant")).toBe(false) + expect(dom.hasAttribute("aria-autocomplete")).toBe(false) // Enter now submits normally — panel + plugin state were cleared. act(() => { diff --git a/src/components/chat/composer/rich-composer.tsx b/src/components/chat/composer/rich-composer.tsx index 6e21967d1..fc41a7b2e 100644 --- a/src/components/chat/composer/rich-composer.tsx +++ b/src/components/chat/composer/rich-composer.tsx @@ -22,7 +22,10 @@ import type { MentionController, MentionRenderState, } from "./suggestion/mention-suggestion" -import { SuggestionPopup } from "./suggestion/suggestion-popup" +import { + MENTION_LISTBOX_ID, + SuggestionPopup, +} from "./suggestion/suggestion-popup" import type { ReferenceSearch, SuggestionPopupHandle } from "./suggestion/types" import type { ReferenceAttrs } from "./types" @@ -404,6 +407,38 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>( [editor, closeMention] ) + // Combobox ARIA on the editing surface: DOM focus stays in the editor while + // the `@` panel is open, so the controlled-listbox relationship lives on the + // contentEditable. `aria-activedescendant` is mirrored from the popup's + // active row (below); here we toggle `aria-controls` and clear both when the + // panel closes. (role stays "textbox" — a multiline editor that surfaces an + // autocomplete list, the recognized textbox-autocomplete pattern.) + const isMentionOpen = mentionState !== null + useEffect(() => { + const dom = editor?.view.dom + if (!dom) return + if (isMentionOpen) { + // `aria-autocomplete="list"` tells AT this textbox offers a list of + // completions; `aria-controls` names the listbox it drives. + dom.setAttribute("aria-autocomplete", "list") + dom.setAttribute("aria-controls", MENTION_LISTBOX_ID) + } else { + dom.removeAttribute("aria-autocomplete") + dom.removeAttribute("aria-controls") + dom.removeAttribute("aria-activedescendant") + } + }, [editor, isMentionOpen]) + + const handleActiveOptionChange = useCallback( + (optionId: string | null) => { + const dom = editor?.view.dom + if (!dom) return + if (optionId) dom.setAttribute("aria-activedescendant", optionId) + else dom.removeAttribute("aria-activedescendant") + }, + [editor] + ) + return ( <div className={cn("codeg-composer flex min-h-0 flex-col", className)} @@ -421,6 +456,7 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>( search={referenceSearch} onSelect={handleReferenceSelect} onClose={closeMention} + onActiveOptionChange={handleActiveOptionChange} /> )} </div> diff --git a/src/components/chat/composer/suggestion/suggestion-popup.test.tsx b/src/components/chat/composer/suggestion/suggestion-popup.test.tsx index 7a98c6c3e..2fc0a10ca 100644 --- a/src/components/chat/composer/suggestion/suggestion-popup.test.tsx +++ b/src/components/chat/composer/suggestion/suggestion-popup.test.tsx @@ -1,4 +1,4 @@ -import { act, render, screen } from "@testing-library/react" +import { act, render, screen, within } from "@testing-library/react" import { createRef } from "react" import { afterEach, describe, expect, it, vi } from "vitest" @@ -82,7 +82,9 @@ describe("SuggestionPopup", () => { it("shows an empty state when there are no matches", async () => { mountPopup({ search: emptySearch, emptyLabel: "Nothing" }) - expect(await screen.findByText("Nothing")).toBeInTheDocument() + // Scope to the listbox: the sr-only live region carries the same text. + const panel = screen.getByTestId("mention-popup") + expect(await within(panel).findByText("Nothing")).toBeInTheDocument() }) it("selects the highlighted row on Enter (default = first)", async () => { @@ -146,7 +148,9 @@ describe("SuggestionPopup", () => { // Query advances; the shown results now answer the *previous* query. rerender(view("ab", 3)) expect(screen.queryByText("alpha.md")).toBeNull() - expect(screen.getByText("Loading")).toBeInTheDocument() + expect( + within(screen.getByTestId("mention-popup")).getByText("Loading") + ).toBeInTheDocument() act(() => ref.current?.onKeyDown(key("Enter"))) expect(onSelect).not.toHaveBeenCalled() @@ -255,4 +259,65 @@ describe("SuggestionPopup", () => { expect(getClientRect.mock.calls.length).toBeGreaterThan(before) expect(container.style.left).toBe("300px") }) + + it("exposes listbox + option roles with the active option selected", async () => { + mountPopup({ listboxLabel: "Mentions" }) + await screen.findByText("alpha.md") + // The listbox is a child of the (testid) panel and owns only options. + const listbox = screen.getByRole("listbox", { name: "Mentions" }) + expect(listbox).toHaveAttribute("id", "mention-listbox") + const options = within(listbox).getAllByRole("option") + expect(options).toHaveLength(2) + expect(options[0]).toHaveAttribute("aria-selected", "true") + expect(options[0]).toHaveAttribute("id", "mention-option-0") + expect(options[1]).toHaveAttribute("aria-selected", "false") + }) + + it("keeps the decorative icon out of the option's accessible name", async () => { + mountPopup() + await screen.findByText("Codex Helper") + // The agent row's AgentIcon is a titled <svg>; if it weren't decorative the + // option would be named "Codex Codex Helper". The name must be just label. + expect( + screen.getByRole("option", { name: "Codex Helper" }) + ).toBeInTheDocument() + }) + + it("moves aria-selected with the keyboard", async () => { + const { ref } = mountPopup() + await screen.findByText("alpha.md") + act(() => ref.current?.onKeyDown(key("ArrowDown"))) + const options = screen + .getByTestId("mention-popup") + .querySelectorAll('[role="option"]') + expect(options[0]).toHaveAttribute("aria-selected", "false") + expect(options[1]).toHaveAttribute("aria-selected", "true") + }) + + it("announces the result count via a polite live region", async () => { + mountPopup() + await screen.findByText("alpha.md") + const status = screen.getByRole("status") + expect(status).toHaveAttribute("aria-live", "polite") + expect(status).toHaveTextContent("2 results") + }) + + it("reports the active option id to the host for aria-activedescendant", async () => { + const onActiveOptionChange = vi.fn() + mountPopup({ onActiveOptionChange }) + await screen.findByText("alpha.md") + expect(onActiveOptionChange).toHaveBeenLastCalledWith("mention-option-0") + }) + + it("reports a null active option while loading or empty", async () => { + const onActiveOptionChange = vi.fn() + mountPopup({ + search: emptySearch, + onActiveOptionChange, + emptyLabel: "None", + }) + const panel = screen.getByTestId("mention-popup") + await within(panel).findByText("None") + expect(onActiveOptionChange).toHaveBeenLastCalledWith(null) + }) }) diff --git a/src/components/chat/composer/suggestion/suggestion-popup.tsx b/src/components/chat/composer/suggestion/suggestion-popup.tsx index 974080479..3b99a6083 100644 --- a/src/components/chat/composer/suggestion/suggestion-popup.tsx +++ b/src/components/chat/composer/suggestion/suggestion-popup.tsx @@ -31,6 +31,16 @@ const FETCH_DEBOUNCE_MS = 150 const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect +/** + * `id` of the listbox element and of each option. The editor's contentEditable + * (which keeps DOM focus) points `aria-controls` at the listbox and + * `aria-activedescendant` at the active option, the standard combobox pattern + * for a popup that doesn't take focus. Only one panel is open at a time (the + * focused editor's), so fixed ids never collide. + */ +export const MENTION_LISTBOX_ID = "mention-listbox" +export const mentionOptionId = (index: number) => `mention-option-${index}` + export interface SuggestionPopupProps { /** Live trigger state (query/range/caret rect). */ state: MentionRenderState @@ -45,6 +55,16 @@ export interface SuggestionPopupProps { onClose: () => void emptyLabel?: string loadingLabel?: string + /** Accessible name for the listbox. */ + listboxLabel?: string + /** Builds the live-region result count announcement. */ + countLabel?: (count: number) => string + /** + * Reports the active option's element id (or null when nothing is + * selectable), so the host can mirror it onto the editor's + * `aria-activedescendant`. Must be referentially stable. + */ + onActiveOptionChange?: (optionId: string | null) => void } interface FlatRow { @@ -69,6 +89,9 @@ export const SuggestionPopup = forwardRef< onClose, emptyLabel = "No matches", loadingLabel = "Searching…", + listboxLabel = "Mentions", + countLabel = (count) => `${count} results`, + onActiveOptionChange, }, ref ) { @@ -134,6 +157,15 @@ export const SuggestionPopup = forwardRef< ?.scrollIntoView({ block: "nearest" }) }, [selectedIndex]) + // Mirror the active option's id to the host (→ editor `aria-activedescendant`). + // Null while nothing is selectable (loading / no matches), so the editor never + // points at a stale or absent option. + useEffect(() => { + onActiveOptionChange?.( + stale || flat.length === 0 ? null : mentionOptionId(selectedIndex) + ) + }, [selectedIndex, flat.length, stale, onActiveOptionChange]) + // Position the caret-anchored panel within the viewport. Measure the rendered // panel (a `visibility:hidden` box still has layout), read the *live* caret // rect, then clamp/flip via the pure helper. A layout effect runs before @@ -204,6 +236,11 @@ export const SuggestionPopup = forwardRef< [flat, selectedIndex, onSelect, onClose, state.range] ) + const liveStatus = stale + ? loadingLabel + : flat.length === 0 + ? emptyLabel + : countLabel(flat.length) let rowIndex = -1 return createPortal( @@ -226,6 +263,8 @@ export const SuggestionPopup = forwardRef< // overflowing — the positioner clamps placement, this bounds the size. className="max-h-[min(18rem,calc(100dvh_-_1rem))] w-80 max-w-[calc(100vw_-_1rem)] overflow-y-auto rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-lg" > + {/* Status text lives *outside* the listbox: a listbox may only own + options/groups. (The sr-only live region below announces it to AT.) */} {stale ? ( <div className="px-2 py-3 text-sm text-muted-foreground"> {loadingLabel} @@ -234,51 +273,71 @@ export const SuggestionPopup = forwardRef< <div className="px-2 py-3 text-sm text-muted-foreground"> {emptyLabel} </div> - ) : ( - result.groups.map((group) => - group.items.length === 0 ? null : ( - <div key={group.kind} className="py-0.5"> - <div className="px-2 py-1 text-xs font-medium text-muted-foreground"> - {group.label} - </div> - {group.items.map((item) => { - rowIndex += 1 - const active = rowIndex === selectedIndex - const index = rowIndex - return ( - <button - key={`${group.kind}:${item.reference.id}`} - type="button" - data-active={active} - className={cn( - "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm", - active - ? "bg-accent text-accent-foreground" - : "hover:bg-accent/50" - )} - onMouseDown={(event) => { - // Keep editor focus; insert on click. - event.preventDefault() - onSelect(item.reference, state.range) - }} - onMouseEnter={() => setSelectedIndex(index)} - > - <ReferenceIcon data={item.reference} /> - <span className="flex-1 truncate"> - {item.reference.label || item.reference.id} - </span> - {item.detail && ( - <span className="max-w-[10rem] truncate text-xs text-muted-foreground"> - {item.detail} + ) : null} + {/* Always rendered (even empty) so the editor's `aria-controls` target + always resolves; holds only option/group children. */} + <div id={MENTION_LISTBOX_ID} role="listbox" aria-label={listboxLabel}> + {!stale && + result.groups.map((group) => + group.items.length === 0 ? null : ( + <div + key={group.kind} + role="group" + aria-label={group.label} + className="py-0.5" + > + <div + aria-hidden + className="px-2 py-1 text-xs font-medium text-muted-foreground" + > + {group.label} + </div> + {group.items.map((item) => { + rowIndex += 1 + const active = rowIndex === selectedIndex + const index = rowIndex + return ( + <button + key={`${group.kind}:${item.reference.id}`} + type="button" + id={mentionOptionId(index)} + role="option" + aria-selected={active} + data-active={active} + className={cn( + "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm", + active + ? "bg-accent text-accent-foreground" + : "hover:bg-accent/50" + )} + onMouseDown={(event) => { + // Keep editor focus; insert on click. + event.preventDefault() + onSelect(item.reference, state.range) + }} + onMouseEnter={() => setSelectedIndex(index)} + > + <ReferenceIcon data={item.reference} /> + <span className="flex-1 truncate"> + {item.reference.label || item.reference.id} </span> - )} - </button> - ) - })} - </div> - ) - ) - )} + {item.detail && ( + <span className="max-w-[10rem] truncate text-xs text-muted-foreground"> + {item.detail} + </span> + )} + </button> + ) + })} + </div> + ) + )} + </div> + </div> + {/* Announce loading / result count / empty state to screen readers; the + listbox keeps no focus, so AT relies on this polite live region. */} + <div role="status" aria-live="polite" className="sr-only"> + {liveStatus} </div> </div>, document.body From ab28ed4fbea93db5e8e103f1fae2eab785cb6429 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Thu, 11 Jun 2026 18:07:22 +0800 Subject: [PATCH 11/31] feat(composer): truncation hint + localized @ panel chrome (Phase 4c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `@` panel capped each group at 50 and silently dropped the rest, and its chrome (empty/loading/listbox name/count) + group headings were hardcoded English in a 10-language app. - buildReferenceGroups flags `truncated` per group cheaply (sets it when a match is found past the cap — no full scan) rather than silently dropping overflow. - The panel renders a per-group, aria-hidden, non-selectable "keep typing to filter" hint (kept out of `flat`, so Arrow/Enter never land on it; the listbox still owns only options), and the polite live region appends it after the count so screen-reader users learn of truncation too. - mentionUiLabels threads localized empty/loading/listbox/more/count from message-input → RichComposer → SuggestionPopup; group headings are localized via useReferenceSearch({labels}). 10 new keys added to all 10 locales (mentionCount uses ICU plurals; Arabic carries the full category set). Codex-reviewed (APPROVED, first round). Gate: 1281 tests (i18n key parity included), eslint clean, build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../chat/composer/rich-composer.tsx | 18 +++++- .../suggestion/suggestion-popup.test.tsx | 21 ++++++ .../composer/suggestion/suggestion-popup.tsx | 20 +++++- .../chat/composer/suggestion/types.ts | 23 +++++++ .../composer/use-reference-search.test.ts | 25 +++++++- .../chat/composer/use-reference-search.ts | 64 +++++++++++++++---- src/components/chat/message-input.tsx | 30 ++++++++- src/i18n/messages/ar.json | 12 +++- src/i18n/messages/de.json | 12 +++- src/i18n/messages/en.json | 12 +++- src/i18n/messages/es.json | 12 +++- src/i18n/messages/fr.json | 12 +++- src/i18n/messages/ja.json | 12 +++- src/i18n/messages/ko.json | 12 +++- src/i18n/messages/pt.json | 12 +++- src/i18n/messages/zh-CN.json | 12 +++- src/i18n/messages/zh-TW.json | 12 +++- 17 files changed, 293 insertions(+), 28 deletions(-) diff --git a/src/components/chat/composer/rich-composer.tsx b/src/components/chat/composer/rich-composer.tsx index fc41a7b2e..01abbc5fc 100644 --- a/src/components/chat/composer/rich-composer.tsx +++ b/src/components/chat/composer/rich-composer.tsx @@ -26,7 +26,11 @@ import { MENTION_LISTBOX_ID, SuggestionPopup, } from "./suggestion/suggestion-popup" -import type { ReferenceSearch, SuggestionPopupHandle } from "./suggestion/types" +import type { + MentionUiLabels, + ReferenceSearch, + SuggestionPopupHandle, +} from "./suggestion/types" import type { ReferenceAttrs } from "./types" /** @@ -101,6 +105,12 @@ export interface RichComposerProps { * effect. Omit to disable mentions. */ referenceSearch?: ReferenceSearch + /** + * Localized chrome for the `@` panel (empty / loading / listbox name / "more + * results" hint / result-count announcement). English fallbacks apply when + * omitted. Render-only — safe to pass a fresh object per render. + */ + mentionUiLabels?: MentionUiLabels /** * Key binding (matchShortcutEvent form) that sends the message. Default * `"enter"`. When set to a non-Enter binding, a plain Enter inserts a newline. @@ -152,6 +162,7 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>( onBlur, onReady, referenceSearch, + mentionUiLabels, submitShortcut, newlineShortcut, isExternalMenuOpen, @@ -457,6 +468,11 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>( onSelect={handleReferenceSelect} onClose={closeMention} onActiveOptionChange={handleActiveOptionChange} + emptyLabel={mentionUiLabels?.empty} + loadingLabel={mentionUiLabels?.loading} + listboxLabel={mentionUiLabels?.listbox} + moreLabel={mentionUiLabels?.more} + countLabel={mentionUiLabels?.count} /> )} </div> diff --git a/src/components/chat/composer/suggestion/suggestion-popup.test.tsx b/src/components/chat/composer/suggestion/suggestion-popup.test.tsx index 2fc0a10ca..a9eb0e3f7 100644 --- a/src/components/chat/composer/suggestion/suggestion-popup.test.tsx +++ b/src/components/chat/composer/suggestion/suggestion-popup.test.tsx @@ -320,4 +320,25 @@ describe("SuggestionPopup", () => { await within(panel).findByText("None") expect(onActiveOptionChange).toHaveBeenLastCalledWith(null) }) + + it("shows a non-selectable, aria-hidden hint for a truncated group", async () => { + const truncatedSearch: ReferenceSearch = () => [ + { + kind: "file", + label: "Files", + items: [{ reference: fileRef, detail: "docs/alpha.md" }], + truncated: true, + }, + ] + mountPopup({ search: truncatedSearch, moreLabel: "More — keep typing" }) + await screen.findByText("alpha.md") + const panel = screen.getByTestId("mention-popup") + const hint = within(panel).getByText("More — keep typing") + // Decorative: hidden from AT (the live region announces truncation) and not + // an option, so arrow/Enter can never land on it. + expect(hint).toHaveAttribute("aria-hidden", "true") + expect(panel.querySelectorAll('[role="option"]')).toHaveLength(1) + // The polite live region also conveys truncation to screen readers. + expect(screen.getByRole("status")).toHaveTextContent("More — keep typing") + }) }) diff --git a/src/components/chat/composer/suggestion/suggestion-popup.tsx b/src/components/chat/composer/suggestion/suggestion-popup.tsx index 3b99a6083..4c28ab020 100644 --- a/src/components/chat/composer/suggestion/suggestion-popup.tsx +++ b/src/components/chat/composer/suggestion/suggestion-popup.tsx @@ -59,6 +59,8 @@ export interface SuggestionPopupProps { listboxLabel?: string /** Builds the live-region result count announcement. */ countLabel?: (count: number) => string + /** Non-selectable hint shown under a group whose matches were capped. */ + moreLabel?: string /** * Reports the active option's element id (or null when nothing is * selectable), so the host can mirror it onto the editor's @@ -91,6 +93,7 @@ export const SuggestionPopup = forwardRef< loadingLabel = "Searching…", listboxLabel = "Mentions", countLabel = (count) => `${count} results`, + moreLabel = "More results — keep typing to filter", onActiveOptionChange, }, ref @@ -236,11 +239,14 @@ export const SuggestionPopup = forwardRef< [flat, selectedIndex, onSelect, onClose, state.range] ) + const anyTruncated = !stale && result.groups.some((group) => group.truncated) const liveStatus = stale ? loadingLabel : flat.length === 0 ? emptyLabel - : countLabel(flat.length) + : anyTruncated + ? `${countLabel(flat.length)} ${moreLabel}` + : countLabel(flat.length) let rowIndex = -1 return createPortal( @@ -329,6 +335,18 @@ export const SuggestionPopup = forwardRef< </button> ) })} + {group.truncated && ( + // aria-hidden: a visual "refine" affordance, not an option — + // keeps the listbox owning only options (the live region + // conveys truncation to AT). Never enters `flat`, so Enter + // can't select it. + <div + aria-hidden + className="px-2 py-1 text-xs italic text-muted-foreground" + > + {moreLabel} + </div> + )} </div> ) )} diff --git a/src/components/chat/composer/suggestion/types.ts b/src/components/chat/composer/suggestion/types.ts index 02397a3f5..3221919cc 100644 --- a/src/components/chat/composer/suggestion/types.ts +++ b/src/components/chat/composer/suggestion/types.ts @@ -16,6 +16,29 @@ export interface SuggestionGroup { /** Display heading for the group. */ label: string items: SuggestionItem[] + /** + * True when more items matched than the per-group cap, so the panel shows a + * non-selectable "keep typing to filter" hint rather than silently dropping + * the overflow. + */ + truncated?: boolean +} + +/** + * Localized chrome for the mention panel, injected by the host (English + * fallbacks live in the popup). Kept together so callers wire it in one place. + */ +export interface MentionUiLabels { + /** Shown when the query matches nothing. */ + empty: string + /** Shown while a search is in flight. */ + loading: string + /** Accessible name for the listbox. */ + listbox: string + /** Per-group "more results, keep typing" hint. */ + more: string + /** Builds the live-region result-count announcement (supports plurals). */ + count: (count: number) => string } /** diff --git a/src/components/chat/composer/use-reference-search.test.ts b/src/components/chat/composer/use-reference-search.test.ts index 82045d490..a0858dfd0 100644 --- a/src/components/chat/composer/use-reference-search.test.ts +++ b/src/components/chat/composer/use-reference-search.test.ts @@ -292,13 +292,34 @@ describe("buildReferenceGroups", () => { expect(itemsOf(groups, "skill")[0].reference.label).toBe("评审员") }) - it("caps each group at 50 items", () => { + it("caps each group at 50 items and flags the overflow as truncated", () => { const files = Array.from({ length: 60 }, (_, i) => makeFile(`f${i}.ts`)) const groups = buildReferenceGroups( "", emptySources({ files, workspaceRoot: "/repo" }) ) - expect(itemsOf(groups, "file")).toHaveLength(50) + const fileGroup = groups.find((g) => g.kind === "file") + expect(fileGroup?.items).toHaveLength(50) + expect(fileGroup?.truncated).toBe(true) + }) + + it("does not flag truncation when a group exactly fills the cap", () => { + const files = Array.from({ length: 50 }, (_, i) => makeFile(`f${i}.ts`)) + const groups = buildReferenceGroups( + "", + emptySources({ files, workspaceRoot: "/repo" }) + ) + const fileGroup = groups.find((g) => g.kind === "file") + expect(fileGroup?.items).toHaveLength(50) + expect(fileGroup?.truncated).toBe(false) + }) + + it("flags truncation for slice-based groups (agents) as well", () => { + const agents = Array.from({ length: 51 }, (_, i) => makeAgent(`a${i}`)) + const groups = buildReferenceGroups("", emptySources({ agents })) + const agentGroup = groups.find((g) => g.kind === "agent") + expect(agentGroup?.items).toHaveLength(50) + expect(agentGroup?.truncated).toBe(true) }) it("returns everything for an empty query (whitespace-trimmed)", () => { diff --git a/src/components/chat/composer/use-reference-search.ts b/src/components/chat/composer/use-reference-search.ts index 1fefaf47e..09abdc0ab 100644 --- a/src/components/chat/composer/use-reference-search.ts +++ b/src/components/chat/composer/use-reference-search.ts @@ -109,37 +109,46 @@ export function buildReferenceGroups( const q = query.trim().toLowerCase() // Files: filter the (potentially large) list on its pre-lowered fields before - // paying to adapt the survivors. + // paying to adapt the survivors. `truncated` is a cheap boolean — set when a + // match is found past the cap — so we never scan the whole list for a count. const fileItems: SuggestionItem[] = [] + let fileTruncated = false const root = sources.workspaceRoot if (root) { for (const entry of sources.files) { if (q && !entry.lowerName.includes(q) && !entry.lowerPath.includes(q)) { continue } + if (fileItems.length >= MAX_PER_GROUP) { + fileTruncated = true + break + } fileItems.push(fileToSuggestion(entry, root)) - if (fileItems.length >= MAX_PER_GROUP) break } } - const agentItems = sources.agents + const agentMatches = sources.agents .map(agentToSuggestion) .filter((item) => suggestionMatches(item, q)) - .slice(0, MAX_PER_GROUP) + const agentItems = agentMatches.slice(0, MAX_PER_GROUP) - const sessionItems = sources.sessions + const sessionMatches = sources.sessions .map(sessionToSuggestion) .filter((item) => suggestionMatches(item, q)) - .slice(0, MAX_PER_GROUP) + const sessionItems = sessionMatches.slice(0, MAX_PER_GROUP) const commitItems: SuggestionItem[] = [] + let commitTruncated = false if (sources.repoKey) { const repoKey = sources.repoKey for (const entry of sources.commits) { const item = commitToSuggestion(entry, repoKey) if (!suggestionMatches(item, q)) continue + if (commitItems.length >= MAX_PER_GROUP) { + commitTruncated = true + break + } commitItems.push(item) - if (commitItems.length >= MAX_PER_GROUP) break } } @@ -147,6 +156,7 @@ export function buildReferenceGroups( // can surface from more than one source, so dedupe by reference id (skill id), // keeping the first occurrence, before filtering. const skillItems: SuggestionItem[] = [] + let skillTruncated = false const seenSkillIds = new Set<string>() const skillCandidates: SuggestionItem[] = [ ...sources.skills.map(skillToSuggestion), @@ -157,16 +167,44 @@ export function buildReferenceGroups( if (seenSkillIds.has(item.reference.id)) continue seenSkillIds.add(item.reference.id) if (!suggestionMatches(item, q)) continue + if (skillItems.length >= MAX_PER_GROUP) { + skillTruncated = true + break + } skillItems.push(item) - if (skillItems.length >= MAX_PER_GROUP) break } return [ - { kind: "file", label: labels.file, items: fileItems }, - { kind: "agent", label: labels.agent, items: agentItems }, - { kind: "session", label: labels.session, items: sessionItems }, - { kind: "commit", label: labels.commit, items: commitItems }, - { kind: "skill", label: labels.skill, items: skillItems }, + { + kind: "file", + label: labels.file, + items: fileItems, + truncated: fileTruncated, + }, + { + kind: "agent", + label: labels.agent, + items: agentItems, + truncated: agentMatches.length > MAX_PER_GROUP, + }, + { + kind: "session", + label: labels.session, + items: sessionItems, + truncated: sessionMatches.length > MAX_PER_GROUP, + }, + { + kind: "commit", + label: labels.commit, + items: commitItems, + truncated: commitTruncated, + }, + { + kind: "skill", + label: labels.skill, + items: skillItems, + truncated: skillTruncated, + }, ] } diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index b47475e5e..7ce8c1cd6 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -116,7 +116,11 @@ import { isComposerEmpty, restoreBlocksIntoEditor, } from "@/components/chat/composer/composer-commands" -import { useReferenceSearch } from "@/components/chat/composer/use-reference-search" +import { + useReferenceSearch, + type ReferenceGroupLabels, +} from "@/components/chat/composer/use-reference-search" +import type { MentionUiLabels } from "@/components/chat/composer/suggestion/types" import type { ImageInputAttachment, InputAttachment, @@ -523,12 +527,35 @@ export function MessageInput({ isPromptingRef.current = isPrompting }, [isPrompting]) + // Localized group headings + panel chrome for the `@` mention panel. + const referenceGroupLabels = useMemo<ReferenceGroupLabels>( + () => ({ + file: t("mentionGroupFile"), + agent: t("mentionGroupAgent"), + session: t("mentionGroupSession"), + commit: t("mentionGroupCommit"), + skill: t("mentionGroupSkill"), + }), + [t] + ) + const mentionUiLabels = useMemo<MentionUiLabels>( + () => ({ + empty: t("mentionEmpty"), + loading: t("mentionLoading"), + listbox: t("mentionListLabel"), + more: t("mentionMore"), + count: (count: number) => t("mentionCount", { count }), + }), + [t] + ) + // Live data sources for the unified `@` mention panel. Pre-warmed only while // this composer is the active one (`enabled`). Referentially stable. const referenceSearch = useReferenceSearch({ defaultPath: defaultPath ?? null, agentType: agentType ?? null, enabled: isActive, + labels: referenceGroupLabels, }) // Debounced v2 draft persistence. We snapshot the Tiptap *document* (JSON, not @@ -2266,6 +2293,7 @@ export function MessageInput({ ariaLabel={resolvedPlaceholder} autoFocus={autoFocus} referenceSearch={referenceSearch} + mentionUiLabels={mentionUiLabels} onChange={handleComposerChange} onReady={handleComposerReady} onSubmit={handleSend} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 97d4806b6..8b80ef46a 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1808,7 +1808,17 @@ "attachUploadTooLarge": "{names} يتجاوز حد التحميل {limit}MB وتم تخطيه.", "attachUploadFailed": "فشل تحميل {names}.", "attachUploadNotAFile": "{names} ليس ملفاً عادياً (مجلد أو ملف خاص) وتم تخطيه.", - "attachUploadQuotaExceeded": "نفدت مساحة التحميل على الخادم، تعذر رفع {names}." + "attachUploadQuotaExceeded": "نفدت مساحة التحميل على الخادم، تعذر رفع {names}.", + "mentionEmpty": "لا توجد نتائج مطابقة", + "mentionLoading": "جارٍ البحث…", + "mentionListLabel": "الإشارات", + "mentionMore": "مزيد من النتائج — تابع الكتابة للتصفية", + "mentionCount": "{count, plural, zero {لا نتائج} one {نتيجة واحدة} two {نتيجتان} few {# نتائج} many {# نتيجة} other {# نتيجة}}", + "mentionGroupFile": "الملفات", + "mentionGroupAgent": "الوكلاء", + "mentionGroupSession": "الجلسات", + "mentionGroupCommit": "عمليات الإيداع", + "mentionGroupSkill": "المهارات" }, "messageQueue": { "addToQueue": "إضافة للقائمة", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index bfadd2669..45384bced 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1808,7 +1808,17 @@ "attachUploadTooLarge": "{names} überschreitet das Upload-Limit von {limit}MB und wurde übersprungen.", "attachUploadFailed": "Upload fehlgeschlagen: {names}.", "attachUploadNotAFile": "{names} ist keine reguläre Datei (Verzeichnis oder Spezialdatei) und wurde übersprungen.", - "attachUploadQuotaExceeded": "Auf dem Server ist kein Upload-Speicher mehr verfügbar; {names} konnte nicht hochgeladen werden." + "attachUploadQuotaExceeded": "Auf dem Server ist kein Upload-Speicher mehr verfügbar; {names} konnte nicht hochgeladen werden.", + "mentionEmpty": "Keine Treffer", + "mentionLoading": "Suche läuft…", + "mentionListLabel": "Erwähnungen", + "mentionMore": "Weitere Ergebnisse – tippe weiter, um zu filtern", + "mentionCount": "{count, plural, one {# Ergebnis} other {# Ergebnisse}}", + "mentionGroupFile": "Dateien", + "mentionGroupAgent": "Agenten", + "mentionGroupSession": "Sitzungen", + "mentionGroupCommit": "Commits", + "mentionGroupSkill": "Fähigkeiten" }, "messageQueue": { "addToQueue": "Zur Warteschlange", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 80bf1a1e9..7150a5c63 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1808,7 +1808,17 @@ "attachUploadTooLarge": "{names} exceeds the {limit}MB upload limit and was skipped.", "attachUploadFailed": "Failed to upload {names}.", "attachUploadNotAFile": "{names} isn't a regular file (directory or special file) and was skipped.", - "attachUploadQuotaExceeded": "The server is out of upload storage; {names} couldn't be uploaded." + "attachUploadQuotaExceeded": "The server is out of upload storage; {names} couldn't be uploaded.", + "mentionEmpty": "No matches", + "mentionLoading": "Searching…", + "mentionListLabel": "Mentions", + "mentionMore": "More results — keep typing to filter", + "mentionCount": "{count, plural, one {# result} other {# results}}", + "mentionGroupFile": "Files", + "mentionGroupAgent": "Agents", + "mentionGroupSession": "Sessions", + "mentionGroupCommit": "Commits", + "mentionGroupSkill": "Skills" }, "messageQueue": { "addToQueue": "Queue message", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 2b40fde85..df60dcfdd 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1808,7 +1808,17 @@ "attachUploadTooLarge": "{names} supera el límite de carga de {limit}MB y se omitió.", "attachUploadFailed": "Error al subir {names}.", "attachUploadNotAFile": "{names} no es un archivo regular (directorio o archivo especial) y se omitió.", - "attachUploadQuotaExceeded": "El servidor se quedó sin espacio para subidas; no se pudo cargar {names}." + "attachUploadQuotaExceeded": "El servidor se quedó sin espacio para subidas; no se pudo cargar {names}.", + "mentionEmpty": "Sin coincidencias", + "mentionLoading": "Buscando…", + "mentionListLabel": "Menciones", + "mentionMore": "Más resultados: sigue escribiendo para filtrar", + "mentionCount": "{count, plural, one {# resultado} other {# resultados}}", + "mentionGroupFile": "Archivos", + "mentionGroupAgent": "Agentes", + "mentionGroupSession": "Sesiones", + "mentionGroupCommit": "Commits", + "mentionGroupSkill": "Habilidades" }, "messageQueue": { "addToQueue": "Agregar a la cola", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 8dfd0d4d4..7de75b12b 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1808,7 +1808,17 @@ "attachUploadTooLarge": "{names} dépasse la limite de téléversement de {limit} Mo et a été ignoré.", "attachUploadFailed": "Échec du téléversement de {names}.", "attachUploadNotAFile": "{names} n'est pas un fichier régulier (répertoire ou fichier spécial) et a été ignoré.", - "attachUploadQuotaExceeded": "Le serveur n'a plus d'espace pour les téléversements ; {names} n'a pas pu être envoyé." + "attachUploadQuotaExceeded": "Le serveur n'a plus d'espace pour les téléversements ; {names} n'a pas pu être envoyé.", + "mentionEmpty": "Aucune correspondance", + "mentionLoading": "Recherche…", + "mentionListLabel": "Mentions", + "mentionMore": "Plus de résultats — continuez à taper pour filtrer", + "mentionCount": "{count, plural, one {# résultat} other {# résultats}}", + "mentionGroupFile": "Fichiers", + "mentionGroupAgent": "Agents", + "mentionGroupSession": "Sessions", + "mentionGroupCommit": "Commits", + "mentionGroupSkill": "Compétences" }, "messageQueue": { "addToQueue": "Mettre en file", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index e31156ed4..b78c8f1a0 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1808,7 +1808,17 @@ "attachUploadTooLarge": "{names} は {limit}MB のアップロード上限を超えたためスキップしました。", "attachUploadFailed": "{names} のアップロードに失敗しました。", "attachUploadNotAFile": "{names} は通常ファイルではなく(ディレクトリまたは特殊ファイル)、スキップされました。", - "attachUploadQuotaExceeded": "サーバーのアップロード容量が不足しているため、{names} をアップロードできませんでした。" + "attachUploadQuotaExceeded": "サーバーのアップロード容量が不足しているため、{names} をアップロードできませんでした。", + "mentionEmpty": "一致する項目がありません", + "mentionLoading": "検索中…", + "mentionListLabel": "メンション", + "mentionMore": "他にも結果があります。入力して絞り込んでください", + "mentionCount": "{count, plural, other {# 件の結果}}", + "mentionGroupFile": "ファイル", + "mentionGroupAgent": "エージェント", + "mentionGroupSession": "セッション", + "mentionGroupCommit": "コミット", + "mentionGroupSkill": "スキル" }, "messageQueue": { "addToQueue": "キューに追加", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index f916eb077..ff7cd0531 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1808,7 +1808,17 @@ "attachUploadTooLarge": "{names}이(가) {limit}MB 업로드 한도를 초과하여 건너뛰었습니다.", "attachUploadFailed": "{names} 업로드 실패.", "attachUploadNotAFile": "{names} 은(는) 일반 파일이 아니므로(디렉터리 또는 특수 파일) 건너뛰었습니다.", - "attachUploadQuotaExceeded": "서버 업로드 저장 공간이 부족하여 {names} 을(를) 업로드하지 못했습니다." + "attachUploadQuotaExceeded": "서버 업로드 저장 공간이 부족하여 {names} 을(를) 업로드하지 못했습니다.", + "mentionEmpty": "일치하는 항목 없음", + "mentionLoading": "검색 중…", + "mentionListLabel": "멘션", + "mentionMore": "결과가 더 있습니다. 계속 입력하여 필터링하세요", + "mentionCount": "{count, plural, other {결과 #개}}", + "mentionGroupFile": "파일", + "mentionGroupAgent": "에이전트", + "mentionGroupSession": "세션", + "mentionGroupCommit": "커밋", + "mentionGroupSkill": "스킬" }, "messageQueue": { "addToQueue": "대기열에 추가", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index c755fe003..ec8da1bd1 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1808,7 +1808,17 @@ "attachUploadTooLarge": "{names} excede o limite de upload de {limit}MB e foi ignorado.", "attachUploadFailed": "Falha ao enviar {names}.", "attachUploadNotAFile": "{names} não é um arquivo regular (diretório ou arquivo especial) e foi ignorado.", - "attachUploadQuotaExceeded": "O servidor ficou sem espaço para uploads; {names} não pôde ser enviado." + "attachUploadQuotaExceeded": "O servidor ficou sem espaço para uploads; {names} não pôde ser enviado.", + "mentionEmpty": "Nenhuma correspondência", + "mentionLoading": "Pesquisando…", + "mentionListLabel": "Menções", + "mentionMore": "Mais resultados — continue digitando para filtrar", + "mentionCount": "{count, plural, one {# resultado} other {# resultados}}", + "mentionGroupFile": "Arquivos", + "mentionGroupAgent": "Agentes", + "mentionGroupSession": "Sessões", + "mentionGroupCommit": "Commits", + "mentionGroupSkill": "Habilidades" }, "messageQueue": { "addToQueue": "Adicionar à fila", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index bdf62c37f..7e87a58e7 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1808,7 +1808,17 @@ "attachUploadTooLarge": "{names} 超过 {limit}MB 上传上限,已跳过。", "attachUploadFailed": "上传失败:{names}。", "attachUploadNotAFile": "{names} 不是常规文件(目录或特殊文件),已跳过。", - "attachUploadQuotaExceeded": "服务器上传空间已用尽,{names} 未能上传。" + "attachUploadQuotaExceeded": "服务器上传空间已用尽,{names} 未能上传。", + "mentionEmpty": "无匹配项", + "mentionLoading": "搜索中…", + "mentionListLabel": "提及", + "mentionMore": "还有更多结果,继续输入以筛选", + "mentionCount": "{count, plural, other {# 项结果}}", + "mentionGroupFile": "文件", + "mentionGroupAgent": "智能体", + "mentionGroupSession": "会话", + "mentionGroupCommit": "提交", + "mentionGroupSkill": "技能" }, "messageQueue": { "addToQueue": "加入队列", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index fd00e31f4..dbfd80fc1 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1808,7 +1808,17 @@ "attachUploadTooLarge": "{names} 超過 {limit}MB 上傳上限,已略過。", "attachUploadFailed": "上傳失敗:{names}。", "attachUploadNotAFile": "{names} 不是常規檔案(目錄或特殊檔案),已略過。", - "attachUploadQuotaExceeded": "伺服器上傳空間已用盡,{names} 未能上傳。" + "attachUploadQuotaExceeded": "伺服器上傳空間已用盡,{names} 未能上傳。", + "mentionEmpty": "無相符項目", + "mentionLoading": "搜尋中…", + "mentionListLabel": "提及", + "mentionMore": "還有更多結果,繼續輸入以篩選", + "mentionCount": "{count, plural, other {# 項結果}}", + "mentionGroupFile": "檔案", + "mentionGroupAgent": "智能體", + "mentionGroupSession": "工作階段", + "mentionGroupCommit": "提交", + "mentionGroupSkill": "技能" }, "messageQueue": { "addToQueue": "加入佇列", From 6ee3239d25e31439bf8b04a4cd006546561c63af Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Thu, 11 Jun 2026 21:24:37 +0800 Subject: [PATCH 12/31] feat(composer): shared codeg uri parser + session <agent_type>_<external_id> format (P5a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the reference-uri grammar (file:/codeg://session/codeg://commit → ReferenceAttrs) out of from-prompt-blocks.ts into a shared reference-uri.ts so the transcript renderer can reuse it; re-export parseReferenceUri under its old name for existing importers. Change the session serialization uri to codeg://session/<agent_type>_<external_id> (falling back to the numeric id when external_id is null) so a transcript badge can recover the agent icon and a future codeg-mcp can resolve sessions. Agent types contain underscores, so the parser recovers the type by prefix-matching ALL_AGENT_TYPES, never by splitting on the first underscore; legacy numeric ids degrade to a session badge without an agent icon. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../chat/composer/from-prompt-blocks.test.ts | 22 +++++ .../chat/composer/from-prompt-blocks.ts | 58 ++---------- .../chat/composer/reference-uri.test.ts | 89 +++++++++++++++++++ src/components/chat/composer/reference-uri.ts | 80 +++++++++++++++++ .../chat/composer/suggestion/adapters.test.ts | 15 +++- .../chat/composer/suggestion/adapters.ts | 13 ++- 6 files changed, 220 insertions(+), 57 deletions(-) create mode 100644 src/components/chat/composer/reference-uri.test.ts create mode 100644 src/components/chat/composer/reference-uri.ts diff --git a/src/components/chat/composer/from-prompt-blocks.test.ts b/src/components/chat/composer/from-prompt-blocks.test.ts index d6ba9b2f2..fa51589ea 100644 --- a/src/components/chat/composer/from-prompt-blocks.test.ts +++ b/src/components/chat/composer/from-prompt-blocks.test.ts @@ -93,6 +93,28 @@ describe("blocksToRestoredDraft", () => { }) }) + it("recovers the agent type from a new-format session link", () => { + const { segments } = blocksToRestoredDraft( + [ + { + type: "resource_link", + uri: "codeg://session/codex_sess1", + name: "My chat", + mime_type: null, + description: null, + }, + ], + counter() + ) + expect(refSegments(segments)[0]).toMatchObject({ + refType: "session", + id: "codex_sess1", + label: "My chat", + uri: "codeg://session/codex_sess1", + meta: { agentType: "codex" }, + }) + }) + it("restores a codeg commit link as a commit reference (hash after @)", () => { const { segments } = blocksToRestoredDraft( [ diff --git a/src/components/chat/composer/from-prompt-blocks.ts b/src/components/chat/composer/from-prompt-blocks.ts index 921a2ed73..0a3beeefd 100644 --- a/src/components/chat/composer/from-prompt-blocks.ts +++ b/src/components/chat/composer/from-prompt-blocks.ts @@ -2,6 +2,7 @@ import type { PromptInputBlock } from "@/lib/types" import { randomUUID } from "@/lib/utils" import type { InputAttachment } from "../message-input-attachments" +import { parseCodegReferenceUri as parseReferenceUri } from "./reference-uri" import type { ReferenceAttrs } from "./types" /** @@ -94,58 +95,11 @@ export function blocksToRestoredDraft( return { segments, attachments } } -// Schemes the composer emits as structured references (mirror reference-node.ts). -const SESSION_URI = /^codeg:\/\/session\/(.+)$/i -const COMMIT_URI = /^codeg:\/\/commit\/.*@(.+)$/i - -/** - * Parse a sent resource uri back into a reference, or null when it isn't a - * composer reference scheme (in which case it's restored as an attachment). - */ -export function parseReferenceUri( - uri: string, - name: string -): ReferenceAttrs | null { - const lower = uri.toLowerCase() - - if (lower.startsWith("file:")) { - const base = fileBaseName(uri) - return { - refType: "file", - id: base || uri, - label: name || base || uri, - uri, - meta: { fileKind: "file" }, - } - } - - const session = uri.match(SESSION_URI) - if (session) { - const id = session[1] - return { - refType: "session", - id, - label: name || `#${id}`, - uri, - meta: null, - } - } - - const commit = uri.match(COMMIT_URI) - if (commit) { - const hash = commit[1] - const shortHash = hash.slice(0, 7) - return { - refType: "commit", - id: hash, - label: name || shortHash, - uri, - meta: { shortHash }, - } - } - - return null -} +// The reference uri grammar (file:/codeg: → ReferenceAttrs) now lives in +// ./reference-uri, shared with transcript badge rendering. Re-exported here +// under its historical name so existing importers (tests, queue-edit restore) +// keep working. +export { parseReferenceUri } /** Best-effort basename of a `file://` (or any path-shaped) uri. */ function fileBaseName(uri: string): string { diff --git a/src/components/chat/composer/reference-uri.test.ts b/src/components/chat/composer/reference-uri.test.ts new file mode 100644 index 000000000..f11f88f43 --- /dev/null +++ b/src/components/chat/composer/reference-uri.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest" + +import { parseCodegReferenceUri } from "./reference-uri" + +describe("parseCodegReferenceUri", () => { + it("returns null for non-reference schemes", () => { + expect(parseCodegReferenceUri("https://example.com", "x")).toBeNull() + expect(parseCodegReferenceUri("data:text/plain,abc", "x")).toBeNull() + expect(parseCodegReferenceUri("codeg://unknown/1", "x")).toBeNull() + }) + + it("parses a file uri, falling back to the basename when label is empty", () => { + expect( + parseCodegReferenceUri("file:///repo/deep/name.ts", "") + ).toMatchObject({ + refType: "file", + id: "name.ts", + label: "name.ts", + uri: "file:///repo/deep/name.ts", + meta: { fileKind: "file" }, + }) + }) + + it("parses a new-format session uri, recovering the agent type", () => { + expect( + parseCodegReferenceUri("codeg://session/codex_abc123", "My chat") + ).toMatchObject({ + refType: "session", + id: "codex_abc123", + label: "My chat", + uri: "codeg://session/codex_abc123", + meta: { agentType: "codex" }, + }) + }) + + it("never splits an agent type on its first underscore", () => { + // claude_code / open_code / open_claw contain underscores; a naive first-`_` + // split would yield "claude" / "open". The whole `<type>_<external_id>` is + // the id and the full type is recovered by prefix match. + expect( + parseCodegReferenceUri("codeg://session/claude_code_sess-9", "") + ).toMatchObject({ + id: "claude_code_sess-9", + meta: { agentType: "claude_code" }, + }) + expect( + parseCodegReferenceUri("codeg://session/open_code_x", "")?.meta + ).toEqual({ agentType: "open_code" }) + expect( + parseCodegReferenceUri("codeg://session/open_claw_y", "")?.meta + ).toEqual({ agentType: "open_claw" }) + }) + + it("treats a legacy numeric session id as opaque (no agent icon)", () => { + expect( + parseCodegReferenceUri("codeg://session/123", "Login") + ).toMatchObject({ + refType: "session", + id: "123", + label: "Login", + uri: "codeg://session/123", + meta: null, + }) + }) + + it("treats a non-agent-prefixed token as a plain session id", () => { + expect( + parseCodegReferenceUri("codeg://session/randomtoken", "") + ).toMatchObject({ refType: "session", id: "randomtoken", meta: null }) + }) + + it("falls back to #id for an empty session label", () => { + expect(parseCodegReferenceUri("codeg://session/123", "")?.label).toBe( + "#123" + ) + }) + + it("parses a commit uri, deriving the short hash", () => { + expect( + parseCodegReferenceUri("codeg://commit/%2Frepo@abc1234def5678", "abc1234") + ).toMatchObject({ + refType: "commit", + id: "abc1234def5678", + label: "abc1234", + uri: "codeg://commit/%2Frepo@abc1234def5678", + meta: { shortHash: "abc1234" }, + }) + }) +}) diff --git a/src/components/chat/composer/reference-uri.ts b/src/components/chat/composer/reference-uri.ts new file mode 100644 index 000000000..b7d2f634e --- /dev/null +++ b/src/components/chat/composer/reference-uri.ts @@ -0,0 +1,80 @@ +import { ALL_AGENT_TYPES } from "@/lib/types" + +import type { ReferenceAttrs } from "./types" + +// The reference uri grammar, shared by two consumers: editor draft restore +// (from-prompt-blocks.ts) and transcript badge rendering +// (ai-elements/markdown-link.tsx). Mirrors the schemes the adapters emit +// (suggestion/adapters.ts) and the node's allow-list (nodes/reference-node.ts). +const SESSION_URI = /^codeg:\/\/session\/(.+)$/i +const COMMIT_URI = /^codeg:\/\/commit\/.*@(.+)$/i + +/** + * Parse a composer reference uri (`file://` / `codeg://…`) back into + * {@link ReferenceAttrs}, or null when it isn't a recognized reference scheme + * (in which case the caller treats it as a plain link / attachment). + * + * `label` is the human-readable text (a sent resource's name, or a markdown + * link's text); it falls back to the uri basename or `#id` when empty. + */ +export function parseCodegReferenceUri( + uri: string, + label: string +): ReferenceAttrs | null { + const lower = uri.toLowerCase() + + if (lower.startsWith("file:")) { + const base = fileBaseName(uri) + return { + refType: "file", + id: base || uri, + label: label || base || uri, + uri, + meta: { fileKind: "file" }, + } + } + + const session = uri.match(SESSION_URI) + if (session) { + const id = session[1] + // New format is `codeg://session/<agent_type>_<external_id>`. Agent types + // themselves contain underscores (claude_code, open_code, open_claw), so the + // type is recovered by prefix match against the known set — never by + // splitting on the first `_`. A legacy all-numeric id (or any opaque token) + // matches no prefix and degrades to a session badge without an agent icon. + const agentType = ALL_AGENT_TYPES.find((type) => id.startsWith(`${type}_`)) + return { + refType: "session", + id, + label: label || `#${id}`, + uri, + meta: agentType ? { agentType } : null, + } + } + + const commit = uri.match(COMMIT_URI) + if (commit) { + const hash = commit[1] + const shortHash = hash.slice(0, 7) + return { + refType: "commit", + id: hash, + label: label || shortHash, + uri, + meta: { shortHash }, + } + } + + return null +} + +/** Best-effort basename of a `file://` (or any path-shaped) uri. */ +function fileBaseName(uri: string): string { + const path = uri.replace(/^[a-z]+:\/+/i, "") + const last = path.split("/").filter(Boolean).pop() ?? "" + try { + return decodeURIComponent(last) + } catch { + return last + } +} diff --git a/src/components/chat/composer/suggestion/adapters.test.ts b/src/components/chat/composer/suggestion/adapters.test.ts index 9d6cda254..99844fba9 100644 --- a/src/components/chat/composer/suggestion/adapters.test.ts +++ b/src/components/chat/composer/suggestion/adapters.test.ts @@ -83,16 +83,25 @@ describe("sessionToSuggestion", () => { status: "in_progress", git_branch: "main", } as DbConversationSummary - it("maps to a session reference with a codeg uri", () => { - const item = sessionToSuggestion({ ...base, title: "Login refactor" }) + it("encodes <agent_type>_<external_id> in the uri (id stays the numeric id)", () => { + const item = sessionToSuggestion({ + ...base, + title: "Login refactor", + external_id: "abc123", + }) expect(item.reference).toMatchObject({ refType: "session", id: "123", label: "Login refactor", - uri: "codeg://session/123", + uri: "codeg://session/codex_abc123", meta: { agentType: "codex", status: "in_progress", branch: "main" }, }) }) + it("falls back to the numeric id when there is no external_id", () => { + expect(sessionToSuggestion({ ...base, title: "x" }).reference.uri).toBe( + "codeg://session/123" + ) + }) it("falls back to #id when the title is empty", () => { expect(sessionToSuggestion({ ...base, title: null }).reference.label).toBe( "#123" diff --git a/src/components/chat/composer/suggestion/adapters.ts b/src/components/chat/composer/suggestion/adapters.ts index 9f07492f7..dfb6e5c54 100644 --- a/src/components/chat/composer/suggestion/adapters.ts +++ b/src/components/chat/composer/suggestion/adapters.ts @@ -60,17 +60,26 @@ export function agentToSuggestion(agent: AcpAgentInfo): SuggestionItem { } } -/** Conversation → session reference (`codeg://session/<id>`). */ +/** + * Conversation → session reference. The serialization uri encodes the agent's + * own session id as `codeg://session/<agent_type>_<external_id>` (so a transcript + * badge can show the right agent icon and a future codeg-mcp can resolve it by + * `(agent_type, external_id)`); sessions without an `external_id` fall back to + * the internal numeric id. The in-app `id` stays the numeric id either way. + */ export function sessionToSuggestion( conversation: DbConversationSummary ): SuggestionItem { const label = conversation.title?.trim() || `#${conversation.id}` + const uri = conversation.external_id + ? `codeg://session/${conversation.agent_type}_${conversation.external_id}` + : `codeg://session/${conversation.id}` return { reference: { refType: "session", id: String(conversation.id), label, - uri: `codeg://session/${conversation.id}`, + uri, meta: { agentType: conversation.agent_type, status: conversation.status, From 6ab4bd3eb876532c438425a1bd7227ac39f71a61 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Thu, 11 Jun 2026 21:41:21 +0800 Subject: [PATCH 13/31] feat(transcript): render codeg:// reference links as inline badges (P5b) Intercept codeg: hrefs in the Streamdown anchor override and render them with the composer's ReferenceBadge instead of a plain (and previously inert, since link-safety rejects the scheme) underlined link. The shared parseCodegReferenceUri recovers refType/id/meta from the uri and the link text is the label; a new-format session uri yields the agent icon, a legacy numeric one degrades to a generic icon. Unknown codeg:// uris and all other schemes keep their existing link rendering. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../ai-elements/markdown-link.test.tsx | 36 +++++++++++++++++++ src/components/ai-elements/markdown-link.tsx | 24 ++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/components/ai-elements/markdown-link.test.tsx b/src/components/ai-elements/markdown-link.test.tsx index 05ad1085f..522504541 100644 --- a/src/components/ai-elements/markdown-link.test.tsx +++ b/src/components/ai-elements/markdown-link.test.tsx @@ -98,4 +98,40 @@ describe("MarkdownLink", () => { expect(mocks.onLinkCheck).not.toHaveBeenCalled() expect(screen.queryByTestId("link-modal")).not.toBeInTheDocument() }) + + describe("codeg:// reference badges", () => { + it("renders a new-format session link as a session badge (agent icon)", () => { + render( + <MarkdownLink href="codeg://session/codex_abc">My chat</MarkdownLink> + ) + // It's a badge, not a clickable link. + expect(screen.queryByRole("button")).toBeNull() + const badge = screen.getByRole("img", { name: "session: My chat" }) + expect(badge).toHaveAttribute("data-reference-badge") + expect(badge).toHaveAttribute("data-ref-type", "session") + // codex agent type recovered from the uri → an AgentIcon svg renders. + expect(badge.querySelector("svg")).not.toBeNull() + }) + + it("renders a legacy numeric session link as a session badge", () => { + render(<MarkdownLink href="codeg://session/123">Login</MarkdownLink>) + const badge = screen.getByRole("img", { name: "session: Login" }) + expect(badge).toHaveAttribute("data-ref-type", "session") + }) + + it("renders a commit link as a commit badge", () => { + render( + <MarkdownLink href="codeg://commit/%2Frepo@abc1234def"> + abc1234 + </MarkdownLink> + ) + const badge = screen.getByRole("img", { name: "commit: abc1234" }) + expect(badge).toHaveAttribute("data-ref-type", "commit") + }) + + it("leaves a non-reference codeg uri as a normal link", () => { + render(<MarkdownLink href="codeg://unknown/x">x</MarkdownLink>) + expect(screen.getByRole("button")).toBeInTheDocument() + }) + }) }) diff --git a/src/components/ai-elements/markdown-link.tsx b/src/components/ai-elements/markdown-link.tsx index b4bd1613b..b7eeb608f 100644 --- a/src/components/ai-elements/markdown-link.tsx +++ b/src/components/ai-elements/markdown-link.tsx @@ -1,10 +1,12 @@ "use client" -import type { ComponentProps, MouseEvent } from "react" +import type { ComponentProps, MouseEvent, ReactNode } from "react" import { useCallback, useState } from "react" import { FileText, Globe, Mail, Phone, type LucideIcon } from "lucide-react" import type { Components, LinkSafetyModalProps } from "streamdown" +import { ReferenceBadge } from "@/components/chat/composer/badges/reference-badge" +import { parseCodegReferenceUri } from "@/components/chat/composer/reference-uri" import { classifyResourceKind, type ResourceKind } from "@/lib/resource-kind" import { cn } from "@/lib/utils" import { useStreamdownLinkSafety } from "./link-safety" @@ -25,6 +27,17 @@ type MarkdownLinkProps = ComponentProps<"a"> & { node?: unknown } +/** Flatten a markdown link's children to plain text (used as the badge label). */ +function nodeText(children: ReactNode): string { + if (typeof children === "string") return children + if (Array.isArray(children)) { + return children + .map((child) => (typeof child === "string" ? child : "")) + .join("") + } + return "" +} + /** * Anchor override for markdown rendered by `<Streamdown>` (chat messages and * reasoning blocks). It mirrors Streamdown's built-in link element — a @@ -79,6 +92,15 @@ export function MarkdownLink({ ) } + // A codeg:// reference link (session / commit / agent) renders as an inline + // badge, mirroring the composer's reference chips. The same parser the editor + // uses on draft restore recovers refType/id/meta from the uri; the link text + // is the label. + if (!isIncomplete && href.toLowerCase().startsWith("codeg:")) { + const reference = parseCodegReferenceUri(href, nodeText(children)) + if (reference) return <ReferenceBadge data={reference} /> + } + const kind = isIncomplete ? null : classifyResourceKind(href) const Icon = kind ? RESOURCE_KIND_ICON[kind] : null From 9fabb86cca16028b4ad243b2cc4cf6c623a41e07 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Thu, 11 Jun 2026 21:56:23 +0800 Subject: [PATCH 14/31] feat(composer): agent codeg://agent/<type> routing anchor (P5c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give agent mentions a codeg://agent/<agent_type> uri so they behave like session/commit: serialize inline as [@label](codeg://agent/…), render as a transcript badge (via the P5b interceptor), and parse back. The no-uri fallback keeps the prior plain @label form (with @ kept outside inlineText so a URL-like label is still code-spanned). The uri is a frontend anchor only — opaque to the agent, which still reads the @label; real routing is a separate future concern. to-prompt-blocks still lifts only file:// to a resource_link, so agent stays inline; the node scheme allow-list already permits codeg:. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../ai-elements/markdown-link.test.tsx | 7 ++++++ .../chat/composer/reference-text.test.ts | 25 +++++++++++++++++++ .../chat/composer/reference-text.ts | 15 ++++++++--- .../chat/composer/reference-uri.test.ts | 23 +++++++++++++++++ src/components/chat/composer/reference-uri.ts | 17 ++++++++++++- .../chat/composer/suggestion/adapters.test.ts | 4 +-- .../chat/composer/suggestion/adapters.ts | 10 ++++++-- .../chat/composer/to-prompt-blocks.test.ts | 18 +++++++++++++ 8 files changed, 111 insertions(+), 8 deletions(-) diff --git a/src/components/ai-elements/markdown-link.test.tsx b/src/components/ai-elements/markdown-link.test.tsx index 522504541..3ff8fa220 100644 --- a/src/components/ai-elements/markdown-link.test.tsx +++ b/src/components/ai-elements/markdown-link.test.tsx @@ -129,6 +129,13 @@ describe("MarkdownLink", () => { expect(badge).toHaveAttribute("data-ref-type", "commit") }) + it("renders an agent link as an agent badge", () => { + render(<MarkdownLink href="codeg://agent/codex">@Codex</MarkdownLink>) + const badge = screen.getByRole("img", { name: "agent: Codex" }) + expect(badge).toHaveAttribute("data-ref-type", "agent") + expect(badge.querySelector("svg")).not.toBeNull() + }) + it("leaves a non-reference codeg uri as a normal link", () => { render(<MarkdownLink href="codeg://unknown/x">x</MarkdownLink>) expect(screen.getByRole("button")).toBeInTheDocument() diff --git a/src/components/chat/composer/reference-text.test.ts b/src/components/chat/composer/reference-text.test.ts index 3cab33065..ebd3111b8 100644 --- a/src/components/chat/composer/reference-text.test.ts +++ b/src/components/chat/composer/reference-text.test.ts @@ -51,6 +51,19 @@ describe("referenceToMarkdown", () => { ).toBe("@Claude Code") }) + it("renders an agent with a uri as a [@label](codeg://agent/…) link", () => { + expect( + referenceToMarkdown( + ref({ + refType: "agent", + id: "codex", + label: "Codex", + uri: "codeg://agent/codex", + }) + ) + ).toBe("[@Codex](codeg://agent/codex)") + }) + it("renders a skill as a /invocation token from its id", () => { expect( referenceToMarkdown(ref({ refType: "skill", id: "code-review" })) @@ -125,6 +138,18 @@ describe("referenceToMarkdown", () => { ) }) + it("escapes link-breaking chars in a uri-bearing agent label", () => { + expect( + referenceToMarkdown( + ref({ + refType: "agent", + label: "a](http://evil) x", + uri: "codeg://agent/codex", + }) + ) + ).toBe("[@a\\]\\(http://evil\\) x](codeg://agent/codex)") + }) + it("code-spans a URL-like agent label so it cannot autolink", () => { expect( referenceToMarkdown(ref({ refType: "agent", label: "http://evil" })) diff --git a/src/components/chat/composer/reference-text.ts b/src/components/chat/composer/reference-text.ts index 9bdcbf1d1..a4cfd5d25 100644 --- a/src/components/chat/composer/reference-text.ts +++ b/src/components/chat/composer/reference-text.ts @@ -68,7 +68,8 @@ function escapeLinkDestination(uri: string): string { * * References with a URI render as a Markdown link `[label](uri)` — matching how * the backend's `user_blocks_from_prompt` already folds ResourceLinks into - * `[name](uri)`. Agents render as `@label`. Skills render as the `/id` + * `[name](uri)`. Agents render as `[@label](codeg://agent/…)` when they carry a + * routing uri, or as plain `@label` otherwise. Skills render as the `/id` * invocation token (the stable id, never the possibly-localized display label). * Every interpolated label/uri is escaped — and free-standing URL/email-like * text is code-spanned — so a crafted reference cannot inject Markdown @@ -76,8 +77,16 @@ function escapeLinkDestination(uri: string): string { */ export function referenceToMarkdown(attrs: ReferenceAttrs): string { switch (attrs.refType) { - case "agent": - return `@${inlineText(attrs.label || attrs.id)}` + case "agent": { + // With a routing uri → `[@label](uri)` (the `@` lives inside the link + // text, where GFM cannot autolink it). Without one → plain `@label`, with + // the `@` kept OUTSIDE inlineText so a URL-like label is still code-spanned + // rather than the whole `@…` string being treated as autolink-triggering. + const text = collapseNewlines(attrs.label || attrs.id) + return attrs.uri + ? `[@${escapeMarkdownText(text)}](${escapeLinkDestination(attrs.uri)})` + : `@${inlineText(attrs.label || attrs.id)}` + } case "skill": { // Invocation token: the stable id is what the agent executes. The label // (possibly localized / containing spaces) is never used; an empty id is diff --git a/src/components/chat/composer/reference-uri.test.ts b/src/components/chat/composer/reference-uri.test.ts index f11f88f43..b20e6cb69 100644 --- a/src/components/chat/composer/reference-uri.test.ts +++ b/src/components/chat/composer/reference-uri.test.ts @@ -21,6 +21,29 @@ describe("parseCodegReferenceUri", () => { }) }) + it("parses an agent uri, stripping a leading @ from the label", () => { + expect( + parseCodegReferenceUri("codeg://agent/codex", "@Codex") + ).toMatchObject({ + refType: "agent", + id: "codex", + label: "Codex", + uri: "codeg://agent/codex", + meta: { agentType: "codex" }, + }) + }) + + it("falls back to the agent type when the agent label is empty", () => { + expect( + parseCodegReferenceUri("codeg://agent/claude_code", "") + ).toMatchObject({ + refType: "agent", + id: "claude_code", + label: "claude_code", + meta: { agentType: "claude_code" }, + }) + }) + it("parses a new-format session uri, recovering the agent type", () => { expect( parseCodegReferenceUri("codeg://session/codex_abc123", "My chat") diff --git a/src/components/chat/composer/reference-uri.ts b/src/components/chat/composer/reference-uri.ts index b7d2f634e..0b4e12576 100644 --- a/src/components/chat/composer/reference-uri.ts +++ b/src/components/chat/composer/reference-uri.ts @@ -1,4 +1,4 @@ -import { ALL_AGENT_TYPES } from "@/lib/types" +import { ALL_AGENT_TYPES, type AgentType } from "@/lib/types" import type { ReferenceAttrs } from "./types" @@ -6,6 +6,7 @@ import type { ReferenceAttrs } from "./types" // (from-prompt-blocks.ts) and transcript badge rendering // (ai-elements/markdown-link.tsx). Mirrors the schemes the adapters emit // (suggestion/adapters.ts) and the node's allow-list (nodes/reference-node.ts). +const AGENT_URI = /^codeg:\/\/agent\/(.+)$/i const SESSION_URI = /^codeg:\/\/session\/(.+)$/i const COMMIT_URI = /^codeg:\/\/commit\/.*@(.+)$/i @@ -34,6 +35,20 @@ export function parseCodegReferenceUri( } } + const agent = uri.match(AGENT_URI) + if (agent) { + const type = agent[1] + return { + refType: "agent", + // The transcript link text is `@name`; strip a single leading `@` so the + // restored badge reads `name`, matching a live-inserted agent badge. + id: type, + label: (label || type).replace(/^@/, "") || type, + uri, + meta: { agentType: type as AgentType }, + } + } + const session = uri.match(SESSION_URI) if (session) { const id = session[1] diff --git a/src/components/chat/composer/suggestion/adapters.test.ts b/src/components/chat/composer/suggestion/adapters.test.ts index 99844fba9..94ec2bbfd 100644 --- a/src/components/chat/composer/suggestion/adapters.test.ts +++ b/src/components/chat/composer/suggestion/adapters.test.ts @@ -58,7 +58,7 @@ describe("fileToSuggestion", () => { }) describe("agentToSuggestion", () => { - it("maps to an agent reference with no uri", () => { + it("maps to an agent reference with a codeg://agent routing uri", () => { const agent = { agent_type: "claude_code", name: "Claude Code", @@ -70,7 +70,7 @@ describe("agentToSuggestion", () => { refType: "agent", id: "claude_code", label: "Claude Code", - uri: null, + uri: "codeg://agent/claude_code", meta: { agentType: "claude_code", available: true }, }) }) diff --git a/src/components/chat/composer/suggestion/adapters.ts b/src/components/chat/composer/suggestion/adapters.ts index dfb6e5c54..68a8c0b26 100644 --- a/src/components/chat/composer/suggestion/adapters.ts +++ b/src/components/chat/composer/suggestion/adapters.ts @@ -45,14 +45,20 @@ export function fileToSuggestion( } } -/** ACP agent → agent reference (no uri; serializes to `@label`). */ +/** + * ACP agent → agent reference. Carries a `codeg://agent/<agent_type>` uri as a + * routing anchor: it serializes inline as `[@label](codeg://agent/…)` and + * renders as a badge in the transcript. The uri is opaque to the agent (the + * readable `@label` carries the meaning); resolving it to real routing is a + * future, separate concern. + */ export function agentToSuggestion(agent: AcpAgentInfo): SuggestionItem { return { reference: { refType: "agent", id: agent.agent_type, label: agent.name || AGENT_LABELS[agent.agent_type], - uri: null, + uri: `codeg://agent/${agent.agent_type}`, meta: { agentType: agent.agent_type, available: agent.available }, }, detail: agent.description || null, diff --git a/src/components/chat/composer/to-prompt-blocks.test.ts b/src/components/chat/composer/to-prompt-blocks.test.ts index ed10baab1..260c55088 100644 --- a/src/components/chat/composer/to-prompt-blocks.test.ts +++ b/src/components/chat/composer/to-prompt-blocks.test.ts @@ -62,6 +62,24 @@ describe("docToPromptBlocks", () => { expect(textBlock(blocks)).toContain("@Codex") }) + it("keeps an agent reference with a codeg uri inline as a markdown link", () => { + editor + .chain() + .insertContent("ask ") + .insertReference( + ref({ + refType: "agent", + id: "codex", + label: "Codex", + uri: "codeg://agent/codex", + }) + ) + .run() + const blocks = docToPromptBlocks(editor) + expect(links(blocks)).toHaveLength(0) + expect(textBlock(blocks)).toContain("[@Codex](codeg://agent/codex)") + }) + it("keeps a skill reference inline as the /id token", () => { editor.commands.insertReference( ref({ refType: "skill", id: "code-review", label: "Code Review" }) From 1e152defd8b5867d5bb8cd1049a536278cdf5fff Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Thu, 11 Jun 2026 22:32:14 +0800 Subject: [PATCH 15/31] feat(composer): tabbed @ mention panel (agent-first, five fixed tabs) (P5d) Replace the single scrolling grouped list with a tabbed panel: one tab per reference kind in [agent, file, session, commit, skill] order, only the active tab's group shown, per-tab result counts. The active tab auto-follows the first non-empty tab (agent-first) until the user pins one via Tab/Shift+Tab or click, so a file/session query never strands the user on an empty agent tab; the panel remounts per @ session so the pin never leaks. Tab/Shift+Tab switch tabs (Enter still selects); the tab strip never takes DOM focus (tabIndex=-1 + mousedown preventDefault, switch on click for AT) so the editor keeps focus and drives the listbox via a tab-namespaced aria-activedescendant. Option ids are namespaced by kind; the listbox still owns only options; P4a positioning and P4c truncation are preserved. tabLabels are threaded from message-input's localized group labels. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../composer/rich-composer-mention.test.tsx | 6 +- .../chat/composer/rich-composer.tsx | 13 +- .../suggestion/suggestion-popup.test.tsx | 154 +++++--- .../composer/suggestion/suggestion-popup.tsx | 346 +++++++++++------- src/components/chat/message-input.tsx | 1 + 5 files changed, 345 insertions(+), 175 deletions(-) diff --git a/src/components/chat/composer/rich-composer-mention.test.tsx b/src/components/chat/composer/rich-composer-mention.test.tsx index 0021d70ab..0697f60ac 100644 --- a/src/components/chat/composer/rich-composer-mention.test.tsx +++ b/src/components/chat/composer/rich-composer-mention.test.tsx @@ -86,7 +86,11 @@ describe("RichComposer @ mention integration", () => { await waitFor(() => { expect(dom.getAttribute("aria-controls")).toBe("mention-listbox") expect(dom.getAttribute("aria-autocomplete")).toBe("list") - expect(dom.getAttribute("aria-activedescendant")).toBe("mention-option-0") + // The search returns only a file group, so the panel auto-targets the + // file tab; option ids are namespaced by tab kind. + expect(dom.getAttribute("aria-activedescendant")).toBe( + "mention-option-file-0" + ) }) act(() => { dom.dispatchEvent( diff --git a/src/components/chat/composer/rich-composer.tsx b/src/components/chat/composer/rich-composer.tsx index 01abbc5fc..f5def7662 100644 --- a/src/components/chat/composer/rich-composer.tsx +++ b/src/components/chat/composer/rich-composer.tsx @@ -31,7 +31,7 @@ import type { ReferenceSearch, SuggestionPopupHandle, } from "./suggestion/types" -import type { ReferenceAttrs } from "./types" +import type { ReferenceAttrs, ReferenceKind } from "./types" /** * Imperative handle exposed to the parent (e.g. the message input that owns @@ -111,6 +111,11 @@ export interface RichComposerProps { * omitted. Render-only — safe to pass a fresh object per render. */ mentionUiLabels?: MentionUiLabels + /** + * Localized per-kind tab labels for the `@` panel (Agents/Files/Sessions/ + * Commits/Skills). English fallbacks apply when omitted. Render-only. + */ + tabLabels?: Record<ReferenceKind, string> /** * Key binding (matchShortcutEvent form) that sends the message. Default * `"enter"`. When set to a non-Enter binding, a plain Enter inserts a newline. @@ -163,6 +168,7 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>( onReady, referenceSearch, mentionUiLabels, + tabLabels, submitShortcut, newlineShortcut, isExternalMenuOpen, @@ -462,6 +468,10 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>( /> {referenceSearch && mentionState && ( <SuggestionPopup + // Remount per `@` session so panel state (active/pinned tab, + // selection) never leaks when one suggestion exits and another + // starts in the same React update (onExit + onStart batched). + key={mentionState.range.from} ref={popupRef} state={mentionState} search={referenceSearch} @@ -473,6 +483,7 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>( listboxLabel={mentionUiLabels?.listbox} moreLabel={mentionUiLabels?.more} countLabel={mentionUiLabels?.count} + tabLabels={tabLabels} /> )} </div> diff --git a/src/components/chat/composer/suggestion/suggestion-popup.test.tsx b/src/components/chat/composer/suggestion/suggestion-popup.test.tsx index a9eb0e3f7..ad9bb90df 100644 --- a/src/components/chat/composer/suggestion/suggestion-popup.test.tsx +++ b/src/components/chat/composer/suggestion/suggestion-popup.test.tsx @@ -1,4 +1,4 @@ -import { act, render, screen, within } from "@testing-library/react" +import { act, fireEvent, render, screen, within } from "@testing-library/react" import { createRef } from "react" import { afterEach, describe, expect, it, vi } from "vitest" @@ -22,17 +22,31 @@ const agentRef = { refType: "agent" as const, id: "codex", label: "Codex Helper", - uri: null, + uri: "codeg://agent/codex", meta: { agentType: "codex" as const }, } +const agentRef2 = { + refType: "agent" as const, + id: "claude_code", + // Label must differ from the AgentIcon's <title> ("Claude Code") so a plain + // text query is unambiguous (the title text is in the DOM even when decorative). + label: "Claude Helper", + uri: "codeg://agent/claude_code", + meta: { agentType: "claude_code" as const }, +} +// The provider keeps file-first order; the panel reorders to agent-first tabs. const groups: SuggestionGroup[] = [ { kind: "file", label: "Files", items: [{ reference: fileRef, detail: "docs/alpha.md" }], }, - { kind: "agent", label: "Agents", items: [{ reference: agentRef }] }, + { + kind: "agent", + label: "Agents", + items: [{ reference: agentRef }, { reference: agentRef2 }], + }, ] const search: ReferenceSearch = () => groups @@ -63,8 +77,8 @@ function mountPopup( return { ref, onSelect, onClose } } -function key(name: string): KeyboardEvent { - return { key: name } as KeyboardEvent +function key(name: string, shiftKey = false): KeyboardEvent { + return { key: name, shiftKey } as KeyboardEvent } describe("SuggestionPopup", () => { @@ -72,36 +86,42 @@ describe("SuggestionPopup", () => { vi.restoreAllMocks() }) - it("renders grouped results from the search provider", async () => { + it("renders the active (agent-first) tab's options plus a five-tab strip", async () => { mountPopup() - expect(await screen.findByText("alpha.md")).toBeInTheDocument() - expect(screen.getByText("Files")).toBeInTheDocument() - expect(screen.getByText("Agents")).toBeInTheDocument() - expect(screen.getByText("Codex Helper")).toBeInTheDocument() + // Agent is the first non-empty tab, so its options show by default. + expect(await screen.findByText("Codex Helper")).toBeInTheDocument() + expect(screen.getByText("Claude Helper")).toBeInTheDocument() + // The file tab's option is hidden until that tab is active. + expect(screen.queryByText("alpha.md")).toBeNull() + // Five fixed tabs, agent selected. + expect(screen.getAllByRole("tab")).toHaveLength(5) + expect(screen.getByRole("tab", { selected: true })).toHaveAccessibleName( + /Agents/ + ) }) - it("shows an empty state when there are no matches", async () => { + it("shows an empty state (but keeps the tabs) when there are no matches", async () => { mountPopup({ search: emptySearch, emptyLabel: "Nothing" }) - // Scope to the listbox: the sr-only live region carries the same text. const panel = screen.getByTestId("mention-popup") expect(await within(panel).findByText("Nothing")).toBeInTheDocument() + expect(screen.getAllByRole("tab")).toHaveLength(5) }) - it("selects the highlighted row on Enter (default = first)", async () => { + it("selects the active tab's highlighted row on Enter (default = first agent)", async () => { const { ref, onSelect } = mountPopup() - await screen.findByText("alpha.md") + await screen.findByText("Codex Helper") act(() => { expect(ref.current?.onKeyDown(key("Enter"))).toBe(true) }) - expect(onSelect).toHaveBeenCalledWith(fileRef, state.range) + expect(onSelect).toHaveBeenCalledWith(agentRef, state.range) }) - it("moves the selection with ArrowDown before selecting", async () => { + it("moves the selection with ArrowDown within the active tab", async () => { const { ref, onSelect } = mountPopup() await screen.findByText("Codex Helper") act(() => ref.current?.onKeyDown(key("ArrowDown"))) act(() => ref.current?.onKeyDown(key("Enter"))) - expect(onSelect).toHaveBeenCalledWith(agentRef, state.range) + expect(onSelect).toHaveBeenCalledWith(agentRef2, state.range) }) it("wraps the selection with ArrowUp from the first row", async () => { @@ -109,12 +129,59 @@ describe("SuggestionPopup", () => { await screen.findByText("Codex Helper") act(() => ref.current?.onKeyDown(key("ArrowUp"))) act(() => ref.current?.onKeyDown(key("Enter"))) - expect(onSelect).toHaveBeenCalledWith(agentRef, state.range) + expect(onSelect).toHaveBeenCalledWith(agentRef2, state.range) + }) + + it("switches to the next tab with Tab and reveals its options", async () => { + const { ref, onSelect } = mountPopup() + await screen.findByText("Codex Helper") + act(() => { + expect(ref.current?.onKeyDown(key("Tab"))).toBe(true) + }) + // agent → file; the file option appears and the agent options are gone. + expect(await screen.findByText("alpha.md")).toBeInTheDocument() + expect(screen.queryByText("Codex Helper")).toBeNull() + expect(screen.getByRole("tab", { selected: true })).toHaveAccessibleName( + /Files/ + ) + // Tab does not select. + expect(onSelect).not.toHaveBeenCalled() + }) + + it("wraps to the last tab with Shift+Tab", async () => { + const { ref } = mountPopup() + await screen.findByText("Codex Helper") + act(() => ref.current?.onKeyDown(key("Tab", true))) + // agent (first) wraps backwards to skill (last in tab order); it's empty. + expect(screen.getByRole("tab", { selected: true })).toHaveAccessibleName( + /Skills/ + ) + }) + + it("switches tabs on click, preventing default on mousedown to keep editor focus", async () => { + mountPopup() + await screen.findByText("Codex Helper") + const filesTab = screen.getByRole("tab", { name: /Files/ }) + // mousedown preventDefault keeps focus in the editor (no blur)... + const down = new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + }) + act(() => { + filesTab.dispatchEvent(down) + }) + expect(down.defaultPrevented).toBe(true) + // ...and the click performs the switch (so AT / synthetic click works too). + act(() => { + fireEvent.click(filesTab) + }) + expect(await screen.findByText("alpha.md")).toBeInTheDocument() + expect(screen.queryByText("Codex Helper")).toBeNull() }) it("closes on Escape and reports the key as consumed", async () => { const { ref, onClose } = mountPopup() - await screen.findByText("alpha.md") + await screen.findByText("Codex Helper") let consumed = false act(() => { consumed = ref.current?.onKeyDown(key("Escape")) ?? false @@ -125,7 +192,7 @@ describe("SuggestionPopup", () => { it("does not consume unrelated keys", async () => { const { ref } = mountPopup() - await screen.findByText("alpha.md") + await screen.findByText("Codex Helper") expect(ref.current?.onKeyDown(key("x"))).toBe(false) }) @@ -143,11 +210,11 @@ describe("SuggestionPopup", () => { /> ) const { rerender } = render(view("a", 2)) - await screen.findByText("alpha.md") // fresh results for "a" + await screen.findByText("Codex Helper") // fresh results for "a" // Query advances; the shown results now answer the *previous* query. rerender(view("ab", 3)) - expect(screen.queryByText("alpha.md")).toBeNull() + expect(screen.queryByText("Codex Helper")).toBeNull() expect( within(screen.getByTestId("mention-popup")).getByText("Loading") ).toBeInTheDocument() @@ -158,7 +225,7 @@ describe("SuggestionPopup", () => { it("selects on click (mousedown) and prevents default to keep editor focus", async () => { const { onSelect } = mountPopup() - const label = await screen.findByText("alpha.md") + const label = await screen.findByText("Codex Helper") const button = label.closest("button") expect(button).not.toBeNull() const event = new MouseEvent("mousedown", { @@ -168,7 +235,7 @@ describe("SuggestionPopup", () => { act(() => { button?.dispatchEvent(event) }) - expect(onSelect).toHaveBeenCalledWith(fileRef, state.range) + expect(onSelect).toHaveBeenCalledWith(agentRef, state.range) // preventDefault keeps focus in the editor rather than the popup button. expect(event.defaultPrevented).toBe(true) }) @@ -188,7 +255,7 @@ describe("SuggestionPopup", () => { onClose={vi.fn()} /> ) - await screen.findByText("alpha.md") + await screen.findByText("Codex Helper") const container = screen.getByTestId("mention-popup") .parentElement as HTMLElement // The layout effect measured the panel and clamped/flipped it into view. @@ -218,7 +285,7 @@ describe("SuggestionPopup", () => { onClose={vi.fn()} /> ) - await screen.findByText("alpha.md") + await screen.findByText("Codex Helper") const container = screen.getByTestId("mention-popup") .parentElement as HTMLElement // left clamps to 1024 - 320 - 8 = 696 (not the raw caret x of 1000). @@ -246,7 +313,7 @@ describe("SuggestionPopup", () => { onClose={vi.fn()} /> ) - await screen.findByText("alpha.md") + await screen.findByText("Codex Helper") const container = screen.getByTestId("mention-popup") .parentElement as HTMLElement expect(container.style.left).toBe("100px") @@ -262,15 +329,16 @@ describe("SuggestionPopup", () => { it("exposes listbox + option roles with the active option selected", async () => { mountPopup({ listboxLabel: "Mentions" }) - await screen.findByText("alpha.md") - // The listbox is a child of the (testid) panel and owns only options. - const listbox = screen.getByRole("listbox", { name: "Mentions" }) + await screen.findByText("Codex Helper") + // The listbox names the active tab and owns only that tab's options. + const listbox = screen.getByRole("listbox", { name: "Mentions: Agents" }) expect(listbox).toHaveAttribute("id", "mention-listbox") const options = within(listbox).getAllByRole("option") expect(options).toHaveLength(2) expect(options[0]).toHaveAttribute("aria-selected", "true") - expect(options[0]).toHaveAttribute("id", "mention-option-0") + expect(options[0]).toHaveAttribute("id", "mention-option-agent-0") expect(options[1]).toHaveAttribute("aria-selected", "false") + expect(options[1]).toHaveAttribute("id", "mention-option-agent-1") }) it("keeps the decorative icon out of the option's accessible name", async () => { @@ -285,7 +353,7 @@ describe("SuggestionPopup", () => { it("moves aria-selected with the keyboard", async () => { const { ref } = mountPopup() - await screen.findByText("alpha.md") + await screen.findByText("Codex Helper") act(() => ref.current?.onKeyDown(key("ArrowDown"))) const options = screen .getByTestId("mention-popup") @@ -294,19 +362,21 @@ describe("SuggestionPopup", () => { expect(options[1]).toHaveAttribute("aria-selected", "true") }) - it("announces the result count via a polite live region", async () => { + it("announces the active tab + result count via a polite live region", async () => { mountPopup() - await screen.findByText("alpha.md") + await screen.findByText("Codex Helper") const status = screen.getByRole("status") expect(status).toHaveAttribute("aria-live", "polite") - expect(status).toHaveTextContent("2 results") + expect(status).toHaveTextContent("Agents: 2 results") }) it("reports the active option id to the host for aria-activedescendant", async () => { const onActiveOptionChange = vi.fn() mountPopup({ onActiveOptionChange }) - await screen.findByText("alpha.md") - expect(onActiveOptionChange).toHaveBeenLastCalledWith("mention-option-0") + await screen.findByText("Codex Helper") + expect(onActiveOptionChange).toHaveBeenLastCalledWith( + "mention-option-agent-0" + ) }) it("reports a null active option while loading or empty", async () => { @@ -321,17 +391,17 @@ describe("SuggestionPopup", () => { expect(onActiveOptionChange).toHaveBeenLastCalledWith(null) }) - it("shows a non-selectable, aria-hidden hint for a truncated group", async () => { + it("shows a non-selectable, aria-hidden hint for a truncated active tab", async () => { const truncatedSearch: ReferenceSearch = () => [ { - kind: "file", - label: "Files", - items: [{ reference: fileRef, detail: "docs/alpha.md" }], + kind: "agent", + label: "Agents", + items: [{ reference: agentRef }], truncated: true, }, ] mountPopup({ search: truncatedSearch, moreLabel: "More — keep typing" }) - await screen.findByText("alpha.md") + await screen.findByText("Codex Helper") const panel = screen.getByTestId("mention-popup") const hint = within(panel).getByText("More — keep typing") // Decorative: hidden from AT (the live region announces truncation) and not diff --git a/src/components/chat/composer/suggestion/suggestion-popup.tsx b/src/components/chat/composer/suggestion/suggestion-popup.tsx index 4c28ab020..f9bc2cbfa 100644 --- a/src/components/chat/composer/suggestion/suggestion-popup.tsx +++ b/src/components/chat/composer/suggestion/suggestion-popup.tsx @@ -14,7 +14,7 @@ import { createPortal } from "react-dom" import { cn } from "@/lib/utils" import { ReferenceIcon } from "../badges/reference-badge" -import type { ReferenceAttrs } from "../types" +import type { ReferenceAttrs, ReferenceKind } from "../types" import type { MentionRenderState } from "./mention-suggestion" import { placeMentionPopup } from "./popup-position" import type { @@ -25,6 +25,26 @@ import type { const FETCH_DEBOUNCE_MS = 150 +// Tab order in the panel: agent first (per product decision), then the rest in +// their usual order. This is a *display* order; the search provider keeps its +// own (file-first) group order, which other code/tests depend on. +const TAB_ORDER: readonly ReferenceKind[] = [ + "agent", + "file", + "session", + "commit", + "skill", +] + +// English fallbacks for the tab labels; the host injects localized ones. +const DEFAULT_TAB_LABELS: Record<ReferenceKind, string> = { + agent: "Agents", + file: "Files", + session: "Sessions", + commit: "Commits", + skill: "Skills", +} + // Commit-synchronous in the browser so the panel is positioned before paint (no // flash at a stale spot); a no-op-safe passive effect during the static-export // prerender where `useLayoutEffect` would warn. @@ -35,11 +55,13 @@ const useIsomorphicLayoutEffect = * `id` of the listbox element and of each option. The editor's contentEditable * (which keeps DOM focus) points `aria-controls` at the listbox and * `aria-activedescendant` at the active option, the standard combobox pattern - * for a popup that doesn't take focus. Only one panel is open at a time (the - * focused editor's), so fixed ids never collide. + * for a popup that doesn't take focus. Option ids are namespaced by tab so the + * id always resolves to a currently-mounted element (only the active tab's + * options are rendered). Only one panel is open at a time, so ids never collide. */ export const MENTION_LISTBOX_ID = "mention-listbox" -export const mentionOptionId = (index: number) => `mention-option-${index}` +export const mentionOptionId = (kind: ReferenceKind, index: number) => + `mention-option-${kind}-${index}` export interface SuggestionPopupProps { /** Live trigger state (query/range/caret rect). */ @@ -55,12 +77,14 @@ export interface SuggestionPopupProps { onClose: () => void emptyLabel?: string loadingLabel?: string - /** Accessible name for the listbox. */ + /** Accessible name for the listbox / tablist. */ listboxLabel?: string /** Builds the live-region result count announcement. */ countLabel?: (count: number) => string - /** Non-selectable hint shown under a group whose matches were capped. */ + /** Non-selectable hint shown under a tab whose matches were capped. */ moreLabel?: string + /** Localized per-kind tab labels (English fallbacks apply when omitted). */ + tabLabels?: Record<ReferenceKind, string> /** * Reports the active option's element id (or null when nothing is * selectable), so the host can mirror it onto the editor's @@ -69,16 +93,13 @@ export interface SuggestionPopupProps { onActiveOptionChange?: (optionId: string | null) => void } -interface FlatRow { - item: SuggestionGroup["items"][number] - groupIndex: number -} - /** - * The unified `@` panel: grouped, keyboard-navigable suggestions positioned at - * the caret. Keys are forwarded from the suggestion plugin via the imperative - * handle (the editor keeps DOM focus), so selection is tracked manually rather - * than relying on focus-based libraries. + * The unified `@` panel: tabbed, keyboard-navigable suggestions positioned at + * the caret. One tab per reference kind (agent first); only the active tab's + * group is shown. Keys are forwarded from the suggestion plugin via the + * imperative handle (the editor keeps DOM focus), so selection and the active + * tab are tracked manually rather than relying on focus-based libraries — the + * tab strip never takes focus (`tabIndex={-1}` + mousedown `preventDefault`). */ export const SuggestionPopup = forwardRef< SuggestionPopupHandle, @@ -94,6 +115,7 @@ export const SuggestionPopup = forwardRef< listboxLabel = "Mentions", countLabel = (count) => `${count} results`, moreLabel = "More results — keep typing to filter", + tabLabels = DEFAULT_TAB_LABELS, onActiveOptionChange, }, ref @@ -109,6 +131,10 @@ export const SuggestionPopup = forwardRef< groups: SuggestionGroup[] }>({ query: null, groups: [] }) const [selectedIndex, setSelectedIndex] = useState(0) + // The tab the user explicitly chose (via Tab/click), or null to auto-follow + // the first non-empty tab. Pinning survives subsequent keystrokes within this + // open session; reopening the panel remounts and resets it to null. + const [pinnedTab, setPinnedTab] = useState<ReferenceKind | null>(null) const [pos, setPos] = useState<{ left: number top: number @@ -142,41 +168,57 @@ export const SuggestionPopup = forwardRef< } }, [state.query, search]) - // Only fresh results are selectable; selection resets to 0 on each fetch. - const flat = useMemo<FlatRow[]>( + const groupByKind = useMemo( + () => new Map(result.groups.map((group) => [group.kind, group])), + [result.groups] + ) + // Auto-target the first non-empty tab (agent-first) until the user pins one, + // so a file/session/… query never strands the user on an empty agent tab. + const firstNonEmpty = useMemo( () => - stale - ? [] - : result.groups.flatMap((group, groupIndex) => - group.items.map((item) => ({ item, groupIndex })) - ), - [stale, result.groups] + TAB_ORDER.find( + (kind) => (groupByKind.get(kind)?.items.length ?? 0) > 0 + ) ?? TAB_ORDER[0], + [groupByKind] + ) + const activeTab = pinnedTab ?? firstNonEmpty + const activeGroup = useMemo( + () => (stale ? null : (groupByKind.get(activeTab) ?? null)), + [stale, groupByKind, activeTab] + ) + // Only the active tab's fresh items are selectable; selection resets to 0 on + // each fetch and on every tab switch. + const flat = useMemo( + () => (stale || !activeGroup ? [] : activeGroup.items), + [stale, activeGroup] ) - // Scroll the active row into view. + // Scroll the active option into view (scoped to options so it never targets + // the active tab button, which also carries an active marker via class only). useEffect(() => { listRef.current - ?.querySelector('[data-active="true"]') + ?.querySelector('[role="option"][data-active="true"]') ?.scrollIntoView({ block: "nearest" }) - }, [selectedIndex]) + }, [selectedIndex, activeTab]) // Mirror the active option's id to the host (→ editor `aria-activedescendant`). - // Null while nothing is selectable (loading / no matches), so the editor never - // points at a stale or absent option. + // Null while nothing is selectable (loading / no matches in the active tab). useEffect(() => { onActiveOptionChange?.( - stale || flat.length === 0 ? null : mentionOptionId(selectedIndex) + stale || flat.length === 0 + ? null + : mentionOptionId(activeTab, selectedIndex) ) - }, [selectedIndex, flat.length, stale, onActiveOptionChange]) + }, [activeTab, selectedIndex, flat.length, stale, onActiveOptionChange]) // Position the caret-anchored panel within the viewport. Measure the rendered // panel (a `visibility:hidden` box still has layout), read the *live* caret // rect, then clamp/flip via the pure helper. A layout effect runs before // paint, so the panel never flashes at a wrong spot. `state` is a fresh object - // each keystroke and the height tracks `stale`/`flat.length`, so this - // re-anchors as the caret moves and results load; resize + capture-phase - // scroll listeners re-anchor on window resize, editor scroll, or page scroll - // while the panel is open (the caret getter returns fresh coords each call). + // each keystroke and the height tracks `stale`/`flat.length`/`activeTab`, so + // this re-anchors as the caret moves, results load, and tabs switch; resize + + // capture-phase scroll listeners re-anchor on window resize, editor scroll, or + // page scroll while the panel is open. useIsomorphicLayoutEffect(() => { if (typeof window === "undefined") return const reposition = () => { @@ -201,7 +243,7 @@ export const SuggestionPopup = forwardRef< window.removeEventListener("resize", reposition) window.removeEventListener("scroll", reposition, true) } - }, [state, stale, flat.length]) + }, [state, stale, flat.length, activeTab]) useImperativeHandle( ref, @@ -220,12 +262,22 @@ export const SuggestionPopup = forwardRef< ) } return true - case "Enter": case "Tab": { + // Tab / Shift+Tab move between tabs (pinning the choice); Enter still + // selects. Wraps around the five tabs. + const dir = event.shiftKey ? -1 : 1 + const at = TAB_ORDER.indexOf(activeTab) + setPinnedTab( + TAB_ORDER[(at + dir + TAB_ORDER.length) % TAB_ORDER.length] + ) + setSelectedIndex(0) + return true + } + case "Enter": { const chosen = flat[selectedIndex] - if (chosen) onSelect(chosen.item.reference, state.range) - // No fresh row (still loading, or no matches): consume the key - // without inserting or submitting. Escape dismisses the panel. + if (chosen) onSelect(chosen.reference, state.range) + // No fresh row (still loading, or empty tab): consume without + // inserting or submitting. Escape dismisses the panel. return true } case "Escape": @@ -236,18 +288,18 @@ export const SuggestionPopup = forwardRef< } }, }), - [flat, selectedIndex, onSelect, onClose, state.range] + [flat, selectedIndex, activeTab, onSelect, onClose, state.range] ) - const anyTruncated = !stale && result.groups.some((group) => group.truncated) + const activeLabel = tabLabels[activeTab] + const truncated = !stale && activeGroup?.truncated === true const liveStatus = stale ? loadingLabel : flat.length === 0 - ? emptyLabel - : anyTruncated - ? `${countLabel(flat.length)} ${moreLabel}` - : countLabel(flat.length) - let rowIndex = -1 + ? `${activeLabel}: ${emptyLabel}` + : truncated + ? `${activeLabel}: ${countLabel(flat.length)} ${moreLabel}` + : `${activeLabel}: ${countLabel(flat.length)}` return createPortal( <div @@ -265,95 +317,127 @@ export const SuggestionPopup = forwardRef< ref={listRef} data-testid="mention-popup" // Cap to the viewport (minus the 8px×2 edge margin = 1rem) so the panel - // can always fit on small windows and scroll internally rather than - // overflowing — the positioner clamps placement, this bounds the size. - className="max-h-[min(18rem,calc(100dvh_-_1rem))] w-80 max-w-[calc(100vw_-_1rem)] overflow-y-auto rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-lg" + // always fits on small windows; the tab strip stays pinned and only the + // option list scrolls. The positioner clamps placement, this bounds size. + className="flex max-h-[min(18rem,calc(100dvh_-_1rem))] w-80 max-w-[calc(100vw_-_1rem)] flex-col overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-lg" > - {/* Status text lives *outside* the listbox: a listbox may only own - options/groups. (The sr-only live region below announces it to AT.) */} - {stale ? ( - <div className="px-2 py-3 text-sm text-muted-foreground"> - {loadingLabel} - </div> - ) : flat.length === 0 ? ( - <div className="px-2 py-3 text-sm text-muted-foreground"> - {emptyLabel} - </div> - ) : null} - {/* Always rendered (even empty) so the editor's `aria-controls` target - always resolves; holds only option/group children. */} - <div id={MENTION_LISTBOX_ID} role="listbox" aria-label={listboxLabel}> - {!stale && - result.groups.map((group) => - group.items.length === 0 ? null : ( - <div - key={group.kind} - role="group" - aria-label={group.label} - className="py-0.5" - > - <div - aria-hidden - className="px-2 py-1 text-xs font-medium text-muted-foreground" + {/* Tab strip: pointer-/key-driven only (tabIndex=-1 keeps editor focus). + Each tab controls the single listbox below (no role=tabpanel, which + cannot legally wrap a listbox). */} + <div + role="tablist" + aria-label={listboxLabel} + aria-orientation="horizontal" + className="flex shrink-0 gap-0.5 overflow-x-auto border-b border-border p-1" + > + {TAB_ORDER.map((kind) => { + const isActive = kind === activeTab + const count = stale ? 0 : (groupByKind.get(kind)?.items.length ?? 0) + return ( + <button + key={kind} + type="button" + role="tab" + tabIndex={-1} + aria-selected={isActive} + aria-controls={MENTION_LISTBOX_ID} + // mousedown only prevents the focus shift (keeps the editor + // focused so aria-activedescendant stays valid); the switch runs + // on click so AT / synthetic activation (which fires click, not + // mousedown) works too. + onMouseDown={(event) => event.preventDefault()} + onClick={() => { + setPinnedTab(kind) + setSelectedIndex(0) + }} + className={cn( + "flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs font-medium", + isActive + ? "bg-accent text-accent-foreground" + : "text-muted-foreground hover:bg-accent/50" + )} + > + <span>{tabLabels[kind]}</span> + {!stale && count > 0 && ( + <span className="rounded bg-muted px-1 text-[0.7rem] tabular-nums text-muted-foreground"> + {count} + </span> + )} + </button> + ) + })} + </div> + <div className="min-h-0 flex-1 overflow-y-auto p-1"> + {/* Status text lives *outside* the listbox: a listbox may only own + options. (The sr-only live region below announces it to AT.) */} + {stale ? ( + <div className="px-2 py-3 text-sm text-muted-foreground"> + {loadingLabel} + </div> + ) : flat.length === 0 ? ( + <div className="px-2 py-3 text-sm text-muted-foreground"> + {emptyLabel} + </div> + ) : null} + {/* Always rendered (even empty) so the editor's `aria-controls` target + always resolves; holds only option children for the active tab. */} + <div + id={MENTION_LISTBOX_ID} + role="listbox" + aria-label={`${listboxLabel}: ${activeLabel}`} + > + {!stale && + activeGroup?.items.map((item, index) => { + const active = index === selectedIndex + return ( + <button + key={`${activeGroup.kind}:${item.reference.id}`} + type="button" + id={mentionOptionId(activeGroup.kind, index)} + role="option" + aria-selected={active} + data-active={active} + className={cn( + "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm", + active + ? "bg-accent text-accent-foreground" + : "hover:bg-accent/50" + )} + onMouseDown={(event) => { + // Keep editor focus; insert on click. + event.preventDefault() + onSelect(item.reference, state.range) + }} + onMouseEnter={() => setSelectedIndex(index)} > - {group.label} - </div> - {group.items.map((item) => { - rowIndex += 1 - const active = rowIndex === selectedIndex - const index = rowIndex - return ( - <button - key={`${group.kind}:${item.reference.id}`} - type="button" - id={mentionOptionId(index)} - role="option" - aria-selected={active} - data-active={active} - className={cn( - "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm", - active - ? "bg-accent text-accent-foreground" - : "hover:bg-accent/50" - )} - onMouseDown={(event) => { - // Keep editor focus; insert on click. - event.preventDefault() - onSelect(item.reference, state.range) - }} - onMouseEnter={() => setSelectedIndex(index)} - > - <ReferenceIcon data={item.reference} /> - <span className="flex-1 truncate"> - {item.reference.label || item.reference.id} - </span> - {item.detail && ( - <span className="max-w-[10rem] truncate text-xs text-muted-foreground"> - {item.detail} - </span> - )} - </button> - ) - })} - {group.truncated && ( - // aria-hidden: a visual "refine" affordance, not an option — - // keeps the listbox owning only options (the live region - // conveys truncation to AT). Never enters `flat`, so Enter - // can't select it. - <div - aria-hidden - className="px-2 py-1 text-xs italic text-muted-foreground" - > - {moreLabel} - </div> - )} - </div> - ) - )} + <ReferenceIcon data={item.reference} /> + <span className="flex-1 truncate"> + {item.reference.label || item.reference.id} + </span> + {item.detail && ( + <span className="max-w-[10rem] truncate text-xs text-muted-foreground"> + {item.detail} + </span> + )} + </button> + ) + })} + </div> + {truncated && ( + // aria-hidden: a visual "refine" affordance, not an option — keeps + // the listbox owning only options (the live region conveys + // truncation to AT). Never enters `flat`, so Enter can't select it. + <div + aria-hidden + className="px-2 py-1 text-xs italic text-muted-foreground" + > + {moreLabel} + </div> + )} </div> </div> - {/* Announce loading / result count / empty state to screen readers; the - listbox keeps no focus, so AT relies on this polite live region. */} + {/* Announce loading / active tab + result count / empty state to screen + readers; the listbox keeps no focus, so AT relies on this live region. */} <div role="status" aria-live="polite" className="sr-only"> {liveStatus} </div> diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 7ce8c1cd6..cacf31c25 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -2294,6 +2294,7 @@ export function MessageInput({ autoFocus={autoFocus} referenceSearch={referenceSearch} mentionUiLabels={mentionUiLabels} + tabLabels={referenceGroupLabels} onChange={handleComposerChange} onReady={handleComposerReady} onSubmit={handleSend} From 1086edd13286ef8b7c823cb8126aaa3423fb75b5 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Thu, 11 Jun 2026 23:41:11 +0800 Subject: [PATCH 16/31] fix(transcript): keep @session/@commit/@agent inline as badges in user messages (P6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractUserResourcesFromText lifted any [label](uri) link whose label started with @ (or whose uri was file://) into the bottom resource-chip row. P5c serialized agents as [@label](codeg://agent/…), so the leading @ matched hasMentionLabel and the agent reference was pulled out of the prose — the P5b/P5c inline ReferenceBadge never rendered in the real transcript (a session whose title starts with @ hit the same trap). Short-circuit any codeg: reference link to stay inline (→ markdown-link → ReferenceBadge), mirroring markdown-link's own codeg: interception. file:// files and @name [blocked] mentions are unchanged (still chips), per the user's 'don't touch files this round' directive. Backend, session parsers, send/restore serialization untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- src/lib/adapters/ai-elements-adapter.test.ts | 120 +++++++++++++++++++ src/lib/adapters/ai-elements-adapter.ts | 10 ++ 2 files changed, 130 insertions(+) diff --git a/src/lib/adapters/ai-elements-adapter.test.ts b/src/lib/adapters/ai-elements-adapter.test.ts index b7b25b005..04ca193f3 100644 --- a/src/lib/adapters/ai-elements-adapter.test.ts +++ b/src/lib/adapters/ai-elements-adapter.test.ts @@ -4,6 +4,7 @@ import { adaptMessageTurn, createMessageTurnAdapter, dropHiddenFeedbackChecks, + extractUserResourcesFromText, groupConsecutiveDelegationStatus, groupGoalRuns, groupConsecutiveToolCalls, @@ -722,3 +723,122 @@ describe("adaptMessageTurn plan handling", () => { expect(adapted.content.every((p) => p.type !== "plan")).toBe(true) }) }) + +describe("extractUserResourcesFromText — codeg references stay inline", () => { + it("keeps a codeg://agent link inline (the @-prefixed label no longer lifts it to a chip)", () => { + const input = "ask [@Codex](codeg://agent/codex) to review" + const { text, resources } = extractUserResourcesFromText(input) + expect(resources).toEqual([]) + expect(text).toBe(input) + }) + + it("keeps codeg://session and codeg://commit links inline", () => { + const session = extractUserResourcesFromText( + "see [#42](codeg://session/claude_code_abc)" + ) + expect(session.resources).toEqual([]) + expect(session.text).toBe("see [#42](codeg://session/claude_code_abc)") + + const commit = extractUserResourcesFromText( + "from [a1b2c3d](codeg://commit/%2Frepo@a1b2c3ddeadbeef)" + ) + expect(commit.resources).toEqual([]) + expect(commit.text).toBe( + "from [a1b2c3d](codeg://commit/%2Frepo@a1b2c3ddeadbeef)" + ) + }) + + it("keeps a codeg://session link inline even when its label starts with @ (a session titled '@…')", () => { + const input = "ping [@周报](codeg://session/codex_99)" + const { text, resources } = extractUserResourcesFromText(input) + expect(resources).toEqual([]) + expect(text).toBe(input) + }) + + it("still lifts file:// links to the resource list (files unchanged this round)", () => { + const { text, resources } = extractUserResourcesFromText( + "look at [foo.ts](file:///x/foo.ts) here" + ) + expect(resources).toEqual([ + { name: "foo.ts", uri: "file:///x/foo.ts", mime_type: null }, + ]) + expect(text).toBe("look at here") + }) + + it("still lifts blocked @-mentions to the resource list", () => { + const { resources } = extractUserResourcesFromText( + "@secret.txt [blocked: outside workspace]" + ) + expect(resources).toEqual([ + { name: "secret.txt", uri: "secret.txt", mime_type: null }, + ]) + }) + + it("splits a mixed message: file → chip, session → inline", () => { + const { text, resources } = extractUserResourcesFromText( + "compare [foo.ts](file:///x/foo.ts) with [#42](codeg://session/codex_abc)" + ) + expect(resources).toEqual([ + { name: "foo.ts", uri: "file:///x/foo.ts", mime_type: null }, + ]) + expect(text).toContain("[#42](codeg://session/codex_abc)") + expect(text).not.toContain("file://") + }) +}) + +describe("adaptMessageTurn — user reference resources", () => { + const msgText = { + attachedResources: "Attached resources", + toolCallFailed: "Tool failed", + } + + it("keeps an agent reference inline in the user turn (no chip row)", () => { + const adapted = adaptMessageTurn( + { + id: "u1", + role: "user", + timestamp: "2026-06-11T00:00:00.000Z", + blocks: [ + { type: "text", text: "ask [@Codex](codeg://agent/codex) to review" }, + ], + }, + msgText + ) + + expect(adapted.userResources).toBeUndefined() + expect(adapted.content).toHaveLength(1) + const part = adapted.content[0] + if (part.type !== "text") throw new Error("expected a text part") + expect(part.text).toContain("[@Codex](codeg://agent/codex)") + }) + + it("routes a file to the chip row while keeping a session reference inline", () => { + // Mirrors the backend fold: prose+session in one text block, the file + // resource_link folded to a trailing `[name](uri)` text block. + const adapted = adaptMessageTurn( + { + id: "u2", + role: "user", + timestamp: "2026-06-11T00:00:00.000Z", + blocks: [ + { + type: "text", + text: "compare these [#42](codeg://session/codex_abc)", + }, + { type: "text", text: "[foo.ts](file:///x/foo.ts)" }, + ], + }, + msgText + ) + + expect(adapted.userResources).toEqual([ + { name: "foo.ts", uri: "file:///x/foo.ts", mime_type: null }, + ]) + const textParts = adapted.content.filter((p) => p.type === "text") + expect(textParts).toHaveLength(1) + const part = textParts[0] + if (part.type !== "text") throw new Error("expected a text part") + expect(part.text).toContain("[#42](codeg://session/codex_abc)") + expect(part.text).not.toContain("file://") + }) +}) diff --git a/src/lib/adapters/ai-elements-adapter.ts b/src/lib/adapters/ai-elements-adapter.ts index c28d89f4c..349e5872c 100644 --- a/src/lib/adapters/ai-elements-adapter.ts +++ b/src/lib/adapters/ai-elements-adapter.ts @@ -712,6 +712,16 @@ export function extractUserResourcesFromText(text: string): { (match: string, label: string, uri: string) => { const normalizedLabel = label.trim() const normalizedUri = uri.trim() + // A `codeg://` reference (session / commit / agent) renders as an inline + // badge in the transcript (markdown-link → ReferenceBadge); never lift it + // to the bottom resource-chip row. The guard mirrors markdown-link's + // interception (`href.startsWith("codeg:")`): an unrecognized codeg path + // is parsed back to null there and degrades to a plain inline link — still + // in-flow, never a chip. (The `@`-prefixed agent link `[@label](codeg:// + // agent/…)` would otherwise be caught by `hasMentionLabel` below.) + if (normalizedUri.toLowerCase().startsWith("codeg:")) { + return match + } const hasMentionLabel = normalizedLabel.startsWith("@") const isFileUri = normalizedUri.toLowerCase().startsWith("file://") if (!hasMentionLabel && !isFileUri) { From 20b4439a67bf8102961129a2f0449373a502ed46 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Fri, 12 Jun 2026 09:19:09 +0800 Subject: [PATCH 17/31] =?UTF-8?q?fix(transcript):=20let=20codeg://=20links?= =?UTF-8?q?=20survive=20sanitization=20=E2=86=92=20inline=20badges=20(P7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-message @agent/@session/@commit references rendered as literal "@Codex CLI [blocked]" instead of inline badges. Streamdown's default rehype pipeline runs rehype-sanitize before rehype-harden; the sanitize schema's protocols.href allow-list omits the app-internal `codeg` scheme, so it strips the href off `[label](codeg://…)` links. harden then sees a hrefless <a> and replaces it with a "… [blocked]" span — all before react-markdown maps <a> to MarkdownLink (→ ReferenceBadge), so the badge code never ran. Re-derive the pipeline (rehype-allow-codeg) with `codeg` added to the sanitize allow-list so the href reaches MarkdownLink. harden is left untouched: it already permits all protocols via its `*` default and still hard-blocks javascript:/data:/file:/vbscript:, so widening sanitize by one inert app scheme adds no XSS surface. file:// links are unaffected (rewritten to local paths at the remark layer before sanitize runs). Tests: helper unit test (adds codeg once, preserves plugin order, no schema mutation) + MessageResponse integration tests through the REAL Streamdown pipeline (agent/session/commit each render a badge, never "[blocked]"; plus an http-link regression guard). Add defaultRehypePlugins to message.test.tsx's streamdown mock so the module-scope call doesn't throw. Gate: vitest 1319 pass, eslint clean, next build OK. Codex review APPROVED. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../ai-elements/message-codeg-badge.test.tsx | 78 +++++++++++++++++++ src/components/ai-elements/message.test.tsx | 1 + src/components/ai-elements/message.tsx | 13 +++- .../ai-elements/rehype-allow-codeg.test.ts | 48 ++++++++++++ .../ai-elements/rehype-allow-codeg.ts | 56 +++++++++++++ 5 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 src/components/ai-elements/message-codeg-badge.test.tsx create mode 100644 src/components/ai-elements/rehype-allow-codeg.test.ts create mode 100644 src/components/ai-elements/rehype-allow-codeg.ts diff --git a/src/components/ai-elements/message-codeg-badge.test.tsx b/src/components/ai-elements/message-codeg-badge.test.tsx new file mode 100644 index 000000000..67d9e2df6 --- /dev/null +++ b/src/components/ai-elements/message-codeg-badge.test.tsx @@ -0,0 +1,78 @@ +import { render, waitFor } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +// Exercise the REAL Streamdown pipeline (no streamdown mock) so the assertion +// covers actual rehype sanitize + harden behavior — the layer that previously +// stripped `codeg://` hrefs and rendered them as "[blocked]". The isolated +// MarkdownLink unit test runs after that layer, so it could not catch the +// regression. Only the link-safety hook is stubbed (irrelevant to badges). +vi.mock("@/components/ai-elements/link-safety", () => ({ + useStreamdownLinkSafety: () => ({ enabled: false }), +})) + +import { MessageResponse } from "./message" + +describe("MessageResponse — codeg references survive sanitization (real Streamdown)", () => { + it("renders an agent reference inline as a badge, not as '[blocked]'", async () => { + const { container } = render( + <MessageResponse softBreaks> + {"[@Codex CLI](codeg://agent/codex) hi"} + </MessageResponse> + ) + await waitFor(() => { + expect( + container.querySelector("[data-reference-badge][data-ref-type='agent']") + ).not.toBeNull() + }) + expect(container.textContent).toContain("Codex CLI") + expect(container.textContent).toContain("hi") + expect(container.textContent).not.toContain("[blocked]") + }) + + it("renders a session reference inline as a badge", async () => { + const { container } = render( + <MessageResponse softBreaks> + {"see [#42](codeg://session/claude_code_abc)"} + </MessageResponse> + ) + await waitFor(() => { + expect( + container.querySelector( + "[data-reference-badge][data-ref-type='session']" + ) + ).not.toBeNull() + }) + expect(container.textContent).toContain("see") + expect(container.textContent).not.toContain("[blocked]") + }) + + it("renders a commit reference inline as a badge", async () => { + const { container } = render( + <MessageResponse softBreaks> + {"[a1b2c3d](codeg://commit/%2Frepo@a1b2c3ddeadbeef)"} + </MessageResponse> + ) + await waitFor(() => { + expect( + container.querySelector( + "[data-reference-badge][data-ref-type='commit']" + ) + ).not.toBeNull() + }) + expect(container.textContent).toContain("a1b2c3d") + expect(container.textContent).not.toContain("[blocked]") + }) + + it("still renders a plain http link as a button (regression guard for non-codeg links)", async () => { + const { container } = render( + <MessageResponse>{"[docs](https://example.com)"}</MessageResponse> + ) + await waitFor(() => { + expect(container.querySelector("[data-streamdown='link']")).not.toBeNull() + }) + expect(container.textContent).toContain("docs") + expect(container.textContent).not.toContain("[blocked]") + // Not mistaken for a reference badge. + expect(container.querySelector("[data-reference-badge]")).toBeNull() + }) +}) diff --git a/src/components/ai-elements/message.test.tsx b/src/components/ai-elements/message.test.tsx index f7081c498..92d51f46c 100644 --- a/src/components/ai-elements/message.test.tsx +++ b/src/components/ai-elements/message.test.tsx @@ -15,6 +15,7 @@ vi.mock("streamdown", () => ({ </div> ), defaultRemarkPlugins: {}, + defaultRehypePlugins: {}, })) vi.mock("@streamdown/cjk", () => ({ cjk: {} })) diff --git a/src/components/ai-elements/message.tsx b/src/components/ai-elements/message.tsx index 673004cb3..23305f400 100644 --- a/src/components/ai-elements/message.tsx +++ b/src/components/ai-elements/message.tsx @@ -27,9 +27,14 @@ import { useMemo, useState, } from "react" -import { Streamdown, defaultRemarkPlugins } from "streamdown" +import { + Streamdown, + defaultRehypePlugins, + defaultRemarkPlugins, +} from "streamdown" import remarkBreaks from "remark-breaks" import { markdownLinkComponents } from "./markdown-link" +import { rehypePluginsAllowingCodeg } from "./rehype-allow-codeg" import { remarkRewriteFileUriLinks } from "./remark-file-uri-links" export type MessageProps = HTMLAttributes<HTMLDivElement> & { @@ -384,6 +389,11 @@ const remarkPlugins = [ // User messages opt in to this set so single newlines render as <br>. const remarkPluginsWithBreaks = [...remarkPlugins, remarkBreaks] +// Streamdown's default rehype pipeline strips `codeg://` reference hrefs in +// sanitization (rendering them as "[blocked]"); re-derive it so they survive to +// MarkdownLink → ReferenceBadge. See rehype-allow-codeg for the full rationale. +const rehypePlugins = rehypePluginsAllowingCodeg(defaultRehypePlugins) + function MessageResponseImpl({ className, children, @@ -406,6 +416,7 @@ function MessageResponseImpl({ )} plugins={streamdownPlugins} remarkPlugins={softBreaks ? remarkPluginsWithBreaks : remarkPlugins} + rehypePlugins={rehypePlugins} {...props} // Merge after spreading props so a caller can still override other // elements, but the link icon + safety routing on `a` always wins. diff --git a/src/components/ai-elements/rehype-allow-codeg.test.ts b/src/components/ai-elements/rehype-allow-codeg.test.ts new file mode 100644 index 000000000..4fd361c35 --- /dev/null +++ b/src/components/ai-elements/rehype-allow-codeg.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest" +import { defaultRehypePlugins } from "streamdown" + +import { rehypePluginsAllowingCodeg } from "./rehype-allow-codeg" + +/** Pull the href protocol allow-list out of a `[rehypeSanitize, schema]` tuple. */ +function hrefProtocols(plugin: unknown): string[] | undefined { + if (!Array.isArray(plugin)) return undefined + const schema = plugin[1] as { protocols?: { href?: string[] } } | undefined + return schema?.protocols?.href +} + +describe("rehypePluginsAllowingCodeg", () => { + it("adds `codeg` to the sanitize schema's href protocol allow-list", () => { + // Guards against an upstream rename of the `sanitize` key — the whole fix + // hinges on this entry existing. + const sanitizeIndex = Object.keys(defaultRehypePlugins).indexOf("sanitize") + expect(sanitizeIndex).toBeGreaterThanOrEqual(0) + + const href = hrefProtocols( + rehypePluginsAllowingCodeg(defaultRehypePlugins)[sanitizeIndex] + ) + expect(href).toContain("codeg") + // Exactly once — no duplicate even if re-derived. + expect(href?.filter((p) => p === "codeg")).toHaveLength(1) + // Pre-existing protocols are preserved (https is always present). + expect(href).toContain("https") + }) + + it("preserves plugin count and order, passing raw/harden through by reference", () => { + const keys = Object.keys(defaultRehypePlugins) + const result = rehypePluginsAllowingCodeg(defaultRehypePlugins) + expect(result).toHaveLength(keys.length) + keys.forEach((key, i) => { + if (key !== "sanitize") { + expect(result[i]).toBe(defaultRehypePlugins[key]) + } + }) + }) + + it("clones rather than mutating the shipped sanitize schema", () => { + // The shipped default must not already contain codeg, else the fix is moot. + expect(hrefProtocols(defaultRehypePlugins.sanitize)).not.toContain("codeg") + rehypePluginsAllowingCodeg(defaultRehypePlugins) + // Still absent on the original after deriving — we built a new schema. + expect(hrefProtocols(defaultRehypePlugins.sanitize)).not.toContain("codeg") + }) +}) diff --git a/src/components/ai-elements/rehype-allow-codeg.ts b/src/components/ai-elements/rehype-allow-codeg.ts new file mode 100644 index 000000000..2f3041585 --- /dev/null +++ b/src/components/ai-elements/rehype-allow-codeg.ts @@ -0,0 +1,56 @@ +import type { ComponentProps } from "react" +import type { Streamdown } from "streamdown" + +type RehypePlugins = NonNullable< + ComponentProps<typeof Streamdown>["rehypePlugins"] +> +type RehypePlugin = RehypePlugins[number] + +/** Minimal view of rehype-sanitize's schema — only the protocol allow-list we widen. */ +type SanitizeSchema = { + protocols?: Record<string, string[]> + [key: string]: unknown +} + +/** + * Re-derive Streamdown's default rehype pipeline so the app-internal `codeg` + * scheme survives sanitization and reaches `MarkdownLink` → `ReferenceBadge`. + * + * Streamdown's default pipeline is `[raw, [rehypeSanitize, schema], harden]` + * (run in that order). The sanitize schema's `protocols.href` allow-list omits + * `codeg`, so it strips the href off our `[label](codeg://…)` reference links; + * rehype-harden then sees a hrefless `<a>`, can't transform it, and replaces it + * with a `… [blocked]` span — all at the rehype stage, *before* react-markdown + * maps `<a>` to `MarkdownLink` (which turns a `codeg:` href into an inline + * badge). The net effect was `@Codex CLI [blocked]` in the transcript. + * + * Adding `codeg` to the sanitize allow-list lets the href survive. harden is + * left untouched: it already permits every protocol via its `*` default and + * still hard-blocks `javascript:` / `data:` / `file:` / `vbscript:`, so widening + * sanitize by one inert app scheme adds no XSS surface. `file://` links are + * unaffected — they are rewritten to local paths at the remark layer (see + * {@link "./remark-file-uri-links"}) before sanitize runs. + * + * Only the `sanitize` entry is rewritten; every other plugin is passed through + * in its original position (mirroring how Streamdown builds the default list via + * `Object.values`), so the pipeline stays correct if upstream adds plugins. + */ +export function rehypePluginsAllowingCodeg( + defaults: Record<string, RehypePlugin> +): RehypePlugins { + return Object.entries(defaults).map<RehypePlugin>(([key, plugin]) => { + if (key !== "sanitize") return plugin + const [sanitizePlugin, schema] = ( + Array.isArray(plugin) ? plugin : [plugin] + ) as [RehypePlugin, SanitizeSchema?] + const href = schema?.protocols?.href ?? [] + const next: SanitizeSchema = { + ...schema, + protocols: { + ...schema?.protocols, + href: href.includes("codeg") ? href : [...href, "codeg"], + }, + } + return [sanitizePlugin, next] as RehypePlugin + }) +} From a659af8db6ab63f9cb05932bf0804b6b5f577cc2 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Fri, 12 Jun 2026 11:40:45 +0800 Subject: [PATCH 18/31] feat(composer): per-type tinted reference badges + skill prefix serialization (P8a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Badge presentation/serialization layer for the composer optimization pass: - ReferenceMeta gains `invocationPrefix?: "/" | "$"`; the skill branch of referenceToMarkdown now emits `${prefix}${id}` (commands & most skills `/`, Codex skills/experts `$`) instead of a hardcoded `/id`. - ReferenceBadge: each refType gets its own soft tinted color set (light+dark) — file=blue, agent=violet, session=emerald, commit=amber, command/skill=sky, expert=fuchsia; the `skill` icon splits to a star (experts) vs cmd glyph. - Fix vertical alignment: badges use `align-middle` so text no longer sits at the bottom relative to the taller badge (transcript + composer). - Harden skill-token serialization (Codex review): an id is emitted raw only when it is a literal invocation slug (alnum slug, no autolink trigger, `_` strictly intraword); filesystem-sourced ids that could inject Markdown (`![x](http://…)`, `a/_b_/c`) fall through to the safe escaped/code-spanned path. Escaping is not applied to real ids (would defeat invocation). Gate: vitest 1360 pass, eslint clean, next build OK. Codex APPROVED. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../composer/badges/reference-badge.test.tsx | 95 +++++++++++++++++++ .../chat/composer/badges/reference-badge.tsx | 44 ++++++++- .../chat/composer/reference-text.test.ts | 67 +++++++++++++ .../chat/composer/reference-text.ts | 37 +++++++- src/components/chat/composer/types.ts | 14 ++- 5 files changed, 247 insertions(+), 10 deletions(-) create mode 100644 src/components/chat/composer/badges/reference-badge.test.tsx diff --git a/src/components/chat/composer/badges/reference-badge.test.tsx b/src/components/chat/composer/badges/reference-badge.test.tsx new file mode 100644 index 000000000..b7be440c2 --- /dev/null +++ b/src/components/chat/composer/badges/reference-badge.test.tsx @@ -0,0 +1,95 @@ +import { render } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { ReferenceBadge } from "./reference-badge" +import type { ReferenceAttrs } from "../types" + +function ref(partial: Partial<ReferenceAttrs>): ReferenceAttrs { + return { + refType: "file", + id: "", + label: "", + uri: null, + meta: null, + ...partial, + } +} + +/** The badge root carries `data-reference-badge` and `data-ref-type`. */ +function badgeOf(container: HTMLElement): HTMLElement { + const el = container.querySelector<HTMLElement>("[data-reference-badge]") + if (!el) throw new Error("no reference badge rendered") + return el +} + +describe("ReferenceBadge", () => { + it("renders the label and is vertically centered (align-middle)", () => { + const { container } = render( + <ReferenceBadge data={ref({ refType: "file", label: "app.ts" })} /> + ) + const badge = badgeOf(container) + expect(badge).toHaveTextContent("app.ts") + // Task 3: badges sit on the text's middle, not its baseline. + expect(badge).toHaveClass("align-middle") + expect(badge).not.toHaveClass("align-baseline") + }) + + it("tints a file reference blue", () => { + const { container } = render( + <ReferenceBadge data={ref({ refType: "file", label: "app.ts" })} /> + ) + const badge = badgeOf(container) + expect(badge).toHaveAttribute("data-ref-type", "file") + expect(badge).toHaveClass("bg-blue-50", "text-blue-700") + expect(container.querySelector(".lucide-file-text")).not.toBeNull() + }) + + it("tints a session reference emerald", () => { + const { container } = render( + <ReferenceBadge data={ref({ refType: "session", label: "#42" })} /> + ) + const badge = badgeOf(container) + expect(badge).toHaveAttribute("data-ref-type", "session") + expect(badge).toHaveClass("bg-emerald-50", "text-emerald-700") + // No agentType meta → falls back to the Hash icon. + expect(container.querySelector(".lucide-hash")).not.toBeNull() + }) + + it("renders a command/skill with the command glyph, tinted sky", () => { + const { container } = render( + <ReferenceBadge + data={ref({ + refType: "skill", + id: "build", + label: "build", + meta: { invocationPrefix: "/" }, + })} + /> + ) + const badge = badgeOf(container) + expect(badge).toHaveAttribute("data-ref-type", "skill") + expect(badge).toHaveClass("bg-sky-50", "text-sky-700") + // Command glyph, not the star. + expect(container.querySelector(".lucide-command")).not.toBeNull() + expect(container.querySelector(".lucide-sparkles")).toBeNull() + }) + + it("renders an expert with the star glyph, tinted fuchsia", () => { + const { container } = render( + <ReferenceBadge + data={ref({ + refType: "skill", + id: "reviewer", + label: "Reviewer", + meta: { scope: "expert", invocationPrefix: "/" }, + })} + /> + ) + const badge = badgeOf(container) + expect(badge).toHaveAttribute("data-ref-type", "skill") + expect(badge).toHaveClass("bg-fuchsia-50", "text-fuchsia-700") + // Star glyph, not the command. + expect(container.querySelector(".lucide-sparkles")).not.toBeNull() + expect(container.querySelector(".lucide-command")).toBeNull() + }) +}) diff --git a/src/components/chat/composer/badges/reference-badge.tsx b/src/components/chat/composer/badges/reference-badge.tsx index a9611502c..f775b079e 100644 --- a/src/components/chat/composer/badges/reference-badge.tsx +++ b/src/components/chat/composer/badges/reference-badge.tsx @@ -1,4 +1,12 @@ -import { Bot, FileText, Folder, GitCommit, Hash, Sparkles } from "lucide-react" +import { + Bot, + Command, + FileText, + Folder, + GitCommit, + Hash, + Sparkles, +} from "lucide-react" import type { ReactNode } from "react" import { AgentIcon } from "@/components/agent-icon" @@ -45,7 +53,14 @@ export function ReferenceIcon({ data }: { data: ReferenceAttrs }) { icon = <GitCommit className={ICON_CLASS} /> break case "skill": - icon = <Sparkles className={ICON_CLASS} /> + // Experts (whole-turn directives) get a star; commands / skills get the + // command glyph. See {@link badgeColorClass} for the matching color. + icon = + meta?.scope === "expert" ? ( + <Sparkles className={ICON_CLASS} /> + ) : ( + <Command className={ICON_CLASS} /> + ) break default: return null @@ -61,6 +76,28 @@ export function ReferenceIcon({ data }: { data: ReferenceAttrs }) { ) } +/** + * Soft tinted color set per reference kind (light + dark). `text-*` colors the + * label and — since the icon strokes with `currentColor` — the icon too. Skills + * split by scope: experts (star) are fuchsia, commands/skills (cmd) are sky. + */ +function badgeColorClass(data: ReferenceAttrs): string { + switch (data.refType) { + case "file": + return "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-500/30 dark:bg-blue-500/15 dark:text-blue-300" + case "agent": + return "border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-500/30 dark:bg-violet-500/15 dark:text-violet-300" + case "session": + return "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/15 dark:text-emerald-300" + case "commit": + return "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-300" + case "skill": + return data.meta?.scope === "expert" + ? "border-fuchsia-200 bg-fuchsia-50 text-fuchsia-700 dark:border-fuchsia-500/30 dark:bg-fuchsia-500/15 dark:text-fuchsia-300" + : "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/15 dark:text-sky-300" + } +} + export interface ReferenceBadgeProps { data: ReferenceAttrs className?: string @@ -88,7 +125,8 @@ export function ReferenceBadge({ data, className }: ReferenceBadgeProps) { role="img" aria-label={`${data.refType}: ${data.label || data.id}`} className={cn( - "inline-flex max-w-[18rem] items-center gap-1 rounded-md border border-border/60 bg-muted/60 px-1.5 py-px align-baseline text-[0.85em] leading-snug text-foreground", + "inline-flex max-w-[18rem] items-center gap-1 rounded-md border px-1.5 py-px align-middle text-[0.85em] leading-snug", + badgeColorClass(data), className )} > diff --git a/src/components/chat/composer/reference-text.test.ts b/src/components/chat/composer/reference-text.test.ts index ebd3111b8..e80dd1208 100644 --- a/src/components/chat/composer/reference-text.test.ts +++ b/src/components/chat/composer/reference-text.test.ts @@ -86,6 +86,40 @@ describe("referenceToMarkdown", () => { ).toBe("") }) + it("uses the `$` prefix for a Codex skill (meta.invocationPrefix)", () => { + expect( + referenceToMarkdown( + ref({ + refType: "skill", + id: "deploy", + label: "Deploy", + meta: { invocationPrefix: "$" }, + }) + ) + ).toBe("$deploy") + }) + + it("uses the `$` prefix for a Codex expert", () => { + expect( + referenceToMarkdown( + ref({ + refType: "skill", + id: "reviewer", + label: "Reviewer", + meta: { scope: "expert", invocationPrefix: "$" }, + }) + ) + ).toBe("$reviewer") + }) + + it("defaults to `/` when invocationPrefix is absent", () => { + expect( + referenceToMarkdown( + ref({ refType: "skill", id: "build", meta: { scope: "expert" } }) + ) + ).toBe("/build") + }) + describe("markdown injection is neutralized", () => { it("escapes brackets and parens in link text so a label cannot break out", () => { expect( @@ -163,6 +197,39 @@ describe("referenceToMarkdown", () => { ) ).toBe("`www.evil.com`") }) + + it("code-spans a skill id that would inject Markdown (autolink trigger)", () => { + // Skill ids come from filesystem names; a malicious one must not inject a + // second link / image into the prompt or the transcript bubble. + expect( + referenceToMarkdown(ref({ refType: "skill", id: "![x](http://evil)" })) + ).toBe("`/![x](http://evil)`") + }) + + it("escapes structural chars in a skill id with no autolink trigger", () => { + expect( + referenceToMarkdown(ref({ refType: "skill", id: "foo[bar]" })) + ).toBe("/foo\\[bar\\]") + }) + + it("keeps a normal underscore/dot/slash skill id literal (must invoke)", () => { + // Underscores are common in agent/skill ids (e.g. claude_code) and MUST + // NOT be escaped, or the agent receives a non-invocable `/claude\_code`. + expect( + referenceToMarkdown(ref({ refType: "skill", id: "claude_code" })) + ).toBe("/claude_code") + expect( + referenceToMarkdown(ref({ refType: "skill", id: "scope/my.skill_v2" })) + ).toBe("/scope/my.skill_v2") + }) + + it("escapes a `_` that flanks a separator (would emphasize otherwise)", () => { + // `/a/_b_/c` parses as `/a/<em>b</em>/c` in CommonMark — a non-intraword + // `_` must be escaped so no emphasis leaks into the prompt/transcript. + expect( + referenceToMarkdown(ref({ refType: "skill", id: "a/_b_/c" })) + ).toBe("/a/\\_b\\_/c") + }) }) it("falls back to the bare label when a uri type has no uri", () => { diff --git a/src/components/chat/composer/reference-text.ts b/src/components/chat/composer/reference-text.ts index a4cfd5d25..7c1946875 100644 --- a/src/components/chat/composer/reference-text.ts +++ b/src/components/chat/composer/reference-text.ts @@ -23,6 +23,29 @@ function escapeMarkdownText(text: string): string { // which never autolinks and reproduces the text literally. const AUTOLINK_TRIGGER = /(?:https?|ftp|mailto):|www\.|@/i +// Slug shape of a real command / skill / expert id: alphanumeric ends with +// interior `._-/` separators only. See {@link isLiteralInvocationToken}. +const SAFE_INVOCATION_TOKEN = /^[A-Za-z0-9](?:[A-Za-z0-9._/-]*[A-Za-z0-9])?$/ + +/** + * Whether `token` can be emitted raw after a `/` / `$` prefix without any + * Markdown breaking out — i.e. it is a genuine invocation slug the agent runs + * verbatim (escaping it would defeat invocation). Beyond the slug shape it + * rejects: GFM autolink triggers (`www.` / `http:` / `@`), and any `_` that is + * not strictly intraword — a `_` touching a separator (e.g. `a/_b_/c`) flanks + * into CommonMark emphasis (`/a/<em>b</em>/c`). Such ids — and anything else, + * e.g. a maliciously-named skill folder `![x](http://evil)` (ids come from the + * filesystem) — fall through to the safe (escaped / code-spanned) inline-text + * path. Real ids (`code-review`, `claude_code`, `scope/my.skill_v2`) pass. + */ +function isLiteralInvocationToken(token: string): boolean { + return ( + SAFE_INVOCATION_TOKEN.test(token) && + !AUTOLINK_TRIGGER.test(token) && + !/[^A-Za-z0-9]_|_[^A-Za-z0-9]/.test(token) + ) +} + /** Wrap text in a Markdown code span with a fence long enough to be literal. */ function toInlineCode(text: string): string { const runs = text.match(/`+/g) @@ -88,11 +111,17 @@ export function referenceToMarkdown(attrs: ReferenceAttrs): string { : `@${inlineText(attrs.label || attrs.id)}` } case "skill": { - // Invocation token: the stable id is what the agent executes. The label - // (possibly localized / containing spaces) is never used; an empty id is - // neutralized to nothing rather than emitting a broken `/command`. + // Invocation token: the stable id is what the agent executes, prefixed by + // the trigger it was created from (`/` commands & most skills, `$` Codex + // skills/experts — read from meta, default `/`). The label (possibly + // localized / containing spaces) is never used; an empty id is neutralized + // to nothing rather than emitting a broken `/command`. const token = collapseNewlines(attrs.id).trim() - return token ? `/${token}` : "" + if (!token) return "" + const prefix = attrs.meta?.invocationPrefix === "$" ? "$" : "/" + return isLiteralInvocationToken(token) + ? `${prefix}${token}` + : inlineText(`${prefix}${token}`) } case "file": case "session": diff --git a/src/components/chat/composer/types.ts b/src/components/chat/composer/types.ts index 54978f40c..6dfbae26b 100644 --- a/src/components/chat/composer/types.ts +++ b/src/components/chat/composer/types.ts @@ -13,8 +13,9 @@ export const REFERENCE_KINDS: readonly ReferenceKind[] = [ /** * Type-specific render hints carried alongside a reference. All fields are - * optional — the badge reads only what its `refType` needs, and serialization - * never depends on `meta`. + * optional — the badge reads only what its `refType` needs. Serialization is + * `meta`-independent for every kind EXCEPT `skill` (commands / skills / experts), + * which reads {@link ReferenceMeta.invocationPrefix} to emit `/id` vs `$id`. */ export interface ReferenceMeta { /** file: whether the entry is a directory. */ @@ -35,12 +36,19 @@ export interface ReferenceMeta { author?: string /** commit: whether the commit is pushed upstream. */ pushed?: boolean | null - /** skill: "global" | "project" scope. */ + /** skill: "global" | "project" | "expert" scope ("expert" → star icon). */ scope?: string /** skill: category grouping. */ category?: string /** skill: lucide icon name. */ icon?: string | null + /** + * skill: the invocation prefix the agent expects (`/` for commands and most + * skills, `$` for Codex skills/experts). Read by `referenceToMarkdown` to + * serialize the badge back to its literal `${prefix}${id}` token; defaults to + * `/` when absent. + */ + invocationPrefix?: "/" | "$" } /** From f6691922c39f67aa93f68b6aec1832166d125bd9 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Fri, 12 Jun 2026 11:56:41 +0800 Subject: [PATCH 19/31] refactor(composer): drop the skill tab from the @ panel (P8b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skills/commands/experts are no longer surfaced in the unified `@` mention panel — they are reached via the `/` and `$` triggers and the expert menu (the badge insertion wiring lands in P8c). - suggestion-popup: TAB_ORDER → 4 tabs (agent/file/session/commit); Shift+Tab from agent now wraps to commit. - use-reference-search: buildReferenceGroups returns 4 groups; the hook no longer loads skills/built-in experts/agent experts/locale, and the `agentType` option (which only scoped those) is removed. R7 referential stability and the R8 folder-switch guard are unchanged. - adapters: retire skillToSuggestion/expertToSuggestion (only the panel used them). - message-input: drop the now-removed agentType arg from the useReferenceSearch call. ReferenceKind keeps `skill` (the badge node kind / serialization). Consequence: non-Codex agents no longer see disk skills in `@` (they were only ever there); their commands stay reachable via `/` ACP availableCommands and experts via the expert menu. Gate: vitest 1354 pass, eslint clean, next build OK. Codex APPROVED (Findings: none). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../chat/composer/suggestion/adapters.test.ts | 60 +----------- .../chat/composer/suggestion/adapters.ts | 44 +-------- .../suggestion/suggestion-popup.test.tsx | 12 +-- .../composer/suggestion/suggestion-popup.tsx | 8 +- .../composer/use-reference-search.test.ts | 94 +----------------- .../chat/composer/use-reference-search.ts | 95 +++---------------- src/components/chat/message-input.tsx | 1 - 7 files changed, 33 insertions(+), 281 deletions(-) diff --git a/src/components/chat/composer/suggestion/adapters.test.ts b/src/components/chat/composer/suggestion/adapters.test.ts index 94ec2bbfd..76bf3e0b5 100644 --- a/src/components/chat/composer/suggestion/adapters.test.ts +++ b/src/components/chat/composer/suggestion/adapters.test.ts @@ -3,20 +3,16 @@ import { describe, expect, it } from "vitest" import type { FlatFileEntry } from "@/hooks/use-file-tree" import type { AcpAgentInfo, - AgentSkillItem, DbConversationSummary, - ExpertListItem, GitLogEntry, } from "@/lib/types" import { agentToSuggestion, commitToSuggestion, - expertToSuggestion, fileToSuggestion, pathToFileUri, sessionToSuggestion, - skillToSuggestion, } from "./adapters" describe("pathToFileUri", () => { @@ -131,56 +127,6 @@ describe("commitToSuggestion", () => { }) }) -describe("skillToSuggestion", () => { - it("maps a user/project skill to a skill reference", () => { - const skill = { - id: "code-review", - name: "Code Review", - scope: "project", - layout: "markdown_file", - path: "/skills/code-review.md", - description: "Review the diff", - read_only: false, - } as AgentSkillItem - expect(skillToSuggestion(skill).reference).toMatchObject({ - refType: "skill", - id: "code-review", - label: "Code Review", - uri: null, - meta: { scope: "project" }, - }) - }) -}) - -describe("expertToSuggestion", () => { - const expert: ExpertListItem = { - metadata: { - id: "deep-research", - category: "research", - icon: "Sparkles", - sort_order: 1, - display_name: { en: "Deep Research", "zh-CN": "深度研究" }, - description: { en: "Research deeply", "zh-CN": "深入研究" }, - bundled_hash: "x", - }, - installed_centrally: true, - user_modified: false, - central_path: "/experts/deep-research", - } - it("uses the localized display name", () => { - expect(expertToSuggestion(expert, "zh-CN").reference.label).toBe("深度研究") - }) - it("falls back to English then id when the locale is missing", () => { - expect(expertToSuggestion(expert, "ja").reference.label).toBe( - "Deep Research" - ) - }) - it("maps to a skill reference (experts invoke as /id)", () => { - expect(expertToSuggestion(expert, "en").reference).toMatchObject({ - refType: "skill", - id: "deep-research", - uri: null, - meta: { scope: "expert", category: "research", icon: "Sparkles" }, - }) - }) -}) +// skillToSuggestion / expertToSuggestion were retired with the `@` panel's skill +// tab — skills/commands/experts are now inserted via the `/` `$` triggers and the +// expert menu (see composer/invocation-reference.ts), not adapted for the panel. diff --git a/src/components/chat/composer/suggestion/adapters.ts b/src/components/chat/composer/suggestion/adapters.ts index 68a8c0b26..14c7654a8 100644 --- a/src/components/chat/composer/suggestion/adapters.ts +++ b/src/components/chat/composer/suggestion/adapters.ts @@ -2,9 +2,7 @@ import type { FlatFileEntry } from "@/hooks/use-file-tree" import { AGENT_LABELS, type AcpAgentInfo, - type AgentSkillItem, type DbConversationSummary, - type ExpertListItem, type GitLogEntry, } from "@/lib/types" @@ -123,42 +121,6 @@ export function commitToSuggestion( } } -/** User/project skill → skill reference (serializes to `/id`). */ -export function skillToSuggestion(skill: AgentSkillItem): SuggestionItem { - return { - reference: { - refType: "skill", - id: skill.id, - label: skill.name, - uri: null, - meta: { scope: skill.scope, icon: null }, - }, - detail: skill.description, - keywords: `${skill.id} ${skill.name}`, - } -} - -/** Built-in expert → skill reference, with the localized display name. */ -export function expertToSuggestion( - expert: ExpertListItem, - locale: string -): SuggestionItem { - const { metadata } = expert - const label = - metadata.display_name[locale] ?? metadata.display_name.en ?? metadata.id - return { - reference: { - refType: "skill", - id: metadata.id, - label, - uri: null, - meta: { - scope: "expert", - category: metadata.category, - icon: metadata.icon, - }, - }, - detail: metadata.description[locale] ?? metadata.description.en ?? null, - keywords: `${metadata.id} ${label} ${metadata.category}`, - } -} +// Skills, commands and experts are no longer surfaced in the `@` panel — they +// are inserted via the `/` / `$` triggers and the expert menu, which build their +// reference attrs directly (see composer/invocation-reference.ts). diff --git a/src/components/chat/composer/suggestion/suggestion-popup.test.tsx b/src/components/chat/composer/suggestion/suggestion-popup.test.tsx index ad9bb90df..41172418f 100644 --- a/src/components/chat/composer/suggestion/suggestion-popup.test.tsx +++ b/src/components/chat/composer/suggestion/suggestion-popup.test.tsx @@ -86,15 +86,15 @@ describe("SuggestionPopup", () => { vi.restoreAllMocks() }) - it("renders the active (agent-first) tab's options plus a five-tab strip", async () => { + it("renders the active (agent-first) tab's options plus a four-tab strip", async () => { mountPopup() // Agent is the first non-empty tab, so its options show by default. expect(await screen.findByText("Codex Helper")).toBeInTheDocument() expect(screen.getByText("Claude Helper")).toBeInTheDocument() // The file tab's option is hidden until that tab is active. expect(screen.queryByText("alpha.md")).toBeNull() - // Five fixed tabs, agent selected. - expect(screen.getAllByRole("tab")).toHaveLength(5) + // Four fixed tabs (no skill tab), agent selected. + expect(screen.getAllByRole("tab")).toHaveLength(4) expect(screen.getByRole("tab", { selected: true })).toHaveAccessibleName( /Agents/ ) @@ -104,7 +104,7 @@ describe("SuggestionPopup", () => { mountPopup({ search: emptySearch, emptyLabel: "Nothing" }) const panel = screen.getByTestId("mention-popup") expect(await within(panel).findByText("Nothing")).toBeInTheDocument() - expect(screen.getAllByRole("tab")).toHaveLength(5) + expect(screen.getAllByRole("tab")).toHaveLength(4) }) it("selects the active tab's highlighted row on Enter (default = first agent)", async () => { @@ -152,9 +152,9 @@ describe("SuggestionPopup", () => { const { ref } = mountPopup() await screen.findByText("Codex Helper") act(() => ref.current?.onKeyDown(key("Tab", true))) - // agent (first) wraps backwards to skill (last in tab order); it's empty. + // agent (first) wraps backwards to commit (last in tab order); it's empty. expect(screen.getByRole("tab", { selected: true })).toHaveAccessibleName( - /Skills/ + /Commits/ ) }) diff --git a/src/components/chat/composer/suggestion/suggestion-popup.tsx b/src/components/chat/composer/suggestion/suggestion-popup.tsx index f9bc2cbfa..e4037dc4f 100644 --- a/src/components/chat/composer/suggestion/suggestion-popup.tsx +++ b/src/components/chat/composer/suggestion/suggestion-popup.tsx @@ -27,16 +27,18 @@ const FETCH_DEBOUNCE_MS = 150 // Tab order in the panel: agent first (per product decision), then the rest in // their usual order. This is a *display* order; the search provider keeps its -// own (file-first) group order, which other code/tests depend on. +// own (file-first) group order, which other code/tests depend on. `skill` is +// intentionally absent — skills/commands are inserted via the `/` and `$` +// triggers (and experts via the expert menu), not the `@` panel. const TAB_ORDER: readonly ReferenceKind[] = [ "agent", "file", "session", "commit", - "skill", ] -// English fallbacks for the tab labels; the host injects localized ones. +// English fallbacks for the tab labels; the host injects localized ones. `skill` +// is kept for type completeness (`ReferenceKind`) though it is not a shown tab. const DEFAULT_TAB_LABELS: Record<ReferenceKind, string> = { agent: "Agents", file: "Files", diff --git a/src/components/chat/composer/use-reference-search.test.ts b/src/components/chat/composer/use-reference-search.test.ts index a0858dfd0..c037cd543 100644 --- a/src/components/chat/composer/use-reference-search.test.ts +++ b/src/components/chat/composer/use-reference-search.test.ts @@ -4,9 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import type { FlatFileEntry } from "@/hooks/use-file-tree" import type { AcpAgentInfo, - AgentSkillItem, DbConversationSummary, - ExpertListItem, GitLogEntry, } from "@/lib/types" @@ -74,36 +72,6 @@ function makeCommit( } } -function makeSkill(id: string, name: string): AgentSkillItem { - return { - id, - name, - scope: "project", - description: `${name} skill`, - } as unknown as AgentSkillItem -} - -function makeExpert( - id: string, - displayName: Record<string, string>, - over: { category?: string } = {} -): ExpertListItem { - return { - metadata: { - id, - category: over.category ?? "review", - icon: null, - sort_order: 0, - display_name: displayName, - description: { en: `${id} description` }, - bundled_hash: "hash", - }, - installed_centrally: true, - user_modified: false, - central_path: "/experts/x", - } -} - function emptySources( over: Partial<ReferenceSearchSources> = {} ): ReferenceSearchSources { @@ -114,10 +82,6 @@ function emptySources( sessions: [], commits: [], repoKey: null, - skills: [], - builtInExperts: [], - agentExperts: [], - locale: "en", ...over, } } @@ -128,20 +92,19 @@ const itemsOf = (groups: SuggestionGroup[], kind: ReferenceKind) => // --- pure builder ----------------------------------------------------------- describe("buildReferenceGroups", () => { - it("returns the five groups in a fixed order", () => { + it("returns the four groups in a fixed order (no skill group)", () => { const groups = buildReferenceGroups("", emptySources()) expect(groups.map((g) => g.kind)).toEqual([ "file", "agent", "session", "commit", - "skill", ]) }) it("keeps every group present (empty groups are not dropped)", () => { const groups = buildReferenceGroups("", emptySources()) - expect(groups).toHaveLength(5) + expect(groups).toHaveLength(4) expect(groups.every((g) => g.items.length === 0)).toBe(true) }) @@ -152,7 +115,6 @@ describe("buildReferenceGroups", () => { DEFAULT_GROUP_LABELS.agent, DEFAULT_GROUP_LABELS.session, DEFAULT_GROUP_LABELS.commit, - DEFAULT_GROUP_LABELS.skill, ]) }) @@ -260,38 +222,6 @@ describe("buildReferenceGroups", () => { expect(commits[0].reference.uri).toBe("codeg://commit/%2Frepo@abc12340000") }) - it("merges skills + experts into one group and dedupes by id (skill wins)", () => { - const groups = buildReferenceGroups( - "", - emptySources({ - skills: [makeSkill("dup", "Skill Dup"), makeSkill("only-skill", "S")], - builtInExperts: [makeExpert("dup", { en: "Expert Dup" })], - agentExperts: [makeExpert("agent-only", { en: "Agent Expert" })], - }) - ) - const skills = itemsOf(groups, "skill") - expect(skills.map((s) => s.reference.id)).toEqual([ - "dup", - "only-skill", - "agent-only", - ]) - // The first occurrence (the project skill) wins the dedupe. - expect(skills[0].reference.label).toBe("Skill Dup") - }) - - it("localizes expert labels by the provided locale", () => { - const groups = buildReferenceGroups( - "", - emptySources({ - builtInExperts: [ - makeExpert("reviewer", { en: "Reviewer", "zh-CN": "评审员" }), - ], - locale: "zh-CN", - }) - ) - expect(itemsOf(groups, "skill")[0].reference.label).toBe("评审员") - }) - it("caps each group at 50 items and flags the overflow as truncated", () => { const files = Array.from({ length: 60 }, (_, i) => makeFile(`f${i}.ts`)) const groups = buildReferenceGroups( @@ -338,14 +268,10 @@ describe("buildReferenceGroups", () => { const mocks = vi.hoisted(() => ({ agents: [] as AcpAgentInfo[], files: { allFiles: [] as FlatFileEntry[], loaded: false }, - skills: [] as AgentSkillItem[], - builtInExperts: [] as ExpertListItem[], - agentExperts: [] as ExpertListItem[], listAllConversations: vi.fn(), gitLog: vi.fn(), })) -vi.mock("next-intl", () => ({ useLocale: () => "en" })) vi.mock("@/hooks/use-file-tree", () => ({ useFileTree: () => ({ allFiles: mocks.files.allFiles, @@ -357,15 +283,6 @@ vi.mock("@/hooks/use-file-tree", () => ({ vi.mock("@/hooks/use-acp-agents", () => ({ useAcpAgents: () => ({ agents: mocks.agents, fresh: true, refresh: vi.fn() }), })) -vi.mock("@/hooks/use-agent-skills", () => ({ - useAgentSkills: () => mocks.skills, -})) -vi.mock("@/hooks/use-built-in-experts", () => ({ - useBuiltInExperts: () => mocks.builtInExperts, -})) -vi.mock("@/hooks/use-agent-experts", () => ({ - useAgentExperts: () => mocks.agentExperts, -})) vi.mock("@/lib/api", () => ({ listAllConversations: (...args: unknown[]) => mocks.listAllConversations(...args), @@ -376,9 +293,6 @@ describe("useReferenceSearch", () => { beforeEach(() => { mocks.agents = [] mocks.files = { allFiles: [], loaded: false } - mocks.skills = [] - mocks.builtInExperts = [] - mocks.agentExperts = [] mocks.listAllConversations.mockReset().mockResolvedValue([]) mocks.gitLog .mockReset() @@ -481,9 +395,8 @@ describe("useReferenceSearch", () => { expect(groups).toEqual([]) }) - it("degrades gracefully with no workspace path: agents/skills resolve, files/commits stay empty (R8)", async () => { + it("degrades gracefully with no workspace path: agents resolve, files/commits stay empty (R8)", async () => { mocks.agents = [makeAgent("codex", { name: "Codex" })] - mocks.builtInExperts = [makeExpert("reviewer", { en: "Reviewer" })] mocks.files = { allFiles: [makeFile("a.ts")], loaded: true } const { result } = renderHook(() => useReferenceSearch({ enabled: true })) @@ -495,7 +408,6 @@ describe("useReferenceSearch", () => { expect(itemsOf(groups, "file")).toHaveLength(0) expect(itemsOf(groups, "commit")).toHaveLength(0) expect(itemsOf(groups, "agent")).toHaveLength(1) - expect(itemsOf(groups, "skill")).toHaveLength(1) expect(mocks.gitLog).not.toHaveBeenCalled() }) diff --git a/src/components/chat/composer/use-reference-search.ts b/src/components/chat/composer/use-reference-search.ts index 09abdc0ab..1f7a5edbd 100644 --- a/src/components/chat/composer/use-reference-search.ts +++ b/src/components/chat/composer/use-reference-search.ts @@ -1,36 +1,27 @@ "use client" import { useCallback, useEffect, useLayoutEffect, useRef } from "react" -import { useLocale } from "next-intl" import { useAcpAgents } from "@/hooks/use-acp-agents" -import { useAgentExperts } from "@/hooks/use-agent-experts" -import { useAgentSkills } from "@/hooks/use-agent-skills" -import { useBuiltInExperts } from "@/hooks/use-built-in-experts" import { useFileTree, type FlatFileEntry } from "@/hooks/use-file-tree" import { gitLog, listAllConversations } from "@/lib/api" import type { AcpAgentInfo, - AgentType, DbConversationSummary, - ExpertListItem, GitLogEntry, } from "@/lib/types" import { agentToSuggestion, commitToSuggestion, - expertToSuggestion, fileToSuggestion, sessionToSuggestion, - skillToSuggestion, } from "./suggestion/adapters" import type { ReferenceSearch, SuggestionGroup, SuggestionItem, } from "./suggestion/types" -import type { AgentSkillItem } from "@/lib/types" // Commit-synchronous on the client (so the guard-critical refs are updated // during commit, before any later macrotask/microtask can resolve a stale @@ -76,10 +67,6 @@ export interface ReferenceSearchSources { commits: GitLogEntry[] /** Repo identity for commit URIs; null disables the commit group. */ repoKey: string | null - skills: AgentSkillItem[] - builtInExperts: ExpertListItem[] - agentExperts: ExpertListItem[] - locale: string } /** Case-insensitive substring match against an adapted item's searchable text. */ @@ -96,8 +83,8 @@ function suggestionMatches(item: SuggestionItem, lowerQuery: string): boolean { /** * Pure: filter + adapt the raw sources into the fixed-order grouped suggestions - * the `@` panel renders (files → agents → sessions → commits → skills). Each - * group is independently capped at {@link MAX_PER_GROUP}; empty groups are kept + * the `@` panel renders (files → agents → sessions → commits). Each group is + * independently capped at {@link MAX_PER_GROUP}; empty groups are kept * (the popup hides them) so the order is always stable. Extracted from the hook * so the matching/ordering/dedup logic is testable without React. */ @@ -152,28 +139,6 @@ export function buildReferenceGroups( } } - // Skills + built-in experts + agent-linked experts share one group. An expert - // can surface from more than one source, so dedupe by reference id (skill id), - // keeping the first occurrence, before filtering. - const skillItems: SuggestionItem[] = [] - let skillTruncated = false - const seenSkillIds = new Set<string>() - const skillCandidates: SuggestionItem[] = [ - ...sources.skills.map(skillToSuggestion), - ...sources.builtInExperts.map((e) => expertToSuggestion(e, sources.locale)), - ...sources.agentExperts.map((e) => expertToSuggestion(e, sources.locale)), - ] - for (const item of skillCandidates) { - if (seenSkillIds.has(item.reference.id)) continue - seenSkillIds.add(item.reference.id) - if (!suggestionMatches(item, q)) continue - if (skillItems.length >= MAX_PER_GROUP) { - skillTruncated = true - break - } - skillItems.push(item) - } - return [ { kind: "file", @@ -199,24 +164,16 @@ export function buildReferenceGroups( items: commitItems, truncated: commitTruncated, }, - { - kind: "skill", - label: labels.skill, - items: skillItems, - truncated: skillTruncated, - }, ] } export interface UseReferenceSearchOptions { /** * Workspace root for the file + commit groups (and the commit `repoKey`). - * When empty/null those two groups stay empty while agents/sessions/skills - * still resolve, so a brand-new draft tab degrades gracefully (R8). + * When empty/null those two groups stay empty while agents/sessions still + * resolve, so a brand-new draft tab degrades gracefully (R8). */ defaultPath?: string | null - /** Active agent type, scoping the skill + expert lists. */ - agentType?: AgentType | null /** * Gates loading. When false the search resolves to empty groups and the file * tree is never fetched — let the host pre-warm only the active composer. @@ -227,9 +184,10 @@ export interface UseReferenceSearchOptions { } /** - * Compose the live data sources (file tree, ACP agents, conversations, git log, - * skills, experts) into a single {@link ReferenceSearch} for the composer's `@` - * panel. + * Compose the live data sources (file tree, ACP agents, conversations, git log) + * into a single {@link ReferenceSearch} for the composer's `@` panel. (Skills, + * commands and experts are inserted via the `/` / `$` triggers and the expert + * menu, not this panel.) * * Referential stability is the contract: the suggestion popup re-runs its fetch * whenever the `search` identity changes (`suggestion-popup.tsx`), so the @@ -238,28 +196,23 @@ export interface UseReferenceSearchOptions { * on window focus) updates the refs but leaves `search` identity untouched — the * open panel keeps its results and the user's selection (R7). * - * Files/agents/skills/experts are hook-loaded (and pre-warmed via `enabled`). - * Sessions and the git log are fetched lazily on the first `@`, key-cached in a - * ref, and awaited by `search` so the first open is populated without an extra - * keystroke; window focus busts those caches so they stay fresh. + * Files and agents are hook-loaded (and pre-warmed via `enabled`). Sessions and + * the git log are fetched lazily on the first `@`, key-cached in a ref, and + * awaited by `search` so the first open is populated without an extra keystroke; + * window focus busts those caches so they stay fresh. */ export function useReferenceSearch({ defaultPath, - agentType = null, enabled = true, labels, }: UseReferenceSearchOptions): ReferenceSearch { const path = defaultPath || null - const locale = useLocale() const { allFiles, loaded } = useFileTree({ folderPath: path ?? undefined, enabled, }) const { agents } = useAcpAgents() - const skills = useAgentSkills(agentType, path) - const builtInExperts = useBuiltInExperts() - const agentExperts = useAgentExperts(agentType) // Mirror every changing source into a ref so `search` can stay identity-stable // (see the doc comment). Initialized from the first render so the refs are @@ -269,10 +222,6 @@ export function useReferenceSearch({ files: [], }) const agentsRef = useRef(agents) - const skillsRef = useRef(skills) - const builtInExpertsRef = useRef(builtInExperts) - const agentExpertsRef = useRef(agentExperts) - const localeRef = useRef(locale) const pathRef = useRef(path) const enabledRef = useRef(enabled) const labelsRef = useRef(labels) @@ -297,22 +246,8 @@ export function useReferenceSearch({ ? { root: path, files: allFiles } : { root: null, files: [] } agentsRef.current = agents - skillsRef.current = skills - builtInExpertsRef.current = builtInExperts - agentExpertsRef.current = agentExperts - localeRef.current = locale labelsRef.current = labels - }, [ - allFiles, - loaded, - path, - agents, - skills, - builtInExperts, - agentExperts, - locale, - labels, - ]) + }, [allFiles, loaded, path, agents, labels]) // Lazily-fetched network sources, key-cached so repeat searches reuse the // in-flight/resolved promise while a folder switch refetches. @@ -404,10 +339,6 @@ export function useReferenceSearch({ sessions, commits, repoKey: path, - skills: skillsRef.current, - builtInExperts: builtInExpertsRef.current, - agentExperts: agentExpertsRef.current, - locale: localeRef.current, }, labelsRef.current ?? DEFAULT_GROUP_LABELS ) diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 8f15daa08..8f9a2de9b 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -553,7 +553,6 @@ export function MessageInput({ // this composer is the active one (`enabled`). Referentially stable. const referenceSearch = useReferenceSearch({ defaultPath: defaultPath ?? null, - agentType: agentType ?? null, enabled: isActive, labels: referenceGroupLabels, }) From 9da129465866f1c9c33e6303f35387f0d4b7404c Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Fri, 12 Jun 2026 12:23:49 +0800 Subject: [PATCH 20/31] feat(composer): / $ and expert menu insert inline badges (P8c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `/` (commands), `$` (Codex skills) triggers and the expert menu now insert inline reference badges (refType `skill`) instead of plain text. On send each badge serializes back to its literal invocation token (`/cmd`, `$skill`, `/expert`) via referenceToMarkdown, so the agent CLI sees exactly what it did before — the badges are a composer-only nicety (the transcript shows the literal text, which is correct). - new invocation-reference.ts: pure builders commandToReference / skillToReference / expertToReference → ReferenceAttrs (meta.invocationPrefix; experts also meta.scope "expert" → star icon). - composer-commands: applyExpertPrefix → applyExpertReference — front-anchors the expert badge (prepends a paragraph when the first block isn't one) and replaces an existing leading expert badge rather than stacking. Replacement keys solely on meta.scope === "expert" (the unambiguous badge marker), which also fixes a latent bug where agent-linked experts — absent from the built-in id set the old code checked — would stack (Codex review). - message-input: replaceTriggerToken → replaceTriggerWithReference; the slash/ skill/"+"-command/expert handlers build refs and insert badges. Expert badge label matches the menu's pickExpertLocalized name. Gate: vitest 1360 pass, eslint clean, next build OK. Codex APPROVED. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../chat/composer/composer-commands.test.ts | 57 ++++++---- .../chat/composer/composer-commands.ts | 75 ++++++------- .../composer/invocation-reference.test.ts | 104 ++++++++++++++++++ .../chat/composer/invocation-reference.ts | 63 +++++++++++ src/components/chat/message-input.tsx | 79 +++++++------ 5 files changed, 287 insertions(+), 91 deletions(-) create mode 100644 src/components/chat/composer/invocation-reference.test.ts create mode 100644 src/components/chat/composer/invocation-reference.ts diff --git a/src/components/chat/composer/composer-commands.test.ts b/src/components/chat/composer/composer-commands.test.ts index 46c1b2c97..e9a18a5f5 100644 --- a/src/components/chat/composer/composer-commands.test.ts +++ b/src/components/chat/composer/composer-commands.test.ts @@ -4,11 +4,23 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest" import type { PromptInputBlock } from "@/lib/types" import { - applyExpertPrefix, + applyExpertReference, isComposerEmpty, restoreBlocksIntoEditor, } from "./composer-commands" import { buildComposerExtensions } from "./editor-config" +import type { ReferenceAttrs } from "./types" + +/** An expert reference (refType `skill`, `meta.scope === "expert"`). */ +function expertAttrs(id: string, prefix: "/" | "$" = "/"): ReferenceAttrs { + return { + refType: "skill", + id, + label: id, + uri: null, + meta: { scope: "expert", invocationPrefix: prefix }, + } +} describe("isComposerEmpty", () => { let editor: Editor @@ -46,7 +58,7 @@ describe("isComposerEmpty", () => { }) }) -describe("applyExpertPrefix", () => { +describe("applyExpertReference", () => { let editor: Editor beforeEach(() => { @@ -54,47 +66,54 @@ describe("applyExpertPrefix", () => { }) afterEach(() => editor?.destroy()) - it("prepends the prefix to an empty document", () => { - applyExpertPrefix(editor, "/", "reviewer", new Set()) + it("prepends an expert badge to an empty document", () => { + applyExpertReference(editor, expertAttrs("reviewer")) + // The badge is a real reference node (not plain text)… + expect(JSON.stringify(editor.getJSON())).toContain('"refType":"skill"') + // …that serializes to its `/reviewer` invocation token at the front. expect(editor.getMarkdown().trimStart()).toMatch(/^\/reviewer\b/) }) - it("prepends the prefix in front of existing prose", () => { + it("prepends the badge in front of existing prose", () => { editor.commands.setContent("look at this", { contentType: "markdown" }) - applyExpertPrefix(editor, "/", "reviewer", new Set()) + applyExpertReference(editor, expertAttrs("reviewer")) expect(editor.getMarkdown().trimStart()).toMatch(/^\/reviewer look at this/) }) - it("replaces an existing known expert prefix instead of stacking", () => { - editor.commands.setContent("/old keep this", { contentType: "markdown" }) - applyExpertPrefix(editor, "/", "reviewer", new Set(["old"])) + it("replaces an existing leading expert badge instead of stacking", () => { + applyExpertReference(editor, expertAttrs("old")) + applyExpertReference(editor, expertAttrs("reviewer")) const md = editor.getMarkdown() - expect(md.trimStart()).toMatch(/^\/reviewer keep this/) - expect(md).not.toContain("old") + expect(md.trimStart()).toMatch(/^\/reviewer\b/) + expect(md).not.toContain("/old") + // Exactly one expert badge remains. + expect( + JSON.stringify(editor.getJSON()).match(/"refType":"skill"/g) + ).toHaveLength(1) }) - it("does NOT replace a leading token that isn't a known expert", () => { + it("does NOT replace a leading plain-text token (only a real expert badge)", () => { editor.commands.setContent("/unknown keep", { contentType: "markdown" }) - applyExpertPrefix(editor, "/", "reviewer", new Set(["old"])) + applyExpertReference(editor, expertAttrs("reviewer")) const md = editor.getMarkdown() expect(md.trimStart()).toMatch(/^\/reviewer /) expect(md).toContain("/unknown") }) - it("keeps the prefix ahead of a heading's Markdown marker (regression)", () => { + it("keeps the badge ahead of a heading's Markdown marker (regression)", () => { // First block is a heading: inserting inline at pos 1 would serialize as - // `# /reviewer Title` (marker first). The prefix must lead the message. + // `# /reviewer Title` (marker first). The badge must lead the message. editor.commands.setContent("# Title", { contentType: "markdown" }) - applyExpertPrefix(editor, "/", "reviewer", new Set()) + applyExpertReference(editor, expertAttrs("reviewer")) const md = editor.getMarkdown() expect(md.trimStart()).toMatch(/^\/reviewer/) expect(md).toContain("# Title") expect(md.indexOf("/reviewer")).toBeLessThan(md.indexOf("# Title")) }) - it("keeps the prefix ahead of a list's Markdown marker", () => { + it("keeps the badge ahead of a list's Markdown marker", () => { editor.commands.setContent("- one\n- two", { contentType: "markdown" }) - applyExpertPrefix(editor, "/", "reviewer", new Set()) + applyExpertReference(editor, expertAttrs("reviewer")) const md = editor.getMarkdown() expect(md.trimStart()).toMatch(/^\/reviewer/) expect(md.indexOf("/reviewer")).toBeLessThan(md.indexOf("one")) @@ -102,7 +121,7 @@ describe("applyExpertPrefix", () => { it("supports the Codex `$` prefix", () => { editor.commands.setContent("ship it", { contentType: "markdown" }) - applyExpertPrefix(editor, "$", "deploy", new Set()) + applyExpertReference(editor, expertAttrs("deploy", "$")) expect(editor.getMarkdown().trimStart()).toMatch(/^\$deploy ship it/) }) }) diff --git a/src/components/chat/composer/composer-commands.ts b/src/components/chat/composer/composer-commands.ts index b79676ab3..70c09b376 100644 --- a/src/components/chat/composer/composer-commands.ts +++ b/src/components/chat/composer/composer-commands.ts @@ -4,6 +4,7 @@ import type { PromptInputBlock } from "@/lib/types" import type { InputAttachment } from "../message-input-attachments" import { blocksToRestoredDraft } from "./from-prompt-blocks" +import type { ReferenceAttrs } from "./types" /** * Whether the composer has nothing sendable. Stricter than `editor.isEmpty`, @@ -27,59 +28,59 @@ export function isComposerEmpty(editor: Editor): boolean { } /** - * Inject `prefix + expertId + " "` as the leading token of the message — experts - * are whole-turn directives the agent inspects first, so they go at the very - * front, never at the caret. + * Insert an expert as the leading inline badge of the message — experts are + * whole-turn directives the agent inspects first, so the badge goes at the very + * front (and serializes to `${prefix}${id}` as the first token), never at the + * caret. `attrs` is an expert reference (refType `skill`, `meta.scope === "expert"`). * - * The prefix must be the FIRST token of the *serialized* Markdown. Inserting - * inline at position 1 only achieves that when the first block is a paragraph; - * for a heading/list/quote/code block the Markdown marker (`# `, `- `, `> `, …) - * would serialize before the prefix, so a fresh paragraph is prepended instead. - * When the first block is a paragraph already carrying an expert prefix (from a - * prior click), it is replaced rather than stacked — the agent only honors the - * first directive. + * The badge must be the FIRST inline node of the FIRST block. Inserting at + * position 1 only achieves that when the first block is a paragraph; for a + * heading/list/quote/code block the Markdown marker (`# `, `- `, `> `, …) would + * serialize before it, so a fresh paragraph is prepended instead. When the first + * block already opens with an expert badge (from a prior pick), it is replaced + * rather than stacked — the agent only honors the first directive. */ -export function applyExpertPrefix( +export function applyExpertReference( editor: Editor, - prefix: string, - expertId: string, - knownExpertIds: ReadonlySet<string> + attrs: ReferenceAttrs ): void { - const insertion = `${prefix}${expertId} ` + const badge = [ + { type: "reference", attrs }, + { type: "text", text: " " }, + ] const first = editor.state.doc.firstChild - if (first && first.type.name !== "paragraph") { + // First block isn't a paragraph: prepend a fresh one so the badge is the very + // first inline content (cursor lands just after the badge + its space, pos 3). + if (!first || first.type.name !== "paragraph") { editor .chain() .focus() - .insertContentAt(0, { - type: "paragraph", - content: [{ type: "text", text: insertion }], - }) - .setTextSelection(insertion.length + 1) + .insertContentAt(0, { type: "paragraph", content: badge }) + .setTextSelection(3) .run() return } - const leading = first - ? first.textBetween(0, Math.min(first.content.size, 80), undefined, " ") - : "" - const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") - const existing = leading.match( - new RegExp(`^${escapedPrefix}([A-Za-z0-9_-]+)\\s`) - ) - const replaceLen = - existing && knownExpertIds.has(existing[1]) ? existing[0].length : 0 + // Paragraph: replace an existing leading expert badge (atom at pos 1) if any, + // taking one following space with it so the replacement doesn't stack spaces. + // `meta.scope === "expert"` is the unambiguous marker — only expert references + // carry it (commands/skills don't), so no extra id allow-list is needed (and + // an allow-list would false-negative on agent-linked experts → stacking). + const firstChild = first.firstChild + const isExpertBadge = + firstChild?.type.name === "reference" && + firstChild.attrs.refType === "skill" && + firstChild.attrs.meta?.scope === "expert" - // Position 1 is just inside the first block (after its opening boundary). let chain = editor.chain().focus() - if (replaceLen > 0) { - chain = chain.deleteRange({ from: 1, to: 1 + replaceLen }) + if (isExpertBadge) { + const afterBadge = first.maybeChild(1) + const trailingSpace = + afterBadge?.isText && afterBadge.text?.startsWith(" ") ? 1 : 0 + chain = chain.deleteRange({ from: 1, to: 2 + trailingSpace }) } - chain - .insertContentAt(1, insertion) - .setTextSelection(1 + insertion.length) - .run() + chain.insertContentAt(1, badge).setTextSelection(3).run() } /** diff --git a/src/components/chat/composer/invocation-reference.test.ts b/src/components/chat/composer/invocation-reference.test.ts new file mode 100644 index 000000000..3dda1173d --- /dev/null +++ b/src/components/chat/composer/invocation-reference.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest" + +import type { + AgentSkillItem, + AvailableCommandInfo, + ExpertListItem, +} from "@/lib/types" + +import { + commandToReference, + expertToReference, + skillToReference, +} from "./invocation-reference" +import { referenceToMarkdown } from "./reference-text" + +const cmd = (name: string): AvailableCommandInfo => ({ + name, + description: `${name} command`, +}) + +const skill = (id: string, name: string): AgentSkillItem => + ({ + id, + name, + scope: "project", + layout: "markdown_file", + path: `/skills/${id}.md`, + description: "desc", + read_only: false, + }) as AgentSkillItem + +const expert = (id: string): ExpertListItem => ({ + metadata: { + id, + category: "review", + icon: null, + sort_order: 0, + display_name: { en: id }, + description: { en: `${id} desc` }, + bundled_hash: "h", + }, + installed_centrally: true, + user_modified: false, + central_path: `/experts/${id}`, +}) + +describe("commandToReference", () => { + it("builds a skill-kind reference that serializes to /name", () => { + const ref = commandToReference(cmd("build")) + expect(ref).toEqual({ + refType: "skill", + id: "build", + label: "build", + uri: null, + meta: { invocationPrefix: "/" }, + }) + expect(referenceToMarkdown(ref)).toBe("/build") + }) +}) + +describe("skillToReference", () => { + it("uses the `$` prefix for a Codex skill and keeps the friendly label", () => { + const ref = skillToReference(skill("deploy", "Deploy"), "$") + expect(ref).toMatchObject({ + refType: "skill", + id: "deploy", + label: "Deploy", + uri: null, + meta: { invocationPrefix: "$", scope: "project" }, + }) + // Serialization uses the id, not the label. + expect(referenceToMarkdown(ref)).toBe("$deploy") + }) + + it("uses the `/` prefix for a non-Codex skill", () => { + expect(referenceToMarkdown(skillToReference(skill("x", "X"), "/"))).toBe( + "/x" + ) + }) + + it("falls back to the id when the skill has no name", () => { + expect(skillToReference(skill("only-id", ""), "/").label).toBe("only-id") + }) +}) + +describe("expertToReference", () => { + it("builds an expert badge (scope=expert) with the given localized label", () => { + const ref = expertToReference(expert("reviewer"), "$", "审查员") + expect(ref).toEqual({ + refType: "skill", + id: "reviewer", + label: "审查员", + uri: null, + meta: { invocationPrefix: "$", scope: "expert" }, + }) + expect(referenceToMarkdown(ref)).toBe("$reviewer") + }) + + it("falls back to the id when the label is empty", () => { + expect(expertToReference(expert("reviewer"), "/", "").label).toBe( + "reviewer" + ) + }) +}) diff --git a/src/components/chat/composer/invocation-reference.ts b/src/components/chat/composer/invocation-reference.ts new file mode 100644 index 000000000..7fb80d1ee --- /dev/null +++ b/src/components/chat/composer/invocation-reference.ts @@ -0,0 +1,63 @@ +import type { + AgentSkillItem, + AvailableCommandInfo, + ExpertListItem, +} from "@/lib/types" + +import type { ReferenceAttrs } from "./types" + +/** + * Builders that turn a runtime command / skill / expert into the inline + * `reference` badge the composer embeds (refType `skill`). They carry no `uri`, + * so on send `referenceToMarkdown` serializes them to their literal invocation + * token `${prefix}${id}` — `/command`, `$skill`, `/expert` — exactly the text + * the agent CLI executes. `meta.invocationPrefix` drives that prefix; + * `meta.scope === "expert"` drives the badge's star icon + color (commands and + * skills get the command glyph). + */ + +export type InvocationPrefix = "/" | "$" + +/** A `/`-triggered ACP slash command → command badge (always `/name`). */ +export function commandToReference(cmd: AvailableCommandInfo): ReferenceAttrs { + return { + refType: "skill", + id: cmd.name, + label: cmd.name, + uri: null, + meta: { invocationPrefix: "/" }, + } +} + +/** A `/`- or `$`-triggered agent skill → skill badge (`${prefix}${id}`). */ +export function skillToReference( + skill: AgentSkillItem, + prefix: InvocationPrefix +): ReferenceAttrs { + return { + refType: "skill", + id: skill.id, + label: skill.name || skill.id, + uri: null, + meta: { invocationPrefix: prefix, scope: skill.scope }, + } +} + +/** + * An expert (built-in or agent-linked) → expert badge (star icon). `label` is the + * already-localized display name (the caller resolves it the same way the expert + * menu does, so the badge reads identically to the row that was clicked). + */ +export function expertToReference( + expert: ExpertListItem, + prefix: InvocationPrefix, + label: string +): ReferenceAttrs { + return { + refType: "skill", + id: expert.metadata.id, + label: label || expert.metadata.id, + uri: null, + meta: { invocationPrefix: prefix, scope: "expert" }, + } +} diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 8f9a2de9b..e5000e6f7 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -112,10 +112,16 @@ import { } from "@/components/chat/composer/rich-composer" import { docToPromptBlocks } from "@/components/chat/composer/to-prompt-blocks" import { - applyExpertPrefix, + applyExpertReference, isComposerEmpty, restoreBlocksIntoEditor, } from "@/components/chat/composer/composer-commands" +import { + commandToReference, + expertToReference, + skillToReference, +} from "@/components/chat/composer/invocation-reference" +import type { ReferenceAttrs } from "@/components/chat/composer/types" import { useReferenceSearch, type ReferenceGroupLabels, @@ -1443,10 +1449,12 @@ export function MessageInput({ }, []) // Replace the live `/`-or-`$` token immediately before the caret with - // `insertion` (+ a trailing space unless one already follows), then close the - // menu. Used by both the command (`/`) and Codex-skill (`$`) selections. - const replaceTriggerToken = useCallback( - (insertion: string) => { + // an inline reference badge (+ a trailing space unless one already follows), + // then close the menu. Used by both the command (`/`) and Codex-skill (`$`) + // selections — the badge serializes back to its literal `/cmd` / `$skill` + // token on send (see invocation-reference / referenceToMarkdown). + const replaceTriggerWithReference = useCallback( + (ref: ReferenceAttrs) => { const editor = editorRef.current?.getEditor() if (!editor) return const { $from } = editor.state.selection @@ -1467,20 +1475,15 @@ export function MessageInput({ ) : "" const suffix = charAfter && /\s/.test(charAfter) ? "" : " " - if (!match) { - // No live trigger token (shouldn't normally happen) — insert at caret. - editor.chain().focus().insertContent(`${insertion}${suffix}`).run() - closeSlashMenu() - return + let chain = editor.chain().focus() + if (match) { + // Remove the live `/…` / `$…` token before the caret. + const tokenLen = match[2].length + match[3].length + chain = chain.deleteRange({ from: $from.pos - tokenLen, to: $from.pos }) } - const tokenLen = match[2].length + match[3].length - const from = $from.pos - tokenLen - editor - .chain() - .focus() - .deleteRange({ from, to: $from.pos }) - .insertContent(`${insertion}${suffix}`) - .run() + chain = chain.insertReference(ref) + if (suffix) chain = chain.insertContent(suffix) + chain.run() closeSlashMenu() }, [closeSlashMenu] @@ -1488,21 +1491,22 @@ export function MessageInput({ const handleSlashSelect = useCallback( (cmd: AvailableCommandInfo) => { - replaceTriggerToken(`/${cmd.name}`) + replaceTriggerWithReference(commandToReference(cmd)) }, - [replaceTriggerToken] + [replaceTriggerWithReference] ) // Codex uses `$<id>`, other agents `/<id>` — matching the trigger prefix. const handleSkillAutocompleteSelect = useCallback( (skill: AgentSkillItem) => { - replaceTriggerToken(`${expertPrefix}${skill.id}`) + replaceTriggerWithReference(skillToReference(skill, expertPrefix)) }, - [replaceTriggerToken, expertPrefix] + [replaceTriggerWithReference, expertPrefix] ) - // The "+" → Slash commands picker inserts at the current caret (no trigger - // token to replace), adding a leading space if the caret isn't at a boundary. + // The "+" → Slash commands picker inserts a command badge at the current caret + // (no trigger token to replace), adding a leading space if the caret isn't at + // a boundary, and a trailing space after. const handleSlashPopoverSelect = useCallback((cmd: AvailableCommandInfo) => { const editor = editorRef.current?.getEditor() if (!editor) return @@ -1517,24 +1521,29 @@ export function MessageInput({ ) : "" const needsSpace = charBefore !== "" && !/\s/.test(charBefore) - editor - .chain() - .focus() - .insertContent(`${needsSpace ? " " : ""}/${cmd.name} `) - .run() + let chain = editor.chain().focus() + if (needsSpace) chain = chain.insertContent(" ") + chain.insertReference(commandToReference(cmd)).insertContent(" ").run() }, []) - // Experts always inject `prefix + expert-id ` at the very front of the input, - // never at the cursor — the expert skill is a whole-turn directive the agent - // inspects first. If an expert prefix is already at the front (from a prior - // click), replace it instead of stacking (the agent only honors the first). + // Experts always inject an expert badge at the very front of the input, never + // at the cursor — the expert skill is a whole-turn directive the agent inspects + // first. If an expert badge is already at the front (from a prior click), it is + // replaced instead of stacked (the agent only honors the first). The badge + // label matches the expert menu's localized name. const handleExpertPopoverSelect = useCallback( (expert: ExpertListItem) => { const editor = editorRef.current?.getEditor() if (!editor) return - applyExpertPrefix(editor, expertPrefix, expert.metadata.id, expertIdSet) + const label = + pickExpertLocalized(expert.metadata.display_name, locale) || + expert.metadata.id + applyExpertReference( + editor, + expertToReference(expert, expertPrefix, label) + ) }, - [expertIdSet, expertPrefix] + [expertPrefix, locale] ) const handlePickFiles = useCallback(async () => { From a05b316860707fce01db67dbc1a35b0483c196a3 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Fri, 12 Jun 2026 12:41:06 +0800 Subject: [PATCH 21/31] feat(composer): click the input's empty chrome to focus the editor (P8d) Previously only the editor surface itself was clickable; the card's padding, the blank space below a short message, and the gaps in the action bar were dead. Now a mousedown on that empty chrome focuses the editor. - new pure helper isComposerChromeClick(target) in composer-commands.ts: true unless the target (or an ancestor) is the editor surface, an interactive control, or an inline badge. - message-input: a handleChromeMouseDown handler on the bordered card preventDefaults (so the editor doesn't blur first) and refocuses the editor when the click lands on empty chrome; bails on disabled. Gate: vitest 1365 pass, eslint clean, next build OK. Codex APPROVED (Findings: none). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../chat/composer/composer-commands.test.ts | 35 +++++++++++++++++++ .../chat/composer/composer-commands.ts | 17 +++++++++ src/components/chat/message-input.test.tsx | 16 ++++++++- src/components/chat/message-input.tsx | 17 +++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/components/chat/composer/composer-commands.test.ts b/src/components/chat/composer/composer-commands.test.ts index e9a18a5f5..512ac6f0b 100644 --- a/src/components/chat/composer/composer-commands.test.ts +++ b/src/components/chat/composer/composer-commands.test.ts @@ -5,6 +5,7 @@ import type { PromptInputBlock } from "@/lib/types" import { applyExpertReference, + isComposerChromeClick, isComposerEmpty, restoreBlocksIntoEditor, } from "./composer-commands" @@ -58,6 +59,40 @@ describe("isComposerEmpty", () => { }) }) +describe("isComposerChromeClick", () => { + it("treats a click on bare chrome (a plain div) as an empty-chrome click", () => { + expect(isComposerChromeClick(document.createElement("div"))).toBe(true) + }) + + it("excludes interactive controls and their descendants", () => { + const button = document.createElement("button") + const icon = document.createElement("span") + button.appendChild(icon) + expect(isComposerChromeClick(button)).toBe(false) + // closest() walks up, so a click on the button's icon is excluded too. + expect(isComposerChromeClick(icon)).toBe(false) + + const roleButton = document.createElement("div") + roleButton.setAttribute("role", "button") + expect(isComposerChromeClick(roleButton)).toBe(false) + }) + + it("excludes the editor surface and inline badges", () => { + const pm = document.createElement("div") + pm.className = "ProseMirror" + expect(isComposerChromeClick(pm)).toBe(false) + + const badge = document.createElement("span") + badge.setAttribute("data-reference-badge", "") + expect(isComposerChromeClick(badge)).toBe(false) + }) + + it("returns false for null / non-Element targets", () => { + expect(isComposerChromeClick(null)).toBe(false) + expect(isComposerChromeClick(document)).toBe(false) + }) +}) + describe("applyExpertReference", () => { let editor: Editor diff --git a/src/components/chat/composer/composer-commands.ts b/src/components/chat/composer/composer-commands.ts index 70c09b376..e1f010f84 100644 --- a/src/components/chat/composer/composer-commands.ts +++ b/src/components/chat/composer/composer-commands.ts @@ -27,6 +27,23 @@ export function isComposerEmpty(editor: Editor): boolean { return !hasReference } +// Elements that own their own click behavior: the editor surface, interactive +// controls, and inline badges. A mousedown landing on any of these (or a +// descendant) is NOT an "empty chrome" click. +const NON_CHROME_SELECTOR = + '.ProseMirror, button, a, input, textarea, select, [role="button"], [role="combobox"], [role="menuitem"], [data-reference-badge], [contenteditable]' + +/** + * Whether a mousedown `target` landed on the message input's empty chrome — its + * padding, the blank space below a short message, or the gaps in the action bar + * — rather than on the editor surface or an interactive control. The host uses + * this to focus the editor when the user clicks the otherwise-dead space around + * it (only the editor surface itself used to be clickable). + */ +export function isComposerChromeClick(target: EventTarget | null): boolean { + return target instanceof Element && !target.closest(NON_CHROME_SELECTOR) +} + /** * Insert an expert as the leading inline badge of the message — experts are * whole-turn directives the agent inspects first, so the badge goes at the very diff --git a/src/components/chat/message-input.test.tsx b/src/components/chat/message-input.test.tsx index a8a87a196..3a1af5c52 100644 --- a/src/components/chat/message-input.test.tsx +++ b/src/components/chat/message-input.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor, cleanup } from "@testing-library/react" +import { render, waitFor, cleanup, fireEvent } from "@testing-library/react" import { NextIntlClientProvider } from "next-intl" import { afterEach, describe, expect, it, vi } from "vitest" @@ -79,4 +79,18 @@ describe("MessageInput (RichComposer integration)", () => { expect(sendButton).not.toBeNull() expect(sendButton).toBeDisabled() }) + + it("claims a mousedown on the input's empty chrome (P8d focus wiring)", async () => { + const { container } = renderInput({}) + await waitFor(() => + expect(container.querySelector('[role="textbox"]')).not.toBeNull() + ) + // The bordered card carries the chrome-focus handler; a mousedown on the + // card itself (not on the editor or a control) is claimed via preventDefault + // before refocusing the editor. Asserting preventDefault (fireEvent returns + // false when the event was canceled) avoids relying on jsdom focus. + const card = container.querySelector('[class~="@container"]') as HTMLElement + expect(card).not.toBeNull() + expect(fireEvent.mouseDown(card)).toBe(false) + }) }) diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index e5000e6f7..33ccd5b35 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -113,6 +113,7 @@ import { import { docToPromptBlocks } from "@/components/chat/composer/to-prompt-blocks" import { applyExpertReference, + isComposerChromeClick, isComposerEmpty, restoreBlocksIntoEditor, } from "@/components/chat/composer/composer-commands" @@ -1975,6 +1976,21 @@ export function MessageInput({ [isEditingQueueItem, slashMenuOpen, onCancelQueueEdit] ) + // Clicking the input's empty chrome (its padding, the blank space below a + // short message, the gaps in the action bar) focuses the editor — previously + // only the editor surface itself was clickable. Interactive controls, inline + // badges and the editor surface handle their own clicks, so they're excluded; + // `preventDefault` keeps the editor from blurring before we refocus it. + const handleChromeMouseDown = useCallback( + (e: React.MouseEvent<HTMLDivElement>) => { + if (disabled || !isComposerChromeClick(e.target)) return + // Keep the editor from blurring before we refocus it. + e.preventDefault() + editorRef.current?.focus() + }, + [disabled] + ) + const handleContainerDragOver = useCallback( (event: React.DragEvent<HTMLDivElement>) => { if (!hasDragFiles(event.dataTransfer)) return @@ -2226,6 +2242,7 @@ export function MessageInput({ )} > <div + onMouseDown={handleChromeMouseDown} className={cn( "@container relative flex flex-col bg-transparent transition-colors", folderBranchPickerAttached From 1267a0e2568ea6098cd94ea5c9f7e5ca312b76e9 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Fri, 12 Jun 2026 14:35:47 +0800 Subject: [PATCH 22/31] feat(composer): unify badge icon to the command glyph + text-only colors (P9a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per user request, inline reference badges are restyled: - Commands, skills and experts now all render the SAME command glyph (the expert star is gone). `meta.scope === "expert"` is kept in the data model only for the editor's expert-replace logic, not the icon/color. - Badges drop the background, border, rounded corners and padding — they are now text-only colored tokens that sit cleanly on the user-message bubble (`bg-secondary`). One color per kind: file blue / agent violet / session emerald / commit amber / skill (cmd) rose. Light shades are `-700` so they clear WCAG AA contrast on the near-white bubble; dark shades `-400`. Gate: vitest 1365 pass, eslint clean, next build OK. Codex APPROVED. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../composer/badges/reference-badge.test.tsx | 27 +++++------ .../chat/composer/badges/reference-badge.tsx | 45 +++++++------------ .../chat/composer/invocation-reference.ts | 8 ++-- src/components/chat/composer/types.ts | 5 ++- 4 files changed, 39 insertions(+), 46 deletions(-) diff --git a/src/components/chat/composer/badges/reference-badge.test.tsx b/src/components/chat/composer/badges/reference-badge.test.tsx index b7be440c2..e973ef7cd 100644 --- a/src/components/chat/composer/badges/reference-badge.test.tsx +++ b/src/components/chat/composer/badges/reference-badge.test.tsx @@ -34,28 +34,31 @@ describe("ReferenceBadge", () => { expect(badge).not.toHaveClass("align-baseline") }) - it("tints a file reference blue", () => { + it("colors a file reference blue with no background or border", () => { const { container } = render( <ReferenceBadge data={ref({ refType: "file", label: "app.ts" })} /> ) const badge = badgeOf(container) expect(badge).toHaveAttribute("data-ref-type", "file") - expect(badge).toHaveClass("bg-blue-50", "text-blue-700") + // Text-only: a color, but no background / border (req 3). + expect(badge).toHaveClass("text-blue-700") + expect(badge.className).not.toMatch(/\bbg-/) + expect(badge.className).not.toMatch(/\bborder\b/) expect(container.querySelector(".lucide-file-text")).not.toBeNull() }) - it("tints a session reference emerald", () => { + it("colors a session reference emerald", () => { const { container } = render( <ReferenceBadge data={ref({ refType: "session", label: "#42" })} /> ) const badge = badgeOf(container) expect(badge).toHaveAttribute("data-ref-type", "session") - expect(badge).toHaveClass("bg-emerald-50", "text-emerald-700") + expect(badge).toHaveClass("text-emerald-700") // No agentType meta → falls back to the Hash icon. expect(container.querySelector(".lucide-hash")).not.toBeNull() }) - it("renders a command/skill with the command glyph, tinted sky", () => { + it("renders a command/skill with the command glyph, colored rose", () => { const { container } = render( <ReferenceBadge data={ref({ @@ -68,13 +71,11 @@ describe("ReferenceBadge", () => { ) const badge = badgeOf(container) expect(badge).toHaveAttribute("data-ref-type", "skill") - expect(badge).toHaveClass("bg-sky-50", "text-sky-700") - // Command glyph, not the star. + expect(badge).toHaveClass("text-rose-700") expect(container.querySelector(".lucide-command")).not.toBeNull() - expect(container.querySelector(".lucide-sparkles")).toBeNull() }) - it("renders an expert with the star glyph, tinted fuchsia", () => { + it("renders an expert with the SAME command glyph + color (unified, no star)", () => { const { container } = render( <ReferenceBadge data={ref({ @@ -87,9 +88,9 @@ describe("ReferenceBadge", () => { ) const badge = badgeOf(container) expect(badge).toHaveAttribute("data-ref-type", "skill") - expect(badge).toHaveClass("bg-fuchsia-50", "text-fuchsia-700") - // Star glyph, not the command. - expect(container.querySelector(".lucide-sparkles")).not.toBeNull() - expect(container.querySelector(".lucide-command")).toBeNull() + // Experts are no longer distinguished — same rose color, command glyph. + expect(badge).toHaveClass("text-rose-700") + expect(container.querySelector(".lucide-command")).not.toBeNull() + expect(container.querySelector(".lucide-sparkles")).toBeNull() }) }) diff --git a/src/components/chat/composer/badges/reference-badge.tsx b/src/components/chat/composer/badges/reference-badge.tsx index f775b079e..6fd8367df 100644 --- a/src/components/chat/composer/badges/reference-badge.tsx +++ b/src/components/chat/composer/badges/reference-badge.tsx @@ -1,12 +1,4 @@ -import { - Bot, - Command, - FileText, - Folder, - GitCommit, - Hash, - Sparkles, -} from "lucide-react" +import { Bot, Command, FileText, Folder, GitCommit, Hash } from "lucide-react" import type { ReactNode } from "react" import { AgentIcon } from "@/components/agent-icon" @@ -53,14 +45,10 @@ export function ReferenceIcon({ data }: { data: ReferenceAttrs }) { icon = <GitCommit className={ICON_CLASS} /> break case "skill": - // Experts (whole-turn directives) get a star; commands / skills get the - // command glyph. See {@link badgeColorClass} for the matching color. - icon = - meta?.scope === "expert" ? ( - <Sparkles className={ICON_CLASS} /> - ) : ( - <Command className={ICON_CLASS} /> - ) + // Commands, skills and experts all use the command glyph — they aren't + // visually distinguished (the `meta.scope` distinction is kept only for + // the editor's expert-replace logic, not the icon). + icon = <Command className={ICON_CLASS} /> break default: return null @@ -77,24 +65,25 @@ export function ReferenceIcon({ data }: { data: ReferenceAttrs }) { } /** - * Soft tinted color set per reference kind (light + dark). `text-*` colors the - * label and — since the icon strokes with `currentColor` — the icon too. Skills - * split by scope: experts (star) are fuchsia, commands/skills (cmd) are sky. + * Per-kind text color (light + dark) — no background or border, so the badge + * reads as a colored inline token that sits cleanly on the user-message bubble + * (`bg-secondary`). `text-*` colors the label and, since the icon strokes with + * `currentColor`, the icon too. Commands/skills/experts share one color (they + * aren't distinguished). Light shades are `-700` so they clear WCAG AA contrast + * on the near-white bubble; dark shades are `-400` for the near-black one. */ function badgeColorClass(data: ReferenceAttrs): string { switch (data.refType) { case "file": - return "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-500/30 dark:bg-blue-500/15 dark:text-blue-300" + return "text-blue-700 dark:text-blue-400" case "agent": - return "border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-500/30 dark:bg-violet-500/15 dark:text-violet-300" + return "text-violet-700 dark:text-violet-400" case "session": - return "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/15 dark:text-emerald-300" + return "text-emerald-700 dark:text-emerald-400" case "commit": - return "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-300" + return "text-amber-700 dark:text-amber-400" case "skill": - return data.meta?.scope === "expert" - ? "border-fuchsia-200 bg-fuchsia-50 text-fuchsia-700 dark:border-fuchsia-500/30 dark:bg-fuchsia-500/15 dark:text-fuchsia-300" - : "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/15 dark:text-sky-300" + return "text-rose-700 dark:text-rose-400" } } @@ -125,7 +114,7 @@ export function ReferenceBadge({ data, className }: ReferenceBadgeProps) { role="img" aria-label={`${data.refType}: ${data.label || data.id}`} className={cn( - "inline-flex max-w-[18rem] items-center gap-1 rounded-md border px-1.5 py-px align-middle text-[0.85em] leading-snug", + "inline-flex max-w-[18rem] items-center gap-0.5 align-middle text-[0.85em] font-medium leading-snug", badgeColorClass(data), className )} diff --git a/src/components/chat/composer/invocation-reference.ts b/src/components/chat/composer/invocation-reference.ts index 7fb80d1ee..3bea5e125 100644 --- a/src/components/chat/composer/invocation-reference.ts +++ b/src/components/chat/composer/invocation-reference.ts @@ -11,9 +11,9 @@ import type { ReferenceAttrs } from "./types" * `reference` badge the composer embeds (refType `skill`). They carry no `uri`, * so on send `referenceToMarkdown` serializes them to their literal invocation * token `${prefix}${id}` — `/command`, `$skill`, `/expert` — exactly the text - * the agent CLI executes. `meta.invocationPrefix` drives that prefix; - * `meta.scope === "expert"` drives the badge's star icon + color (commands and - * skills get the command glyph). + * the agent CLI executes. `meta.invocationPrefix` drives that prefix. + * `meta.scope === "expert"` is kept for the editor's expert-replace logic; all + * three render the same command-glyph badge (they aren't distinguished). */ export type InvocationPrefix = "/" | "$" @@ -44,7 +44,7 @@ export function skillToReference( } /** - * An expert (built-in or agent-linked) → expert badge (star icon). `label` is the + * An expert (built-in or agent-linked) → expert badge. `label` is the * already-localized display name (the caller resolves it the same way the expert * menu does, so the badge reads identically to the row that was clicked). */ diff --git a/src/components/chat/composer/types.ts b/src/components/chat/composer/types.ts index 6dfbae26b..c8a507887 100644 --- a/src/components/chat/composer/types.ts +++ b/src/components/chat/composer/types.ts @@ -36,7 +36,10 @@ export interface ReferenceMeta { author?: string /** commit: whether the commit is pushed upstream. */ pushed?: boolean | null - /** skill: "global" | "project" | "expert" scope ("expert" → star icon). */ + /** + * skill: "global" | "project" | "expert" scope. "expert" is read by the + * editor's expert-replace logic (not the badge — all skills share one icon). + */ scope?: string /** skill: category grouping. */ category?: string From 14990c134f4e23e687bba5d26c4e21af4b33807b Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Fri, 12 Jun 2026 14:53:05 +0800 Subject: [PATCH 23/31] =?UTF-8?q?feat(transcript):=20badge=20bare=20/comma?= =?UTF-8?q?nd=20=C2=B7=20$skill=20tokens=20in=20user=20messages=20(P9b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slash commands / Codex skills / experts are sent as bare invocation tokens (`/review`, `$deploy`) — the agent needs them literal, so there's no link to key a badge off. This restores the badge FOR DISPLAY in transcript user messages via a heuristic rehype plugin. - new ai-elements/rehype-command-badges.ts: a hand-rolled hast walk (no new deps, mirrors remark-file-uri-links) that scans text for `/slug`·`$slug` tokens and wraps each in a `codeg://skill/<slug>` link (keeping the literal prefix as the label), which the existing MarkdownLink → ReferenceBadge path renders. Skips code/pre, existing links, and math; skips paths (`/a/b`), digit-leading tokens, and word-glued tokens (`a/b`). - reference-uri.ts: parse `codeg://skill/<slug>` → refType skill. - message.tsx: apply the plugin ONLY to user messages (`softBreaks`), appended after harden and (per the Streamdown dist) before the math/katex rehype plugin — so `$x$` math (already a `.math` element by then) is never mistaken for a `$skill` token. Assistant messages are untouched; copy still yields bare text. Gate: vitest 1383 pass (rehype unit 10 + real-Streamdown integration 6 + uri 2), eslint clean, next build OK. Codex APPROVED (Findings: none). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../message-command-badge.test.tsx | 68 ++++++++++ src/components/ai-elements/message.tsx | 9 +- .../ai-elements/rehype-command-badges.test.ts | 119 ++++++++++++++++++ .../ai-elements/rehype-command-badges.ts | 117 +++++++++++++++++ .../chat/composer/reference-uri.test.ts | 18 +++ src/components/chat/composer/reference-uri.ts | 22 ++++ 6 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 src/components/ai-elements/message-command-badge.test.tsx create mode 100644 src/components/ai-elements/rehype-command-badges.test.ts create mode 100644 src/components/ai-elements/rehype-command-badges.ts diff --git a/src/components/ai-elements/message-command-badge.test.tsx b/src/components/ai-elements/message-command-badge.test.tsx new file mode 100644 index 000000000..d70127484 --- /dev/null +++ b/src/components/ai-elements/message-command-badge.test.tsx @@ -0,0 +1,68 @@ +import { render, waitFor } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +// Exercise the REAL Streamdown pipeline (no streamdown mock) so the assertion +// covers the actual rehype ordering — the command-badge plugin must run after +// sanitize/harden and before the math (katex) plugin. Only the link-safety hook +// is stubbed (irrelevant to badges). +vi.mock("@/components/ai-elements/link-safety", () => ({ + useStreamdownLinkSafety: () => ({ enabled: false }), +})) + +import { MessageResponse } from "./message" + +const skillBadge = (c: HTMLElement) => + c.querySelector("[data-reference-badge][data-ref-type='skill']") + +describe("MessageResponse — `/`·`$` tokens badge in user messages (real Streamdown)", () => { + it("badges a `/command` token (with its prefix) in a user message", async () => { + const { container } = render( + <MessageResponse softBreaks>{"run /review please"}</MessageResponse> + ) + await waitFor(() => expect(skillBadge(container)).not.toBeNull()) + expect(container.textContent).toContain("/review") + expect(container.textContent).toContain("please") + expect(container.textContent).not.toContain("[blocked]") + }) + + it("badges a `$skill` token in a user message", async () => { + const { container } = render( + <MessageResponse softBreaks>{"$deploy now"}</MessageResponse> + ) + await waitFor(() => expect(skillBadge(container)).not.toBeNull()) + expect(container.textContent).toContain("$deploy") + }) + + it("does NOT badge `/command` in an assistant message (no softBreaks)", async () => { + const { container } = render( + <MessageResponse>{"run /review please"}</MessageResponse> + ) + // Let the async block render, then assert the token stayed plain text. + await waitFor(() => expect(container.textContent).toContain("/review")) + expect(skillBadge(container)).toBeNull() + }) + + it("does NOT badge a file-ish path", async () => { + const { container } = render( + <MessageResponse softBreaks>{"see /usr/bin for it"}</MessageResponse> + ) + await waitFor(() => expect(container.textContent).toContain("/usr/bin")) + expect(skillBadge(container)).toBeNull() + }) + + it("does NOT badge a token inside inline code", async () => { + const { container } = render( + <MessageResponse softBreaks>{"type `/review` here"}</MessageResponse> + ) + await waitFor(() => expect(container.textContent).toContain("/review")) + expect(skillBadge(container)).toBeNull() + }) + + it("does NOT badge `$…$` math as a skill token", async () => { + const { container } = render( + <MessageResponse softBreaks>{"the value $x$ holds"}</MessageResponse> + ) + await waitFor(() => expect(container.textContent).toContain("holds")) + expect(skillBadge(container)).toBeNull() + }) +}) diff --git a/src/components/ai-elements/message.tsx b/src/components/ai-elements/message.tsx index 23305f400..2b6a6b827 100644 --- a/src/components/ai-elements/message.tsx +++ b/src/components/ai-elements/message.tsx @@ -34,6 +34,7 @@ import { } from "streamdown" import remarkBreaks from "remark-breaks" import { markdownLinkComponents } from "./markdown-link" +import { rehypeCommandBadges } from "./rehype-command-badges" import { rehypePluginsAllowingCodeg } from "./rehype-allow-codeg" import { remarkRewriteFileUriLinks } from "./remark-file-uri-links" @@ -394,6 +395,12 @@ const remarkPluginsWithBreaks = [...remarkPlugins, remarkBreaks] // MarkdownLink → ReferenceBadge. See rehype-allow-codeg for the full rationale. const rehypePlugins = rehypePluginsAllowingCodeg(defaultRehypePlugins) +// User messages additionally badge bare `/slash` / `$skill` invocation tokens. +// Appended AFTER harden so the injected `codeg://skill/…` links aren't stripped, +// and it runs before Streamdown's math (katex) rehype plugin so `$x$` math (by +// then a `.math` element) is skipped, not mistaken for a `$skill` token. +const rehypePluginsForUser = [...rehypePlugins, rehypeCommandBadges] + function MessageResponseImpl({ className, children, @@ -416,7 +423,7 @@ function MessageResponseImpl({ )} plugins={streamdownPlugins} remarkPlugins={softBreaks ? remarkPluginsWithBreaks : remarkPlugins} - rehypePlugins={rehypePlugins} + rehypePlugins={softBreaks ? rehypePluginsForUser : rehypePlugins} {...props} // Merge after spreading props so a caller can still override other // elements, but the link icon + safety routing on `a` always wins. diff --git a/src/components/ai-elements/rehype-command-badges.test.ts b/src/components/ai-elements/rehype-command-badges.test.ts new file mode 100644 index 000000000..e18eec631 --- /dev/null +++ b/src/components/ai-elements/rehype-command-badges.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest" + +import { rehypeCommandBadges } from "./rehype-command-badges" + +type Node = { + type: string + tagName?: string + value?: string + properties?: Record<string, unknown> + children?: Node[] +} + +const text = (value: string): Node => ({ type: "text", value }) +const el = ( + tagName: string, + children: Node[], + properties: Record<string, unknown> = {} +): Node => ({ type: "element", tagName, properties, children }) +const root = (children: Node[]): Node => ({ type: "root", children }) + +function run(tree: Node): Node { + rehypeCommandBadges()(tree) + return tree +} + +/** All `codeg://skill/…` anchors anywhere in the tree. */ +function skillAnchors(node: Node): Node[] { + const out: Node[] = [] + const walk = (n: Node) => { + if ( + n.type === "element" && + n.tagName === "a" && + typeof n.properties?.href === "string" && + n.properties.href.startsWith("codeg://skill/") + ) { + out.push(n) + } + n.children?.forEach(walk) + } + walk(node) + return out +} + +const anchorText = (a: Node) => + (a.children ?? []).map((c) => c.value ?? "").join("") + +describe("rehypeCommandBadges", () => { + it("badges a `/command` token, keeping the literal prefix", () => { + const tree = run(root([el("p", [text("run /review please")])])) + const anchors = skillAnchors(tree) + expect(anchors).toHaveLength(1) + expect(anchors[0].properties?.href).toBe("codeg://skill/review") + expect(anchorText(anchors[0])).toBe("/review") + }) + + it("badges a `$skill` token", () => { + const tree = run(root([el("p", [text("$deploy now")])])) + const anchors = skillAnchors(tree) + expect(anchors).toHaveLength(1) + expect(anchors[0].properties?.href).toBe("codeg://skill/deploy") + expect(anchorText(anchors[0])).toBe("$deploy") + }) + + it("badges multiple tokens and preserves the surrounding text", () => { + const tree = run(root([el("p", [text("/a and $b")])])) + const p = tree.children![0] + expect(skillAnchors(tree)).toHaveLength(2) + // [anchor /a][text " and "][anchor $b] + expect(p.children).toHaveLength(3) + expect(p.children![1]).toMatchObject({ type: "text", value: " and " }) + }) + + it("does NOT badge a file-ish path (`/usr/bin`)", () => { + expect(skillAnchors(run(root([el("p", [text("see /usr/bin")])])))).toEqual( + [] + ) + }) + + it("does NOT badge a digit-leading token", () => { + expect(skillAnchors(run(root([el("p", [text("/123 $5")])])))).toEqual([]) + }) + + it("does NOT badge a token glued to a preceding word (`a/b`)", () => { + expect(skillAnchors(run(root([el("p", [text("a/b x/y")])])))).toEqual([]) + }) + + it("skips text inside <code> and <pre>", () => { + const tree = run( + root([ + el("p", [el("code", [text("/review")])]), + el("pre", [el("code", [text("$deploy")])]), + ]) + ) + expect(skillAnchors(tree)).toEqual([]) + }) + + it("skips text inside an existing link (no nested anchors)", () => { + const tree = run( + root([ + el("p", [el("a", [text("/review")], { href: "https://example.com" })]), + ]) + ) + // Only the original non-codeg link remains; no codeg://skill anchor added. + expect(skillAnchors(tree)).toEqual([]) + }) + + it("skips text inside a math element (the `$` belongs to math)", () => { + const tree = run( + root([el("span", [text("$x")], { className: ["math", "math-inline"] })]) + ) + expect(skillAnchors(tree)).toEqual([]) + }) + + it("leaves a token-free tree untouched (same text node identity)", () => { + const original = text("hello world") + const tree = run(root([el("p", [original])])) + expect(tree.children![0].children![0]).toBe(original) + }) +}) diff --git a/src/components/ai-elements/rehype-command-badges.ts b/src/components/ai-elements/rehype-command-badges.ts new file mode 100644 index 000000000..a852dc013 --- /dev/null +++ b/src/components/ai-elements/rehype-command-badges.ts @@ -0,0 +1,117 @@ +// User messages send slash commands / Codex skills / experts as bare invocation +// tokens (`/review`, `$deploy`, `/code-reviewer`) — the agent CLI needs them +// literal, so there is no link to key a badge off. This rehype plugin restores +// the badge *for display* by scanning text nodes for `/slug` / `$slug` tokens and +// wrapping each in a `codeg://skill/<slug>` link, which MarkdownLink renders as a +// ReferenceBadge. It is intentionally a HEURISTIC (the token is indistinguishable +// from typed text), so it skips the obvious false positives: file-ish paths +// (`/a/b`), code, math, and existing links. +// +// Why rehype (not remark): Streamdown appends its math remark plugin AFTER the +// host's remark plugins, so at the remark stage `$x$` is still raw text and a +// `$slug` scan would corrupt math. Its rehype plugins run BEFORE the math +// (katex) rehype plugin, and by then remark-math has already turned `$x$` into a +// `.math` element with the `$` delimiters stripped — so a rehype-stage scan that +// skips `.math` (and code/pre/a) never collides with math. +// +// Apply only to user messages (the host gates on `softBreaks`); an assistant's +// `/path` or `$var` in prose must not be badged. + +type HastNode = { + type: string + tagName?: string + value?: string + properties?: { className?: unknown; [key: string]: unknown } + children?: HastNode[] +} + +// `/` commands & most skills, `$` Codex skills/experts. The slug starts with a +// letter (so `/123` / `$5` aren't matched) and the boundary before it must be +// start-of-text or whitespace. A trailing `/` (a path like `/usr/bin`) disqualifies +// it. `\w`/`-` in the lookahead are already excluded by the greedy slug; `/` is +// the meaningful guard. +const TOKEN_RE = /(^|\s)([/$][A-Za-z][A-Za-z0-9_-]*)(?![/\w-])/g + +/** Elements whose text must NOT be scanned: code, existing links, and math. */ +function isSkipElement(node: HastNode): boolean { + if (node.type !== "element") return false + if ( + node.tagName === "code" || + node.tagName === "pre" || + node.tagName === "a" + ) { + return true + } + const cls = node.properties?.className + const list = Array.isArray(cls) + ? cls + : typeof cls === "string" + ? cls.split(/\s+/) + : [] + return list.some( + (c) => + typeof c === "string" && + (c === "math" || c.startsWith("math-") || c.startsWith("katex")) + ) +} + +/** A `codeg://skill/<slug>` link whose text keeps the literal `/`·`$` prefix. */ +function badgeAnchor(token: string): HastNode { + const slug = token.slice(1) + return { + type: "element", + tagName: "a", + properties: { href: `codeg://skill/${encodeURIComponent(slug)}` }, + children: [{ type: "text", value: token }], + } +} + +/** + * Split a text value into `[text, anchor, text, …]`, or null when it has no + * invocation token (so the caller can keep the original node untouched). + */ +function tokenize(value: string): HastNode[] | null { + TOKEN_RE.lastIndex = 0 + let match: RegExpExecArray | null + let lastIndex = 0 + let out: HastNode[] | null = null + while ((match = TOKEN_RE.exec(value)) !== null) { + const token = match[2] + const tokenStart = match.index + match[1].length + out ??= [] + if (tokenStart > lastIndex) { + out.push({ type: "text", value: value.slice(lastIndex, tokenStart) }) + } + out.push(badgeAnchor(token)) + lastIndex = TOKEN_RE.lastIndex + } + if (out && lastIndex < value.length) { + out.push({ type: "text", value: value.slice(lastIndex) }) + } + return out +} + +function transform(node: HastNode, skip: boolean): void { + if (!Array.isArray(node.children)) return + const childrenSkip = skip || isSkipElement(node) + const next: HastNode[] = [] + for (const child of node.children) { + if (child.type === "text" && !childrenSkip) { + const tokens = + typeof child.value === "string" ? tokenize(child.value) : null + if (tokens) next.push(...tokens) + else next.push(child) + } else { + if (child.type === "element") transform(child, childrenSkip) + next.push(child) + } + } + node.children = next +} + +/** Rehype plugin: badge `/slug` / `$slug` tokens (user messages only). */ +export function rehypeCommandBadges() { + return (tree: HastNode) => { + transform(tree, false) + } +} diff --git a/src/components/chat/composer/reference-uri.test.ts b/src/components/chat/composer/reference-uri.test.ts index b20e6cb69..aa134a6b3 100644 --- a/src/components/chat/composer/reference-uri.test.ts +++ b/src/components/chat/composer/reference-uri.test.ts @@ -109,4 +109,22 @@ describe("parseCodegReferenceUri", () => { meta: { shortHash: "abc1234" }, }) }) + + it("parses a skill uri, keeping the literal `/`·`$` token as the label", () => { + expect( + parseCodegReferenceUri("codeg://skill/review", "/review") + ).toMatchObject({ + refType: "skill", + id: "review", + label: "/review", + uri: "codeg://skill/review", + meta: null, + }) + }) + + it("falls back to a /-prefixed id for an empty skill label", () => { + expect(parseCodegReferenceUri("codeg://skill/deploy", "")?.label).toBe( + "/deploy" + ) + }) }) diff --git a/src/components/chat/composer/reference-uri.ts b/src/components/chat/composer/reference-uri.ts index 0b4e12576..6d695bc45 100644 --- a/src/components/chat/composer/reference-uri.ts +++ b/src/components/chat/composer/reference-uri.ts @@ -9,6 +9,9 @@ import type { ReferenceAttrs } from "./types" const AGENT_URI = /^codeg:\/\/agent\/(.+)$/i const SESSION_URI = /^codeg:\/\/session\/(.+)$/i const COMMIT_URI = /^codeg:\/\/commit\/.*@(.+)$/i +// command / skill / expert tokens, surfaced as badges in transcript user messages +// (rehype-command-badges.ts). The label carries the literal `/`·`$` prefix. +const SKILL_URI = /^codeg:\/\/skill\/(.+)$/i /** * Parse a composer reference uri (`file://` / `codeg://…`) back into @@ -80,6 +83,25 @@ export function parseCodegReferenceUri( } } + const skill = uri.match(SKILL_URI) + if (skill) { + let id = skill[1] + try { + id = decodeURIComponent(id) + } catch { + // keep the raw segment if it isn't valid percent-encoding + } + return { + refType: "skill", + // The link text keeps the literal token (`/build` / `$deploy`); fall back + // to a `/`-prefixed id only if it was somehow empty. + id, + label: label || `/${id}`, + uri, + meta: null, + } + } + return null } From 5184dac4046bf41b11945a1b2afa28e0b83bfa09 Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Sat, 13 Jun 2026 21:28:04 +0800 Subject: [PATCH 24/31] feat(chat): show attached files as inline file badges in user messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-image files attached to a user message now render as inline file badges inside the message body, at the position where they were inserted — in the composer and in sent messages alike — alongside the file chip row kept below the text. The badges are clickable and open the file in the workspace panel, and sit vertically centered on the text line. Files travel through the send pipeline as inline [name](file://…) markdown links, so their in-text position is preserved when a conversation is reloaded, for every agent. Path-less pasted attachments render as inert codeg://embedded badges and have their bytes attached out of band. --- src/components/ai-elements/link-safety.tsx | 8 +- .../ai-elements/markdown-link.test.tsx | 51 +++ src/components/ai-elements/markdown-link.tsx | 59 +++- .../chat/composer/from-prompt-blocks.test.ts | 33 +- .../chat/composer/from-prompt-blocks.ts | 21 +- .../chat/composer/reference-uri.test.ts | 30 +- src/components/chat/composer/reference-uri.ts | 34 ++ .../chat/composer/to-prompt-blocks.test.ts | 82 +++-- .../chat/composer/to-prompt-blocks.ts | 112 +++--- src/components/chat/message-input.tsx | 329 +++++++++++++----- .../conversation-detail-panel.tsx | 19 +- .../message/user-resource-links.tsx | 7 + src/lib/adapters/ai-elements-adapter.test.ts | 150 +++++++- src/lib/adapters/ai-elements-adapter.ts | 187 +++++++--- 14 files changed, 850 insertions(+), 272 deletions(-) diff --git a/src/components/ai-elements/link-safety.tsx b/src/components/ai-elements/link-safety.tsx index 90ee0fb28..61aaf643f 100644 --- a/src/components/ai-elements/link-safety.tsx +++ b/src/components/ai-elements/link-safety.tsx @@ -283,7 +283,13 @@ function DirectLinkOpen({ return null } -function useOpenLinkOrFile() { +/** + * Hook returning an async opener for a link or local-file uri: `file://` (and + * bare local paths) open in the workspace file panel; http(s)/mailto/tel route + * to the browser / OS handler. Used by the Streamdown link-safety modal and by + * standalone clickable file affordances (e.g. user-message resource badges). + */ +export function useOpenLinkOrFile() { const t = useTranslations("Folder.chat.linkSafety") const { activeFolder: folder } = useActiveFolder() const folderPath = folder?.path diff --git a/src/components/ai-elements/markdown-link.test.tsx b/src/components/ai-elements/markdown-link.test.tsx index 3ff8fa220..125925ebd 100644 --- a/src/components/ai-elements/markdown-link.test.tsx +++ b/src/components/ai-elements/markdown-link.test.tsx @@ -141,4 +141,55 @@ describe("MarkdownLink", () => { expect(screen.getByRole("button")).toBeInTheDocument() }) }) + + describe("file reference badges", () => { + it("renders a file link as a clickable inline file badge", () => { + render(<MarkdownLink href="file:///repo/app.ts">app.ts</MarkdownLink>) + // Clickable: a button wraps the badge (opens in the workspace panel). + const button = screen.getByRole("button") + expect(button).toHaveAttribute("data-resource-kind", "file") + // …whose vertical-align is centered (mirrors ReferenceBadge), not baseline. + expect(button.className).toContain("align-middle") + // `appearance-none` + `leading-none` strip the button's UA strut and the + // inherited surrounding-text line-height so it lays out like the bare + // badge; `-translate-y` then lifts the chip from the x-height midline onto + // the line's optical center (WebKit/CJK otherwise read it low). See + // MarkdownLink's file branch. + expect(button.className).toContain("appearance-none") + expect(button.className).toContain("leading-none") + expect(button.className).toContain("-translate-y-[1.5px]") + // It reads as a file badge, matching the inline `@`-file chips. + const badge = screen.getByRole("img", { name: "file: app.ts" }) + expect(badge).toHaveAttribute("data-reference-badge") + expect(badge).toHaveAttribute("data-ref-type", "file") + }) + + it("routes a file badge click through the link-safety modal hook", async () => { + mocks.onLinkCheck.mockReturnValue(false) + + render(<MarkdownLink href="/repo/src/app.ts">app.ts</MarkdownLink>) + fireEvent.click(screen.getByRole("button")) + + await waitFor(() => { + expect(screen.getByTestId("link-modal")).toBeInTheDocument() + }) + expect(window.open).not.toHaveBeenCalled() + }) + }) + + describe("embedded attachment badges", () => { + it("renders a codeg://embedded link as an inert file badge", () => { + // Path-less pasted bytes serialize to this inert display uri; the badge + // name is the link text the composer wrote. + render( + <MarkdownLink href="codeg://embedded/abc-123">report.pdf</MarkdownLink> + ) + // It's a badge, not a clickable link (nothing to open — bytes are + // appended out of band as a resource block on send). + expect(screen.queryByRole("button")).toBeNull() + const badge = screen.getByRole("img", { name: "file: report.pdf" }) + expect(badge).toHaveAttribute("data-reference-badge") + expect(badge).toHaveAttribute("data-ref-type", "file") + }) + }) }) diff --git a/src/components/ai-elements/markdown-link.tsx b/src/components/ai-elements/markdown-link.tsx index b7eeb608f..bdd87e32d 100644 --- a/src/components/ai-elements/markdown-link.tsx +++ b/src/components/ai-elements/markdown-link.tsx @@ -7,6 +7,7 @@ import type { Components, LinkSafetyModalProps } from "streamdown" import { ReferenceBadge } from "@/components/chat/composer/badges/reference-badge" import { parseCodegReferenceUri } from "@/components/chat/composer/reference-uri" +import type { ReferenceAttrs } from "@/components/chat/composer/types" import { classifyResourceKind, type ResourceKind } from "@/lib/resource-kind" import { cn } from "@/lib/utils" import { useStreamdownLinkSafety } from "./link-safety" @@ -92,10 +93,12 @@ export function MarkdownLink({ ) } - // A codeg:// reference link (session / commit / agent) renders as an inline - // badge, mirroring the composer's reference chips. The same parser the editor - // uses on draft restore recovers refType/id/meta from the uri; the link text - // is the label. + // A codeg:// reference link renders as an inline badge, mirroring the + // composer's reference chips: session / commit / agent links, plus the inert + // `codeg://embedded/…` badge a path-less pasted attachment serializes to (its + // bytes travel out of band, so it has no openable target). The same parser the + // editor uses on draft restore recovers refType/id/meta from the uri; the link + // text is the label. if (!isIncomplete && href.toLowerCase().startsWith("codeg:")) { const reference = parseCodegReferenceUri(href, nodeText(children)) if (reference) return <ReferenceBadge data={reference} /> @@ -111,6 +114,54 @@ export function MarkdownLink({ onConfirm: () => window.open(href, "_blank", "noreferrer"), } + // A file reference — a `file://` uri (rewritten to a local path by + // remark-file-uri-links before it reaches us) or a bare local path — renders + // as an inline file badge, visually matching the composer's `@`-file chips and + // the inline session/commit/agent badges above, while staying clickable: the + // same link-safety flow (`handleClick` → modal → `useOpenLinkOrFile`) opens it + // in the workspace file panel. + // + // Vertical centering happens in two steps: + // + // 1. Equalize with the bare `<ReferenceBadge>` span used for the + // session/commit/agent badges above. A `<button>` inherits two metrics a + // span never gets from Tailwind's preflight — `appearance: button` (a UA + // inline strut) and `font: inherit`, which resets its `line-height` to the + // message body's inherited value (the `text-sm` wrapper, ~20px) instead of + // the badge's own tighter `leading-snug`. In WebKit (the macOS Tauri + // webview) that taller, UA-strutted inline box pulls the badge low. + // `appearance-none` + `leading-none` strip both so the button lays out like + // the bare badge. + // 2. Lift onto the line's optical center. `align-middle` centers the chip on + // the parent's x-height, which sits ~1.5px below the optical center of a + // line that also carries ascenders, caps and full-height CJK glyphs — so + // the chip still reads slightly low (most visible next to Chinese text). + // A small upward `-translate-y` nudge (purely visual, no layout shift) + // seats it on that optical center. + if (kind === "file") { + const fileData: ReferenceAttrs = { + refType: "file", + id: href, + label: nodeText(children) || href, + uri: href, + meta: { fileKind: "file" }, + } + return ( + <> + <button + type="button" + data-resource-kind="file" + title={href} + onClick={handleClick} + className="inline-flex max-w-full -translate-y-[1.5px] cursor-pointer appearance-none items-center align-middle leading-none hover:opacity-80" + > + <ReferenceBadge data={fileData} /> + </button> + {linkSafety.renderModal ? linkSafety.renderModal(modalProps) : null} + </> + ) + } + return ( <> <button diff --git a/src/components/chat/composer/from-prompt-blocks.test.ts b/src/components/chat/composer/from-prompt-blocks.test.ts index fa51589ea..891a485b7 100644 --- a/src/components/chat/composer/from-prompt-blocks.test.ts +++ b/src/components/chat/composer/from-prompt-blocks.test.ts @@ -72,6 +72,24 @@ describe("blocksToRestoredDraft", () => { ]) }) + it("restores an inline file link in a text block as markdown, not a badge", () => { + // docToPromptBlocks keeps files inline now, so a text block can carry a + // `[name](file://…)` link; it must replay as prose (a markdown segment), not + // a re-hydrated reference badge. The resource_link branch above stays for + // host-appended payloads (embedded bytes / data uris). + const { segments, attachments } = blocksToRestoredDraft( + [{ type: "text", text: "see [app.ts](file:///repo/src/app.ts) please" }], + counter() + ) + expect(attachments).toEqual([]) + expect(segments).toEqual([ + { + kind: "markdown", + text: "see [app.ts](file:///repo/src/app.ts) please", + }, + ]) + }) + it("restores a codeg session link as a session reference", () => { const { segments } = blocksToRestoredDraft( [ @@ -252,7 +270,7 @@ describe("round-trip with docToPromptBlocks", () => { editor?.destroy() }) - it("a file reference survives send → restore as a badge", () => { + it("keeps a file reference inline through send → restore (markdown link, not a badge)", () => { editor .chain() .insertContent("see ") @@ -270,12 +288,13 @@ describe("round-trip with docToPromptBlocks", () => { const { segments, attachments } = blocksToRestoredDraft(blocks, counter()) expect(attachments).toEqual([]) + // The file is no longer a structured resource_link, so it round-trips as an + // inline markdown link inside the prose — never lifted out to a badge segment + // (consistent with how session/commit/agent/skill refs round-trip). + expect(refSegments(segments)).toEqual([]) const md = segments.find((s) => s.kind === "markdown") - expect(md && md.kind === "markdown" && md.text).toContain("see") - expect(refSegments(segments)[0]).toMatchObject({ - refType: "file", - uri: "file:///repo/src/app.ts", - label: "app.ts", - }) + expect(md && md.kind === "markdown" && md.text).toContain( + "[app.ts](file:///repo/src/app.ts)" + ) }) }) diff --git a/src/components/chat/composer/from-prompt-blocks.ts b/src/components/chat/composer/from-prompt-blocks.ts index 0a3beeefd..027f70b1a 100644 --- a/src/components/chat/composer/from-prompt-blocks.ts +++ b/src/components/chat/composer/from-prompt-blocks.ts @@ -6,18 +6,23 @@ import { parseCodegReferenceUri as parseReferenceUri } from "./reference-uri" import type { ReferenceAttrs } from "./types" /** - * Restore serialization (inverse of {@link "./to-prompt-blocks".docToPromptBlocks}): - * turn a sent `PromptInputBlock[]` back into editor content + attachments, so a - * queued message can be re-opened for editing with its badges and attachments + * Restore serialization (loose inverse of + * {@link "./to-prompt-blocks".docToPromptBlocks}): turn a sent + * `PromptInputBlock[]` back into editor content + attachments, so a queued + * message can be re-opened for editing with its references and attachments * intact. * * The split mirrors the send rule: - * - `text` blocks → markdown segments replayed into the editor (inline - * session/commit/agent/skill references that were serialized *as text* come - * back as their text form — only **file** references were structured blocks, - * so only they round-trip to badges). + * - `text` blocks → markdown segments replayed into the editor. Every inline + * reference that was serialized *as text* comes back in that text form: file + * links `[name](file://…)` (which `docToPromptBlocks` now keeps inline) and + * session/commit/agent/skill references alike replay as inline links/text, not + * re-hydrated badges — consistent across every reference kind on a queue-edit. * - `resource_link` blocks whose uri is a composer scheme (`file:` / `codeg:`) - * → reference badge segments. + * → reference badge segments. `docToPromptBlocks` no longer emits file + * resource_links (files stay inline above), but this branch still restores any + * composer-scheme resource_link the host appended out of band (e.g. an embedded + * payload). * - everything else (`image`, embedded `resource`, non-composer `resource_link`) * → out-of-band attachments. * diff --git a/src/components/chat/composer/reference-uri.test.ts b/src/components/chat/composer/reference-uri.test.ts index aa134a6b3..84da1e2b4 100644 --- a/src/components/chat/composer/reference-uri.test.ts +++ b/src/components/chat/composer/reference-uri.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest" -import { parseCodegReferenceUri } from "./reference-uri" +import { + buildEmbeddedReferenceUri, + isEmbeddedReferenceUri, + parseCodegReferenceUri, +} from "./reference-uri" describe("parseCodegReferenceUri", () => { it("returns null for non-reference schemes", () => { @@ -127,4 +131,28 @@ describe("parseCodegReferenceUri", () => { "/deploy" ) }) + + it("parses an embedded-attachment uri as an inert file badge", () => { + expect( + parseCodegReferenceUri("codeg://embedded/9f3c-uuid", "report.pdf") + ).toMatchObject({ + refType: "file", + label: "report.pdf", + uri: "codeg://embedded/9f3c-uuid", + meta: { fileKind: "file" }, + }) + }) + + it("falls back to a generic label for an empty embedded-attachment label", () => { + expect( + parseCodegReferenceUri("codeg://embedded/9f3c-uuid", "")?.label + ).toBe("resource") + }) + + it("recognizes a freshly minted embedded reference uri", () => { + const uri = buildEmbeddedReferenceUri() + expect(isEmbeddedReferenceUri(uri)).toBe(true) + expect(isEmbeddedReferenceUri("file:///codeg-embedded/real.ts")).toBe(false) + expect(isEmbeddedReferenceUri("codeg://session/abc")).toBe(false) + }) }) diff --git a/src/components/chat/composer/reference-uri.ts b/src/components/chat/composer/reference-uri.ts index 6d695bc45..7734df2c6 100644 --- a/src/components/chat/composer/reference-uri.ts +++ b/src/components/chat/composer/reference-uri.ts @@ -1,4 +1,5 @@ import { ALL_AGENT_TYPES, type AgentType } from "@/lib/types" +import { randomUUID } from "@/lib/utils" import type { ReferenceAttrs } from "./types" @@ -13,6 +14,26 @@ const COMMIT_URI = /^codeg:\/\/commit\/.*@(.+)$/i // (rehype-command-badges.ts). The label carries the literal `/`·`$` prefix. const SKILL_URI = /^codeg:\/\/skill\/(.+)$/i +// A path-less attached file (local-desktop paste/drop of inline bytes — an +// embedded `resource` or a `data:` link) can't live in the doc by its real uri, +// so its inline badge carries this synthetic display uri while the real +// bytes-bearing block is held in a send-time map keyed by it (see +// message-input's `embeddedPayloadsRef`). `codeg://` (not `file://`) is used on +// purpose: it is never a real filesystem path (so it can't collide with a +// genuine attachment) and it survives Streamdown's sanitize/harden pipeline, so +// the transcript renders it as an inert file badge rather than a blocked link. +const EMBEDDED_URI_PREFIX = "codeg://embedded/" + +/** Mint a fresh inert display uri for a path-less embedded attachment badge. */ +export function buildEmbeddedReferenceUri(): string { + return `${EMBEDDED_URI_PREFIX}${randomUUID()}` +} + +/** Whether `uri` is an embedded-attachment display uri (see {@link buildEmbeddedReferenceUri}). */ +export function isEmbeddedReferenceUri(uri: string): boolean { + return uri.toLowerCase().startsWith(EMBEDDED_URI_PREFIX) +} + /** * Parse a composer reference uri (`file://` / `codeg://…`) back into * {@link ReferenceAttrs}, or null when it isn't a recognized reference scheme @@ -102,6 +123,19 @@ export function parseCodegReferenceUri( } } + // A path-less embedded attachment: render as an inert file badge (the bytes + // live out of band, so there is nothing to open — the badge name comes from + // the link text the composer serialized). + if (isEmbeddedReferenceUri(uri)) { + return { + refType: "file", + id: label || "resource", + label: label || "resource", + uri, + meta: { fileKind: "file" }, + } + } + return null } diff --git a/src/components/chat/composer/to-prompt-blocks.test.ts b/src/components/chat/composer/to-prompt-blocks.test.ts index 260c55088..cf52847c2 100644 --- a/src/components/chat/composer/to-prompt-blocks.test.ts +++ b/src/components/chat/composer/to-prompt-blocks.test.ts @@ -138,7 +138,33 @@ describe("docToPromptBlocks", () => { expect(textBlock(blocks)).toContain("codeg://session/9") }) - it("lifts a file reference to a trailing resource_link and drops it from the prose", () => { + it("drops an embedded-attachment reference from the prose without lifting it", () => { + // A path-less pasted attachment badge carries an inert codeg://embedded uri; + // its bytes are appended separately by the host, so it must neither survive + // in the prose nor become a resource_link with the synthetic uri. + editor + .chain() + .insertContent("see ") + .insertReference( + ref({ + refType: "file", + id: "report.pdf", + label: "report.pdf", + uri: "codeg://embedded/abc-123", + }) + ) + .insertContent(" please") + .run() + const blocks = docToPromptBlocks(editor) + const text = textBlock(blocks) + expect(text).toContain("see") + expect(text).toContain("please") + expect(text).not.toContain("codeg://embedded") + expect(text).not.toContain("report.pdf") + expect(links(blocks)).toHaveLength(0) + }) + + it("keeps a file reference inline as a markdown link (no resource_link)", () => { editor .chain() .insertContent("see ") @@ -156,20 +182,14 @@ describe("docToPromptBlocks", () => { const text = textBlock(blocks) expect(text).toContain("see") expect(text).toContain("please") - expect(text).not.toContain("file://") - expect(text).not.toContain("app.ts") - expect(links(blocks)).toEqual([ - { - type: "resource_link", - uri: "file:///repo/src/app.ts", - name: "app.ts", - mime_type: null, - description: null, - }, - ]) - }) - - it("emits a file-only document as just the resource_link (no empty text block)", () => { + // The file stays inline, at the typed position, as a markdown link — never + // lifted to a trailing resource_link (which would land at the end of the + // message on cold reload). + expect(text).toContain("[app.ts](file:///repo/src/app.ts)") + expect(links(blocks)).toHaveLength(0) + }) + + it("emits a file-only document as a single inline-link text block", () => { editor.commands.insertReference( ref({ refType: "file", @@ -181,12 +201,13 @@ describe("docToPromptBlocks", () => { const blocks = docToPromptBlocks(editor) expect(blocks).toHaveLength(1) expect(blocks[0]).toMatchObject({ - type: "resource_link", - uri: "file:///repo/a.ts", + type: "text", + text: "[a.ts](file:///repo/a.ts)", }) + expect(links(blocks)).toHaveLength(0) }) - it("preserves document order across multiple file references", () => { + it("keeps multiple file references inline in document order", () => { editor .chain() .insertContent("a ") @@ -208,11 +229,18 @@ describe("docToPromptBlocks", () => { }) ) .run() - const uris = links(docToPromptBlocks(editor)).map((l) => l.uri) - expect(uris).toEqual(["file:///one.ts", "file:///two.ts"]) + const blocks = docToPromptBlocks(editor) + const text = textBlock(blocks) + expect(links(blocks)).toHaveLength(0) + expect(text).toContain("[one.ts](file:///one.ts)") + expect(text).toContain("[two.ts](file:///two.ts)") + expect(text.indexOf("one.ts")).toBeLessThan(text.indexOf("two.ts")) }) - it("falls back to the uri basename when a file reference has no label", () => { + it("inlines a no-label file reference as a link carrying its uri", () => { + // The composer always labels a file with its basename; even without a label + // the reference still serializes inline as a link (its uri is the + // destination), never as a resource_link. editor.commands.insertReference( ref({ refType: "file", @@ -221,10 +249,12 @@ describe("docToPromptBlocks", () => { uri: "file:///repo/deep/name.ts", }) ) - expect(links(docToPromptBlocks(editor))[0].name).toBe("name.ts") + const blocks = docToPromptBlocks(editor) + expect(links(blocks)).toHaveLength(0) + expect(textBlock(blocks)).toContain("(file:///repo/deep/name.ts)") }) - it("preserves marks in prose alongside a lifted file reference", () => { + it("preserves marks in prose alongside an inline file reference", () => { editor .chain() .insertContent("look at ") @@ -235,7 +265,9 @@ describe("docToPromptBlocks", () => { ) .run() const blocks = docToPromptBlocks(editor) - expect(textBlock(blocks)).toContain("**this**") - expect(links(blocks)).toHaveLength(1) + const text = textBlock(blocks) + expect(text).toContain("**this**") + expect(text).toContain("[x.ts](file:///x.ts)") + expect(links(blocks)).toHaveLength(0) }) }) diff --git a/src/components/chat/composer/to-prompt-blocks.ts b/src/components/chat/composer/to-prompt-blocks.ts index 6f920ad5f..ca2c2a491 100644 --- a/src/components/chat/composer/to-prompt-blocks.ts +++ b/src/components/chat/composer/to-prompt-blocks.ts @@ -2,101 +2,81 @@ import type { Editor, JSONContent } from "@tiptap/core" import type { PromptInputBlock } from "@/lib/types" -import type { ReferenceAttrs } from "./types" +import { isEmbeddedReferenceUri } from "./reference-uri" /** - * Send serialization: turn the composer document into the prose + reference - * portion of a `PromptInputBlock[]`. (Out-of-band image/resource attachments are + * Send serialization: turn the composer document into the prose portion of a + * `PromptInputBlock[]`. (Out-of-band image / embedded-byte attachments are * appended by the host's `buildDraft`; this function owns only the editor doc.) * - * Per-refType rule — see the P3 design: - * - **file** references carry a `file://` uri and become first-class - * `resource_link` blocks (agent-readable resources), matching the pre-existing - * `@`-file behavior exactly: they are removed from the prose and appended as - * trailing ResourceLinks in document order. The backend folds each back to a - * `[name](uri)` link (`user_blocks_from_prompt`) and the transcript renders it - * as a chip — identical to today. + * Every reference EXCEPT an embedded-attachment ref serializes **inline, in + * place**, via the node's own `renderMarkdown` (see + * {@link "./reference-text".referenceToMarkdown}): + * + * - **file** references render as an inline `[label](file://uri)` Markdown link + * at the exact position they were typed. They are deliberately *not* lifted + * into trailing `resource_link` blocks: codeg keeps no copy of the user's + * prompt, so on cold reload the message is reparsed from the agent's own + * session file — and only what stays inline in the text survives at its + * original position. A trailing ResourceLink ends up stored/reparsed at the + * *end* of the message (or dropped entirely — e.g. Claude's parser ignores the + * resulting `document` block), which is why a file badge used to jump to the + * end of the bubble after reopening a conversation. Keeping the link inline + * fixes that for every agent. For a local `file://` an ACP ResourceLink only + * conveys the path anyway — identical information to the inline link — so + * nothing is lost on the agent side. * - **session / commit** references (a `codeg://` uri the agent can't fetch) and - * **agent / skill** references (no uri) stay *inline* as text, rendered by the - * node's own `renderMarkdown` (see {@link "../reference-text".referenceToMarkdown}). + * **agent / skill** references stay inline as their text/link form, unchanged. + * - **embedded** references (a `codeg://embedded/…` display uri for path-less + * pasted bytes) are dropped from the prose: their real bytes-bearing block is + * appended separately by the host's `buildDraft` (keyed on the same uri via the + * send-time payload map), so emitting their synthetic display link here would + * leak a uri the agent shouldn't see. * - * Removing files from the prose (rather than splitting the text around them) - * keeps each text run a single block — no mid-paragraph fragmentation, no - * boundary-whitespace loss — so a sentence like "see <file> please" renders on - * one line with the file as a chip, exactly as the plain-textarea input did. + * The whole document serializes to a single text block (no mid-paragraph + * fragmentation), with every reference sitting inline exactly where the sender + * placed it. */ export function docToPromptBlocks(editor: Editor): PromptInputBlock[] { const doc = editor.getJSON() - const files: ReferenceAttrs[] = [] - const stripped = stripFileReferences(doc, files) - - const blocks: PromptInputBlock[] = [] + const stripped = stripEmbeddedReferences(doc) const text = serializeMarkdown(editor, stripped).trim() - if (text) blocks.push({ type: "text", text }) - for (const file of files) blocks.push(fileResourceLink(file)) - return blocks + return text ? [{ type: "text", text }] : [] } -/** A reference node that should become a `resource_link` block: a file reference - * carrying a `file://` uri. The uri scheme is checked (not just refType) because - * the reference node's parseHTML allow-list also permits `codeg:` uris, so a - * pasted/forged `file`-typed node could carry a non-fetchable `codeg://` uri — - * those must stay inline as text, never be lifted to an ACP ResourceLink. */ -function isFileReference(node: JSONContent): boolean { +/** A display-only embedded-attachment reference (`codeg://embedded/…`): dropped + * from the prose here, its bytes appended out of band by the host. The + * synthetic uri points at no fetchable target, so it must never reach the + * agent — neither inline nor as a ResourceLink. */ +function isEmbeddedReference(node: JSONContent): boolean { return ( node.type === "reference" && - node.attrs?.refType === "file" && typeof node.attrs?.uri === "string" && - node.attrs.uri.toLowerCase().startsWith("file://") + isEmbeddedReferenceUri(node.attrs.uri) ) } /** - * Deep-clone `node`, dropping every file reference from the inline content and - * collecting the originals into `files` in document order. Non-file references - * are left intact so they serialize inline. Dropping (rather than replacing with - * placeholder text) leaves the surrounding prose untouched; any incidental - * double space collapses on render and is harmless to the agent. + * Deep-clone `node`, dropping every embedded-attachment reference from the inline + * content (the host emits their bytes-bearing blocks separately). Every other + * node — including file references, which serialize to an inline + * `[label](file://uri)` link — is left intact so it stays in place in the prose. + * Dropping (rather than replacing with placeholder text) leaves the surrounding + * prose untouched; any incidental double space collapses on render and is + * harmless to the agent. */ -function stripFileReferences( - node: JSONContent, - files: ReferenceAttrs[] -): JSONContent { +function stripEmbeddedReferences(node: JSONContent): JSONContent { if (!node.content) return node const content: JSONContent[] = [] for (const child of node.content) { - if (isFileReference(child)) { - files.push(child.attrs as ReferenceAttrs) + if (isEmbeddedReference(child)) { continue } - content.push(stripFileReferences(child, files)) + content.push(stripEmbeddedReferences(child)) } return { ...node, content } } -function fileResourceLink(attrs: ReferenceAttrs): PromptInputBlock { - const uri = attrs.uri as string - const name = attrs.label.trim() || fileBaseName(uri) || attrs.id || uri - return { - type: "resource_link", - uri, - name, - mime_type: null, - description: null, - } -} - -/** Best-effort basename of a `file://` uri, for a ResourceLink that lost its label. */ -function fileBaseName(uri: string): string { - const path = uri.replace(/^file:\/+/i, "") - const last = path.split("/").filter(Boolean).pop() ?? "" - try { - return decodeURIComponent(last) - } catch { - return last - } -} - /** The Markdown manager is always present (the Markdown extension is always loaded). */ function serializeMarkdown(editor: Editor, doc: JSONContent): string { if (!editor.markdown) throw new Error("Markdown extension not loaded") diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 33ccd5b35..b10986c0f 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -10,7 +10,6 @@ import { Check, ChevronUp, Cog, - FileSearch, FolderSearch, GitFork, MessageSquarePlus, @@ -111,6 +110,10 @@ import { type RichComposerHandle, } from "@/components/chat/composer/rich-composer" import { docToPromptBlocks } from "@/components/chat/composer/to-prompt-blocks" +import { + buildEmbeddedReferenceUri, + isEmbeddedReferenceUri, +} from "@/components/chat/composer/reference-uri" import { applyExpertReference, isComposerChromeClick, @@ -123,6 +126,7 @@ import { skillToReference, } from "@/components/chat/composer/invocation-reference" import type { ReferenceAttrs } from "@/components/chat/composer/types" +import type { Editor, JSONContent } from "@tiptap/core" import { useReferenceSearch, type ReferenceGroupLabels, @@ -337,6 +341,62 @@ function buildClipboardResourceUri(name: string): string { return `clipboard://${encodeURIComponent(normalizedName)}-${randomUUID()}` } +// Non-image files attach as inline file badges in the editor (like `@`-file +// references), not as out-of-band chips. A file with a real `file://` path uses +// that uri directly (it serializes to a ResourceLink and round-trips through the +// draft doc untouched). A path-less file (a local-desktop paste/drop carrying +// inline bytes — an embedded resource or a `data:` link) can't live in the doc, +// so its badge carries an inert `codeg://embedded/<uuid>` display uri +// (`buildEmbeddedReferenceUri`) while the real bytes-bearing block is held in the +// `embeddedPayloadsRef` map keyed by that uri. `docToPromptBlocks` drops the +// embedded badge from the prose; `buildDraft` appends the mapped block for every +// embedded badge still in the document. The `codeg://` scheme is never a real +// path (no collision with a genuine attachment) and survives the transcript's +// sanitize/harden pipeline, so it renders as an inert file badge, not a blocked +// link — see {@link buildEmbeddedReferenceUri} / {@link isEmbeddedReferenceUri}. + +/** Whether the document already holds a file reference badge for `uri` (used to + * dedupe repeated drops/picks of the same path, mirroring the old seen-set). */ +function editorHasFileReference(editor: Editor, uri: string): boolean { + let found = false + editor.state.doc.descendants((node) => { + if (found) return false + if ( + node.type.name === "reference" && + node.attrs?.refType === "file" && + node.attrs?.uri === uri + ) { + found = true + return false + } + return true + }) + return found +} + +/** Drop embedded-attachment reference badges from a draft document before it is + * persisted: their bytes live only in the in-memory `embeddedPayloadsRef` map + * (never serialized into the draft), so a restored badge would send nothing. + * Identified purely by the unambiguous `codeg://embedded/…` display uri (no map + * needed) — a real `file://` attachment is never matched. Stripping at save + * keeps the live badge visible this session but matches the pre-existing + * behavior where out-of-band pasted bytes don't survive a draft round-trip. */ +function stripEmbeddedReferences(doc: JSONContent): JSONContent { + if (!doc.content) return doc + const content: JSONContent[] = [] + for (const child of doc.content) { + if ( + child.type === "reference" && + typeof child.attrs?.uri === "string" && + isEmbeddedReferenceUri(child.attrs.uri) + ) { + continue + } + content.push(stripEmbeddedReferences(child)) + } + return { ...doc, content } +} + function buildDataUri(base64Data: string, mimeType: string | null): string { const safeMime = mimeType && mimeType.trim() ? mimeType : "application/octet-stream" @@ -496,7 +556,12 @@ export function MessageInput({ // Flips true once the RichComposer's async (immediatelyRender:false) editor has // mounted, so the hydration effect can use the imperative handle. const [composerReady, setComposerReady] = useState(false) + // `attachments` now holds only images; non-image files live inline as editor + // reference badges. This map carries the real bytes-bearing block for each + // embedded/data-uri badge, keyed by its synthetic `file://` sentinel uri, and + // is reconciled into the outgoing blocks by `buildDraft`. const [attachments, setAttachments] = useState<InputAttachment[]>([]) + const embeddedPayloadsRef = useRef<Map<string, PromptInputBlock>>(new Map()) const [isDragActive, setIsDragActive] = useState(false) const [quickMessages, setQuickMessages] = useState<QuickMessage[]>([]) const [quickMessagesLoading, setQuickMessagesLoading] = useState(false) @@ -581,7 +646,10 @@ export function MessageInput({ if (ed.isEmpty()) { clearMessageInputDraftV2(effectiveDraftStorageKey) } else { - saveMessageInputDraftV2(effectiveDraftStorageKey, ed.getJSON()) + saveMessageInputDraftV2( + effectiveDraftStorageKey, + stripEmbeddedReferences(ed.getJSON()) + ) } }, 300) }, [effectiveDraftStorageKey, isEditingQueueItem]) @@ -594,6 +662,56 @@ export function MessageInput({ } }, []) + // Replay a sent `PromptInputBlock[]` (a queued message being re-edited) into + // the editor: prose + file badges inline, images into `attachments`, and any + // embedded/data-uri resources re-inlined as sentinel badges with their + // bytes-bearing blocks re-registered in the payload map. + const hydrateFromBlocks = useCallback( + (editor: Editor, blocks: PromptInputBlock[]) => { + embeddedPayloadsRef.current.clear() + const restored = restoreBlocksIntoEditor(editor, blocks) + setAttachments( + restored.filter((a): a is ImageInputAttachment => a.type === "image") + ) + const resources = restored.filter( + (a): a is ResourceInputAttachment => a.type === "resource" + ) + if (resources.length === 0) return + let chain = editor.chain().focus("end") + for (const att of resources) { + const refUri = buildEmbeddedReferenceUri() + const block: PromptInputBlock = + att.kind === "embedded" + ? { + type: "resource", + uri: att.uri, + mime_type: att.mimeType, + text: att.text ?? null, + blob: att.blob ?? null, + } + : { + type: "resource_link", + uri: att.uri, + name: att.name, + mime_type: att.mimeType, + description: null, + } + embeddedPayloadsRef.current.set(refUri, block) + chain = chain + .insertReference({ + refType: "file", + id: refUri, + label: att.name, + uri: refUri, + meta: { fileKind: "file" }, + }) + .insertContent(" ") + } + chain.run() + }, + [] + ) + // One-time hydration once the editor is ready: a queue-edit payload, else a v2 // draft document (or a legacy v1 Markdown draft migrated forward). Guarded so // it never re-runs and clobbers later user edits. @@ -608,8 +726,8 @@ export function MessageInput({ ) { const editor = ed.getEditor() if (editingDraftBlocks && editingDraftBlocks.length > 0 && editor) { - // Full fidelity: restore inline badges + attachments from the blocks. - setAttachments(restoreBlocksIntoEditor(editor, editingDraftBlocks)) + // Full fidelity: restore inline badges + images from the blocks. + hydrateFromBlocks(editor, editingDraftBlocks) } else if (editingDraftText != null) { ed.setMarkdown(editingDraftText) } @@ -631,6 +749,7 @@ export function MessageInput({ editingDraftText, editingDraftBlocks, effectiveDraftStorageKey, + hydrateFromBlocks, ]) // Re-hydrate when the user (re)edits a *different* queue item after the @@ -645,7 +764,7 @@ export function MessageInput({ prevEditingItemIdRef.current = editingItemId const editor = editorRef.current?.getEditor() if (editingDraftBlocks && editingDraftBlocks.length > 0 && editor) { - setAttachments(restoreBlocksIntoEditor(editor, editingDraftBlocks)) + hydrateFromBlocks(editor, editingDraftBlocks) } else if (editingDraftText != null) { editorRef.current?.setMarkdown(editingDraftText) } @@ -656,7 +775,13 @@ export function MessageInput({ } else if (!isEditingQueueItem) { prevEditingItemIdRef.current = null } - }, [isEditingQueueItem, editingItemId, editingDraftText, editingDraftBlocks]) + }, [ + isEditingQueueItem, + editingItemId, + editingDraftText, + editingDraftBlocks, + hydrateFromBlocks, + ]) const setDragActiveIfChanged = useCallback((next: boolean) => { if (dragActiveRef.current === next) return @@ -722,14 +847,6 @@ export function MessageInput({ : null, [previewAttachmentId, imageAttachments] ) - const resourceAttachments = useMemo( - () => - attachments.filter( - (attachment): attachment is ResourceInputAttachment => - attachment.type === "resource" - ), - [attachments] - ) const hasAttachments = attachments.length > 0 const hasSendableContent = !composerEmpty || hasAttachments @@ -895,6 +1012,54 @@ export function MessageInput({ detectSlashTriggerRef.current = detectSlashTrigger }, [detectSlashTrigger]) + // Insert one inline file reference badge per item, matching `@`-file mentions. + // A genuine `file://` item uses its uri directly (deduped against the document); + // an item carrying a `realBlock` (embedded bytes / `data:` link) gets an inert + // `codeg://embedded/…` display uri and its block is stashed in + // `embeddedPayloadsRef` for send-time reconciliation. Files are "attach" + // actions, so badges append at the doc end. + const insertFileReferences = useCallback( + ( + items: Array<{ + name: string + uri?: string + realBlock?: PromptInputBlock + }> + ) => { + if (items.length === 0) return + const editor = editorRef.current?.getEditor() + if (!editor) return + const seen = new Set<string>() + let chain = editor.chain().focus("end") + let inserted = 0 + for (const item of items) { + let refUri: string + if (item.realBlock) { + refUri = buildEmbeddedReferenceUri() + embeddedPayloadsRef.current.set(refUri, item.realBlock) + } else { + if (!item.uri) continue + refUri = item.uri + if (seen.has(refUri) || editorHasFileReference(editor, refUri)) + continue + seen.add(refUri) + } + chain = chain + .insertReference({ + refType: "file", + id: refUri, + label: item.name, + uri: refUri, + meta: { fileKind: "file" }, + }) + .insertContent(" ") + inserted++ + } + if (inserted > 0) chain.run() + }, + [] + ) + const appendResourceLinks = useCallback( ( links: Array<{ @@ -904,30 +1069,29 @@ export function MessageInput({ dedupeKey: string }> ) => { - if (links.length === 0) return - setAttachments((prev) => { - const seen = new Set( - prev.flatMap((item) => - item.type === "resource" && item.kind === "link" ? [item.uri] : [] + // `file://` links the agent can read directly become inline file badges + // (uri used as-is); a non-fetchable `data:` link keeps its real block out + // of band behind a sentinel badge. + insertFileReferences( + links + .filter((link) => link.uri) + .map((link) => + link.uri.toLowerCase().startsWith("file://") + ? { name: link.name, uri: link.uri } + : { + name: link.name, + realBlock: { + type: "resource_link" as const, + uri: link.uri, + name: link.name, + mime_type: link.mimeType, + description: null, + }, + } ) - ) - const next = [...prev] - for (const link of links) { - if (!link.uri || seen.has(link.dedupeKey)) continue - seen.add(link.dedupeKey) - next.push({ - id: `resource-link:${link.dedupeKey}`, - type: "resource", - kind: "link", - uri: link.uri, - name: link.name, - mimeType: link.mimeType, - }) - } - return next - }) + ) }, - [] + [insertFileReferences] ) const appendResourceAttachments = useCallback( @@ -1048,22 +1212,22 @@ export function MessageInput({ blob?: string | null }> ) => { - if (resources.length === 0) return - setAttachments((prev) => [ - ...prev, - ...resources.map((resource) => ({ - id: `resource-embedded:${randomUUID()}`, - type: "resource" as const, - kind: "embedded" as const, - uri: resource.uri, + // Inline bytes (no real path): each becomes a sentinel file badge whose + // embedded `resource` block is reconciled back in at send time. + insertFileReferences( + resources.map((resource) => ({ name: resource.name, - mimeType: resource.mimeType, - text: resource.text ?? null, - blob: resource.blob ?? null, - })), - ]) + realBlock: { + type: "resource" as const, + uri: resource.uri, + mime_type: resource.mimeType, + text: resource.text ?? null, + blob: resource.blob ?? null, + }, + })) + ) }, - [] + [insertFileReferences] ) // Path-less files (browser `File` objects: drag-drop in web mode, paste, @@ -1793,32 +1957,34 @@ export function MessageInput({ const buildDraft = useCallback((): PromptDraft | null => { const editor = editorRef.current?.getEditor() // Inline badges + prose → text/resource_link blocks (file mentions become - // first-class ResourceLinks; agent/session/commit/skill stay inline text). + // first-class ResourceLinks; agent/session/commit/skill stay inline text; + // embedded badges are dropped here and re-added below from the payload map). const blocks: PromptInputBlock[] = editor ? docToPromptBlocks(editor) : [] + // Append the real bytes-bearing block for every embedded-attachment badge + // still present in the document, looked up by its `codeg://embedded/…` uri. + // Walking the live doc (rather than a swap pass over a stored draft) means a + // deleted badge's stale map entry is simply never emitted, and an undo that + // resurrects a badge re-emits it — no pruning, and no orphan uri can leak. + if (editor) { + editor.state.doc.descendants((node) => { + if ( + node.type.name === "reference" && + typeof node.attrs?.uri === "string" && + isEmbeddedReferenceUri(node.attrs.uri) + ) { + const real = embeddedPayloadsRef.current.get(node.attrs.uri) + if (real) blocks.push(real) + } + return true + }) + } const displayMarkdown = editorRef.current?.getMarkdown().trim() ?? "" if (blocks.length === 0 && attachments.length === 0) return null + // `attachments` holds only images now — files live inline as badges above. for (const attachment of attachments) { - if (attachment.type === "resource") { - if (attachment.kind === "link") { - blocks.push({ - type: "resource_link", - uri: attachment.uri, - name: attachment.name, - mime_type: attachment.mimeType, - description: null, - }) - } else { - blocks.push({ - type: "resource", - uri: attachment.uri, - mime_type: attachment.mimeType, - text: attachment.text ?? null, - blob: attachment.blob ?? null, - }) - } - } else { + if (attachment.type === "image") { blocks.push({ type: "image", data: attachment.data, @@ -1839,6 +2005,7 @@ export function MessageInput({ editorRef.current?.clear() setComposerEmpty(true) setAttachments([]) + embeddedPayloadsRef.current.clear() closeSlashMenu() }, [closeSlashMenu]) @@ -2035,7 +2202,6 @@ export function MessageInput({ ) const hasImageAttachments = imageAttachments.length > 0 - const hasResourceAttachments = resourceAttachments.length > 0 const showDragActive = isDragActive && !disabled const selectorItems = ( @@ -2255,7 +2421,7 @@ export function MessageInput({ )} > <ConversationContextBar - hasExtraContent={hasImageAttachments || hasResourceAttachments} + hasExtraContent={hasImageAttachments} scrollEndTrigger={attachments.length} extraContent={ <> @@ -2290,25 +2456,6 @@ export function MessageInput({ </button> </div> ))} - {resourceAttachments.map((attachment) => ( - <div - key={attachment.id} - className="inline-flex h-6 shrink-0 items-center gap-1 rounded-full border border-border/70 bg-muted/40 px-2 text-[11px] text-muted-foreground" - > - <FileSearch className="h-3 w-3" /> - <span className="max-w-40 truncate">{attachment.name}</span> - <button - type="button" - onClick={() => removeAttachment(attachment.id)} - className="rounded-sm p-0.5 hover:bg-muted-foreground/15" - aria-label={t("removeAttachmentAria", { - name: attachment.name, - })} - > - <X className="h-3 w-3" /> - </button> - </div> - ))} </> } /> diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 01b4a55ed..334f5a8c5 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -61,7 +61,6 @@ import { useConversationRuntime } from "@/contexts/conversation-runtime-context" import { useConversationDetail } from "@/hooks/use-conversation-detail" import { extractUserImagesFromDraft, - extractUserResourcesFromDraft, getPromptDraftDisplayText, } from "@/lib/prompt-draft" import { @@ -115,18 +114,12 @@ function buildOptimisticUserTurnFromDraft( draft: PromptDraft, attachedResourcesFallback: string ): MessageTurn { - const displayText = getPromptDraftDisplayText( - draft, - attachedResourcesFallback - ) - const resources = extractUserResourcesFromDraft(draft) - const resourceLines = resources.map((resource) => { - const label = resource.uri.toLowerCase().startsWith("file://") - ? resource.name - : `@${resource.name}` - return `[${label}](${resource.uri})` - }) - const text = [displayText, ...resourceLines].join("\n").trim() + // `draft.displayText` is the composer's full Markdown, which already renders + // every inline file/resource badge as a `[label](uri)` link (see + // `referenceToMarkdown`). Re-appending the resource blocks here would duplicate + // each attached file in the optimistic bubble, so the display text is used + // as-is — images are the only out-of-band content left to add as blocks. + const text = getPromptDraftDisplayText(draft, attachedResourcesFallback) const blocks: ContentBlock[] = [] for (const image of extractUserImagesFromDraft(draft)) { diff --git a/src/components/message/user-resource-links.tsx b/src/components/message/user-resource-links.tsx index 1ba6d5c92..d1c60faeb 100644 --- a/src/components/message/user-resource-links.tsx +++ b/src/components/message/user-resource-links.tsx @@ -8,6 +8,13 @@ interface UserResourceLinksProps { className?: string } +/** + * The attachment summary row shown beneath a user message: one grey chip per + * attached file. This is the original (pre-rich-composer) attachment style — a + * plain, non-interactive list that complements the inline file badges now kept + * in the message prose (markdown-link → ReferenceBadge). Images are handled + * separately as thumbnails. + */ export function UserResourceLinks({ resources, className, diff --git a/src/lib/adapters/ai-elements-adapter.test.ts b/src/lib/adapters/ai-elements-adapter.test.ts index 04ca193f3..224a7fdcb 100644 --- a/src/lib/adapters/ai-elements-adapter.test.ts +++ b/src/lib/adapters/ai-elements-adapter.test.ts @@ -755,14 +755,140 @@ describe("extractUserResourcesFromText — codeg references stay inline", () => expect(text).toBe(input) }) - it("still lifts file:// links to the resource list (files unchanged this round)", () => { + it("keeps a file:// link inline AND copies it to the resource row", () => { const { text, resources } = extractUserResourcesFromText( "look at [foo.ts](file:///x/foo.ts) here" ) + // Copied to the row (original grey-chip attachment list)… expect(resources).toEqual([ { name: "foo.ts", uri: "file:///x/foo.ts", mime_type: null }, ]) - expect(text).toBe("look at here") + // …and left in place in the prose so it still renders as an inline badge. + expect(text).toBe("look at [foo.ts](file:///x/foo.ts) here") + }) + + it("chips a file:// link with a space (CommonMark angle-bracket destination)", () => { + // `referenceToMarkdown` wraps uris with spaces/parens in <…>; the row must + // still pick the file up (the bare-destination regex would have missed it). + const { text, resources } = extractUserResourcesFromText( + "see [a b.ts](<file:///x/a b.ts>) please" + ) + expect(resources).toEqual([ + { name: "a b.ts", uri: "file:///x/a b.ts", mime_type: null }, + ]) + // The original bracketed form is preserved inline (Streamdown parses it). + expect(text).toBe("see [a b.ts](<file:///x/a b.ts>) please") + }) + + it("unescapes a filename with parentheses for the row chip (e.g. `Screenshot (1).png`)", () => { + // `referenceToMarkdown` backslash-escapes label punctuation and wraps the + // space/paren uri in <…>, so the text carries `[Screenshot \(1\).png](<…>)`. + // The chip name must read cleanly, not leak the escaping backslashes. + const { text, resources } = extractUserResourcesFromText( + "look at [Screenshot \\(1\\).png](<file:///x/Screenshot (1).png>) here" + ) + expect(resources).toEqual([ + { + name: "Screenshot (1).png", + uri: "file:///x/Screenshot (1).png", + mime_type: null, + }, + ]) + // Inline form (with its escaping) is preserved for Streamdown to render. + expect(text).toBe( + "look at [Screenshot \\(1\\).png](<file:///x/Screenshot (1).png>) here" + ) + }) + + it("chips a filename containing `]` (escaped as `\\]` in the label)", () => { + // The escaped `]` would defeat a `[^\]]+` label regex, dropping the chip; the + // escape-aware regex matches it and the unescaped name reads `a]b.ts`. + const { text, resources } = extractUserResourcesFromText( + "open [a\\]b.ts](file:///x/a]b.ts) now" + ) + expect(resources).toEqual([ + { name: "a]b.ts", uri: "file:///x/a]b.ts", mime_type: null }, + ]) + expect(text).toBe("open [a\\]b.ts](file:///x/a]b.ts) now") + }) + + it("preserves consecutive spaces in a file path verbatim (no whitespace collapse)", () => { + // A filename with two spaces must round-trip byte-for-byte: collapsing the + // run would rewrite the inline link's path and break the badge target. + const { text, resources } = extractUserResourcesFromText( + "open [a b.ts](<file:///x/a b.ts>) now" + ) + expect(resources).toEqual([ + { name: "a b.ts", uri: "file:///x/a b.ts", mime_type: null }, + ]) + expect(text).toBe("open [a b.ts](<file:///x/a b.ts>) now") + }) + + it("keeps a leading `@` in a file name (scoped-package path), not a mention", () => { + // A file whose name starts with `@` (e.g. a scoped-package dir) must keep the + // `@` — the file uri takes precedence over the `@`-mention heuristic. + const { text, resources } = extractUserResourcesFromText( + "see [@scope](file:///repo/node_modules/@scope) here" + ) + expect(resources).toEqual([ + { + name: "@scope", + uri: "file:///repo/node_modules/@scope", + mime_type: null, + }, + ]) + expect(text).toBe("see [@scope](file:///repo/node_modules/@scope) here") + }) + + it("does not let the blocked-mention pass corrupt a file link containing `[blocked]`", () => { + // Pathological filename `@foo [blocked].txt`: the blocked-`@mention` pre-pass + // must NOT run inside the kept file link, so the inline link survives verbatim + // and the chip name is the real (unescaped) filename. + const { text, resources } = extractUserResourcesFromText( + "see [@foo \\[blocked\\].txt](<file:///x/@foo [blocked].txt>) ok" + ) + expect(resources).toEqual([ + { + name: "@foo [blocked].txt", + uri: "file:///x/@foo [blocked].txt", + mime_type: null, + }, + ]) + expect(text).toBe( + "see [@foo \\[blocked\\].txt](<file:///x/@foo [blocked].txt>) ok" + ) + }) + + it("strips a real blocked @-mention in prose while keeping an adjacent file link", () => { + const { text, resources } = extractUserResourcesFromText( + "@secret.txt [blocked: outside] see [foo.ts](file:///x/foo.ts)" + ) + expect(resources).toEqual([ + { name: "secret.txt", uri: "secret.txt", mime_type: null }, + { name: "foo.ts", uri: "file:///x/foo.ts", mime_type: null }, + ]) + expect(text).toBe("see [foo.ts](file:///x/foo.ts)") + }) + + it("does not corrupt a typed <file://…> angle-string containing [blocked]", () => { + // A bare angle-wrapped uri is not a Markdown link; the blocked-mention pass + // must skip `<…>` spans so it can't strip an `@…[blocked…]` substring out of + // a typed uri and rewrite the path. + const { text, resources } = extractUserResourcesFromText( + "raw <file:///x/@foo [blocked].txt> ok" + ) + expect(resources).toEqual([]) + expect(text).toBe("raw <file:///x/@foo [blocked].txt> ok") + }) + + it("chips a codeg://embedded attachment while keeping its inert badge inline", () => { + const { text, resources } = extractUserResourcesFromText( + "here [report.pdf](codeg://embedded/abc-123) ok" + ) + expect(resources).toEqual([ + { name: "report.pdf", uri: "codeg://embedded/abc-123", mime_type: null }, + ]) + expect(text).toBe("here [report.pdf](codeg://embedded/abc-123) ok") }) it("still lifts blocked @-mentions to the resource list", () => { @@ -774,7 +900,7 @@ describe("extractUserResourcesFromText — codeg references stay inline", () => ]) }) - it("splits a mixed message: file → chip, session → inline", () => { + it("keeps both file:// and session links inline; only the file is also chipped", () => { const { text, resources } = extractUserResourcesFromText( "compare [foo.ts](file:///x/foo.ts) with [#42](codeg://session/codex_abc)" ) @@ -782,7 +908,7 @@ describe("extractUserResourcesFromText — codeg references stay inline", () => { name: "foo.ts", uri: "file:///x/foo.ts", mime_type: null }, ]) expect(text).toContain("[#42](codeg://session/codex_abc)") - expect(text).not.toContain("file://") + expect(text).toContain("[foo.ts](file:///x/foo.ts)") }) }) @@ -812,9 +938,10 @@ describe("adaptMessageTurn — user reference resources", () => { expect(part.text).toContain("[@Codex](codeg://agent/codex)") }) - it("routes a file to the chip row while keeping a session reference inline", () => { + it("chips a folded file link AND keeps it inline as a badge; session stays inline", () => { // Mirrors the backend fold: prose+session in one text block, the file - // resource_link folded to a trailing `[name](uri)` text block. + // resource_link folded to a trailing `[name](uri)` text block. The file is + // copied to the row AND kept inline (rendered as an inline file badge). const adapted = adaptMessageTurn( { id: "u2", @@ -834,11 +961,10 @@ describe("adaptMessageTurn — user reference resources", () => { expect(adapted.userResources).toEqual([ { name: "foo.ts", uri: "file:///x/foo.ts", mime_type: null }, ]) - const textParts = adapted.content.filter((p) => p.type === "text") - expect(textParts).toHaveLength(1) - const part = textParts[0] - if (part.type !== "text") throw new Error("expected a text part") - expect(part.text).toContain("[#42](codeg://session/codex_abc)") - expect(part.text).not.toContain("file://") + const joined = adapted.content + .map((p) => (p.type === "text" ? p.text : "")) + .join("\n") + expect(joined).toContain("[#42](codeg://session/codex_abc)") + expect(joined).toContain("[foo.ts](file:///x/foo.ts)") }) }) diff --git a/src/lib/adapters/ai-elements-adapter.ts b/src/lib/adapters/ai-elements-adapter.ts index 349e5872c..5cd018052 100644 --- a/src/lib/adapters/ai-elements-adapter.ts +++ b/src/lib/adapters/ai-elements-adapter.ts @@ -132,7 +132,19 @@ export interface UserImageDisplay { } const BLOCKED_RESOURCE_MENTION_RE = /@([^\s@]+)\s*\[blocked[^\]]*\]/gi -const MARKDOWN_LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g +// Matches a Markdown link, capturing label + destination. +// +// The label alternative `(?:\\.|[^\]\\])+` accepts backslash escapes inside the +// label: `referenceToMarkdown` backslash-escapes inline punctuation, so a +// filename containing `]` rides in the text as `[a\]b.ts](…)` — a bare `[^\]]+` +// would stop at the escaped `]` and fail to match, dropping the file from the row. +// +// The destination alternative `<[^>]*>` handles CommonMark angle-bracket +// destinations, which `referenceToMarkdown` emits for file URIs containing spaces +// or parentheses (e.g. `[a b.ts](<file:///x/a b.ts>)`): without it the `[^)]*` +// form would stop at the first `)` inside the path, truncating the uri (or, with +// a leading `<`, failing the `file://` test) and dropping the file from the row. +const MARKDOWN_LINK_RE = /\[((?:\\.|[^\]\\])+)\]\((<[^>]*>|[^)]*)\)/g /** * Adapted message format for AI SDK Elements @@ -642,12 +654,25 @@ function sanitizeMentionName(raw: string): string { return raw.replace(/[),.;:!?]+$/g, "") } +/** + * Reverse {@link referenceToMarkdown}'s `escapeMarkdownText`: drop the backslash + * from an escaped inline-punctuation char so a display name reads cleanly. A file + * like `foo (1).ts` is serialized into the text block as `foo \(1\).ts`; the + * resource chip must show `foo (1).ts`, not the backslashes. + */ +function unescapeMarkdownLabel(text: string): string { + return text.replace(/\\([\\`*_~[\]()<>])/g, "$1") +} + +// Tidy the prose AFTER resources were lifted/removed, WITHOUT mutating a +// `file://` link kept inline (the COPY case). Collapsing internal `[ \t]{2,}` +// runs would rewrite a path that legitimately contains consecutive spaces +// (e.g. `a b.ts`) and break the inline badge's target, so only newline-adjacent +// whitespace is normalized — a kept link never contains a newline +// (`referenceToMarkdown` strips them), so these can't touch it. Stray double +// spaces a removed `@`-mention may leave behind collapse harmlessly at render. function normalizeResourceText(text: string): string { - return text - .replace(/[ \t]{2,}/g, " ") - .replace(/\s+\n/g, "\n") - .replace(/\n\s+/g, "\n") - .trim() + return text.replace(/\s+\n/g, "\n").replace(/\n\s+/g, "\n").trim() } function fileNameFromUri(uri: string): string { @@ -688,61 +713,135 @@ function addImage(images: UserImageDisplay[], image: UserImageDisplay) { images.push(image) } -export function extractUserResourcesFromText(text: string): { - text: string +// A `<…>` span (an autolink, a typed bare uri, or a tag). A genuine blocked +// marker is plain `@name [blocked: …]` prose, never angle-wrapped, so the +// blocked-mention pass skips these spans rather than mangle a uri/tag that +// coincidentally contains the `[blocked]` sentinel. +const ANGLE_SPAN_RE = /<[^<>]*>/g + +/** Run the blocked-`@mention` removal over a stretch of non-angle-wrapped prose, + * lifting each `@name [blocked: …]` marker the backend injected to the row. */ +function liftBlockedMentions( + prose: string, resources: UserResourceDisplay[] -} { - const resources: UserResourceDisplay[] = [] - const withoutBlocked = text.replace( +): string { + return prose.replace( BLOCKED_RESOURCE_MENTION_RE, (_match: string, mention: string) => { const name = sanitizeMentionName(mention) if (name.length > 0) { - addResource(resources, { - name, - uri: name, - mime_type: null, - }) + addResource(resources, { name, uri: name, mime_type: null }) } return "" } ) - const cleaned = withoutBlocked.replace( - MARKDOWN_LINK_RE, - (match: string, label: string, uri: string) => { - const normalizedLabel = label.trim() - const normalizedUri = uri.trim() - // A `codeg://` reference (session / commit / agent) renders as an inline - // badge in the transcript (markdown-link → ReferenceBadge); never lift it - // to the bottom resource-chip row. The guard mirrors markdown-link's - // interception (`href.startsWith("codeg:")`): an unrecognized codeg path - // is parsed back to null there and degrades to a plain inline link — still - // in-flow, never a chip. (The `@`-prefixed agent link `[@label](codeg:// - // agent/…)` would otherwise be caught by `hasMentionLabel` below.) - if (normalizedUri.toLowerCase().startsWith("codeg:")) { - return match - } - const hasMentionLabel = normalizedLabel.startsWith("@") - const isFileUri = normalizedUri.toLowerCase().startsWith("file://") - if (!hasMentionLabel && !isFileUri) { - return match - } +} - const candidateName = hasMentionLabel - ? normalizedLabel.slice(1) - : normalizedLabel - const name = sanitizeMentionName(candidateName) || fileNameFromUri(uri) +/** Apply the blocked-`@mention` rule to a run of PLAIN PROSE (never the inside of + * a Markdown link — the caller has already split those out). `<…>` spans within + * the prose are kept verbatim so a typed uri/tag can't be corrupted. */ +function stripBlockedMentions( + segment: string, + resources: UserResourceDisplay[] +): string { + let out = "" + let cursor = 0 + for (const m of segment.matchAll(ANGLE_SPAN_RE)) { + const start = m.index ?? cursor + out += liftBlockedMentions(segment.slice(cursor, start), resources) + out += m[0] + cursor = start + m[0].length + } + out += liftBlockedMentions(segment.slice(cursor), resources) + return out +} + +/** Apply the per-scheme rule to ONE Markdown link, mutating `resources`. Returns + * the text to keep in place of the link: the original `match` for an inline-kept + * ref (file / codeg / non-resource link), or "" for a moved-out `@mention`. */ +function handleMarkdownLink( + match: string, + label: string, + uri: string, + resources: UserResourceDisplay[] +): string { + const normalizedLabel = label.trim() + // Unwrap a CommonMark angle-bracket destination (`<uri>`) to the bare uri so + // scheme tests and the stored value are clean. `match` (returned for + // inline-kept refs) keeps the original bracketed form untouched. + const rawUri = uri.trim() + const normalizedUri = + rawUri.startsWith("<") && rawUri.endsWith(">") + ? rawUri.slice(1, -1).trim() + : rawUri + // A `codeg://` reference (session / commit / agent) renders as an inline badge + // in the transcript (markdown-link → ReferenceBadge); never lift it to the + // bottom resource-chip row. The guard mirrors markdown-link's interception + // (`href.startsWith("codeg:")`): an unrecognized codeg path is parsed back to + // null there and degrades to a plain inline link — still in-flow, never a chip. + // (The `@`-prefixed agent link `[@label](codeg://agent/…)` would otherwise be + // caught by `hasMentionLabel` below.) + if (normalizedUri.toLowerCase().startsWith("codeg:")) { + // A `codeg://embedded/…` ref is a path-less pasted attachment — still an + // attached file, so it is COPIED to the row too (kept inline as its inert + // badge). Other codeg refs are not attachments: inline only. + if (normalizedUri.toLowerCase().startsWith("codeg://embedded/")) { addResource(resources, { - name, + name: unescapeMarkdownLabel(normalizedLabel) || "attachment", uri: normalizedUri, mime_type: null, }) - return "" } - ) + return match + } + const hasMentionLabel = normalizedLabel.startsWith("@") + const isFileUri = normalizedUri.toLowerCase().startsWith("file://") + if (!hasMentionLabel && !isFileUri) { + return match + } + + // `referenceToMarkdown` backslash-escapes label punctuation, so unescape it for + // the chip name. A real file takes precedence: its label is the filename, which + // can legitimately start with `@` (a scoped-package path like + // `node_modules/@scope`) or end in `)`/`.`, so it is used verbatim — never run + // through the mention trimming. Only a NON-file `@`-mention gets its `@` + // stripped and trailing sentence punctuation trimmed. + const name = isFileUri + ? unescapeMarkdownLabel(normalizedLabel) || fileNameFromUri(normalizedUri) + : sanitizeMentionName(unescapeMarkdownLabel(normalizedLabel.slice(1))) || + fileNameFromUri(normalizedUri) + addResource(resources, { name, uri: normalizedUri, mime_type: null }) + // A real `file://` attachment is COPIED, not moved: it stays inline in the + // prose (so markdown-link renders it as an inline file badge at the position + // the sender typed it) AND is listed in the attachment row below the message + // (the original grey-chip style). A bare blocked `@mention` link carries no + // openable uri, so there is no inline badge to keep — it is still lifted out + // (moved) to the row only. + return isFileUri ? match : "" +} + +export function extractUserResourcesFromText(text: string): { + text: string + resources: UserResourceDisplay[] +} { + const resources: UserResourceDisplay[] = [] + // Tokenize into alternating [prose, link, prose, link, …] so the + // blocked-mention pass only ever touches PLAIN PROSE — never the inside of a + // kept Markdown file link, whose label/uri could otherwise coincidentally + // contain an `@…[blocked…]` pattern and be mutated before extraction. The link + // segments are handled verbatim by `handleMarkdownLink`. + let out = "" + let cursor = 0 + for (const match of text.matchAll(MARKDOWN_LINK_RE)) { + const start = match.index ?? cursor + out += stripBlockedMentions(text.slice(cursor, start), resources) + out += handleMarkdownLink(match[0], match[1], match[2], resources) + cursor = start + match[0].length + } + out += stripBlockedMentions(text.slice(cursor), resources) return { - text: normalizeResourceText(cleaned), + text: normalizeResourceText(out), resources, } } From d6400a42248c6e367a4dce8264c7077168c9fa5d Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Sat, 13 Jun 2026 23:29:15 +0800 Subject: [PATCH 25/31] feat(composer): click-to-place caret and a text cursor across the input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking anywhere in the composer — including the blank padding and the dead space around a short message — now places the caret at the click point instead of jumping to the end of the document, matching a native textarea. The whole input box paints the text I-beam cursor over its blank areas, while interactive controls keep the pointer cursor. --- src/app/globals.css | 15 +++++++ .../chat/composer/rich-composer.tsx | 39 +++++++++++++++++++ src/components/chat/message-input.test.tsx | 3 ++ src/components/chat/message-input.tsx | 13 +++++-- 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 6503bac3a..b9c9c423e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1461,6 +1461,21 @@ /* ───────────────── Rich-text composer (Tiptap) ───────────────── Live WYSIWYG Markdown styling for the message composer. Scoped to `.codeg-composer` so it never leaks into Streamdown message rendering. */ + +/* The composer box reads as one editable surface: clicking its blank chrome + (padding, the dead space below a short message, the action-bar gaps) focuses + the editor, so the I-beam should show across all of it. `cursor` inherits, so + the contenteditable already gets `text`; interactive controls inside the box + re-assert a pointer (buttons carry no explicit cursor of their own). Disabled + buttons keep `pointer-events: none`, so the box's I-beam shows through them — + intended: clicking a disabled control still focuses the editor. */ +.codeg-composer-chrome { + cursor: text; +} +.codeg-composer-chrome :is(button, a[href], [role="button"], [role="menuitem"], [role="combobox"]) { + cursor: pointer; +} + .codeg-composer .ProseMirror { outline: none; white-space: pre-wrap; diff --git a/src/components/chat/composer/rich-composer.tsx b/src/components/chat/composer/rich-composer.tsx index f5def7662..3ae9f7db0 100644 --- a/src/components/chat/composer/rich-composer.tsx +++ b/src/components/chat/composer/rich-composer.tsx @@ -53,6 +53,14 @@ export interface RichComposerHandle { clear: () => void /** Focus the editor at the end of the document. */ focus: () => void + /** + * Focus the editor and place the caret at the document position nearest the + * given viewport coordinates (native-textarea behavior). Falls back to the + * end of the document when the point can't be mapped (e.g. it lands outside + * the editing surface). Used by the host to honor where a user clicks in the + * composer's blank chrome instead of always jumping to the end. + */ + focusAtCoords: (clientX: number, clientY: number) => void /** Whether the document is empty (no text, no nodes). */ isEmpty: () => boolean /** Serialize the current document to Tiptap JSON (for draft persistence). */ @@ -377,6 +385,37 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>( setDoc: (doc) => editor?.commands.setContent(doc), clear: () => editor?.commands.clearContent(true), focus: () => editor?.commands.focus("end"), + focusAtCoords: (clientX, clientY) => { + if (!editor) return + const view = editor.view + // Map the click point to a document position. Chrome clicks land on + // the composer's padding/dead space, which is *outside* the + // contenteditable (`view.dom` is the inner `.ProseMirror`; the + // `px-3 py-2` padding lives on the EditorContent wrapper), so + // `posAtCoords` returns null there. Clamp the point onto the editor's + // own box and retry, so left/top/bottom-padding clicks snap to the + // nearest in-text position (native-textarea feel) instead of jumping + // to the end. Only a point that maps nowhere even when clamped (e.g. + // an empty editor edge case) falls through to end-of-doc. + let hit = view.posAtCoords({ left: clientX, top: clientY }) + if (!hit) { + const rect = view.dom.getBoundingClientRect() + const left = Math.min( + Math.max(clientX, rect.left + 1), + rect.right - 1 + ) + const top = Math.min( + Math.max(clientY, rect.top + 1), + rect.bottom - 1 + ) + hit = view.posAtCoords({ left, top }) + } + if (hit) { + editor.chain().focus().setTextSelection(hit.pos).run() + } else { + editor.commands.focus("end") + } + }, isEmpty: () => editor?.isEmpty ?? true, getJSON: () => editor?.getJSON() ?? { type: "doc", content: [] }, insertMarkdownAtCursor: (markdown) => { diff --git a/src/components/chat/message-input.test.tsx b/src/components/chat/message-input.test.tsx index 3a1af5c52..4e07752dc 100644 --- a/src/components/chat/message-input.test.tsx +++ b/src/components/chat/message-input.test.tsx @@ -91,6 +91,9 @@ describe("MessageInput (RichComposer integration)", () => { // false when the event was canceled) avoids relying on jsdom focus. const card = container.querySelector('[class~="@container"]') as HTMLElement expect(card).not.toBeNull() + // The same box paints the text I-beam across its blank chrome (see the + // `.codeg-composer-chrome` rule in globals.css). + expect(card.className).toContain("codeg-composer-chrome") expect(fireEvent.mouseDown(card)).toBe(false) }) }) diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index b10986c0f..d1b9233e2 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -2147,13 +2147,16 @@ export function MessageInput({ // short message, the gaps in the action bar) focuses the editor — previously // only the editor surface itself was clickable. Interactive controls, inline // badges and the editor surface handle their own clicks, so they're excluded; - // `preventDefault` keeps the editor from blurring before we refocus it. + // `preventDefault` keeps the editor from blurring before we refocus it. We + // focus *at the click point* (not the end of the document) so clicking the + // left/top padding next to existing text lands the caret there, like a native + // textarea, instead of always jumping to the end. const handleChromeMouseDown = useCallback( (e: React.MouseEvent<HTMLDivElement>) => { if (disabled || !isComposerChromeClick(e.target)) return // Keep the editor from blurring before we refocus it. e.preventDefault() - editorRef.current?.focus() + editorRef.current?.focusAtCoords(e.clientX, e.clientY) }, [disabled] ) @@ -2410,7 +2413,11 @@ export function MessageInput({ <div onMouseDown={handleChromeMouseDown} className={cn( - "@container relative flex flex-col bg-transparent transition-colors", + // `codeg-composer-chrome` paints the text I-beam across the box's + // blank areas (padding, the dead space below a short message, the + // action-bar gaps) so the whole input reads as clickable-to-type; + // interactive controls re-assert their own cursor (see globals.css). + "codeg-composer-chrome @container relative flex flex-col bg-transparent transition-colors", folderBranchPickerAttached ? "rounded-xl border border-input bg-background focus-within:border-ring focus-within:ring-[3px] focus-within:ring-inset focus-within:ring-ring/50" : "rounded-xl border border-input focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50", From e03d7e42fc7cab90935bcf553b402dd33a9a24ca Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Sat, 13 Jun 2026 23:29:28 +0800 Subject: [PATCH 26/31] feat(conversations): fold reference badge links to their label in titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conversation titles that embed inline reference links — the `[label](uri)` form produced by file, session, commit and agent mentions — now display just the bracket label across the tab bar, the sidebar, search, the manage dialog and the pet panel, instead of raw Markdown. The change is display-only; the stored title is unchanged. --- src/app/pet-panel/_components/SessionRow.tsx | 4 +- .../conversation-manage-dialog.tsx | 4 +- .../conversations/search-command-dialog.tsx | 6 +- .../sidebar-conversation-card.tsx | 8 +- src/contexts/tab-context.tsx | 30 ++-- src/lib/conversation-title.test.ts | 151 ++++++++++++++++++ src/lib/conversation-title.ts | 146 +++++++++++++++++ 7 files changed, 333 insertions(+), 16 deletions(-) create mode 100644 src/lib/conversation-title.test.ts create mode 100644 src/lib/conversation-title.ts diff --git a/src/app/pet-panel/_components/SessionRow.tsx b/src/app/pet-panel/_components/SessionRow.tsx index 49e9f6afa..ca06210b9 100644 --- a/src/app/pet-panel/_components/SessionRow.tsx +++ b/src/app/pet-panel/_components/SessionRow.tsx @@ -10,6 +10,7 @@ import { type PetSessionStatusKind, } from "@/lib/pet/session-display" import { cn } from "@/lib/utils" +import { formatConversationTitle } from "@/lib/conversation-title" import { PanelPermissionCard } from "./PanelPermissionCard" interface SessionRowProps { @@ -57,7 +58,8 @@ export function SessionRow({ session }: SessionRowProps) { > <AgentIcon agentType={session.agentType} className="h-4 w-4 shrink-0" /> <span className="min-w-0 flex-1 truncate text-sm"> - {session.title || AGENT_LABELS[session.agentType]} + {formatConversationTitle(session.title) || + AGENT_LABELS[session.agentType]} </span> <span className="flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground"> <span diff --git a/src/components/conversations/conversation-manage-dialog.tsx b/src/components/conversations/conversation-manage-dialog.tsx index 0d088f719..13265d2b9 100644 --- a/src/components/conversations/conversation-manage-dialog.tsx +++ b/src/components/conversations/conversation-manage-dialog.tsx @@ -60,6 +60,7 @@ import type { } from "@/lib/types" import { AGENT_LABELS, ALL_AGENT_TYPES, STATUS_ORDER } from "@/lib/types" import { cn } from "@/lib/utils" +import { formatConversationTitle } from "@/lib/conversation-title" import { toErrorMessage } from "@/lib/app-error" import { ConversationStatusDot } from "@/components/conversations/conversation-status-dot" @@ -374,7 +375,8 @@ export function ConversationManageDialog({ className="h-4 w-4 shrink-0" /> <span className="flex-1 min-w-0 truncate text-sm"> - {conv.title || t("untitledConversation")} + {formatConversationTitle(conv.title) || + t("untitledConversation")} </span> <span className="shrink-0 text-xs text-muted-foreground tabular-nums w-14 text-right"> {t("messagesShort", { count: conv.message_count })} diff --git a/src/components/conversations/search-command-dialog.tsx b/src/components/conversations/search-command-dialog.tsx index 9ccaa5782..fe5bfb880 100644 --- a/src/components/conversations/search-command-dialog.tsx +++ b/src/components/conversations/search-command-dialog.tsx @@ -29,6 +29,7 @@ import { CommandItem, } from "@/components/ui/command" import { cn } from "@/lib/utils" +import { formatConversationTitle } from "@/lib/conversation-title" type SearchTab = "conversations" | "files" @@ -280,14 +281,15 @@ export function SearchCommandDialog({ {results.map((conv) => ( <CommandItem key={conv.id} - value={`${conv.id}-${conv.title ?? ""}`} + value={`${conv.id}-${formatConversationTitle(conv.title)}`} onSelect={() => handleSelectConversation(conv)} > <ConversationStatusDot status={conv.status as ConversationStatus} /> <span className="flex-1 truncate"> - {conv.title || t("untitledConversation")} + {formatConversationTitle(conv.title) || + t("untitledConversation")} </span> <span className="text-xs text-muted-foreground shrink-0"> {AGENT_LABELS[conv.agent_type]} diff --git a/src/components/conversations/sidebar-conversation-card.tsx b/src/components/conversations/sidebar-conversation-card.tsx index 0a7bccb99..1eacecf48 100644 --- a/src/components/conversations/sidebar-conversation-card.tsx +++ b/src/components/conversations/sidebar-conversation-card.tsx @@ -16,6 +16,7 @@ import { useTranslations } from "next-intl" import type { DbConversationSummary, ConversationStatus } from "@/lib/types" import { STATUS_ORDER } from "@/lib/types" import { cn } from "@/lib/utils" +import { formatConversationTitle } from "@/lib/conversation-title" import { ContextMenu, ContextMenuTrigger, @@ -205,7 +206,8 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({ isOpenInTab && "text-primary" )} > - {conversation.title || t("untitledConversation")} + {formatConversationTitle(conversation.title) || + t("untitledConversation")} </span> </button> @@ -411,7 +413,9 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({ <AlertDialogTitle>{t("deleteConversationTitle")}</AlertDialogTitle> <AlertDialogDescription> {t("deleteConversationDescription", { - title: conversation.title || t("untitledConversation"), + title: + formatConversationTitle(conversation.title) || + t("untitledConversation"), })} </AlertDialogDescription> </AlertDialogHeader> diff --git a/src/contexts/tab-context.tsx b/src/contexts/tab-context.tsx index d57fd6d74..e9a3fca36 100644 --- a/src/contexts/tab-context.tsx +++ b/src/contexts/tab-context.tsx @@ -19,6 +19,7 @@ import { useSortedAvailableAgents } from "@/hooks/use-sorted-available-agents" import { listOpenedTabs, saveOpenedTabs } from "@/lib/api" import { onTransportReconnect, subscribe } from "@/lib/platform" import { resolveDefaultAgent } from "@/lib/resolve-default-agent" +import { formatConversationTitle } from "@/lib/conversation-title" import { loadLastActiveContext, saveLastActiveContext, @@ -694,7 +695,8 @@ export function TabProvider({ children }: TabProviderProps) { `${tab.folderId}-${tab.agentType}-${tab.conversationId}` ) if (conv) { - const newTitle = conv.title || t("untitledConversation") + const newTitle = + formatConversationTitle(conv.title) || t("untitledConversation") const newStatus = conv.status as ConversationStatus | undefined if (tab.title !== newTitle || tab.status !== newStatus) { return { ...tab, title: newTitle, status: newStatus } @@ -738,15 +740,20 @@ export function TabProvider({ children }: TabProviderProps) { return { ...prevState, activeTabId: activateTabId } } + // Format the seed title so a draft/conversation title carrying an + // inline reference link (`[README.md](file://…)`) shows its label, not + // raw Markdown, before the `tabs` memo re-derives it from the refreshed + // conversation list. const resolvedTitle = - title ?? - conversationsRef.current.find( - (c) => - c.id === conversationId && - c.agent_type === agentType && - c.folder_id === folderId - )?.title ?? - t("untitledConversation") + formatConversationTitle( + title ?? + conversationsRef.current.find( + (c) => + c.id === conversationId && + c.agent_type === agentType && + c.folder_id === folderId + )?.title + ) || t("untitledConversation") const tabId = makeConversationTabId(folderId, agentType, conversationId) const newTab: TabItemInternal = { @@ -1539,7 +1546,10 @@ export function TabProvider({ children }: TabProviderProps) { ...tab, conversationId, agentType, - title, + // The bind title is the first message's display text, which can + // carry an inline reference link — fold it to the label so the + // tab never flashes raw `[name](file://…)` Markdown. + title: formatConversationTitle(title) || tab.title, runtimeConversationId, // Bound to a real conversation now — drop the provisional // hint so the correction effect never revisits it. diff --git a/src/lib/conversation-title.test.ts b/src/lib/conversation-title.test.ts new file mode 100644 index 000000000..15fb1fdca --- /dev/null +++ b/src/lib/conversation-title.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest" + +import { formatConversationTitle } from "./conversation-title" + +describe("formatConversationTitle", () => { + it("returns an empty string for nullish titles", () => { + expect(formatConversationTitle(null)).toBe("") + expect(formatConversationTitle(undefined)).toBe("") + expect(formatConversationTitle("")).toBe("") + }) + + it("leaves plain prose untouched", () => { + expect(formatConversationTitle("Fix the login bug")).toBe( + "Fix the login bug" + ) + expect(formatConversationTitle("看看这个问题")).toBe("看看这个问题") + }) + + it("reduces a file reference link to its label", () => { + expect( + formatConversationTitle("[README.md](file:///Users/x/README.md)") + ).toBe("README.md") + }) + + it("keeps surrounding text around a reference link", () => { + expect( + formatConversationTitle( + "看看 [README.md](file:///Users/x/README.md) 这是什么" + ) + ).toBe("看看 README.md 这是什么") + }) + + it("folds multiple reference links in one title", () => { + expect( + formatConversationTitle( + "compare [a.ts](file:///a.ts) and [b.ts](file:///b.ts)" + ) + ).toBe("compare a.ts and b.ts") + }) + + it("reduces session/commit/agent links (codeg:// uris) to their label", () => { + expect( + formatConversationTitle("[My chat](codeg://session/codex_abc)") + ).toBe("My chat") + expect( + formatConversationTitle("[abc1234](codeg://commit/%2Frepo@abc)") + ).toBe("abc1234") + // An agent reference keeps the `@` — it lives inside the bracket text. + expect(formatConversationTitle("[@Codex](codeg://agent/codex)")).toBe( + "@Codex" + ) + }) + + it("does not touch invocation tokens that are not links", () => { + expect(formatConversationTitle("@Codex please review")).toBe( + "@Codex please review" + ) + expect(formatConversationTitle("run /review on this")).toBe( + "run /review on this" + ) + }) + + it("handles an angle-bracket-wrapped destination (spaces/parens in the uri)", () => { + expect( + formatConversationTitle("[report (1).pdf](<file:///tmp/report (1).pdf>)") + ).toBe("report (1).pdf") + }) + + it("unescapes Markdown-escaped characters in the label", () => { + // A label containing `]` and `(` is emitted escaped as `\]` / `\(`. + expect(formatConversationTitle("[a\\]b\\(c](file:///x)")).toBe("a]b(c") + }) + + it("handles a Windows file uri with escaped backslashes inside <…>", () => { + // `escapeLinkDestination` doubles every `\` inside `<…>`, so a real emitted + // Windows path looks like `<file:///C:\\proj\\dir\\>` (the trailing `\\` is + // an escaped backslash, not an escape of the closing `>`). + expect( + formatConversationTitle("[dir](<file:///C:\\\\proj\\\\dir\\\\>)") + ).toBe("dir") + }) + + it("leaves an unterminated/partial link as-is", () => { + expect(formatConversationTitle("[oops no close](file:///x")).toBe( + "[oops no close](file:///x" + ) + expect(formatConversationTitle("just [brackets]")).toBe("just [brackets]") + }) + + it("leaves a malformed angle destination (no closing >) untouched", () => { + // The angle branch needs a closing `>`, and the bare branch rejects `<`, + // so neither matches — the text is not mistaken for a link. + expect(formatConversationTitle("[a](<unterminated)")).toBe( + "[a](<unterminated)" + ) + }) + + it("reduces an empty-destination link to its label", () => { + // `[a]()` is a valid (empty-href) CommonMark link, so it folds to `a`. + expect(formatConversationTitle("[a]()")).toBe("a") + }) + + it("also reduces an ordinary web link — a raw url never belongs in a title", () => { + expect( + formatConversationTitle("see [the docs](https://example.com/x) first") + ).toBe("see the docs first") + }) + + it("closes a balanced nested-bracket label at the right `]`", () => { + // The label is `a [b]` (balanced), so it folds to that — not the inner link. + expect(formatConversationTitle("[a [b]](https://x)")).toBe("a [b]") + // A bracketed-only label keeps its inner brackets verbatim in the label. + expect(formatConversationTitle("[[b]](https://x)")).toBe("[b]") + }) + + it("leaves an unbalanced nested-bracket fragment untouched", () => { + // `[a [b](https://x)` never balances the outer `[`, so it is not a link. + expect(formatConversationTitle("[a [b](https://x)")).toBe( + "[a [b](https://x)" + ) + }) + + it("does not let a backslash escape whitespace in a destination", () => { + // A `\` + space / line break is a literal backslash (CommonMark won't escape + // whitespace), so the destination is malformed and the text is left raw. + expect(formatConversationTitle("[a](foo\\ bar)")).toBe("[a](foo\\ bar)") + expect(formatConversationTitle("[a](foo\\\nbar)")).toBe("[a](foo\\\nbar)") + expect(formatConversationTitle("[a](<\\\n>)")).toBe("[a](<\\\n>)") + // …but a backslash-escaped `>` inside `<…>` is a real escape and still folds. + expect(formatConversationTitle("[a](<x\\>y>)")).toBe("a") + }) + + it("stays linear on pathological unmatched-bracket input (no ReDoS)", () => { + // A regex for `[label](dest)` backtracks super-linearly here; the + // single-pass parser returns instantly. A quadratic regression would blow + // vitest's default timeout, so these large malformed inputs guard it. + const brackets = "[".repeat(100_000) + expect(formatConversationTitle(brackets)).toBe(brackets) + const bracketsThenPairs = "[".repeat(50_000) + "](".repeat(200) + expect(formatConversationTitle(bracketsThenPairs)).toBe(bracketsThenPairs) + // Repeated unterminated angle destinations: each `<…` must stop at the next + // `<` rather than scanning to EOF, or this is quadratic. + const angleAttack = "[a](<".repeat(50_000) + expect(formatConversationTitle(angleAttack)).toBe(angleAttack) + // A genuine link after a long prose prefix is still folded. + const prefix = "x".repeat(50_000) + expect(formatConversationTitle(`${prefix} [a](file:///a)`)).toBe( + `${prefix} a` + ) + }) +}) diff --git a/src/lib/conversation-title.ts b/src/lib/conversation-title.ts new file mode 100644 index 000000000..e9cf371a9 --- /dev/null +++ b/src/lib/conversation-title.ts @@ -0,0 +1,146 @@ +/** + * A conversation's auto-title is parsed from the first user message, which since + * the inline-file-badge work can carry Markdown reference links — a `@`-file + * mention, a session/commit/agent reference — serialized as `[label](uri)` (see + * `referenceToMarkdown`). Shown verbatim in a tab or the sidebar that reads as + * raw `[README.md](file:///…)` noise. {@link formatConversationTitle} folds each + * such link back to just its bracket label (the human-readable badge text), + * leaving all other title text untouched, so titles display the way the message + * does. Display-only — the stored title (rename, search, export) is unchanged. + * + * Implemented as a single forward scan rather than a regex: titles are not + * length-capped on the rename/API paths, and a regex for `[label](dest)` with + * its escaped-label / `<…>`-dest branches backtracks super-linearly on + * pathological input (e.g. thousands of unmatched `[`), which would jank every + * sidebar/tab render. This parser visits each character O(1) times. + */ + +// Reverse `escapeMarkdownText`: drop the backslash from each escaped +// inline-significant punctuation char so the recovered label reads literally. +function unescapeLabel(label: string): string { + return label.replace(/\\([\\`*_~[\]()<>])/g, "$1") +} + +// Whether the backslash at `k` escapes the next character. CommonMark never lets +// a backslash escape a space or line break, so a `\` + whitespace must END (not +// extend) a label/destination scan — only `\` + a non-whitespace char (the +// punctuation we care about: `]`, `>`, `<`, `)`) is a real escape. This keeps a +// malformed `[a](foo\ bar)` or `[a](<…\<newline>…>)` correctly left verbatim. +function escapesNext(s: string, k: number): boolean { + return s[k] === "\\" && k + 1 < s.length && !/\s/.test(s[k + 1]) +} + +/** + * If a well-formed `(destination)` begins at `start`, return the index just past + * its closing `)`; otherwise -1. Mirrors `escapeLinkDestination`'s two forms: an + * `<…>`-wrapped destination (interior `\`, `<`, `>` backslash-escaped) or a bare + * run containing no `(`, `)`, whitespace, `<` or `>`. + */ +function destinationEnd(s: string, start: number): number { + const n = s.length + if (start >= n || s[start] !== "(") return -1 + let k = start + 1 + if (s[k] === "<") { + k += 1 + while (k < n) { + const c = s[k] + if (escapesNext(s, k)) { + k += 2 + continue + } + if (c === ">") return s[k + 1] === ")" ? k + 2 : -1 + // CommonMark forbids an unescaped `<` or a line break inside `<…>`, so + // bail on them. This also bounds the scan: a malformed `…](<…` without a + // closing `>` stops at the next `<` instead of running to EOF, which is + // what keeps `"[a](<".repeat(n)` linear rather than quadratic. + if (c === "<" || c === "\n" || c === "\r") return -1 + k += 1 + } + return -1 + } + while (k < n) { + const c = s[k] + if (escapesNext(s, k)) { + k += 2 + continue + } + if (c === ")") return k + 1 + if (c === "(" || c === "<" || c === ">" || /\s/.test(c)) return -1 + k += 1 + } + return -1 +} + +/** + * Replace every `[label](destination)` link in a conversation title with its + * unescaped `label`, so inline badges display as their text instead of raw + * Markdown. Plain prose (including invocation tokens like `@Codex` or `/review`, + * which are not links) is left as-is, as are malformed `[…]`/`(…)` fragments. A + * raw `[text](url)` never belongs in a one-line title, so ordinary links are + * folded too. Returns `""` for a nullish title so callers can keep their + * `formatConversationTitle(title) || untitledFallback` shape. + */ +export function formatConversationTitle( + title: string | null | undefined +): string { + if (!title) return "" + const n = title.length + let out = "" + let i = 0 + while (i < n) { + if (title[i] !== "[") { + out += title[i] + i += 1 + continue + } + // Scan the label to the `]` that balances this `[`, skipping escaped pairs + // and tracking nested unescaped brackets so a balanced label closes at the + // right `]` (`[a [b]](u)` folds to `a [b]`, not the inner `[b]`), while an + // unbalanced `[a [b](u)` never closes and is left verbatim. Reference labels + // escape their brackets, so depth only matters for hand-typed nested prose; + // we deliberately don't replicate CommonMark's full nested-link resolution + // (which needs backtracking) — that would forfeit the single-pass O(n) scan. + let j = i + 1 + let depth = 0 + let closed = false + while (j < n) { + const c = title[j] + if (escapesNext(title, j)) { + j += 2 + continue + } + if (c === "[") { + depth += 1 + j += 1 + continue + } + if (c === "]") { + if (depth === 0) { + closed = true + break + } + depth -= 1 + j += 1 + continue + } + j += 1 + } + if (!closed) { + // No `]` remains ahead, so nothing else can be a link either. + out += title.slice(i) + break + } + const end = destinationEnd(title, j + 1) + if (end === -1) { + // `[…]` not followed by a well-formed `(dest)`: emit it literally and + // resume just after `]` — never re-scanning the label, which keeps the + // whole pass O(n) even on adversarial unmatched-bracket input. + out += title.slice(i, j + 1) + i = j + 1 + continue + } + out += unescapeLabel(title.slice(i + 1, j)) + i = end + } + return out +} From 94d8fcffa375f43569ffec976dbd99a98371a59e Mon Sep 17 00:00:00 2001 From: xintaofei <itpkcn@gmail.com> Date: Sun, 14 Jun 2026 07:39:48 +0800 Subject: [PATCH 27/31] feat(composer): conversation-icon session badges and folded @ panel titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline session reference badges — in the composer and in user-message transcripts — now show a neutral conversation icon instead of the owning agent's icon and no longer render a trailing status dot, so a session reads as a conversation rather than as the agent that owns it. The @ panel's option rows keep the agent icon so sessions stay distinguishable while picking one. The @ panel also folds any inline reference link inside a conversation's title down to its label text, matching the sidebar, so a session row and the badge it inserts read as plain titles instead of raw Markdown. --- .../ai-elements/markdown-link.test.tsx | 11 +++- .../composer/badges/reference-badge.test.tsx | 29 ++++++++- .../chat/composer/badges/reference-badge.tsx | 64 ++++++++++++------- .../chat/composer/suggestion/adapters.test.ts | 15 +++++ .../chat/composer/suggestion/adapters.ts | 19 ++++-- .../composer/suggestion/suggestion-popup.tsx | 2 +- src/components/chat/composer/types.ts | 10 ++- 7 files changed, 111 insertions(+), 39 deletions(-) diff --git a/src/components/ai-elements/markdown-link.test.tsx b/src/components/ai-elements/markdown-link.test.tsx index 125925ebd..b17565619 100644 --- a/src/components/ai-elements/markdown-link.test.tsx +++ b/src/components/ai-elements/markdown-link.test.tsx @@ -100,7 +100,7 @@ describe("MarkdownLink", () => { }) describe("codeg:// reference badges", () => { - it("renders a new-format session link as a session badge (agent icon)", () => { + it("renders a session link as a session badge (conversation glyph, no agent icon or status dot)", () => { render( <MarkdownLink href="codeg://session/codex_abc">My chat</MarkdownLink> ) @@ -109,8 +109,13 @@ describe("MarkdownLink", () => { const badge = screen.getByRole("img", { name: "session: My chat" }) expect(badge).toHaveAttribute("data-reference-badge") expect(badge).toHaveAttribute("data-ref-type", "session") - // codex agent type recovered from the uri → an AgentIcon svg renders. - expect(badge.querySelector("svg")).not.toBeNull() + // Even though the codex agent type is recoverable from the uri, the inline + // transcript badge shows the neutral conversation glyph — not the owning + // agent's icon (an AgentIcon svg would carry <title>Codex) — and no + // trailing status dot. (User messages mirror the composer badge.) + expect(badge.querySelector(".lucide-message-square")).not.toBeNull() + expect(badge.querySelector("title")).toBeNull() + expect(badge.querySelector(".rounded-full")).toBeNull() }) it("renders a legacy numeric session link as a session badge", () => { diff --git a/src/components/chat/composer/badges/reference-badge.test.tsx b/src/components/chat/composer/badges/reference-badge.test.tsx index e973ef7cd..bdd0b3257 100644 --- a/src/components/chat/composer/badges/reference-badge.test.tsx +++ b/src/components/chat/composer/badges/reference-badge.test.tsx @@ -47,15 +47,38 @@ describe("ReferenceBadge", () => { expect(container.querySelector(".lucide-file-text")).not.toBeNull() }) - it("colors a session reference emerald", () => { + it("colors a session reference emerald with the conversation glyph", () => { const { container } = render( ) const badge = badgeOf(container) expect(badge).toHaveAttribute("data-ref-type", "session") expect(badge).toHaveClass("text-emerald-700") - // No agentType meta → falls back to the Hash icon. - expect(container.querySelector(".lucide-hash")).not.toBeNull() + // A session badge always shows the neutral conversation glyph (not the + // owning agent's icon, not Hash) — see the `session` case in ReferenceIcon. + expect(container.querySelector(".lucide-message-square")).not.toBeNull() + expect(container.querySelector(".lucide-hash")).toBeNull() + }) + + it("shows the conversation glyph and no status dot even with agent/status meta", () => { + // The inline badge ignores the owning agent and the live status: it never + // shows an agent icon and never paints the trailing status dot (those belong + // to the `@`-panel option row and the sidebar, not the inline chip). + const { container } = render( + + ) + expect(container.querySelector(".lucide-message-square")).not.toBeNull() + // No agent icon: AgentIcon for codex renders an with Codex; + // the conversation glyph carries no , so none should be present. + expect(container.querySelector("title")).toBeNull() + // No trailing status dot (the removed `rounded-full` indicator). + expect(container.querySelector(".rounded-full")).toBeNull() }) it("renders a command/skill with the command glyph, colored rose", () => { diff --git a/src/components/chat/composer/badges/reference-badge.tsx b/src/components/chat/composer/badges/reference-badge.tsx index 6fd8367df..efb0fcd6d 100644 --- a/src/components/chat/composer/badges/reference-badge.tsx +++ b/src/components/chat/composer/badges/reference-badge.tsx @@ -1,19 +1,34 @@ -import { Bot, Command, FileText, Folder, GitCommit, Hash } from "lucide-react" +import { + Bot, + Command, + FileText, + Folder, + GitCommit, + Hash, + MessageSquare, +} from "lucide-react" import type { ReactNode } from "react" import { AgentIcon } from "@/components/agent-icon" -import { - STATUS_COLORS, - type AgentType, - type ConversationStatus, -} from "@/lib/types" +import { type AgentType } from "@/lib/types" import { cn } from "@/lib/utils" import type { ReferenceAttrs } from "../types" const ICON_CLASS = "size-3.5 shrink-0" -export function ReferenceIcon({ data }: { data: ReferenceAttrs }) { +export function ReferenceIcon({ + data, + variant = "badge", +}: { + data: ReferenceAttrs + /** + * Where the icon is shown. `"badge"` (default) is the inline reference chip in + * the composer and the message transcript; `"option"` is a row in the `@` + * panel. They differ only for sessions (see the `session` case). + */ + variant?: "badge" | "option" +}) { const meta = data.meta let icon: ReactNode = null switch (data.refType) { @@ -35,11 +50,22 @@ export function ReferenceIcon({ data }: { data: ReferenceAttrs }) { break } case "session": - icon = meta?.agentType ? ( - - ) : ( - - ) + // The inline badge (composer + transcript) shows a neutral conversation + // glyph: a session reference reads as "a conversation", not as the agent + // that owns it, and it carries no live status. The `@`-panel option row + // (`variant="option"`) instead shows the owning agent's icon so sessions + // stay distinguishable while picking one (falling back to `Hash` for a + // legacy id with no recoverable agent type). + icon = + variant === "option" ? ( + meta?.agentType ? ( + + ) : ( + + ) + ) : ( + + ) break case "commit": icon = @@ -94,14 +120,10 @@ export interface ReferenceBadgeProps { /** * Presentational inline chip for a reference. Shared by the editor node view and - * (later) message-transcript rendering. Purely visual — no editor coupling. + * the message-transcript rendering (markdown-link → here). Purely visual — no + * editor coupling. */ export function ReferenceBadge({ data, className }: ReferenceBadgeProps) { - const statusColor = - data.refType === "session" && data.meta?.status - ? STATUS_COLORS[data.meta.status as ConversationStatus] - : undefined - return ( {data.label || data.id} - {statusColor && ( - - )} ) } diff --git a/src/components/chat/composer/suggestion/adapters.test.ts b/src/components/chat/composer/suggestion/adapters.test.ts index 76bf3e0b5..af84b3861 100644 --- a/src/components/chat/composer/suggestion/adapters.test.ts +++ b/src/components/chat/composer/suggestion/adapters.test.ts @@ -103,6 +103,21 @@ describe("sessionToSuggestion", () => { "#123" ) }) + it("folds inline reference badges in the title to their label text", () => { + // A title carrying a serialized file badge shows like the sidebar — just the + // bracket text — in the panel row and on the inserted session badge. + const item = sessionToSuggestion({ + ...base, + title: "[README.md](file:///repo/README.md) fix the bug", + }) + expect(item.reference.label).toBe("README.md fix the bug") + expect(item.keywords).toBe("README.md fix the bug codex") + }) + it("falls back to #id when the title is only whitespace", () => { + expect(sessionToSuggestion({ ...base, title: " " }).reference.label).toBe( + "#123" + ) + }) }) describe("commitToSuggestion", () => { diff --git a/src/components/chat/composer/suggestion/adapters.ts b/src/components/chat/composer/suggestion/adapters.ts index 14c7654a8..00512624b 100644 --- a/src/components/chat/composer/suggestion/adapters.ts +++ b/src/components/chat/composer/suggestion/adapters.ts @@ -1,4 +1,5 @@ import type { FlatFileEntry } from "@/hooks/use-file-tree" +import { formatConversationTitle } from "@/lib/conversation-title" import { AGENT_LABELS, type AcpAgentInfo, @@ -66,15 +67,23 @@ export function agentToSuggestion(agent: AcpAgentInfo): SuggestionItem { /** * Conversation → session reference. The serialization uri encodes the agent's - * own session id as `codeg://session/_` (so a transcript - * badge can show the right agent icon and a future codeg-mcp can resolve it by - * `(agent_type, external_id)`); sessions without an `external_id` fall back to - * the internal numeric id. The in-app `id` stays the numeric id either way. + * own session id as `codeg://session/_` (so the `@`-panel + * option row can show the owning agent's icon and a future codeg-mcp can resolve + * it by `(agent_type, external_id)`); sessions without an `external_id` fall back + * to the internal numeric id. The in-app `id` stays the numeric id either way. + * The inline session badge itself shows a neutral conversation glyph, not the + * agent icon. */ export function sessionToSuggestion( conversation: DbConversationSummary ): SuggestionItem { - const label = conversation.title?.trim() || `#${conversation.id}` + // Fold any inline reference badges in the title (`[name](file://…)`, …) down + // to their bracket text, so the panel row and the inserted session badge read + // like the sidebar's title (`README.md fix`, not raw `[README.md](…)`) rather + // than leaking serialized Markdown. The numeric `#id` fallback also covers a + // whitespace-only title (folding can't turn blank into non-blank). + const label = + formatConversationTitle(conversation.title).trim() || `#${conversation.id}` const uri = conversation.external_id ? `codeg://session/${conversation.agent_type}_${conversation.external_id}` : `codeg://session/${conversation.id}` diff --git a/src/components/chat/composer/suggestion/suggestion-popup.tsx b/src/components/chat/composer/suggestion/suggestion-popup.tsx index e4037dc4f..9446e07b1 100644 --- a/src/components/chat/composer/suggestion/suggestion-popup.tsx +++ b/src/components/chat/composer/suggestion/suggestion-popup.tsx @@ -412,7 +412,7 @@ export const SuggestionPopup = forwardRef< }} onMouseEnter={() => setSelectedIndex(index)} > - + {item.reference.label || item.reference.id} diff --git a/src/components/chat/composer/types.ts b/src/components/chat/composer/types.ts index c8a507887..0b3ae5ef7 100644 --- a/src/components/chat/composer/types.ts +++ b/src/components/chat/composer/types.ts @@ -20,13 +20,17 @@ export const REFERENCE_KINDS: readonly ReferenceKind[] = [ export interface ReferenceMeta { /** file: whether the entry is a directory. */ fileKind?: "file" | "dir" - /** agent/session: agent type, drives the icon. */ + /** + * agent: drives the badge icon. session: the owning agent — used only for the + * `@`-panel option-row icon; the inline session badge shows a neutral + * conversation glyph regardless. + */ agentType?: AgentType /** agent: whether the agent is currently available. */ available?: boolean - /** session: conversation status (drives the status dot). */ + /** session: conversation status snapshot (not rendered — the inline badge has no status dot). */ status?: string - /** session: git branch. */ + /** session: git branch snapshot (carried with the reference; not rendered on the badge). */ branch?: string | null /** commit: short hash for display. */ shortHash?: string From f51d8534ed0691f0f1ca7041a88da731ac03bf38 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sun, 14 Jun 2026 13:15:23 +0800 Subject: [PATCH 28/31] feat(mcp): add get_session_info tool to resolve @-mentioned sessions Resolve a session by its numeric conversation id and return metadata, token-usage stats, and an optional bounded view of recent messages (max_messages, default 20). The composer now emits codeg://session/ for session mentions so the agent can read the id straight out of the link. Gated by a new `sessions` feature group with a Settings toggle, defaulting on. --- src-tauri/src/acp/connection.rs | 46 +- src-tauri/src/acp/delegation/companion.rs | 407 ++++++++++- src-tauri/src/acp/delegation/listener.rs | 211 +++++- src-tauri/src/acp/delegation/tool_schema.json | 19 + src-tauri/src/acp/delegation/transport.rs | 49 ++ src-tauri/src/acp/mod.rs | 1 + src-tauri/src/acp/session_info.rs | 209 ++++++ src-tauri/src/app_state.rs | 12 +- src-tauri/src/bin/codeg_mcp.rs | 15 +- src-tauri/src/bin/codeg_server.rs | 13 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/session_info.rs | 649 ++++++++++++++++++ src-tauri/src/lib.rs | 39 +- src-tauri/src/web/event_bridge.rs | 6 + src-tauri/src/web/handlers/mod.rs | 1 + src-tauri/src/web/handlers/session_info.rs | 42 ++ src-tauri/src/web/mod.rs | 6 + src-tauri/src/web/router.rs | 8 + src-tauri/tests/delegation_e2e_uds.rs | 18 + src-tauri/tests/delegation_e2e_windows.rs | 15 + src/components/chat/composer/reference-uri.ts | 14 +- .../chat/composer/suggestion/adapters.test.ts | 9 +- .../chat/composer/suggestion/adapters.ts | 17 +- src/components/settings/general-settings.tsx | 3 + .../settings/session-info-settings.tsx | 116 ++++ src/i18n/messages/ar.json | 11 + src/i18n/messages/de.json | 11 + src/i18n/messages/en.json | 11 + src/i18n/messages/es.json | 11 + src/i18n/messages/fr.json | 11 + src/i18n/messages/ja.json | 11 + src/i18n/messages/ko.json | 11 + src/i18n/messages/pt.json | 11 + src/i18n/messages/zh-CN.json | 11 + src/i18n/messages/zh-TW.json | 11 + src/lib/api.ts | 17 + 36 files changed, 1988 insertions(+), 65 deletions(-) create mode 100644 src-tauri/src/acp/session_info.rs create mode 100644 src-tauri/src/commands/session_info.rs create mode 100644 src-tauri/src/web/handlers/session_info.rs create mode 100644 src/components/settings/session-info-settings.tsx diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index d74c7f08b..e99fec405 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -976,6 +976,11 @@ pub struct DelegationInjection { /// of the three is on, and the companion's `--features` lists `ask` to expose /// the `ask_user_question` tool. pub ask: crate::acp::question::QuestionRuntimeConfig, + /// Hot-swappable "is get-session-info enabled?" flag. Read at injection time + /// alongside the other three so `codeg-mcp` is injected when ANY of the four + /// is on, and the companion's `--features` lists `sessions` to expose the + /// `get_session_info` tool. No teardown handle (the lookup is stateless). + pub sessions: crate::acp::session_info::SessionInfoRuntimeConfig, /// Question registry handle for the teardown cascade. The `run_connection` /// cleanup guard calls `cancel_questions_by_parent` through this so a pending /// `ask_user_question` is reclaimed synchronously on disconnect, mirroring @@ -1064,7 +1069,7 @@ fn is_executable_file(path: &Path) -> bool { /// delegate tool silently. Skipping leaves the agent fully functional minus /// `delegate_to_agent`, which is the right degradation when codeg-mcp didn't /// make it into the install. -/// The `--features` value for a companion launch given the three feature flags, +/// The `--features` value for a companion launch given the four feature flags, /// or `None` when none is enabled (the companion isn't injected at all). /// Pulled out as a pure function so the inject/skip decision is unit-testable /// without a real binary on disk or a live broker. @@ -1072,8 +1077,9 @@ fn companion_features_arg( delegation_enabled: bool, feedback_enabled: bool, ask_enabled: bool, + sessions_enabled: bool, ) -> Option { - if !delegation_enabled && !feedback_enabled && !ask_enabled { + if !delegation_enabled && !feedback_enabled && !ask_enabled && !sessions_enabled { return None; } let mut features: Vec<&str> = Vec::new(); @@ -1086,6 +1092,9 @@ fn companion_features_arg( if ask_enabled { features.push("ask"); } + if sessions_enabled { + features.push("sessions"); + } Some(features.join(",")) } @@ -1110,15 +1119,20 @@ async fn inject_codeg_mcp( let delegation_enabled = injection.broker.config_snapshot().await.enabled; let feedback_enabled = injection.feedback.is_enabled().await; let ask_enabled = injection.ask.is_enabled().await; + let sessions_enabled = injection.sessions.is_enabled().await; // `None` (no feature enabled) short-circuits the whole injection. - let features_arg = - companion_features_arg(delegation_enabled, feedback_enabled, ask_enabled)?; + let features_arg = companion_features_arg( + delegation_enabled, + feedback_enabled, + ask_enabled, + sessions_enabled, + )?; let Some(binary_path) = locate_codeg_mcp_binary() else { eprintln!( "[delegation][WARN] codeg-mcp companion binary not found (checked CODEG_MCP_BIN, \ exe sibling, and PATH); skipping delegate_to_agent / check_user_feedback / \ - ask_user_question tool injection for connection {parent_connection_id}. Reinstall \ - codeg or set CODEG_MCP_BIN to fix." + ask_user_question / get_session_info tool injection for connection \ + {parent_connection_id}. Reinstall codeg or set CODEG_MCP_BIN to fix." ); return None; }; @@ -1147,7 +1161,7 @@ async fn inject_codeg_mcp( // (any platform). "--parent-pid".to_string(), std::process::id().to_string(), - // Tool groups to expose this launch (delegation and/or feedback). + // Tool groups to expose this launch (delegation / feedback / ask / sessions). "--features".to_string(), features_arg, ]); @@ -4581,6 +4595,7 @@ mod tests { socket_path: std::path::PathBuf::from("/tmp/codeg-mcp.sock"), feedback: crate::acp::feedback::FeedbackRuntimeConfig::new(), ask: crate::acp::question::QuestionRuntimeConfig::new(), + sessions: crate::acp::session_info::SessionInfoRuntimeConfig::new(), questions: Arc::new(NoQuestions) as Arc, }; @@ -4617,27 +4632,32 @@ mod tests { #[test] fn companion_features_arg_inject_skip_decision() { // All off → no companion at all. - assert_eq!(companion_features_arg(false, false, false), None); + assert_eq!(companion_features_arg(false, false, false, false), None); // Delegation only. assert_eq!( - companion_features_arg(true, false, false), + companion_features_arg(true, false, false, false), Some("delegation".to_string()) ); // Feedback only — the decoupling: companion injected for feedback even // when delegation is off. assert_eq!( - companion_features_arg(false, true, false), + companion_features_arg(false, true, false, false), Some("feedback".to_string()) ); // Ask only — likewise injects the companion on its own. assert_eq!( - companion_features_arg(false, false, true), + companion_features_arg(false, false, true, false), Some("ask".to_string()) ); + // Sessions only — likewise injects the companion on its own. + assert_eq!( + companion_features_arg(false, false, false, true), + Some("sessions".to_string()) + ); // All on → comma-joined, in declaration order. assert_eq!( - companion_features_arg(true, true, true), - Some("delegation,feedback,ask".to_string()) + companion_features_arg(true, true, true, true), + Some("delegation,feedback,ask,sessions".to_string()) ); } } diff --git a/src-tauri/src/acp/delegation/companion.rs b/src-tauri/src/acp/delegation/companion.rs index df11e09b9..a91fd85ea 100644 --- a/src-tauri/src/acp/delegation/companion.rs +++ b/src-tauri/src/acp/delegation/companion.rs @@ -5,14 +5,16 @@ //! The companion speaks newline-delimited JSON-RPC 2.0 on stdio: //! one request → one response per line, with concurrent dispatch so //! `notifications/cancelled` can race an in-flight `tools/call`. It exposes up -//! to four tools — `delegate_to_agent` (async; returns a `task_id` ack), +//! to six tools — `delegate_to_agent` (async; returns a `task_id` ack), //! `get_delegation_status` (poll/long-poll for the result), `cancel_delegation`, -//! and `check_user_feedback` (pull the user's mid-turn steering notes) — whose -//! schemas are embedded at compile time from [`TOOL_SCHEMA_JSON`] and gated by -//! the `--features` groups (delegation / feedback). Only `delegate_to_agent` -//! registers a broker-side cancel handle; canceling a status / cancel / feedback -//! round-trip merely suppresses its response — and for `check_user_feedback` -//! also skips the delivery commit, so a cancelled note stays pending. +//! `check_user_feedback` (pull the user's mid-turn steering notes), +//! `ask_user_question` (block on a multiple-choice card), and `get_session_info` +//! (resolve a referenced session by id) — whose schemas are embedded at compile +//! time from [`TOOL_SCHEMA_JSON`] and gated by the `--features` groups (delegation +//! / feedback / ask / sessions). Only `delegate_to_agent` registers a broker-side +//! cancel handle; canceling a status / cancel / feedback / session round-trip +//! merely suppresses its response — and for `check_user_feedback` also skips the +//! delivery commit, so a cancelled note stays pending. //! //! Notifications (id = None) produce no response, matching MCP's expectation //! that `notifications/initialized` etc. are fire-and-forget. @@ -41,11 +43,13 @@ use tokio::sync::{oneshot, Mutex}; use crate::acp::delegation::transport::{ client_ask_round_trip, client_cancel, client_cancel_task_round_trip, client_commit_feedback, - client_feedback_round_trip, client_round_trip, client_status_round_trip, BrokerAskRequest, - BrokerCancelRequest, BrokerCancelTaskRequest, BrokerCommitFeedbackRequest, BrokerFeedbackRequest, - BrokerRequest, BrokerResponse, BrokerStatusRequest, + client_feedback_round_trip, client_round_trip, client_session_round_trip, + client_status_round_trip, BrokerAskRequest, BrokerCancelRequest, BrokerCancelTaskRequest, + BrokerCommitFeedbackRequest, BrokerFeedbackRequest, BrokerRequest, BrokerResponse, + BrokerSessionRequest, BrokerStatusRequest, }; use crate::acp::question::parse_questions; +use crate::acp::session_info::MAX_SESSION_MESSAGES; /// Upper bound on one broker-side cancel round-trip. Bounds both /// `handle_cancel_notification` (so stdin dispatch can't stall behind a @@ -133,32 +137,36 @@ pub struct CompanionFeatures { pub delegation: bool, pub feedback: bool, pub ask: bool, + pub sessions: bool, } impl CompanionFeatures { - /// Parse the comma-joined `--features` value (e.g. `delegation,feedback,ask`). - /// Unknown tokens are ignored. An absent value (`None`) defaults to - /// delegation-only — backward compatible with a parent that predates - /// feature gating (companion + listener ship together, so post-upgrade the - /// parent always passes an explicit `--features`). + /// Parse the comma-joined `--features` value (e.g. + /// `delegation,feedback,ask,sessions`). Unknown tokens are ignored. An absent + /// value (`None`) defaults to delegation-only — backward compatible with a + /// parent that predates feature gating (companion + listener ship together, so + /// post-upgrade the parent always passes an explicit `--features`). pub fn parse(raw: Option<&str>) -> Self { let Some(s) = raw else { return Self { delegation: true, feedback: false, ask: false, + sessions: false, }; }; let mut f = Self { delegation: false, feedback: false, ask: false, + sessions: false, }; for tok in s.split(',').map(str::trim).filter(|t| !t.is_empty()) { match tok { "delegation" => f.delegation = true, "feedback" => f.feedback = true, "ask" => f.ask = true, + "sessions" => f.sessions = true, _ => {} } } @@ -170,6 +178,7 @@ impl CompanionFeatures { match name { "check_user_feedback" => self.feedback, "ask_user_question" => self.ask, + "get_session_info" => self.sessions, "delegate_to_agent" | "get_delegation_status" | "cancel_delegation" => self.delegation, _ => false, } @@ -500,6 +509,36 @@ async fn build_tools_call_spawn( let round_trip = Box::pin(async move { client_ask_round_trip(&socket, &req).await }); register_and_spawn(inflight, id, None, round_trip, render_ask_result).await } + "get_session_info" => { + // `session_id` is the codeg conversation id the agent read out of a + // `codeg://session/` reference. Accept a JSON number or a numeric + // string (some hosts stringify integer args); reject anything else + // synchronously so the LLM can fix it. + let session_id = match parse_session_id(&arguments) { + Some(id) => id, + None => { + return LineAction::Respond(err( + id, + -32602, + "get_session_info requires an integer `session_id` \ + (the number in the codeg://session/ reference)", + )); + } + }; + // Default to a modest recent-message window; `0` means metadata-only. + // Robust against stringified / oversized values (see helper). + let max_messages = parse_max_messages(&arguments); + let req = BrokerSessionRequest { + token: ctx.token.clone(), + session_id, + max_messages: Some(max_messages), + }; + // No external_handle: a read-only lookup has nothing to cancel + // broker-side — canceling only suppresses the response. + let round_trip = + Box::pin(async move { client_session_round_trip(&socket, &req).await }); + register_and_spawn(inflight, id, None, round_trip, render_session_result).await + } other => LineAction::Respond(err(id, -32602, format!("unknown tool: {other}"))), } } @@ -929,6 +968,160 @@ pub fn render_ask_result(outcome: &Value) -> Value { }) } +/// Extract the `session_id` integer from the `get_session_info` arguments, +/// tolerating a JSON number (int or whole float) or a numeric string — some MCP +/// hosts stringify integer args. `None` for missing / non-integer / out-of-range, +/// which the dispatcher maps to a synchronous `-32602` the LLM can fix. +fn parse_session_id(arguments: &Value) -> Option { + let v = arguments.get("session_id")?; + if let Some(n) = v.as_i64() { + return i32::try_from(n).ok(); + } + if let Some(f) = v.as_f64() { + if f.fract() == 0.0 && f >= f64::from(i32::MIN) && f <= f64::from(i32::MAX) { + return Some(f as i32); + } + } + if let Some(s) = v.as_str() { + return s.trim().parse::().ok(); + } + None +} + +/// Parse the optional `max_messages` tuning arg robustly: a JSON number (integer +/// or whole non-negative float) or a numeric string — consistent with how +/// `session_id` tolerates stringified ints. Clamps in `u64` space BEFORE narrowing +/// to `u32`, so a huge value (e.g. `4294967296`) saturates to the cap instead of +/// wrapping to a small number. An absent OR unparseable value falls back to the +/// default window — it is an optional knob, not a hard error — while an explicit +/// `0` (or `"0"`) is preserved to mean metadata-only. +fn parse_max_messages(arguments: &Value) -> u32 { + const DEFAULT_MAX_MESSAGES: u32 = 20; + let Some(v) = arguments.get("max_messages") else { + return DEFAULT_MAX_MESSAGES; + }; + let raw: Option = if let Some(n) = v.as_u64() { + Some(n) + } else if let Some(f) = v.as_f64() { + // Reject negatives / fractions; `f as u64` saturates a huge float. + (f.fract() == 0.0 && f >= 0.0).then_some(f as u64) + } else if let Some(s) = v.as_str() { + s.trim().parse::().ok() + } else { + None + }; + match raw { + Some(n) => n.min(u64::from(MAX_SESSION_MESSAGES)) as u32, + None => DEFAULT_MAX_MESSAGES, + } +} + +/// Map the `get_session_info` round-trip outcome (a serialized +/// [`crate::acp::session_info::SessionInfo`]) into an MCP `tools/call` result. A +/// not-found result is surfaced as readable text with `isError: false` (the LLM +/// reads it and proceeds), never as a tool error. The full structured envelope +/// rides along in `structuredContent` for hosts that keep it. +pub fn render_session_result(outcome: &Value) -> Value { + let found = outcome + .get("found") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let text = if found { + render_session_summary_text(outcome) + } else { + outcome + .get("note") + .and_then(|v| v.as_str()) + .unwrap_or("No matching session was found.") + .to_string() + }; + json!({ + "content": [{ "type": "text", "text": text }], + "isError": false, + "structuredContent": outcome.clone(), + }) +} + +/// Build the human-readable summary block for a found session: a metadata header +/// plus, when present, a "Recent messages" section. +fn render_session_summary_text(o: &Value) -> String { + let s = |k: &str| o.get(k).and_then(|v| v.as_str()); + let id = o.get("session_id").and_then(|v| v.as_i64()).unwrap_or(0); + let agent = s("agent_type").unwrap_or("unknown"); + let mut out = format!("Session #{id} ({agent})\n"); + if let Some(t) = s("title") { + out.push_str(&format!("Title: {t}\n")); + } + let mut meta: Vec = Vec::new(); + if let Some(v) = s("status") { + meta.push(format!("status: {v}")); + } + if let Some(v) = s("git_branch") { + meta.push(format!("branch: {v}")); + } + if let Some(v) = s("model") { + meta.push(format!("model: {v}")); + } + if !meta.is_empty() { + out.push_str(&meta.join(" | ")); + out.push('\n'); + } + if let Some(v) = s("workspace_path") { + out.push_str(&format!("Workspace: {v}\n")); + } + if let Some(n) = o.get("message_count").and_then(|v| v.as_u64()) { + out.push_str(&format!("Messages: {n}\n")); + } + if o.get("is_delegation_child") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + if let Some(p) = o.get("parent_id").and_then(|v| v.as_i64()) { + out.push_str(&format!("Delegation child of session #{p}\n")); + } + } + if let Some(tokens) = o + .get("stats") + .and_then(|st| st.get("total_tokens")) + .and_then(|v| v.as_u64()) + { + out.push_str(&format!("Total tokens: {tokens}\n")); + } + if let Some(note) = s("note") { + out.push_str(&format!("Note: {note}\n")); + } + if let Some(messages) = o.get("messages") { + let total = messages.get("total").and_then(|v| v.as_u64()).unwrap_or(0); + let included = messages + .get("included") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let truncated = messages + .get("truncated") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let suffix = if truncated { ", older turns omitted" } else { "" }; + out.push_str(&format!("\nRecent messages ({included}/{total}{suffix}):\n")); + if let Some(items) = messages.get("items").and_then(|v| v.as_array()) { + for item in items { + let role = item.get("role").and_then(|v| v.as_str()).unwrap_or("?"); + let body = item.get("text").and_then(|v| v.as_str()).unwrap_or(""); + let tools: Vec<&str> = item + .get("tools") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|x| x.as_str()).collect()) + .unwrap_or_default(); + out.push_str(&format!("- [{role}] {body}")); + if !tools.is_empty() { + out.push_str(&format!(" (tools: {})", tools.join(", "))); + } + out.push('\n'); + } + } + } + out +} + pub fn render_task_report(report: &Value) -> Value { let status = report.get("status").and_then(|v| v.as_str()).unwrap_or(""); let is_error = status == "failed"; @@ -970,6 +1163,7 @@ mod tests { delegation: true, feedback: false, ask: false, + sessions: false, }) } @@ -1436,16 +1630,25 @@ mod tests { delegation: false, feedback: true, ask: false, + sessions: false, }; const BOTH: CompanionFeatures = CompanionFeatures { delegation: true, feedback: true, ask: false, + sessions: false, }; const ASK_ONLY: CompanionFeatures = CompanionFeatures { delegation: false, feedback: false, ask: true, + sessions: false, + }; + const SESSIONS_ONLY: CompanionFeatures = CompanionFeatures { + delegation: false, + feedback: false, + ask: false, + sessions: true, }; fn list_tool_names(action: LineAction) -> Vec { @@ -1464,16 +1667,19 @@ mod tests { let def = CompanionFeatures::parse(None); assert!(def.delegation && !def.feedback); assert!(!def.ask); + assert!(!def.sessions); // Explicit list, whitespace + unknown tokens tolerated. - let all = CompanionFeatures::parse(Some(" delegation , feedback , ask ,bogus")); - assert!(all.delegation && all.feedback && all.ask); + let all = CompanionFeatures::parse(Some(" delegation , feedback , ask , sessions ,bogus")); + assert!(all.delegation && all.feedback && all.ask && all.sessions); let fb = CompanionFeatures::parse(Some("feedback")); assert!(!fb.delegation && fb.feedback && !fb.ask); let ask = CompanionFeatures::parse(Some("ask")); assert!(!ask.delegation && !ask.feedback && ask.ask); + let sessions = CompanionFeatures::parse(Some("sessions")); + assert!(!sessions.delegation && !sessions.feedback && !sessions.ask && sessions.sessions); // Empty string → nothing enabled. let none = CompanionFeatures::parse(Some("")); - assert!(!none.delegation && !none.feedback && !none.ask); + assert!(!none.delegation && !none.feedback && !none.ask && !none.sessions); } #[tokio::test] @@ -1639,6 +1845,171 @@ mod tests { assert!(text.contains("dismissed")); } + // -- get_session_info feature gating + parsing + rendering ------------- + + #[tokio::test] + async fn tools_list_includes_session_only_when_enabled() { + // Default ctx is delegation-only: get_session_info must NOT appear. + let names = list_tool_names( + dispatch_for_test(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#).await, + ); + assert!(!names.contains(&"get_session_info".to_string())); + // sessions feature on → exactly that one tool surfaces. + let names = list_tool_names( + dispatch_with_features( + SESSIONS_ONLY, + r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#, + ) + .await, + ); + assert_eq!(names, vec!["get_session_info".to_string()]); + } + + #[tokio::test] + async fn get_session_info_spawns_when_valid_and_enabled() { + let line = json!({ + "jsonrpc": "2.0", "id": 30, "method": "tools/call", + "params": { "name": "get_session_info", "arguments": { "session_id": 214 } } + }) + .to_string(); + assert!(matches!( + dispatch_with_features(SESSIONS_ONLY, &line).await, + LineAction::Spawn(_) + )); + } + + #[tokio::test] + async fn get_session_info_accepts_numeric_string_id() { + // Some hosts stringify integer args — still resolves to a Spawn. + let line = json!({ + "jsonrpc": "2.0", "id": 31, "method": "tools/call", + "params": { "name": "get_session_info", "arguments": { "session_id": "214" } } + }) + .to_string(); + assert!(matches!( + dispatch_with_features(SESSIONS_ONLY, &line).await, + LineAction::Spawn(_) + )); + } + + #[tokio::test] + async fn get_session_info_missing_or_bad_id_rejected_synchronously() { + for args in [json!({}), json!({ "session_id": "abc" }), json!({ "session_id": true })] { + let line = json!({ + "jsonrpc": "2.0", "id": 32, "method": "tools/call", + "params": { "name": "get_session_info", "arguments": args } + }) + .to_string(); + let resp = unwrap_respond(dispatch_with_features(SESSIONS_ONLY, &line).await); + let e = resp.error.expect("bad session_id must be rejected"); + assert_eq!(e.code, -32602); + assert!(e.message.contains("session_id")); + } + } + + #[tokio::test] + async fn get_session_info_rejected_as_unknown_when_feature_off() { + // Default ctx is delegation-only — calling the tool by name is rejected + // uniformly as an unknown tool (no leak that the feature exists but is off). + let line = json!({ + "jsonrpc": "2.0", "id": 33, "method": "tools/call", + "params": { "name": "get_session_info", "arguments": { "session_id": 1 } } + }) + .to_string(); + let resp = unwrap_respond(dispatch_for_test(&line).await); + let e = resp.error.unwrap(); + assert_eq!(e.code, -32602); + assert!(e.message.contains("unknown tool")); + } + + #[test] + fn parse_session_id_tolerates_number_string_and_whole_float() { + assert_eq!(parse_session_id(&json!({ "session_id": 7 })), Some(7)); + assert_eq!(parse_session_id(&json!({ "session_id": " 7 " })), Some(7)); + assert_eq!(parse_session_id(&json!({ "session_id": 7.0 })), Some(7)); + assert_eq!(parse_session_id(&json!({ "session_id": "abc" })), None); + assert_eq!(parse_session_id(&json!({ "session_id": 7.5 })), None); + assert_eq!(parse_session_id(&json!({})), None); + } + + #[test] + fn parse_max_messages_is_robust() { + // Omitted → default. + assert_eq!(parse_max_messages(&json!({})), 20); + // Explicit 0 (number AND string) is preserved → metadata-only. + assert_eq!(parse_max_messages(&json!({ "max_messages": 0 })), 0); + assert_eq!(parse_max_messages(&json!({ "max_messages": "0" })), 0); + // Plain value within range. + assert_eq!(parse_max_messages(&json!({ "max_messages": 5 })), 5); + assert_eq!(parse_max_messages(&json!({ "max_messages": "5" })), 5); + // Whole float ok; over the cap clamps to MAX_SESSION_MESSAGES. + assert_eq!(parse_max_messages(&json!({ "max_messages": 50.0 })), 50); + assert_eq!(parse_max_messages(&json!({ "max_messages": 999 })), 200); + // A huge value must SATURATE to the cap, not wrap to a small number. + assert_eq!( + parse_max_messages(&json!({ "max_messages": 4_294_967_296_u64 })), + 200 + ); + assert_eq!( + parse_max_messages(&json!({ "max_messages": 1e30 })), + 200 + ); + // Invalid / negative / fractional → default (optional knob, not an error). + assert_eq!(parse_max_messages(&json!({ "max_messages": "abc" })), 20); + assert_eq!(parse_max_messages(&json!({ "max_messages": -5 })), 20); + assert_eq!(parse_max_messages(&json!({ "max_messages": 5.5 })), 20); + assert_eq!(parse_max_messages(&json!({ "max_messages": true })), 20); + } + + #[test] + fn render_session_result_not_found_is_soft_with_note_text() { + let outcome = json!({ + "found": false, "session_id": 9, + "note": "No session matches id 9. It may have been deleted, or never imported into codeg." + }); + let rendered = render_session_result(&outcome); + assert_eq!(rendered["isError"], false); + let text = rendered["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("No session matches id 9")); + assert_eq!(rendered["structuredContent"]["found"], false); + } + + #[test] + fn render_session_result_found_renders_metadata_and_messages() { + let outcome = json!({ + "found": true, + "session_id": 214, + "agent_type": "claude_code", + "title": "Fix auth flow", + "status": "completed", + "git_branch": "main", + "model": "claude-opus-4-8", + "workspace_path": "/home/me/proj", + "message_count": 12, + "is_delegation_child": false, + "stats": { "total_tokens": 4242 }, + "messages": { + "total": 12, "included": 2, "truncated": true, + "items": [ + { "role": "user", "text": "fix the login", "tools": [] }, + { "role": "assistant", "text": "done", "tools": ["Read", "Edit"] } + ] + } + }); + let rendered = render_session_result(&outcome); + assert_eq!(rendered["isError"], false); + let text = rendered["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("Session #214 (claude_code)")); + assert!(text.contains("Fix auth flow")); + assert!(text.contains("status: completed")); + assert!(text.contains("Workspace: /home/me/proj")); + assert!(text.contains("Total tokens: 4242")); + assert!(text.contains("Recent messages (2/12, older turns omitted)")); + assert!(text.contains("- [assistant] done (tools: Read, Edit)")); + // Full structured envelope preserved for hosts that keep it. + assert_eq!(rendered["structuredContent"]["session_id"], 214); + } + #[test] fn render_feedback_empty_is_not_error_and_says_no_feedback() { let rendered = render_feedback_result(&json!({ "count": 0, "feedback": [] })); diff --git a/src-tauri/src/acp/delegation/listener.rs b/src-tauri/src/acp/delegation/listener.rs index 650eff410..10afe0df0 100644 --- a/src-tauri/src/acp/delegation/listener.rs +++ b/src-tauri/src/acp/delegation/listener.rs @@ -19,11 +19,12 @@ use crate::acp::delegation::broker::{DelegationBroker, StatusWait}; use crate::acp::delegation::transport::{ read_frame, write_frame, BrokerAskRequest, BrokerCancelRequest, BrokerCancelTaskRequest, BrokerCommitFeedbackRequest, BrokerFeedbackRequest, BrokerMessage, BrokerRequest, - BrokerResponse, BrokerStatusRequest, + BrokerResponse, BrokerSessionRequest, BrokerStatusRequest, }; use crate::acp::delegation::types::{DelegationRequest, DelegationTaskReport, TaskStatus}; use crate::acp::feedback::{PendingFeedback, SessionFeedbackAccess}; use crate::acp::question::{QuestionOutcome, SessionQuestionAccess}; +use crate::acp::session_info::{SessionInfo, SessionInfoAccess}; use crate::models::AgentType; use serde_json::Value; @@ -90,15 +91,21 @@ pub struct DelegationListener { /// Registers / cancels the blocking `ask_user_question` tool's pending /// questions. Same `tokens` registry and parent-connection scoping. pub questions: Arc, + /// Resolves a referenced session for the `get_session_info` tool. Unlike the + /// other arms this is NOT parent-scoped — it looks any non-deleted session up + /// by its codeg conversation id (still token-gated against an invalid caller). + pub session_info: Arc, } impl DelegationListener { + #[allow(clippy::too_many_arguments)] pub fn new( broker: Arc, tokens: Arc, parent_lookup: Arc, feedback: Arc, questions: Arc, + session_info: Arc, ) -> Arc { Arc::new(Self { broker, @@ -106,6 +113,7 @@ impl DelegationListener { parent_lookup, feedback, questions, + session_info, }) } @@ -296,6 +304,13 @@ impl DelegationListener { write_frame(conn, &resp).await?; return Ok(()); } + BrokerMessage::SessionInfo(req) => { + // Read-only resolution (DB + a bounded transcript parse). No + // peer-close race needed: unlike Status/Ask this never blocks on + // a long-poll or a human — the bounded parse always completes — + // and there is nothing to tear down on cancel. + session_response(self.process_session_info(req).await)? + } BrokerMessage::Cancel(cancel) => { self.process_cancel(cancel).await; // Empty ack — the companion only uses this to detect the @@ -405,6 +420,30 @@ impl DelegationListener { .await; } + /// Validate the token and resolve the `get_session_info` target. An invalid + /// token yields a `found:false` outcome (the LLM can't usefully distinguish it + /// from a deleted session, and we don't leak which). + /// + /// SCOPE (deliberate, user-confirmed): the lookup is by codeg conversation id + /// and is intentionally NOT scoped to the caller's parent connection or to the + /// session ids actually referenced in the prompt — any non-deleted session + /// resolves. This is sound in codeg's single-tenant trust model: there is no + /// per-user isolation anywhere (desktop is one local user; server mode shares + /// one `CODEG_TOKEN` + one data dir across an operator's devices), the user can + /// already open every session in the UI, and the agent already has full + /// filesystem access to every agent's raw session files via its own tools — so + /// reading session metadata by id is strictly less capability than the agent + /// already holds, not an escalation. The token gate above still prevents an + /// unrelated process from reaching the broker at all. + async fn process_session_info(&self, req: BrokerSessionRequest) -> SessionInfo { + if self.tokens.lookup(&req.token).await.is_none() { + return SessionInfo::not_found(req.session_id); + } + self.session_info + .resolve(req.session_id, req.max_messages.unwrap_or(0)) + .await + } + async fn process(&self, req: BrokerRequest) -> DelegationTaskReport { // 1. Token + parent_connection_id consistency check. Treat both as // "canceled" since the LLM can't usefully react to either — @@ -524,6 +563,17 @@ fn ask_response(outcome: &QuestionOutcome) -> std::io::Result { }) } +/// Serialize a resolved [`SessionInfo`] into a [`BrokerResponse`] for the +/// `SessionInfo` arm — the companion renders it into the `get_session_info` +/// tool result. +fn session_response(info: SessionInfo) -> std::io::Result { + Ok(BrokerResponse { + outcome: serde_json::to_value(&info).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::InvalidData, format!("encode: {e}")) + })?, + }) +} + /// The `declined` outcome — used when the token is invalid, the connection is /// gone, or the answer one-shot was dropped without a response. The LLM reads it /// as "the user didn't answer; proceed with your own judgment". @@ -715,6 +765,31 @@ mod tests { } } + /// In-memory session-info stub. Records every `(session_id, max_messages)` it + /// was asked to resolve and returns a seeded outcome — `found` sessions echo + /// their id, unknown ids return `not_found`. Default knows about no sessions. + #[derive(Default)] + struct StubSessionInfo { + known: std::collections::HashSet, + calls: tokio::sync::Mutex>, + } + #[async_trait] + impl SessionInfoAccess for StubSessionInfo { + async fn resolve(&self, session_id: i32, max_messages: u32) -> SessionInfo { + self.calls.lock().await.push((session_id, max_messages)); + if self.known.contains(&session_id) { + SessionInfo { + found: true, + session_id, + title: Some(format!("session {session_id}")), + ..Default::default() + } + } else { + SessionInfo::not_found(session_id) + } + } + } + use tokio::sync::oneshot; async fn make_broker(mock: Arc) -> Arc { @@ -746,6 +821,7 @@ mod tests { Arc::new(StaticParentLookup(parent_conversation)), Arc::new(StubFeedback::default()), Arc::new(StubQuestion::default()), + Arc::new(StubSessionInfo::default()), ) } @@ -765,6 +841,7 @@ mod tests { Arc::new(StaticParentLookup(Some(1))), feedback, Arc::new(StubQuestion::default()), + Arc::new(StubSessionInfo::default()), ) } @@ -785,6 +862,27 @@ mod tests { Arc::new(StaticParentLookup(Some(1))), Arc::new(StubFeedback::default()), questions, + Arc::new(StubSessionInfo::default()), + ) + } + + /// Build a listener whose session-info access is the given stub, so + /// `get_session_info` tests can seed known sessions and assert the round-trip. + fn make_session_listener( + tokens: Arc, + session_info: Arc, + ) -> Arc { + let broker = Arc::new(DelegationBroker::new( + Arc::new(MockSpawner::new()) as Arc, + Arc::new(AlwaysRootLookup) as Arc, + )); + DelegationListener::new( + broker, + tokens, + Arc::new(StaticParentLookup(Some(1))), + Arc::new(StubFeedback::default()), + Arc::new(StubQuestion::default()), + session_info, ) } @@ -1502,6 +1600,117 @@ mod tests { assert!(feedback.committed.lock().await.is_empty()); } + /// A valid `get_session_info` resolves the session by id and returns its + /// metadata; the resolver is called with the requested id + max_messages. + #[tokio::test] + async fn session_info_valid_token_resolves_by_id() { + let session_info = Arc::new(StubSessionInfo { + known: std::collections::HashSet::from([42]), + ..Default::default() + }); + let tokens = Arc::new(TokenRegistry::default()); + tokens + .register( + "tok".into(), + TokenEntry { + parent_connection_id: "parent-conn".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + let listener = make_session_listener(tokens, session_info.clone()); + + let (mut client, mut server) = duplex(8 * 1024); + let server_task = tokio::spawn(async move { + listener.serve_one(&mut server).await.unwrap(); + }); + let msg = BrokerMessage::SessionInfo(BrokerSessionRequest { + token: "tok".into(), + session_id: 42, + max_messages: Some(15), + }); + write_frame(&mut client, &msg).await.unwrap(); + let resp: BrokerResponse = read_frame(&mut client).await.unwrap(); + server_task.await.unwrap(); + + assert_eq!(resp.outcome["found"], true); + assert_eq!(resp.outcome["session_id"], 42); + assert_eq!(resp.outcome["title"], "session 42"); + // The resolver saw the id + the requested message budget. + assert_eq!(session_info.calls.lock().await.as_slice(), &[(42, 15)]); + } + + /// Accepted-policy coverage (deliberate single-tenant scope): a single valid + /// token resolves ANY non-deleted session id — not only ids "referenced" in the + /// prompt. Three unrelated ids all resolve through one token. + #[tokio::test] + async fn session_info_resolves_any_session_id_not_just_referenced() { + let session_info = Arc::new(StubSessionInfo { + known: std::collections::HashSet::from([7, 42, 1000]), + ..Default::default() + }); + let tokens = Arc::new(TokenRegistry::default()); + tokens + .register( + "tok".into(), + TokenEntry { + parent_connection_id: "parent-conn".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + let listener = make_session_listener(tokens, session_info.clone()); + + for id in [7, 42, 1000] { + let (mut client, mut server) = duplex(8 * 1024); + let l = listener.clone(); + let server_task = tokio::spawn(async move { + l.serve_one(&mut server).await.unwrap(); + }); + let msg = BrokerMessage::SessionInfo(BrokerSessionRequest { + token: "tok".into(), + session_id: id, + max_messages: Some(0), + }); + write_frame(&mut client, &msg).await.unwrap(); + let resp: BrokerResponse = read_frame(&mut client).await.unwrap(); + server_task.await.unwrap(); + assert_eq!(resp.outcome["found"], true, "id {id} should resolve"); + assert_eq!(resp.outcome["session_id"], id); + } + } + + /// An invalid token yields a `found:false` outcome WITHOUT touching the + /// resolver (no leak of whether the session exists). + #[tokio::test] + async fn session_info_invalid_token_is_not_found_without_resolving() { + let session_info = Arc::new(StubSessionInfo { + known: std::collections::HashSet::from([42]), + ..Default::default() + }); + // No token registered. + let tokens = Arc::new(TokenRegistry::default()); + let listener = make_session_listener(tokens, session_info.clone()); + + let (mut client, mut server) = duplex(8 * 1024); + let server_task = tokio::spawn(async move { + listener.serve_one(&mut server).await.unwrap(); + }); + let msg = BrokerMessage::SessionInfo(BrokerSessionRequest { + token: "bogus".into(), + session_id: 42, + max_messages: None, + }); + write_frame(&mut client, &msg).await.unwrap(); + let resp: BrokerResponse = read_frame(&mut client).await.unwrap(); + server_task.await.unwrap(); + + assert_eq!(resp.outcome["found"], false); + assert_eq!(resp.outcome["session_id"], 42); + // The resolver was never consulted for an unauthenticated caller. + assert!(session_info.calls.lock().await.is_empty()); + } + /// `CommitFeedback` marks the named ids delivered, scoped (via the token) to /// the parent connection — the companion sends this only after it delivers. #[tokio::test] diff --git a/src-tauri/src/acp/delegation/tool_schema.json b/src-tauri/src/acp/delegation/tool_schema.json index 85b2d66e8..6322206e3 100644 --- a/src-tauri/src/acp/delegation/tool_schema.json +++ b/src-tauri/src/acp/delegation/tool_schema.json @@ -127,5 +127,24 @@ } } } + }, + { + "name": "get_session_info", + "description": "Look up a session/conversation the user referenced in their message and return what it is: its title, which agent ran it, its status, workspace folder, git branch, model, message count, and token-usage stats — and, on request, a compact view of its most recent messages. Call this whenever the user's message contains a `codeg://session/` reference (a mentioned session) and you need to know what that session is about or what happened in it (e.g. to continue its work or summarize it). Pass the NUMBER from the reference as `session_id` (it is codeg's internal conversation id, NOT the agent's own session id). The result is read-only and never modifies the session. A session that no longer exists returns a `found: false` result you can act on — it is not an error.", + "inputSchema": { + "type": "object", + "required": ["session_id"], + "properties": { + "session_id": { + "type": "integer", + "description": "The number in the `codeg://session/` reference from the user's message (codeg's internal conversation id). For example, given `[Fix auth](codeg://session/214)`, pass 214." + }, + "max_messages": { + "type": "integer", + "minimum": 0, + "description": "How many of the session's most recent turns to include as compact text. Defaults to 20; pass 0 for metadata only (faster, no transcript read). Capped at 200." + } + } + } } ] diff --git a/src-tauri/src/acp/delegation/transport.rs b/src-tauri/src/acp/delegation/transport.rs index 7cdddfd62..41b81728a 100644 --- a/src-tauri/src/acp/delegation/transport.rs +++ b/src-tauri/src/acp/delegation/transport.rs @@ -165,6 +165,24 @@ pub struct BrokerAskRequest { pub questions: Vec, } +/// Resolve a session the user referenced (`codeg://session/`) into its +/// metadata + stats, optionally with its recent messages. Backs the +/// `get_session_info` MCP tool. Authenticated by the same per-launch `token`; the +/// lookup is by codeg's internal conversation id (the number in the reference), +/// so — unlike the delegation arms — it is NOT scoped to the parent connection +/// (any non-deleted session the user references can be read). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrokerSessionRequest { + pub token: String, + /// codeg's internal conversation PK (the number in `codeg://session/`). + pub session_id: i32, + /// How many of the most recent turns to include as compacted text. `None` / + /// `0` → metadata only (no transcript parse); a positive value is clamped to + /// [`crate::acp::session_info::MAX_SESSION_MESSAGES`] by the resolver. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_messages: Option, +} + /// Tagged top-level message dispatched by the listener. Adding new variants /// is the wire-stable way to grow the broker protocol without touching the /// frame layer. @@ -178,6 +196,7 @@ pub enum BrokerMessage { Feedback(BrokerFeedbackRequest), CommitFeedback(BrokerCommitFeedbackRequest), Ask(BrokerAskRequest), + SessionInfo(BrokerSessionRequest), } /// The wrapped outcome the main process returns over the same socket. @@ -317,6 +336,16 @@ pub async fn client_ask_round_trip( message_round_trip(socket_path, &BrokerMessage::Ask(req.clone())).await } +/// Dispatch a `get_session_info` request and read back the serialized +/// [`crate::acp::session_info::SessionInfo`] envelope (metadata + stats, and the +/// recent messages when `max_messages > 0`). +pub async fn client_session_round_trip( + socket_path: &str, + req: &BrokerSessionRequest, +) -> io::Result { + message_round_trip(socket_path, &BrokerMessage::SessionInfo(req.clone())).await +} + /// Total budget for `open()` retries on Windows named pipes. Has to be /// short enough that it nests comfortably inside the companion's /// `BROKER_CANCEL_BUDGET` (500 ms) — leaving ≥ 300 ms for the actual @@ -421,6 +450,26 @@ mod tests { } } + #[tokio::test] + async fn session_message_round_trip_in_memory() { + let (mut a, mut b) = duplex(8 * 1024); + let msg = BrokerMessage::SessionInfo(BrokerSessionRequest { + token: "tok".into(), + session_id: 42, + max_messages: Some(20), + }); + write_frame(&mut a, &msg).await.unwrap(); + let got: BrokerMessage = read_frame(&mut b).await.unwrap(); + match got { + BrokerMessage::SessionInfo(req) => { + assert_eq!(req.token, "tok"); + assert_eq!(req.session_id, 42); + assert_eq!(req.max_messages, Some(20)); + } + other => panic!("expected SessionInfo variant, got {other:?}"), + } + } + #[tokio::test] async fn cancel_message_round_trip_in_memory() { let (mut a, mut b) = duplex(8 * 1024); diff --git a/src-tauri/src/acp/mod.rs b/src-tauri/src/acp/mod.rs index fb032c81d..bc5d0925b 100644 --- a/src-tauri/src/acp/mod.rs +++ b/src-tauri/src/acp/mod.rs @@ -14,6 +14,7 @@ pub mod opencode_plugins; pub mod preflight; pub mod question; pub mod registry; +pub mod session_info; pub mod session_state; pub mod terminal_runtime; pub mod types; diff --git a/src-tauri/src/acp/session_info.rs b/src-tauri/src/acp/session_info.rs new file mode 100644 index 000000000..346f64d75 --- /dev/null +++ b/src-tauri/src/acp/session_info.rs @@ -0,0 +1,209 @@ +//! Session-info lookup domain types — backing the `get_session_info` MCP tool. +//! +//! When the user references a session in the composer it serializes into the +//! agent's prompt as `[title](codeg://session/)`. The agent +//! reads the numeric id out of that link and calls `get_session_info`, which +//! resolves it to the session's metadata + token-usage stats and, on demand, a +//! bounded compacted view of its recent messages. +//! +//! This module holds the layer-shared pieces (mirroring [`crate::acp::question`] +//! / [`crate::acp::feedback`]): +//! * [`SessionInfo`] / [`SessionMessages`] / [`SessionMessageItem`] — the +//! self-describing outcome delivered over the broker socket to the tool (so +//! the companion renders it without re-querying). +//! * [`SessionInfoAccess`] — the listener-facing trait the production +//! `DbSessionInfoLookup` (in `crate::commands::session_info`) implements; kept +//! here so the listener can be unit-tested with an in-memory stub. +//! * [`SessionInfoRuntimeConfig`] — the hot-swappable "is the feature on?" flag, +//! read at MCP injection time (mirrors [`crate::acp::question`]). + +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; + +use crate::models::conversation::SessionStats; + +/// Hard cap on how many recent turns a single `get_session_info` call may +/// request. The companion clamps the agent-supplied `max_messages` to this, and +/// the resolver treats it as the authoritative ceiling so a pathological value +/// can't blow up the UDS frame or the LLM context. +pub const MAX_SESSION_MESSAGES: u32 = 200; + +/// One compacted turn from the referenced session's transcript. NEVER carries a +/// raw `MessageTurn` / image bytes — only role + truncated text + tool names — so +/// the UDS frame and the agent's context window stay bounded. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionMessageItem { + /// `"user"` | `"assistant"` | `"system"`. + pub role: String, + /// Concatenated text/thinking content of the turn, truncated to a per-turn + /// character budget. + pub text: String, + /// Tool names invoked in this turn (deduped, first-seen order); empty when + /// the turn ran no tools. + pub tools: Vec, +} + +/// The recent-messages slice of a [`SessionInfo`], present only when the caller +/// asked for messages (`max_messages > 0`) and the transcript parsed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionMessages { + /// Total number of turns in the session. + pub total: u32, + /// How many turns are included in `items` (the most recent ones). + pub included: u32, + /// True when older turns were dropped to fit `included` / the char budget. + pub truncated: bool, + pub items: Vec, +} + +/// The resolved session description handed back to the `get_session_info` tool. +/// `found == false` is a soft "no such session" result the LLM reads (not an +/// error), produced via [`SessionInfo::not_found`]. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SessionInfo { + pub found: bool, + /// The id that was queried (codeg's internal conversation PK). Echoed so the + /// companion can render a precise not-found message. + pub session_id: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub external_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub git_branch: Option, + /// Absolute path of the session's workspace folder. + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option>, + /// The parent conversation id when this session is a delegation child. + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + /// `true` when this session was spawned by a `delegate_to_agent` call. + pub is_delegation_child: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub stats: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub messages: Option, + /// Human-readable note for a not-found result, or a partial one (e.g. the + /// transcript parse timed out so `messages` is absent). `None` on a clean + /// full result. + #[serde(skip_serializing_if = "Option::is_none")] + pub note: Option, +} + +impl SessionInfo { + /// A "no session matches this id" outcome — the id was well-formed but no + /// non-deleted conversation row exists for it. + pub fn not_found(session_id: i32) -> Self { + Self { + found: false, + session_id, + note: Some(format!( + "No session matches id {session_id}. It may have been deleted, \ + or never imported into codeg." + )), + ..Default::default() + } + } +} + +/// Listener-facing access to resolve a session by its codeg conversation id. The +/// production impl (`crate::commands::session_info::DbSessionInfoLookup`) reads +/// the DB + parses the on-disk transcript; tests use an in-memory stub. Mirrors +/// [`crate::acp::feedback::SessionFeedbackAccess`] and +/// [`crate::acp::question::SessionQuestionAccess`]. +#[async_trait] +pub trait SessionInfoAccess: Send + Sync { + /// Resolve `session_id` (codeg's internal conversation PK) into a + /// [`SessionInfo`]. When `max_messages > 0`, include up to that many of the + /// most recent compacted turns (capped at [`MAX_SESSION_MESSAGES`]). A + /// missing / deleted id yields [`SessionInfo::not_found`]. + async fn resolve(&self, session_id: i32, max_messages: u32) -> SessionInfo; +} + +/// The hot-swappable feature config read at MCP injection time. Kept tiny and +/// separate from the other feature configs so the `sessions` tool group toggles +/// independently — `codeg-mcp` is injected when ANY feature is enabled, and each +/// tool is listed only when its own feature is on. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SessionInfoConfig { + pub enabled: bool, +} + +/// Shared, hot-swappable handle to [`SessionInfoConfig`]. Cloned into +/// `DelegationInjection` (read at injection) and `AppState` (updated on save). +/// Byte-for-byte mirror of [`crate::acp::question::QuestionRuntimeConfig`]. +#[derive(Clone, Default)] +pub struct SessionInfoRuntimeConfig { + inner: Arc>, +} + +impl SessionInfoRuntimeConfig { + pub fn new() -> Self { + Self::default() + } + + pub async fn snapshot(&self) -> SessionInfoConfig { + self.inner.read().await.clone() + } + + pub async fn set(&self, cfg: SessionInfoConfig) { + *self.inner.write().await = cfg; + } + + /// Convenience read used at MCP injection time. + pub async fn is_enabled(&self) -> bool { + self.inner.read().await.enabled + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn not_found_is_soft_and_carries_id() { + let info = SessionInfo::not_found(42); + assert!(!info.found); + assert_eq!(info.session_id, 42); + assert!(info.messages.is_none()); + assert!(info.note.as_deref().unwrap().contains("42")); + } + + #[test] + fn not_found_serializes_without_absent_option_fields() { + // The skip_serializing_if Options keep the not-found envelope compact. + let v = serde_json::to_value(SessionInfo::not_found(7)).unwrap(); + assert_eq!(v["found"], false); + assert_eq!(v["session_id"], 7); + assert!(v.get("title").is_none()); + assert!(v.get("messages").is_none()); + assert!(v.get("note").is_some()); + } + + #[tokio::test] + async fn runtime_config_round_trips() { + let cfg = SessionInfoRuntimeConfig::new(); + assert!(!cfg.is_enabled().await); + cfg.set(SessionInfoConfig { enabled: true }).await; + assert!(cfg.is_enabled().await); + assert_eq!(cfg.snapshot().await, SessionInfoConfig { enabled: true }); + } +} diff --git a/src-tauri/src/app_state.rs b/src-tauri/src/app_state.rs index 67eabca26..7d43d873e 100644 --- a/src-tauri/src/app_state.rs +++ b/src-tauri/src/app_state.rs @@ -54,6 +54,11 @@ pub struct AppState { /// the question settings command on save. Populated at startup by /// `apply_persisted_question_config`. pub question_config: crate::acp::question::QuestionRuntimeConfig, + /// Hot-swappable get-session-info (`get_session_info`) enable flag. Shared + /// with the `DelegationInjection` so MCP injection reads it, and updated by + /// the session-info settings command on save. Populated at startup by + /// `apply_persisted_session_info_config`. + pub session_info_config: crate::acp::session_info::SessionInfoRuntimeConfig, /// Serializes mutually-exclusive system operations — in-place /// self-update, restart, rollback — so a second click can't race a /// download/swap already in flight. Handlers `try_lock` and reject when @@ -103,6 +108,7 @@ pub fn build_delegation_stack( PathBuf, crate::acp::feedback::FeedbackRuntimeConfig, crate::acp::question::QuestionRuntimeConfig, + crate::acp::session_info::SessionInfoRuntimeConfig, ) { use crate::acp::connection::DelegationInjection; use crate::acp::delegation::broker::{ @@ -148,6 +154,7 @@ pub fn build_delegation_stack( let socket_path = default_socket_path(&std::env::temp_dir()); let feedback = crate::acp::feedback::FeedbackRuntimeConfig::new(); let ask = crate::acp::question::QuestionRuntimeConfig::new(); + let sessions = crate::acp::session_info::SessionInfoRuntimeConfig::new(); // Install the injection on the manager so spawn_agent picks it up // without an extra parameter at every call site. @@ -157,6 +164,7 @@ pub fn build_delegation_stack( socket_path: socket_path.clone(), feedback: feedback.clone(), ask: ask.clone(), + sessions: sessions.clone(), // Same backing manager as the listener's question lookup; used only by // the run_connection teardown guard to reclaim a parked ask. questions: Arc::new(crate::acp::manager::ConnectionManagerQuestionLookup { @@ -164,7 +172,7 @@ pub fn build_delegation_stack( }) as Arc, }); - (broker, tokens, socket_path, feedback, ask) + (broker, tokens, socket_path, feedback, ask, sessions) } impl AppState { @@ -191,6 +199,7 @@ impl AppState { delegation_socket_path, feedback_config, question_config, + session_info_config, ) = build_delegation_stack(&connection_manager, db.conn.clone(), data_dir.clone()); Self { @@ -214,6 +223,7 @@ impl AppState { delegation_socket_path, feedback_config, question_config, + session_info_config, system_op_lock: default_system_op_lock(), update_state: default_update_state(), } diff --git a/src-tauri/src/bin/codeg_mcp.rs b/src-tauri/src/bin/codeg_mcp.rs index 86a5562b2..3543f3158 100644 --- a/src-tauri/src/bin/codeg_mcp.rs +++ b/src-tauri/src/bin/codeg_mcp.rs @@ -1,7 +1,9 @@ //! `codeg-mcp` — the per-launch stdio MCP companion that an agent CLI runs //! to surface codeg's tools to its LLM: the multi-agent delegation tools -//! (`delegate_to_agent` etc.) and/or `check_user_feedback` (pull the user's -//! mid-turn steering notes), gated by the `--features` groups. +//! (`delegate_to_agent` etc.), `check_user_feedback` (pull the user's mid-turn +//! steering notes), `ask_user_question` (block on a multiple-choice card), and +//! `get_session_info` (resolve a referenced session by id), gated by the +//! `--features` groups (`delegation` / `feedback` / `ask` / `sessions`). //! //! The agent's MCP config (injected by codeg via `load_mcp_servers_for_agent`) //! spawns this binary with three required flags: @@ -46,9 +48,10 @@ struct Args { /// upgrade failure) or hold open a UDS / pipe nobody will ever read /// from. Omitted by older parents — backward compatible. parent_pid: Option, - /// Comma-joined tool groups to expose (e.g. `delegation,feedback`). Omitted - /// by parents that predate feature gating; see `CompanionFeatures::parse` - /// (defaults to delegation-only). + /// Comma-joined tool groups to expose (e.g. + /// `delegation,feedback,ask,sessions`). Omitted by parents that predate + /// feature gating; see `CompanionFeatures::parse` (defaults to + /// delegation-only). features: Option, } @@ -97,7 +100,7 @@ fn parse_args() -> Result { } "--help" | "-h" => { println!( - "codeg-mcp --parent-connection-id --socket-path --token [--parent-pid ] [--features delegation,feedback]" + "codeg-mcp --parent-connection-id --socket-path --token [--parent-pid ] [--features delegation,feedback,ask,sessions]" ); std::process::exit(0); } diff --git a/src-tauri/src/bin/codeg_server.rs b/src-tauri/src/bin/codeg_server.rs index c5c8ef2d7..7905cfdad 100644 --- a/src-tauri/src/bin/codeg_server.rs +++ b/src-tauri/src/bin/codeg_server.rs @@ -233,6 +233,7 @@ async fn async_main() { delegation_socket_path, feedback_config, question_config, + session_info_config, ) = codeg_lib::app_state::build_delegation_stack( &connection_manager, db.conn.clone(), @@ -257,6 +258,7 @@ async fn async_main() { delegation_socket_path: delegation_socket_path.clone(), feedback_config: feedback_config.clone(), question_config: question_config.clone(), + session_info_config: session_info_config.clone(), system_op_lock: codeg_lib::app_state::default_system_op_lock(), update_state: codeg_lib::app_state::default_update_state(), }); @@ -281,6 +283,12 @@ async fn async_main() { &question_config, ) .await; + // Same for the get-session-info enable flag. + codeg_lib::commands::session_info::apply_persisted_session_info_config( + &state.db.conn, + &session_info_config, + ) + .await; // Spawn the delegation listener so companion processes can round-trip // through the broker. Path is PID-scoped, so the listener owns it for @@ -298,6 +306,11 @@ async fn async_main() { Arc::new(codeg_lib::acp::manager::ConnectionManagerQuestionLookup { manager: Arc::new(state.connection_manager.clone_ref()), }), + Arc::new(codeg_lib::commands::session_info::DbSessionInfoLookup::new( + Arc::new(codeg_lib::db::AppDatabase { + conn: state.db.conn.clone(), + }), + )), ); let socket = delegation_socket_path.clone(); tokio::spawn(async move { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 0b30309dc..2717a5e98 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -23,6 +23,7 @@ pub mod quick_messages; pub mod remote_proxy; #[cfg(feature = "tauri-runtime")] pub mod remote_workspace; +pub mod session_info; pub mod system_settings; pub mod terminal; pub mod version_control; diff --git a/src-tauri/src/commands/session_info.rs b/src-tauri/src/commands/session_info.rs new file mode 100644 index 000000000..31358eb9c --- /dev/null +++ b/src-tauri/src/commands/session_info.rs @@ -0,0 +1,649 @@ +//! `get_session_info` backing logic + settings persistence. +//! +//! Two surfaces live here, mirroring `crate::commands::question`: +//! +//! * [`DbSessionInfoLookup`] — the production [`SessionInfoAccess`] impl the +//! delegation listener calls to resolve a referenced session +//! (`codeg://session/`) into metadata + token stats and, on demand, a +//! bounded compacted view of its recent messages. It reuses +//! [`get_folder_conversation_core`] (which reads the conversation row, uses +//! its bound `external_id` + `agent_type` to pick the right parser, and parses +//! the on-disk transcript off the runtime via `spawn_blocking`). +//! * The `session_info.enabled` settings knob (**default true**) — read at MCP +//! injection time via [`SessionInfoRuntimeConfig`]. Persist + apply + broadcast +//! follows the exact shape of the ask-question / feedback toggles. + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use sea_orm::DatabaseConnection; +use serde::{Deserialize, Serialize}; + +use crate::acp::session_info::{ + SessionInfo, SessionInfoAccess, SessionInfoConfig, SessionInfoRuntimeConfig, SessionMessageItem, + SessionMessages, MAX_SESSION_MESSAGES, +}; +use crate::app_error::AppCommandError; +use crate::commands::conversations::get_folder_conversation_core; +use crate::db::service::{app_metadata_service, conversation_service, folder_service}; +use crate::db::AppDatabase; +use crate::models::agent::AgentType; +use crate::models::message::{ContentBlock, MessageTurn, TurnRole}; +use crate::web::event_bridge::{emit_event, EventEmitter, SESSION_INFO_SETTINGS_CHANGED_EVENT}; + +/// Upper bound on a single compacted turn's text. Keeps one chatty turn from +/// dominating the budget; longer text is truncated with an ellipsis marker. +const PER_TURN_CHARS: usize = 1_500; + +/// Overall character budget across all included turns (text AND tool names). +/// Walked newest-first so the most recent context survives; older turns are +/// dropped once this is exhausted. +const OVERALL_CHARS: usize = 16_000; + +/// Per-turn caps on the collected tool names, so a turn with thousands of +/// `ToolUse` blocks (or one with a pathologically long tool name) can't inflate +/// the payload past the text budget. Tool-name chars are ALSO charged against +/// [`OVERALL_CHARS`]. +const MAX_TOOLS_PER_TURN: usize = 16; +const MAX_TOOL_NAME_CHARS: usize = 64; + +/// How long the transcript parse may run before the resolver gives up and returns +/// metadata only. The session keeps existing; the agent simply gets no messages +/// this call (with a `note` explaining why). Generous for large session files, +/// bounded so a pathological file can't pin the round-trip. +/// +/// NOTE: this bounds the *response latency*, not the underlying work — the parse +/// runs on tokio's blocking pool (`spawn_blocking` inside +/// `get_folder_conversation_core`), which cannot be canceled, so a timed-out parse +/// keeps running to completion in the background. [`MAX_CONCURRENT_PARSES`] is what +/// actually protects the blocking pool from a flood of large-session reads. +const PARSE_TIMEOUT: Duration = Duration::from_secs(8); + +/// Cap on concurrent transcript parses across all `get_session_info` calls. A +/// small bound keeps a burst of reads on large sessions from saturating tokio's +/// blocking pool (each parse pins one blocking thread, uncancelable). Excess +/// concurrent callers do NOT queue — `bounded_parse` uses a non-blocking +/// `try_acquire`, so a call that finds every slot taken returns `Busy` and +/// degrades to metadata-only immediately. +const MAX_CONCURRENT_PARSES: usize = 4; + +/// Production [`SessionInfoAccess`]: resolves a session by codeg conversation id +/// against the DB + on-disk transcript. Wraps an `Arc` (like +/// `DbDepthLookup` / `DbChildStatusLookup`) plus a semaphore bounding concurrent +/// transcript parses. Construct via [`DbSessionInfoLookup::new`]. +pub struct DbSessionInfoLookup { + pub db: Arc, + /// Limits concurrent `get_folder_conversation_core` parses (see + /// [`MAX_CONCURRENT_PARSES`]). + parse_limit: Arc, +} + +impl DbSessionInfoLookup { + pub fn new(db: Arc) -> Self { + Self { + db, + parse_limit: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_PARSES)), + } + } +} + +#[async_trait] +impl SessionInfoAccess for DbSessionInfoLookup { + async fn resolve(&self, session_id: i32, max_messages: u32) -> SessionInfo { + let conn = &self.db.conn; + // get_by_id is the authoritative existence + metadata source (cheap PK + // lookup, already filters soft-deleted). Resolve it FIRST so a missing + // session is cleanly "not found" rather than conflated with a parse error. + let summary = match conversation_service::get_by_id(conn, session_id).await { + Ok(s) => s, + Err(_) => return SessionInfo::not_found(session_id), + }; + + let (workspace_path, workspace_name) = + match folder_service::get_folder_by_id(conn, summary.folder_id).await { + Ok(Some(folder)) => (Some(folder.path), Some(folder.name)), + _ => (None, None), + }; + + let mut info = SessionInfo { + found: true, + session_id: summary.id, + external_id: summary.external_id.clone(), + agent_type: Some(agent_type_str(summary.agent_type)), + title: summary.title.clone().filter(|t| !t.trim().is_empty()), + status: Some(summary.status.clone()), + model: summary.model.clone(), + git_branch: summary.git_branch.clone(), + workspace_path, + workspace_name, + message_count: Some(summary.message_count), + created_at: Some(summary.created_at), + updated_at: Some(summary.updated_at), + parent_id: summary.parent_id, + is_delegation_child: summary.parent_id.is_some(), + stats: None, + messages: None, + note: None, + }; + + if max_messages == 0 { + return info; + } + let max = max_messages.min(MAX_SESSION_MESSAGES); + + // Parse the transcript off the hot path, gated by `parse_limit` and bounded + // by PARSE_TIMEOUT (see `bounded_parse`). A busy / timeout / parse error all + // degrade to metadata-only with an explanatory note rather than failing the + // whole tool call. + let conn_owned = self.db.conn.clone(); + let parse = + async move { get_folder_conversation_core(&conn_owned, session_id).await }; + match bounded_parse(self.parse_limit.clone(), PARSE_TIMEOUT, parse).await { + ParseSlot::Ready(Ok((detail, parsed_title))) => { + if info.title.is_none() { + info.title = parsed_title.filter(|t| !t.trim().is_empty()); + } + // get_folder_conversation_core sets summary.message_count to the + // parsed turn count — more accurate than the stored row. + info.message_count = Some(detail.summary.message_count); + info.stats = detail.session_stats; + info.messages = Some(compact_turns(&detail.turns, max)); + } + ParseSlot::Ready(Err(_)) => { + info.note = Some( + "Recent messages are unavailable — the session transcript could not be parsed." + .to_string(), + ); + } + ParseSlot::Busy => { + info.note = Some( + "Recent messages are unavailable — too many session reads are in progress. \ + Retry, or call again with max_messages: 0 for metadata only." + .to_string(), + ); + } + ParseSlot::TimedOut => { + info.note = Some( + "Recent messages are unavailable — reading the session transcript timed out. \ + Retry, or call again with max_messages: 0 for metadata only." + .to_string(), + ); + } + } + info + } +} + +/// Outcome of a permit-gated, timeout-bounded parse (see [`bounded_parse`]). +enum ParseSlot { + /// The parse completed within the timeout; carries its result. + Ready(T), + /// No parse slot was free — work was NOT started (caller should degrade now). + Busy, + /// A slot was taken and the parse started, but the caller's wait elapsed. The + /// detached worker keeps its permit until the (uncancelable) work finishes, so + /// the slot count keeps bounding real blocking concurrency. + TimedOut, +} + +/// Run `parse` under a real concurrency bound: acquire one of `parse_limit`'s +/// permits with a NON-blocking `try_acquire` (so a flood returns [`ParseSlot::Busy`] +/// instead of queueing), spawn the work into a detached task that holds the permit +/// until `parse` actually returns, and wait for it for at most `timeout`. +/// +/// The permit lives with the spawned task — NOT with the caller's `timeout` future +/// — so when the caller gives up ([`ParseSlot::TimedOut`]) the permit stays held +/// until the underlying (uncancelable `spawn_blocking`) parse completes. That is +/// what makes `MAX_CONCURRENT_PARSES` bound the number of in-flight blocking parses +/// even under repeated timeouts, rather than just the number of waiting callers. +async fn bounded_parse( + parse_limit: Arc, + timeout: Duration, + parse: Fut, +) -> ParseSlot +where + Fut: std::future::Future + Send + 'static, + T: Send + 'static, +{ + // try_acquire (not acquire): don't queue behind busy parses — fail fast so the + // caller can return metadata-only immediately instead of piling up tasks. + let Ok(permit) = parse_limit.try_acquire_owned() else { + return ParseSlot::Busy; + }; + let handle = tokio::spawn(async move { + // Hold the permit for the WHOLE parse; released only when `parse` returns. + let _permit = permit; + parse.await + }); + match tokio::time::timeout(timeout, handle).await { + Ok(Ok(value)) => ParseSlot::Ready(value), + // Task panicked — treat as a failed parse (no value). + Ok(Err(_join)) => ParseSlot::TimedOut, + // Caller's wait elapsed; the detached task keeps running with its permit. + Err(_) => ParseSlot::TimedOut, + } +} + +/// Snake_case wire form of an [`AgentType`] (e.g. `claude_code`) — matches the +/// `conversation.agent_type` column and the frontend's `ALL_AGENT_TYPES`. +fn agent_type_str(at: AgentType) -> String { + serde_json::to_value(at) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default() +} + +/// Compact the most recent `max` turns into [`SessionMessages`]. Walks the turns +/// newest-first so the freshest context is kept under [`OVERALL_CHARS`], always +/// retaining at least the newest turn, then restores chronological order. +fn compact_turns(turns: &[MessageTurn], max: u32) -> SessionMessages { + let total = turns.len(); + let max = max as usize; + let mut items: Vec = Vec::new(); + let mut budget = OVERALL_CHARS; + + for turn in turns.iter().rev() { + if items.len() >= max { + break; + } + let item = compact_turn(turn); + // Charge BOTH the text and the (bounded) tool names against the budget so + // a turn can't smuggle an oversized payload through `tools`. + let cost = item.text.chars().count() + + item + .tools + .iter() + .map(|t| t.chars().count()) + .sum::(); + // Always keep the newest turn; stop once the budget can't fit the next. + if !items.is_empty() && cost > budget { + break; + } + budget = budget.saturating_sub(cost); + items.push(item); + } + items.reverse(); + + let included = items.len(); + SessionMessages { + total: total as u32, + included: included as u32, + truncated: included < total, + items, + } +} + +/// One turn → role + truncated text (Text/Thinking blocks) + tool names. +fn compact_turn(turn: &MessageTurn) -> SessionMessageItem { + let role = match turn.role { + TurnRole::User => "user", + TurnRole::Assistant => "assistant", + TurnRole::System => "system", + } + .to_string(); + + let mut parts: Vec<&str> = Vec::new(); + let mut tools: Vec = Vec::new(); + for block in &turn.blocks { + match block { + ContentBlock::Text { text } | ContentBlock::Thinking { text } => { + let t = text.trim(); + if !t.is_empty() { + parts.push(t); + } + } + ContentBlock::ToolUse { tool_name, .. } => { + // Bound the count per turn and each name's length; dedup on the + // truncated form so the collected `tools` can't exceed + // MAX_TOOLS_PER_TURN * MAX_TOOL_NAME_CHARS. + if tools.len() < MAX_TOOLS_PER_TURN && !tool_name.is_empty() { + let name = truncate_chars(tool_name, MAX_TOOL_NAME_CHARS); + if !tools.contains(&name) { + tools.push(name); + } + } + } + // Tool results, images, and image-generation carry no useful plain + // text for a compact preview — skipped to keep the payload lean. + _ => {} + } + } + let text = truncate_chars(&parts.join("\n"), PER_TURN_CHARS); + SessionMessageItem { role, text, tools } +} + +/// Truncate to at most `cap` characters, appending an ellipsis marker when cut. +fn truncate_chars(s: &str, cap: usize) -> String { + if s.chars().count() <= cap { + return s.to_string(); + } + let mut out: String = s.chars().take(cap).collect(); + out.push('…'); + out +} + +// =========================================================================== +// Settings persistence — `session_info.enabled` (default ON). Mirrors +// `crate::commands::question`. +// =========================================================================== + +pub const KEY_SESSION_INFO_ENABLED: &str = "session_info.enabled"; + +/// On by default (`enabled: true`). The `get_session_info` tool is read-only and +/// only fires when the user references a session, so — like `ask_user_question` — +/// it ships enabled; a user who never wants agents reading session data can turn +/// it off in settings. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionInfoSettings { + pub enabled: bool, +} + +impl Default for SessionInfoSettings { + fn default() -> Self { + Self { enabled: true } + } +} + +impl SessionInfoSettings { + fn into_runtime_config(self) -> SessionInfoConfig { + SessionInfoConfig { + enabled: self.enabled, + } + } +} + +/// Read the persisted key from `app_metadata`, falling back to the default +/// (enabled) for a missing or malformed value. Never errors hard. +pub async fn load_session_info_settings(conn: &DatabaseConnection) -> SessionInfoSettings { + let mut settings = SessionInfoSettings::default(); + if let Ok(Some(raw)) = app_metadata_service::get_value(conn, KEY_SESSION_INFO_ENABLED).await { + if let Ok(v) = raw.parse::() { + settings.enabled = v; + } + } + settings +} + +/// Pull settings from the DB and push the resulting `SessionInfoConfig` onto the +/// shared runtime handle. Idempotent — safe on startup or after any save. +pub async fn apply_persisted_session_info_config( + conn: &DatabaseConnection, + config: &SessionInfoRuntimeConfig, +) { + let settings = load_session_info_settings(conn).await; + config.set(settings.into_runtime_config()).await; +} + +/// Persist + apply + broadcast. Shared by the Tauri command and the HTTP handler +/// so the write + re-apply + notify chain lives in one place. +pub async fn set_session_info_settings_core( + conn: &DatabaseConnection, + config: &SessionInfoRuntimeConfig, + emitter: &EventEmitter, + desired: SessionInfoSettings, +) -> Result { + app_metadata_service::upsert_value( + conn, + KEY_SESSION_INFO_ENABLED, + &desired.enabled.to_string(), + ) + .await + .map_err(AppCommandError::from)?; + config.set(desired.clone().into_runtime_config()).await; + emit_event(emitter, SESSION_INFO_SETTINGS_CHANGED_EVENT, &desired); + Ok(desired) +} + +// -------- Tauri commands ----------------------------------------------------- + +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn get_session_info_settings( + #[cfg(feature = "tauri-runtime")] db: tauri::State<'_, crate::db::AppDatabase>, +) -> Result { + #[cfg(feature = "tauri-runtime")] + { + Ok(load_session_info_settings(&db.conn).await) + } + #[cfg(not(feature = "tauri-runtime"))] + { + Err(AppCommandError::configuration_invalid("tauri-only command")) + } +} + +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn set_session_info_settings( + #[cfg(feature = "tauri-runtime")] app: tauri::AppHandle, + #[cfg(feature = "tauri-runtime")] db: tauri::State<'_, crate::db::AppDatabase>, + #[cfg(feature = "tauri-runtime")] config: tauri::State<'_, SessionInfoRuntimeConfig>, + settings: SessionInfoSettings, +) -> Result { + #[cfg(feature = "tauri-runtime")] + { + let emitter = EventEmitter::Tauri(app); + set_session_info_settings_core(&db.conn, &config, &emitter, settings).await + } + #[cfg(not(feature = "tauri-runtime"))] + { + let _ = settings; + Err(AppCommandError::configuration_invalid("tauri-only command")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use crate::models::message::ContentBlock; + + fn turn(role: TurnRole, blocks: Vec) -> MessageTurn { + MessageTurn { + id: "t".into(), + role, + blocks, + timestamp: Utc::now(), + usage: None, + duration_ms: None, + model: None, + completed_at: None, + } + } + + #[test] + fn compact_turn_joins_text_and_collects_tools() { + let item = compact_turn(&turn( + TurnRole::Assistant, + vec![ + ContentBlock::Text { text: "hello".into() }, + ContentBlock::ToolUse { + tool_use_id: None, + tool_name: "Read".into(), + input_preview: None, + meta: None, + }, + ContentBlock::ToolUse { + tool_use_id: None, + tool_name: "Read".into(), + input_preview: None, + meta: None, + }, + ContentBlock::Thinking { text: "hmm".into() }, + ], + )); + assert_eq!(item.role, "assistant"); + assert_eq!(item.text, "hello\nhmm"); + assert_eq!(item.tools, vec!["Read".to_string()]); // deduped + } + + #[test] + fn compact_turns_keeps_most_recent_within_max() { + let turns: Vec = (0..5) + .map(|i| { + turn( + TurnRole::User, + vec![ContentBlock::Text { + text: format!("msg{i}"), + }], + ) + }) + .collect(); + let out = compact_turns(&turns, 2); + assert_eq!(out.total, 5); + assert_eq!(out.included, 2); + assert!(out.truncated); + // Chronological order preserved, newest two kept. + assert_eq!(out.items[0].text, "msg3"); + assert_eq!(out.items[1].text, "msg4"); + } + + #[test] + fn compact_turn_bounds_tool_names() { + // A turn with thousands of distinct, long tool names must collapse to at + // most MAX_TOOLS_PER_TURN names, each ≤ MAX_TOOL_NAME_CHARS. + let blocks: Vec = (0..5000) + .map(|i| ContentBlock::ToolUse { + tool_use_id: None, + tool_name: format!("{}_{i}", "x".repeat(500)), + input_preview: None, + meta: None, + }) + .collect(); + let item = compact_turn(&turn(TurnRole::Assistant, blocks)); + assert!(item.tools.len() <= MAX_TOOLS_PER_TURN); + for name in &item.tools { + assert!(name.chars().count() <= MAX_TOOL_NAME_CHARS + 1); // +1 for the ellipsis + } + } + + #[test] + fn compact_turns_charges_tools_against_budget() { + // Many turns, each carrying the max tool payload, must still be bounded: + // the budget accounts for tool-name chars, so `included` stays small and + // `truncated` is set rather than returning every turn. + let one_turn = || { + let blocks: Vec = (0..MAX_TOOLS_PER_TURN) + .map(|i| ContentBlock::ToolUse { + tool_use_id: None, + tool_name: format!("{}_{i}", "t".repeat(MAX_TOOL_NAME_CHARS)), + input_preview: None, + meta: None, + }) + .collect(); + turn(TurnRole::Assistant, blocks) + }; + let turns: Vec = (0..500).map(|_| one_turn()).collect(); + let out = compact_turns(&turns, 500); + // Total payload chars (text + tools) stays within ~one turn of the budget. + let total_chars: usize = out + .items + .iter() + .map(|i| { + i.text.chars().count() + + i.tools.iter().map(|t| t.chars().count()).sum::() + }) + .sum(); + assert!(total_chars <= OVERALL_CHARS + PER_TURN_CHARS); + assert!(out.truncated); + assert!((out.included as usize) < out.total as usize); + } + + #[test] + fn compact_turns_not_truncated_when_all_fit() { + let turns = vec![turn( + TurnRole::User, + vec![ContentBlock::Text { text: "only".into() }], + )]; + let out = compact_turns(&turns, 20); + assert_eq!(out.total, 1); + assert_eq!(out.included, 1); + assert!(!out.truncated); + } + + #[test] + fn truncate_chars_marks_when_cut() { + assert_eq!(truncate_chars("abc", 5), "abc"); + assert_eq!(truncate_chars("abcdef", 3), "abc…"); + } + + #[test] + fn agent_type_str_is_snake_case() { + assert_eq!(agent_type_str(AgentType::ClaudeCode), "claude_code"); + assert_eq!(agent_type_str(AgentType::OpenClaw), "open_claw"); + } + + #[tokio::test] + async fn bounded_parse_returns_value_within_timeout() { + let sem = Arc::new(tokio::sync::Semaphore::new(1)); + let out = bounded_parse(sem.clone(), Duration::from_secs(5), async { 7 }).await; + assert!(matches!(out, ParseSlot::Ready(7))); + // The permit is released once the work completes. + assert_eq!(sem.available_permits(), 1); + } + + #[tokio::test] + async fn bounded_parse_reports_busy_when_no_slot_free() { + // A semaphore with zero permits → no slot → Busy, work never starts. + let sem = Arc::new(tokio::sync::Semaphore::new(0)); + let out: ParseSlot = + bounded_parse(sem, Duration::from_secs(5), async { 1 }).await; + assert!(matches!(out, ParseSlot::Busy)); + } + + /// Regression for the review finding: a timed-out parse must keep occupying its + /// slot until the underlying (uncancelable) work actually finishes — NOT be + /// released the moment the caller gives up waiting. + #[tokio::test] + async fn bounded_parse_holds_permit_past_caller_timeout() { + let sem = Arc::new(tokio::sync::Semaphore::new(1)); + let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>(); + // A "parse" that blocks until we signal it, modeling a slow spawn_blocking. + let parse = async move { + let _ = release_rx.await; + 99 + }; + // Caller waits only briefly while the work is still blocked → TimedOut. + let out = bounded_parse(sem.clone(), Duration::from_millis(50), parse).await; + assert!(matches!(out, ParseSlot::TimedOut)); + // The permit is STILL held by the detached worker (work hasn't finished). + assert_eq!(sem.available_permits(), 0); + assert!(sem.try_acquire().is_err()); + + // Now let the work complete; the worker drops the permit. + release_tx.send(()).unwrap(); + // Await the permit returning (generous bound; no fixed sleep) to prove it + // is released only after the underlying work finishes. + let _permit = tokio::time::timeout(Duration::from_secs(2), sem.acquire()) + .await + .expect("permit must be released once the parse completes") + .unwrap(); + } + + #[tokio::test] + async fn settings_default_on_and_round_trip() { + let db = crate::db::test_helpers::fresh_in_memory_db().await; + assert!(load_session_info_settings(&db.conn).await.enabled); + + let config = SessionInfoRuntimeConfig::new(); + set_session_info_settings_core( + &db.conn, + &config, + &EventEmitter::Noop, + SessionInfoSettings { enabled: false }, + ) + .await + .unwrap(); + assert!(!load_session_info_settings(&db.conn).await.enabled); + assert!(!config.is_enabled().await); + } + + #[tokio::test] + async fn resolve_unknown_id_is_not_found() { + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let lookup = DbSessionInfoLookup::new(Arc::new(AppDatabase { + conn: db.conn.clone(), + })); + let info = lookup.resolve(999_999, 10).await; + assert!(!info.found); + assert_eq!(info.session_id, 999_999); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 60877cb43..ef206bc20 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -50,7 +50,8 @@ mod tauri_app { model_provider as model_provider_commands, notification, pet as pet_commands, project_boot, question as question_commands, quick_messages as quick_messages_commands, remote_proxy as remote_proxy_commands, - remote_workspace as remote_workspace_commands, system_settings, terminal as terminal_commands, + remote_workspace as remote_workspace_commands, session_info as session_info_commands, + system_settings, terminal as terminal_commands, version_control, windows, workspace_state as workspace_state_commands, }; use crate::terminal::manager::TerminalManager; @@ -436,26 +437,34 @@ mod tauri_app { let broker_for_lifecycle = { let cm_state = app.state::(); let db_conn = app.state::().conn.clone(); - let (broker, tokens, socket_path, feedback_config, question_config) = - crate::app_state::build_delegation_stack( - &cm_state, - db_conn.clone(), - effective_data_dir.clone(), - ); + let ( + broker, + tokens, + socket_path, + feedback_config, + question_config, + session_info_config, + ) = crate::app_state::build_delegation_stack( + &cm_state, + db_conn.clone(), + effective_data_dir.clone(), + ); app.manage(broker.clone()); app.manage(tokens.clone()); app.manage(feedback_config.clone()); app.manage(question_config.clone()); + app.manage(session_info_config.clone()); app.manage(crate::commands::delegation::DelegationSocketPath( socket_path.clone(), )); // Push persisted settings into the broker + feedback + question - // config before listener accept. + // + session-info config before listener accept. let broker_for_init = broker.clone(); let db_for_init = db_conn.clone(); let feedback_for_init = feedback_config.clone(); let question_for_init = question_config.clone(); + let session_info_for_init = session_info_config.clone(); tauri::async_runtime::block_on(async move { delegation_commands::apply_persisted_config( &db_for_init, @@ -472,6 +481,11 @@ mod tauri_app { &question_for_init, ) .await; + crate::commands::session_info::apply_persisted_session_info_config( + &db_for_init, + &session_info_for_init, + ) + .await; }); let listener_broker = broker.clone(); @@ -493,6 +507,13 @@ mod tauri_app { manager: std::sync::Arc::new(cm_state.clone_ref()), }, ), + std::sync::Arc::new( + crate::commands::session_info::DbSessionInfoLookup::new( + std::sync::Arc::new(db::AppDatabase { + conn: db_conn.clone(), + }), + ), + ), ); tauri::async_runtime::spawn(async move { if let Err(e) = listener.run(socket_path).await { @@ -929,6 +950,8 @@ mod tauri_app { feedback_commands::submit_session_feedback, question_commands::get_question_settings, question_commands::set_question_settings, + session_info_commands::get_session_info_settings, + session_info_commands::set_session_info_settings, version_control::detect_git, version_control::test_git_path, version_control::get_git_settings, diff --git a/src-tauri/src/web/event_bridge.rs b/src-tauri/src/web/event_bridge.rs index 4d996be39..c977b7d81 100644 --- a/src-tauri/src/web/event_bridge.rs +++ b/src-tauri/src/web/event_bridge.rs @@ -164,6 +164,12 @@ pub const FEEDBACK_SETTINGS_CHANGED_EVENT: &str = "feedback-settings://changed"; /// bool }`). pub const QUESTION_SETTINGS_CHANGED_EVENT: &str = "question-settings://changed"; +/// Global side-channel announcing a `get_session_info` enable/disable. Same +/// cross-window rationale as [`QUESTION_SETTINGS_CHANGED_EVENT`]: the settings UI +/// runs in a separate window, so other views learn the flag flipped only via this +/// backend broadcast. Payload: `SessionInfoSettings` (`{ "enabled": bool }`). +pub const SESSION_INFO_SETTINGS_CHANGED_EVENT: &str = "session-info-settings://changed"; + /// Payload for the global [`CONVERSATION_CHANGED_EVENT`] side-channel. Drives /// cross-client sidebar sync (membership + status) independent of the /// per-connection ACP attach protocol, so clients that are NOT attached to a diff --git a/src-tauri/src/web/handlers/mod.rs b/src-tauri/src/web/handlers/mod.rs index 163023d41..e5bed632a 100644 --- a/src-tauri/src/web/handlers/mod.rs +++ b/src-tauri/src/web/handlers/mod.rs @@ -18,6 +18,7 @@ pub mod pet; pub mod project_boot; pub mod question; pub mod quick_messages; +pub mod session_info; pub mod system_settings; pub mod terminal; mod upload_jail; diff --git a/src-tauri/src/web/handlers/session_info.rs b/src-tauri/src/web/handlers/session_info.rs new file mode 100644 index 000000000..96db5b2e0 --- /dev/null +++ b/src-tauri/src/web/handlers/session_info.rs @@ -0,0 +1,42 @@ +//! HTTP handlers for get-session-info settings — the web-mode mirror of the +//! Tauri commands in `commands::session_info`. +//! +//! Both endpoints share the same core helpers (`load_session_info_settings`, +//! `set_session_info_settings_core`) so the persist + runtime-config re-apply +//! behavior stays identical across transports. + +use std::sync::Arc; + +use axum::{extract::Extension, Json}; +use serde::Deserialize; + +use crate::app_error::AppCommandError; +use crate::app_state::AppState; +use crate::commands::session_info::{ + load_session_info_settings, set_session_info_settings_core, SessionInfoSettings, +}; + +pub async fn get_session_info_settings( + Extension(state): Extension>, +) -> Result, AppCommandError> { + Ok(Json(load_session_info_settings(&state.db.conn).await)) +} + +#[derive(Deserialize)] +pub struct SetSessionInfoSettingsParams { + pub settings: SessionInfoSettings, +} + +pub async fn set_session_info_settings( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let saved = set_session_info_settings_core( + &state.db.conn, + &state.session_info_config, + &state.emitter, + params.settings, + ) + .await?; + Ok(Json(saved)) +} diff --git a/src-tauri/src/web/mod.rs b/src-tauri/src/web/mod.rs index d657cd9fb..8d57043f7 100644 --- a/src-tauri/src/web/mod.rs +++ b/src-tauri/src/web/mod.rs @@ -824,6 +824,12 @@ pub(crate) async fn do_start_web_server_tauri( .state::() .inner() .clone(), + // Reuse the same get-session-info config handle the desktop MCP injection + // reads, so HTTP-side session-info settings target the same flag. + session_info_config: app + .state::() + .inner() + .clone(), system_op_lock: crate::app_state::default_system_op_lock(), // Reuse the same handle the desktop `app_update` commands write to so // HTTP and webview readers see the identical update snapshot. diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index c47432ee6..60ded77f7 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -84,6 +84,14 @@ pub fn build_router( "/set_question_settings", post(handlers::question::set_question_settings), ) + .route( + "/get_session_info_settings", + post(handlers::session_info::get_session_info_settings), + ) + .route( + "/set_session_info_settings", + post(handlers::session_info::set_session_info_settings), + ) .route( "/get_folder_conversation", post(handlers::conversations::get_folder_conversation), diff --git a/src-tauri/tests/delegation_e2e_uds.rs b/src-tauri/tests/delegation_e2e_uds.rs index ebf541237..703ef8bd3 100644 --- a/src-tauri/tests/delegation_e2e_uds.rs +++ b/src-tauri/tests/delegation_e2e_uds.rs @@ -64,6 +64,19 @@ impl codeg_lib::acp::feedback::SessionFeedbackAccess for NoFeedback { async fn commit_feedback_delivered(&self, _parent_connection_id: &str, _ids: Vec) {} } +/// No-op session-info access — this e2e suite never drives `get_session_info`. +struct NoSessionInfo; +#[async_trait] +impl codeg_lib::acp::session_info::SessionInfoAccess for NoSessionInfo { + async fn resolve( + &self, + session_id: i32, + _max_messages: u32, + ) -> codeg_lib::acp::session_info::SessionInfo { + codeg_lib::acp::session_info::SessionInfo::not_found(session_id) + } +} + /// Controllable question access for the ask round-trip test: `register_question` /// parks a sender keyed by a freshly-minted id; the test pops it via /// `take_pending` and resolves it, exactly as a user answering the card would. @@ -150,6 +163,7 @@ async fn end_to_end_uds_happy_path() { Arc::new(FixedParent(1)) as Arc, Arc::new(NoFeedback) as Arc, Arc::new(StubQuestions::default()) as Arc, + Arc::new(NoSessionInfo) as Arc, ); // PID-scoped socket inside the OS temp dir — no clashes across test bins. @@ -264,6 +278,7 @@ async fn end_to_end_uds_batch_status() { Arc::new(FixedParent(1)) as Arc, Arc::new(NoFeedback) as Arc, Arc::new(StubQuestions::default()) as Arc, + Arc::new(NoSessionInfo) as Arc, ); let dir = tempfile::tempdir().unwrap(); @@ -349,6 +364,7 @@ async fn end_to_end_uds_invalid_token_rejected() { Arc::new(FixedParent(1)) as Arc, Arc::new(NoFeedback) as Arc, Arc::new(StubQuestions::default()) as Arc, + Arc::new(NoSessionInfo) as Arc, ); let dir = tempfile::tempdir().unwrap(); @@ -413,6 +429,7 @@ async fn end_to_end_uds_ask_question_round_trip() { Arc::new(FixedParent(1)) as Arc, Arc::new(NoFeedback) as Arc, questions.clone() as Arc, + Arc::new(NoSessionInfo) as Arc, ); let dir = tempfile::tempdir().unwrap(); @@ -550,6 +567,7 @@ async fn end_to_end_uds_ask_revoked_after_register_declines() { Arc::new(FixedParent(1)) as Arc, Arc::new(NoFeedback) as Arc, questions as Arc, + Arc::new(NoSessionInfo) as Arc, ); let dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/tests/delegation_e2e_windows.rs b/src-tauri/tests/delegation_e2e_windows.rs index 512983cfc..44aff7264 100644 --- a/src-tauri/tests/delegation_e2e_windows.rs +++ b/src-tauri/tests/delegation_e2e_windows.rs @@ -70,6 +70,19 @@ impl SessionQuestionAccess for NoQuestions { async fn cancel_questions_by_parent(&self, _parent_connection_id: &str) {} } +/// No-op session-info access — this e2e suite never drives `get_session_info`. +struct NoSessionInfo; +#[async_trait] +impl codeg_lib::acp::session_info::SessionInfoAccess for NoSessionInfo { + async fn resolve( + &self, + session_id: i32, + _max_messages: u32, + ) -> codeg_lib::acp::session_info::SessionInfo { + codeg_lib::acp::session_info::SessionInfo::not_found(session_id) + } +} + fn unique_pipe(tag: &str) -> String { format!( r"\\.\pipe\codeg-e2e-{}-{}-{}", @@ -157,6 +170,7 @@ async fn end_to_end_named_pipe_happy_path() { Arc::new(FixedParent(1)) as Arc, Arc::new(NoFeedback) as Arc, Arc::new(NoQuestions) as Arc, + Arc::new(NoSessionInfo) as Arc, ); let pipe = unique_pipe("happy"); @@ -257,6 +271,7 @@ async fn end_to_end_named_pipe_back_to_back_requests() { Arc::new(FixedParent(1)) as Arc, Arc::new(NoFeedback) as Arc, Arc::new(NoQuestions) as Arc, + Arc::new(NoSessionInfo) as Arc, ); let pipe = unique_pipe("repeat"); diff --git a/src/components/chat/composer/reference-uri.ts b/src/components/chat/composer/reference-uri.ts index 7734df2c6..3c928dc17 100644 --- a/src/components/chat/composer/reference-uri.ts +++ b/src/components/chat/composer/reference-uri.ts @@ -76,11 +76,15 @@ export function parseCodegReferenceUri( const session = uri.match(SESSION_URI) if (session) { const id = session[1] - // New format is `codeg://session/_`. Agent types - // themselves contain underscores (claude_code, open_code, open_claw), so the - // type is recovered by prefix match against the known set — never by - // splitting on the first `_`. A legacy all-numeric id (or any opaque token) - // matches no prefix and degrades to a session badge without an agent icon. + // Current format is `codeg://session/` (a bare numeric id), + // which matches no agent-type prefix and so degrades to a session badge + // without an agent icon — fine, since the live-inserted badge carries the + // agent via `meta` and get_session_info resolves the agent server-side. + // LEGACY links `codeg://session/_` (still present in + // historical transcripts) keep their agent icon: the type is recovered by + // prefix match against the known set — never by splitting on the first `_`, + // since agent types themselves contain underscores (claude_code, open_code, + // open_claw). const agentType = ALL_AGENT_TYPES.find((type) => id.startsWith(`${type}_`)) return { refType: "session", diff --git a/src/components/chat/composer/suggestion/adapters.test.ts b/src/components/chat/composer/suggestion/adapters.test.ts index af84b3861..ee39a85fe 100644 --- a/src/components/chat/composer/suggestion/adapters.test.ts +++ b/src/components/chat/composer/suggestion/adapters.test.ts @@ -79,7 +79,7 @@ describe("sessionToSuggestion", () => { status: "in_progress", git_branch: "main", } as DbConversationSummary - it("encodes _ in the uri (id stays the numeric id)", () => { + it("encodes the numeric conversation id in the uri (regardless of external_id)", () => { const item = sessionToSuggestion({ ...base, title: "Login refactor", @@ -89,11 +89,14 @@ describe("sessionToSuggestion", () => { refType: "session", id: "123", label: "Login refactor", - uri: "codeg://session/codex_abc123", + // Always the internal numeric id now — get_session_info resolves it + // server-side via the row's bound external_id + agent_type. + uri: "codeg://session/123", + // meta.agentType still set, so the @-panel option row shows the agent icon. meta: { agentType: "codex", status: "in_progress", branch: "main" }, }) }) - it("falls back to the numeric id when there is no external_id", () => { + it("uses the numeric id even when there is no external_id", () => { expect(sessionToSuggestion({ ...base, title: "x" }).reference.uri).toBe( "codeg://session/123" ) diff --git a/src/components/chat/composer/suggestion/adapters.ts b/src/components/chat/composer/suggestion/adapters.ts index 00512624b..9abc12c8b 100644 --- a/src/components/chat/composer/suggestion/adapters.ts +++ b/src/components/chat/composer/suggestion/adapters.ts @@ -66,13 +66,12 @@ export function agentToSuggestion(agent: AcpAgentInfo): SuggestionItem { } /** - * Conversation → session reference. The serialization uri encodes the agent's - * own session id as `codeg://session/_` (so the `@`-panel - * option row can show the owning agent's icon and a future codeg-mcp can resolve - * it by `(agent_type, external_id)`); sessions without an `external_id` fall back - * to the internal numeric id. The in-app `id` stays the numeric id either way. - * The inline session badge itself shows a neutral conversation glyph, not the - * agent icon. + * Conversation → session reference. The serialization uri encodes codeg's + * internal numeric conversation id as `codeg://session/` — the + * stable key the `get_session_info` MCP tool resolves directly (it then reads the + * row's bound `external_id` + `agent_type` server-side). The `@`-panel option row + * still shows the owning agent's icon via `meta.agentType`; the inline session + * badge shows a neutral conversation glyph, not the agent icon. */ export function sessionToSuggestion( conversation: DbConversationSummary @@ -84,9 +83,7 @@ export function sessionToSuggestion( // whitespace-only title (folding can't turn blank into non-blank). const label = formatConversationTitle(conversation.title).trim() || `#${conversation.id}` - const uri = conversation.external_id - ? `codeg://session/${conversation.agent_type}_${conversation.external_id}` - : `codeg://session/${conversation.id}` + const uri = `codeg://session/${conversation.id}` return { reference: { refType: "session", diff --git a/src/components/settings/general-settings.tsx b/src/components/settings/general-settings.tsx index fd5de68bc..eaf4ffa2e 100644 --- a/src/components/settings/general-settings.tsx +++ b/src/components/settings/general-settings.tsx @@ -32,6 +32,7 @@ import { toErrorMessage } from "@/lib/app-error" import { DelegationSettingsSection } from "@/components/settings/delegation-settings" import { SessionFeedbackSettingsSection } from "@/components/settings/session-feedback-settings" import { AskQuestionSettingsSection } from "@/components/settings/ask-question-settings" +import { SessionInfoSettingsSection } from "@/components/settings/session-info-settings" const TERMINAL_SHELL_OPTION_SYSTEM = "system" const TERMINAL_SHELL_OPTION_CUSTOM = "custom" @@ -392,6 +393,8 @@ export function GeneralSettings() { + + ) diff --git a/src/components/settings/session-info-settings.tsx b/src/components/settings/session-info-settings.tsx new file mode 100644 index 000000000..a5f14e5e5 --- /dev/null +++ b/src/components/settings/session-info-settings.tsx @@ -0,0 +1,116 @@ +"use client" + +/** + * Get-session-info settings panel — a single feature kill switch persisted as + * `session_info.enabled` on the Rust side. + * + * When enabled (the default), `codeg-mcp` exposes the read-only `get_session_info` + * tool so an agent can resolve a session the user referenced in the composer + * (`codeg://session/`) into its title, agent, status, workspace, token usage, + * and recent messages. Mounted under `/settings/general` next to the other + * MCP-tool feature toggles, because it's a global feature, not per-agent. + */ + +import { useCallback, useEffect, useState } from "react" +import { useTranslations } from "next-intl" +import { Loader2, MessageSquare } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" +import { + type SessionInfoSettings, + getSessionInfoSettings, + setSessionInfoSettings, +} from "@/lib/api" +import { toErrorMessage } from "@/lib/app-error" + +export function SessionInfoSettingsSection() { + const t = useTranslations("SessionInfoSettings") + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [enabled, setEnabled] = useState(true) + const [loadError, setLoadError] = useState(null) + + useEffect(() => { + let cancelled = false + void getSessionInfoSettings() + .then((s) => { + if (cancelled) return + setEnabled(s.enabled) + setLoadError(null) + }) + .catch((err: unknown) => { + if (cancelled) return + setLoadError(toErrorMessage(err)) + }) + .finally(() => { + if (cancelled) return + setLoading(false) + }) + return () => { + cancelled = true + } + }, []) + + const save = useCallback(async () => { + const payload: SessionInfoSettings = { enabled } + setSaving(true) + try { + const applied = await setSessionInfoSettings(payload) + setEnabled(applied.enabled) + toast.success(t("saved")) + } catch (err: unknown) { + toast.error(t("saveFailed"), { description: toErrorMessage(err) }) + } finally { + setSaving(false) + } + }, [enabled, t]) + + return ( +
+
+ +

{t("title")}

+
+

+ {t("description")} +

+ + {loadError && ( +

+ {t("loadFailed", { detail: loadError })} +

+ )} + +
+
+ +

{t("enableHint")}

+
+ +
+ +
+ +
+
+ ) +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 89daccaef..56c5424a9 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2895,6 +2895,17 @@ "turnEnded": "انتهت الجولة بالفعل.", "submitFailed": "تعذّر إرسال ملاحظتك" }, + "SessionInfoSettings": { + "title": "الحصول على معلومات الجلسة", + "description": "يتيح للوكلاء البحث عن جلسة تشير إليها في رسالتك (شارة جلسة) لقراءة عنوانها والوكيل والحالة ومساحة العمل واستهلاك الرموز وأحدث الرسائل.", + "enable": "تفعيل الحصول على معلومات الجلسة", + "enableHint": "يضيف أداة get_session_info إلى الوكلاء الذين يبدؤون بعد تفعيل هذا.", + "save": "حفظ", + "saving": "جارٍ الحفظ…", + "saved": "تم حفظ إعدادات معلومات الجلسة", + "saveFailed": "فشل حفظ إعدادات معلومات الجلسة", + "loadFailed": "فشل التحميل: {detail}" + }, "AskQuestionSettings": { "title": "سؤال المستخدم", "description": "يسمح للوكلاء بالتوقّف وطرح سؤال متعدّد الخيارات يظهر فوق مربّع إدخال المحادثة. ينتظر الوكيل حتى تجيب (أو تتخطّى).", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index c61dc73ae..7d994e25b 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2895,6 +2895,17 @@ "turnEnded": "Die Runde ist bereits beendet.", "submitFailed": "Deine Notiz konnte nicht gesendet werden" }, + "SessionInfoSettings": { + "title": "Sitzungsinformationen abrufen", + "description": "Erlaubt Agenten, eine in deiner Nachricht referenzierte Sitzung (ein Sitzungs-Badge) nachzuschlagen, um Titel, Agent, Status, Arbeitsbereich, Token-Verbrauch und letzte Nachrichten zu lesen.", + "enable": "Sitzungsinformationen abrufen aktivieren", + "enableHint": "Fügt das get_session_info-Tool zu Agenten hinzu, die nach dem Aktivieren gestartet werden.", + "save": "Speichern", + "saving": "Wird gespeichert…", + "saved": "Einstellungen für Sitzungsinformationen gespeichert", + "saveFailed": "Einstellungen für Sitzungsinformationen konnten nicht gespeichert werden", + "loadFailed": "Laden fehlgeschlagen: {detail}" + }, "AskQuestionSettings": { "title": "Benutzer fragen", "description": "Erlaubt Agenten, anzuhalten und dir eine Multiple-Choice-Frage zu stellen, die über dem Eingabefeld der Unterhaltung erscheint. Der Agent wartet, bis du antwortest (oder überspringst).", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 849898911..246472abb 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2895,6 +2895,17 @@ "turnEnded": "The turn already ended.", "submitFailed": "Couldn't send your note" }, + "SessionInfoSettings": { + "title": "Get session info", + "description": "Let agents look up a session you reference in your message (a session badge) to read its title, agent, status, workspace, token usage, and recent messages.", + "enable": "Enable get session info", + "enableHint": "Adds the get_session_info tool to agents started after this is turned on.", + "save": "Save", + "saving": "Saving…", + "saved": "Session info settings saved", + "saveFailed": "Failed to save session info settings", + "loadFailed": "Failed to load: {detail}" + }, "AskQuestionSettings": { "title": "Ask user question", "description": "Let agents pause and ask you a multiple-choice question that appears above the conversation input box. The agent waits until you answer (or skip).", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 0002321f5..188e76cbe 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2895,6 +2895,17 @@ "turnEnded": "El turno ya terminó.", "submitFailed": "No se pudo enviar tu nota" }, + "SessionInfoSettings": { + "title": "Obtener información de sesión", + "description": "Permite que los agentes consulten una sesión que mencionas en tu mensaje (una insignia de sesión) para leer su título, agente, estado, espacio de trabajo, uso de tokens y mensajes recientes.", + "enable": "Activar obtener información de sesión", + "enableHint": "Añade la herramienta get_session_info a los agentes iniciados después de activarla.", + "save": "Guardar", + "saving": "Guardando…", + "saved": "Ajustes de información de sesión guardados", + "saveFailed": "No se pudieron guardar los ajustes de información de sesión", + "loadFailed": "Error al cargar: {detail}" + }, "AskQuestionSettings": { "title": "Preguntar al usuario", "description": "Permite que los agentes se detengan y te hagan una pregunta de opción múltiple que aparece encima del cuadro de entrada de la conversación. El agente espera hasta que respondas (u omitas).", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 7b7dd0b36..35d34e7d6 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2895,6 +2895,17 @@ "turnEnded": "Le tour est déjà terminé.", "submitFailed": "Impossible d'envoyer votre note" }, + "SessionInfoSettings": { + "title": "Obtenir les infos de session", + "description": "Permet aux agents de consulter une session que vous mentionnez dans votre message (un badge de session) pour lire son titre, son agent, son statut, son espace de travail, sa consommation de jetons et ses messages récents.", + "enable": "Activer l'obtention des infos de session", + "enableHint": "Ajoute l'outil get_session_info aux agents démarrés après l'activation.", + "save": "Enregistrer", + "saving": "Enregistrement…", + "saved": "Paramètres d'infos de session enregistrés", + "saveFailed": "Échec de l'enregistrement des paramètres d'infos de session", + "loadFailed": "Échec du chargement : {detail}" + }, "AskQuestionSettings": { "title": "Poser une question à l'utilisateur", "description": "Permet aux agents de s'interrompre et de vous poser une question à choix multiples affichée au-dessus de la zone de saisie de la conversation. L'agent attend votre réponse (ou que vous ignoriez).", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index c89483aaa..7e70cc696 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2895,6 +2895,17 @@ "turnEnded": "ターンはすでに終了しています。", "submitFailed": "メモを送信できませんでした" }, + "SessionInfoSettings": { + "title": "セッション情報の取得", + "description": "メッセージ内で参照したセッション(セッションバッジ)をエージェントが照会し、タイトル・エージェント・ステータス・ワークスペース・トークン使用量・最近のメッセージを読み取れるようにします。", + "enable": "セッション情報の取得を有効にする", + "enableHint": "有効化後に起動したエージェントに get_session_info ツールを追加します。", + "save": "保存", + "saving": "保存中…", + "saved": "セッション情報の設定を保存しました", + "saveFailed": "セッション情報の設定の保存に失敗しました", + "loadFailed": "読み込みに失敗しました: {detail}" + }, "AskQuestionSettings": { "title": "ユーザーに質問", "description": "エージェントが処理を一時停止し、会話の入力欄の上に表示される選択式の質問をできるようにします。回答(またはスキップ)するまでエージェントは待機します。", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 8b6e1c229..0c617272b 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2895,6 +2895,17 @@ "turnEnded": "턴이 이미 종료되었습니다.", "submitFailed": "메모를 보내지 못했습니다" }, + "SessionInfoSettings": { + "title": "세션 정보 가져오기", + "description": "메시지에서 참조한 세션(세션 배지)을 에이전트가 조회하여 제목, 에이전트, 상태, 작업 공간, 토큰 사용량, 최근 메시지를 읽을 수 있도록 합니다.", + "enable": "세션 정보 가져오기 사용", + "enableHint": "사용 설정한 후 시작된 에이전트에 get_session_info 도구를 추가합니다.", + "save": "저장", + "saving": "저장 중…", + "saved": "세션 정보 설정이 저장되었습니다", + "saveFailed": "세션 정보 설정 저장에 실패했습니다", + "loadFailed": "불러오기 실패: {detail}" + }, "AskQuestionSettings": { "title": "사용자에게 질문", "description": "에이전트가 작업을 멈추고 대화 입력창 위에 표시되는 객관식 질문을 할 수 있습니다. 답변(또는 건너뛰기)할 때까지 에이전트는 대기합니다.", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 6dede349f..f123448dc 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2895,6 +2895,17 @@ "turnEnded": "O turno já terminou.", "submitFailed": "Não foi possível enviar a sua nota" }, + "SessionInfoSettings": { + "title": "Obter informações da sessão", + "description": "Permite que os agentes consultem uma sessão mencionada na sua mensagem (um selo de sessão) para ler o título, agente, status, espaço de trabalho, uso de tokens e mensagens recentes.", + "enable": "Ativar obter informações da sessão", + "enableHint": "Adiciona a ferramenta get_session_info aos agentes iniciados após ativar isto.", + "save": "Salvar", + "saving": "Salvando…", + "saved": "Configurações de informações da sessão salvas", + "saveFailed": "Falha ao salvar as configurações de informações da sessão", + "loadFailed": "Falha ao carregar: {detail}" + }, "AskQuestionSettings": { "title": "Perguntar ao usuário", "description": "Permite que os agentes pausem e façam uma pergunta de múltipla escolha que aparece acima da caixa de entrada da conversa. O agente aguarda até você responder (ou pular).", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 623720e3b..51379d835 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2895,6 +2895,17 @@ "turnEnded": "本轮已结束。", "submitFailed": "留言发送失败" }, + "SessionInfoSettings": { + "title": "获取会话信息", + "description": "允许智能体查询你在消息中提及的会话(会话徽章),读取其标题、智能体、状态、工作区、Token 用量和最近消息。", + "enable": "启用获取会话信息", + "enableHint": "在开启后启动的智能体将获得 get_session_info 工具。", + "save": "保存", + "saving": "保存中…", + "saved": "会话信息设置已保存", + "saveFailed": "保存会话信息设置失败", + "loadFailed": "加载失败:{detail}" + }, "AskQuestionSettings": { "title": "向用户提问", "description": "允许智能体暂停并向你提出多选问题,问题会显示在会话输入框上方。智能体会一直等待,直到你作答(或跳过)。", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index c0d2bb1d8..5e3bb0078 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2895,6 +2895,17 @@ "turnEnded": "本輪已結束。", "submitFailed": "留言送出失敗" }, + "SessionInfoSettings": { + "title": "取得工作階段資訊", + "description": "允許智慧代理查詢你在訊息中提及的工作階段(工作階段徽章),讀取其標題、代理、狀態、工作區、Token 用量與最近訊息。", + "enable": "啟用取得工作階段資訊", + "enableHint": "在開啟後啟動的代理將取得 get_session_info 工具。", + "save": "儲存", + "saving": "儲存中…", + "saved": "工作階段資訊設定已儲存", + "saveFailed": "儲存工作階段資訊設定失敗", + "loadFailed": "載入失敗:{detail}" + }, "AskQuestionSettings": { "title": "向使用者提問", "description": "允許智能體暫停並向你提出多選問題,問題會顯示在對話輸入框上方。智能體會一直等待,直到你作答(或略過)。", diff --git a/src/lib/api.ts b/src/lib/api.ts index 239a0bd37..c6af4b131 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2777,6 +2777,23 @@ export async function setQuestionSettings( return getTransport().call("set_question_settings", { settings }) } +// ─── Get-session-info settings ───────────────────────────────────────────── + +/** Mirror of Rust `SessionInfoSettings` (default ON). */ +export interface SessionInfoSettings { + enabled: boolean +} + +export async function getSessionInfoSettings(): Promise { + return getTransport().call("get_session_info_settings") +} + +export async function setSessionInfoSettings( + settings: SessionInfoSettings +): Promise { + return getTransport().call("set_session_info_settings", { settings }) +} + /** Live probe — opens a transient ACP connection to `agent_type`, reads what * it advertises (modes / config_options), and tears down. Used by the * delegation-settings UI so the option set on screen matches exactly what From cd647ec0cf3ae56ab5f42e2fde38d2581e11a2a6 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sun, 14 Jun 2026 19:20:18 +0800 Subject: [PATCH 29/31] fix(conversations): fold file reference links to labels in titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conversation titles derived from a user's first message kept raw Markdown reference links such as `[name.xlsx](file:///…)`, and the length cap could slice a link mid-destination into a broken, unfoldable fragment. Parsers now fold reference links to their labels before truncating, so titles show the clean label across the sidebar, tabs and search. Consolidate the previously duplicated reference-link logic into a single canonical module, src/lib/reference-link.ts: file:// URI building, label unescaping, and an O(n) [label](destination) tokenizer/folder. formatConversationTitle and the transcript resource extractor use it, and the drag-to-input and @-mention paths share one file:// builder. A matching folder in the Rust parsers keeps backend-generated titles in step. --- src-tauri/src/parsers/claude.rs | 8 +- src-tauri/src/parsers/cline.rs | 9 +- src-tauri/src/parsers/codex.rs | 6 +- src-tauri/src/parsers/gemini.rs | 6 +- src-tauri/src/parsers/mod.rs | 273 +++++++++++++++++- src-tauri/src/parsers/openclaw.rs | 8 +- .../chat/composer/suggestion/adapters.test.ts | 13 - .../chat/composer/suggestion/adapters.ts | 14 +- src/components/chat/message-input.tsx | 16 +- src/lib/adapters/ai-elements-adapter.test.ts | 19 ++ src/lib/adapters/ai-elements-adapter.ts | 50 ++-- src/lib/conversation-title.test.ts | 10 +- src/lib/conversation-title.ts | 141 +-------- src/lib/reference-link.test.ts | 192 ++++++++++++ src/lib/reference-link.ts | 209 ++++++++++++++ 15 files changed, 750 insertions(+), 224 deletions(-) create mode 100644 src/lib/reference-link.test.ts create mode 100644 src/lib/reference-link.ts diff --git a/src-tauri/src/parsers/claude.rs b/src-tauri/src/parsers/claude.rs index 0a227d117..058cfe85b 100644 --- a/src-tauri/src/parsers/claude.rs +++ b/src-tauri/src/parsers/claude.rs @@ -7,7 +7,9 @@ use chrono::{DateTime, Utc}; use regex::Regex; use crate::models::*; -use crate::parsers::{folder_name_from_path, truncate_str, AgentParser, ParseError}; +use crate::parsers::{ + folder_name_from_path, title_from_user_text, truncate_str, AgentParser, ParseError, +}; /// Regex that matches Claude Code system-injected XML tags and their content. /// These tags are internal metadata and should not be displayed to users. @@ -434,7 +436,7 @@ impl ClaudeParser { // Extract title from first user message if msg_type == "user" && title.is_none() { - title = extract_user_text(&value).map(|t| truncate_str(&t, 100)); + title = extract_user_text(&value).map(|t| title_from_user_text(&t)); } } } @@ -724,7 +726,7 @@ impl ClaudeParser { ContentBlock::Text { text } => Some(text.clone()), _ => None, }) { - title = Some(truncate_str(&first_text, 100)); + title = Some(title_from_user_text(&first_text)); } } MessageRole::User diff --git a/src-tauri/src/parsers/cline.rs b/src-tauri/src/parsers/cline.rs index be145708c..817fbd365 100644 --- a/src-tauri/src/parsers/cline.rs +++ b/src-tauri/src/parsers/cline.rs @@ -9,7 +9,10 @@ use crate::models::{ TurnUsage, }; -use super::{compute_session_stats, folder_name_from_path, truncate_str, AgentParser, ParseError}; +use super::{ + compute_session_stats, folder_name_from_path, title_from_user_text, truncate_str, AgentParser, + ParseError, +}; // --------------------------------------------------------------------------- // On-disk JSON structures @@ -158,7 +161,7 @@ impl AgentParser for ClineParser { let folder_path = entry.cwd_on_task_initialization.clone(); let folder_name = folder_path.as_deref().map(folder_name_from_path); - let title = entry.task.as_deref().map(|t| truncate_str(t.trim(), 100)); + let title = entry.task.as_deref().map(|t| title_from_user_text(t.trim())); // Count messages from api_conversation_history.json let api_path = tasks_dir.join("api_conversation_history.json"); @@ -239,7 +242,7 @@ impl AgentParser for ClineParser { let title = history_entry .as_ref() .and_then(|e| e.task.as_deref()) - .map(|t| truncate_str(t.trim(), 100)); + .map(|t| title_from_user_text(t.trim())); let mut turns: Vec = Vec::new(); let mut turn_counter = 0u32; diff --git a/src-tauri/src/parsers/codex.rs b/src-tauri/src/parsers/codex.rs index 71808c72e..8a4684ed0 100644 --- a/src-tauri/src/parsers/codex.rs +++ b/src-tauri/src/parsers/codex.rs @@ -9,7 +9,9 @@ use regex::Regex; use walkdir::WalkDir; use crate::models::*; -use crate::parsers::{folder_name_from_path, truncate_str, AgentParser, ParseError}; +use crate::parsers::{ + folder_name_from_path, title_from_user_text, truncate_str, AgentParser, ParseError, +}; pub struct CodexParser { base_dir: PathBuf, @@ -1593,7 +1595,7 @@ fn extract_codex_title_candidate(input: &str, fallback_attached: bool) -> Option None } } else { - Some(truncate_str(&cleaned, 100)) + Some(title_from_user_text(&cleaned)) } } diff --git a/src-tauri/src/parsers/gemini.rs b/src-tauri/src/parsers/gemini.rs index 7c42a00f4..fdc37babf 100644 --- a/src-tauri/src/parsers/gemini.rs +++ b/src-tauri/src/parsers/gemini.rs @@ -7,7 +7,9 @@ use serde_json::{Map, Value}; use walkdir::WalkDir; use crate::models::*; -use crate::parsers::{folder_name_from_path, truncate_str, AgentParser, ParseError}; +use crate::parsers::{ + folder_name_from_path, title_from_user_text, truncate_str, AgentParser, ParseError, +}; pub struct GeminiParser { base_dir: PathBuf, @@ -419,7 +421,7 @@ impl GeminiParser { .filter(|m| m.get("type").and_then(|t| t.as_str()) == Some("user")) .filter_map(Self::extract_message_text) .find(|t| !t.trim_start().starts_with(" String { } } +/// Punctuation the serializer escapes with a leading backslash inside a +/// reference label (mirrors `escapeMarkdownText` in `src/lib/reference-text.ts` +/// and the class in the frontend `unescapeReferenceLabel`). +fn is_escapable_reference_punct(c: char) -> bool { + matches!( + c, + '\\' | '`' | '*' | '_' | '~' | '[' | ']' | '(' | ')' | '<' | '>' + ) +} + +/// Reverse the serializer's label escaping: drop the backslash from each escaped +/// inline-significant punctuation char so the recovered label reads literally. +/// Mirrors the frontend `unescapeReferenceLabel`. +fn unescape_reference_label(label: &[char]) -> String { + let mut out = String::with_capacity(label.len()); + let mut i = 0; + while i < label.len() { + if label[i] == '\\' && i + 1 < label.len() && is_escapable_reference_punct(label[i + 1]) { + out.push(label[i + 1]); + i += 2; + } else { + out.push(label[i]); + i += 1; + } + } + out +} + +/// Mirror ECMAScript's `/\s/` — the whitespace class the frontend +/// `foldReferenceLinks` (`src/lib/reference-link.ts`) scans destinations with — +/// so this port stays in step with it. It deliberately differs from Rust's +/// `char::is_whitespace()` in exactly two code points: `U+FEFF` (BOM) is +/// whitespace to JS but not to Rust, and `U+0085` (NEL) is whitespace to Rust +/// but not to JS. The set is ECMAScript WhiteSpace + LineTerminator. +fn is_markdown_whitespace(c: char) -> bool { + matches!( + c, + '\u{0009}'..='\u{000D}' // tab, LF, VT, FF, CR + | '\u{0020}' // space + | '\u{00A0}' // no-break space + | '\u{1680}' + | '\u{2000}'..='\u{200A}' + | '\u{2028}' // line separator + | '\u{2029}' // paragraph separator + | '\u{202F}' + | '\u{205F}' + | '\u{3000}' + | '\u{FEFF}' // zero-width no-break space (BOM) + ) +} + +/// Whether the backslash at `k` escapes the next character. CommonMark never +/// lets a backslash escape whitespace, so `\` + whitespace ENDS (not extends) a +/// label/destination scan — only `\` + a non-whitespace char is a real escape. +fn reference_escapes_next(chars: &[char], k: usize) -> bool { + chars.get(k) == Some(&'\\') && chars.get(k + 1).is_some_and(|c| !is_markdown_whitespace(*c)) +} + +/// If a well-formed `(destination)` begins at `start`, return the index just +/// past its closing `)`; otherwise `None`. Mirrors the frontend `destinationEnd` +/// and the serializer's two forms: a `<…>`-wrapped destination (interior `\`, +/// `<`, `>` backslash-escaped) or a bare run with no `(`, `)`, whitespace, `<` or +/// `>`. +fn reference_destination_end(chars: &[char], start: usize) -> Option { + let n = chars.len(); + if start >= n || chars[start] != '(' { + return None; + } + let mut k = start + 1; + if chars.get(k) == Some(&'<') { + k += 1; + while k < n { + if reference_escapes_next(chars, k) { + k += 2; + continue; + } + match chars[k] { + '>' => { + return if chars.get(k + 1) == Some(&')') { + Some(k + 2) + } else { + None + }; + } + // An unescaped `<` or a line break is forbidden inside `<…>`; + // bailing here also bounds the scan so a missing `>` stops at the + // next `<` instead of running to EOF (keeps adversarial input + // linear). + '<' | '\n' | '\r' => return None, + _ => k += 1, + } + } + return None; + } + while k < n { + if reference_escapes_next(chars, k) { + k += 2; + continue; + } + let c = chars[k]; + if c == ')' { + return Some(k + 1); + } + if c == '(' || c == '<' || c == '>' || is_markdown_whitespace(c) { + return None; + } + k += 1; + } + None +} + +/// Replace every inline `[label](destination)` reference link in `text` with its +/// unescaped `label`, leaving all other prose (including malformed `[…]`/`(…)` +/// fragments and invocation tokens like `@Codex`) untouched. +/// +/// This is the Rust counterpart of the frontend canonical fold +/// (`foldReferenceLinks` in `src/lib/reference-link.ts`) and MUST stay in step +/// with it: a single O(n) left-to-right scan over a stack of unmatched `[` +/// positions, matching each `]` against the most recent opener so a balanced +/// nested label closes at the right bracket, requiring a non-empty label and a +/// well-formed `(dest)` for a link, and recovering later links after a +/// stray/unbalanced `[`. Used to derive conversation titles from a user's first +/// message: folding BEFORE truncation means a long `file://` destination can +/// never be sliced mid-link into an unterminable `[label](file://…` fragment. +pub fn fold_reference_links(text: &str) -> String { + let chars: Vec = text.chars().collect(); + let n = chars.len(); + let mut out = String::with_capacity(text.len()); + // Start of the pending prose run; flushed before each link and at the end. + let mut text_start = 0usize; + // Indices of `[` seen but not yet matched by a `]` (most recent on top). + let mut openers: Vec = Vec::new(); + let mut i = 0usize; + + while i < n { + if reference_escapes_next(&chars, i) { + // `\[` / `\]` (and any `\x`) is literal — skip both chars. + i += 2; + continue; + } + match chars[i] { + '[' => { + openers.push(i); + i += 1; + } + ']' if !openers.is_empty() => { + let open = openers.pop().expect("openers is non-empty"); + match reference_destination_end(&chars, i + 1) { + // A link needs a well-formed `(dest)` right after `]` and a + // non-empty label between the brackets. + Some(end) if i > open + 1 => { + out.extend(chars[text_start..open].iter()); + out.push_str(&unescape_reference_label(&chars[open + 1..i])); + // Everything up to `open` is committed, so any still-open + // outer `[` can no longer span a link. + openers.clear(); + i = end; + text_start = end; + } + // Not a link: keep the brackets in the pending prose run and + // keep scanning so a later valid link is still found. + _ => i += 1, + } + } + _ => i += 1, + } + } + out.extend(chars[text_start..n].iter()); + out +} + +/// Derive a conversation title from a user's first message: fold inline +/// reference links to their labels, then cap the length. Folding first ensures a +/// `[name](file://)` mention becomes `name` instead of a raw — and, +/// once truncated, unterminable — Markdown link. +pub fn title_from_user_text(text: &str) -> String { + truncate_str(&fold_reference_links(text), 100) +} + /// Aggregate turn-level usage and duration into a single `SessionStats`. pub fn compute_session_stats(turns: &[MessageTurn]) -> Option { let mut total_in = 0u64; @@ -785,11 +964,101 @@ mod tests { use chrono::Utc; use super::{ - infer_context_window_max_tokens, latest_turn_total_usage_tokens, - merge_context_window_stats, path_eq_for_matching, + fold_reference_links, infer_context_window_max_tokens, latest_turn_total_usage_tokens, + merge_context_window_stats, path_eq_for_matching, title_from_user_text, }; use crate::models::{MessageTurn, SessionStats, TurnRole, TurnUsage}; + #[test] + fn fold_reference_links_reduces_links_to_labels() { + // Plain prose is untouched. + assert_eq!(fold_reference_links("hello world"), "hello world"); + // A file link folds to its label; surrounding text is preserved. + assert_eq!( + fold_reference_links("看看 [README.md](file:///Users/x/README.md) 这是什么"), + "看看 README.md 这是什么" + ); + // codeg:// links fold too; an agent mention keeps its `@`. + assert_eq!( + fold_reference_links("调用 [@Codex CLI](codeg://agent/codex) 执行"), + "调用 @Codex CLI 执行" + ); + // Multiple links in one string. + assert_eq!( + fold_reference_links("compare [a.ts](file:///a.ts) and [b.ts](file:///b.ts)"), + "compare a.ts and b.ts" + ); + } + + #[test] + fn fold_reference_links_handles_escapes_and_angle_destinations() { + // A `<…>`-wrapped destination (spaces/parens in the path) still folds. + assert_eq!( + fold_reference_links("[report (1).pdf]()"), + "report (1).pdf" + ); + // Escaped punctuation in the label is unescaped. + assert_eq!(fold_reference_links("[a\\]b\\(c](file:///x)"), "a]b(c"); + // A balanced nested-bracket label closes at the outer `]`. + assert_eq!(fold_reference_links("[a [b]](https://x)"), "a [b]"); + // A later link is recovered after a stray/unbalanced `[`. + assert_eq!(fold_reference_links("[a [b](url)"), "[a b"); + } + + #[test] + fn fold_reference_links_matches_js_whitespace_class() { + // Parity with the frontend `foldReferenceLinks`, whose destination scan + // uses ECMAScript `/\s/` rather than Rust's `char::is_whitespace()`. The + // two classes differ on exactly these code points (verified against the + // TS module): U+FEFF (BOM) and U+00A0 (NBSP) ARE JS whitespace, so a bare + // destination containing them is malformed and the text stays raw… + assert_eq!( + fold_reference_links("[a](foo\u{FEFF}bar)"), + "[a](foo\u{FEFF}bar)" + ); + assert_eq!( + fold_reference_links("[a](foo\u{00A0}bar)"), + "[a](foo\u{00A0}bar)" + ); + // …while U+0085 (NEL) is NOT JS whitespace, so it is an ordinary + // destination char and the link folds (Rust's is_whitespace would have + // wrongly rejected it). + assert_eq!(fold_reference_links("[a](foo\u{0085}bar)"), "a"); + } + + #[test] + fn fold_reference_links_leaves_malformed_fragments_raw() { + // An unterminated link (no closing `)`) is left verbatim — exactly the + // truncated-title shape this fix keeps from ever being stored. + assert_eq!( + fold_reference_links("[oops no close](file:///x"), + "[oops no close](file:///x" + ); + // An empty-label `[](x)` is not a link. + assert_eq!(fold_reference_links("[](x)"), "[](x)"); + // A bare destination with an unescaped space is malformed. + assert_eq!(fold_reference_links("[a](foo bar)"), "[a](foo bar)"); + } + + #[test] + fn title_from_user_text_folds_before_truncating() { + // The regression: a long percent-encoded file mention used to be + // truncated mid-destination into an unterminable `[label](file://…` + // fragment. Folding first yields the short, clean filename. + let long_path = "%E5%85%A8".repeat(40); // > 100 chars when raw + let raw = format!("[全天候运维.xlsx](file:///Users/xggz/Desktop/{long_path}.xlsx)"); + assert!(raw.chars().count() > 100, "fixture must exceed the cap"); + assert_eq!(title_from_user_text(&raw), "全天候运维.xlsx"); + } + + #[test] + fn title_from_user_text_still_caps_plain_prose() { + let long = "x".repeat(250); + let title = title_from_user_text(&long); + assert_eq!(title.chars().count(), 103); // 100 + "..." + assert!(title.ends_with("...")); + } + #[test] fn infers_model_context_limits() { assert_eq!( diff --git a/src-tauri/src/parsers/openclaw.rs b/src-tauri/src/parsers/openclaw.rs index 7d9d8ff18..6946d8e9c 100644 --- a/src-tauri/src/parsers/openclaw.rs +++ b/src-tauri/src/parsers/openclaw.rs @@ -11,8 +11,8 @@ use serde::Deserialize; use crate::models::*; use crate::parsers::{ compute_session_stats, folder_name_from_path, infer_context_window_max_tokens, - latest_turn_total_usage_tokens, merge_context_window_stats, truncate_str, AgentParser, - ParseError, + latest_turn_total_usage_tokens, merge_context_window_stats, title_from_user_text, truncate_str, + AgentParser, ParseError, }; /// Regex to strip the "Sender (untrusted metadata):" block and optional @@ -450,7 +450,7 @@ impl OpenClawParser { if title.is_none() { let cleaned = strip_openclaw_user_prefix(&text); if !cleaned.is_empty() { - title = Some(truncate_str(&cleaned, 100)); + title = Some(title_from_user_text(&cleaned)); } } } @@ -603,7 +603,7 @@ impl OpenClawParser { } if title.is_none() { if let Some(ContentBlock::Text { ref text }) = content.first() { - title = Some(truncate_str(text, 100)); + title = Some(title_from_user_text(text)); } } messages.push(UnifiedMessage { diff --git a/src/components/chat/composer/suggestion/adapters.test.ts b/src/components/chat/composer/suggestion/adapters.test.ts index ee39a85fe..b9943c748 100644 --- a/src/components/chat/composer/suggestion/adapters.test.ts +++ b/src/components/chat/composer/suggestion/adapters.test.ts @@ -11,22 +11,9 @@ import { agentToSuggestion, commitToSuggestion, fileToSuggestion, - pathToFileUri, sessionToSuggestion, } from "./adapters" -describe("pathToFileUri", () => { - it("builds a triple-slash uri for a posix path", () => { - expect(pathToFileUri("/repo/src/app.ts")).toBe("file:///repo/src/app.ts") - }) - it("normalizes Windows backslashes and encodes the drive segment", () => { - expect(pathToFileUri("C:\\repo\\app.ts")).toBe("file:///C%3A/repo/app.ts") - }) - it("percent-encodes spaces, # and ? within segments (not the separators)", () => { - expect(pathToFileUri("/a/b c#d?e.ts")).toBe("file:///a/b%20c%23d%3Fe.ts") - }) -}) - describe("fileToSuggestion", () => { const entry: FlatFileEntry = { name: "app.ts", diff --git a/src/components/chat/composer/suggestion/adapters.ts b/src/components/chat/composer/suggestion/adapters.ts index 9abc12c8b..79ece547d 100644 --- a/src/components/chat/composer/suggestion/adapters.ts +++ b/src/components/chat/composer/suggestion/adapters.ts @@ -1,5 +1,6 @@ import type { FlatFileEntry } from "@/hooks/use-file-tree" import { formatConversationTitle } from "@/lib/conversation-title" +import { buildFileUri } from "@/lib/reference-link" import { AGENT_LABELS, type AcpAgentInfo, @@ -9,17 +10,6 @@ import { import type { SuggestionItem } from "./types" -/** - * Build a `file://` URI from an absolute path (POSIX or Windows), percent- - * encoding each path segment so spaces / `#` / `?` / `%` can't corrupt the URI. - * Mirrors `toFileUri` in message-input.tsx. - */ -export function pathToFileUri(absolutePath: string): string { - const normalized = absolutePath.replace(/\\/g, "/") - const encoded = normalized.split("/").map(encodeURIComponent).join("/") - return normalized.startsWith("/") ? `file://${encoded}` : `file:///${encoded}` -} - function joinPath(root: string, relative: string): string { const left = root.replace(/[/\\]+$/, "") const right = relative.replace(/^[/\\]+/, "") @@ -36,7 +26,7 @@ export function fileToSuggestion( refType: "file", id: entry.relativePath, label: entry.name, - uri: pathToFileUri(joinPath(workspaceRoot, entry.relativePath)), + uri: buildFileUri(joinPath(workspaceRoot, entry.relativePath)), meta: { fileKind: entry.kind }, }, detail: entry.relativePath, diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index d1b9233e2..9f9f485e3 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -38,6 +38,7 @@ import { import { ImagePreviewDialog } from "@/components/ui/image-preview-dialog" import { AgentIcon } from "@/components/agent-icon" import { cn, randomUUID } from "@/lib/utils" +import { buildFileUri } from "@/lib/reference-link" import { filesFromClipboard, clipboardHasText, @@ -226,15 +227,6 @@ function mimeTypeFromPath(path: string): string | null { return MIME_BY_EXT[ext] ?? null } -function toFileUri(path: string): string { - const normalized = path.replace(/\\/g, "/") - const encoded = normalized.split("/").map(encodeURIComponent).join("/") - if (normalized.startsWith("/")) { - return `file://${encoded}` - } - return `file:///${encoded}` -} - function hasDragFiles(dataTransfer: DataTransfer | null): boolean { if (!dataTransfer?.types) return false return Array.from(dataTransfer.types).includes("Files") @@ -1101,7 +1093,7 @@ export function MessageInput({ (path): path is string => typeof path === "string" && path.length > 0 ) .map((path) => { - const uri = toFileUri(path) + const uri = buildFileUri(path) return { uri, name: fileNameFromPath(path), @@ -1265,7 +1257,7 @@ export function MessageInput({ const name = file.name || `resource-${randomUUID()}` const mimeType = file.type || mimeTypeFromPath(name) if (path) { - const uri = toFileUri(path) + const uri = buildFileUri(path) pathLinks.push({ uri, name: fileNameFromPath(path), @@ -1360,7 +1352,7 @@ export function MessageInput({ id: `image:${Date.now()}:${index}:${randomUUID()}`, type: "image" as const, data, - uri: toFileUri(path), + uri: buildFileUri(path), name: fileNameFromPath(path), mimeType: mimeTypeFromPath(path) ?? "image/png", } diff --git a/src/lib/adapters/ai-elements-adapter.test.ts b/src/lib/adapters/ai-elements-adapter.test.ts index 224a7fdcb..39c9ccb3d 100644 --- a/src/lib/adapters/ai-elements-adapter.test.ts +++ b/src/lib/adapters/ai-elements-adapter.test.ts @@ -910,6 +910,25 @@ describe("extractUserResourcesFromText — codeg references stay inline", () => expect(text).toContain("[#42](codeg://session/codex_abc)") expect(text).toContain("[foo.ts](file:///x/foo.ts)") }) + + it("recovers a file chip after stray/unbalanced brackets in prose", () => { + // The unmatched `[oops` must not swallow the later real file reference. + const { text, resources } = extractUserResourcesFromText( + "text [oops [still open] [foo.ts](file:///x/foo.ts)" + ) + expect(resources).toEqual([ + { name: "foo.ts", uri: "file:///x/foo.ts", mime_type: null }, + ]) + expect(text).toContain("[foo.ts](file:///x/foo.ts)") + }) + + it("ignores an empty-label [](file://…) link, adding no chip", () => { + const { text, resources } = extractUserResourcesFromText( + "see [](file:///x/foo.ts) ok" + ) + expect(resources).toEqual([]) + expect(text).toBe("see [](file:///x/foo.ts) ok") + }) }) describe("adaptMessageTurn — user reference resources", () => { diff --git a/src/lib/adapters/ai-elements-adapter.ts b/src/lib/adapters/ai-elements-adapter.ts index 5cd018052..76be64da9 100644 --- a/src/lib/adapters/ai-elements-adapter.ts +++ b/src/lib/adapters/ai-elements-adapter.ts @@ -14,6 +14,10 @@ import { import { normalizeToolName } from "@/lib/tool-call-normalization" import { feedbackCheckHasContent } from "@/lib/feedback-check" import { isPlanLikeToolName, parseTodosFromJson } from "@/lib/plan-parse" +import { + tokenizeReferenceLinks, + unescapeReferenceLabel, +} from "@/lib/reference-link" /** * Adapted content part types for AI SDK Elements components @@ -132,19 +136,6 @@ export interface UserImageDisplay { } const BLOCKED_RESOURCE_MENTION_RE = /@([^\s@]+)\s*\[blocked[^\]]*\]/gi -// Matches a Markdown link, capturing label + destination. -// -// The label alternative `(?:\\.|[^\]\\])+` accepts backslash escapes inside the -// label: `referenceToMarkdown` backslash-escapes inline punctuation, so a -// filename containing `]` rides in the text as `[a\]b.ts](…)` — a bare `[^\]]+` -// would stop at the escaped `]` and fail to match, dropping the file from the row. -// -// The destination alternative `<[^>]*>` handles CommonMark angle-bracket -// destinations, which `referenceToMarkdown` emits for file URIs containing spaces -// or parentheses (e.g. `[a b.ts]()`): without it the `[^)]*` -// form would stop at the first `)` inside the path, truncating the uri (or, with -// a leading `<`, failing the `file://` test) and dropping the file from the row. -const MARKDOWN_LINK_RE = /\[((?:\\.|[^\]\\])+)\]\((<[^>]*>|[^)]*)\)/g /** * Adapted message format for AI SDK Elements @@ -654,16 +645,6 @@ function sanitizeMentionName(raw: string): string { return raw.replace(/[),.;:!?]+$/g, "") } -/** - * Reverse {@link referenceToMarkdown}'s `escapeMarkdownText`: drop the backslash - * from an escaped inline-punctuation char so a display name reads cleanly. A file - * like `foo (1).ts` is serialized into the text block as `foo \(1\).ts`; the - * resource chip must show `foo (1).ts`, not the backslashes. - */ -function unescapeMarkdownLabel(text: string): string { - return text.replace(/\\([\\`*_~[\]()<>])/g, "$1") -} - // Tidy the prose AFTER resources were lifted/removed, WITHOUT mutating a // `file://` link kept inline (the COPY case). Collapsing internal `[ \t]{2,}` // runs would rewrite a path that legitimately contains consecutive spaces @@ -787,7 +768,7 @@ function handleMarkdownLink( // badge). Other codeg refs are not attachments: inline only. if (normalizedUri.toLowerCase().startsWith("codeg://embedded/")) { addResource(resources, { - name: unescapeMarkdownLabel(normalizedLabel) || "attachment", + name: unescapeReferenceLabel(normalizedLabel) || "attachment", uri: normalizedUri, mime_type: null, }) @@ -807,8 +788,8 @@ function handleMarkdownLink( // through the mention trimming. Only a NON-file `@`-mention gets its `@` // stripped and trailing sentence punctuation trimmed. const name = isFileUri - ? unescapeMarkdownLabel(normalizedLabel) || fileNameFromUri(normalizedUri) - : sanitizeMentionName(unescapeMarkdownLabel(normalizedLabel.slice(1))) || + ? unescapeReferenceLabel(normalizedLabel) || fileNameFromUri(normalizedUri) + : sanitizeMentionName(unescapeReferenceLabel(normalizedLabel.slice(1))) || fileNameFromUri(normalizedUri) addResource(resources, { name, uri: normalizedUri, mime_type: null }) // A real `file://` attachment is COPIED, not moved: it stays inline in the @@ -831,14 +812,17 @@ export function extractUserResourcesFromText(text: string): { // contain an `@…[blocked…]` pattern and be mutated before extraction. The link // segments are handled verbatim by `handleMarkdownLink`. let out = "" - let cursor = 0 - for (const match of text.matchAll(MARKDOWN_LINK_RE)) { - const start = match.index ?? cursor - out += stripBlockedMentions(text.slice(cursor, start), resources) - out += handleMarkdownLink(match[0], match[1], match[2], resources) - cursor = start + match[0].length + for (const token of tokenizeReferenceLinks(text)) { + out += + token.type === "link" + ? handleMarkdownLink( + token.raw, + token.label, + token.destination, + resources + ) + : stripBlockedMentions(token.value, resources) } - out += stripBlockedMentions(text.slice(cursor), resources) return { text: normalizeResourceText(out), diff --git a/src/lib/conversation-title.test.ts b/src/lib/conversation-title.test.ts index 15fb1fdca..493a38ed0 100644 --- a/src/lib/conversation-title.test.ts +++ b/src/lib/conversation-title.test.ts @@ -113,11 +113,11 @@ describe("formatConversationTitle", () => { expect(formatConversationTitle("[[b]](https://x)")).toBe("[b]") }) - it("leaves an unbalanced nested-bracket fragment untouched", () => { - // `[a [b](https://x)` never balances the outer `[`, so it is not a link. - expect(formatConversationTitle("[a [b](https://x)")).toBe( - "[a [b](https://x)" - ) + it("recovers the inner link after an unbalanced outer `[`", () => { + // `[a ` never balances, but the later `[b](https://x)` is still a valid link + // and folds to its label; the stray `[a ` is kept as prose. (The shared + // tokenizer recovers later links instead of giving up at the unmatched `[`.) + expect(formatConversationTitle("[a [b](https://x)")).toBe("[a b") }) it("does not let a backslash escape whitespace in a destination", () => { diff --git a/src/lib/conversation-title.ts b/src/lib/conversation-title.ts index e9cf371a9..16976ea9f 100644 --- a/src/lib/conversation-title.ts +++ b/src/lib/conversation-title.ts @@ -1,146 +1,21 @@ +import { foldReferenceLinks } from "@/lib/reference-link" + /** * A conversation's auto-title is parsed from the first user message, which since * the inline-file-badge work can carry Markdown reference links — a `@`-file * mention, a session/commit/agent reference — serialized as `[label](uri)` (see - * `referenceToMarkdown`). Shown verbatim in a tab or the sidebar that reads as - * raw `[README.md](file:///…)` noise. {@link formatConversationTitle} folds each + * `referenceToMarkdown`). Shown verbatim, a tab or the sidebar reads as raw + * `[README.md](file:///…)` noise. {@link formatConversationTitle} folds each * such link back to just its bracket label (the human-readable badge text), * leaving all other title text untouched, so titles display the way the message * does. Display-only — the stored title (rename, search, export) is unchanged. * - * Implemented as a single forward scan rather than a regex: titles are not - * length-capped on the rename/API paths, and a regex for `[label](dest)` with - * its escaped-label / `<…>`-dest branches backtracks super-linearly on - * pathological input (e.g. thousands of unmatched `[`), which would jank every - * sidebar/tab render. This parser visits each character O(1) times. - */ - -// Reverse `escapeMarkdownText`: drop the backslash from each escaped -// inline-significant punctuation char so the recovered label reads literally. -function unescapeLabel(label: string): string { - return label.replace(/\\([\\`*_~[\]()<>])/g, "$1") -} - -// Whether the backslash at `k` escapes the next character. CommonMark never lets -// a backslash escape a space or line break, so a `\` + whitespace must END (not -// extend) a label/destination scan — only `\` + a non-whitespace char (the -// punctuation we care about: `]`, `>`, `<`, `)`) is a real escape. This keeps a -// malformed `[a](foo\ bar)` or `[a](<…\…>)` correctly left verbatim. -function escapesNext(s: string, k: number): boolean { - return s[k] === "\\" && k + 1 < s.length && !/\s/.test(s[k + 1]) -} - -/** - * If a well-formed `(destination)` begins at `start`, return the index just past - * its closing `)`; otherwise -1. Mirrors `escapeLinkDestination`'s two forms: an - * `<…>`-wrapped destination (interior `\`, `<`, `>` backslash-escaped) or a bare - * run containing no `(`, `)`, whitespace, `<` or `>`. - */ -function destinationEnd(s: string, start: number): number { - const n = s.length - if (start >= n || s[start] !== "(") return -1 - let k = start + 1 - if (s[k] === "<") { - k += 1 - while (k < n) { - const c = s[k] - if (escapesNext(s, k)) { - k += 2 - continue - } - if (c === ">") return s[k + 1] === ")" ? k + 2 : -1 - // CommonMark forbids an unescaped `<` or a line break inside `<…>`, so - // bail on them. This also bounds the scan: a malformed `…](<…` without a - // closing `>` stops at the next `<` instead of running to EOF, which is - // what keeps `"[a](<".repeat(n)` linear rather than quadratic. - if (c === "<" || c === "\n" || c === "\r") return -1 - k += 1 - } - return -1 - } - while (k < n) { - const c = s[k] - if (escapesNext(s, k)) { - k += 2 - continue - } - if (c === ")") return k + 1 - if (c === "(" || c === "<" || c === ">" || /\s/.test(c)) return -1 - k += 1 - } - return -1 -} - -/** - * Replace every `[label](destination)` link in a conversation title with its - * unescaped `label`, so inline badges display as their text instead of raw - * Markdown. Plain prose (including invocation tokens like `@Codex` or `/review`, - * which are not links) is left as-is, as are malformed `[…]`/`(…)` fragments. A - * raw `[text](url)` never belongs in a one-line title, so ordinary links are - * folded too. Returns `""` for a nullish title so callers can keep their - * `formatConversationTitle(title) || untitledFallback` shape. + * The folding itself (a single-pass O(n) scan, ReDoS-safe) lives in the shared + * {@link foldReferenceLinks} so the title, the transcript extractor and every + * other reference-link consumer parse `[label](uri)` exactly one way. */ export function formatConversationTitle( title: string | null | undefined ): string { - if (!title) return "" - const n = title.length - let out = "" - let i = 0 - while (i < n) { - if (title[i] !== "[") { - out += title[i] - i += 1 - continue - } - // Scan the label to the `]` that balances this `[`, skipping escaped pairs - // and tracking nested unescaped brackets so a balanced label closes at the - // right `]` (`[a [b]](u)` folds to `a [b]`, not the inner `[b]`), while an - // unbalanced `[a [b](u)` never closes and is left verbatim. Reference labels - // escape their brackets, so depth only matters for hand-typed nested prose; - // we deliberately don't replicate CommonMark's full nested-link resolution - // (which needs backtracking) — that would forfeit the single-pass O(n) scan. - let j = i + 1 - let depth = 0 - let closed = false - while (j < n) { - const c = title[j] - if (escapesNext(title, j)) { - j += 2 - continue - } - if (c === "[") { - depth += 1 - j += 1 - continue - } - if (c === "]") { - if (depth === 0) { - closed = true - break - } - depth -= 1 - j += 1 - continue - } - j += 1 - } - if (!closed) { - // No `]` remains ahead, so nothing else can be a link either. - out += title.slice(i) - break - } - const end = destinationEnd(title, j + 1) - if (end === -1) { - // `[…]` not followed by a well-formed `(dest)`: emit it literally and - // resume just after `]` — never re-scanning the label, which keeps the - // whole pass O(n) even on adversarial unmatched-bracket input. - out += title.slice(i, j + 1) - i = j + 1 - continue - } - out += unescapeLabel(title.slice(i + 1, j)) - i = end - } - return out + return foldReferenceLinks(title) } diff --git a/src/lib/reference-link.test.ts b/src/lib/reference-link.test.ts new file mode 100644 index 000000000..e70dc509c --- /dev/null +++ b/src/lib/reference-link.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from "vitest" + +import { + buildFileUri, + foldReferenceLinks, + tokenizeReferenceLinks, + unescapeReferenceLabel, +} from "./reference-link" + +describe("buildFileUri", () => { + it("builds a triple-slash uri for a posix path", () => { + expect(buildFileUri("/repo/src/app.ts")).toBe("file:///repo/src/app.ts") + }) + it("normalizes Windows backslashes and encodes the drive segment", () => { + expect(buildFileUri("C:\\repo\\app.ts")).toBe("file:///C%3A/repo/app.ts") + }) + it("percent-encodes spaces, # and ? within segments (not the separators)", () => { + expect(buildFileUri("/a/b c#d?e.ts")).toBe("file:///a/b%20c%23d%3Fe.ts") + }) +}) + +describe("unescapeReferenceLabel", () => { + it("drops the backslash from escaped inline punctuation", () => { + expect(unescapeReferenceLabel("a\\]b\\(c")).toBe("a]b(c") + expect(unescapeReferenceLabel("Screenshot \\(1\\).png")).toBe( + "Screenshot (1).png" + ) + }) + it("leaves unescaped text untouched", () => { + expect(unescapeReferenceLabel("plain name.ts")).toBe("plain name.ts") + }) +}) + +describe("tokenizeReferenceLinks", () => { + it("splits prose around a bare-destination link", () => { + expect( + tokenizeReferenceLinks("look at [foo.ts](file:///x/foo.ts) here") + ).toEqual([ + { type: "text", value: "look at " }, + { + type: "link", + raw: "[foo.ts](file:///x/foo.ts)", + label: "foo.ts", + destination: "file:///x/foo.ts", + }, + { type: "text", value: " here" }, + ]) + }) + + it("keeps the angle brackets in a <…>-wrapped destination", () => { + // The destination must equal the old regex group `(<[^>]*>|[^)]*)` exactly — + // including the `<…>` — so handleMarkdownLink's own unwrap still applies. + expect(tokenizeReferenceLinks("[a b.ts]()")).toEqual([ + { + type: "link", + raw: "[a b.ts]()", + label: "a b.ts", + destination: "", + }, + ]) + }) + + it("accepts an empty destination", () => { + expect(tokenizeReferenceLinks("[a]()")).toEqual([ + { type: "link", raw: "[a]()", label: "a", destination: "" }, + ]) + }) + + it("treats an empty-label [](x) as prose, not a link", () => { + // The serializer never emits an empty label (it uses `label || id`), and the + // historical adapter regex required ≥1 label char — so this stays prose. + expect(tokenizeReferenceLinks("[](x)")).toEqual([ + { type: "text", value: "[](x)" }, + ]) + }) + + it("closes a balanced nested-bracket label at the outer ]", () => { + expect(tokenizeReferenceLinks("[a [b]](url)")).toEqual([ + { type: "link", raw: "[a [b]](url)", label: "a [b]", destination: "url" }, + ]) + }) + + it("recovers the inner link after an unbalanced outer [", () => { + // The stray `[a ` has no balancing `]`, but the later `[b](url)` must still + // be found rather than swallowed by the unmatched opener. + expect(tokenizeReferenceLinks("[a [b](url)")).toEqual([ + { type: "text", value: "[a " }, + { type: "link", raw: "[b](url)", label: "b", destination: "url" }, + ]) + }) + + it("recovers a later file link after stray/unbalanced brackets in prose", () => { + // Regression for the depth-scan giving up at EOF: the trailing file link must + // still be extracted so the transcript chip survives stray prose brackets. + expect( + tokenizeReferenceLinks( + "text [oops [still open] [foo.ts](file:///x/foo.ts)" + ) + ).toEqual([ + { type: "text", value: "text [oops [still open] " }, + { + type: "link", + raw: "[foo.ts](file:///x/foo.ts)", + label: "foo.ts", + destination: "file:///x/foo.ts", + }, + ]) + }) + + it("does not treat ] inside a bare destination as a terminator", () => { + expect( + tokenizeReferenceLinks("open [a\\]b.ts](file:///x/a]b.ts) now") + ).toEqual([ + { type: "text", value: "open " }, + { + type: "link", + raw: "[a\\]b.ts](file:///x/a]b.ts)", + label: "a\\]b.ts", + destination: "file:///x/a]b.ts", + }, + { type: "text", value: " now" }, + ]) + }) + + it("leaves a bare destination with an unescaped space as prose", () => { + // `\` + space is not a real escape; the malformed destination is verbatim. + expect(tokenizeReferenceLinks("[a](foo\\ bar)")).toEqual([ + { type: "text", value: "[a](foo\\ bar)" }, + ]) + }) + + it("leaves an unterminated link as prose", () => { + expect(tokenizeReferenceLinks("[oops no close](file:///x")).toEqual([ + { type: "text", value: "[oops no close](file:///x" }, + ]) + }) + + it("reconstructs the exact input from its tokens", () => { + const inputs = [ + "plain text only", + "look at [foo.ts](file:///x/foo.ts) here", + "compare [a](file:///a) and [b]()", + "[a [b]](u) trailing", + "[a](foo\\ bar) [ok](file:///ok)", + ] + for (const input of inputs) { + const rebuilt = tokenizeReferenceLinks(input) + .map((t) => (t.type === "link" ? t.raw : t.value)) + .join("") + expect(rebuilt).toBe(input) + } + }) + + it("stays linear on adversarial unmatched-bracket input (ReDoS guard)", () => { + // A backtracking regex would go quadratic here; the single forward scan + // must finish effectively instantly. + const open = "[".repeat(100_000) + expect(tokenizeReferenceLinks(open)).toEqual([ + { type: "text", value: open }, + ]) + const partial = "[a](<".repeat(100_000) + const rebuilt = tokenizeReferenceLinks(partial) + .map((t) => (t.type === "link" ? t.raw : t.value)) + .join("") + expect(rebuilt).toBe(partial) + }) +}) + +describe("foldReferenceLinks", () => { + it("returns '' for nullish input", () => { + expect(foldReferenceLinks(null)).toBe("") + expect(foldReferenceLinks(undefined)).toBe("") + expect(foldReferenceLinks("")).toBe("") + }) + + it("folds links to their unescaped labels and keeps surrounding prose", () => { + expect( + foldReferenceLinks("看看 [README.md](file:///Users/x/README.md) 这是什么") + ).toBe("看看 README.md 这是什么") + expect( + foldReferenceLinks( + "[Screenshot \\(1\\).png]()" + ) + ).toBe("Screenshot (1).png") + }) + + it("leaves malformed fragments and non-link tokens verbatim", () => { + expect(foldReferenceLinks("@Codex /review [oops](file:///x")).toBe( + "@Codex /review [oops](file:///x" + ) + }) +}) diff --git a/src/lib/reference-link.ts b/src/lib/reference-link.ts new file mode 100644 index 000000000..24eb4573d --- /dev/null +++ b/src/lib/reference-link.ts @@ -0,0 +1,209 @@ +/** + * Canonical, framework-agnostic codec for inline reference links — the + * `[label](destination)` form produced by `referenceToMarkdown` (file / session + * / commit / agent mentions) and folded by the backend's + * `user_blocks_from_prompt`. + * + * This module is the single source of truth for three operations that used to be + * re-implemented per consumer (and had drifted apart): + * - building a `file://` uri from a path ({@link buildFileUri}), + * - reversing the serializer's label escaping ({@link unescapeReferenceLabel}), + * - parsing a raw string into prose/link tokens ({@link tokenizeReferenceLinks}) + * and folding those links to their labels ({@link foldReferenceLinks}). + * + * The tokenizer is a single forward scan that visits each character O(1) times — + * a regex for `[label](dest)` with its escaped-label / `<…>`-dest branches + * backtracks super-linearly on pathological input (e.g. thousands of unmatched + * `[`), which would jank every sidebar/tab render and the transcript pipeline. + */ + +/** + * Build a `file://` uri from an absolute path (POSIX or Windows), percent- + * encoding each path segment so spaces / `#` / `?` / `%` can't corrupt the uri. + * A POSIX path (leading `/`) yields `file://`; anything else (a Windows + * `C:\…` path) yields `file:///` so the drive segment is encoded. + */ +export function buildFileUri(absolutePath: string): string { + const normalized = absolutePath.replace(/\\/g, "/") + const encoded = normalized.split("/").map(encodeURIComponent).join("/") + return normalized.startsWith("/") ? `file://${encoded}` : `file:///${encoded}` +} + +/** + * Reverse `escapeMarkdownText` (reference-text.ts): drop the backslash from each + * escaped inline-significant punctuation char so the recovered label reads + * literally. The character class mirrors the serializer's exactly. + */ +export function unescapeReferenceLabel(label: string): string { + return label.replace(/\\([\\`*_~[\]()<>])/g, "$1") +} + +/** + * Whether the backslash at `k` escapes the next character. CommonMark never lets + * a backslash escape a space or line break, so a `\` + whitespace must END (not + * extend) a label/destination scan — only `\` + a non-whitespace char (the + * punctuation we care about: `]`, `>`, `<`, `)`) is a real escape. This keeps a + * malformed `[a](foo\ bar)` or `[a](<…\…>)` correctly left verbatim. + */ +function escapesNext(s: string, k: number): boolean { + return s[k] === "\\" && k + 1 < s.length && !/\s/.test(s[k + 1]) +} + +/** + * If a well-formed `(destination)` begins at `start`, return the index just past + * its closing `)`; otherwise -1. Mirrors `escapeLinkDestination`'s two forms: an + * `<…>`-wrapped destination (interior `\`, `<`, `>` backslash-escaped) or a bare + * run containing no `(`, `)`, whitespace, `<` or `>`. + */ +function destinationEnd(s: string, start: number): number { + const n = s.length + if (start >= n || s[start] !== "(") return -1 + let k = start + 1 + if (s[k] === "<") { + k += 1 + while (k < n) { + const c = s[k] + if (escapesNext(s, k)) { + k += 2 + continue + } + if (c === ">") return s[k + 1] === ")" ? k + 2 : -1 + // CommonMark forbids an unescaped `<` or a line break inside `<…>`, so + // bail on them. This also bounds the scan: a malformed `…](<…` without a + // closing `>` stops at the next `<` instead of running to EOF, which is + // what keeps `"[a](<".repeat(n)` linear rather than quadratic. + if (c === "<" || c === "\n" || c === "\r") return -1 + k += 1 + } + return -1 + } + while (k < n) { + const c = s[k] + if (escapesNext(s, k)) { + k += 2 + continue + } + if (c === ")") return k + 1 + if (c === "(" || c === "<" || c === ">" || /\s/.test(c)) return -1 + k += 1 + } + return -1 +} + +/** A reference-link tokenizer token: a run of prose or a parsed link. */ +export type ReferenceLinkToken = + | { type: "text"; value: string } + | { + type: "link" + /** The full `[label](destination)` substring, verbatim. */ + raw: string + /** The bracket text, still escaped (consumers unescape as needed). */ + label: string + /** + * The destination exactly as written between the parens — a bare uri, or a + * `<…>`-wrapped uri including its angle brackets. Consumers strip the + * `<…>` / test the scheme themselves. + */ + destination: string + } + +/** + * Split `text` into an ordered list of prose and `[label](destination)` link + * tokens. Every character of the input appears in exactly one token, so + * `tokens.map(raw-or-value).join("")` reconstructs the original string. + * + * Single O(n) left-to-right scan over a stack of unmatched `[` positions. A `]` + * is matched against the most recent open `[`, so a balanced nested label closes + * at the right bracket (`[a [b]](u)` → label `a [b]`). When that pair is followed + * by a well-formed `(dest)` and the label is non-empty it becomes a link; + * otherwise the `[` was not a link opener and the scan keeps going — so a + * stray/unbalanced `[` in prose never hides a later valid link + * (`text [oops [x](file://…)` still yields the `[x](…)` link). Escaped brackets + * (`\[`, `\]`) are skipped and never open or close. A non-empty label is required + * — the serializer emits `[label || id](uri)` and `id` is never empty, and the + * historical extractor ignored empty-label links too. + * + * ReDoS-safe: each character is visited O(1) times, and each `]` triggers at + * most one `destinationEnd` probe which bails at the next delimiter + * (`(`/`)`/`<`/`>`/whitespace), so adversarial bracket/paren runs stay linear. + */ +export function tokenizeReferenceLinks(text: string): ReferenceLinkToken[] { + const tokens: ReferenceLinkToken[] = [] + const n = text.length + // Start of the pending prose run; flushed as one text token before each link + // (and at the end), so prose is never emitted character-by-character. + let textStart = 0 + // Indices of `[` seen but not yet matched by a `]` (most recent on top). + const openers: number[] = [] + let i = 0 + + const flushTextBefore = (end: number) => { + if (end > textStart) { + tokens.push({ type: "text", value: text.slice(textStart, end) }) + } + } + + while (i < n) { + if (escapesNext(text, i)) { + // `\[` / `\]` (and any `\x`) is literal — skip both chars so an escaped + // bracket never opens or closes a label. + i += 2 + continue + } + const c = text[i] + if (c === "[") { + openers.push(i) + i += 1 + continue + } + if (c === "]" && openers.length > 0) { + const open = openers.pop() as number + const end = destinationEnd(text, i + 1) + // A link needs a well-formed `(dest)` right after `]` and a non-empty label + // between the brackets. + if (end !== -1 && i > open + 1) { + flushTextBefore(open) + // `i` is the closing `]`, so `i + 1` is `(` and `i + 2` is the first + // destination char; `end - 1` is the closing `)`. + tokens.push({ + type: "link", + raw: text.slice(open, end), + label: text.slice(open + 1, i), + destination: text.slice(i + 2, end - 1), + }) + // Everything up to `open` is committed (flushed as text or consumed by + // this link), so any still-open outer `[` can no longer span a link. + openers.length = 0 + i = end + textStart = end + continue + } + // Not a link: the brackets stay in the pending prose run; keep scanning so + // a later well-formed link is still found. + i += 1 + continue + } + i += 1 + } + flushTextBefore(n) + return tokens +} + +/** + * Replace every `[label](destination)` link in `text` with its unescaped + * `label`, so inline references display as their text instead of raw Markdown. + * Plain prose (including invocation tokens like `@Codex` or `/review`, which are + * not links) is left as-is, as are malformed `[…]`/`(…)` fragments. A raw + * `[text](url)` never belongs in a one-line title, so ordinary links fold too. + * Returns `""` for a nullish input so callers can keep their + * `foldReferenceLinks(title) || untitledFallback` shape. + */ +export function foldReferenceLinks(text: string | null | undefined): string { + if (!text) return "" + let out = "" + for (const token of tokenizeReferenceLinks(text)) { + out += + token.type === "link" ? unescapeReferenceLabel(token.label) : token.value + } + return out +} From 01ad20b7f604f37bcaf35484c3369cd70f5f7cf7 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sun, 14 Jun 2026 20:04:11 +0800 Subject: [PATCH 30/31] feat(appearance): default the interface font to System Monospace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interface font now defaults to System Monospace, joining the editor and terminal so all three font targets in appearance settings share the same monospace stack out of the box. The interface picker still offers every sans and mono option, so the former System UI default stays one click away. The :root --font-sans fallback carries the monospace stack as well, so the first paint matches the resolved default with no flash, and the pre-hydration script only applies a cached interface font stack when an explicit selection exists — users who never picked a font follow the new default while explicit choices are preserved. --- src/app/globals.css | 11 ++++++----- src/lib/appearance-script.ts | 8 ++++++-- src/lib/font-presets.test.ts | 6 +++--- src/lib/font-presets.ts | 9 +++++---- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index b9c9c423e..97c5b80e8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -11,11 +11,12 @@ @custom-variant dark (&:is(.dark *)); :root { - /* 默认界面字体 = 系统 UI 字体,运行时由 AppearanceProvider 覆盖 --font-sans。 - 会话消息区等普通文本均跟随 --font-sans;--font-mono 不在此自定义, - 由 Tailwind 默认等宽栈兜底(与代码编辑器字体相互独立)。 */ - --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", - Arial, sans-serif; + /* 默认界面字体 = 系统等宽字体(与 DEFAULT_UI_FONT_ID = "system-mono" 一致, + 须等于 font-presets.ts 的 MONO_FALLBACK,否则首屏到水合会闪字),运行时由 + AppearanceProvider 覆盖 --font-sans。会话消息区等普通文本均跟随 --font-sans; + --font-mono 不在此自定义,由 Tailwind 默认等宽栈兜底(与代码编辑器字体相互独立)。 */ + --font-sans: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; font-family: var(--font-sans); font-size: 16px; diff --git a/src/lib/appearance-script.ts b/src/lib/appearance-script.ts index 633b81bd1..4682fd1e2 100644 --- a/src/lib/appearance-script.ts +++ b/src/lib/appearance-script.ts @@ -50,9 +50,13 @@ const SCRIPT = ` document.documentElement.style.fontSize = (16 * zoom / 100) + "px"; // 界面字体:预水合写入 --font-sans(普通组件与会话消息区都跟随它)。 - // 仅写入已解析的 stack,无需在脚本里复制字体目录;空/超长/含越界字符则跳过走默认。 + // stack 只是「显式选择」的缓存,不是偏好本身:仅当存在显式 id(codeg-ui-font) + // 时才应用它。无显式选择的用户(含从旧默认升级、Provider 仅缓存过 stack 的用户) + // 跳过,落到 :root 的 --font-sans 兜底(= 当前默认 MONO_FALLBACK),避免升级首屏闪字。 + // 无需在脚本里复制字体目录;空/超长/含越界字符同样跳过走默认。 + var uiFontId = localStorage.getItem("${STORAGE_KEY_UI_FONT}"); var uiFontStack = localStorage.getItem("${STORAGE_KEY_UI_FONT_STACK}"); - if (uiFontStack && uiFontStack.length < 512 && !/[;{}<>]/.test(uiFontStack)) { + if (uiFontId && uiFontStack && uiFontStack.length < 512 && !/[;{}<>]/.test(uiFontStack)) { document.documentElement.style.setProperty("--font-sans", uiFontStack); } diff --git a/src/lib/font-presets.test.ts b/src/lib/font-presets.test.ts index 7c067b46d..e52de5a1f 100644 --- a/src/lib/font-presets.test.ts +++ b/src/lib/font-presets.test.ts @@ -65,9 +65,9 @@ describe("defaults", () => { } }) - it("default UI font is the system sans font", () => { - expect(DEFAULT_UI_FONT_ID).toBe("system-ui") - expect(FONT_BY_ID[DEFAULT_UI_FONT_ID].category).toBe("sans") + it("default UI font is the system monospace font", () => { + expect(DEFAULT_UI_FONT_ID).toBe("system-mono") + expect(FONT_BY_ID[DEFAULT_UI_FONT_ID].category).toBe("mono") expect(FONT_BY_ID[DEFAULT_UI_FONT_ID].source).toBe("system") }) diff --git a/src/lib/font-presets.ts b/src/lib/font-presets.ts index 97b6c99a5..dea344957 100644 --- a/src/lib/font-presets.ts +++ b/src/lib/font-presets.ts @@ -90,7 +90,7 @@ export const FONT_BY_ID: Record = Object.fromEntries( FONTS.map((f) => [f.id, f]) ) -/** 界面字体可选项:无衬线 + 等宽全部允许(当前默认 JetBrains Mono 即等宽)。 */ +/** 界面字体可选项:无衬线 + 等宽全部允许(当前默认 System Monospace 即等宽)。 */ export const UI_FONTS: readonly FontDef[] = FONTS /** 编辑器 / 终端字体可选项:仅等宽。 */ export const MONO_FONTS: readonly FontDef[] = FONTS.filter( @@ -98,11 +98,12 @@ export const MONO_FONTS: readonly FontDef[] = FONTS.filter( ) /** - * 默认:界面 = 系统 UI 字体(原生观感,会话消息区也跟随它), - * 编辑器/终端 = 系统等宽字体。 + * 默认:界面 / 编辑器 / 终端三者均为系统等宽字体(会话消息区也跟随界面字体)。 * 编辑器字体只作用于代码编辑器(Monaco),不影响界面与消息区。 + * 注意:界面默认改动须与 globals.css 的 :root --font-sans 兜底栈保持一致, + * 否则首屏到水合之间会闪字(inline 脚本无存储值时回退到该 CSS 兜底)。 */ -export const DEFAULT_UI_FONT_ID = "system-ui" +export const DEFAULT_UI_FONT_ID = "system-mono" export const DEFAULT_EDITOR_FONT_ID = "system-mono" export const DEFAULT_TERMINAL_FONT_ID = "system-mono" From 1670ebd3de4b6a682a1d0d233a656b6b5926c3b5 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sun, 14 Jun 2026 22:27:14 +0800 Subject: [PATCH 31/31] feat(editor): add selected lines to the conversation as a line-range badge Select lines in the file editor and send them to the composer as an inline file badge (e.g. foo.ts:10-25) via the context menu, Cmd/Ctrl+L, or a floating Add to Chat pill. The badge serializes to a file:// link with an #L- fragment and opens to the start line when clicked. --- .../ai-elements/link-safety.test.tsx | 13 + src/components/ai-elements/link-safety.tsx | 4 +- src/components/chat/message-input.tsx | 34 +- src/components/files/file-workspace-panel.tsx | 324 +++++++++++++++++- src/i18n/messages/ar.json | 3 + src/i18n/messages/de.json | 3 + src/i18n/messages/en.json | 3 + src/i18n/messages/es.json | 3 + src/i18n/messages/fr.json | 3 + src/i18n/messages/ja.json | 3 + src/i18n/messages/ko.json | 3 + src/i18n/messages/pt.json | 3 + src/i18n/messages/zh-CN.json | 3 + src/i18n/messages/zh-TW.json | 3 + src/lib/reference-link.test.ts | 68 ++++ src/lib/reference-link.ts | 51 +++ src/lib/session-attachment-events.ts | 7 + 17 files changed, 522 insertions(+), 9 deletions(-) diff --git a/src/components/ai-elements/link-safety.test.tsx b/src/components/ai-elements/link-safety.test.tsx index 2a1250f82..c20530fff 100644 --- a/src/components/ai-elements/link-safety.test.tsx +++ b/src/components/ai-elements/link-safety.test.tsx @@ -127,6 +127,19 @@ describe("link safety direct opening", () => { expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument() }) + it("jumps to the start line of a ranged file link (#L-)", async () => { + render() + + fireEvent.click(screen.getByRole("button", { name: "Trigger link" })) + + await waitFor(() => { + expect(mocks.openFilePreview).toHaveBeenCalledWith("src/app.ts", { + line: 10, + }) + }) + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument() + }) + it("blocks unsupported markdown link protocols without rendering a confirmation dialog", async () => { render() diff --git a/src/components/ai-elements/link-safety.tsx b/src/components/ai-elements/link-safety.tsx index 61aaf643f..063907392 100644 --- a/src/components/ai-elements/link-safety.tsx +++ b/src/components/ai-elements/link-safety.tsx @@ -60,8 +60,10 @@ function parseLineValue(raw: string | undefined): number | null { function parseHashLine(hash: string): number | null { const normalized = hash.startsWith("#") ? hash.slice(1) : hash if (!normalized) return null + // `L` / `L-` / `L-L` (GitHub-style) — a range + // (e.g. the editor's "add selection" badge `#L10-25`) jumps to its start line. return ( - parseLineValue(normalized.match(/^L(\d+)$/i)?.[1]) ?? + parseLineValue(normalized.match(/^L(\d+)(?:-L?\d+)?$/i)?.[1]) ?? parseLineValue(normalized.match(/^line=(\d+)$/i)?.[1]) ?? parseLineValue(normalized.match(/^(\d+)$/)?.[1]) ) diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 9f9f485e3..0c62c2420 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -38,7 +38,11 @@ import { import { ImagePreviewDialog } from "@/components/ui/image-preview-dialog" import { AgentIcon } from "@/components/agent-icon" import { cn, randomUUID } from "@/lib/utils" -import { buildFileUri } from "@/lib/reference-link" +import { + buildFileUri, + buildFileUriWithRange, + formatFileRangeLabel, +} from "@/lib/reference-link" import { filesFromClipboard, clipboardHasText, @@ -1106,6 +1110,25 @@ export function MessageInput({ [appendResourceLinks] ) + // Attach a single file as a ranged badge (`foo.ts:10-25`), used by the file + // editor's "add selection to chat". The line span is encoded into both the + // label and the uri fragment (`file://…#L10-25`), so distinct selections of + // the same file stay distinct (the uri is the dedupe key in + // `insertFileReferences`) and the range rides along to the agent in the + // serialized `[label](uri)` link. + const appendFileRangeAttachment = useCallback( + (path: string, range: { start: number; end: number }) => { + if (!path) return + insertFileReferences([ + { + name: formatFileRangeLabel(fileNameFromPath(path), range), + uri: buildFileUriWithRange(path, range), + }, + ]) + }, + [insertFileReferences] + ) + // Shared upload pool used by the menu's "Upload local file" button, // browser drag-drop in web mode, paste in web mode, and the fallback // path of `appendFilesAsResources` for remote-desktop. Splits oversize @@ -1784,14 +1807,19 @@ export function MessageInput({ const customEvent = event as CustomEvent if (!customEvent.detail) return if (customEvent.detail.tabId !== attachmentTabId) return - appendResourceAttachments([customEvent.detail.path]) + const { path, range } = customEvent.detail + if (range) { + appendFileRangeAttachment(path, range) + } else { + appendResourceAttachments([path]) + } } window.addEventListener(ATTACH_FILE_TO_SESSION_EVENT, handleAttachFile) return () => { window.removeEventListener(ATTACH_FILE_TO_SESSION_EVENT, handleAttachFile) } - }, [appendResourceAttachments, attachmentTabId]) + }, [appendResourceAttachments, appendFileRangeAttachment, attachmentTabId]) useEffect(() => { if (!attachmentTabId) return diff --git a/src/components/files/file-workspace-panel.tsx b/src/components/files/file-workspace-panel.tsx index 6941a4185..b228b74fa 100644 --- a/src/components/files/file-workspace-panel.tsx +++ b/src/components/files/file-workspace-panel.tsx @@ -3,9 +3,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import dynamic from "next/dynamic" import { ChevronDown, ChevronRight, FileCode2, FileIcon } from "lucide-react" -import type { editor as MonacoEditorNs } from "monaco-editor" +import type { + editor as MonacoEditorNs, + IDisposable, + IPosition, +} from "monaco-editor" +import type { Monaco, OnMount } from "@monaco-editor/react" +import { toast } from "sonner" import { useTranslations } from "next-intl" import { useActiveFolder } from "@/contexts/active-folder-context" +import { useTabContext } from "@/contexts/tab-context" +import { emitAttachFileToSession } from "@/lib/session-attachment-events" +import { formatFileRangeLabel } from "@/lib/reference-link" +import { joinFsPath } from "@/lib/path-utils" import { useWorkspaceContext, type FileWorkspaceTab, @@ -207,6 +217,77 @@ function buildMonacoModelPath(path: string | null, id: string): string { return `file:///${encoded}` } +interface AddToChatPill { + widget: MonacoEditorNs.IContentWidget + setVisible: (visible: boolean, position: IPosition | null) => void + /** Re-read the label (e.g. after a locale change) even while already shown. */ + refreshLabel: () => void +} + +/** + * The floating "Add to Chat" pill shown next to a text selection, built as a + * Monaco content widget. Raw DOM (not React) so it lives in Monaco's own + * overflow layer; `allowEditorOverflow` keeps it un-clipped at the viewport edge + * and during horizontal scroll, and `suppressMouseDown` stops a click from + * collapsing the selection before `addSelectionToChat` reads it. `getPosition` + * returns null while hidden — Monaco unmounts the widget on a null position, so + * visibility is driven entirely through {@link AddToChatPill.setVisible} + + * `layoutContentWidget`. + */ +function createAddToChatPill( + monaco: Monaco, + onActivate: () => void, + getLabel: () => string +): AddToChatPill { + const dom = document.createElement("button") + dom.type = "button" + dom.className = + "codeg-add-to-chat-pill inline-flex items-center gap-1 rounded-md border border-border bg-popover px-2 py-0.5 text-xs font-medium text-popover-foreground shadow-md cursor-pointer select-none hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50" + // Hand-written SVG (lucide "message-square-plus"): raw DOM can't host a React + // lucide component. `currentColor` + the blue text class echoes the file badge. + dom.innerHTML = + '' + const labelSpan = dom.querySelector("span") + if (labelSpan) labelSpan.textContent = getLabel() + dom.addEventListener("click", (event) => { + event.preventDefault() + event.stopPropagation() + onActivate() + }) + + let visible = false + let position: IPosition | null = null + + const widget: MonacoEditorNs.IContentWidget = { + getId: () => "codeg.addToChatPill", + getDomNode: () => dom, + getPosition: () => + visible && position + ? { + position, + preference: [ + monaco.editor.ContentWidgetPositionPreference.ABOVE, + monaco.editor.ContentWidgetPositionPreference.BELOW, + ], + } + : null, + allowEditorOverflow: true, + suppressMouseDown: true, + } + + return { + widget, + setVisible: (next, pos) => { + visible = next + position = pos + if (next && labelSpan) labelSpan.textContent = getLabel() + }, + refreshLabel: () => { + if (labelSpan) labelSpan.textContent = getLabel() + }, + } +} + // True once a tab has *something* to render. Drives the rendering predicate // so that a refresh on a loaded tab keeps the previous content visible // (and the loading state is signalled by the subtle top-right badge), @@ -784,12 +865,36 @@ export function FileWorkspacePanel() { saveActiveFile, updateActiveFileContent, } = useWorkspaceContext() + const { tabs, activeTabId } = useTabContext() const { activeFolder: folder } = useActiveFolder() const folderPath = folder?.path ?? null const activeScope = activeFileTab?.id ?? "__default__" const editorRef = useRef(null) const cursorListenerRef = useRef<{ dispose: () => void } | null>(null) const gitChangeDecorationsRef = useRef([]) + // "Add selection to chat" plumbing. The Monaco action + content widget are + // registered once at mount (the editor instance persists across file-tab + // switches), so everything they read at activation time lives in refs to dodge + // stale closures: the resolved target (folder/file/session), the attachable + // flag (also mirrored into a Monaco context key that gates the triggers), + // translations, and the disposables torn down on editor disposal. + const attachContextRef = useRef<{ + folderPath: string | null + filePath: string | null + fileName: string + sessionTabId: string | null + }>({ folderPath: null, filePath: null, fileName: "", sessionTabId: null }) + const attachableRef = useRef(false) + const attachableKeyRef = useRef | null>( + null + ) + const addToChatActionRef = useRef(null) + const addToChatPillRef = useRef(null) + const selectionListenerRef = useRef(null) + const focusListenerRef = useRef(null) + const blurListenerRef = useRef(null) + const tRef = useRef(t) + const monacoRef = useRef(null) const editorTheme = useMonacoThemeSync() const { zoomLevel } = useZoomLevel() const { editorFontStack, editorFontSize, editorLigatures } = useEditorFont() @@ -807,6 +912,154 @@ export function FileWorkspacePanel() { const fileSaveState = isFileTab ? (activeFileTab.saveState ?? "idle") : "idle" const fileIsDirty = isFileTab ? Boolean(activeFileTab.isDirty) : false const canEdit = isFileTab && !fileReadonly + // The conversation a selection attaches to: the active top-bar tab when it is + // a conversation (mirrors aux-panel-file-tree-tab's "Attach to Current + // Session"). Null when no conversation is focused. + const activeSessionTabId = useMemo(() => { + const activeTab = tabs.find((tab) => tab.id === activeTabId) + return activeTab && activeTab.kind === "conversation" ? activeTab.id : null + }, [tabs, activeTabId]) + // Gate every trigger (menu item, ⌘L, pill) on a real file tab with a + // resolvable absolute path AND a conversation to receive it. The same Monaco + // instance also renders some diff tabs, so this guard is required. + const isAttachable = + isFileTab && + Boolean(activeFileTab?.path) && + Boolean(folderPath) && + Boolean(activeSessionTabId) + + // Read the live selection and emit it to the composer as a ranged file badge. + // Reads only refs so it stays stable for the once-registered Monaco action. + const addSelectionToChat = useCallback(() => { + const editor = editorRef.current + const ctx = attachContextRef.current + if (!editor || !ctx.sessionTabId || !ctx.folderPath || !ctx.filePath) return + const selection = editor.getSelection() + // The single gate for the empty case: the action's precondition claims ⌘L + // even with no selection (to swallow the built-in expandLineSelection), so + // every trigger (menu, ⌘L, pill, programmatic) silently no-ops here when + // nothing is selected — never an accidental current-line badge. + if (!selection || selection.isEmpty()) return + const start = selection.startLineNumber + let end = selection.endLineNumber + // A full-line drag ends at column 1 of the line below the last selected + // line; trim that trailing boundary so selecting lines 10–25 yields 10-25. + if (end > start && selection.endColumn === 1) end -= 1 + if (end < start) end = start + const range = { start, end } + emitAttachFileToSession({ + tabId: ctx.sessionTabId, + path: joinFsPath(ctx.folderPath, ctx.filePath), + range, + }) + toast( + tRef.current("addSelectionToChatDone", { + label: formatFileRangeLabel(ctx.fileName, range), + }) + ) + }, []) + + // Register (or re-register) the context-menu action + ⌘L. Re-registerable so + // the label can follow an in-app locale change — Monaco caches an action's + // label at registration time, and the editor instance outlives a tab switch. + const registerAddToChatAction = useCallback( + ( + editor: MonacoEditorNs.IStandaloneCodeEditor, + monaco: Monaco, + label: string + ) => { + addToChatActionRef.current?.dispose() + addToChatActionRef.current = editor.addAction({ + id: "codeg.addSelectionToChat", + label, + contextMenuGroupId: "navigation", + contextMenuOrder: 1.5, + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyL], + // Gate on attachability only — NOT `editorHasSelection`. Monaco's + // addAction drives the context menu, the F1 palette, AND the keybinding + // from this one precondition. Claiming ⌘L even without a selection is + // deliberate: it stops ⌘L from falling through to the built-in + // `expandLineSelection` (which would let a second ⌘L attach the current + // line), honoring the user's choice of ⌘L as "add to chat". The empty + // case is handled in `run` by `addSelectionToChat`'s `selection.isEmpty` + // guard, so the action/menu/⌘L silently no-op with nothing selected — + // no accidental badge. (Gating the menu on selection would also ungate + // the keybinding, reintroducing the fall-through; a second keybinding- + // only action would resurface as a no-op F1 palette entry.) + precondition: "codegSelectionAttachable", + run: () => addSelectionToChat(), + }) + }, + [addSelectionToChat] + ) + + // Single teardown for the action, listeners, pill widget, and context key — + // called from both the editor's onDidDispose and the React unmount effect. + // `removeContentWidget` guards its view call internally, so it's a safe no-op + // once disposed; the optional editor arg lets onDidDispose target the exact + // disposing instance rather than a possibly-reassigned `editorRef`. + const teardownAddToChat = useCallback( + (editor?: MonacoEditorNs.IStandaloneCodeEditor | null) => { + const target = editor ?? editorRef.current + addToChatActionRef.current?.dispose() + addToChatActionRef.current = null + selectionListenerRef.current?.dispose() + selectionListenerRef.current = null + focusListenerRef.current?.dispose() + focusListenerRef.current = null + blurListenerRef.current?.dispose() + blurListenerRef.current = null + if (addToChatPillRef.current) { + target?.removeContentWidget(addToChatPillRef.current.widget) + addToChatPillRef.current = null + } + attachableKeyRef.current = null + }, + [] + ) + + // Keep the activation refs + Monaco context key in sync with React state, and + // hide the pill the moment the tab stops being attachable. + useEffect(() => { + tRef.current = t + attachContextRef.current = { + folderPath, + filePath: activeFileTab?.path ?? null, + fileName: activeFileTab?.title ?? "", + sessionTabId: activeSessionTabId, + } + attachableRef.current = isAttachable + attachableKeyRef.current?.set(isAttachable) + if (!isAttachable && editorRef.current && addToChatPillRef.current) { + addToChatPillRef.current.setVisible(false, null) + editorRef.current.layoutContentWidget(addToChatPillRef.current.widget) + } + }, [ + t, + folderPath, + activeFileTab?.path, + activeFileTab?.title, + activeSessionTabId, + isAttachable, + ]) + + // Refresh the cached action label when the locale changes. The initial + // registration happens in handleEditorMount (the editor isn't mounted yet on + // first render); this only re-fires once the label string actually changes. + const addSelectionLabel = t("addSelectionToChat") + useEffect(() => { + // The sync effect above runs first, so tRef.current is the fresh locale here + // — refresh the (registration-cached) action label and the live pill label. + if (editorRef.current && monacoRef.current) { + registerAddToChatAction( + editorRef.current, + monacoRef.current, + addSelectionLabel + ) + } + addToChatPillRef.current?.refreshLabel() + }, [addSelectionLabel, registerAddToChatAction]) + const autoSaveTimerRef = useRef | null>(null) const autoSaveGuardRef = useRef({ canEdit: false, @@ -1024,8 +1277,8 @@ export function FileWorkspacePanel() { diffOutline, ]) - const handleEditorMount = useCallback( - (editorInstance: MonacoEditorNs.IStandaloneCodeEditor) => { + const handleEditorMount: OnMount = useCallback( + (editorInstance, monaco) => { editorRef.current = editorInstance cursorListenerRef.current?.dispose() cursorListenerRef.current = editorInstance.onDidChangeCursorPosition( @@ -1038,6 +1291,58 @@ export function FileWorkspacePanel() { applyHiddenAreas() applyGitChangeDecorations() + // --- "Add selection to chat": context-menu action, ⌘L shortcut, and the + // floating pill. All three are gated by the `codegSelectionAttachable` + // context key so they only appear for a real file tab with an active + // conversation to attach to. + const attachableKey = editorInstance.createContextKey( + "codegSelectionAttachable", + false + ) + attachableKeyRef.current = attachableKey + attachableKey.set(attachableRef.current) + + monacoRef.current = monaco + registerAddToChatAction( + editorInstance, + monaco, + tRef.current("addSelectionToChat") + ) + const pill = createAddToChatPill( + monaco, + () => addSelectionToChat(), + () => tRef.current("addToChat") + ) + addToChatPillRef.current = pill + editorInstance.addContentWidget(pill.widget) + + const refreshPill = () => { + const selection = editorInstance.getSelection() + const show = + attachableRef.current && + editorInstance.hasTextFocus() && + Boolean(selection) && + !selection?.isEmpty() + pill.setVisible( + show, + show && selection ? selection.getStartPosition() : null + ) + editorInstance.layoutContentWidget(pill.widget) + } + selectionListenerRef.current?.dispose() + selectionListenerRef.current = + editorInstance.onDidChangeCursorSelection(refreshPill) + focusListenerRef.current?.dispose() + focusListenerRef.current = + editorInstance.onDidFocusEditorText(refreshPill) + blurListenerRef.current?.dispose() + blurListenerRef.current = editorInstance.onDidBlurEditorText(() => { + pill.setVisible(false, null) + editorInstance.layoutContentWidget(pill.widget) + }) + + editorInstance.onDidDispose(() => teardownAddToChat(editorInstance)) + // Set CSS custom properties so hover tooltips can use position:fixed // to escape overflow:hidden clipping on ancestor elements. const dom = editorInstance.getContainerDomNode() @@ -1053,7 +1358,13 @@ export function FileWorkspacePanel() { editorInstance.onDidDispose(() => ro.disconnect()) } }, - [applyGitChangeDecorations, applyHiddenAreas] + [ + addSelectionToChat, + registerAddToChatAction, + teardownAddToChat, + applyGitChangeDecorations, + applyHiddenAreas, + ] ) const jumpToLine = useCallback((lineNumber: number) => { @@ -1244,8 +1555,11 @@ export function FileWorkspacePanel() { gitChangeDecorationsRef.current = [] cursorListenerRef.current?.dispose() cursorListenerRef.current = null + // Full add-to-chat teardown (action, listeners, pill widget, context key) + // in case React unmounts the panel before the editor's own onDidDispose. + teardownAddToChat() }, - [] + [teardownAddToChat] ) if (!activeFileTab) { diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 56c5424a9..7ef4221e7 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1175,6 +1175,9 @@ "recentOpen": "المفتوح مؤخرًا" }, "fileWorkspacePanel": { + "addSelectionToChat": "إضافة التحديد إلى المحادثة", + "addToChat": "إضافة إلى المحادثة", + "addSelectionToChatDone": "تمت إضافة {label} إلى المحادثة", "viewDiff": "عرض Diff", "openFile": "فتح الملف", "fileCount": "{count, plural, one {# ملف} other {# ملفات}}", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 7d994e25b..5f3b3cc2e 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1175,6 +1175,9 @@ "recentOpen": "Zuletzt geöffnet" }, "fileWorkspacePanel": { + "addSelectionToChat": "Auswahl zum Chat hinzufügen", + "addToChat": "Zum Chat hinzufügen", + "addSelectionToChatDone": "{label} zur Unterhaltung hinzugefügt", "viewDiff": "Diff anzeigen", "openFile": "Datei öffnen", "fileCount": "{count, plural, one {# Datei} other {# Dateien}}", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 246472abb..c4d3391cf 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1175,6 +1175,9 @@ "recentOpen": "Recently Opened" }, "fileWorkspacePanel": { + "addSelectionToChat": "Add Selection to Chat", + "addToChat": "Add to Chat", + "addSelectionToChatDone": "Added {label} to the conversation", "viewDiff": "View Diff", "openFile": "Open File", "fileCount": "{count, plural, one {# file} other {# files}}", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 188e76cbe..bd4ca9324 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1175,6 +1175,9 @@ "recentOpen": "Abiertos recientemente" }, "fileWorkspacePanel": { + "addSelectionToChat": "Añadir selección al chat", + "addToChat": "Añadir al chat", + "addSelectionToChatDone": "Se añadió {label} a la conversación", "viewDiff": "Ver Diff", "openFile": "Abrir archivo", "fileCount": "{count, plural, one {# archivo} other {# archivos}}", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 35d34e7d6..4b217385d 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1175,6 +1175,9 @@ "recentOpen": "Ouvert récemment" }, "fileWorkspacePanel": { + "addSelectionToChat": "Ajouter la sélection au chat", + "addToChat": "Ajouter au chat", + "addSelectionToChatDone": "{label} ajouté à la conversation", "viewDiff": "Voir le Diff", "openFile": "Ouvrir le fichier", "fileCount": "{count, plural, one {# fichier} other {# fichiers}}", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 7e70cc696..ca99bb737 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1175,6 +1175,9 @@ "recentOpen": "最近開いたフォルダ" }, "fileWorkspacePanel": { + "addSelectionToChat": "選択範囲をチャットに追加", + "addToChat": "チャットに追加", + "addSelectionToChatDone": "{label} を会話に追加しました", "viewDiff": "差分を見る", "openFile": "ファイルを開く", "fileCount": "{count, plural, one {# 個のファイル} other {# 個のファイル}}", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 0c617272b..0ba8c7fb4 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1175,6 +1175,9 @@ "recentOpen": "최근 연 항목" }, "fileWorkspacePanel": { + "addSelectionToChat": "선택 영역을 대화에 추가", + "addToChat": "대화에 추가", + "addSelectionToChatDone": "{label}을(를) 대화에 추가했습니다", "viewDiff": "Diff 보기", "openFile": "파일 열기", "fileCount": "{count, plural, one {#개 파일} other {#개 파일}}", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index f123448dc..0f74aec8a 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1175,6 +1175,9 @@ "recentOpen": "Abertos recentemente" }, "fileWorkspacePanel": { + "addSelectionToChat": "Adicionar seleção ao chat", + "addToChat": "Adicionar ao chat", + "addSelectionToChatDone": "{label} adicionado à conversa", "viewDiff": "Ver Diff", "openFile": "Abrir arquivo", "fileCount": "{count, plural, one {# arquivo} other {# arquivos}}", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 51379d835..4d9fe9653 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1175,6 +1175,9 @@ "recentOpen": "最近打开" }, "fileWorkspacePanel": { + "addSelectionToChat": "添加选区到会话", + "addToChat": "加入会话", + "addSelectionToChatDone": "已将 {label} 加入会话", "viewDiff": "查看差异", "openFile": "打开文件", "fileCount": "{count} 个文件", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 5e3bb0078..7c70f08d3 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1175,6 +1175,9 @@ "recentOpen": "最近打開" }, "fileWorkspacePanel": { + "addSelectionToChat": "新增選取範圍到對話", + "addToChat": "加入對話", + "addSelectionToChatDone": "已將 {label} 加入對話", "viewDiff": "查看差異", "openFile": "打開檔案", "fileCount": "{count} 個檔案", diff --git a/src/lib/reference-link.test.ts b/src/lib/reference-link.test.ts index e70dc509c..6dcf97666 100644 --- a/src/lib/reference-link.test.ts +++ b/src/lib/reference-link.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest" import { buildFileUri, + buildFileUriWithRange, foldReferenceLinks, + formatFileRangeLabel, tokenizeReferenceLinks, unescapeReferenceLabel, } from "./reference-link" @@ -189,4 +191,70 @@ describe("foldReferenceLinks", () => { "@Codex /review [oops](file:///x" ) }) + + it("folds a ranged file badge to its label", () => { + expect( + foldReferenceLinks("see [app.ts:10-25](file:///repo/src/app.ts#L10-25)") + ).toBe("see app.ts:10-25") + }) +}) + +describe("buildFileUriWithRange", () => { + it("returns the plain file uri when no range is given", () => { + expect(buildFileUriWithRange("/repo/src/app.ts")).toBe( + "file:///repo/src/app.ts" + ) + expect(buildFileUriWithRange("/repo/src/app.ts", null)).toBe( + "file:///repo/src/app.ts" + ) + }) + it("appends an #L fragment for a single-line span", () => { + expect( + buildFileUriWithRange("/repo/src/app.ts", { start: 10, end: 10 }) + ).toBe("file:///repo/src/app.ts#L10") + }) + it("appends an #L- fragment for a multi-line span", () => { + expect( + buildFileUriWithRange("/repo/src/app.ts", { start: 10, end: 25 }) + ).toBe("file:///repo/src/app.ts#L10-25") + }) + it("keeps the # literal after percent-encoding the path segments", () => { + expect(buildFileUriWithRange("/a/b c.ts", { start: 3, end: 7 })).toBe( + "file:///a/b%20c.ts#L3-7" + ) + }) + it("round-trips through the tokenizer as a single ranged link", () => { + expect( + tokenizeReferenceLinks( + `[app.ts:10-25](${buildFileUriWithRange("/repo/app.ts", { + start: 10, + end: 25, + })})` + ) + ).toEqual([ + { + type: "link", + raw: "[app.ts:10-25](file:///repo/app.ts#L10-25)", + label: "app.ts:10-25", + destination: "file:///repo/app.ts#L10-25", + }, + ]) + }) +}) + +describe("formatFileRangeLabel", () => { + it("returns the bare file name with no range", () => { + expect(formatFileRangeLabel("app.ts")).toBe("app.ts") + expect(formatFileRangeLabel("app.ts", null)).toBe("app.ts") + }) + it("suffixes a single line as :", () => { + expect(formatFileRangeLabel("app.ts", { start: 10, end: 10 })).toBe( + "app.ts:10" + ) + }) + it("suffixes a span as :-", () => { + expect(formatFileRangeLabel("app.ts", { start: 10, end: 25 })).toBe( + "app.ts:10-25" + ) + }) }) diff --git a/src/lib/reference-link.ts b/src/lib/reference-link.ts index 24eb4573d..8e7cf4957 100644 --- a/src/lib/reference-link.ts +++ b/src/lib/reference-link.ts @@ -29,6 +29,57 @@ export function buildFileUri(absolutePath: string): string { return normalized.startsWith("/") ? `file://${encoded}` : `file:///${encoded}` } +/** + * A 1-based, inclusive line span selected in the editor. `end === start` is a + * single line; `end > start` is a range. Callers normalize so `end >= start`. + */ +export interface FileLineRange { + start: number + end: number +} + +/** + * The `#L…` uri fragment for a line span: `L10` for one line, `L10-25` for a + * range. Mirrors the GitHub/VS Code convention so the destination stays human- + * readable and the agent can recover the lines from the link. + */ +function lineRangeFragment(range: FileLineRange): string { + return range.end > range.start + ? `L${range.start}-${range.end}` + : `L${range.start}` +} + +/** + * {@link buildFileUri} with an optional `#L[-]` line-range fragment + * appended. The fragment is concatenated AFTER per-segment encoding (and is + * never itself encoded) so the `#` stays a literal fragment delimiter rather + * than a percent-encoded path character. Encoding the range in the uri — not + * just the label — is what lets two different selections of the same file + * coexist as distinct badges (the composer dedupes file references by uri). + */ +export function buildFileUriWithRange( + absolutePath: string, + range?: FileLineRange | null +): string { + const base = buildFileUri(absolutePath) + return range ? `${base}#${lineRangeFragment(range)}` : base +} + +/** + * The badge label for a file selection: `foo.ts` with no range, `foo.ts:10` for + * a single line, `foo.ts:10-25` for a span — matching how mainstream editors + * present a "selection" chip. + */ +export function formatFileRangeLabel( + fileName: string, + range?: FileLineRange | null +): string { + if (!range) return fileName + return range.end > range.start + ? `${fileName}:${range.start}-${range.end}` + : `${fileName}:${range.start}` +} + /** * Reverse `escapeMarkdownText` (reference-text.ts): drop the backslash from each * escaped inline-significant punctuation char so the recovered label reads diff --git a/src/lib/session-attachment-events.ts b/src/lib/session-attachment-events.ts index d16cd5814..953e6eacb 100644 --- a/src/lib/session-attachment-events.ts +++ b/src/lib/session-attachment-events.ts @@ -3,6 +3,13 @@ export const ATTACH_FILE_TO_SESSION_EVENT = "codeg:attach-file-to-session" export interface AttachFileToSessionDetail { tabId: string path: string + /** + * Optional 1-based, inclusive line span to attach as a ranged file badge + * (`foo.ts:10-25`). Omitted by whole-file callers (file tree, git changes); + * supplied by the editor's "add selection to chat". When present the consumer + * encodes it into the badge uri (`file://…#L10-25`) and label. + */ + range?: { start: number; end: number } } export function emitAttachFileToSession(