@thaut/claude-ide 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/claude-ide.js +23 -0
- package/package.json +75 -4
- package/server/add-dirs.ts +126 -0
- package/server/auth.test.ts +148 -0
- package/server/auth.ts +235 -0
- package/server/bang.ts +54 -0
- package/server/btw-instructions.ts +19 -0
- package/server/btw-policy.ts +17 -0
- package/server/btw.ts +143 -0
- package/server/child-env.ts +29 -0
- package/server/cli-control.ts +255 -0
- package/server/cli-files.ts +55 -0
- package/server/cli-info.ts +73 -0
- package/server/config/account.ts +65 -0
- package/server/config/agents.ts +186 -0
- package/server/config/mcp.ts +86 -0
- package/server/config/memory.ts +193 -0
- package/server/config/paths.ts +156 -0
- package/server/config/plugins.ts +204 -0
- package/server/config/routes.ts +95 -0
- package/server/config/settings.ts +246 -0
- package/server/config.ts +46 -0
- package/server/db.ts +121 -0
- package/server/directories.test.ts +180 -0
- package/server/directories.ts +87 -0
- package/server/dist-version.ts +69 -0
- package/server/durable-tasks.ts +61 -0
- package/server/file-transfers.ts +79 -0
- package/server/files.test.ts +127 -0
- package/server/files.ts +386 -0
- package/server/fs-queue.ts +14 -0
- package/server/fsutil.ts +144 -0
- package/server/git.ts +86 -0
- package/server/goal-state.test.ts +64 -0
- package/server/goal-state.ts +78 -0
- package/server/history-pages.test.ts +619 -0
- package/server/history-pages.ts +513 -0
- package/server/http-channel-manager.ts +468 -0
- package/server/http-channel-pull.test.ts +314 -0
- package/server/http-channel-push.test.ts +364 -0
- package/server/http-channel-registration.test.ts +291 -0
- package/server/http-channel-routes.ts +73 -0
- package/server/http-channel-store.ts +142 -0
- package/server/http-channel-test-fixture.ts +103 -0
- package/server/ide-messages-mcp.test.ts +82 -0
- package/server/ide-messages-mcp.ts +47 -0
- package/server/idecron-mcp.ts +65 -0
- package/server/idecron.test.ts +79 -0
- package/server/idecron.ts +271 -0
- package/server/index.ts +336 -0
- package/server/live-session.test.ts +1126 -0
- package/server/live-session.ts +1165 -0
- package/server/project-routes.ts +46 -0
- package/server/projects.test.ts +28 -0
- package/server/push.ts +315 -0
- package/server/report-cache.ts +28 -0
- package/server/session-aliases.test.ts +57 -0
- package/server/session-aliases.ts +50 -0
- package/server/session-branch.ts +71 -0
- package/server/session-dirs.ts +189 -0
- package/server/session-file.test.ts +134 -0
- package/server/session-file.ts +113 -0
- package/server/session-messaging.test.ts +297 -0
- package/server/session-messaging.ts +165 -0
- package/server/sessions.ts +638 -0
- package/server/subagents.test.ts +43 -0
- package/server/subagents.ts +58 -0
- package/server/tool-images.test.ts +76 -0
- package/server/tool-images.ts +45 -0
- package/server/turn-changes.ts +308 -0
- package/server/worktree-git.ts +87 -0
- package/server/worktrees.ts +138 -0
- package/server/ws.test.ts +195 -0
- package/server/ws.ts +197 -0
- package/shared/agent-model.ts +15 -0
- package/shared/agent-tasks.ts +12 -0
- package/shared/config.ts +175 -0
- package/shared/directories.ts +12 -0
- package/shared/files.ts +51 -0
- package/shared/http-channels.ts +63 -0
- package/shared/message-window.ts +189 -0
- package/shared/project.ts +77 -0
- package/shared/protocol.ts +203 -0
- package/shared/task-types.ts +4 -0
- package/shared/todos.ts +40 -0
- package/shared/write-policy.ts +9 -0
- package/web/dist/assets/AgentView-wrTKgcJ3.js +1 -0
- package/web/dist/assets/AnnotationSelection-BZmNmwVW.js +7 -0
- package/web/dist/assets/ChangesView-G1WTzh4q.js +2 -0
- package/web/dist/assets/FilesView-DPgAbYt_.js +6 -0
- package/web/dist/assets/Form-BKTwiYyw.js +1 -0
- package/web/dist/assets/HomePage-Dq2apjBe.js +1 -0
- package/web/dist/assets/Markdown-IwP-xF29.js +67 -0
- package/web/dist/assets/MessageList-DD9GypSy.js +15 -0
- package/web/dist/assets/Modal-g2rv8I_n.js +1 -0
- package/web/dist/assets/NotificationSettings-DpuLO_Bv.js +1 -0
- package/web/dist/assets/OverviewPage-Dx6miabE.js +1 -0
- package/web/dist/assets/ProjectHome-CousyTLL.js +1 -0
- package/web/dist/assets/SessionView-CPVL7_HO.js +2 -0
- package/web/dist/assets/SettingsPage-D_oYSnW2.js +7 -0
- package/web/dist/assets/WorktreesView-C7e0HV__.js +1 -0
- package/web/dist/assets/abap-CLvhMVsD.js +1 -0
- package/web/dist/assets/actionscript-3-Cs6JJXT5.js +1 -0
- package/web/dist/assets/ada-X3Y2mlq2.js +1 -0
- package/web/dist/assets/ahk-DwADkxyd.js +1 -0
- package/web/dist/assets/ahk2-_6rzW9BS.js +1 -0
- package/web/dist/assets/andromeeda-vGVdxbeo.js +1 -0
- package/web/dist/assets/angular-html-Cw29lun8.js +1 -0
- package/web/dist/assets/angular-ts-BXz-O3SJ.js +1 -0
- package/web/dist/assets/apache-U0d_L8uA.js +1 -0
- package/web/dist/assets/apex-KFGFyKwG.js +1 -0
- package/web/dist/assets/apl-xBchdRhy.js +1 -0
- package/web/dist/assets/applescript-CCn79oCD.js +1 -0
- package/web/dist/assets/ara-4CJ0cIlV.js +1 -0
- package/web/dist/assets/asciidoc-SCjQUq34.js +1 -0
- package/web/dist/assets/asm-Cmm7eHzH.js +1 -0
- package/web/dist/assets/astro-1q6DGuN3.js +1 -0
- package/web/dist/assets/aurora-x-CDeNXAV0.js +1 -0
- package/web/dist/assets/awk-BWXHIvNe.js +1 -0
- package/web/dist/assets/ayu-dark-DluEY0Gj.js +1 -0
- package/web/dist/assets/ayu-light-C3h-C4tm.js +1 -0
- package/web/dist/assets/ayu-mirage-Bqwy1Gya.js +1 -0
- package/web/dist/assets/ballerina-B7ZEbQpA.js +1 -0
- package/web/dist/assets/bat-qdxAdBWQ.js +1 -0
- package/web/dist/assets/beancount-C2IN4Vk8.js +1 -0
- package/web/dist/assets/berry-DKpUyyne.js +1 -0
- package/web/dist/assets/bibtex-Ci_nEsc7.js +1 -0
- package/web/dist/assets/bicep-CUHmPFLl.js +1 -0
- package/web/dist/assets/bird2-CYIUgYqB.js +1 -0
- package/web/dist/assets/blade-Cqdv4oZN.js +1 -0
- package/web/dist/assets/bsl-CVED6lJ1.js +1 -0
- package/web/dist/assets/c-D9gIxSfx.js +1 -0
- package/web/dist/assets/c3-D0apG41V.js +1 -0
- package/web/dist/assets/cadence-CQ2zXKGN.js +1 -0
- package/web/dist/assets/cairo-DRAZz75c.js +1 -0
- package/web/dist/assets/catppuccin-frappe-C9Q5zVZY.js +1 -0
- package/web/dist/assets/catppuccin-latte-CtsyrtPq.js +1 -0
- package/web/dist/assets/catppuccin-macchiato-R8nAf1vR.js +1 -0
- package/web/dist/assets/catppuccin-mocha-DjuaAJKi.js +1 -0
- package/web/dist/assets/chapel-CuseGurG.js +1 -0
- package/web/dist/assets/clarity-CtfBBspg.js +1 -0
- package/web/dist/assets/clojure-DqKBuwfJ.js +1 -0
- package/web/dist/assets/cmake-Bj61d0ZC.js +1 -0
- package/web/dist/assets/cobol-Bdpj3Qoh.js +1 -0
- package/web/dist/assets/codeowners-C8r90Shi.js +1 -0
- package/web/dist/assets/codeql-oeQT6MSM.js +1 -0
- package/web/dist/assets/coffee-Sk8pHFjs.js +1 -0
- package/web/dist/assets/common-lisp-Cv5bFMCO.js +1 -0
- package/web/dist/assets/coq-CGaSfhwR.js +1 -0
- package/web/dist/assets/core-BxI4V4-T.js +12 -0
- package/web/dist/assets/cpp-BQfBYKx3.js +1 -0
- package/web/dist/assets/crystal-B3j7DoOZ.js +1 -0
- package/web/dist/assets/csharp-dmMxLVLW.js +1 -0
- package/web/dist/assets/css-DpFbYmRh.js +1 -0
- package/web/dist/assets/csv-Dx-8-gkx.js +1 -0
- package/web/dist/assets/cue-CE9AQfxI.js +1 -0
- package/web/dist/assets/cypher-ClKdZ_lG.js +1 -0
- package/web/dist/assets/d-qD-0Kul2.js +1 -0
- package/web/dist/assets/dark-plus-Cs2F2srj.js +1 -0
- package/web/dist/assets/dart-CHZY9Xhz.js +1 -0
- package/web/dist/assets/dax-BkyTk9wS.js +1 -0
- package/web/dist/assets/desktop-Dlh5hvp9.js +1 -0
- package/web/dist/assets/diff-woXpYk--.js +1 -0
- package/web/dist/assets/docker-IyjqRm3v.js +1 -0
- package/web/dist/assets/dotenv-CuH5my32.js +1 -0
- package/web/dist/assets/dracula-BHWKrbxM.js +1 -0
- package/web/dist/assets/dracula-soft-5eyTD99u.js +1 -0
- package/web/dist/assets/dream-maker-DW3nJb8Q.js +1 -0
- package/web/dist/assets/edge-DXQWmXTB.js +1 -0
- package/web/dist/assets/elixir-CrDIY2Vo.js +1 -0
- package/web/dist/assets/elm-BslFEg8K.js +1 -0
- package/web/dist/assets/emacs-lisp-DR2vtWaj.js +1 -0
- package/web/dist/assets/engine-javascript-DJg3-Vu2.js +141 -0
- package/web/dist/assets/erb-CUYhg0Ws.js +1 -0
- package/web/dist/assets/erlang-BXdgHVfi.js +1 -0
- package/web/dist/assets/everforest-dark-sB-x3p7T.js +1 -0
- package/web/dist/assets/everforest-light-Df2xbC6M.js +1 -0
- package/web/dist/assets/fennel-DQxkIbk2.js +1 -0
- package/web/dist/assets/fish-BJitypiv.js +1 -0
- package/web/dist/assets/fluent-C03EYrpw.js +1 -0
- package/web/dist/assets/format-DrBC7w0A.js +1 -0
- package/web/dist/assets/fortran-fixed-form-D6mN0kjy.js +1 -0
- package/web/dist/assets/fortran-free-form-CYNrtFtB.js +1 -0
- package/web/dist/assets/fsharp-CwoEF39o.js +1 -0
- package/web/dist/assets/gdresource-CZkFGo3B.js +1 -0
- package/web/dist/assets/gdscript-DYvRD3hg.js +1 -0
- package/web/dist/assets/gdshader-CBce3t8t.js +1 -0
- package/web/dist/assets/genie-CV2tkWYe.js +1 -0
- package/web/dist/assets/gherkin-DExj1W_8.js +1 -0
- package/web/dist/assets/git-commit-dUGrvm6Q.js +1 -0
- package/web/dist/assets/git-rebase-4zCRqF28.js +1 -0
- package/web/dist/assets/github-dark-C-LZuMrd.js +1 -0
- package/web/dist/assets/github-dark-default-DXG-b-1a.js +1 -0
- package/web/dist/assets/github-dark-dimmed-Bx1FflLF.js +1 -0
- package/web/dist/assets/github-dark-high-contrast-B_tTalzw.js +1 -0
- package/web/dist/assets/github-light-EUqPIrTm.js +1 -0
- package/web/dist/assets/github-light-default-BXViO-2h.js +1 -0
- package/web/dist/assets/github-light-high-contrast-B68TUdTA.js +1 -0
- package/web/dist/assets/gleam-CSRkHgEL.js +1 -0
- package/web/dist/assets/glimmer-js-7adTRvH4.js +1 -0
- package/web/dist/assets/glimmer-ts-IdSskDYw.js +1 -0
- package/web/dist/assets/glsl-Qzy53KDt.js +1 -0
- package/web/dist/assets/gn-CAXYAgV-.js +1 -0
- package/web/dist/assets/gnuplot-7GGW24-e.js +1 -0
- package/web/dist/assets/go-rLFTqkRN.js +1 -0
- package/web/dist/assets/graphql-CV_puxbB.js +1 -0
- package/web/dist/assets/groovy-CacY0gHj.js +1 -0
- package/web/dist/assets/gruvbox-dark-hard-C820rvS2.js +1 -0
- package/web/dist/assets/gruvbox-dark-medium-BPjhmG05.js +1 -0
- package/web/dist/assets/gruvbox-dark-soft-MrdJrrXF.js +1 -0
- package/web/dist/assets/gruvbox-light-hard-BC_s9l72.js +1 -0
- package/web/dist/assets/gruvbox-light-medium-BAWPOn9u.js +1 -0
- package/web/dist/assets/gruvbox-light-soft-BSMLrYjP.js +1 -0
- package/web/dist/assets/hack-C5GPjiLV.js +1 -0
- package/web/dist/assets/haml-BQ5YR1DQ.js +1 -0
- package/web/dist/assets/handlebars-DnhRK9A1.js +1 -0
- package/web/dist/assets/haskell-D8IpX4py.js +1 -0
- package/web/dist/assets/haxe-C8oCnwJJ.js +1 -0
- package/web/dist/assets/hcl-Dh228itO.js +1 -0
- package/web/dist/assets/highlight.worker-CY-ZeDAl.js +1 -0
- package/web/dist/assets/hjson-CxZEssPk.js +1 -0
- package/web/dist/assets/hlsl-Cvrh5tZx.js +1 -0
- package/web/dist/assets/horizon-CE9ld1lL.js +1 -0
- package/web/dist/assets/horizon-bright-DSNQnXHK.js +1 -0
- package/web/dist/assets/houston-CsvMBhTu.js +1 -0
- package/web/dist/assets/html-CmHiEKV9.js +1 -0
- package/web/dist/assets/html-derivative-BXE-rYIK.js +1 -0
- package/web/dist/assets/http-DPjjqtcL.js +1 -0
- package/web/dist/assets/hurl-AMBpSloO.js +1 -0
- package/web/dist/assets/hxml-D7Y24wKF.js +1 -0
- package/web/dist/assets/hy-CZbG8q4J.js +1 -0
- package/web/dist/assets/imba-DsUTQ-LC.js +1 -0
- package/web/dist/assets/index-DNPnl4sh.css +2 -0
- package/web/dist/assets/index-DQh72lEx.js +14 -0
- package/web/dist/assets/ini-B5eOa1yu.js +1 -0
- package/web/dist/assets/java-CEM7Tdw_.js +1 -0
- package/web/dist/assets/javascript-CU3PLowd.js +1 -0
- package/web/dist/assets/jinja-fR0x_9Xy.js +1 -0
- package/web/dist/assets/jison-BoKsxs5M.js +1 -0
- package/web/dist/assets/json-BM2FWsRB.js +1 -0
- package/web/dist/assets/json5-BR5RXkoi.js +1 -0
- package/web/dist/assets/jsonc-CYpm1nAK.js +1 -0
- package/web/dist/assets/jsonl-CmCQp5Yx.js +1 -0
- package/web/dist/assets/jsonnet-CJTPZ8u_.js +1 -0
- package/web/dist/assets/jssm-DXw9l8Rf.js +1 -0
- package/web/dist/assets/jsx-B5fTecQP.js +1 -0
- package/web/dist/assets/julia-DGyDG39_.js +1 -0
- package/web/dist/assets/just-B-RyLcSR.js +1 -0
- package/web/dist/assets/kanagawa-dragon-Bozd7klA.js +1 -0
- package/web/dist/assets/kanagawa-lotus-BmuOG9ZW.js +1 -0
- package/web/dist/assets/kanagawa-wave-BML5mF2J.js +1 -0
- package/web/dist/assets/kdl-CsD5j6eV.js +1 -0
- package/web/dist/assets/kotlin-DhhofPvG.js +1 -0
- package/web/dist/assets/kusto-C7mF5XQf.js +1 -0
- package/web/dist/assets/langs-CciPKqaF.js +1 -0
- package/web/dist/assets/laserwave-C_8bwKvT.js +1 -0
- package/web/dist/assets/latex-CV1pQelm.js +1 -0
- package/web/dist/assets/lean-CewbzKMR.js +1 -0
- package/web/dist/assets/less-DVTAwKKz.js +1 -0
- package/web/dist/assets/lib-DlcVMUqT.js +1 -0
- package/web/dist/assets/light-plus-DVQuIRkW.js +1 -0
- package/web/dist/assets/liquid-9p05RSzQ.js +1 -0
- package/web/dist/assets/llvm-DqHOMS-s.js +1 -0
- package/web/dist/assets/log-BNLmms1o.js +1 -0
- package/web/dist/assets/logo-Cluzi2Zq.js +1 -0
- package/web/dist/assets/lua-CIPVh0S-.js +1 -0
- package/web/dist/assets/luau-VGWVLoqX.js +1 -0
- package/web/dist/assets/make-Dixweg8N.js +1 -0
- package/web/dist/assets/markdown-BYOwaDjH.js +1 -0
- package/web/dist/assets/marko-h2h6YUMS.js +1 -0
- package/web/dist/assets/material-theme-Bm3Qr25_.js +1 -0
- package/web/dist/assets/material-theme-darker-2IIEA8gg.js +1 -0
- package/web/dist/assets/material-theme-lighter-uhdI0v04.js +1 -0
- package/web/dist/assets/material-theme-ocean-CHQ94UKr.js +1 -0
- package/web/dist/assets/material-theme-palenight-B5W6OYN7.js +1 -0
- package/web/dist/assets/matlab-D7qyCx1q.js +1 -0
- package/web/dist/assets/mdc-yAyZMdoC.js +1 -0
- package/web/dist/assets/mdx-DQZ5AkYe.js +1 -0
- package/web/dist/assets/mermaid-BxpfTIzt.js +1 -0
- package/web/dist/assets/message-font-size-DoaZfwkT.js +1 -0
- package/web/dist/assets/min-dark-BSWPekZh.js +1 -0
- package/web/dist/assets/min-light-DDpmG2fV.js +1 -0
- package/web/dist/assets/mipsasm-BMqwQI7S.js +1 -0
- package/web/dist/assets/mojo-fg40_ntQ.js +1 -0
- package/web/dist/assets/monokai-BscAjiQZ.js +1 -0
- package/web/dist/assets/moonbit-D9W784E_.js +1 -0
- package/web/dist/assets/move-BYnGhZoM.js +1 -0
- package/web/dist/assets/narrat-_X_XdTYD.js +1 -0
- package/web/dist/assets/nextflow-BoTnSBQ_.js +1 -0
- package/web/dist/assets/nextflow-groovy-D7IRt6Pp.js +1 -0
- package/web/dist/assets/nginx-jWINucNT.js +1 -0
- package/web/dist/assets/night-owl-DhmEMT88.js +1 -0
- package/web/dist/assets/night-owl-light-eJ-hLW7d.js +1 -0
- package/web/dist/assets/nim-Dtt9DZ0t.js +1 -0
- package/web/dist/assets/nix-Cl_7024t.js +1 -0
- package/web/dist/assets/nord-Cb4Vim4T.js +1 -0
- package/web/dist/assets/nsis-CjNLYCLg.js +1 -0
- package/web/dist/assets/nushell--0fT3PI1.js +1 -0
- package/web/dist/assets/objective-c-D1A_Heim.js +1 -0
- package/web/dist/assets/objective-cpp-BsSzOQcm.js +1 -0
- package/web/dist/assets/ocaml-O90oeIOV.js +1 -0
- package/web/dist/assets/odin-B1RWQWA5.js +1 -0
- package/web/dist/assets/one-dark-pro-C6YyR8OD.js +1 -0
- package/web/dist/assets/one-light-D7Lr4KcI.js +1 -0
- package/web/dist/assets/openscad-BUDT5pXO.js +1 -0
- package/web/dist/assets/org-ClD5huOa.js +1 -0
- package/web/dist/assets/pascal-4ZHwLPI5.js +1 -0
- package/web/dist/assets/perl-BFegxyeJ.js +1 -0
- package/web/dist/assets/php-B-qOA3Y4.js +1 -0
- package/web/dist/assets/pkl-ot-7Btpt.js +1 -0
- package/web/dist/assets/plastic-DQwYfKfQ.js +1 -0
- package/web/dist/assets/plsql-DGHpHOYJ.js +1 -0
- package/web/dist/assets/po-BiJDBrnU.js +1 -0
- package/web/dist/assets/poimandres-DRFjx7u4.js +1 -0
- package/web/dist/assets/polar-C7UOKdEL.js +1 -0
- package/web/dist/assets/postcss-BXeXVLqQ.js +1 -0
- package/web/dist/assets/powerquery-DNMTfnFr.js +1 -0
- package/web/dist/assets/powershell-QtSnmXcu.js +1 -0
- package/web/dist/assets/prisma-DMgNhnYS.js +1 -0
- package/web/dist/assets/prolog-iXnhIJG7.js +1 -0
- package/web/dist/assets/proto-DB4EqR-F.js +1 -0
- package/web/dist/assets/pug-Cq0NBPu-.js +1 -0
- package/web/dist/assets/puppet-CDv2pdJW.js +1 -0
- package/web/dist/assets/purescript-9MfHhQsQ.js +1 -0
- package/web/dist/assets/python-gzcpVVnB.js +1 -0
- package/web/dist/assets/qml-CYUsa87k.js +1 -0
- package/web/dist/assets/qmldir-DCQb3MpD.js +1 -0
- package/web/dist/assets/qss-Fe1Jh2GI.js +1 -0
- package/web/dist/assets/r-4xJhgbOf.js +1 -0
- package/web/dist/assets/racket-DcIDlBhZ.js +1 -0
- package/web/dist/assets/raku-B3gFvitq.js +1 -0
- package/web/dist/assets/razor-BlHaOHx8.js +1 -0
- package/web/dist/assets/rbs-C0MpOrj4.js +1 -0
- package/web/dist/assets/react-B4UGpcWC.js +1 -0
- package/web/dist/assets/red-CYlAq2b0.js +1 -0
- package/web/dist/assets/reg-CRGYupPL.js +1 -0
- package/web/dist/assets/regexp-BUSxjv2o.js +1 -0
- package/web/dist/assets/rel-BtDbiS_P.js +1 -0
- package/web/dist/assets/riscv-Ckw8ddFX.js +1 -0
- package/web/dist/assets/ron-VUp2lXgN.js +1 -0
- package/web/dist/assets/rose-pine-aMsaMD1D.js +1 -0
- package/web/dist/assets/rose-pine-dawn-8PwUyIlP.js +1 -0
- package/web/dist/assets/rose-pine-moon-DO-qXg-I.js +1 -0
- package/web/dist/assets/rosmsg-CAekHB0j.js +1 -0
- package/web/dist/assets/rst-zjfguEh7.js +1 -0
- package/web/dist/assets/ruby-4oR9waBK.js +1 -0
- package/web/dist/assets/rust-DpiIf5JO.js +1 -0
- package/web/dist/assets/sas-Do4V_2i9.js +1 -0
- package/web/dist/assets/sass-DXrisJhu.js +1 -0
- package/web/dist/assets/scala-BiFt_7mu.js +1 -0
- package/web/dist/assets/scheme-DQCgrYNe.js +1 -0
- package/web/dist/assets/scss-CW6JPtag.js +1 -0
- package/web/dist/assets/sdbl-bTVj8UrX.js +1 -0
- package/web/dist/assets/shaderlab-C4rnI7x0.js +1 -0
- package/web/dist/assets/shellscript-zBmOMFJh.js +1 -0
- package/web/dist/assets/shellsession-DxGUTWaf.js +1 -0
- package/web/dist/assets/slack-dark-DnToyrRv.js +1 -0
- package/web/dist/assets/slack-ochin-B2OO5cIa.js +1 -0
- package/web/dist/assets/smalltalk-BHpQb5nM.js +1 -0
- package/web/dist/assets/smithy-Dgaz7aur.js +1 -0
- package/web/dist/assets/snazzy-light-4G7pJPwS.js +1 -0
- package/web/dist/assets/solarized-dark-b97czJOk.js +1 -0
- package/web/dist/assets/solarized-light-DFPxpIYQ.js +1 -0
- package/web/dist/assets/solidity-Bj7E1mRn.js +1 -0
- package/web/dist/assets/soy-DVtw20Yn.js +1 -0
- package/web/dist/assets/sparql-C0ia5K60.js +1 -0
- package/web/dist/assets/splunk-BC2Px7Mm.js +1 -0
- package/web/dist/assets/sql-DGCM-jag.js +1 -0
- package/web/dist/assets/ssh-config-BgfXC-Er.js +1 -0
- package/web/dist/assets/stata-DfuMOXna.js +1 -0
- package/web/dist/assets/stylus-B6D30XZt.js +1 -0
- package/web/dist/assets/surrealql-CBoRWxfR.js +1 -0
- package/web/dist/assets/svelte-g_uCUem5.js +1 -0
- package/web/dist/assets/swift-CyEgAFGc.js +1 -0
- package/web/dist/assets/synthwave-84-nFMaYfgc.js +1 -0
- package/web/dist/assets/system-verilog-Dh5ihWbf.js +1 -0
- package/web/dist/assets/systemd-BxMlprV5.js +1 -0
- package/web/dist/assets/talonscript-CohzipZa.js +1 -0
- package/web/dist/assets/tasl-DMoTqEGO.js +1 -0
- package/web/dist/assets/tcl-CZd0xW_V.js +1 -0
- package/web/dist/assets/templ-N5C1hJ4Y.js +1 -0
- package/web/dist/assets/terraform-DswuEJGm.js +1 -0
- package/web/dist/assets/tex-mx6xDwuK.js +1 -0
- package/web/dist/assets/themes-CNElGaXw.js +1 -0
- package/web/dist/assets/tokyo-night-oM2G3aXe.js +1 -0
- package/web/dist/assets/toml-CcmNWLt0.js +1 -0
- package/web/dist/assets/ts-tags-C-j71FRb.js +1 -0
- package/web/dist/assets/tsv-sltzmVWM.js +1 -0
- package/web/dist/assets/tsx-z04y7qR9.js +1 -0
- package/web/dist/assets/turtle-ByJddavk.js +1 -0
- package/web/dist/assets/twig-Bz57G_nl.js +1 -0
- package/web/dist/assets/typescript-RMqVTmzR.js +1 -0
- package/web/dist/assets/typespec-BGhR2SqG.js +1 -0
- package/web/dist/assets/typst-BgkqA2Pg.js +1 -0
- package/web/dist/assets/v-DBj4K9d_.js +1 -0
- package/web/dist/assets/vala-zf12oZj6.js +1 -0
- package/web/dist/assets/vb-DOwzaHZ0.js +1 -0
- package/web/dist/assets/verilog-B2gwz-sJ.js +1 -0
- package/web/dist/assets/vesper-DdrHHSXu.js +1 -0
- package/web/dist/assets/vhdl-BroJfC0k.js +1 -0
- package/web/dist/assets/viml-DvXPmvsu.js +1 -0
- package/web/dist/assets/vitesse-black-fwtXNY1n.js +1 -0
- package/web/dist/assets/vitesse-dark-BZCL-v6S.js +1 -0
- package/web/dist/assets/vitesse-light-VbXTXTou.js +1 -0
- package/web/dist/assets/vue-CQgvQytb.js +1 -0
- package/web/dist/assets/vue-html-Dhl6fh9l.js +1 -0
- package/web/dist/assets/vue-vine-Dw_uU59K.js +1 -0
- package/web/dist/assets/vyper-CgoNMtux.js +1 -0
- package/web/dist/assets/wasm-ByWQv1Qj.js +1 -0
- package/web/dist/assets/wenyan-C8pVoKbM.js +1 -0
- package/web/dist/assets/wgsl-BsKzXJz4.js +1 -0
- package/web/dist/assets/wikitext-ClFFjSW2.js +1 -0
- package/web/dist/assets/wit-DdvCle-K.js +1 -0
- package/web/dist/assets/wolfram-DLL8P-h_.js +1 -0
- package/web/dist/assets/xml-B49klYQ3.js +1 -0
- package/web/dist/assets/xsl-D7tnD3qq.js +1 -0
- package/web/dist/assets/yaml-DNQ5_xkL.js +1 -0
- package/web/dist/assets/zenscript-BnlCZFoB.js +1 -0
- package/web/dist/assets/zig-CMLA9XwU.js +1 -0
- package/web/dist/icons/apple-touch-icon.png +0 -0
- package/web/dist/icons/badge-96.png +0 -0
- package/web/dist/icons/icon-192.png +0 -0
- package/web/dist/icons/icon-512.png +0 -0
- package/web/dist/icons/icon.svg +4 -0
- package/web/dist/icons/maskable-192.png +0 -0
- package/web/dist/icons/maskable-512.png +0 -0
- package/web/dist/index.html +30 -0
- package/web/dist/manifest.webmanifest +20 -0
- package/web/dist/sw.js +176 -0
- package/web/dist/version.json +1 -0
- package/web/src/App.tsx +97 -0
- package/web/src/agent-history-flow.test.ts +191 -0
- package/web/src/agent-history-flow.ts +58 -0
- package/web/src/agent-owners.ts +30 -0
- package/web/src/annotation-selection.test.ts +36 -0
- package/web/src/annotation-selection.ts +123 -0
- package/web/src/annotations.test.ts +279 -0
- package/web/src/annotations.ts +191 -0
- package/web/src/api.ts +27 -0
- package/web/src/auth-events.ts +11 -0
- package/web/src/auth-lifecycle.test.ts +105 -0
- package/web/src/clipboard.ts +32 -0
- package/web/src/components/ActivityNotifier.tsx +22 -0
- package/web/src/components/AnnotationEditor.tsx +135 -0
- package/web/src/components/AnnotationSelection.tsx +122 -0
- package/web/src/components/Automation.tsx +390 -0
- package/web/src/components/CodeView.tsx +121 -0
- package/web/src/components/Composer.tsx +552 -0
- package/web/src/components/ComposerAnnotations.tsx +45 -0
- package/web/src/components/Confirmation.tsx +91 -0
- package/web/src/components/CopyButton.tsx +23 -0
- package/web/src/components/DiffView.tsx +94 -0
- package/web/src/components/DirectoryPicker.tsx +230 -0
- package/web/src/components/FileCopyMenu.tsx +58 -0
- package/web/src/components/FileEntryActions.tsx +95 -0
- package/web/src/components/ImagePreview.tsx +68 -0
- package/web/src/components/Items.tsx +608 -0
- package/web/src/components/Markdown.tsx +73 -0
- package/web/src/components/MessageList.tsx +458 -0
- package/web/src/components/Modal.tsx +72 -0
- package/web/src/components/NotificationEntry.tsx +31 -0
- package/web/src/components/NotificationSettings.tsx +226 -0
- package/web/src/components/NotificationToasts.tsx +59 -0
- package/web/src/components/OpenTabs.tsx +112 -0
- package/web/src/components/Pending.tsx +175 -0
- package/web/src/components/ProjectExtras.tsx +299 -0
- package/web/src/components/ProjectNav.tsx +47 -0
- package/web/src/components/SessionActions.tsx +271 -0
- package/web/src/components/SessionChannels.tsx +288 -0
- package/web/src/components/SessionExtras.tsx +263 -0
- package/web/src/components/SettingsLink.tsx +21 -0
- package/web/src/components/Shell.tsx +320 -0
- package/web/src/components/SvgPreview.tsx +18 -0
- package/web/src/components/TodoPanel.tsx +37 -0
- package/web/src/components/VersionNotice.tsx +40 -0
- package/web/src/components/icons.tsx +43 -0
- package/web/src/diff.ts +62 -0
- package/web/src/drafts.test.ts +80 -0
- package/web/src/drafts.ts +64 -0
- package/web/src/format.ts +5 -0
- package/web/src/highlight.worker.ts +49 -0
- package/web/src/hooks.ts +59 -0
- package/web/src/item-view-state.test.ts +51 -0
- package/web/src/item-view-state.ts +43 -0
- package/web/src/live-history-flow.test.ts +77 -0
- package/web/src/live-merge.test.ts +64 -0
- package/web/src/live-merge.ts +50 -0
- package/web/src/live-message-hydration.test.ts +253 -0
- package/web/src/live.ts +404 -0
- package/web/src/main.tsx +27 -0
- package/web/src/markdown-assets.test.ts +70 -0
- package/web/src/markdown-assets.ts +78 -0
- package/web/src/markdown-html.test.ts +84 -0
- package/web/src/markdown-html.ts +60 -0
- package/web/src/markdown-svg.test.ts +37 -0
- package/web/src/markdown-svg.ts +42 -0
- package/web/src/markdown-tables.test.ts +34 -0
- package/web/src/markdown-tables.ts +17 -0
- package/web/src/message-bodies.ts +117 -0
- package/web/src/message-cache.test.ts +208 -0
- package/web/src/message-cache.ts +158 -0
- package/web/src/message-font-size.test.ts +41 -0
- package/web/src/message-font-size.ts +57 -0
- package/web/src/message-time.test.ts +32 -0
- package/web/src/message-time.ts +26 -0
- package/web/src/notify.ts +132 -0
- package/web/src/open-tabs.test.ts +69 -0
- package/web/src/open-tabs.ts +110 -0
- package/web/src/pages/AgentView.tsx +138 -0
- package/web/src/pages/ChangesView.tsx +193 -0
- package/web/src/pages/FilesView.tsx +406 -0
- package/web/src/pages/HomePage.tsx +96 -0
- package/web/src/pages/LoginPage.tsx +56 -0
- package/web/src/pages/OverviewPage.tsx +124 -0
- package/web/src/pages/ProjectHome.tsx +136 -0
- package/web/src/pages/SessionView.tsx +238 -0
- package/web/src/pages/WorktreesView.tsx +197 -0
- package/web/src/pages/settings/AccountPage.tsx +149 -0
- package/web/src/pages/settings/AgentsPage.tsx +182 -0
- package/web/src/pages/settings/ConfigPage.tsx +181 -0
- package/web/src/pages/settings/GeneralPage.tsx +27 -0
- package/web/src/pages/settings/HooksPage.tsx +244 -0
- package/web/src/pages/settings/McpPage.tsx +310 -0
- package/web/src/pages/settings/MemoryPage.tsx +309 -0
- package/web/src/pages/settings/PermissionsPage.tsx +160 -0
- package/web/src/pages/settings/PluginsPage.tsx +251 -0
- package/web/src/pages/settings/SettingsPage.tsx +141 -0
- package/web/src/pages/settings/SkillsPage.tsx +142 -0
- package/web/src/pages/settings/ui.tsx +165 -0
- package/web/src/preference-document.ts +133 -0
- package/web/src/project-actions.ts +18 -0
- package/web/src/project-files-state.test.ts +49 -0
- package/web/src/project-files-state.ts +64 -0
- package/web/src/project-images.ts +13 -0
- package/web/src/pwa.ts +33 -0
- package/web/src/queryClient.ts +5 -0
- package/web/src/reasoning-preference.test.ts +39 -0
- package/web/src/reasoning-preference.ts +42 -0
- package/web/src/session-confirmations.ts +36 -0
- package/web/src/stream-replay.test.ts +163 -0
- package/web/src/styles.css +231 -0
- package/web/src/tool-result-body.test.ts +115 -0
- package/web/src/transcript.test.ts +186 -0
- package/web/src/transcript.ts +583 -0
- package/web/src/turns.test.ts +338 -0
- package/web/src/turns.ts +178 -0
- package/web/src/ui/Button.tsx +60 -0
- package/web/src/ui/Controls.tsx +227 -0
- package/web/src/ui/Form.tsx +203 -0
- package/web/src/ui/MenuPopover.tsx +144 -0
- package/web/src/ui/Popover.tsx +147 -0
- package/web/src/ui/ResizablePane.tsx +110 -0
- package/web/src/ui/Select.tsx +150 -0
- package/web/src/ui/icons.tsx +270 -0
- package/web/src/unsaved.tsx +99 -0
- package/web/src/user-text.test.ts +60 -0
- package/web/src/user-text.ts +35 -0
- package/web/src/uuid.ts +12 -0
- package/README.md +0 -3
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import type { LiveEvent, SdkMessageLike } from "@shared/protocol.ts";
|
|
4
|
+
import { MESSAGE_LIMIT, SESSION_BYTES, SESSION_LIMIT, TOTAL_BYTES, boundedReplay, displayEvent } from "@shared/message-window.ts";
|
|
5
|
+
import { boundSession, boundSessions, browsingBase, compactEvents, edgeMessage, mergePage, sessionWeight } from "./message-cache.ts";
|
|
6
|
+
import type { SessionLive } from "./live-merge.ts";
|
|
7
|
+
import { Transcript } from "./transcript.ts";
|
|
8
|
+
|
|
9
|
+
const msg = (id: number, text = "hello"): SdkMessageLike => ({ type: "user", uuid: `u${id}`, message: { content: text } });
|
|
10
|
+
const event = (id: number, text?: string): LiveEvent => ({ k: "sdk", m: msg(id, text) });
|
|
11
|
+
const session = (events: LiveEvent[] = []): SessionLive => ({ seq: events.length, resetCount: 1, snapshot: { state: "running", pending: [], planMode: false, tasks: [], crons: [] }, events });
|
|
12
|
+
|
|
13
|
+
test("history and live replay share the 1,500-record cap", () => {
|
|
14
|
+
const s = session(Array.from({ length: 1400 }, (_, i) => event(i + 100)));
|
|
15
|
+
s.history = { ...mergePage(undefined, { items: Array.from({ length: 300 }, (_, i) => msg(i)), generation: "g" }, "latest", 1), source: "main" };
|
|
16
|
+
const next = boundSession(s);
|
|
17
|
+
assert.ok(next.history!.items.length + next.events.length <= MESSAGE_LIMIT);
|
|
18
|
+
assert.equal(next.history!.items[0]!.uuid, "u0");
|
|
19
|
+
assert.equal(next.evicted, true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("a long running turn reaches a stable payload and count ceiling", () => {
|
|
23
|
+
let s = session();
|
|
24
|
+
for (let batch = 0; batch < 8; batch++) {
|
|
25
|
+
s = boundSession({ ...s, events: [...s.events, ...Array.from({ length: 500 }, (_, i) => event(batch * 500 + i, "x".repeat(20_000)))] });
|
|
26
|
+
assert.ok(sessionWeight(s) <= SESSION_BYTES);
|
|
27
|
+
assert.ok(s.events.length <= MESSAGE_LIMIT);
|
|
28
|
+
}
|
|
29
|
+
assert.equal((s.events.at(-1) as Extract<LiveEvent, { k: "sdk" }>).m.uuid, "u3999");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("ordinary window eviction preserves the full active streaming checkpoint", () => {
|
|
33
|
+
const s = boundSession(session([
|
|
34
|
+
{ k: "delta", msgId: "active", index: 0, parent: null, kind: "text", text: "prefix " },
|
|
35
|
+
...Array.from({ length: MESSAGE_LIMIT + 100 }, (_, i) => event(i)),
|
|
36
|
+
{ k: "delta", msgId: "active", index: 0, parent: null, kind: "text", text: "suffix" },
|
|
37
|
+
]));
|
|
38
|
+
assert.ok(s.events.length <= MESSAGE_LIMIT);
|
|
39
|
+
assert.ok(sessionWeight(s) <= SESSION_BYTES);
|
|
40
|
+
const draft = new Transcript().update(undefined, s.events).find((i) => i.key === "draft-active:0");
|
|
41
|
+
assert.equal(draft?.kind, "text");
|
|
42
|
+
assert.equal(draft && "text" in draft ? draft.text : undefined, "prefix suffix");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("LRU releases the sixth session and obeys the global byte budget", () => {
|
|
46
|
+
const sessions = Object.fromEntries(Array.from({ length: 7 }, (_, i) => [String(i), { ...session([event(i)]), accessed: i }]));
|
|
47
|
+
const next = boundSessions(sessions, new Set(["0"]));
|
|
48
|
+
assert.equal(Object.keys(next).length, SESSION_LIMIT);
|
|
49
|
+
assert.ok(next["0"]);
|
|
50
|
+
assert.equal(next["1"], undefined);
|
|
51
|
+
assert.equal(next["2"], undefined);
|
|
52
|
+
const large = Object.fromEntries(Array.from({ length: 5 }, (_, i) => [String(i), boundSession({ ...session([event(i, "x".repeat(17_000_000))]), accessed: i })]));
|
|
53
|
+
const bounded = boundSessions(large, new Set(["4"]));
|
|
54
|
+
assert.ok(Object.values(bounded).reduce((n, s) => n + sessionWeight(s), 0) <= TOTAL_BYTES);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("older browsing keeps its reading edge while newer paging advances after the window fills", () => {
|
|
58
|
+
const items = Array.from({ length: 1600 }, (_, i) => msg(i));
|
|
59
|
+
const page = { items, generation: "g" };
|
|
60
|
+
const older = boundSession({ ...session(Array.from({ length: 750 }, (_, i) => event(2000 + i))), history: mergePage(undefined, page, "older", 1) });
|
|
61
|
+
assert.equal(older.history!.items[0]!.uuid, "u0");
|
|
62
|
+
assert.equal(older.history!.newer, "trimmed");
|
|
63
|
+
const newer = boundSession({ ...session(), history: mergePage(undefined, page, "newer", 2) });
|
|
64
|
+
assert.equal(newer.history!.items.at(-1)!.uuid, "u1599");
|
|
65
|
+
assert.equal(newer.history!.older, "trimmed");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("compacted deltas preserve settlement, result order and release old Transcript references", () => {
|
|
69
|
+
const events: LiveEvent[] = [
|
|
70
|
+
{ k: "delta", msgId: "m", index: 0, kind: "text", text: "he", parent: null },
|
|
71
|
+
{ k: "delta", msgId: "m", index: 0, kind: "text", text: "llo", parent: null },
|
|
72
|
+
{ k: "sdk", m: { type: "assistant", uuid: "a", message: { id: "m", content: [{ type: "text", text: "hello" }] } } },
|
|
73
|
+
{ k: "sdk", m: { type: "result", uuid: "r", subtype: "success" } },
|
|
74
|
+
];
|
|
75
|
+
const t = new Transcript();
|
|
76
|
+
const before = t.update(undefined, events);
|
|
77
|
+
const after = t.update(undefined, compactEvents(events));
|
|
78
|
+
assert.deepEqual(after, before);
|
|
79
|
+
const rebuilt = t.update([msg(10)], []);
|
|
80
|
+
assert.deepEqual(rebuilt.map((i) => i.key), ["u10"]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("reconnect replay cannot bypass the client window and preserves event seq", () => {
|
|
84
|
+
const events = Array.from({ length: 5000 }, (_, i) => ({ seq: i + 1, ev: event(i) }));
|
|
85
|
+
const bounded = boundedReplay(events);
|
|
86
|
+
assert.equal(bounded.length, MESSAGE_LIMIT);
|
|
87
|
+
assert.equal(bounded[0]!.seq, 3501);
|
|
88
|
+
assert.equal(bounded.at(-1)!.seq, 5000);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("history edge follows overlapping replay order rather than the recent REST page", () => {
|
|
92
|
+
const s = session([event(1), event(2), event(3), event(4)]);
|
|
93
|
+
s.history = { ...mergePage(undefined, { items: [msg(3), msg(4)], generation: "g" }, "latest", 1), source: "main" };
|
|
94
|
+
assert.equal(edgeMessage(s, "first"), "u1");
|
|
95
|
+
s.events = [event(4), event(5)];
|
|
96
|
+
assert.equal(edgeMessage(s, "first"), "u3");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("main history edges skip subagent replay UUIDs before and after the overlap", () => {
|
|
100
|
+
const subagent = (id: number): LiveEvent => ({ k: "sdk", m: { ...msg(id), parent_tool_use_id: "agent-tool" } });
|
|
101
|
+
const s = session([subagent(0), event(1), event(2), subagent(3)]);
|
|
102
|
+
s.history = { ...mergePage(undefined, { items: [msg(2)], generation: "g" }, "latest", 1), source: "main" };
|
|
103
|
+
assert.equal(edgeMessage(s, "first"), "u1");
|
|
104
|
+
assert.equal(edgeMessage(s, "last"), "u2");
|
|
105
|
+
assert.deepEqual(browsingBase(s)!.items.map((m) => m.uuid), ["u1", "u2"]);
|
|
106
|
+
s.history = { ...s.history, source: "agent", items: [msg(10), msg(11)] };
|
|
107
|
+
assert.equal(edgeMessage(s, "first"), "u10");
|
|
108
|
+
assert.equal(edgeMessage(s, "last"), "u11");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a full parent replay cannot evict the currently displayed older subagent", () => {
|
|
112
|
+
for (const text of ["hello", "x".repeat(40_000)]) {
|
|
113
|
+
const s = session(Array.from({ length: MESSAGE_LIMIT }, (_, i) => event(i, text)));
|
|
114
|
+
s.history = { ...mergePage(undefined, { items: [msg(-1, "old agent transcript")], generation: "g" }, "latest", 1), source: "older-agent" };
|
|
115
|
+
const next = boundSession(s);
|
|
116
|
+
assert.equal(next.history!.items[0]!.uuid, "u-1");
|
|
117
|
+
assert.ok(next.history!.items.length + next.events.length <= MESSAGE_LIMIT);
|
|
118
|
+
assert.ok(sessionWeight(next) <= SESSION_BYTES);
|
|
119
|
+
assert.equal(new Transcript({ parent: "older-agent-tool" }).update(next.history!.items, next.events)[0]?.key, "u-1");
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("large live attachments become hydratable previews without changing their identity", () => {
|
|
124
|
+
const ev = displayEvent({ k: "user", uuid: "image", text: "hello", ts: 1, imageData: [{ mediaType: "image/jpeg", data: "x".repeat(500_000) }] });
|
|
125
|
+
assert.equal(ev.k, "sdk");
|
|
126
|
+
if (ev.k === "sdk") {
|
|
127
|
+
assert.equal(ev.m.uuid, "image");
|
|
128
|
+
assert.ok(ev.m._deferred);
|
|
129
|
+
assert.ok(JSON.stringify(ev).length < 10_000);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("entering history preserves the records between the replay edge and REST page", () => {
|
|
134
|
+
const s = session(Array.from({ length: 500 }, (_, i) => event(i + 100)));
|
|
135
|
+
s.history = { ...mergePage(undefined, { items: Array.from({ length: 100 }, (_, i) => msg(i + 500)), generation: "g" }, "latest", 1), source: "main" };
|
|
136
|
+
const merged = mergePage(browsingBase(s), { items: Array.from({ length: 100 }, (_, i) => msg(i)), generation: "g" }, "older", 2);
|
|
137
|
+
assert.deepEqual(merged.items.map((m) => m.uuid), Array.from({ length: 600 }, (_, i) => `u${i}`));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("deferred completed blocks settle their full streaming preview", () => {
|
|
141
|
+
const items = new Transcript().update(undefined, [
|
|
142
|
+
{ k: "delta", msgId: "m", index: 0, kind: "text", text: "x".repeat(5000), parent: null },
|
|
143
|
+
{ k: "sdk", m: { type: "assistant", uuid: "a", _deferred: { bytes: 500_000 }, message: { id: "m", content: [{ type: "text", text: "x".repeat(2000) }] } } },
|
|
144
|
+
]);
|
|
145
|
+
assert.equal(items.length, 1);
|
|
146
|
+
assert.equal(items[0]?.deferred?.uuid, "a");
|
|
147
|
+
assert.equal(items.some((i) => "streaming" in i && i.streaming), false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("history and replay show a shared compaction marker only once", () => {
|
|
151
|
+
const marker: SdkMessageLike = { type: "system", subtype: "compact_boundary", uuid: "b" };
|
|
152
|
+
const items = new Transcript().update([msg(1), marker, msg(2)], [{ k: "sdk", m: marker }]);
|
|
153
|
+
assert.equal(items.filter((i) => i.key === "compact-b").length, 1);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
test("child replay cannot starve visible main history", () => {
|
|
158
|
+
const s = session(Array.from({ length: MESSAGE_LIMIT }, (_, i) => ({ k: "sdk", m: { ...msg(i, "x".repeat(40_000)), parent_tool_use_id: "child" } })));
|
|
159
|
+
s.history = { ...mergePage(undefined, { items: [msg(-1)], generation: "g" }, "latest", 1), source: "main" };
|
|
160
|
+
const next = boundSession(s);
|
|
161
|
+
assert.equal(next.history!.items[0]!.uuid, "u-1");
|
|
162
|
+
assert.ok(new Transcript().update(next.history!.items, next.events).some((i) => i.key === "u-1"));
|
|
163
|
+
assert.ok(sessionWeight(next) <= SESSION_BYTES);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("explicit historical detail survives either retained edge and a full replay", () => {
|
|
167
|
+
for (const direction of ["older", "newer"] as const) {
|
|
168
|
+
const target = { ...msg(100, "x".repeat(12_000_000)), _hydrated: true };
|
|
169
|
+
const s = session(Array.from({ length: 1200 }, (_, i) => event(i + 2000, "x".repeat(20_000))));
|
|
170
|
+
s.history = mergePage(undefined, { items: [...Array.from({ length: 100 }, (_, i) => msg(i)), target, ...Array.from({ length: 100 }, (_, i) => msg(i + 101))], generation: "g" }, direction, 1);
|
|
171
|
+
const next = boundSession(s, { protectedMessage: target.uuid });
|
|
172
|
+
assert.ok(next.history!.items.includes(target));
|
|
173
|
+
assert.ok(sessionWeight(next) <= SESSION_BYTES);
|
|
174
|
+
assert.ok(next.events.length + next.history!.items.length <= MESSAGE_LIMIT);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("explicit detail retains live attribution and active checkpoints or rejects", () => {
|
|
179
|
+
const target = { ...msg(1, "x".repeat(12_000_000)), _hydrated: true, parent_tool_use_id: "child" };
|
|
180
|
+
const checkpoint: LiveEvent = { k: "delta", msgId: "active", parent: null, index: 0, kind: "text", text: "prefix", checkpoint: true };
|
|
181
|
+
const s = session([{ k: "sdk", m: target }, ...Array.from({ length: 1500 }, (_, i) => event(i + 2)), checkpoint]);
|
|
182
|
+
const next = boundSession(s, { protectedMessage: target.uuid });
|
|
183
|
+
assert.ok(next.events.some((e) => e.k === "sdk" && e.m === target));
|
|
184
|
+
assert.ok(next.events.some((e) => e.k === "delta" && e.text === "prefix"));
|
|
185
|
+
assert.ok(sessionWeight(next) <= SESSION_BYTES);
|
|
186
|
+
assert.throws(() => boundSession(session([{ k: "sdk", m: { ...msg(1, "x".repeat(SESSION_BYTES)), _hydrated: true } }]), { protectedMessage: "u1" }), /cannot fit/);
|
|
187
|
+
assert.throws(() => boundSession(session(), { protectedMessage: "missing" }), /cannot fit/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("freezing visible history retains system records once in replay order", () => {
|
|
191
|
+
const local: SdkMessageLike = { type: "system", subtype: "local_command", uuid: "local", content: "command output" };
|
|
192
|
+
const compact: SdkMessageLike = { type: "system", subtype: "compact_boundary", uuid: "compact" };
|
|
193
|
+
const s = session([event(1), { k: "sdk", m: local }, { k: "sdk", m: compact }, event(2), event(3)]);
|
|
194
|
+
s.history = { ...mergePage(undefined, { items: [msg(3)], generation: "g" }, "latest", 1), source: "main" };
|
|
195
|
+
const frozen = browsingBase(s)!;
|
|
196
|
+
assert.deepEqual(frozen.items.map((m) => m.uuid), ["u1", "local", "compact", "u2", "u3"]);
|
|
197
|
+
const before = new Transcript().update(s.history.items, s.events);
|
|
198
|
+
const after = new Transcript().update(frozen.items, []);
|
|
199
|
+
assert.deepEqual(after, before);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
test("freezing a UUID-less system record does not copy its replay prefix twice", () => {
|
|
204
|
+
const system: SdkMessageLike = { type: "system", subtype: "compact_boundary" };
|
|
205
|
+
const s = session([event(1), { k: "sdk", m: system }, event(2)]);
|
|
206
|
+
s.history = { ...mergePage(undefined, { items: [msg(2)], generation: "g" }, "latest", 1), source: "main" };
|
|
207
|
+
assert.equal(browsingBase(s)!.items.filter((m) => m === system).length, 1);
|
|
208
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { HistoryPage, LiveEvent, SdkMessageLike } from "@shared/protocol.ts";
|
|
2
|
+
import { MESSAGE_LIMIT, SESSION_BYTES, SESSION_LIMIT, TOTAL_BYTES, compactStreamEvents, contentBytes, displayEvent, eventUuid } from "@shared/message-window.ts";
|
|
3
|
+
import type { SessionLive } from "./live-merge.ts";
|
|
4
|
+
import { matchesMessageTarget } from "./message-bodies.ts";
|
|
5
|
+
import { mergeAgentOwners, retainAgentOwners } from "./agent-owners.ts";
|
|
6
|
+
|
|
7
|
+
export type HistoryWindow = HistoryPage & { source?: string; retain?: "older" | "newer"; mode: "latest" | "history"; loading?: boolean; error?: string; request: number };
|
|
8
|
+
|
|
9
|
+
/** State is already in the snapshot; stream checkpoints remain safe to trim. */
|
|
10
|
+
export function compactEvents(events: LiveEvent[]): LiveEvent[] {
|
|
11
|
+
return compactStreamEvents(events
|
|
12
|
+
.filter((e) => !["settings", "state", "pending", "resolved", "tasks", "agent_tasks", "crons"].includes(e.k))
|
|
13
|
+
.map(displayEvent));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function messageUuid(ev: LiveEvent): string | undefined {
|
|
17
|
+
if (ev.k === "sdk" && (ev.m.parent_tool_use_id || ev.m.type !== "user" && ev.m.type !== "assistant")) return undefined;
|
|
18
|
+
return eventUuid(ev);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const weights = new WeakMap<object, number>();
|
|
22
|
+
export function weight(value: object): number {
|
|
23
|
+
let size = weights.get(value);
|
|
24
|
+
if (size === undefined) { size = contentBytes(value); weights.set(value, size); }
|
|
25
|
+
return size;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function sessionWeight(s: SessionLive): number {
|
|
29
|
+
return s.events.reduce((n, e) => n + weight(e), 0) + (s.history?.items.reduce((n, m) => n + weight(m), 0) ?? 0);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Both history pages and live replay share one budget; neither can retain a hidden full transcript. */
|
|
33
|
+
export type MessageBudget = { protectedMessage?: string; protectedMessages?: string[] };
|
|
34
|
+
|
|
35
|
+
/** Protect one explicit detail request for this budget pass. Reject an unretainable
|
|
36
|
+
* target rather than reporting success with its preview/body already discarded. */
|
|
37
|
+
export function boundSession(s: SessionLive, budget: MessageBudget = {}): SessionLive {
|
|
38
|
+
const owners = mergeAgentOwners(s.agentOwners, s.events, s.snapshot.agentTasks);
|
|
39
|
+
let events = s.events;
|
|
40
|
+
let history = s.history;
|
|
41
|
+
let bytes = events.reduce((n, e) => n + weight(e), 0);
|
|
42
|
+
const historical = history?.mode === "history";
|
|
43
|
+
// The displayed main OR agent file can be older than unrelated replay.
|
|
44
|
+
// Reserve room for its visible records even while following the live tail.
|
|
45
|
+
const reserveHistory = !!history?.items.length;
|
|
46
|
+
const targets = new Set([...(budget.protectedMessages ?? []), ...(budget.protectedMessage ? [budget.protectedMessage] : [])]);
|
|
47
|
+
const protectMessage = (m: SdkMessageLike) => [...targets].some((uuid) => matchesMessageTarget(m, uuid));
|
|
48
|
+
const protectedHistory = history?.items.filter(protectMessage) ?? [];
|
|
49
|
+
const protectedHistoryBytes = protectedHistory.reduce((n, m) => n + weight(m), 0);
|
|
50
|
+
const protectedEvent = (e: LiveEvent) => e.k === "sdk" ? protectMessage(e.m) : targets.has(eventUuid(e) ?? "");
|
|
51
|
+
const targetEventsBytes = events.filter(protectedEvent).reduce((n, e) => n + weight(e), 0);
|
|
52
|
+
const eventLimit = Math.min(MESSAGE_LIMIT - protectedHistory.length, Math.max(reserveHistory ? MESSAGE_LIMIT / 2 : MESSAGE_LIMIT, events.filter(protectedEvent).length));
|
|
53
|
+
const eventBudget = Math.min(SESSION_BYTES - protectedHistoryBytes, Math.max(reserveHistory ? SESSION_BYTES / 2 : SESSION_BYTES, targetEventsBytes));
|
|
54
|
+
if (events.length > eventLimit || bytes > eventBudget) {
|
|
55
|
+
events = compactEvents(events);
|
|
56
|
+
bytes = events.reduce((n, e) => n + weight(e), 0);
|
|
57
|
+
}
|
|
58
|
+
// Compaction places cumulative active stream checkpoints at the tail. Keep
|
|
59
|
+
// those checkpoints alongside the explicitly requested body or reject it.
|
|
60
|
+
const keepEvent = (e: LiveEvent) => protectedEvent(e) || targets.size > 0 && e.k === "delta" && !!e.checkpoint;
|
|
61
|
+
let removed = 0;
|
|
62
|
+
const retainedEvents = events.filter((e) => {
|
|
63
|
+
if (events.length - removed <= eventLimit && bytes <= eventBudget || keepEvent(e)) return true;
|
|
64
|
+
bytes -= weight(e); removed++; return false;
|
|
65
|
+
});
|
|
66
|
+
let evicted = s.evicted || removed > 0;
|
|
67
|
+
if (removed) events = retainedEvents;
|
|
68
|
+
if (history) {
|
|
69
|
+
let items = history.items;
|
|
70
|
+
let size = items.reduce((n, m) => n + weight(m), 0);
|
|
71
|
+
let from = 0;
|
|
72
|
+
let to = items.length;
|
|
73
|
+
while (from < to && (to - from + events.length > MESSAGE_LIMIT || size + bytes > SESSION_BYTES)) {
|
|
74
|
+
// Keep the older edge while reading backwards, the newer edge for the live view.
|
|
75
|
+
const protectFirst = protectMessage(items[from]!);
|
|
76
|
+
const protectLast = protectMessage(items[to - 1]!);
|
|
77
|
+
if (protectFirst && protectLast) break;
|
|
78
|
+
if (protectFirst || historical && history.retain === "older" && !protectLast) size -= weight(items[--to]!);
|
|
79
|
+
else size -= weight(items[from++]!);
|
|
80
|
+
}
|
|
81
|
+
if (from || to < items.length) {
|
|
82
|
+
items = items.slice(from, to);
|
|
83
|
+
history = { ...history, items, older: from ? "trimmed" : history.older, newer: to < history.items.length ? "trimmed" : history.newer };
|
|
84
|
+
evicted = true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (targets.size && (
|
|
88
|
+
events.length + (history?.items.length ?? 0) > MESSAGE_LIMIT
|
|
89
|
+
|| bytes + (history?.items.reduce((n, m) => n + weight(m), 0) ?? 0) > SESSION_BYTES
|
|
90
|
+
|| [...targets].some((uuid) => !events.some((e) => e.k === "sdk" ? matchesMessageTarget(e.m, uuid) : eventUuid(e) === uuid) && !history?.items.some((m) => matchesMessageTarget(m, uuid)))
|
|
91
|
+
)) throw new Error("Requested message cannot fit in the message window");
|
|
92
|
+
const agentOwners = retainAgentOwners(owners, events, s.snapshot.agentTasks);
|
|
93
|
+
const sameOwners = Object.keys(agentOwners).length === Object.keys(s.agentOwners ?? {}).length
|
|
94
|
+
&& Object.entries(agentOwners).every(([owner, agent]) => s.agentOwners?.[owner] === agent);
|
|
95
|
+
return events === s.events && history === s.history && evicted === s.evicted && sameOwners ? s : { ...s, events, history, evicted, agentOwners };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function boundSessions(sessions: Record<string, SessionLive>, protectedIds: ReadonlySet<string>): Record<string, SessionLive> {
|
|
99
|
+
const entries = Object.entries(sessions).sort((a, b) => (a[1].accessed ?? 0) - (b[1].accessed ?? 0));
|
|
100
|
+
let count = entries.length;
|
|
101
|
+
let bytes = entries.reduce((n, [, s]) => n + sessionWeight(s), 0);
|
|
102
|
+
let next = sessions;
|
|
103
|
+
for (const [id, s] of entries) {
|
|
104
|
+
if (count <= SESSION_LIMIT && bytes <= TOTAL_BYTES) break;
|
|
105
|
+
if (protectedIds.has(id)) continue;
|
|
106
|
+
if (next === sessions) next = { ...sessions };
|
|
107
|
+
delete next[id]; count--; bytes -= sessionWeight(s);
|
|
108
|
+
}
|
|
109
|
+
return next;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function mergePage(current: HistoryWindow | undefined, page: HistoryPage, direction: "latest" | "older" | "newer", request: number): HistoryWindow {
|
|
113
|
+
const same = current?.generation === page.generation && direction !== "latest";
|
|
114
|
+
const incoming = direction === "older" ? [...page.items, ...(same ? current!.items : [])] : [...(same ? current!.items : []), ...page.items];
|
|
115
|
+
const seen = new Set<string>();
|
|
116
|
+
const items = incoming.filter((m) => !m.uuid || !seen.has(m.uuid) && !!seen.add(m.uuid));
|
|
117
|
+
// Page cursors are replaced with the retained edge UUID when the cache trims a page.
|
|
118
|
+
return { items, generation: page.generation, retain: direction === "older" ? "older" : "newer", mode: direction === "latest" ? "latest" : "history", request,
|
|
119
|
+
older: direction === "newer" && same ? current!.older : page.older,
|
|
120
|
+
newer: direction === "older" && same ? current!.newer : page.newer };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function edgeMessage(s: SessionLive, edge: "first" | "last"): string | undefined {
|
|
124
|
+
const historyIds = s.history?.items.map((m) => m.uuid).filter((id): id is string => !!id) ?? [];
|
|
125
|
+
if (s.history?.mode === "history" || s.history?.source && s.history.source !== "main") return edge === "first" ? historyIds[0] : historyIds.at(-1);
|
|
126
|
+
const eventIds = s.events.map(messageUuid).filter((id): id is string => !!id);
|
|
127
|
+
const overlap = eventIds.findIndex((id) => historyIds.includes(id));
|
|
128
|
+
const prefix = overlap > 0 ? eventIds.slice(0, overlap) : historyIds.length ? [] : eventIds;
|
|
129
|
+
const ids = [...new Set([...prefix, ...historyIds, ...eventIds])];
|
|
130
|
+
return edge === "first" ? ids[0] : ids.at(-1);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Freeze the displayed recent records before entering history mode, avoiding a gap
|
|
134
|
+
* between older replay-only messages and the more recent REST page. */
|
|
135
|
+
export function browsingBase(s: SessionLive): HistoryWindow | undefined {
|
|
136
|
+
const history = s.history;
|
|
137
|
+
if (!history || history.mode === "history" || history.source && history.source !== "main") return history;
|
|
138
|
+
const live: SdkMessageLike[] = [];
|
|
139
|
+
for (const ev of s.events) {
|
|
140
|
+
if (ev.k === "sdk" && (ev.m.type === "user" || ev.m.type === "assistant" || ev.m.type === "system" && ["compact_boundary", "local_command"].includes(ev.m.subtype ?? "")) && !ev.m.parent_tool_use_id) live.push(ev.m);
|
|
141
|
+
else if (ev.k === "user") live.push({ type: "user", uuid: ev.uuid, timestamp: new Date(ev.ts).toISOString(), ...(ev.scheduled ? { scheduled: true } : {}), message: { content: [
|
|
142
|
+
...(ev.imageData ?? []).map((img) => ({ type: "image", source: { media_type: img.mediaType, data: img.data } })),
|
|
143
|
+
{ type: "text", text: ev.text },
|
|
144
|
+
] } });
|
|
145
|
+
}
|
|
146
|
+
const ids = new Set(history.items.map((m) => m.uuid));
|
|
147
|
+
const overlap = live.findIndex((m) => ids.has(m.uuid));
|
|
148
|
+
const prefix = overlap > 0 ? live.slice(0, overlap) : history.items.length ? [] : live;
|
|
149
|
+
const seen = new Set<string>();
|
|
150
|
+
const records = new Set<SdkMessageLike>();
|
|
151
|
+
const items = [...prefix, ...history.items, ...live].filter((m) => {
|
|
152
|
+
if (records.has(m) || m.uuid && seen.has(m.uuid)) return false;
|
|
153
|
+
records.add(m);
|
|
154
|
+
if (m.uuid) seen.add(m.uuid);
|
|
155
|
+
return true;
|
|
156
|
+
});
|
|
157
|
+
return { ...history, items };
|
|
158
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createMessageFontPreference, MESSAGE_FONT_STORAGE_KEY, parseMessageFontSize } from "./message-font-size.ts";
|
|
4
|
+
|
|
5
|
+
test("message font defaults to the current large size and rejects invalid stored values", () => {
|
|
6
|
+
for (const saved of [null, "", "huge", "{\"size\":\"small\"}"]) {
|
|
7
|
+
assert.equal(createMessageFontPreference({ getItem: () => saved, setItem() {} }).getSnapshot(), "large");
|
|
8
|
+
}
|
|
9
|
+
assert.equal(parseMessageFontSize("small"), "small");
|
|
10
|
+
assert.equal(parseMessageFontSize("medium"), "medium");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("choice persists across preference recreation and captures readers before notifying them", () => {
|
|
14
|
+
const data = new Map<string, string>();
|
|
15
|
+
const storage = { getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => { data.set(key, value); } };
|
|
16
|
+
const preference = createMessageFontPreference(storage);
|
|
17
|
+
const events: string[] = [];
|
|
18
|
+
const unsubscribe = preference.subscribe(() => events.push(`render:${preference.getSnapshot()}`), () => events.push(`anchor:${preference.getSnapshot()}`));
|
|
19
|
+
preference.set("small");
|
|
20
|
+
assert.deepEqual(events, ["anchor:large", "render:small"]);
|
|
21
|
+
assert.equal(data.get(MESSAGE_FONT_STORAGE_KEY), "small");
|
|
22
|
+
assert.equal(createMessageFontPreference(storage).getSnapshot(), "small");
|
|
23
|
+
preference.set("small");
|
|
24
|
+
assert.equal(events.length, 2, "same size does not invalidate transcript measurements");
|
|
25
|
+
unsubscribe();
|
|
26
|
+
preference.set("medium");
|
|
27
|
+
assert.equal(events.length, 2, "unmounted readers are not retained");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("unavailable storage keeps a working in-memory preference, and cross-tab restore does not rewrite storage", () => {
|
|
31
|
+
let writes = 0;
|
|
32
|
+
const preference = createMessageFontPreference({ getItem() { throw new Error("blocked"); }, setItem() { writes++; throw new Error("blocked"); } });
|
|
33
|
+
assert.equal(preference.getSnapshot(), "large");
|
|
34
|
+
preference.set("medium");
|
|
35
|
+
assert.equal(preference.getSnapshot(), "medium");
|
|
36
|
+
preference.restore("small");
|
|
37
|
+
assert.equal(preference.getSnapshot(), "small");
|
|
38
|
+
preference.restore(null);
|
|
39
|
+
assert.equal(preference.getSnapshot(), "large");
|
|
40
|
+
assert.equal(writes, 1);
|
|
41
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
export type MessageFontSize = "small" | "medium" | "large";
|
|
4
|
+
export const MESSAGE_FONT_STORAGE_KEY = "claude-ide:message-font-size";
|
|
5
|
+
export const MESSAGE_FONT_OPTIONS: { value: MessageFontSize; label: string; bodyRem: number }[] = [
|
|
6
|
+
{ value: "small", label: "小", bodyRem: 0.875 },
|
|
7
|
+
{ value: "medium", label: "中", bodyRem: 0.9375 },
|
|
8
|
+
{ value: "large", label: "大", bodyRem: 1 },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
type FontStorage = Pick<Storage, "getItem" | "setItem">;
|
|
12
|
+
|
|
13
|
+
export function parseMessageFontSize(value: unknown): MessageFontSize {
|
|
14
|
+
return value === "small" || value === "medium" ? value : "large";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A shared local preference, independent of session mounts and transcript caches. */
|
|
18
|
+
export function createMessageFontPreference(storage?: FontStorage) {
|
|
19
|
+
let size: MessageFontSize = "large";
|
|
20
|
+
try { size = parseMessageFontSize(storage?.getItem(MESSAGE_FONT_STORAGE_KEY)); } catch { /* Use the existing large text. */ }
|
|
21
|
+
const subscriptions = new Set<{ notify: () => void; beforeChange?: () => void }>();
|
|
22
|
+
const update = (next: MessageFontSize) => {
|
|
23
|
+
if (next === size) return;
|
|
24
|
+
// Readers record their row position before React applies the new line heights.
|
|
25
|
+
for (const subscription of subscriptions) subscription.beforeChange?.();
|
|
26
|
+
size = next;
|
|
27
|
+
for (const subscription of subscriptions) subscription.notify();
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
getSnapshot: () => size,
|
|
31
|
+
subscribe(notify: () => void, beforeChange?: () => void) {
|
|
32
|
+
const subscription = { notify, beforeChange };
|
|
33
|
+
subscriptions.add(subscription);
|
|
34
|
+
return () => { subscriptions.delete(subscription); };
|
|
35
|
+
},
|
|
36
|
+
set(next: MessageFontSize) {
|
|
37
|
+
try { storage?.setItem(MESSAGE_FONT_STORAGE_KEY, next); } catch { /* Keep the in-memory preference. */ }
|
|
38
|
+
update(next);
|
|
39
|
+
},
|
|
40
|
+
restore(value: unknown) { update(parseMessageFontSize(value)); },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let storage: FontStorage | undefined;
|
|
45
|
+
try { storage = typeof localStorage === "undefined" ? undefined : localStorage; } catch { /* Storage may be disabled. */ }
|
|
46
|
+
const preference = createMessageFontPreference(storage);
|
|
47
|
+
if (typeof window !== "undefined") window.addEventListener("storage", (event) => {
|
|
48
|
+
if (event.storageArea && event.storageArea !== storage) return;
|
|
49
|
+
if (event.key === MESSAGE_FONT_STORAGE_KEY || event.key === null) preference.restore(event.newValue);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export const setMessageFontSize = preference.set;
|
|
53
|
+
|
|
54
|
+
export function useMessageFontSize(beforeChange?: () => void): MessageFontSize {
|
|
55
|
+
const subscribe = useCallback((notify: () => void) => preference.subscribe(notify, beforeChange), [beforeChange]);
|
|
56
|
+
return useSyncExternalStore(subscribe, preference.getSnapshot, () => "large");
|
|
57
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { formatMessageTime, isNewMessageTime } from "./message-time.ts";
|
|
4
|
+
|
|
5
|
+
test("message times keep a machine-readable timestamp and add the date when needed", () => {
|
|
6
|
+
const now = new Date(2026, 9, 22, 12, 0, 0).getTime();
|
|
7
|
+
const sameDay = new Date(2026, 9, 22, 9, 5, 0).getTime();
|
|
8
|
+
const older = new Date(2025, 0, 2, 3, 4, 0).getTime();
|
|
9
|
+
|
|
10
|
+
const recent = formatMessageTime(sameDay, now);
|
|
11
|
+
assert.ok(recent);
|
|
12
|
+
assert.equal(recent.dateTime, new Date(sameDay).toISOString());
|
|
13
|
+
assert.equal(recent.lines.length, 1, "same-day messages show only the time");
|
|
14
|
+
assert.ok(recent.lines[0]!.length > 0);
|
|
15
|
+
assert.ok(recent.title.length > 0);
|
|
16
|
+
|
|
17
|
+
const past = formatMessageTime(older, now);
|
|
18
|
+
assert.ok(past);
|
|
19
|
+
assert.equal(past.lines.length, 2, "older messages stack the date above the time");
|
|
20
|
+
assert.match(past.lines[0]!, /25/);
|
|
21
|
+
assert.match(past.title, /2025/);
|
|
22
|
+
|
|
23
|
+
assert.equal(formatMessageTime(undefined), undefined);
|
|
24
|
+
assert.equal(formatMessageTime(Number.NaN), undefined);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("only the first item of a message run gets a time label", () => {
|
|
28
|
+
assert.equal(isNewMessageTime(1000, undefined), true);
|
|
29
|
+
assert.equal(isNewMessageTime(1000, 1000), false, "blocks from one message share its timestamp");
|
|
30
|
+
assert.equal(isNewMessageTime(1001, 1000), true);
|
|
31
|
+
assert.equal(isNewMessageTime(undefined, 1000), false);
|
|
32
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Small, local-time label for a message row. Recent messages show HH:MM; older
|
|
2
|
+
* ones stack the date above it so the narrow left gutter does not clip it. */
|
|
3
|
+
const timeFormat = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" });
|
|
4
|
+
const monthDayFormat = new Intl.DateTimeFormat(undefined, { month: "2-digit", day: "2-digit" });
|
|
5
|
+
// Two-digit years keep the gutter narrow; the full year stays in the title.
|
|
6
|
+
const yearMonthDayFormat = new Intl.DateTimeFormat(undefined, { year: "2-digit", month: "2-digit", day: "2-digit" });
|
|
7
|
+
const fullFormat = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" });
|
|
8
|
+
|
|
9
|
+
/** Consecutive items of one SDK message share its timestamp; label the run once. */
|
|
10
|
+
export function isNewMessageTime(ts: number | undefined, previousTs: number | undefined): boolean {
|
|
11
|
+
return ts !== undefined && ts !== previousTs;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function formatMessageTime(ts: number | undefined, now = Date.now()): { lines: string[]; title: string; dateTime: string } | undefined {
|
|
15
|
+
if (ts === undefined || !Number.isFinite(ts)) return undefined;
|
|
16
|
+
const date = new Date(ts);
|
|
17
|
+
if (Number.isNaN(date.getTime())) return undefined;
|
|
18
|
+
const current = new Date(now);
|
|
19
|
+
const sameDay = date.getFullYear() === current.getFullYear()
|
|
20
|
+
&& date.getMonth() === current.getMonth()
|
|
21
|
+
&& date.getDate() === current.getDate();
|
|
22
|
+
const sameYear = date.getFullYear() === current.getFullYear();
|
|
23
|
+
const time = timeFormat.format(date);
|
|
24
|
+
const day = sameDay ? undefined : (sameYear ? monthDayFormat : yearMonthDayFormat).format(date);
|
|
25
|
+
return { lines: day ? [day, time] : [time], title: fullFormat.format(date), dateTime: date.toISOString() };
|
|
26
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import { currentPushSubscription } from "./pwa.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* In-page notifications: what the app shows while a page is open, independent of Web Push.
|
|
6
|
+
* Main thread calls notifyInPage() when a session finishes or starts waiting for the user.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type NotifyKind = "done" | "attention" | "error";
|
|
10
|
+
export type NotifyKinds = Record<NotifyKind, boolean>;
|
|
11
|
+
export const NOTIFY_KIND_LABELS: Record<NotifyKind, string> = {
|
|
12
|
+
done: "会话完成",
|
|
13
|
+
attention: "等待处理(提问、计划审批)",
|
|
14
|
+
error: "出错",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type InPageNotice = {
|
|
18
|
+
kind: NotifyKind;
|
|
19
|
+
title: string;
|
|
20
|
+
body?: string;
|
|
21
|
+
/** Same-origin path to open on click, e.g. /p/<pid>/s/<sid>. */
|
|
22
|
+
url?: string;
|
|
23
|
+
/** Same tag replaces the previous toast / system notification. */
|
|
24
|
+
tag?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// ---------- per-device preferences ----------
|
|
28
|
+
|
|
29
|
+
const KINDS_KEY = "cide.notify.kinds";
|
|
30
|
+
const ALL_ON: NotifyKinds = { done: true, attention: true, error: true };
|
|
31
|
+
|
|
32
|
+
export function getNotifyKinds(): NotifyKinds {
|
|
33
|
+
try {
|
|
34
|
+
const v = JSON.parse(localStorage.getItem(KINDS_KEY) ?? "null") as Partial<NotifyKinds> | null;
|
|
35
|
+
return { ...ALL_ON, ...(v ?? {}) };
|
|
36
|
+
} catch {
|
|
37
|
+
return ALL_ON;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function setNotifyKinds(kinds: NotifyKinds): void {
|
|
42
|
+
try {
|
|
43
|
+
localStorage.setItem(KINDS_KEY, JSON.stringify(kinds));
|
|
44
|
+
} catch {
|
|
45
|
+
// private mode: keep defaults
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------- toasts ----------
|
|
50
|
+
|
|
51
|
+
export type Toast = InPageNotice & { id: number };
|
|
52
|
+
|
|
53
|
+
export const useToasts = create<{ toasts: Toast[] }>(() => ({ toasts: [] }));
|
|
54
|
+
|
|
55
|
+
let nextId = 1;
|
|
56
|
+
const timers = new Map<number, ReturnType<typeof setTimeout>>();
|
|
57
|
+
|
|
58
|
+
export function dismissToast(id: number) {
|
|
59
|
+
clearTimeout(timers.get(id));
|
|
60
|
+
timers.delete(id);
|
|
61
|
+
useToasts.setState((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function showToast(n: InPageNotice, ms = 8000): number {
|
|
65
|
+
const id = nextId++;
|
|
66
|
+
useToasts.setState((s) => ({
|
|
67
|
+
toasts: [...s.toasts.filter((t) => !(n.tag && t.tag === n.tag)), { ...n, id }].slice(-4),
|
|
68
|
+
}));
|
|
69
|
+
timers.set(
|
|
70
|
+
id,
|
|
71
|
+
setTimeout(() => dismissToast(id), ms),
|
|
72
|
+
);
|
|
73
|
+
return id;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------- navigation from notifications ----------
|
|
77
|
+
|
|
78
|
+
let navigate: ((url: string) => void) | undefined;
|
|
79
|
+
|
|
80
|
+
/** Installed by <NotificationToasts/> (inside the router) so clicks navigate without a reload. */
|
|
81
|
+
export function setNotifyNavigator(fn: ((url: string) => void) | undefined) {
|
|
82
|
+
navigate = fn;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function openNoticeUrl(url: string | undefined) {
|
|
86
|
+
if (!url) return;
|
|
87
|
+
if (navigate) navigate(url);
|
|
88
|
+
else location.assign(url);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------- entry point ----------
|
|
92
|
+
|
|
93
|
+
export type NotifyOutcome = "off" | "toast" | "system" | "push";
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Show a notification for this page.
|
|
97
|
+
* - Page in front: a toast.
|
|
98
|
+
* - Page in background with a push subscription: nothing — the server's Web Push covers it.
|
|
99
|
+
* - Page in background, notification permission granted: a system notification.
|
|
100
|
+
* - Otherwise: a toast, seen when the user comes back.
|
|
101
|
+
* `force` ignores the per-kind switches (used by the "test" button).
|
|
102
|
+
*/
|
|
103
|
+
export async function notifyInPage(n: InPageNotice, opts: { force?: boolean } = {}): Promise<NotifyOutcome> {
|
|
104
|
+
if (!opts.force && !getNotifyKinds()[n.kind]) return "off";
|
|
105
|
+
const inFront = document.visibilityState === "visible" && document.hasFocus();
|
|
106
|
+
if (inFront) {
|
|
107
|
+
showToast(n);
|
|
108
|
+
return "toast";
|
|
109
|
+
}
|
|
110
|
+
if (!opts.force && (await currentPushSubscription())) return "push";
|
|
111
|
+
if ("Notification" in window && Notification.permission === "granted") {
|
|
112
|
+
try {
|
|
113
|
+
const reg = "serviceWorker" in navigator ? await navigator.serviceWorker.getRegistration() : undefined;
|
|
114
|
+
const options: NotificationOptions = { body: n.body ?? "", tag: n.tag, icon: "/icons/icon-192.png", data: { url: n.url ?? "/" } };
|
|
115
|
+
if (reg) {
|
|
116
|
+
await reg.showNotification(n.title, options);
|
|
117
|
+
} else {
|
|
118
|
+
const sys = new Notification(n.title, options);
|
|
119
|
+
sys.onclick = () => {
|
|
120
|
+
window.focus();
|
|
121
|
+
openNoticeUrl(n.url);
|
|
122
|
+
sys.close();
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return "system";
|
|
126
|
+
} catch {
|
|
127
|
+
// e.g. Android Chrome forbids `new Notification()`; fall through to a toast
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
showToast(n, 30_000);
|
|
131
|
+
return "toast";
|
|
132
|
+
}
|