From f7a40bd573f2db01b15c88ad7a9bb25763b877e0 Mon Sep 17 00:00:00 2001 From: hahahu Date: Wed, 6 May 2026 09:18:27 +0800 Subject: [PATCH] feat(web): polish chat rendering and fix remote session interactions (#567) * feat(web): polish chat rendering Refresh the web chat presentation across user messages, tool cards, code blocks, diffs, reasoning, and Mermaid diagrams.\n\nAdd focused regression coverage for bubble/status behavior, code and diff rendering, clipboard output, Mermaid theming, and message-window updates. * fix(web): stabilize chat tool rendering Preserve manual scroll anchors while older messages and tool dialogs update, and align code, diff, and tool result rendering with chat typography. Add chat font-weight settings, ignore local Playwright CLI artifacts, document the Angular commit-message convention, and cover the scroll, result, code, diff, and settings behavior with focused tests. Constraint: User requested committing all current workspace diffs with Angular-style commit messaging Tested: bun run typecheck:web && bun run test:web && git diff --check Co-authored-by: OmX * style(tool-card): polish question and permission card styles Align AskUserQuestion option surfaces and permission action hierarchy with the existing tool card visual language while preserving interaction logic. Extract shared option presentation helpers and theme-driven hover/muted colors to reduce duplication. Constraint: Frontend style-only polish; preserve existing permission and answer submission behavior Rejected: Keep screenshot artifacts in the repo | they are local visual review output, not source Confidence: high Scope-risk: narrow Tested: git diff --check; bun run typecheck:web; bun run test:web; bun run build:web Not-tested: manual cross-browser visual QA beyond local Playwright inspection Co-authored-by: OmX * fix(cli): keep Claude remote plan prompts actionable Handle Claude remote /plan locally so HAPI switches plan permission mode before forwarding any prompt text. This avoids Claude Code treating /plan as an unknown skill and ending with only a ready event. Constraint: Claude SDK result messages are not conversation log entries, so command handling must happen before the prompt reaches Claude.\nRejected: surfacing SDK result summaries in web chat | would expose transport-level summaries broadly instead of fixing the slash-command path.\nConfidence: high\nScope-risk: narrow\nDirective: Keep Claude remote slash commands that alter runtime mode in the CLI special-command parser.\nTested: bun test cli/src/parsers/specialCommands.test.ts; bun typecheck; git diff --check\nNot-tested: Manual GitHub-hosted runner deployment. * fix(web): polish tool result rendering * fix(web): preserve collapsed session order * fix(chat): settle initial thread scroll * fix(settings): remove chat font weight option * fix(web): remove font weight bootstrap code * chore: remove unrelated branch artifacts * test(web): update consumed message invocation test * fix(chat): cancel initial scroll settling on manual scroll --------- Co-authored-by: huhaoyu.hahahu Co-authored-by: OmX --- bun.lock | 94 ++-- cli/src/claude/runClaude.ts | 35 ++ cli/src/parsers/specialCommands.test.ts | 56 ++- cli/src/parsers/specialCommands.ts | 68 ++- web/README.md | 3 + web/package.json | 1 + .../AssistantChat/HappyThread.test.tsx | 121 +++++- .../components/AssistantChat/HappyThread.tsx | 296 +++++++++++-- .../messages/AssistantMessage.tsx | 68 +-- .../messages/MessageStatusIndicator.tsx | 10 +- .../AssistantChat/messages/ToolMessage.tsx | 24 +- .../AssistantChat/messages/UserMessage.tsx | 30 +- .../messages/user-bubble.test.tsx | 54 +++ .../AssistantChat/messages/user-bubble.tsx | 87 ++++ web/src/components/CliOutputBlock.test.tsx | 17 + web/src/components/CliOutputBlock.tsx | 86 ++-- web/src/components/CodeBlock.test.tsx | 27 ++ web/src/components/CodeBlock.tsx | 86 +++- web/src/components/DiffView.test.tsx | 69 +++ web/src/components/DiffView.tsx | 199 ++++++--- web/src/components/LazyRainbowText.tsx | 46 +- web/src/components/MarkdownRenderer.tsx | 12 +- web/src/components/SessionList.test.ts | 20 +- web/src/components/SessionList.tsx | 30 +- .../ToolCard/AskUserQuestionFooter.tsx | 74 ++-- .../components/ToolCard/PermissionFooter.tsx | 41 +- web/src/components/ToolCard/ToolCard.tsx | 56 +-- .../ToolCard/askUserQuestionOptionCard.tsx | 111 +++++ web/src/components/ToolCard/icons.tsx | 11 + .../ToolCard/views/AskUserQuestionView.tsx | 126 +++--- .../ToolCard/views/CodexDiffView.tsx | 8 +- .../components/ToolCard/views/EditView.tsx | 2 + .../ToolCard/views/MultiEditView.tsx | 2 + .../components/ToolCard/views/WriteView.tsx | 16 + .../ToolCard/views/_results.test.tsx | 149 ++++++- .../components/ToolCard/views/_results.tsx | 404 +++++++++++++----- .../components/assistant-ui/markdown-text.tsx | 66 +-- .../assistant-ui/mermaid-diagram.test.tsx | 47 ++ .../assistant-ui/mermaid-diagram.tsx | 138 ++++++ web/src/components/assistant-ui/reasoning.tsx | 30 +- .../assistant-ui/shiki-highlighter.tsx | 32 +- web/src/components/ui/dialog.tsx | 2 +- web/src/index.css | 210 ++++++++- web/src/lib/locales/en.ts | 12 +- web/src/lib/message-window-store.test.ts | 78 ++++ web/src/lib/message-window-store.ts | 2 +- web/src/test/setup.ts | 39 ++ 47 files changed, 2554 insertions(+), 641 deletions(-) create mode 100644 web/src/components/AssistantChat/messages/user-bubble.test.tsx create mode 100644 web/src/components/AssistantChat/messages/user-bubble.tsx create mode 100644 web/src/components/CliOutputBlock.test.tsx create mode 100644 web/src/components/CodeBlock.test.tsx create mode 100644 web/src/components/DiffView.test.tsx create mode 100644 web/src/components/ToolCard/askUserQuestionOptionCard.tsx create mode 100644 web/src/components/assistant-ui/mermaid-diagram.test.tsx create mode 100644 web/src/components/assistant-ui/mermaid-diagram.tsx create mode 100644 web/src/lib/message-window-store.test.ts diff --git a/bun.lock b/bun.lock index f6c158cc..2c59deef 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "hapi", @@ -112,6 +111,7 @@ "diff": "^8.0.2", "hast-util-to-jsx-runtime": "^2.3.6", "katex": "^0.16.45", + "mermaid": "^11.12.0", "react": "^19.2.3", "react-dom": "^19.2.3", "rehype-katex": "^7.0.1", @@ -493,15 +493,15 @@ "@builder.io/vite-plugin-jsx-loc": ["@builder.io/vite-plugin-jsx-loc@0.1.1", "", { "dependencies": { "@builder.io/jsx-loc-internals": "0.0.1" }, "peerDependencies": { "vite": "^4.0.0 || ^5.0.0" } }, "sha512-iAHFkaLBDJBC+EkGO1hF7hnIW2+oKKYVOl8NFAQH//3xeNEzvGdS9tOALRPR+JjR/M5NLyj+FG0VV7WFb1aJmw=="], - "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.0.3", "", { "dependencies": { "@chevrotain/gast": "11.0.3", "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ=="], + "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@12.0.0", "", { "dependencies": { "@chevrotain/gast": "12.0.0", "@chevrotain/types": "12.0.0" } }, "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg=="], - "@chevrotain/gast": ["@chevrotain/gast@11.0.3", "", { "dependencies": { "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q=="], + "@chevrotain/gast": ["@chevrotain/gast@12.0.0", "", { "dependencies": { "@chevrotain/types": "12.0.0" } }, "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ=="], "@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@12.0.0", "", {}, "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA=="], - "@chevrotain/types": ["@chevrotain/types@11.0.3", "", {}, "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="], + "@chevrotain/types": ["@chevrotain/types@12.0.0", "", {}, "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA=="], - "@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="], + "@chevrotain/utils": ["@chevrotain/utils@12.0.0", "", {}, "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA=="], "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], @@ -701,7 +701,7 @@ "@medv/finder": ["@medv/finder@4.0.2", "", {}, "sha512-RraNY9SCcx4KZV0Dh6BEW6XEW2swkqYca74pkFFRw6hHItSHiy+O/xMnpbofjYbzXj0tSpBGthUF1hHTsr3vIQ=="], - "@mermaid-js/parser": ["@mermaid-js/parser@0.6.3", "", { "dependencies": { "langium": "3.3.1" } }, "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA=="], + "@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.1", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ=="], @@ -1421,9 +1421,9 @@ "check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="], - "chevrotain": ["chevrotain@11.0.3", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", "@chevrotain/regexp-to-ast": "11.0.3", "@chevrotain/types": "11.0.3", "@chevrotain/utils": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw=="], + "chevrotain": ["chevrotain@12.0.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "12.0.0", "@chevrotain/gast": "12.0.0", "@chevrotain/regexp-to-ast": "12.0.0", "@chevrotain/types": "12.0.0", "@chevrotain/utils": "12.0.0" } }, "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ=="], - "chevrotain-allstar": ["chevrotain-allstar@0.3.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^11.0.0" } }, "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw=="], + "chevrotain-allstar": ["chevrotain-allstar@0.4.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^12.0.0" } }, "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA=="], "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], @@ -1575,7 +1575,7 @@ "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], - "dagre-d3-es": ["dagre-d3-es@7.0.13", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q=="], + "dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], "data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="], @@ -2087,7 +2087,7 @@ "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], - "langium": ["langium@3.3.1", "", { "dependencies": { "chevrotain": "~11.0.3", "chevrotain-allstar": "~0.3.0", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.0.8" } }, "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w=="], + "langium": ["langium@4.2.2", "", { "dependencies": { "@chevrotain/regexp-to-ast": "~12.0.0", "chevrotain": "~12.0.0", "chevrotain-allstar": "~0.4.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.1.0" } }, "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ=="], "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], @@ -2135,7 +2135,7 @@ "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], - "lodash-es": ["lodash-es@4.17.22", "", {}, "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q=="], + "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], @@ -2211,7 +2211,7 @@ "merge-value": ["merge-value@1.0.0", "", { "dependencies": { "get-value": "^2.0.6", "is-extendable": "^1.0.0", "mixin-deep": "^1.2.0", "set-value": "^2.0.0" } }, "sha512-fJMmvat4NeKz63Uv9iHWcPDjCWcCkoiRoajRTEO8hlhUC6rwaHg0QCF9hBOTjZmm4JuglPckPSTtcuJL5kp0TQ=="], - "mermaid": ["mermaid@11.12.2", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.1", "@mermaid-js/parser": "^0.6.3", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.13", "dayjs": "^1.11.18", "dompurify": "^3.2.5", "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.21", "marked": "^16.2.1", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w=="], + "mermaid": ["mermaid@11.14.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "katex": "^0.16.25", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g=="], "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], @@ -2993,7 +2993,7 @@ "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], - "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], "v8n": ["v8n@1.5.1", "", {}, "sha512-LdabyT4OffkyXFCe9UT+uMkxNBs5rcTVuZClvxQr08D5TUgo1OFKkoT65qYRCsiKBl/usHjpXvP4hHMzzDRj3A=="], @@ -3035,7 +3035,7 @@ "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], - "vscode-uri": ["vscode-uri@3.0.8", "", {}, "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw=="], + "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], "vue": ["vue@3.5.26", "", { "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/compiler-sfc": "3.5.26", "@vue/runtime-dom": "3.5.26", "@vue/server-renderer": "3.5.26", "@vue/shared": "3.5.26" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA=="], @@ -3157,10 +3157,6 @@ "@builder.io/vite-plugin-jsx-loc/vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], - "@chevrotain/cst-dts-gen/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], - - "@chevrotain/gast/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], - "@emotion/babel-plugin/@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="], "@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], @@ -3205,14 +3201,14 @@ "@lobehub/ui/marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], - "@lobehub/ui/mermaid": ["mermaid@11.14.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "katex": "^0.16.25", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g=="], - "@lobehub/ui/remark-cjk-friendly": ["remark-cjk-friendly@2.0.1", "", { "dependencies": { "micromark-extension-cjk-friendly": "2.0.1" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-6WwkoQyZf/4j5k53zdFYrR8Ca+UVn992jXdLUSBDZR4eBpFhKyVxmA4gUHra/5fesjGIxrDhHesNr/sVoiiysA=="], "@lobehub/ui/remend": ["remend@1.3.0", "", {}, "sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw=="], "@lobehub/ui/shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], + "@lobehub/ui/uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], + "@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], @@ -3417,10 +3413,6 @@ "body-parser/raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], - "chevrotain/@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@11.0.3", "", {}, "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA=="], - - "chevrotain/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], - "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -3485,12 +3477,6 @@ "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - "mermaid/dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], - - "mermaid/katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="], - - "mermaid/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], - "micromark-extension-math/katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="], "motion/framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], @@ -3553,6 +3539,8 @@ "streamdown/lucide-react": ["lucide-react@0.542.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw=="], + "streamdown/mermaid": ["mermaid@11.12.2", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.1", "@mermaid-js/parser": "^0.6.3", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.13", "dayjs": "^1.11.18", "dompurify": "^3.2.5", "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.21", "marked": "^16.2.1", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w=="], + "string-width-cjs/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -3611,16 +3599,6 @@ "@lobehub/ui/@shikijs/transformers/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], - "@lobehub/ui/mermaid/@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="], - - "@lobehub/ui/mermaid/dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], - - "@lobehub/ui/mermaid/lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], - - "@lobehub/ui/mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], - - "@lobehub/ui/mermaid/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], - "@lobehub/ui/remark-cjk-friendly/micromark-extension-cjk-friendly": ["micromark-extension-cjk-friendly@2.0.1", "", { "dependencies": { "devlop": "^1.1.0", "micromark-extension-cjk-friendly-util": "3.0.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-OkzoYVTL1ChbvQ8Cc1ayTIz7paFQz8iS9oIYmewncweUSwmWR+hkJF9spJ1lxB90XldJl26A1F4IkPOKS3bDXw=="], "@lobehub/ui/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="], @@ -3787,6 +3765,14 @@ "source-map/whatwg-url/webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="], + "streamdown/mermaid/@mermaid-js/parser": ["@mermaid-js/parser@0.6.3", "", { "dependencies": { "langium": "3.3.1" } }, "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA=="], + + "streamdown/mermaid/dagre-d3-es": ["dagre-d3-es@7.0.13", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q=="], + + "streamdown/mermaid/dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], + + "streamdown/mermaid/lodash-es": ["lodash-es@4.17.22", "", {}, "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q=="], + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA=="], @@ -3973,8 +3959,6 @@ "@floating-ui/react/@floating-ui/react-dom/@floating-ui/dom/@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], - "@lobehub/ui/mermaid/@mermaid-js/parser/langium": ["langium@4.2.2", "", { "dependencies": { "@chevrotain/regexp-to-ast": "~12.0.0", "chevrotain": "~12.0.0", "chevrotain-allstar": "~0.4.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.1.0" } }, "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ=="], - "@lobehub/ui/remark-cjk-friendly/micromark-extension-cjk-friendly/micromark-extension-cjk-friendly-util": ["micromark-extension-cjk-friendly-util@3.0.1", "", { "dependencies": { "get-east-asian-width": "^1.4.0", "micromark-util-character": "^2.1.1", "micromark-util-symbol": "^2.0.1" } }, "sha512-GcbXqTTHOsiZHyF753oIddP/J2eH8j9zpyQPhkof6B2JNxfEJabnQqxbCgzJNuNes0Y2jTNJ3LiYPSXr6eJA8w=="], "@modelcontextprotocol/sdk/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], @@ -4043,6 +4027,8 @@ "qrcode/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "streamdown/mermaid/@mermaid-js/parser/langium": ["langium@3.3.1", "", { "dependencies": { "chevrotain": "~11.0.3", "chevrotain-allstar": "~0.3.0", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.0.8" } }, "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w=="], + "vite-node/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], "vite-node/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], @@ -4139,12 +4125,6 @@ "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "@lobehub/ui/mermaid/@mermaid-js/parser/langium/chevrotain": ["chevrotain@12.0.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "12.0.0", "@chevrotain/gast": "12.0.0", "@chevrotain/regexp-to-ast": "12.0.0", "@chevrotain/types": "12.0.0", "@chevrotain/utils": "12.0.0" } }, "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ=="], - - "@lobehub/ui/mermaid/@mermaid-js/parser/langium/chevrotain-allstar": ["chevrotain-allstar@0.4.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^12.0.0" } }, "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA=="], - - "@lobehub/ui/mermaid/@mermaid-js/parser/langium/vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], - "hapi-website/vitest/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], "hapi-website/vitest/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], @@ -4197,12 +4177,22 @@ "qrcode/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "@lobehub/ui/mermaid/@mermaid-js/parser/langium/chevrotain/@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@12.0.0", "", { "dependencies": { "@chevrotain/gast": "12.0.0", "@chevrotain/types": "12.0.0" } }, "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg=="], + "streamdown/mermaid/@mermaid-js/parser/langium/chevrotain": ["chevrotain@11.0.3", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", "@chevrotain/regexp-to-ast": "11.0.3", "@chevrotain/types": "11.0.3", "@chevrotain/utils": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw=="], - "@lobehub/ui/mermaid/@mermaid-js/parser/langium/chevrotain/@chevrotain/gast": ["@chevrotain/gast@12.0.0", "", { "dependencies": { "@chevrotain/types": "12.0.0" } }, "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ=="], + "streamdown/mermaid/@mermaid-js/parser/langium/chevrotain-allstar": ["chevrotain-allstar@0.3.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^11.0.0" } }, "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw=="], - "@lobehub/ui/mermaid/@mermaid-js/parser/langium/chevrotain/@chevrotain/types": ["@chevrotain/types@12.0.0", "", {}, "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA=="], + "streamdown/mermaid/@mermaid-js/parser/langium/vscode-uri": ["vscode-uri@3.0.8", "", {}, "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw=="], - "@lobehub/ui/mermaid/@mermaid-js/parser/langium/chevrotain/@chevrotain/utils": ["@chevrotain/utils@12.0.0", "", {}, "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA=="], + "streamdown/mermaid/@mermaid-js/parser/langium/chevrotain/@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.0.3", "", { "dependencies": { "@chevrotain/gast": "11.0.3", "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ=="], + + "streamdown/mermaid/@mermaid-js/parser/langium/chevrotain/@chevrotain/gast": ["@chevrotain/gast@11.0.3", "", { "dependencies": { "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q=="], + + "streamdown/mermaid/@mermaid-js/parser/langium/chevrotain/@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@11.0.3", "", {}, "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA=="], + + "streamdown/mermaid/@mermaid-js/parser/langium/chevrotain/@chevrotain/types": ["@chevrotain/types@11.0.3", "", {}, "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="], + + "streamdown/mermaid/@mermaid-js/parser/langium/chevrotain/@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="], + + "streamdown/mermaid/@mermaid-js/parser/langium/chevrotain/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], } } diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index c7aae047..9874d833 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -282,6 +282,41 @@ export async function runClaude(options: StartOptions = {}): Promise { return; } + if (specialCommand.type === 'plan') { + logger.debug('[start] Detected /plan command'); + currentPermissionMode = specialCommand.mode ?? 'plan'; + currentSessionRef.current?.setPermissionMode(currentPermissionMode); + currentSessionRef.current?.pushKeepAlive(); + session.sendSessionEvent({ + type: 'permission-mode-changed', + mode: currentPermissionMode + }); + + const enhancedMode: EnhancedMode = { + permissionMode: currentPermissionMode, + model: messageModel, + effort: messageEffort, + fallbackModel: messageFallbackModel, + customSystemPrompt: messageCustomSystemPrompt, + appendSystemPrompt: messageAppendSystemPrompt, + allowedTools: messageAllowedTools, + disallowedTools: messageDisallowedTools + }; + + if (!specialCommand.prompt) { + if (localId) { + session.emitMessagesConsumed([localId]); + } + logger.debugLargeJson('[start] /plan command applied without prompt:', message); + return; + } + + const planPrompt = formatMessageWithAttachments(specialCommand.prompt, message.content.attachments); + messageQueue.push(planPrompt, enhancedMode, localId); + logger.debugLargeJson('[start] /plan command prompt pushed to queue:', message); + return; + } + // Push with resolved permission mode, model, system prompts, and tools const enhancedMode: EnhancedMode = { permissionMode: messagePermissionMode ?? 'default', diff --git a/cli/src/parsers/specialCommands.test.ts b/cli/src/parsers/specialCommands.test.ts index ad6a5da2..c38b1997 100644 --- a/cli/src/parsers/specialCommands.test.ts +++ b/cli/src/parsers/specialCommands.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseCompact, parseClear, parseSpecialCommand } from './specialCommands'; +import { parseCompact, parseClear, parsePlan, parseSpecialCommand } from './specialCommands'; describe('parseCompact', () => { it('should parse /compact command with argument', () => { @@ -49,6 +49,49 @@ describe('parseClear', () => { }); }); +describe('parsePlan', () => { + it('should parse /plan without a prompt', () => { + const result = parsePlan('/plan'); + expect(result).toEqual({ + isPlan: true, + mode: 'plan', + prompt: undefined + }); + }); + + it('should parse /plan with a prompt', () => { + const result = parsePlan('/plan 帮我规划五一行程'); + expect(result).toEqual({ + isPlan: true, + mode: 'plan', + prompt: '帮我规划五一行程' + }); + }); + + it('should strip matching quotes around the prompt', () => { + const result = parsePlan('/plan "帮我规划五一行程"'); + expect(result).toEqual({ + isPlan: true, + mode: 'plan', + prompt: '帮我规划五一行程' + }); + }); + + it('should parse /plan off as default mode', () => { + const result = parsePlan('/plan off'); + expect(result).toEqual({ + isPlan: true, + mode: 'default', + prompt: undefined + }); + }); + + it('should not parse partial matches', () => { + expect(parsePlan('/planner test').isPlan).toBe(false); + expect(parsePlan('please /plan this').isPlan).toBe(false); + }); +}); + describe('parseSpecialCommand', () => { it('should detect compact command', () => { const result = parseSpecialCommand('/compact optimize'); @@ -62,6 +105,13 @@ describe('parseSpecialCommand', () => { expect(result.originalMessage).toBeUndefined(); }); + it('should detect plan command with prompt', () => { + const result = parseSpecialCommand('/plan "帮我规划五一行程"'); + expect(result.type).toBe('plan'); + expect(result.mode).toBe('plan'); + expect(result.prompt).toBe('帮我规划五一行程'); + }); + it('should return null for regular messages', () => { const result = parseSpecialCommand('hello world'); expect(result.type).toBeNull(); @@ -72,10 +122,12 @@ describe('parseSpecialCommand', () => { // Test with extra whitespace expect(parseSpecialCommand(' /compact test ').type).toBe('compact'); expect(parseSpecialCommand(' /clear ').type).toBe('clear'); + expect(parseSpecialCommand(' /plan test ').type).toBe('plan'); // Test partial matches should not trigger expect(parseSpecialCommand('some /compact text').type).toBeNull(); expect(parseSpecialCommand('/compactor').type).toBeNull(); expect(parseSpecialCommand('/clearing').type).toBeNull(); + expect(parseSpecialCommand('/planner').type).toBeNull(); }); -}); \ No newline at end of file +}); diff --git a/cli/src/parsers/specialCommands.ts b/cli/src/parsers/specialCommands.ts index 69226460..a9ebcac7 100644 --- a/cli/src/parsers/specialCommands.ts +++ b/cli/src/parsers/specialCommands.ts @@ -11,9 +11,17 @@ export interface ClearCommandResult { isClear: boolean; } +export interface PlanCommandResult { + isPlan: boolean; + mode: 'plan' | 'default'; + prompt?: string; +} + export interface SpecialCommandResult { - type: 'compact' | 'clear' | null; + type: 'compact' | 'clear' | 'plan' | null; originalMessage?: string; + mode?: 'plan' | 'default'; + prompt?: string; } /** @@ -55,6 +63,52 @@ export function parseClear(message: string): ClearCommandResult { }; } +function stripMatchingQuotes(value: string): string { + const trimmed = value.trim(); + if (trimmed.length < 2) { + return trimmed; + } + + const first = trimmed[0]; + const last = trimmed[trimmed.length - 1]; + if ((first === '"' && last === '"') || (first === '\'' && last === '\'')) { + return trimmed.slice(1, -1).trim(); + } + + return trimmed; +} + +/** + * Parse /plan command for remote Claude sessions. + * - /plan: switch to Claude plan permission mode. + * - /plan off: switch back to default mode. + * - /plan : switch to plan mode and send the remaining prompt. + */ +export function parsePlan(message: string): PlanCommandResult { + const trimmed = message.trim(); + const match = /^\/plan(?:\s+([\s\S]*))?$/i.exec(trimmed); + if (!match) { + return { + isPlan: false, + mode: 'plan' + }; + } + + const rawArg = match[1]?.trim() ?? ''; + if (rawArg.toLowerCase() === 'off') { + return { + isPlan: true, + mode: 'default' + }; + } + + return { + isPlan: true, + mode: 'plan', + prompt: rawArg ? stripMatchingQuotes(rawArg) : undefined + }; +} + /** * Unified parser for special commands * Returns the type of command and original message if applicable @@ -74,8 +128,18 @@ export function parseSpecialCommand(message: string): SpecialCommandResult { type: 'clear' }; } + + const planResult = parsePlan(message); + if (planResult.isPlan) { + return { + type: 'plan', + mode: planResult.mode, + prompt: planResult.prompt, + originalMessage: message.trim() + }; + } return { type: null }; -} \ No newline at end of file +} diff --git a/web/README.md b/web/README.md index 383eee1f..4336d590 100644 --- a/web/README.md +++ b/web/README.md @@ -77,6 +77,7 @@ See `src/router.tsx` for route definitions. ### New session (`src/components/NewSession/`) Modular session creation: + - Machine selector - Directory input with recent paths - Agent type selector @@ -128,7 +129,9 @@ bun install bun run dev:web ``` + If testing in Telegram, set: + - `HAPI_PUBLIC_URL` to the public HTTPS URL of the dev server. - `CORS_ORIGINS` to include the dev server origin. diff --git a/web/package.json b/web/package.json index d3571527..1d7b9892 100644 --- a/web/package.json +++ b/web/package.json @@ -33,6 +33,7 @@ "diff": "^8.0.2", "hast-util-to-jsx-runtime": "^2.3.6", "katex": "^0.16.45", + "mermaid": "^11.12.0", "react": "^19.2.3", "react-dom": "^19.2.3", "rehype-katex": "^7.0.1", diff --git a/web/src/components/AssistantChat/HappyThread.test.tsx b/web/src/components/AssistantChat/HappyThread.test.tsx index 51be7fc5..45176b53 100644 --- a/web/src/components/AssistantChat/HappyThread.test.tsx +++ b/web/src/components/AssistantChat/HappyThread.test.tsx @@ -2,7 +2,13 @@ import { describe, expect, it, vi } from 'vitest' import { fireEvent, render, screen } from '@testing-library/react' import type { ComponentProps } from 'react' import { I18nProvider } from '@/lib/i18n-context' -import { ConversationOutlinePanel } from '@/components/AssistantChat/HappyThread' +import { + ConversationOutlinePanel, + captureScrollAnchor, + getScrollIntent, + restoreScrollAnchor, + shouldCancelInitialScrollSettling, +} from '@/components/AssistantChat/HappyThread' import type { ConversationOutlineItem } from '@/chat/outline' const outlineItems: ConversationOutlineItem[] = [ @@ -22,6 +28,19 @@ const outlineItems: ConversationOutlineItem[] = [ } ] +function rect(values: Pick & Partial): DOMRect { + return { + left: 0, + right: 300, + width: 300, + height: values.bottom - values.top, + x: 0, + y: values.top, + toJSON: () => ({}), + ...values + } as DOMRect +} + function renderPanel(props: Partial> = {}) { return render( @@ -64,3 +83,103 @@ describe('ConversationOutlinePanel', () => { expect(screen.getByText('No outline items in loaded messages')).toBeInTheDocument() }) }) + +describe('scroll anchor helpers', () => { + it('captures the first visible message relative to the viewport', () => { + const viewport = document.createElement('div') + const first = document.createElement('div') + const second = document.createElement('div') + first.id = 'first-message' + second.id = 'second-message' + viewport.className = 'viewport' + const messages = document.createElement('div') + messages.className = 'happy-thread-messages' + messages.append(first, second) + viewport.append(messages) + document.body.append(viewport) + + vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(rect({ top: 100, bottom: 500 })) + vi.spyOn(first, 'getBoundingClientRect').mockReturnValue(rect({ top: 60, bottom: 90 })) + vi.spyOn(second, 'getBoundingClientRect').mockReturnValue(rect({ top: 120, bottom: 180 })) + + expect(captureScrollAnchor(viewport)).toEqual({ + id: 'second-message', + topOffset: 20 + }) + + viewport.remove() + }) + + it('treats upward motion near the bottom as manual scroll intent', () => { + expect(getScrollIntent({ + scrollTop: 690, + previousScrollTop: 702, + scrollHeight: 1232, + clientHeight: 530 + })).toMatchObject({ + distanceFromBottom: 12, + isNearBottom: true, + isScrollingUp: true + }) + }) + + it('does not classify downward movement as upward manual scroll intent', () => { + expect(getScrollIntent({ + scrollTop: 702, + previousScrollTop: 690, + scrollHeight: 1232, + clientHeight: 530 + })).toMatchObject({ + distanceFromBottom: 0, + isNearBottom: true, + isScrollingUp: false + }) + }) + + it('cancels initial scroll settling when the user scrolls up away from the bottom', () => { + const intent = getScrollIntent({ + scrollTop: 520, + previousScrollTop: 700, + scrollHeight: 1232, + clientHeight: 530 + }) + + expect(intent).toMatchObject({ + distanceFromBottom: 182, + isScrollingUp: true + }) + expect(shouldCancelInitialScrollSettling(intent)).toBe(true) + }) + + it('keeps initial scroll settling for negligible movement at the bottom', () => { + const intent = getScrollIntent({ + scrollTop: 702, + previousScrollTop: 702, + scrollHeight: 1232, + clientHeight: 530 + }) + + expect(intent).toMatchObject({ + distanceFromBottom: 0, + isScrollingUp: false + }) + expect(shouldCancelInitialScrollSettling(intent)).toBe(false) + }) + + it('restores the captured message to the same viewport offset', () => { + const viewport = document.createElement('div') + const message = document.createElement('div') + message.id = 'anchored-message' + viewport.append(message) + document.body.append(viewport) + viewport.scrollTop = 200 + + vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(rect({ top: 100, bottom: 500 })) + vi.spyOn(message, 'getBoundingClientRect').mockReturnValue(rect({ top: 180, bottom: 260 })) + + expect(restoreScrollAnchor(viewport, { id: 'anchored-message', topOffset: 30 })).toBe(true) + expect(viewport.scrollTop).toBe(250) + + viewport.remove() + }) +}) diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index 6016fd43..986498b9 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react' import { ThreadPrimitive } from '@assistant-ui/react' import type { ApiClient } from '@/api/client' import type { SessionMetadataSummary } from '@/types/api' @@ -13,6 +13,75 @@ import { Spinner } from '@/components/Spinner' import { useTranslation } from '@/lib/use-translation' import { CloseIcon } from '@/components/icons' +type ScrollAnchor = { + id: string + topOffset: number +} + +type PendingScrollRestore = { + anchor: ScrollAnchor | null + scrollTop: number + scrollHeight: number +} + +const MESSAGE_ANCHOR_SELECTOR = '.happy-thread-messages > [id]' +const AUTO_SCROLL_RESUME_THRESHOLD_PX = 120 +const MANUAL_SCROLL_EPSILON_PX = 1 +const INITIAL_SCROLL_SETTLE_MS = 1800 +const INITIAL_SCROLL_SETTLE_DELAYS_MS = [0, 16, 50, 120, 250, 500, 900, 1400, 1800] as const + +type ScrollIntent = { + distanceFromBottom: number + isNearBottom: boolean + isScrollingUp: boolean +} + +export function getScrollIntent(params: { + scrollTop: number + scrollHeight: number + clientHeight: number + previousScrollTop: number + thresholdPx?: number +}): ScrollIntent { + const thresholdPx = params.thresholdPx ?? AUTO_SCROLL_RESUME_THRESHOLD_PX + const distanceFromBottom = params.scrollHeight - params.scrollTop - params.clientHeight + return { + distanceFromBottom, + isNearBottom: distanceFromBottom < thresholdPx, + isScrollingUp: params.scrollTop < params.previousScrollTop - MANUAL_SCROLL_EPSILON_PX + } +} + +export function shouldCancelInitialScrollSettling(intent: ScrollIntent): boolean { + return intent.isScrollingUp && intent.distanceFromBottom > MANUAL_SCROLL_EPSILON_PX +} + +export function captureScrollAnchor(viewport: HTMLElement): ScrollAnchor | null { + const viewportRect = viewport.getBoundingClientRect() + const messages = Array.from(viewport.querySelectorAll(MESSAGE_ANCHOR_SELECTOR)) + for (const message of messages) { + const rect = message.getBoundingClientRect() + if (rect.bottom > viewportRect.top && rect.top < viewportRect.bottom) { + return { + id: message.id, + topOffset: rect.top - viewportRect.top + } + } + } + return null +} + +export function restoreScrollAnchor(viewport: HTMLElement, anchor: ScrollAnchor): boolean { + const target = document.getElementById(anchor.id) + if (!target || !viewport.contains(target)) { + return false + } + const viewportRect = viewport.getBoundingClientRect() + const targetRect = target.getBoundingClientRect() + viewport.scrollTop += targetRect.top - viewportRect.top - anchor.topOffset + return true +} + function NewMessagesIndicator(props: { count: number; onClick: () => void }) { const { t } = useTranslation() if (props.count === 0) { @@ -176,9 +245,10 @@ export function HappyThread(props: { }) { const { t } = useTranslation() const viewportRef = useRef(null) + const contentRef = useRef(null) const topSentinelRef = useRef(null) const loadLockRef = useRef(false) - const pendingScrollRef = useRef<{ scrollTop: number; scrollHeight: number } | null>(null) + const pendingScrollRef = useRef(null) const prevLoadingMoreRef = useRef(false) const loadStartedRef = useRef(false) const isLoadingMoreRef = useRef(props.isLoadingMoreMessages) @@ -190,15 +260,14 @@ export function HappyThread(props: { const onAtBottomChangeRef = useRef(props.onAtBottomChange) const onFlushPendingRef = useRef(props.onFlushPending) const forceScrollTokenRef = useRef(props.forceScrollToken) + const lastScrollTopRef = useRef(0) + const sessionIdRef = useRef(props.sessionId) + const initialScrollSessionRef = useRef(null) + const initialScrollDeadlineRef = useRef(0) + const initialScrollTimersRef = useRef([]) - // Smart scroll state: autoScroll enabled when user is near bottom - const [autoScrollEnabled, setAutoScrollEnabled] = useState(true) - const autoScrollEnabledRef = useRef(autoScrollEnabled) - - // Keep refs in sync with state - useEffect(() => { - autoScrollEnabledRef.current = autoScrollEnabled - }, [autoScrollEnabled]) + // Smart scroll state: enabled only while the user is intentionally at the bottom. + const autoScrollEnabledRef = useRef(true) useEffect(() => { onAtBottomChangeRef.current = props.onAtBottomChange }, [props.onAtBottomChange]) @@ -215,43 +284,101 @@ export function HappyThread(props: { onLoadMoreRef.current = props.onLoadMore }, [props.onLoadMore]) + useEffect(() => { + sessionIdRef.current = props.sessionId + }, [props.sessionId]) + + const isInitialScrollSettling = useCallback(() => { + return initialScrollSessionRef.current === sessionIdRef.current && Date.now() < initialScrollDeadlineRef.current + }, []) + + const clearInitialScrollTimers = useCallback(() => { + for (const timer of initialScrollTimersRef.current) { + window.clearTimeout(timer) + } + initialScrollTimersRef.current = [] + }, []) + // Track scroll position to toggle autoScroll (stable listener using refs) useEffect(() => { const viewport = viewportRef.current if (!viewport) return - const THRESHOLD_PX = 120 + lastScrollTopRef.current = viewport.scrollTop + + const setAutoScrollMode = (enabled: boolean) => { + if (autoScrollEnabledRef.current === enabled) { + return + } + autoScrollEnabledRef.current = enabled + } + + const setAtBottomMode = (atBottom: boolean) => { + if (atBottom === atBottomRef.current) { + return + } + atBottomRef.current = atBottom + onAtBottomChangeRef.current(atBottom) + if (atBottom) { + onFlushPendingRef.current() + } + } const handleScroll = () => { - const distanceFromBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight - const isNearBottom = distanceFromBottom < THRESHOLD_PX + const intent = getScrollIntent({ + scrollTop: viewport.scrollTop, + scrollHeight: viewport.scrollHeight, + clientHeight: viewport.clientHeight, + previousScrollTop: lastScrollTopRef.current + }) + lastScrollTopRef.current = viewport.scrollTop - if (isNearBottom) { - if (!autoScrollEnabledRef.current) setAutoScrollEnabled(true) - } else if (autoScrollEnabledRef.current) { - setAutoScrollEnabled(false) - } - - if (isNearBottom !== atBottomRef.current) { - atBottomRef.current = isNearBottom - onAtBottomChangeRef.current(isNearBottom) - if (isNearBottom) { - onFlushPendingRef.current() + if (isInitialScrollSettling()) { + if (shouldCancelInitialScrollSettling(intent)) { + initialScrollDeadlineRef.current = 0 + clearInitialScrollTimers() + setAutoScrollMode(false) + setAtBottomMode(false) } + return } + + if (intent.isScrollingUp && intent.distanceFromBottom > MANUAL_SCROLL_EPSILON_PX) { + setAutoScrollMode(false) + setAtBottomMode(false) + return + } + + if (intent.isNearBottom) { + setAutoScrollMode(true) + setAtBottomMode(true) + return + } + + setAutoScrollMode(false) + setAtBottomMode(false) } viewport.addEventListener('scroll', handleScroll, { passive: true }) return () => viewport.removeEventListener('scroll', handleScroll) }, []) // Stable: no dependencies, reads from refs + const scrollToBottomInstant = useCallback(() => { + const viewport = viewportRef.current + if (viewport) { + viewport.scrollTo({ top: viewport.scrollHeight, behavior: 'instant' }) + lastScrollTopRef.current = viewport.scrollTop + } + }, []) + // Scroll to bottom handler for the indicator button const scrollToBottom = useCallback(() => { const viewport = viewportRef.current if (viewport) { viewport.scrollTo({ top: viewport.scrollHeight, behavior: 'smooth' }) + lastScrollTopRef.current = viewport.scrollTop } - setAutoScrollEnabled(true) + autoScrollEnabledRef.current = true if (!atBottomRef.current) { atBottomRef.current = true onAtBottomChangeRef.current(true) @@ -260,12 +387,59 @@ export function HappyThread(props: { }, []) // Reset state when session changes - useEffect(() => { - setAutoScrollEnabled(true) + useLayoutEffect(() => { + autoScrollEnabledRef.current = true + lastScrollTopRef.current = viewportRef.current?.scrollTop ?? 0 atBottomRef.current = true onAtBottomChangeRef.current(true) forceScrollTokenRef.current = props.forceScrollToken - }, [props.sessionId]) + initialScrollSessionRef.current = null + initialScrollDeadlineRef.current = 0 + clearInitialScrollTimers() + }, [props.sessionId, clearInitialScrollTimers]) + + useLayoutEffect(() => { + if ( + initialScrollSessionRef.current === props.sessionId + || props.isLoadingMessages + || props.rawMessagesCount === 0 + || pendingScrollRef.current + ) { + return + } + + initialScrollSessionRef.current = props.sessionId + autoScrollEnabledRef.current = true + atBottomRef.current = true + onAtBottomChangeRef.current(true) + scrollToBottomInstant() + + initialScrollDeadlineRef.current = Date.now() + INITIAL_SCROLL_SETTLE_MS + clearInitialScrollTimers() + initialScrollTimersRef.current = INITIAL_SCROLL_SETTLE_DELAYS_MS.map((delay) => window.setTimeout(() => { + if ( + initialScrollSessionRef.current !== props.sessionId + || !autoScrollEnabledRef.current + || pendingScrollRef.current + ) { + return + } + scrollToBottomInstant() + }, delay)) + }, [ + props.sessionId, + props.isLoadingMessages, + props.rawMessagesCount, + props.messagesVersion, + scrollToBottomInstant, + clearInitialScrollTimers + ]) + + useEffect(() => { + return () => { + clearInitialScrollTimers() + } + }, [clearInitialScrollTimers]) useEffect(() => { if (forceScrollTokenRef.current === props.forceScrollToken) { @@ -276,7 +450,13 @@ export function HappyThread(props: { }, [props.forceScrollToken, scrollToBottom]) const handleLoadMore = useCallback(() => { - if (isLoadingMessagesRef.current || !hasMoreMessagesRef.current || isLoadingMoreRef.current || loadLockRef.current) { + if ( + isInitialScrollSettling() + || isLoadingMessagesRef.current + || !hasMoreMessagesRef.current + || isLoadingMoreRef.current + || loadLockRef.current + ) { return } const viewport = viewportRef.current @@ -284,9 +464,11 @@ export function HappyThread(props: { return } pendingScrollRef.current = { + anchor: captureScrollAnchor(viewport), scrollTop: viewport.scrollTop, scrollHeight: viewport.scrollHeight } + autoScrollEnabledRef.current = false loadLockRef.current = true loadStartedRef.current = false let loadPromise: Promise @@ -307,13 +489,13 @@ export function HappyThread(props: { loadLockRef.current = false } }) - }, []) + }, [isInitialScrollSettling]) const handleOutlineSelect = useCallback((item: ConversationOutlineItem) => { const target = document.getElementById(getConversationMessageAnchorId(item.targetMessageId)) if (target) { target.scrollIntoView({ block: 'start', behavior: 'smooth' }) - setAutoScrollEnabled(false) + autoScrollEnabledRef.current = false } props.onOutlineItemClick?.(item) props.onOutlineOpenChange(false) @@ -337,6 +519,9 @@ export function HappyThread(props: { (entries) => { for (const entry of entries) { if (entry.isIntersecting) { + if (isInitialScrollSettling()) { + continue + } handleLoadMoreRef.current() } } @@ -349,19 +534,44 @@ export function HappyThread(props: { observer.observe(sentinel) return () => observer.disconnect() - }, [props.hasMoreMessages, props.isLoadingMessages]) + }, [props.hasMoreMessages, props.isLoadingMessages, isInitialScrollSettling]) + + useEffect(() => { + const content = contentRef.current + if (!content || typeof ResizeObserver === 'undefined') { + return + } + + const observer = new ResizeObserver(() => { + if (isInitialScrollSettling() && autoScrollEnabledRef.current && !pendingScrollRef.current) { + scrollToBottomInstant() + } + }) + observer.observe(content) + return () => observer.disconnect() + }, [isInitialScrollSettling, scrollToBottomInstant]) useLayoutEffect(() => { const pending = pendingScrollRef.current const viewport = viewportRef.current - if (!pending || !viewport) { + if (!viewport) { return } - const delta = viewport.scrollHeight - pending.scrollHeight - viewport.scrollTop = pending.scrollTop + delta - pendingScrollRef.current = null - loadLockRef.current = false - }, [props.messagesVersion]) + if (pending) { + const restoredByAnchor = pending.anchor ? restoreScrollAnchor(viewport, pending.anchor) : false + if (!restoredByAnchor) { + const delta = viewport.scrollHeight - pending.scrollHeight + viewport.scrollTop = pending.scrollTop + delta + } + lastScrollTopRef.current = viewport.scrollTop + pendingScrollRef.current = null + loadLockRef.current = false + return + } + if (atBottomRef.current && autoScrollEnabledRef.current) { + scrollToBottomInstant() + } + }, [props.messagesVersion, scrollToBottomInstant]) useEffect(() => { isLoadingMoreRef.current = props.isLoadingMoreMessages @@ -387,9 +597,15 @@ export function HappyThread(props: { onRetryMessage: props.onRetryMessage }}> - +
-
+