From d9392e3e767ae0328452e7364906df1083465199 Mon Sep 17 00:00:00 2001 From: agentforce314 Date: Sat, 8 Aug 2026 00:53:13 -0700 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20track=20ui-desktop's=20lib/=20d?= =?UTF-8?q?irectories=20=E2=80=94=20180=20source=20files=20were=20gitignor?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root .gitignore's global `lib/` pattern (Python build-output hygiene) silently excluded every lib/ directory under ui-desktop from the stage-1 import: src/lib/** (the renderer's core utility layer), scripts/perf/lib/, and src/app/pet-generate/lib/ — 180 files. Local gates all passed because the files existed on disk in the working tree; only a fresh clone exposed the hole (vite: 'Failed to resolve import "./lib/clipboard"'). Adds the ui-desktop exception next to the existing ui-tui one and commits the missing files. Verified by typechecking a pristine `git archive` export of the branch (the gate stage 1 lacked). Co-Authored-By: Claude Fable 5 --- .gitignore | 4 +- ui-desktop/scripts/perf/lib/baseline.mjs | 84 ++ ui-desktop/scripts/perf/lib/cdp.mjs | 202 +++ ui-desktop/scripts/perf/lib/launch.mjs | 418 ++++++ ui-desktop/scripts/perf/lib/stats.mjs | 89 ++ .../src/app/pet-generate/lib/frame-count.ts | 32 + .../pet-generate/lib/read-reference-image.ts | 49 + ui-desktop/src/lib/ansi.test.ts | 123 ++ ui-desktop/src/lib/ansi.ts | 186 +++ ui-desktop/src/lib/artifact-detect.test.ts | 121 ++ ui-desktop/src/lib/artifact-detect.ts | 232 ++++ ui-desktop/src/lib/brand-icon.test.ts | 43 + ui-desktop/src/lib/brand-icon.ts | 429 ++++++ ui-desktop/src/lib/chat-messages.test.ts | 1136 ++++++++++++++++ ui-desktop/src/lib/chat-messages.ts | 1174 +++++++++++++++++ ui-desktop/src/lib/chat-runtime.test.ts | 202 +++ ui-desktop/src/lib/chat-runtime.ts | 497 +++++++ ui-desktop/src/lib/clipboard.ts | 28 + ui-desktop/src/lib/commit-changelog.test.ts | 114 ++ ui-desktop/src/lib/commit-changelog.ts | 179 +++ ui-desktop/src/lib/completion-sound.ts | 530 ++++++++ .../src/lib/composer-input-sanitize.test.ts | 51 + ui-desktop/src/lib/composer-input-sanitize.ts | 74 ++ ui-desktop/src/lib/desktop-fs.test.ts | 217 +++ ui-desktop/src/lib/desktop-fs.ts | 191 +++ ui-desktop/src/lib/desktop-git.test.ts | 99 ++ ui-desktop/src/lib/desktop-git.ts | 109 ++ .../src/lib/desktop-remote-auth.test.ts | 51 + ui-desktop/src/lib/desktop-remote-auth.ts | 26 + .../src/lib/desktop-slash-commands.test.ts | 356 +++++ ui-desktop/src/lib/desktop-slash-commands.ts | 626 +++++++++ ui-desktop/src/lib/desktop-toolsets.test.ts | 17 + ui-desktop/src/lib/desktop-toolsets.ts | 24 + ui-desktop/src/lib/display-path.test.ts | 47 + ui-desktop/src/lib/display-path.ts | 157 +++ ui-desktop/src/lib/download-text.ts | 16 + ui-desktop/src/lib/drag-ghost.ts | 43 + ui-desktop/src/lib/embedded-images.test.ts | 91 ++ ui-desktop/src/lib/embedded-images.ts | 204 +++ ui-desktop/src/lib/escape-layers.test.ts | 46 + ui-desktop/src/lib/escape-layers.ts | 53 + ui-desktop/src/lib/excluded-paths.ts | 44 + ui-desktop/src/lib/external-link.test.tsx | 275 ++++ ui-desktop/src/lib/external-link.tsx | 331 +++++ ui-desktop/src/lib/find-in-page.ts | 109 ++ ui-desktop/src/lib/format.ts | 24 + ui-desktop/src/lib/gateway-events.test.ts | 99 ++ ui-desktop/src/lib/gateway-events.ts | 158 +++ ui-desktop/src/lib/gateway-rpc.test.ts | 28 + ui-desktop/src/lib/gateway-rpc.ts | 21 + ui-desktop/src/lib/gateway-ws-url.test.ts | 116 ++ ui-desktop/src/lib/generated-images.test.ts | 120 ++ ui-desktop/src/lib/generated-images.ts | 114 ++ ui-desktop/src/lib/haptics.ts | 129 ++ ui-desktop/src/lib/icons.ts | 269 ++++ ...incremental-external-store-runtime.test.ts | 144 ++ .../lib/incremental-external-store-runtime.ts | 265 ++++ .../src/lib/inflight-turn-journal.test.ts | 321 +++++ ui-desktop/src/lib/inflight-turn-journal.ts | 545 ++++++++ ui-desktop/src/lib/input-modality.test.ts | 31 + ui-desktop/src/lib/input-modality.ts | 26 + ui-desktop/src/lib/json-format.test.ts | 26 + ui-desktop/src/lib/json-format.ts | 15 + .../lib/json-rpc-gateway-url-guard.test.ts | 65 + ui-desktop/src/lib/katex-memo.ts | 260 ++++ ui-desktop/src/lib/keybinds/actions.ts | 243 ++++ ui-desktop/src/lib/keybinds/combo.test.ts | 130 ++ ui-desktop/src/lib/keybinds/combo.ts | 230 ++++ .../lib/keybinds/composer-focus-keys.test.ts | 203 +++ .../src/lib/keybinds/composer-focus-keys.ts | 147 +++ .../lib/keybinds/contributed-actions.test.ts | 66 + .../src/lib/keybinds/use-keybind-hint.ts | 36 + ui-desktop/src/lib/loadout.ts | 279 ++++ ui-desktop/src/lib/local-preview.test.ts | 199 +++ ui-desktop/src/lib/local-preview.ts | 276 ++++ ui-desktop/src/lib/markdown-blocks.test.ts | 164 +++ ui-desktop/src/lib/markdown-blocks.ts | 138 ++ ui-desktop/src/lib/markdown-code.test.ts | 23 + ui-desktop/src/lib/markdown-code.ts | 328 +++++ ui-desktop/src/lib/markdown-preprocess.ts | 520 ++++++++ .../src/lib/mcp-dashboard-oauth.test.ts | 72 + ui-desktop/src/lib/mcp-dashboard-oauth.ts | 71 + ui-desktop/src/lib/mcp-tool-filter.test.ts | 74 ++ ui-desktop/src/lib/mcp-tool-filter.ts | 61 + ui-desktop/src/lib/media.remote.test.ts | 253 ++++ ui-desktop/src/lib/media.ts | 194 +++ ui-desktop/src/lib/middle-click.test.tsx | 86 ++ ui-desktop/src/lib/middle-click.ts | 64 + ui-desktop/src/lib/model-options.test.ts | 99 ++ ui-desktop/src/lib/model-options.ts | 78 ++ ui-desktop/src/lib/model-search-text.ts | 30 + ui-desktop/src/lib/model-status-label.test.ts | 73 + ui-desktop/src/lib/model-status-label.ts | 124 ++ ui-desktop/src/lib/mutable-ref.ts | 6 + ui-desktop/src/lib/oneshot.ts | 58 + ui-desktop/src/lib/persisted.ts | 78 ++ ui-desktop/src/lib/pool.test.ts | 29 + ui-desktop/src/lib/pool.ts | 20 + ui-desktop/src/lib/preview-targets.test.ts | 27 + ui-desktop/src/lib/preview-targets.ts | 63 + ui-desktop/src/lib/profile-color.ts | 55 + ui-desktop/src/lib/project-idea-templates.ts | 116 ++ .../src/lib/provider-setup-errors.test.ts | 44 + ui-desktop/src/lib/provider-setup-errors.ts | 14 + ui-desktop/src/lib/query-client.test.ts | 58 + ui-desktop/src/lib/query-client.ts | 48 + ui-desktop/src/lib/raf-coalesce.ts | 34 + ui-desktop/src/lib/reasoning-blocks.test.ts | 48 + ui-desktop/src/lib/reasoning-blocks.ts | 31 + ui-desktop/src/lib/reasoning-effort.test.ts | 53 + ui-desktop/src/lib/reasoning-effort.ts | 54 + ui-desktop/src/lib/reconnect-backoff.test.ts | 92 ++ ui-desktop/src/lib/reconnect-backoff.ts | 45 + ui-desktop/src/lib/remote-url.test.ts | 25 + ui-desktop/src/lib/remote-url.ts | 22 + ui-desktop/src/lib/render-weight.test.ts | 120 ++ ui-desktop/src/lib/render-weight.ts | 218 +++ ui-desktop/src/lib/renderer-loop-pause.ts | 50 + ui-desktop/src/lib/reorder.ts | 33 + ui-desktop/src/lib/runtime-readiness.test.ts | 111 ++ ui-desktop/src/lib/runtime-readiness.ts | 152 +++ ui-desktop/src/lib/sanitize.test.ts | 32 + ui-desktop/src/lib/sanitize.ts | 21 + ui-desktop/src/lib/selectable-card.ts | 31 + .../src/lib/session-branch-tree.test.ts | 83 ++ ui-desktop/src/lib/session-branch-tree.ts | 124 ++ .../src/lib/session-date-groups.test.ts | 216 +++ ui-desktop/src/lib/session-date-groups.ts | 150 +++ ui-desktop/src/lib/session-export.ts | 59 + ui-desktop/src/lib/session-ids.test.ts | 44 + ui-desktop/src/lib/session-ids.ts | 26 + ui-desktop/src/lib/session-link-title.test.ts | 115 ++ ui-desktop/src/lib/session-link-title.ts | 143 ++ ui-desktop/src/lib/session-refs.test.ts | 124 ++ ui-desktop/src/lib/session-refs.ts | 118 ++ ui-desktop/src/lib/session-search.test.ts | 72 + ui-desktop/src/lib/session-search.ts | 23 + ui-desktop/src/lib/session-signatures.test.ts | 49 + ui-desktop/src/lib/session-signatures.ts | 54 + ui-desktop/src/lib/session-source.test.ts | 35 + ui-desktop/src/lib/session-source.ts | 130 ++ ui-desktop/src/lib/slash-completion-cache.ts | 107 ++ ui-desktop/src/lib/speech-text.test.ts | 152 +++ ui-desktop/src/lib/speech-text.ts | 167 +++ ui-desktop/src/lib/stable-array.ts | 7 + ui-desktop/src/lib/statusbar.tsx | 81 ++ ui-desktop/src/lib/storage.test.ts | 25 + ui-desktop/src/lib/storage.ts | 158 +++ ui-desktop/src/lib/summarize-command.test.ts | 110 ++ ui-desktop/src/lib/summarize-command.ts | 216 +++ ui-desktop/src/lib/svg-image.ts | 56 + ui-desktop/src/lib/text.ts | 15 + ui-desktop/src/lib/thinking-sound.test.ts | 118 ++ ui-desktop/src/lib/thinking-sound.ts | 108 ++ ui-desktop/src/lib/time.test.ts | 124 ++ ui-desktop/src/lib/time.ts | 238 ++++ ui-desktop/src/lib/todos.test.ts | 80 ++ ui-desktop/src/lib/todos.ts | 88 ++ ui-desktop/src/lib/tool-render-class.ts | 44 + .../src/lib/tool-result-summary.test.ts | 106 ++ ui-desktop/src/lib/tool-result-summary.ts | 469 +++++++ .../src/lib/tool-run-continuity.test.ts | 250 ++++ ui-desktop/src/lib/trackpad-gestures.ts | 50 + ui-desktop/src/lib/update-copy.test.ts | 38 + ui-desktop/src/lib/update-copy.ts | 44 + .../src/lib/use-enter-animation.test.tsx | 82 ++ ui-desktop/src/lib/use-enter-animation.ts | 110 ++ ui-desktop/src/lib/use-session-slice.ts | 63 + ui-desktop/src/lib/utils.ts | 6 + ui-desktop/src/lib/version-status.test.ts | 85 ++ ui-desktop/src/lib/version-status.ts | 106 ++ ui-desktop/src/lib/voice-barge-in.ts | 326 +++++ ui-desktop/src/lib/voice-playback.ts | 516 ++++++++ ui-desktop/src/lib/voice-stop-word.test.ts | 86 ++ ui-desktop/src/lib/voice-stop-word.ts | 105 ++ ui-desktop/src/lib/wake-client-capture.ts | 234 ++++ ui-desktop/src/lib/wake-indicator.test.ts | 46 + ui-desktop/src/lib/wake-indicator.ts | 55 + ui-desktop/src/lib/wake-sound.test.ts | 83 ++ ui-desktop/src/lib/wake-sound.ts | 88 ++ ui-desktop/src/lib/yolo-session.ts | 76 ++ 181 files changed, 24436 insertions(+), 1 deletion(-) create mode 100644 ui-desktop/scripts/perf/lib/baseline.mjs create mode 100644 ui-desktop/scripts/perf/lib/cdp.mjs create mode 100644 ui-desktop/scripts/perf/lib/launch.mjs create mode 100644 ui-desktop/scripts/perf/lib/stats.mjs create mode 100644 ui-desktop/src/app/pet-generate/lib/frame-count.ts create mode 100644 ui-desktop/src/app/pet-generate/lib/read-reference-image.ts create mode 100644 ui-desktop/src/lib/ansi.test.ts create mode 100644 ui-desktop/src/lib/ansi.ts create mode 100644 ui-desktop/src/lib/artifact-detect.test.ts create mode 100644 ui-desktop/src/lib/artifact-detect.ts create mode 100644 ui-desktop/src/lib/brand-icon.test.ts create mode 100644 ui-desktop/src/lib/brand-icon.ts create mode 100644 ui-desktop/src/lib/chat-messages.test.ts create mode 100644 ui-desktop/src/lib/chat-messages.ts create mode 100644 ui-desktop/src/lib/chat-runtime.test.ts create mode 100644 ui-desktop/src/lib/chat-runtime.ts create mode 100644 ui-desktop/src/lib/clipboard.ts create mode 100644 ui-desktop/src/lib/commit-changelog.test.ts create mode 100644 ui-desktop/src/lib/commit-changelog.ts create mode 100644 ui-desktop/src/lib/completion-sound.ts create mode 100644 ui-desktop/src/lib/composer-input-sanitize.test.ts create mode 100644 ui-desktop/src/lib/composer-input-sanitize.ts create mode 100644 ui-desktop/src/lib/desktop-fs.test.ts create mode 100644 ui-desktop/src/lib/desktop-fs.ts create mode 100644 ui-desktop/src/lib/desktop-git.test.ts create mode 100644 ui-desktop/src/lib/desktop-git.ts create mode 100644 ui-desktop/src/lib/desktop-remote-auth.test.ts create mode 100644 ui-desktop/src/lib/desktop-remote-auth.ts create mode 100644 ui-desktop/src/lib/desktop-slash-commands.test.ts create mode 100644 ui-desktop/src/lib/desktop-slash-commands.ts create mode 100644 ui-desktop/src/lib/desktop-toolsets.test.ts create mode 100644 ui-desktop/src/lib/desktop-toolsets.ts create mode 100644 ui-desktop/src/lib/display-path.test.ts create mode 100644 ui-desktop/src/lib/display-path.ts create mode 100644 ui-desktop/src/lib/download-text.ts create mode 100644 ui-desktop/src/lib/drag-ghost.ts create mode 100644 ui-desktop/src/lib/embedded-images.test.ts create mode 100644 ui-desktop/src/lib/embedded-images.ts create mode 100644 ui-desktop/src/lib/escape-layers.test.ts create mode 100644 ui-desktop/src/lib/escape-layers.ts create mode 100644 ui-desktop/src/lib/excluded-paths.ts create mode 100644 ui-desktop/src/lib/external-link.test.tsx create mode 100644 ui-desktop/src/lib/external-link.tsx create mode 100644 ui-desktop/src/lib/find-in-page.ts create mode 100644 ui-desktop/src/lib/format.ts create mode 100644 ui-desktop/src/lib/gateway-events.test.ts create mode 100644 ui-desktop/src/lib/gateway-events.ts create mode 100644 ui-desktop/src/lib/gateway-rpc.test.ts create mode 100644 ui-desktop/src/lib/gateway-rpc.ts create mode 100644 ui-desktop/src/lib/gateway-ws-url.test.ts create mode 100644 ui-desktop/src/lib/generated-images.test.ts create mode 100644 ui-desktop/src/lib/generated-images.ts create mode 100644 ui-desktop/src/lib/haptics.ts create mode 100644 ui-desktop/src/lib/icons.ts create mode 100644 ui-desktop/src/lib/incremental-external-store-runtime.test.ts create mode 100644 ui-desktop/src/lib/incremental-external-store-runtime.ts create mode 100644 ui-desktop/src/lib/inflight-turn-journal.test.ts create mode 100644 ui-desktop/src/lib/inflight-turn-journal.ts create mode 100644 ui-desktop/src/lib/input-modality.test.ts create mode 100644 ui-desktop/src/lib/input-modality.ts create mode 100644 ui-desktop/src/lib/json-format.test.ts create mode 100644 ui-desktop/src/lib/json-format.ts create mode 100644 ui-desktop/src/lib/json-rpc-gateway-url-guard.test.ts create mode 100644 ui-desktop/src/lib/katex-memo.ts create mode 100644 ui-desktop/src/lib/keybinds/actions.ts create mode 100644 ui-desktop/src/lib/keybinds/combo.test.ts create mode 100644 ui-desktop/src/lib/keybinds/combo.ts create mode 100644 ui-desktop/src/lib/keybinds/composer-focus-keys.test.ts create mode 100644 ui-desktop/src/lib/keybinds/composer-focus-keys.ts create mode 100644 ui-desktop/src/lib/keybinds/contributed-actions.test.ts create mode 100644 ui-desktop/src/lib/keybinds/use-keybind-hint.ts create mode 100644 ui-desktop/src/lib/loadout.ts create mode 100644 ui-desktop/src/lib/local-preview.test.ts create mode 100644 ui-desktop/src/lib/local-preview.ts create mode 100644 ui-desktop/src/lib/markdown-blocks.test.ts create mode 100644 ui-desktop/src/lib/markdown-blocks.ts create mode 100644 ui-desktop/src/lib/markdown-code.test.ts create mode 100644 ui-desktop/src/lib/markdown-code.ts create mode 100644 ui-desktop/src/lib/markdown-preprocess.ts create mode 100644 ui-desktop/src/lib/mcp-dashboard-oauth.test.ts create mode 100644 ui-desktop/src/lib/mcp-dashboard-oauth.ts create mode 100644 ui-desktop/src/lib/mcp-tool-filter.test.ts create mode 100644 ui-desktop/src/lib/mcp-tool-filter.ts create mode 100644 ui-desktop/src/lib/media.remote.test.ts create mode 100644 ui-desktop/src/lib/media.ts create mode 100644 ui-desktop/src/lib/middle-click.test.tsx create mode 100644 ui-desktop/src/lib/middle-click.ts create mode 100644 ui-desktop/src/lib/model-options.test.ts create mode 100644 ui-desktop/src/lib/model-options.ts create mode 100644 ui-desktop/src/lib/model-search-text.ts create mode 100644 ui-desktop/src/lib/model-status-label.test.ts create mode 100644 ui-desktop/src/lib/model-status-label.ts create mode 100644 ui-desktop/src/lib/mutable-ref.ts create mode 100644 ui-desktop/src/lib/oneshot.ts create mode 100644 ui-desktop/src/lib/persisted.ts create mode 100644 ui-desktop/src/lib/pool.test.ts create mode 100644 ui-desktop/src/lib/pool.ts create mode 100644 ui-desktop/src/lib/preview-targets.test.ts create mode 100644 ui-desktop/src/lib/preview-targets.ts create mode 100644 ui-desktop/src/lib/profile-color.ts create mode 100644 ui-desktop/src/lib/project-idea-templates.ts create mode 100644 ui-desktop/src/lib/provider-setup-errors.test.ts create mode 100644 ui-desktop/src/lib/provider-setup-errors.ts create mode 100644 ui-desktop/src/lib/query-client.test.ts create mode 100644 ui-desktop/src/lib/query-client.ts create mode 100644 ui-desktop/src/lib/raf-coalesce.ts create mode 100644 ui-desktop/src/lib/reasoning-blocks.test.ts create mode 100644 ui-desktop/src/lib/reasoning-blocks.ts create mode 100644 ui-desktop/src/lib/reasoning-effort.test.ts create mode 100644 ui-desktop/src/lib/reasoning-effort.ts create mode 100644 ui-desktop/src/lib/reconnect-backoff.test.ts create mode 100644 ui-desktop/src/lib/reconnect-backoff.ts create mode 100644 ui-desktop/src/lib/remote-url.test.ts create mode 100644 ui-desktop/src/lib/remote-url.ts create mode 100644 ui-desktop/src/lib/render-weight.test.ts create mode 100644 ui-desktop/src/lib/render-weight.ts create mode 100644 ui-desktop/src/lib/renderer-loop-pause.ts create mode 100644 ui-desktop/src/lib/reorder.ts create mode 100644 ui-desktop/src/lib/runtime-readiness.test.ts create mode 100644 ui-desktop/src/lib/runtime-readiness.ts create mode 100644 ui-desktop/src/lib/sanitize.test.ts create mode 100644 ui-desktop/src/lib/sanitize.ts create mode 100644 ui-desktop/src/lib/selectable-card.ts create mode 100644 ui-desktop/src/lib/session-branch-tree.test.ts create mode 100644 ui-desktop/src/lib/session-branch-tree.ts create mode 100644 ui-desktop/src/lib/session-date-groups.test.ts create mode 100644 ui-desktop/src/lib/session-date-groups.ts create mode 100644 ui-desktop/src/lib/session-export.ts create mode 100644 ui-desktop/src/lib/session-ids.test.ts create mode 100644 ui-desktop/src/lib/session-ids.ts create mode 100644 ui-desktop/src/lib/session-link-title.test.ts create mode 100644 ui-desktop/src/lib/session-link-title.ts create mode 100644 ui-desktop/src/lib/session-refs.test.ts create mode 100644 ui-desktop/src/lib/session-refs.ts create mode 100644 ui-desktop/src/lib/session-search.test.ts create mode 100644 ui-desktop/src/lib/session-search.ts create mode 100644 ui-desktop/src/lib/session-signatures.test.ts create mode 100644 ui-desktop/src/lib/session-signatures.ts create mode 100644 ui-desktop/src/lib/session-source.test.ts create mode 100644 ui-desktop/src/lib/session-source.ts create mode 100644 ui-desktop/src/lib/slash-completion-cache.ts create mode 100644 ui-desktop/src/lib/speech-text.test.ts create mode 100644 ui-desktop/src/lib/speech-text.ts create mode 100644 ui-desktop/src/lib/stable-array.ts create mode 100644 ui-desktop/src/lib/statusbar.tsx create mode 100644 ui-desktop/src/lib/storage.test.ts create mode 100644 ui-desktop/src/lib/storage.ts create mode 100644 ui-desktop/src/lib/summarize-command.test.ts create mode 100644 ui-desktop/src/lib/summarize-command.ts create mode 100644 ui-desktop/src/lib/svg-image.ts create mode 100644 ui-desktop/src/lib/text.ts create mode 100644 ui-desktop/src/lib/thinking-sound.test.ts create mode 100644 ui-desktop/src/lib/thinking-sound.ts create mode 100644 ui-desktop/src/lib/time.test.ts create mode 100644 ui-desktop/src/lib/time.ts create mode 100644 ui-desktop/src/lib/todos.test.ts create mode 100644 ui-desktop/src/lib/todos.ts create mode 100644 ui-desktop/src/lib/tool-render-class.ts create mode 100644 ui-desktop/src/lib/tool-result-summary.test.ts create mode 100644 ui-desktop/src/lib/tool-result-summary.ts create mode 100644 ui-desktop/src/lib/tool-run-continuity.test.ts create mode 100644 ui-desktop/src/lib/trackpad-gestures.ts create mode 100644 ui-desktop/src/lib/update-copy.test.ts create mode 100644 ui-desktop/src/lib/update-copy.ts create mode 100644 ui-desktop/src/lib/use-enter-animation.test.tsx create mode 100644 ui-desktop/src/lib/use-enter-animation.ts create mode 100644 ui-desktop/src/lib/use-session-slice.ts create mode 100644 ui-desktop/src/lib/utils.ts create mode 100644 ui-desktop/src/lib/version-status.test.ts create mode 100644 ui-desktop/src/lib/version-status.ts create mode 100644 ui-desktop/src/lib/voice-barge-in.ts create mode 100644 ui-desktop/src/lib/voice-playback.ts create mode 100644 ui-desktop/src/lib/voice-stop-word.test.ts create mode 100644 ui-desktop/src/lib/voice-stop-word.ts create mode 100644 ui-desktop/src/lib/wake-client-capture.ts create mode 100644 ui-desktop/src/lib/wake-indicator.test.ts create mode 100644 ui-desktop/src/lib/wake-indicator.ts create mode 100644 ui-desktop/src/lib/wake-sound.test.ts create mode 100644 ui-desktop/src/lib/wake-sound.ts create mode 100644 ui-desktop/src/lib/yolo-session.ts diff --git a/.gitignore b/.gitignore index 48afd05d..818f7260 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,9 @@ downloads/ eggs/ .eggs/ lib/ -# ui-tui/src/lib is TypeScript source, not Python build output — keep tracking it +# ui-tui/src/lib and ui-desktop's lib/ dirs are TypeScript source, not Python +# build output — keep tracking them +!ui-desktop/**/lib/ # (the bare `lib/` above is from the Python template and would otherwise drop it). !ui-tui/src/lib/ lib64/ diff --git a/ui-desktop/scripts/perf/lib/baseline.mjs b/ui-desktop/scripts/perf/lib/baseline.mjs new file mode 100644 index 00000000..5ff270f8 --- /dev/null +++ b/ui-desktop/scripts/perf/lib/baseline.mjs @@ -0,0 +1,84 @@ +// Baseline + regression gate. This is the capability the old one-off scripts +// never had: measured numbers are compared against a committed baseline so a +// PR that regresses streaming/typing/mount cost fails loudly instead of +// silently drifting. +// +// Every tracked metric is "lower is better" (longtask counts, frame/keystroke +// percentiles, mount ms). A metric regresses when it exceeds +// `baseline * (1 + tolFrac) + tolAbs`. tolAbs absorbs sub-millisecond jitter on +// already-fast metrics so they don't false-positive. + +import { readFileSync, writeFileSync } from 'node:fs' + +const DEFAULT_TOLERANCE = { tolFrac: 0.25, tolAbs: 1 } + +export function loadBaseline(path) { + try { + return JSON.parse(readFileSync(path, 'utf8')) + } catch { + return { _meta: {}, scenarios: {} } + } +} + +/** + * Compare a scenario's measured metrics against the baseline. + * @returns {{ rows: Array, regressed: boolean }} + */ +export function compareScenario(name, measured, baseline) { + const base = baseline.scenarios?.[name] + const tol = { ...DEFAULT_TOLERANCE, ...(base?.tolerance ?? {}) } + const rows = [] + let regressed = false + + for (const [metric, value] of Object.entries(measured)) { + if (typeof value !== 'number') { + continue + } + + const baseValue = base?.metrics?.[metric] + + if (typeof baseValue !== 'number') { + rows.push({ metric, measured: value, baseline: null, limit: null, status: 'new' }) + + continue + } + + const limit = baseValue * (1 + tol.tolFrac) + tol.tolAbs + const over = value > limit + regressed = regressed || over + + rows.push({ + metric, + measured: value, + baseline: baseValue, + limit: Math.round(limit * 100) / 100, + deltaPct: baseValue ? Math.round(((value - baseValue) / baseValue) * 1000) / 10 : null, + status: over ? 'REGRESSED' : 'ok' + }) + } + + return { rows, regressed } +} + +/** Write measured metrics back as the new baseline for the given scenarios. */ +export function updateBaseline(path, results) { + const baseline = loadBaseline(path) + baseline.scenarios ??= {} + + for (const { name, metrics } of results) { + const numeric = Object.fromEntries(Object.entries(metrics).filter(([, v]) => typeof v === 'number')) + const prev = baseline.scenarios[name] ?? {} + baseline.scenarios[name] = { ...prev, metrics: numeric } + } + + baseline._meta = { + ...baseline._meta, + updated: new Date().toISOString(), + platform: `${process.platform}-${process.arch}`, + node: process.version + } + + writeFileSync(path, `${JSON.stringify(baseline, null, 2)}\n`) +} + +export { DEFAULT_TOLERANCE } diff --git a/ui-desktop/scripts/perf/lib/cdp.mjs b/ui-desktop/scripts/perf/lib/cdp.mjs new file mode 100644 index 00000000..edba451d --- /dev/null +++ b/ui-desktop/scripts/perf/lib/cdp.mjs @@ -0,0 +1,202 @@ +// The one Chrome DevTools Protocol client for the desktop perf harness. +// +// Before this, every measure-*/profile-* script shipped its own copy-pasted +// `CDP` class (four subtly different implementations), its own `/json` vs +// `/json/list` target discovery, and its own Profiler ranking. Scenarios now +// import from here so there is a single place to fix a protocol bug. + +const DEFAULT_PORT = 9222 + +// Stable DOM hooks the renderer exposes. Centralised so a component refactor +// updates one constant instead of a dozen scattered querySelector strings. +export const SELECTORS = { + composer: '[data-slot="composer-rich-input"]', + threadViewport: '[data-slot="aui_thread-viewport"]', + threadContent: '[data-slot="aui_thread-content"]', + assistantMessage: '[data-slot="aui_assistant-message-root"]', + turnPair: '[data-slot="aui_turn-pair"]', + profileRail: '[data-slot="profile-rail"]', + rowButton: '[data-slot="row-button"]' +} + +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) + +/** + * Poll the CDP HTTP endpoint until a page target is available. + * @param {object} [opts] + * @param {number} [opts.port] remote-debugging-port (default 9222). + * @param {string} [opts.match] substring the target URL must contain (e.g. a dev-server port). + * @param {number} [opts.timeoutMs] how long to wait for a target. + */ +export async function discoverTarget({ port = DEFAULT_PORT, match, timeoutMs = 30000 } = {}) { + const deadline = Date.now() + timeoutMs + + for (;;) { + try { + const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json() + const pages = list.filter(t => t.type === 'page' && typeof t.webSocketDebuggerUrl === 'string') + const target = match + ? pages.find(t => String(t.url).includes(match)) + : pages.find(t => String(t.url).startsWith('http')) ?? pages[0] + + if (target) { + return target + } + } catch { + // debug port not up yet — keep polling until the deadline. + } + + if (Date.now() >= deadline) { + throw new Error(`no CDP page target on :${port}${match ? ` matching "${match}"` : ''} within ${timeoutMs}ms`) + } + + await sleep(250) + } +} + +export class CDP { + constructor(ws) { + this.ws = ws + this.id = 0 + this.pending = new Map() + this.listeners = new Map() + } + + static async open(url) { + const ws = new WebSocket(url) + + await new Promise((resolve, reject) => { + ws.addEventListener('open', resolve, { once: true }) + ws.addEventListener('error', reject, { once: true }) + }) + + const cdp = new CDP(ws) + + ws.addEventListener('message', ev => { + const m = JSON.parse(typeof ev.data === 'string' ? ev.data : ev.data.toString('utf8')) + + if (m.id != null && cdp.pending.has(m.id)) { + const { resolve, reject } = cdp.pending.get(m.id) + cdp.pending.delete(m.id) + + if (m.error) { + reject(new Error(m.error.message)) + } else { + resolve(m.result) + } + } else if (m.method) { + for (const handler of cdp.listeners.get(m.method) ?? []) { + handler(m.params) + } + } + }) + + ws.addEventListener('close', () => { + for (const { reject } of cdp.pending.values()) { + reject(new Error('CDP socket closed')) + } + + cdp.pending.clear() + }) + + return cdp + } + + /** Connect straight to a discovered target. */ + static async connect(opts) { + const target = await discoverTarget(opts) + + return CDP.open(target.webSocketDebuggerUrl) + } + + send(method, params = {}) { + const id = ++this.id + + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }) + this.ws.send(JSON.stringify({ id, method, params })) + }) + } + + on(method, handler) { + if (!this.listeners.has(method)) { + this.listeners.set(method, []) + } + + this.listeners.get(method).push(handler) + } + + /** Evaluate an expression in the page and return its value (awaits promises). */ + async eval(expression) { + const r = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }) + + if (r.exceptionDetails) { + throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text || 'eval failed') + } + + return r.result.value + } + + close() { + this.ws.close() + } +} + +/** Assert the renderer has the dev-only `__PERF_DRIVE__` harness attached. */ +export async function requireDriver(cdp) { + const ok = await cdp.eval('!!(window.__PERF_DRIVE__ && window.__PERF_DRIVE__.stream)') + + if (!ok) { + throw new Error( + '__PERF_DRIVE__ not on window. The perf harness needs a DEV renderer ' + + '(perf-probe.tsx is excluded from production builds). Launch with `npm run perf:serve`.' + ) + } +} + +/** Type real key events into the composer, one char at a time, at `cps` chars/sec. */ +export async function typeIntoComposer(cdp, text, { cps = 15 } = {}) { + await cdp.eval(`(() => { + const el = document.querySelector(${JSON.stringify(SELECTORS.composer)}) + if (!el) return false + el.focus() + const range = document.createRange() + range.selectNodeContents(el) + range.collapse(false) + const sel = window.getSelection() + sel.removeAllRanges() + sel.addRange(range) + return true + })()`) + + const intervalMs = Math.max(1, Math.round(1000 / cps)) + + for (const ch of text) { + await cdp.send('Input.dispatchKeyEvent', { type: 'char', text: ch, unmodifiedText: ch }) + await sleep(intervalMs) + } +} + +/** + * Run `body()` while a V8 CPU profile is recording. Returns + * `{ result, profile }`; the caller decides whether to write the .cpuprofile. + */ +export async function withCpuProfile(cdp, body, { samplingIntervalUs = 100 } = {}) { + await cdp.send('Profiler.enable') + await cdp.send('Profiler.setSamplingInterval', { interval: samplingIntervalUs }) + await cdp.send('Profiler.start') + + let result + let stopped + + try { + result = await body() + } finally { + // Always stop so a scenario error can't leave the profiler running. + stopped = await cdp.send('Profiler.stop') + } + + return { result, profile: stopped.profile } +} + +export { sleep } diff --git a/ui-desktop/scripts/perf/lib/launch.mjs b/ui-desktop/scripts/perf/lib/launch.mjs new file mode 100644 index 00000000..1517044d --- /dev/null +++ b/ui-desktop/scripts/perf/lib/launch.mjs @@ -0,0 +1,418 @@ +// Connect the harness to a renderer — either an already-running debug instance +// (`attach`) or a freshly spawned, fully isolated one (`startIsolatedInstance`). +// +// The isolated instance is what makes the harness self-contained and unblocks +// the measurement that the single-instance lock used to prevent: +// · its own --user-data-dir → its own Electron single-instance lock, so it +// never collides with (or steals focus from) the user's running `hgui`. +// · its own CLAWCODEX_CONFIG_DIR → its own backend + sessions, no shared state. +// · its own --remote-debugging-port → a private CDP endpoint. +// · CLAWCODEX_DESKTOP_BOOT_FAKE=1 → deterministic boot overlay. +// The synthetic scenarios drive `$messages` directly, so no LLM credits are +// spent regardless of the isolated backend. + +import { spawn } from 'node:child_process' +import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { createRequire } from 'node:module' +import { homedir, tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { CDP, requireDriver, sleep } from './cdp.mjs' + +const require = createRequire(import.meta.url) +const DESKTOP_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') + +async function reachable(url) { + try { + await fetch(url) + + return true + } catch { + return false + } +} + +async function waitFor(fn, { timeoutMs, label }) { + const deadline = Date.now() + timeoutMs + + while (Date.now() < deadline) { + if (await fn()) { + return + } + + await sleep(300) + } + + throw new Error(`timed out after ${timeoutMs}ms waiting for ${label}`) +} + +// Seed an isolated CLAWCODEX_CONFIG_DIR with just enough config (NOT sessions) so the +// spawned instance reaches an empty chat view instead of the onboarding wizard. +// A separate CLAWCODEX_CONFIG_DIR dir means a separate gateway lock — no collision with +// the user's running app, which keeps its own sessions DB and state. +function seedConfigFrom(sourceHome, targetHome) { + if (!existsSync(sourceHome)) { + return + } + + for (const name of ['config.yaml', '.env', 'auth.json']) { + const from = join(sourceHome, name) + + if (existsSync(from)) { + try { + copyFileSync(from, join(targetHome, name)) + } catch { + // best-effort — a missing file just means onboarding may appear. + } + } + } +} + +// Resolve the vite CLI entry via its package.json `bin` (Vite 8's `exports` +// blocks importing `vite/bin/vite.js` directly). +function resolveViteBin() { + const pkgPath = require.resolve('vite/package.json') + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) + const rel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.vite + + if (!rel) { + throw new Error('could not resolve the vite CLI from vite/package.json') + } + + return join(dirname(pkgPath), rel) +} + +// Poll the perf driver's `connected()` until the gateway socket is open. +// Returns false if the probe predates this helper or the timeout elapses. +async function waitForConnected(cdp, timeoutMs) { + const hasProbe = await cdp.eval('typeof window.__PERF_DRIVE__.connected === "function"') + + if (!hasProbe) { + return false + } + + const deadline = Date.now() + timeoutMs + + while (Date.now() < deadline) { + if (await cdp.eval('window.__PERF_DRIVE__.connected()')) { + return true + } + + await sleep(500) + } + + return false +} + +function runProcess(command, args, { env } = {}) { + return new Promise((resolveRun, reject) => { + const child = spawn(command, args, { + cwd: DESKTOP_DIR, + stdio: 'inherit', + env: env ? { ...process.env, ...env } : process.env + }) + child.on('error', reject) + child.on('exit', code => (code === 0 ? resolveRun() : reject(new Error(`${command} ${args[0]} exited ${code}`)))) + }) +} + +function runNode(scriptRelPath, args = []) { + return runProcess(process.execPath, [join(DESKTOP_DIR, scriptRelPath), ...args]) +} + +// Build a production renderer WITH the perf probe included (VITE_PERF_PROBE=1), +// plus the prod electron-main bundle, so the harness can measure a real, +// minified React build instead of the ~3x-slower dev build. Slow (a full vite +// build); do it once, then run/attach many times. +export async function buildProdRenderer() { + const viteBin = resolveViteBin() + await runProcess(process.execPath, [viteBin, 'build'], { env: { VITE_PERF_PROBE: '1' } }) + await runNode('scripts/bundle-electron-main.mjs') +} + +/** Attach to a renderer already listening on `port` (launched via perf:serve or with --remote-debugging-port). */ +export async function attach({ port = 9222, match } = {}) { + const cdp = await CDP.connect({ port, match }) + await requireDriver(cdp) + + return { cdp, teardown: () => cdp.close() } +} + +/** + * Spawn an isolated dev instance (vite + electron), wait for the perf driver, + * and return `{ cdp, teardown, devUrl, port }`. `teardown` kills both children + * and removes any temp dirs it created. + */ +// Chromium switches that stop frame-production throttling for a window that +// isn't foregrounded (the perf window usually sits behind the IDE/terminal). +const ANTI_THROTTLE_FLAGS = [ + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--disable-backgrounding-occluded-windows', + '--disable-features=CalculateNativeWinOcclusion' +] + +/** + * Spawn an isolated instance and connect the perf driver. Two render modes: + * · dev (default): vite dev server + dev electron-main bundle. + * · prod (`prod: true`): a production build (call buildProdRenderer first); + * electron loads dist/index.html — representative, minified React. + * `coldStart: true` skips the gateway-connect wait and settle (for launch-time + * measurement) and returns `timings` (spawn→CDP, spawn→driver) plus renderer + * boot marks (FCP, time-to-composer). + */ +export async function startIsolatedInstance({ + port = 9222, + devPort = 5174, + prod = false, + coldStart = false, + clawcodexHome, + userDataDir, + seedConfig = true, + settleMs = 2500, + connectTimeoutMs = 90000 +} = {}) { + const children = [] + const tempDirs = [] + + const mkTemp = prefix => { + const dir = mkdtempSync(join(tmpdir(), prefix)) + tempDirs.push(dir) + + return dir + } + + const home = clawcodexHome ?? mkTemp('clawcodex-perf-home-') + const userData = userDataDir ?? mkTemp('clawcodex-perf-ud-') + const devUrl = prod ? null : `http://127.0.0.1:${devPort}` + + if (seedConfig && !clawcodexHome) { + seedConfigFrom(join(homedir(), '.clawcodex'), home) + } + + const teardown = () => { + for (const child of children) { + try { + child.kill('SIGTERM') + } catch { + // already gone + } + } + + for (const dir of tempDirs) { + try { + rmSync(dir, { recursive: true, force: true }) + } catch { + // best-effort + } + } + } + + try { + if (prod) { + // Renderer + main are expected pre-built (buildProdRenderer). Cheap to + // re-bundle main so an isolated run always matches current source. + await runNode('scripts/bundle-electron-main.mjs') + } else { + if (!(await reachable(devUrl))) { + const viteBin = resolveViteBin() + const vite = spawn(process.execPath, [viteBin, '--host', '127.0.0.1', '--port', String(devPort)], { + cwd: DESKTOP_DIR, + stdio: ['ignore', 'inherit', 'inherit'] + }) + children.push(vite) + await waitFor(() => reachable(devUrl), { timeoutMs: 60000, label: `vite dev server on :${devPort}` }) + } + + await runNode('scripts/bundle-electron-main.mjs', ['--dev']) + } + + // Isolated Electron: own --user-data-dir (single-instance lock scope) + own + // CLAWCODEX_CONFIG_DIR (backend + sessions). No DEV_SERVER env in prod → dist load. + const electronBin = require('electron') + // NB: do NOT set CLAWCODEX_DESKTOP_BOOT_FAKE here — it injects artificial + // per-phase sleeps into the boot overlay, which inflates cold-start timing + // (and adds pointless startup latency to the steady-state runs). We want the + // real boot sequence. + const env = { + ...process.env, + CLAWCODEX_CONFIG_DIR: home, + XCURSOR_SIZE: '24' + } + + if (devUrl) { + env.CLAWCODEX_DESKTOP_DEV_SERVER = devUrl + } + + const spawnAt = Date.now() + const electron = spawn( + electronBin, + ['.', `--user-data-dir=${userData}`, `--remote-debugging-port=${port}`, ...ANTI_THROTTLE_FLAGS], + { cwd: DESKTOP_DIR, stdio: ['ignore', 'inherit', 'inherit'], env } + ) + children.push(electron) + + // Wait for the renderer + perf driver. In prod the target URL is file://, + // so don't match on the dev port. + let cdp = null + let cdpAt = 0 + await waitFor( + async () => { + try { + cdp = await CDP.connect({ port, match: devUrl ? String(devPort) : undefined, timeoutMs: 2000 }) + cdpAt = cdpAt || Date.now() + + return await cdp.eval('!!(window.__PERF_DRIVE__ && window.__PERF_DRIVE__.stream)') + } catch { + if (cdp) { + cdp.close() + cdp = null + } + + return false + } + }, + { timeoutMs: 120000, label: 'isolated renderer + __PERF_DRIVE__' } + ) + const driverAt = Date.now() + + try { + await cdp.send('Emulation.setFocusEmulationEnabled', { enabled: true }) + } catch { + // Older CDP / not supported — fall back to the anti-throttle flags. + } + + // Renderer-side boot marks (relative to its own navigation start). + const bootMarks = await readBootMarks(cdp) + const timings = { + spawn_to_cdp_ms: cdpAt ? cdpAt - spawnAt : null, + spawn_to_driver_ms: driverAt - spawnAt, + ...bootMarks + } + + let connected = true + + if (!coldStart) { + // Steady-state scenarios: wait for the gateway to connect (reconnect churn + // contaminates frame pacing) and let residual cold-start work drain. + connected = await waitForConnected(cdp, connectTimeoutMs) + + if (!connected) { + console.warn( + `[perf] gateway did not connect within ${connectTimeoutMs}ms — ` + + 'stream/frame numbers may be inflated by reconnect churn.' + ) + } + + await sleep(settleMs) + } + + return { + connected, + cdp, + devUrl, + port, + prod, + timings, + teardown: () => { + cdp?.close() + teardown() + } + } + } catch (err) { + teardown() + throw err + } +} + +// Representative cold-start sampling. A fresh --user-data-dir means a COLD V8 +// code cache and worst-case bundle recompile every run (~+400ms measured); real +// users reuse their profile, so a warm cache is the representative case. We reuse +// ONE profile across runs: run 0 warms the cache (discarded), runs 1..N are the +// warm samples. Each run steps the port so a just-killed instance can't be +// re-attached, and we pause between runs so the single-instance lock releases. +export async function coldStartSamples({ runs = 3, port = 9222, devPort = 5174, prod = false, warm = true } = {}) { + const pickNumeric = timings => Object.fromEntries(Object.entries(timings).filter(([, v]) => typeof v === 'number')) + const samples = [] + + if (warm) { + // Shared profile across runs: run 0 warms the V8 code cache (discarded), + // runs 1..N are the representative warm samples. + const home = mkdtempSync(join(tmpdir(), 'clawcodex-perf-cold-home-')) + const userDataDir = mkdtempSync(join(tmpdir(), 'clawcodex-perf-cold-ud-')) + seedConfigFrom(join(homedir(), '.clawcodex'), home) + + try { + for (let i = 0; i <= runs; i++) { + const inst = await startIsolatedInstance({ + port: port + i, + devPort: devPort + i, + prod, + coldStart: true, + clawcodexHome: home, + userDataDir, + seedConfig: false + }) + + if (i > 0) { + samples.push(pickNumeric(inst.timings)) + } + + inst.teardown() + await sleep(2500) // let the single-instance lock release before reuse + } + } finally { + for (const dir of [home, userDataDir]) { + try { + rmSync(dir, { recursive: true, force: true }) + } catch { + // best-effort + } + } + } + } else { + // Worst case: a fresh profile per run → cold code cache every launch + // (first-launch-after-install). startIsolatedInstance makes+removes its dirs. + for (let i = 0; i < runs; i++) { + const inst = await startIsolatedInstance({ port: port + i, devPort: devPort + i, prod, coldStart: true }) + samples.push(pickNumeric(inst.timings)) + inst.teardown() + await sleep(2500) + } + } + + return samples +} + +// Read First Contentful Paint + time-to-composer from the renderer, relative to +// its navigation start (the process-spawn deltas live in `timings`). +async function readBootMarks(cdp) { + try { + return await cdp.eval(`(() => { + const paints = performance.getEntriesByType('paint') + const fcp = paints.find(p => p.name === 'first-contentful-paint') + const nav = performance.getEntriesByType('navigation')[0] + const composer = document.querySelector('[data-slot="composer-rich-input"]') + // Largest script resource ≈ the (intentionally single) renderer bundle. + // responseEnd → the script's own decode; the eval cost shows up as the gap + // between the bundle's responseEnd and domInteractive. + const scripts = performance.getEntriesByType('resource').filter(r => r.initiatorType === 'script') + const mainScript = scripts.sort((a, b) => (b.encodedBodySize || 0) - (a.encodedBodySize || 0))[0] + const round = n => (typeof n === 'number' ? Math.round(n) : null) + return { + fcp_ms: fcp ? round(fcp.startTime) : null, + dom_interactive_ms: nav ? round(nav.domInteractive) : null, + dom_content_loaded_ms: nav ? round(nav.domContentLoadedEventEnd) : null, + main_script_kb: mainScript ? round((mainScript.encodedBodySize || 0) / 1024) : null, + main_script_response_end_ms: mainScript ? round(mainScript.responseEnd) : null, + nav_to_read_ms: round(performance.now()), + composer_present: !!composer + } + })()`) + } catch { + return { fcp_ms: null, dom_interactive_ms: null, composer_present: false } + } +} + +export { DESKTOP_DIR } diff --git a/ui-desktop/scripts/perf/lib/stats.mjs b/ui-desktop/scripts/perf/lib/stats.mjs new file mode 100644 index 00000000..d82dce40 --- /dev/null +++ b/ui-desktop/scripts/perf/lib/stats.mjs @@ -0,0 +1,89 @@ +// Shared numeric helpers for perf scenarios. Every measure-*/profile-* script +// used to carry its own copy of these. + +/** Nearest-rank percentile over an UNSORTED array. p in [0,1]. */ +export function percentile(values, p) { + if (!values.length) { + return 0 + } + + const sorted = [...values].sort((a, b) => a - b) + const idx = Math.min(sorted.length - 1, Math.floor(sorted.length * p)) + + return sorted[idx] +} + +/** min/p50/p90/p95/p99/max/mean over a sample array (rounded to 2dp). */ +export function summarize(values) { + const round = n => Math.round(n * 100) / 100 + + if (!values.length) { + return { n: 0, min: 0, p50: 0, p90: 0, p95: 0, p99: 0, max: 0, mean: 0 } + } + + const sorted = [...values].sort((a, b) => a - b) + const mean = values.reduce((a, b) => a + b, 0) / values.length + + return { + n: values.length, + min: round(sorted[0]), + p50: round(percentile(sorted, 0.5)), + p90: round(percentile(sorted, 0.9)), + p95: round(percentile(sorted, 0.95)), + p99: round(percentile(sorted, 0.99)), + max: round(sorted[sorted.length - 1]), + mean: round(mean) + } +} + +/** Median of a numeric array (used to reduce N repeated runs to one number). */ +export function median(values) { + return percentile(values, 0.5) +} + +/** Frame-interval histogram matching the buckets the stream scripts reported. */ +export function frameHistogram(frames) { + const buckets = { '<=16.7': 0, '16.7-33': 0, '33-50': 0, '50-100': 0, '100-200': 0, '>200': 0 } + + for (const f of frames) { + if (f <= 16.7) buckets['<=16.7']++ + else if (f <= 33) buckets['16.7-33']++ + else if (f <= 50) buckets['33-50']++ + else if (f <= 100) buckets['50-100']++ + else if (f <= 200) buckets['100-200']++ + else buckets['>200']++ + } + + return buckets +} + +/** + * Rank functions by self-time from a V8 CPU profile (Profiler.stop output). + * Returns the top `limit` entries as { ms, name, url, line }. + */ +export function cpuProfileTopSelf(profile, limit = 30) { + const samples = profile.samples || [] + const timeDeltas = profile.timeDeltas || [] + const nodes = new Map(profile.nodes.map(n => [n.id, n])) + const selfUs = new Map() + + for (let i = 0; i < samples.length; i++) { + const id = samples[i] + selfUs.set(id, (selfUs.get(id) || 0) + (timeDeltas[i] ?? 0)) + } + + return [...selfUs.entries()] + .map(([id, us]) => { + const cf = nodes.get(id)?.callFrame || {} + + return { + ms: us / 1000, + name: cf.functionName || '(anonymous)', + url: String(cf.url || '').slice(-70), + line: cf.lineNumber + } + }) + .filter(x => !/\(root\)|\(idle\)|\(garbage collector\)|\(program\)/.test(x.name)) + .sort((a, b) => b.ms - a.ms) + .slice(0, limit) +} diff --git a/ui-desktop/src/app/pet-generate/lib/frame-count.ts b/ui-desktop/src/app/pet-generate/lib/frame-count.ts new file mode 100644 index 00000000..d65dc5e7 --- /dev/null +++ b/ui-desktop/src/app/pet-generate/lib/frame-count.ts @@ -0,0 +1,32 @@ +import { type PetInfo } from '@/store/pet' + +// Sprite row → the PetInfo frame-count key it resolves to (directional walks and +// aliases collapse onto their base state). +const ROW_TO_FRAME_KEY: Record = { + idle: 'idle', + wave: 'wave', + waving: 'wave', + jump: 'jump', + jumping: 'jump', + run: 'run', + running: 'run', + 'running-right': 'run', + 'running-left': 'run', + failed: 'failed', + review: 'review', + waiting: 'waiting' +} + +// Real frame count for a row, preferring the concrete per-row count, then the +// per-state count, then the mapped base state, then the sheet-wide default. +export function frameCountForRow(pet: PetInfo, row: string): number { + const mapped = ROW_TO_FRAME_KEY[row] + + return ( + pet.framesByRow?.[row] ?? + pet.framesByState?.[row] ?? + (mapped ? pet.framesByState?.[mapped] : undefined) ?? + pet.framesPerState ?? + 0 + ) +} diff --git a/ui-desktop/src/app/pet-generate/lib/read-reference-image.ts b/ui-desktop/src/app/pet-generate/lib/read-reference-image.ts new file mode 100644 index 00000000..06c480e9 --- /dev/null +++ b/ui-desktop/src/app/pet-generate/lib/read-reference-image.ts @@ -0,0 +1,49 @@ +const DEFAULT_MAX_INPUT_BYTES = 16 * 1024 * 1024 + +function loadImage(url: string): Promise { + const img = new Image() + + return new Promise((resolve, reject) => { + img.onload = () => resolve(img) + img.onerror = () => reject(new Error('unreadable image')) + img.src = url + }) +} + +// Read an image file as a downscaled PNG data URL. We decode from an object URL +// (not readAsDataURL) so large files don't inflate into giant base64 strings +// before we scale them down for generation. +export async function readReferenceImage( + file: File, + max = 1024, + maxInputBytes = DEFAULT_MAX_INPUT_BYTES +): Promise { + if (file.size > maxInputBytes) { + throw new Error('reference image too large') + } + + const objectUrl = URL.createObjectURL(file) + + try { + const img = await loadImage(objectUrl) + const scale = Math.min(1, max / Math.max(img.width, img.height)) + const width = Math.max(1, Math.round(img.width * scale)) + const height = Math.max(1, Math.round(img.height * scale)) + + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + + const ctx = canvas.getContext('2d') + + if (!ctx) { + throw new Error('could not create canvas context') + } + + ctx.drawImage(img, 0, 0, width, height) + + return canvas.toDataURL('image/png') + } finally { + URL.revokeObjectURL(objectUrl) + } +} diff --git a/ui-desktop/src/lib/ansi.test.ts b/ui-desktop/src/lib/ansi.test.ts new file mode 100644 index 00000000..30b9d410 --- /dev/null +++ b/ui-desktop/src/lib/ansi.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' + +import { ansiColorClass, hasAnsiCodes, parseAnsi } from './ansi' + +const ESC = '\x1b' + +describe('parseAnsi', () => { + it('returns a single default segment for plain text', () => { + expect(parseAnsi('hello world')).toEqual([{ bold: false, fg: null, text: 'hello world' }]) + }) + + it('returns nothing for an empty string', () => { + expect(parseAnsi('')).toEqual([]) + }) + + it('parses a basic foreground color sequence and resets', () => { + const input = `${ESC}[31merror${ESC}[0m ok` + + expect(parseAnsi(input)).toEqual([ + { bold: false, fg: 'red', text: 'error' }, + { bold: false, fg: null, text: ' ok' } + ]) + }) + + it('treats bold (1) and bold-off (22) as toggles without affecting fg', () => { + const input = `${ESC}[1mloud${ESC}[22m quiet` + + expect(parseAnsi(input)).toEqual([ + { bold: true, fg: null, text: 'loud' }, + { bold: false, fg: null, text: ' quiet' } + ]) + }) + + it('treats default-fg (39) as a foreground-only reset (keeps bold)', () => { + const input = `${ESC}[1;31mboth${ESC}[39mbold-only` + + expect(parseAnsi(input)).toEqual([ + { bold: true, fg: 'red', text: 'both' }, + { bold: true, fg: null, text: 'bold-only' } + ]) + }) + + it('handles bright colors via the 90-97 range', () => { + expect(parseAnsi(`${ESC}[92mgreen`)).toEqual([{ bold: false, fg: 'bright-green', text: 'green' }]) + }) + + it('coalesces adjacent runs with the same style', () => { + const input = `${ESC}[31ma${ESC}[31mb${ESC}[31mc` + + expect(parseAnsi(input)).toEqual([{ bold: false, fg: 'red', text: 'abc' }]) + }) + + it('skips 256-color (38;5) trailing args without painting fg or leaking the params as text', () => { + // 256-color and truecolor aren't rendered (FG_BY_CODE doesn't cover them), + // but the parser must consume the trailing `;5;` / `;2;r;g;b` args so + // they never bleed into the visible segment text. + const segments = parseAnsi(`${ESC}[38;5;208morange${ESC}[0m`) + + expect(segments).toHaveLength(1) + expect(segments[0].fg).toBe(null) + expect(segments[0].text).toBe('orange') + }) + + it('skips truecolor (38;2;r;g;b) trailing args', () => { + const segments = parseAnsi(`${ESC}[38;2;10;20;30mrgb${ESC}[0m`) + + expect(segments).toHaveLength(1) + expect(segments[0].fg).toBe(null) + expect(segments[0].text).toBe('rgb') + }) + + it('drops non-SGR CSI sequences (cursor motion, erase) without consuming surrounding text', () => { + const input = `before${ESC}[2Jmiddle${ESC}[10;5Hafter` + + expect(parseAnsi(input)).toEqual([{ bold: false, fg: null, text: 'beforemiddleafter' }]) + }) + + it('treats an empty SGR parameter (ESC[m) as a full reset', () => { + const input = `${ESC}[1;31mfoo${ESC}[mbar` + + expect(parseAnsi(input)).toEqual([ + { bold: true, fg: 'red', text: 'foo' }, + { bold: false, fg: null, text: 'bar' } + ]) + }) +}) + +describe('hasAnsiCodes', () => { + it('returns false for plain text', () => { + expect(hasAnsiCodes('hello world')).toBe(false) + }) + + it('returns true when any CSI introducer is present', () => { + expect(hasAnsiCodes(`${ESC}[31mred`)).toBe(true) + }) +}) + +describe('ansiColorClass', () => { + it('returns a non-empty Tailwind class string for every supported color', () => { + const colors = [ + 'black', + 'red', + 'green', + 'yellow', + 'blue', + 'magenta', + 'cyan', + 'white', + 'bright-black', + 'bright-red', + 'bright-green', + 'bright-yellow', + 'bright-blue', + 'bright-magenta', + 'bright-cyan', + 'bright-white' + ] as const + + for (const color of colors) { + expect(ansiColorClass(color)).toMatch(/\S/) + } + }) +}) diff --git a/ui-desktop/src/lib/ansi.ts b/ui-desktop/src/lib/ansi.ts new file mode 100644 index 00000000..c7770e8b --- /dev/null +++ b/ui-desktop/src/lib/ansi.ts @@ -0,0 +1,186 @@ +// Minimal ANSI SGR parser for rendering terminal output inside chat tool +// cards. Only handles the SGR codes that show up in practice (color, bold, +// reset); cursor motions and other CSI sequences are dropped silently. +// +// Returns a flat array of styled segments so callers can render them as +// React spans without each consumer having to re-implement the parser. + +export interface AnsiSegment { + bold: boolean + /** Tailwind text-color class or null for the default foreground. */ + fg: AnsiColor | null + text: string +} + +export type AnsiColor = + | 'black' + | 'red' + | 'green' + | 'yellow' + | 'blue' + | 'magenta' + | 'cyan' + | 'white' + | 'bright-black' + | 'bright-red' + | 'bright-green' + | 'bright-yellow' + | 'bright-blue' + | 'bright-magenta' + | 'bright-cyan' + | 'bright-white' + +const FG_BY_CODE: Record = { + 30: 'black', + 31: 'red', + 32: 'green', + 33: 'yellow', + 34: 'blue', + 35: 'magenta', + 36: 'cyan', + 37: 'white', + 90: 'bright-black', + 91: 'bright-red', + 92: 'bright-green', + 93: 'bright-yellow', + 94: 'bright-blue', + 95: 'bright-magenta', + 96: 'bright-cyan', + 97: 'bright-white' +} + +// CSI = ESC '[' params 'final'. We only care about SGR (final == 'm'); other +// final bytes are matched and consumed so they don't leak into the rendered +// text. Range covers the common CSI command set (A-Z / a-z / @). +// eslint-disable-next-line no-control-regex +const CSI_RE = /\x1b\[([\d;]*)([\x40-\x7e])/g +// Other escape sequences (single-char OSC/SS3/etc.) — strip silently. +// eslint-disable-next-line no-control-regex +const OTHER_ESCAPE_RE = /\x1b[@-Z\\-_]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g + +export function parseAnsi(input: string): AnsiSegment[] { + if (!input) { + return [] + } + + // Strip non-CSI escapes upfront — none of them carry text we want to keep + // and CSI_RE wouldn't match them. + const cleaned = input.replace(OTHER_ESCAPE_RE, '') + + const segments: AnsiSegment[] = [] + let cursor = 0 + let bold = false + let fg: AnsiColor | null = null + + const pushText = (text: string) => { + if (!text) { + return + } + + const last = segments.at(-1) + + if (last && last.bold === bold && last.fg === fg) { + last.text += text + + return + } + + segments.push({ bold, fg, text }) + } + + CSI_RE.lastIndex = 0 + let match: RegExpExecArray | null + + while ((match = CSI_RE.exec(cleaned)) !== null) { + const start = match.index + + if (start > cursor) { + pushText(cleaned.slice(cursor, start)) + } + + if (match[2] === 'm') { + const codes = match[1] + .split(';') + .map(part => (part === '' ? 0 : Number(part))) + .filter(value => Number.isFinite(value)) + + for (let i = 0; i < codes.length; i += 1) { + const code = codes[i] + + if (code === 0) { + bold = false + fg = null + } else if (code === 1) { + bold = true + } else if (code === 22) { + bold = false + } else if (code === 39) { + fg = null + } else if (code in FG_BY_CODE) { + fg = FG_BY_CODE[code] + } else if (code === 38) { + // 256-color / truecolor — skip the trailing args we don't render. + if (codes[i + 1] === 5) { + i += 2 + } else if (codes[i + 1] === 2) { + i += 4 + } + } + // Background colors (40-47, 100-107) and effects we don't render are + // intentionally ignored — the segment keeps the prior bold/fg state. + } + } + + cursor = CSI_RE.lastIndex + } + + if (cursor < cleaned.length) { + pushText(cleaned.slice(cursor)) + } + + return segments +} + +const TAILWIND_BY_COLOR: Record = { + // Tuned for legibility against the muted bg-(--ui-bg-tertiary) surface used + // in tool cards. We don't paint pure ANSI colors (#000, #fff) because they + // disappear into the surface. + black: 'text-zinc-700 dark:text-zinc-300', + red: 'text-red-700 dark:text-red-300', + green: 'text-emerald-700 dark:text-emerald-300', + yellow: 'text-amber-700 dark:text-amber-300', + blue: 'text-blue-700 dark:text-blue-300', + magenta: 'text-fuchsia-700 dark:text-fuchsia-300', + cyan: 'text-cyan-700 dark:text-cyan-300', + white: 'text-zinc-600 dark:text-zinc-200', + 'bright-black': 'text-zinc-500 dark:text-zinc-400', + 'bright-red': 'text-rose-600 dark:text-rose-300', + 'bright-green': 'text-emerald-600 dark:text-emerald-200', + 'bright-yellow': 'text-amber-600 dark:text-amber-200', + 'bright-blue': 'text-sky-600 dark:text-sky-300', + 'bright-magenta': 'text-pink-600 dark:text-pink-300', + 'bright-cyan': 'text-teal-600 dark:text-teal-200', + 'bright-white': 'text-zinc-500 dark:text-zinc-100' +} + +export function ansiColorClass(color: AnsiColor): string { + return TAILWIND_BY_COLOR[color] +} + +/** Returns true if the input contains at least one CSI sequence. Cheap check + * so callers can skip the parser for plain-ASCII output. */ +export function hasAnsiCodes(input: string): boolean { + // eslint-disable-next-line no-control-regex + return /\x1b\[/.test(input) +} + +/** Remove all ANSI escape sequences, returning plain text. Use when output is + * rendered as text (e.g. chat system messages) rather than styled segments — + * otherwise the ESC byte is invisible and the `[1;31m…` payload leaks through. */ +export function stripAnsi(input: string): string { + if (!input) { + return input + } + + return input.replace(OTHER_ESCAPE_RE, '').replace(CSI_RE, '') +} diff --git a/ui-desktop/src/lib/artifact-detect.test.ts b/ui-desktop/src/lib/artifact-detect.test.ts new file mode 100644 index 00000000..d4aba791 --- /dev/null +++ b/ui-desktop/src/lib/artifact-detect.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' + +import { artifactContentHash, artifactDownloadName, artifactSlug, detectArtifact } from './artifact-detect' + +const HTML_DOC = ` + +Pomodoro Timer + +

Pomodoro

+ + +` + +function longCode(lines: number): string { + return Array.from( + { length: lines }, + (_, i) => `export function helper${i}(value: number) { return value * ${i} }` + ).join('\n') +} + +describe('detectArtifact', () => { + it('promotes a full html document', () => { + const detection = detectArtifact('html', HTML_DOC) + + expect(detection).not.toBeNull() + expect(detection?.kind).toBe('html') + expect(detection?.title).toBe('Pomodoro Timer') + }) + + it('falls back to h1 when the document has no title tag', () => { + const doc = `

Budget Dashboard

${'
x
'.repeat(30)}` + + expect(detectArtifact('html', doc)?.title).toBe('Budget Dashboard') + }) + + it('ignores a small html snippet', () => { + expect(detectArtifact('html', '
hello
')).toBeNull() + }) + + it('ignores small svg fences (inline embed owns them)', () => { + expect(detectArtifact('svg', '')).toBeNull() + }) + + it('promotes a large svg', () => { + const svg = `Org Chart${''.repeat(80)}` + const detection = detectArtifact('svg', svg) + + expect(detection?.kind).toBe('svg') + expect(detection?.title).toBe('Org Chart') + }) + + it('keeps short code inline', () => { + expect(detectArtifact('python', 'print("hi")')).toBeNull() + }) + + it('promotes long code and derives a declaration title', () => { + const code = `export function buildDashboard(config: Config) {\n${longCode(60)}\n}` + const detection = detectArtifact('typescript', code) + + expect(detection?.kind).toBe('code') + expect(detection?.title).toBe('buildDashboard') + }) + + it('prefers a filename comment for the title', () => { + const code = `# server.py\n${longCode(60) + .replace(/export function/g, 'def') + .replace(/\{|\}/g, '')}` + + const detection = detectArtifact('python', code) + + expect(detection?.kind).toBe('code') + expect(detection?.title).toBe('server.py') + }) + + it('never promotes prose-ish or terminal fences', () => { + expect(detectArtifact('text', longCode(80))).toBeNull() + expect(detectArtifact('diff', longCode(80))).toBeNull() + expect(detectArtifact('markdown', longCode(80))).toBeNull() + expect(detectArtifact('mermaid', longCode(80))).toBeNull() + }) +}) + +describe('artifactSlug', () => { + it('is stable across regenerations of the same artifact', () => { + const a = artifactSlug({ kind: 'html', language: 'html', title: 'Pomodoro Timer' }) + const b = artifactSlug({ kind: 'html', language: 'html', title: 'Pomodoro Timer' }) + + expect(a).toBe(b) + expect(a).toContain('html') + }) + + it('distinguishes different titles', () => { + expect(artifactSlug({ kind: 'html', language: 'html', title: 'Timer' })).not.toBe( + artifactSlug({ kind: 'html', language: 'html', title: 'Dashboard' }) + ) + }) + + it('handles empty/symbol-only titles', () => { + expect(artifactSlug({ kind: 'code', language: 'ts', title: '!!!' })).toBe('code:ts:untitled') + }) +}) + +describe('artifactContentHash', () => { + it('is deterministic and content-sensitive', () => { + expect(artifactContentHash('abc')).toBe(artifactContentHash('abc')) + expect(artifactContentHash('abc')).not.toBe(artifactContentHash('abd')) + }) +}) + +describe('artifactDownloadName', () => { + it('keeps an existing extension', () => { + expect(artifactDownloadName('code', 'python', 'server.py')).toBe('server.py') + }) + + it('appends by kind and language', () => { + expect(artifactDownloadName('html', 'html', 'Pomodoro Timer')).toBe('Pomodoro-Timer.html') + expect(artifactDownloadName('svg', 'svg', 'Org Chart')).toBe('Org-Chart.svg') + expect(artifactDownloadName('code', 'typescript', 'buildDashboard')).toBe('buildDashboard.ts') + expect(artifactDownloadName('code', 'unknownlang', '')).toBe('artifact.txt') + }) +}) diff --git a/ui-desktop/src/lib/artifact-detect.ts b/ui-desktop/src/lib/artifact-detect.ts new file mode 100644 index 00000000..464b92b6 --- /dev/null +++ b/ui-desktop/src/lib/artifact-detect.ts @@ -0,0 +1,232 @@ +import { isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code' + +/** + * Artifact detection — decides when a fenced block in an assistant message is + * substantial, self-contained content that deserves an artifact card (opening + * in the right rail) instead of an inline code block. + * + * Pure and cheap: it runs per streaming delta on the growing fence body, so + * everything here is a bounded regex scan or a line count. No store access. + */ + +export type ArtifactKind = 'code' | 'html' | 'svg' + +export interface ArtifactDetection { + kind: ArtifactKind + /** Sanitized fence language ('' possible for html/svg detected by shape). */ + language: string + /** Human title derived from the content (html , svg <title>, a named + * declaration for code). Falls back to a kind/language label. */ + title: string +} + +// A fence only becomes an artifact once it is unambiguously a document (html), +// a large standalone graphic (svg), or long enough that inlining it would +// drown the conversation (code). Small snippets stay inline code cards. +const HTML_DOC_RE = /<!doctype\s+html|<html[\s>]|<head[\s>]|<body[\s>]/i +const HTML_TAG_RE = /<[a-z][a-z0-9-]*(\s[^>]*)?>/i +const HTML_DOC_MIN_CHARS = 160 +const HTML_FRAGMENT_MIN_CHARS = 1200 +const SVG_MIN_CHARS = 2000 +const CODE_MIN_LINES = 48 +const CODE_MIN_CHARS = 3000 + +const HTML_LANGUAGES = new Set(['html', 'htm', 'xhtml']) + +// Languages whose fences are never artifacts: prose-ish, terminal output, and +// the fences already owned by richer renderers (mermaid diagrams, small svg). +const NON_ARTIFACT_LANGUAGES = new Set([ + '', + 'console', + 'diff', + 'log', + 'logs', + 'markdown', + 'md', + 'mermaid', + 'output', + 'patch', + 'plain', + 'plaintext', + 'shell-session', + 'stdout', + 'text', + 'txt' +]) + +function countLines(text: string): number { + let lines = 1 + let index = text.indexOf('\n') + + while (index !== -1) { + lines += 1 + index = text.indexOf('\n', index + 1) + } + + return lines +} + +function stripTags(value: string): string { + return value + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function titleFromTag(content: string, tag: 'h1' | 'title'): string { + const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i').exec(content) + + return match ? stripTags(match[1] || '').slice(0, 80) : '' +} + +const CODE_DECLARATION_RE = + /(?:^|\n)\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|struct|interface|enum|trait|impl|def|fn)\s+([A-Za-z_$][\w$]*)/ + +// `// app.py`, `# server.ts`, `<!-- index.html -->`, `/* main.rs */` on the +// first meaningful line — the de-facto LLM convention for naming a file. +const FILENAME_COMMENT_RE = /^\s*(?:\/\/|#|--|<!--|\/\*)\s*([\w./-]+\.[a-z0-9]{1,8})\b/i + +function codeTitle(language: string, content: string): string { + const head = content.slice(0, 2000) + const fileName = FILENAME_COMMENT_RE.exec(head)?.[1] + + if (fileName) { + return fileName + } + + const declaration = CODE_DECLARATION_RE.exec(head)?.[1] + + if (declaration) { + return declaration + } + + return language +} + +export function detectArtifact(language: string | undefined, code: string | undefined): ArtifactDetection | null { + const trimmed = (code ?? '').trim() + + if (!trimmed) { + return null + } + + const clean = sanitizeLanguageTag(language || '') + + if (HTML_LANGUAGES.has(clean)) { + const isDocument = HTML_DOC_RE.test(trimmed) + + if ( + (isDocument && trimmed.length >= HTML_DOC_MIN_CHARS) || + (!isDocument && trimmed.length >= HTML_FRAGMENT_MIN_CHARS && HTML_TAG_RE.test(trimmed)) + ) { + return { + kind: 'html', + language: clean, + title: titleFromTag(trimmed, 'title') || titleFromTag(trimmed, 'h1') || 'HTML' + } + } + + return null + } + + if (clean === 'svg') { + // Small svg fences keep their inline embed (svg-embed.tsx); only a large + // standalone graphic graduates into an artifact tab. + if (trimmed.length >= SVG_MIN_CHARS && /<svg[\s>]/i.test(trimmed)) { + return { kind: 'svg', language: clean, title: titleFromTag(trimmed, 'title') || 'SVG' } + } + + return null + } + + if (NON_ARTIFACT_LANGUAGES.has(clean)) { + return null + } + + if (trimmed.length < CODE_MIN_CHARS && countLines(trimmed) < CODE_MIN_LINES) { + return null + } + + if (isLikelyProseCodeBlock(clean, trimmed)) { + return null + } + + return { kind: 'code', language: clean, title: codeTitle(clean, trimmed) } +} + +/** Stable identity slug for versioning: the same (kind, title, language) in a + * session is treated as one artifact the model iterates on. */ +export function artifactSlug(detection: Pick<ArtifactDetection, 'kind' | 'language' | 'title'>): string { + const title = detection.title + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48) + + return `${detection.kind}:${detection.language}:${title || 'untitled'}` +} + +/** Tiny non-cryptographic content hash (FNV-1a) for version dedupe. */ +export function artifactContentHash(content: string): string { + let hash = 0x811c9dc5 + + for (let i = 0; i < content.length; i += 1) { + hash ^= content.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) + } + + return (hash >>> 0).toString(36) +} + +const DOWNLOAD_EXTENSION_BY_LANGUAGE: Record<string, string> = { + bash: '.sh', + c: '.c', + cpp: '.cpp', + csharp: '.cs', + css: '.css', + go: '.go', + htm: '.html', + html: '.html', + java: '.java', + javascript: '.js', + js: '.js', + json: '.json', + jsx: '.jsx', + kotlin: '.kt', + php: '.php', + py: '.py', + python: '.py', + rb: '.rb', + rs: '.rs', + ruby: '.rb', + rust: '.rs', + sh: '.sh', + sql: '.sql', + svg: '.svg', + swift: '.swift', + toml: '.toml', + ts: '.ts', + tsx: '.tsx', + typescript: '.ts', + xhtml: '.html', + xml: '.xml', + yaml: '.yaml', + yml: '.yaml' +} + +export function artifactDownloadName(kind: ArtifactKind, language: string, title: string): string { + const base = + title + .replace(/[^\p{L}\p{N}._ -]+/gu, '') + .trim() + .replace(/\s+/g, '-') + .slice(0, 60) || 'artifact' + + if (/\.[a-z0-9]{1,8}$/i.test(base)) { + return base + } + + const ext = kind === 'html' ? '.html' : kind === 'svg' ? '.svg' : DOWNLOAD_EXTENSION_BY_LANGUAGE[language] || '.txt' + + return `${base}${ext}` +} diff --git a/ui-desktop/src/lib/brand-icon.test.ts b/ui-desktop/src/lib/brand-icon.test.ts new file mode 100644 index 00000000..1b23ec45 --- /dev/null +++ b/ui-desktop/src/lib/brand-icon.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' + +import { resolveBrandIcon } from './brand-icon' + +describe('resolveBrandIcon', () => { + it('resolves a registrable domain to a brand glyph', () => { + expect(resolveBrandIcon('github.com')).toBeTruthy() + expect(resolveBrandIcon('gitlab.com')).toBeTruthy() + }) + + it('ignores case and a leading www.', () => { + const github = resolveBrandIcon('github.com') + + expect(resolveBrandIcon('WWW.GitHub.com')).toBe(github) + }) + + it('inherits the parent brand on subdomains', () => { + const github = resolveBrandIcon('github.com') + + expect(resolveBrandIcon('gist.github.com')).toBe(github) + expect(resolveBrandIcon('api.github.com')).toBe(github) + }) + + it('prefers the longest matching suffix over its parent', () => { + const google = resolveBrandIcon('google.com') + const docs = resolveBrandIcon('docs.google.com') + + expect(docs).toBeTruthy() + expect(docs).not.toBe(google) + }) + + it('returns null for unknown hosts and bare labels', () => { + expect(resolveBrandIcon('example.com')).toBeNull() + expect(resolveBrandIcon('localhost')).toBeNull() + expect(resolveBrandIcon('')).toBeNull() + }) + + it('never matches on a bare public suffix', () => { + // `github.io` is a real entry; a lone `io` must not inherit it. + expect(resolveBrandIcon('io')).toBeNull() + expect(resolveBrandIcon('com')).toBeNull() + }) +}) diff --git a/ui-desktop/src/lib/brand-icon.ts b/ui-desktop/src/lib/brand-icon.ts new file mode 100644 index 00000000..dcad58eb --- /dev/null +++ b/ui-desktop/src/lib/brand-icon.ts @@ -0,0 +1,429 @@ +import { + SiAnthropic, + SiArchlinux, + SiArxiv, + SiAsana, + SiAtlassian, + SiBehance, + SiBitbucket, + SiBlender, + SiBluesky, + SiBun, + SiBuymeacoffee, + SiCircleci, + SiClaude, + SiCloudflare, + SiCodeberg, + SiCodecov, + SiCodesandbox, + SiConfluence, + SiCoursera, + SiCrunchbase, + SiCursor, + SiDatadog, + SiDebian, + SiDeno, + SiDevdotto, + SiDigitalocean, + SiDiscord, + SiDjango, + SiDocker, + SiDropbox, + SiElectron, + SiEslint, + SiExcalidraw, + SiFacebook, + SiFastapi, + SiFigma, + SiFirebase, + SiFlask, + SiForgejo, + SiGhost, + SiGitea, + SiGithub, + SiGitlab, + SiGmail, + SiGo, + SiGodotengine, + SiGoodreads, + SiGoogle, + SiGoogledocs, + SiGoogledrive, + SiGooglegemini, + SiGooglemaps, + SiGooglescholar, + SiGrafana, + SiGraphql, + SiHashnode, + SiHomebrew, + SiHuggingface, + SiImdb, + SiInstagram, + SiInternetarchive, + SiItchdotio, + SiJenkins, + SiJira, + SiJupyter, + SiKaggle, + SiKofi, + SiKotlin, + SiKubernetes, + SiLaravel, + SiLeetcode, + SiLinear, + SiMastodon, + SiMdnwebdocs, + SiMedium, + SiMiro, + SiMistralai, + SiMongodb, + SiNetflix, + SiNetlify, + SiNodedotjs, + SiNotion, + SiNpm, + SiNvidia, + SiObsidian, + SiOllama, + SiOpenrouter, + SiOpenstreetmap, + SiOverleaf, + SiPatreon, + SiPaypal, + SiPerplexity, + SiPhp, + SiPinterest, + SiPnpm, + SiPostgresql, + SiPostman, + SiPrisma, + SiProducthunt, + SiPypi, + SiPython, + SiPytorch, + SiQuora, + SiRailway, + SiRaspberrypi, + SiRaycast, + SiReact, + SiReadthedocs, + SiReddit, + SiRedis, + SiRender, + SiReplicate, + SiReplit, + SiResearchgate, + SiRuby, + SiRubyonrails, + SiRust, + SiScikitlearn, + SiSemanticscholar, + SiSentry, + SiServerfault, + SiShopify, + SiSnyk, + SiSoundcloud, + SiSourcehut, + SiSpotify, + SiStackblitz, + SiStackexchange, + SiStackoverflow, + SiSteam, + SiStorybook, + SiStripe, + SiSubstack, + SiSupabase, + SiSuperuser, + SiSwift, + SiTailwindcss, + SiTauri, + SiTelegram, + SiTensorflow, + SiThreedotjs, + SiTiktok, + SiTldraw, + SiTrello, + SiTurborepo, + SiTwitch, + SiTypescript, + SiUbuntu, + SiUdemy, + SiUnity, + SiUnsplash, + SiVercel, + SiVimeo, + SiVite, + SiWarp, + SiWebflow, + SiWeightsandbiases, + SiWikipedia, + SiWindsurf, + SiWordpress, + SiX, + SiYcombinator, + SiYelp, + SiYoutube, + SiZedindustries, + SiZoom, + SiZotero +} from '@icons-pack/react-simple-icons' +import type { ComponentType, SVGProps } from 'react' + +// Simple Icons components accept a `title` prop on top of the usual SVG props. +// It matters: they always render a <title> element, defaulting to the brand +// name, so callers need to be able to blank it out (see `LinkBrandIcon`). +export type BrandIcon = ComponentType<SVGProps<SVGSVGElement> & { title?: string }> + +// simpleicons.org brand marks keyed by registrable domain. Lookup walks the +// host's suffixes (see `resolveBrandIcon`), so one `github.com` entry also +// covers `gist.github.com` and `api.github.com` — only list a subdomain when it +// belongs to a *different* brand than its parent. +// +// Some brands are absent on purpose: Simple Icons has removed Slack, LinkedIn, +// OpenAI, Amazon, and CodePen at their owners' request, so those hosts fall +// through to no glyph rather than a lookalike. +const BRAND_ICONS: Record<string, BrandIcon> = { + // Code hosting & package registries + 'github.com': SiGithub, + 'github.io': SiGithub, + 'githubusercontent.com': SiGithub, + 'gitlab.com': SiGitlab, + 'gitlab.io': SiGitlab, + 'bitbucket.org': SiBitbucket, + 'codeberg.org': SiCodeberg, + 'gitea.com': SiGitea, + 'gitea.io': SiGitea, + 'forgejo.org': SiForgejo, + 'sr.ht': SiSourcehut, + 'npmjs.com': SiNpm, + 'pypi.org': SiPypi, + 'crates.io': SiRust, + 'huggingface.co': SiHuggingface, + 'hf.co': SiHuggingface, + + // Q&A, docs & reference + 'stackoverflow.com': SiStackoverflow, + 'stackexchange.com': SiStackexchange, + 'serverfault.com': SiServerfault, + 'superuser.com': SiSuperuser, + 'developer.mozilla.org': SiMdnwebdocs, + 'readthedocs.io': SiReadthedocs, + 'readthedocs.org': SiReadthedocs, + 'wikipedia.org': SiWikipedia, + 'archive.org': SiInternetarchive, + + // Research + 'arxiv.org': SiArxiv, + 'semanticscholar.org': SiSemanticscholar, + 'scholar.google.com': SiGooglescholar, + 'researchgate.net': SiResearchgate, + 'zotero.org': SiZotero, + 'overleaf.com': SiOverleaf, + + // AI + 'anthropic.com': SiAnthropic, + 'claude.ai': SiClaude, + 'gemini.google.com': SiGooglegemini, + 'perplexity.ai': SiPerplexity, + 'openrouter.ai': SiOpenrouter, + 'mistral.ai': SiMistralai, + 'ollama.com': SiOllama, + 'replicate.com': SiReplicate, + 'wandb.ai': SiWeightsandbiases, + 'kaggle.com': SiKaggle, + + // Social + 'x.com': SiX, + 'twitter.com': SiX, + 't.co': SiX, + 'reddit.com': SiReddit, + 'redd.it': SiReddit, + 'news.ycombinator.com': SiYcombinator, + 'bsky.app': SiBluesky, + 'mastodon.social': SiMastodon, + 'joinmastodon.org': SiMastodon, + 'facebook.com': SiFacebook, + 'instagram.com': SiInstagram, + 'tiktok.com': SiTiktok, + 'pinterest.com': SiPinterest, + 'discord.com': SiDiscord, + 'discord.gg': SiDiscord, + 't.me': SiTelegram, + 'telegram.org': SiTelegram, + 'producthunt.com': SiProducthunt, + 'crunchbase.com': SiCrunchbase, + 'quora.com': SiQuora, + + // Writing & blogs + 'medium.com': SiMedium, + 'substack.com': SiSubstack, + 'dev.to': SiDevdotto, + 'hashnode.dev': SiHashnode, + 'hashnode.com': SiHashnode, + 'wordpress.com': SiWordpress, + 'wordpress.org': SiWordpress, + 'ghost.org': SiGhost, + + // Media + 'youtube.com': SiYoutube, + 'youtu.be': SiYoutube, + 'vimeo.com': SiVimeo, + 'twitch.tv': SiTwitch, + 'soundcloud.com': SiSoundcloud, + 'spotify.com': SiSpotify, + 'netflix.com': SiNetflix, + 'imdb.com': SiImdb, + 'goodreads.com': SiGoodreads, + 'unsplash.com': SiUnsplash, + 'behance.net': SiBehance, + 'store.steampowered.com': SiSteam, + 'itch.io': SiItchdotio, + + // Product & project tools + 'linear.app': SiLinear, + 'notion.so': SiNotion, + 'notion.site': SiNotion, + 'figma.com': SiFigma, + 'atlassian.net': SiAtlassian, + 'atlassian.com': SiJira, + 'confluence.com': SiConfluence, + 'asana.com': SiAsana, + 'trello.com': SiTrello, + 'obsidian.md': SiObsidian, + 'miro.com': SiMiro, + 'excalidraw.com': SiExcalidraw, + 'tldraw.com': SiTldraw, + 'zoom.us': SiZoom, + + // Google + 'google.com': SiGoogle, + 'goo.gl': SiGoogle, + 'docs.google.com': SiGoogledocs, + 'drive.google.com': SiGoogledrive, + 'mail.google.com': SiGmail, + 'maps.google.com': SiGooglemaps, + 'openstreetmap.org': SiOpenstreetmap, + + // Hosting & infra + 'vercel.com': SiVercel, + 'vercel.app': SiVercel, + 'netlify.com': SiNetlify, + 'netlify.app': SiNetlify, + 'cloudflare.com': SiCloudflare, + 'pages.dev': SiCloudflare, + 'workers.dev': SiCloudflare, + 'railway.app': SiRailway, + 'render.com': SiRender, + 'digitalocean.com': SiDigitalocean, + 'firebase.google.com': SiFirebase, + 'supabase.com': SiSupabase, + 'docker.com': SiDocker, + 'kubernetes.io': SiKubernetes, + 'grafana.com': SiGrafana, + 'datadoghq.com': SiDatadog, + 'sentry.io': SiSentry, + 'snyk.io': SiSnyk, + 'codecov.io': SiCodecov, + 'circleci.com': SiCircleci, + 'jenkins.io': SiJenkins, + + // Languages, frameworks & tooling + 'python.org': SiPython, + 'nodejs.org': SiNodedotjs, + 'react.dev': SiReact, + 'typescriptlang.org': SiTypescript, + 'tailwindcss.com': SiTailwindcss, + 'vite.dev': SiVite, + 'vitejs.dev': SiVite, + 'rust-lang.org': SiRust, + 'go.dev': SiGo, + 'golang.org': SiGo, + 'ruby-lang.org': SiRuby, + 'rubyonrails.org': SiRubyonrails, + 'php.net': SiPhp, + 'laravel.com': SiLaravel, + 'djangoproject.com': SiDjango, + 'flask.palletsprojects.com': SiFlask, + 'fastapi.tiangolo.com': SiFastapi, + 'swift.org': SiSwift, + 'kotlinlang.org': SiKotlin, + 'deno.com': SiDeno, + 'bun.sh': SiBun, + 'pnpm.io': SiPnpm, + 'turborepo.com': SiTurborepo, + 'eslint.org': SiEslint, + 'storybook.js.org': SiStorybook, + 'graphql.org': SiGraphql, + 'prisma.io': SiPrisma, + 'postgresql.org': SiPostgresql, + 'redis.io': SiRedis, + 'mongodb.com': SiMongodb, + 'pytorch.org': SiPytorch, + 'tensorflow.org': SiTensorflow, + 'scikit-learn.org': SiScikitlearn, + 'jupyter.org': SiJupyter, + 'threejs.org': SiThreedotjs, + 'blender.org': SiBlender, + 'godotengine.org': SiGodotengine, + 'unity.com': SiUnity, + 'electronjs.org': SiElectron, + 'tauri.app': SiTauri, + 'brew.sh': SiHomebrew, + 'archlinux.org': SiArchlinux, + 'debian.org': SiDebian, + 'ubuntu.com': SiUbuntu, + 'raspberrypi.com': SiRaspberrypi, + 'nvidia.com': SiNvidia, + + // Dev environments & editors + 'codesandbox.io': SiCodesandbox, + 'stackblitz.com': SiStackblitz, + 'replit.com': SiReplit, + 'cursor.com': SiCursor, + 'windsurf.com': SiWindsurf, + 'zed.dev': SiZedindustries, + 'warp.dev': SiWarp, + 'raycast.com': SiRaycast, + 'postman.com': SiPostman, + + // Commerce, learning & the rest + 'stripe.com': SiStripe, + 'paypal.com': SiPaypal, + 'patreon.com': SiPatreon, + 'ko-fi.com': SiKofi, + 'buymeacoffee.com': SiBuymeacoffee, + 'shopify.com': SiShopify, + 'webflow.com': SiWebflow, + 'dropbox.com': SiDropbox, + 'leetcode.com': SiLeetcode, + 'coursera.org': SiCoursera, + 'udemy.com': SiUdemy, + 'yelp.com': SiYelp +} + +// Resolve a hostname to its brand glyph, walking suffixes so subdomains inherit +// their parent's mark (`api.github.com` → `github.com`). Longest match wins, so +// a subdomain entry like `docs.google.com` beats the `google.com` fallback. The +// bare TLD is never tested. +export function resolveBrandIcon(hostname: string): BrandIcon | null { + const host = hostname + .trim() + .toLowerCase() + .replace(/^www\./, '') + + if (!host.includes('.')) { + return null + } + + const parts = host.split('.') + + for (let i = 0; i < parts.length - 1; i += 1) { + const icon = BRAND_ICONS[parts.slice(i).join('.')] + + if (icon) { + return icon + } + } + + return null +} diff --git a/ui-desktop/src/lib/chat-messages.test.ts b/ui-desktop/src/lib/chat-messages.test.ts new file mode 100644 index 00000000..d83e852d --- /dev/null +++ b/ui-desktop/src/lib/chat-messages.test.ts @@ -0,0 +1,1136 @@ +import { describe, expect, it } from 'vitest' + +import type { SessionMessage } from '@/types/clawcodex' + +import type { ChatMessage, ChatMessagePart } from './chat-messages' +import { + appendAssistantTextPart, + appendReasoningPart, + chatMessageText, + collectUnspokenTurnSpeech, + mergeFinalAssistantText, + preserveLocalAssistantErrors, + reasoningPart, + renderMediaTags, + toChatMessages, + upsertToolPart +} from './chat-messages' + +describe('toChatMessages', () => { + it('keeps a turn with interleaved tool-only rows in a single bubble', () => { + const messages = toChatMessages([ + { role: 'assistant', content: 'Planning.', timestamp: 1 }, + { + role: 'assistant', + content: '', + timestamp: 2, + tool_calls: [{ id: 'tc', function: { name: 'terminal', arguments: '{}' } }] + }, + { role: 'assistant', content: 'Done.', timestamp: 3 } + ]) + + expect(messages).toHaveLength(1) + expect(messages[0].parts.map(p => p.type)).toEqual(['text', 'tool-call', 'text']) + expect(chatMessageText(messages[0])).toBe('Planning.Done.') + }) + + it('keeps assistant tool-call iterations in one loaded assistant bubble', () => { + const messages = toChatMessages([ + { role: 'user', content: 'check this repo', timestamp: 1 }, + { + role: 'assistant', + content: "Let me also check if there's a top-level lint workflow.", + timestamp: 2, + tool_calls: [{ id: 'tc-1', function: { name: 'search_files', arguments: '{"path":".github"}' } }] + }, + { + role: 'tool', + tool_call_id: 'tc-1', + tool_name: 'search_files', + content: '{"error":"Path not found: /repo/.github"}', + timestamp: 3 + }, + { + role: 'assistant', + content: 'No CI in this repo. Build is enough.', + timestamp: 4, + tool_calls: [{ id: 'tc-2', function: { name: 'terminal', arguments: '{"command":"git status --short"}' } }] + }, + { + role: 'tool', + tool_call_id: 'tc-2', + tool_name: 'terminal', + content: '{"output":"M src/ui/components/image-distortion.tsx\\n","exit_code":0}', + timestamp: 5 + }, + { role: 'assistant', content: 'Now let me check git status and commit.', timestamp: 6 } + ]) + + const assistantMessages = messages.filter(message => message.role === 'assistant') + + expect(assistantMessages).toHaveLength(1) + expect(assistantMessages[0].parts.filter(part => part.type === 'tool-call')).toHaveLength(2) + expect(chatMessageText(assistantMessages[0])).toContain("Let me also check if there's a top-level lint workflow.") + expect(chatMessageText(assistantMessages[0])).toContain('Now let me check git status and commit.') + }) + + it('hides attached context payloads from user message display', () => { + const [message] = toChatMessages([ + { + role: 'user', + content: + 'what is this file\n\n--- Attached Context ---\n\n📄 @file:tsconfig.tsbuildinfo (981 tokens)\n```json\n{"root":["./src/main.tsx"]}\n```', + timestamp: 1 + } + ]) + + expect(chatMessageText(message)).toBe('@file:tsconfig.tsbuildinfo\n\nwhat is this file') + }) + + it('renders MEDIA tags as assistant attachment links', () => { + const [message] = toChatMessages([ + { + role: 'assistant', + content: "MEDIA:/Users/brooklyn/.clawcodex/cache/audio/tts_20260501_222725.mp3\n\nhow's that sound?", + timestamp: 1 + } + ]) + + expect(chatMessageText(message)).toBe( + "[Audio: tts_20260501_222725.mp3](#media:%2FUsers%2Fbrooklyn%2F.clawcodex%2Fcache%2Faudio%2Ftts_20260501_222725.mp3)\n\nhow's that sound?" + ) + }) + + it('keeps the generated image on the tool row while preserving agent prose', () => { + const [message] = toChatMessages([ + { + content: '', + role: 'assistant', + timestamp: 1, + tool_calls: [{ id: 'img-1', function: { name: 'image_generate', arguments: '{"prompt":"draw a cat"}' } }] + }, + { + content: '{"success":true,"image":"https://cdn.example/cat.png"}', + role: 'tool', + timestamp: 2, + tool_call_id: 'img-1', + tool_name: 'image_generate' + }, + { + content: 'Here you go.\n\n![Generated image](https://cdn.example/cat.png)', + role: 'assistant', + timestamp: 3 + } + ]) + + const toolPart = message.parts.find( + (part): part is Extract<ChatMessagePart, { type: 'tool-call' }> => + part.type === 'tool-call' && part.toolName === 'image_generate' + ) + + expect(toolPart?.result).toMatchObject({ image: 'https://cdn.example/cat.png', success: true }) + // The duplicated image is stripped, but the agent's words survive. + expect(chatMessageText(message)).toBe('Here you go.') + }) + + it('lifts @image directive lines into attachmentRefs instead of inline text', () => { + const [message] = toChatMessages([ + { + role: 'user', + content: '@image:/tmp/cat.png\nwhat is in this photo?', + timestamp: 1 + } + ]) + + expect(chatMessageText(message)).toBe('what is in this photo?') + expect((message as { attachmentRefs?: string[] }).attachmentRefs).toEqual(['@image:/tmp/cat.png']) + }) + + it('keeps a user turn that carried only an attached image (no caption)', () => { + const [message] = toChatMessages([ + { + role: 'user', + content: '@image:/tmp/cat.png', + timestamp: 1 + } + ]) + + // The bubble has no visible text, but must survive the empty-turn filter + // because it carries attachment refs — otherwise a stand-alone attachment + // vanishes from the transcript after a session switch / restart. + expect(chatMessageText(message)).toBe('') + expect((message as { attachmentRefs?: string[] }).attachmentRefs).toEqual(['@image:/tmp/cat.png']) + }) + + it('renders a native-vision turn as caption plus thumbnail, not raw placeholder text', () => { + // How a turn sent to a natively-vision-capable model comes back out of the + // session store: a backtick-quoted ref (the path has spaces) and the + // `[screenshot]` stand-in left by flattening the parts list. + const ref = '@image:`/Users/me/Library/Application Support/ClawCodex/composer-images/a.png`' + + const [message] = toChatMessages([ + { + role: 'user', + content: `${ref}\nwhat is in this photo?\n[screenshot]`, + timestamp: 1 + } + ]) + + expect(chatMessageText(message)).toBe('what is in this photo?') + expect((message as { attachmentRefs?: string[] }).attachmentRefs).toEqual([ref]) + }) + + it('leaves a plain user prompt without attachment refs untouched', () => { + const [message] = toChatMessages([ + { + role: 'user', + content: 'just a question', + timestamp: 1 + } + ]) + + expect((message as { attachmentRefs?: string[] }).attachmentRefs).toBeUndefined() + }) + + it('coerces non-string message content without throwing', () => { + const [message] = toChatMessages([ + { + content: { + text: 'hello from object content' + }, + role: 'assistant', + timestamp: 1 + } + ]) + + expect(chatMessageText(message)).toBe('hello from object content') + }) + + it('applies attached-context filtering when user content is object-shaped', () => { + const [message] = toChatMessages([ + { + content: { + text: 'look\n\n--- Attached Context ---\n\n📄 @file:foo.ts (10 tokens)\n```ts\nconst x = 1\n```' + }, + role: 'user', + timestamp: 1 + } + ]) + + expect(chatMessageText(message)).toBe('@file:foo.ts\n\nlook') + }) + + it('leaves an inline @ ref in place instead of hoisting a duplicate', () => { + const [message] = toChatMessages([ + { + role: 'user', + content: + 'summarize @file:`src/main.ts` for me\n\n--- Attached Context ---\n\n📄 @file:`src/main.ts` (10 tokens)\n```ts\nconst x = 1\n```', + timestamp: 1 + } + ]) + + expect(chatMessageText(message)).toBe('summarize @file:`src/main.ts` for me') + }) + + it('never paints redirect scaffolding as an assistant bubble', () => { + // What the desktop actually receives after a mid-stream steer: the runtime + // keeps the interrupt scaffolding in a server-only api_content sidecar + // (never shipped to the client) so content is already clean, and marks a + // prose-free checkpoint display_kind:'hidden'. The transcript must show the + // partial reply and the user's correction — never + // "[This response was interrupted by a user correction.]". + const messages = toChatMessages([ + { role: 'user', content: 'go', timestamp: 1 }, + { + role: 'assistant', + content: 'Hey. I was mid-Figma MCP fix when we paused.', + timestamp: 2 + }, + { role: 'user', content: 'i love you', timestamp: 3 }, + { + // Nothing had reached the screen — checkpoint exists only for the model. + role: 'assistant', + content: '[This response was interrupted by a user correction.]', + display_kind: 'hidden', + timestamp: 4 + }, + { role: 'user', content: 'keep going', timestamp: 5 } + ]) + + expect(messages.map(chatMessageText)).toEqual([ + 'go', + 'Hey. I was mid-Figma MCP fix when we paused.', + 'i love you', + 'keep going' + ]) + + for (const message of messages) { + expect(chatMessageText(message)).not.toContain('This response was interrupted') + expect(chatMessageText(message)).not.toContain('Visible response before the interruption') + expect(chatMessageText(message)).not.toContain('Context from the interrupted assistant response') + } + }) + + it('projects durable timeline kinds without inspecting their text', () => { + const messages = toChatMessages([ + { role: 'user', content: 'real user turn', timestamp: 1 }, + { role: 'assistant', content: 'real assistant reply', timestamp: 2 }, + { + role: 'user', + content: 'opaque compaction payload', + display_kind: 'hidden', + timestamp: 3 + }, + { + role: 'user', + content: 'opaque model context payload', + display_kind: 'model_switch', + timestamp: 4 + }, + { + role: 'user', + content: 'opaque delegation context payload', + display_kind: 'async_delegation_complete', + timestamp: 5 + }, + { + role: 'user', + content: '[System note: Your previous turn was interrupted mid-run…]\n\noriginal prompt', + display_kind: 'auto_continue', + timestamp: 6 + } + ]) + + expect(messages.map(message => message.role)).toEqual(['user', 'assistant', 'system', 'system', 'system']) + expect(messages.map(chatMessageText)).toEqual([ + 'real user turn', + 'real assistant reply', + 'model changed', + 'background agent work finished', + 'resumed interrupted turn' + ]) + }) + + // A backend older than this app serves display_metadata as unparsed JSON + // text. Indexing into that string used to throw and fail the whole resume. + it.each([ + ['an object', { delegation_id: 'deleg_1', task_count: 2 }, '2 background agents finished'], + ['JSON text', JSON.stringify({ delegation_id: 'deleg_1', task_count: 1 }), '1 background agent finished'], + ['unparseable text', '{not-json', 'background agent work finished'], + ['text that is not an object', '"deleg_1"', 'background agent work finished'], + ['a missing task count', { delegation_id: 'deleg_1' }, 'background agent work finished'] + ])('labels a delegation event given %s', (_case, displayMetadata, expected) => { + const read = () => + toChatMessages([ + { + role: 'user', + content: 'opaque delegation context payload', + display_kind: 'async_delegation_complete', + display_metadata: displayMetadata as SessionMessage['display_metadata'], + timestamp: 1 + } + ]) + + expect(read).not.toThrow() + expect(chatMessageText(read()[0])).toBe(expected) + }) +}) + +describe('renderMediaTags', () => { + it('renders standalone and inline MEDIA tags as links', () => { + expect(renderMediaTags('here\nMEDIA:/tmp/voice.mp3\nthere')).toBe( + 'here\n[Audio: voice.mp3](#media:%2Ftmp%2Fvoice.mp3)\nthere' + ) + expect(renderMediaTags('audio: MEDIA:/tmp/voice.mp3 done')).toBe( + 'audio: [Audio: voice.mp3](#media:%2Ftmp%2Fvoice.mp3) done' + ) + expect(renderMediaTags('MEDIA:/tmp/demo.mp4')).toBe('[Video: demo.mp4](#media:%2Ftmp%2Fdemo.mp4)') + }) + + it('renders streamed assistant media once the tag is complete', () => { + const parts = appendAssistantTextPart(appendAssistantTextPart([], 'ok\nMEDIA:'), '/tmp/voice.mp3') + const text = chatMessageText({ id: 'a', role: 'assistant', parts }) + + expect(text).toBe('ok\n[Audio: voice.mp3](#media:%2Ftmp%2Fvoice.mp3)') + }) +}) + +describe('interleaved reasoning/text coalescing', () => { + it('keeps narration contiguous when reasoning interrupts mid-sentence', () => { + // Models that interleave reasoning_content + content deltas emit + // text → reasoning → text within one tool-bounded segment. The two text + // fragments are really one sentence and must not be split by the + // "Thinking" block between them. + let parts: ChatMessagePart[] = appendAssistantTextPart([], 'Let me ') + parts = appendReasoningPart(parts, 'checking the file...') + parts = appendAssistantTextPart(parts, 'verify the full file is correct:') + + expect(parts.map(p => p.type)).toEqual(['text', 'reasoning']) + expect((parts[0] as { text: string }).text).toBe('Let me verify the full file is correct:') + expect((parts[1] as { text: string }).text).toBe('checking the file...') + }) + + it('merges reasoning bursts that straddle a narration fragment', () => { + let parts: ChatMessagePart[] = appendReasoningPart([], 'first thought ') + parts = appendAssistantTextPart(parts, 'Working on it.') + parts = appendReasoningPart(parts, 'second thought') + + expect(parts.map(p => p.type)).toEqual(['reasoning', 'text']) + expect((parts[0] as { text: string }).text).toBe('first thought second thought') + expect((parts[1] as { text: string }).text).toBe('Working on it.') + }) + + it('starts a fresh text part after a tool call (segment boundary)', () => { + let parts: ChatMessagePart[] = appendAssistantTextPart([], 'Let me check.') + parts = upsertToolPart(parts, { name: 'read_file', tool_id: 'tc-1' }, 'running') + parts = appendAssistantTextPart(parts, 'Now editing.') + + expect(parts.map(p => p.type)).toEqual(['text', 'tool-call', 'text']) + expect((parts[0] as { text: string }).text).toBe('Let me check.') + expect((parts[2] as { text: string }).text).toBe('Now editing.') + }) + + it('does not merge reasoning across a tool call', () => { + let parts: ChatMessagePart[] = appendReasoningPart([], 'before tool') + parts = upsertToolPart(parts, { name: 'read_file', tool_id: 'tc-1' }, 'running') + parts = appendReasoningPart(parts, 'after tool') + + expect(parts.map(p => p.type)).toEqual(['reasoning', 'tool-call', 'reasoning']) + expect((parts[0] as { text: string }).text).toBe('before tool') + expect((parts[2] as { text: string }).text).toBe('after tool') + }) +}) + +describe('preserveLocalAssistantErrors', () => { + it('preserves a local user+error pair when hydration omits the failed turn', () => { + const nextMessages: ChatMessage[] = [ + { + id: 'stored-user', + parts: [{ text: 'earlier', type: 'text' }], + role: 'user' + } + ] + + const currentMessages: ChatMessage[] = [ + { + id: 'stored-user', + parts: [{ text: 'earlier', type: 'text' }], + role: 'user' + }, + { + id: 'user-123', + parts: [{ text: 'new prompt', type: 'text' }], + role: 'user' + }, + { + error: 'OpenRouter 403', + id: 'assistant-error-1', + parts: [], + role: 'assistant' + } + ] + + const merged = preserveLocalAssistantErrors(nextMessages, currentMessages) + + expect(merged.map(message => message.id)).toEqual(['stored-user', 'user-123', 'assistant-error-1']) + expect(merged[2]?.error).toBe('OpenRouter 403') + }) + + it('does not keep orphan local user turns when there is no inline assistant error', () => { + const nextMessages: ChatMessage[] = [ + { + id: 'stored-user', + parts: [{ text: 'earlier', type: 'text' }], + role: 'user' + } + ] + + const currentMessages: ChatMessage[] = [ + ...nextMessages, + { + id: 'user-123', + parts: [{ text: 'new prompt', type: 'text' }], + role: 'user' + } + ] + + const merged = preserveLocalAssistantErrors(nextMessages, currentMessages) + + expect(merged.map(message => message.id)).toEqual(['stored-user']) + }) + + it('does not duplicate local user when stored history already has equivalent text', () => { + const nextMessages: ChatMessage[] = [ + { + id: 'stored-user', + parts: [{ text: 'hi', type: 'text' }], + role: 'user' + } + ] + + const currentMessages: ChatMessage[] = [ + { + id: 'optimistic-user', + parts: [{ text: 'hi', type: 'text' }], + role: 'user' + }, + { + error: 'OpenRouter 403', + id: 'assistant-error-1', + parts: [], + role: 'assistant' + } + ] + + const merged = preserveLocalAssistantErrors(nextMessages, currentMessages) + + expect(merged.map(message => message.id)).toEqual(['stored-user', 'assistant-error-1']) + }) + + it('keeps local user when only older history has equivalent text', () => { + const nextMessages: ChatMessage[] = [ + { + id: 'older-user', + parts: [{ text: 'hi', type: 'text' }], + role: 'user' + }, + { + id: 'older-assistant', + parts: [{ text: 'hello', type: 'text' }], + role: 'assistant' + }, + { + id: 'tail-user', + parts: [{ text: 'different prompt', type: 'text' }], + role: 'user' + } + ] + + const currentMessages: ChatMessage[] = [ + { + id: 'optimistic-user', + parts: [{ text: 'hi', type: 'text' }], + role: 'user' + }, + { + error: 'OpenRouter 403', + id: 'assistant-error-1', + parts: [], + role: 'assistant' + } + ] + + const merged = preserveLocalAssistantErrors(nextMessages, currentMessages) + + expect(merged.map(message => message.id)).toEqual([ + 'older-user', + 'older-assistant', + 'tail-user', + 'optimistic-user', + 'assistant-error-1' + ]) + }) + + it('keeps local assistant error when hydrated message reuses same id', () => { + const nextMessages: ChatMessage[] = [ + { + id: 'user-1', + parts: [{ text: 'new prompt', type: 'text' }], + role: 'user' + }, + { + id: 'assistant-stream-1', + parts: [{ text: '', type: 'text' }], + role: 'assistant' + } + ] + + const currentMessages: ChatMessage[] = [ + { + id: 'user-1', + parts: [{ text: 'new prompt', type: 'text' }], + role: 'user' + }, + { + error: 'OpenRouter 403', + id: 'assistant-stream-1', + parts: [], + role: 'assistant' + } + ] + + const merged = preserveLocalAssistantErrors(nextMessages, currentMessages) + + const assistant = merged.find(message => message.id === 'assistant-stream-1') + + expect(assistant?.error).toBe('OpenRouter 403') + expect(assistant?.pending).toBe(false) + }) +}) + +describe('upsertToolPart', () => { + it('preserves inline diffs from tool completion events', () => { + const parts = upsertToolPart( + [], + { + inline_diff: '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new', + name: 'patch', + tool_id: 'tool-1' + }, + 'complete' + ) + + const [part] = parts + + expect(part?.type).toBe('tool-call') + expect(part && 'result' in part ? part.result : undefined).toMatchObject({ + inline_diff: '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new' + }) + }) + + it('keeps live todo rows stable across sparse progress payloads', () => { + const first = upsertToolPart( + [], + { + name: 'todo', + todos: [{ content: 'Boil water', id: 'boil', status: 'in_progress' }], + tool_id: 'todo-1' + }, + 'running' + ) + + const progressed = upsertToolPart( + first, + { + name: 'todo', + preview: 'updating plan', + tool_id: 'todo-1' + }, + 'running' + ) + + const [part] = progressed + const args = part && 'args' in part ? (part.args as Record<string, unknown>) : {} + + expect(args.todos).toEqual([{ content: 'Boil water', id: 'boil', status: 'in_progress' }]) + }) + + it('archives todo state on completion and accepts explicit empty clears', () => { + const started = upsertToolPart( + [], + { + name: 'todo', + todos: [{ content: 'Boil water', id: 'boil', status: 'in_progress' }], + tool_id: 'todo-1' + }, + 'running' + ) + + const completed = upsertToolPart( + started, + { + name: 'todo', + tool_id: 'todo-1' + }, + 'complete' + ) + + const cleared = upsertToolPart( + completed, + { + name: 'todo', + todos: [], + tool_id: 'todo-1' + }, + 'complete' + ) + + const completedResult = + completed[0] && 'result' in completed[0] ? (completed[0].result as Record<string, unknown>) : {} + + const clearedResult = cleared[0] && 'result' in cleared[0] ? (cleared[0].result as Record<string, unknown>) : {} + + expect(completedResult.todos).toEqual([{ content: 'Boil water', id: 'boil', status: 'in_progress' }]) + expect(clearedResult.todos).toEqual([]) + }) + + it('keeps parallel same-name tools distinct without explicit ids', () => { + const startedTokyo = upsertToolPart( + [], + { + context: 'tokyo weather', + name: 'web_search' + }, + 'running' + ) + + const startedReykjavik = upsertToolPart( + startedTokyo, + { + context: 'reykjavik weather', + name: 'web_search' + }, + 'running' + ) + + const completedTokyo = upsertToolPart( + startedReykjavik, + { + context: 'tokyo weather', + message: 'tokyo done', + name: 'web_search', + summary: 'Did 5 searches' + }, + 'complete' + ) + + const completedBoth = upsertToolPart( + completedTokyo, + { + context: 'reykjavik weather', + message: 'reykjavik done', + name: 'web_search', + summary: 'Did 5 searches' + }, + 'complete' + ) + + const webParts = completedBoth.filter( + (part): part is Extract<ChatMessagePart, { type: 'tool-call' }> => + part.type === 'tool-call' && part.toolName === 'web_search' + ) + + const contexts = webParts.map(part => String((part.args as Record<string, unknown>)?.context || '')) + + const summaries = webParts.map(part => { + if (!('result' in part) || !part.result || typeof part.result !== 'object') { + return '' + } + + return String((part.result as Record<string, unknown>).summary || '') + }) + + expect(webParts).toHaveLength(2) + expect(contexts).toEqual(['tokyo weather', 'reykjavik weather']) + expect(summaries).toEqual(['Did 5 searches', 'Did 5 searches']) + }) + + it('pairs a terminal completion with its context-only start when event IDs differ', () => { + const started = upsertToolPart( + [], + { context: 'echo "Hello from the terminal"', name: 'terminal', tool_id: 'terminal-start' }, + 'running' + ) + + const completed = upsertToolPart( + started, + { + args: { command: 'echo "Hello from the terminal"' }, + name: 'terminal', + result: { exit_code: 0, stdout: 'Hello from the terminal' }, + tool_id: 'terminal-complete' + }, + 'complete' + ) + + const terminalParts = completed.filter( + (part): part is Extract<ChatMessagePart, { type: 'tool-call' }> => + part.type === 'tool-call' && part.toolName === 'terminal' + ) + + expect(terminalParts).toHaveLength(1) + expect(terminalParts[0]?.toolCallId).toBe('terminal-complete') + expect(terminalParts[0] && 'result' in terminalParts[0] ? terminalParts[0].result : undefined).toMatchObject({ + exit_code: 0, + stdout: 'Hello from the terminal' + }) + }) + + it('preserves query args when completion payload omits context', () => { + const started = upsertToolPart( + [], + { + context: 'auckland weather today and tomorrow forecast', + name: 'web_search', + tool_id: 'search-1' + }, + 'running' + ) + + const completed = upsertToolPart( + started, + { + duration_s: 1.1, + name: 'web_search', + summary: 'Did 5 searches in 1.1s', + tool_id: 'search-1' + }, + 'complete' + ) + + const [part] = completed + + expect(part?.type).toBe('tool-call') + expect((part as Extract<ChatMessagePart, { type: 'tool-call' }>).args).toMatchObject({ + context: 'auckland weather today and tomorrow forecast' + }) + expect((part as Extract<ChatMessagePart, { type: 'tool-call' }>).result).toMatchObject({ + summary: 'Did 5 searches in 1.1s' + }) + }) + + it('does not append phantom same-name tool rows for id-less progress updates', () => { + const startedA = upsertToolPart( + [], + { + context: 'reykjavik weather today and tomorrow forecast', + name: 'web_search' + }, + 'running' + ) + + const startedB = upsertToolPart( + startedA, + { + context: 'kathmandu weather today and tomorrow forecast', + name: 'web_search' + }, + 'running' + ) + + const progressed = upsertToolPart( + startedB, + { + name: 'web_search' + }, + 'running' + ) + + const webParts = progressed.filter( + (part): part is Extract<ChatMessagePart, { type: 'tool-call' }> => + part.type === 'tool-call' && part.toolName === 'web_search' + ) + + expect(webParts).toHaveLength(2) + }) + + it('matches id-less live starts with later identified completions', () => { + const started = upsertToolPart( + [], + { + context: 'asuncion paraguay weather today and tomorrow forecast', + name: 'web_search' + }, + 'running' + ) + + const completed = upsertToolPart( + started, + { + context: 'asuncion paraguay weather today and tomorrow forecast', + duration_s: 1.1, + name: 'web_search', + summary: 'Did 5 searches in 1.1s', + tool_id: 'search-asuncion' + }, + 'complete' + ) + + const webParts = completed.filter( + (part): part is Extract<ChatMessagePart, { type: 'tool-call' }> => + part.type === 'tool-call' && part.toolName === 'web_search' + ) + + expect(webParts).toHaveLength(1) + expect(webParts[0].toolCallId).toBe('search-asuncion') + expect(webParts[0].result).toMatchObject({ summary: 'Did 5 searches in 1.1s' }) + }) + + it('matches id-less live starts with later identified progress updates', () => { + const started = upsertToolPart( + [], + { + context: 'reykjavik tashkent uzbekistan weather today and tomorrow forecast', + name: 'web_search' + }, + 'running' + ) + + const progressed = upsertToolPart( + started, + { + context: 'reykjavik tashkent uzbekistan weather today and tomorrow forecast', + name: 'web_search', + tool_id: 'search-reykjavik' + }, + 'running' + ) + + const webParts = progressed.filter( + (part): part is Extract<ChatMessagePart, { type: 'tool-call' }> => + part.type === 'tool-call' && part.toolName === 'web_search' + ) + + expect(webParts).toHaveLength(1) + expect(webParts[0].toolCallId).toBe('search-reykjavik') + }) + + it('reconciles preview-first progress rows with later stable-id starts', () => { + const progressA = upsertToolPart( + [], + { + name: 'web_search', + preview: 'tokyo weather' + }, + 'running' + ) + + const progressB = upsertToolPart( + progressA, + { + name: 'web_search', + preview: 'reykjavik weather' + }, + 'running' + ) + + const startedA = upsertToolPart( + progressB, + { + args: { query: 'tokyo weather' }, + name: 'web_search', + tool_id: 'search-tokyo' + }, + 'running' + ) + + const startedB = upsertToolPart( + startedA, + { + args: { query: 'reykjavik weather' }, + name: 'web_search', + tool_id: 'search-reykjavik' + }, + 'running' + ) + + const completedA = upsertToolPart( + startedB, + { + name: 'web_search', + summary: 'Did 5 searches', + tool_id: 'search-tokyo' + }, + 'complete' + ) + + const completedB = upsertToolPart( + completedA, + { + name: 'web_search', + summary: 'Did 5 searches', + tool_id: 'search-reykjavik' + }, + 'complete' + ) + + const webParts = completedB + .filter( + (part): part is Extract<ChatMessagePart, { type: 'tool-call' }> => + part.type === 'tool-call' && part.toolName === 'web_search' + ) + .map(part => ({ + id: part.toolCallId, + query: String((part.args as Record<string, unknown>)?.query || ''), + summary: + part.result && typeof part.result === 'object' + ? String((part.result as Record<string, unknown>).summary || '') + : '' + })) + + expect(webParts).toEqual([ + { id: 'search-tokyo', query: 'tokyo weather', summary: 'Did 5 searches' }, + { id: 'search-reykjavik', query: 'reykjavik weather', summary: 'Did 5 searches' } + ]) + }) + + it('uses structured live tool args for titles before hydrate', () => { + const started = upsertToolPart( + [], + { + args: { search_term: 'reykjavik bishkek kyrgyzstan weather today and tomorrow forecast' }, + name: 'web_search', + tool_id: 'search-bishkek' + }, + 'running' + ) + + const [part] = started + + expect(part?.type).toBe('tool-call') + expect((part as Extract<ChatMessagePart, { type: 'tool-call' }>).args).toMatchObject({ + search_term: 'reykjavik bishkek kyrgyzstan weather today and tomorrow forecast' + }) + }) + + it('keeps structured live tool results before hydrate', () => { + const completed = upsertToolPart( + [], + { + args: { query: 'suva weather' }, + name: 'web_search', + result: { data: { web: [{ title: 'Suva forecast', url: 'https://example.test', description: 'Sunny' }] } }, + summary: 'Did 1 search in 0.5s', + tool_id: 'search-suva' + }, + 'complete' + ) + + const [part] = completed + + expect(part?.type).toBe('tool-call') + expect((part as Extract<ChatMessagePart, { type: 'tool-call' }>).result).toMatchObject({ + data: { web: [{ title: 'Suva forecast' }] }, + summary: 'Did 1 search in 0.5s' + }) + }) +}) + +describe('mergeFinalAssistantText', () => { + it('removes all text parts and appends the final text', () => { + const parts = [ + { type: 'text' as const, text: 'streamed delta 1' }, + { type: 'text' as const, text: 'streamed delta 2' }, + { type: 'tool-call' as const, toolCallId: 'tc1', toolName: 'terminal', args: {} as never, argsText: '{}' } + ] + + const result = mergeFinalAssistantText(parts, 'final answer') + + expect(result.filter(p => p.type === 'text')).toHaveLength(1) + expect(result.filter(p => p.type === 'text')[0]).toMatchObject({ text: 'final answer' }) + expect(result.some(p => p.type === 'tool-call')).toBe(true) + }) + + it('drops reasoning that the final text fully covers (reasoning ⊆ final)', () => { + const parts = [reasoningPart('Let me check the files.'), { type: 'text' as const, text: 'streamed' }] + + const result = mergeFinalAssistantText(parts, 'Let me check the files. Everything looks good.') + + expect(result.filter(p => p.type === 'reasoning')).toHaveLength(0) + expect(result.filter(p => p.type === 'text')).toHaveLength(1) + }) + + it('keeps a longer reasoning block when the final text is only a short prefix', () => { + // #61447: a short final ("Done.") must NOT swallow a longer reasoning block + // that merely starts with it. + const parts = [ + reasoningPart( + 'Done. The root cause was a bare catch block swallowing Stripe errors. The fix adds proper error logging.' + ), + { type: 'text' as const, text: 'streamed' } + ] + + const result = mergeFinalAssistantText(parts, 'Done.') + + expect(result.filter(p => p.type === 'reasoning')).toHaveLength(1) + expect(result.filter(p => p.type === 'text')[0]).toMatchObject({ text: 'Done.' }) + }) + + it('keeps non-restating reasoning', () => { + const parts = [ + reasoningPart('I analyzed the issue and found a race condition in the event loop.'), + { type: 'text' as const, text: 'streamed' } + ] + + const result = mergeFinalAssistantText(parts, 'Fixed the race condition.') + + expect(result.filter(p => p.type === 'reasoning')).toHaveLength(1) + expect(result.filter(p => p.type === 'text')).toHaveLength(1) + }) + + it('handles empty final text', () => { + const parts = [{ type: 'text' as const, text: 'streamed' }, reasoningPart('some reasoning')] + + const result = mergeFinalAssistantText(parts, '') + + expect(result.filter(p => p.type === 'text')).toHaveLength(0) + expect(result.filter(p => p.type === 'reasoning')).toHaveLength(1) + }) +}) + +describe('collectUnspokenTurnSpeech', () => { + const assistant = (id: string, text: string, extra: Partial<ChatMessage> = {}): ChatMessage => ({ + id, + role: 'assistant', + parts: text ? [{ type: 'text', text }] : [], + ...extra + }) + + const user = (id: string, text: string): ChatMessage => ({ + id, + role: 'user', + parts: [{ type: 'text', text }] + }) + + it('includes sealed interim narration AND the final answer of a tool-calling turn', () => { + const messages = [ + user('u1', 'what time is it?'), + assistant('a1', 'Let me check the clock.', { interim: true }), + assistant('a2', 'It is 9 PM.') + ] + + const speech = collectUnspokenTurnSpeech(messages, null) + + expect(speech).not.toBeNull() + expect(speech?.id).toBe('a1') + expect(speech?.text).toBe('Let me check the clock.\n\nIt is 9 PM.') + expect(speech?.pending).toBe(false) + }) + + it('keeps the binding id stable while later bubbles stream in', () => { + const turnStart = [user('u1', 'go'), assistant('a1', 'Let me check.', { interim: true })] + const first = collectUnspokenTurnSpeech(turnStart, null) + + const turnLater = [...turnStart, assistant('a2', 'Still work', { pending: true })] + const later = collectUnspokenTurnSpeech(turnLater, null) + + expect(first?.id).toBe('a1') + expect(later?.id).toBe('a1') + // The earlier snapshot's text is a prefix of the later one — the live + // session appends by length, so aggregation must be append-only. + expect(later?.text.startsWith(first?.text ?? '')).toBe(true) + expect(later?.pending).toBe(true) + }) + + it('starts after the last spoken message and skips hidden/empty bubbles', () => { + const messages = [ + assistant('a0', 'Spoken last turn.'), + user('u1', 'next'), + assistant('a1', '', { pending: false }), + assistant('a2', 'hidden note', { hidden: true }), + assistant('a3', 'The real reply.') + ] + + const speech = collectUnspokenTurnSpeech(messages, 'a0') + + expect(speech?.id).toBe('a3') + expect(speech?.text).toBe('The real reply.') + }) + + it('reports pending from the newest assistant bubble even when it has no text yet', () => { + const messages = [assistant('a1', 'Narration done.', { interim: true }), assistant('a2', '', { pending: true })] + + const speech = collectUnspokenTurnSpeech(messages, null) + + expect(speech?.id).toBe('a1') + expect(speech?.text).toBe('Narration done.') + expect(speech?.pending).toBe(true) + }) + + it('returns null when everything is spoken or there is no assistant text', () => { + expect(collectUnspokenTurnSpeech([], null)).toBeNull() + expect(collectUnspokenTurnSpeech([assistant('a1', 'Done.')], 'a1')).toBeNull() + expect(collectUnspokenTurnSpeech([user('u1', 'hello'), assistant('a1', '')], null)).toBeNull() + }) +}) diff --git a/ui-desktop/src/lib/chat-messages.ts b/ui-desktop/src/lib/chat-messages.ts new file mode 100644 index 00000000..7048cc0d --- /dev/null +++ b/ui-desktop/src/lib/chat-messages.ts @@ -0,0 +1,1174 @@ +import type { ThreadMessageLike } from '@assistant-ui/react' +import { type BillingBlock, skillInvocationText } from '@clawcodex/shared' + +import { extractImageRefs } from '@/lib/embedded-images' +import { dedupeGeneratedImageEchoesInParts } from '@/lib/generated-images' +import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media' +import { normalize } from '@/lib/text' +import { parseTodos } from '@/lib/todos' +import type { MessageReaction, SessionMessage, UsageStats } from '@/types/clawcodex' + +export type ChatMessagePart = Exclude<ThreadMessageLike['content'], string>[number] + +export type ChatMessage = { + id: string + role: SessionMessage['role'] + parts: ChatMessagePart[] + timestamp?: number + pending?: boolean + error?: string + branchGroupId?: string + hidden?: boolean + /** Sealed mid-turn commentary (`message.interim`) — rendered without the + * action footer so only the turn's final reply carries copy/refresh, and + * the live view matches rehydration (which merges the turn into one bubble). */ + interim?: boolean + /** Composer attachment ref strings (`@file:...`, `@image:...`) sent with this user message. */ + attachmentRefs?: string[] + /** Durable backend `messages.id`. Absent until the row is persisted. */ + rowId?: number + /** Emoji reactions on this message — one per author (see MessageReaction). */ + reactions?: MessageReaction[] +} + +export type GatewayEventPayload = { + text?: string + rendered?: string + status?: string + message?: string + id?: string + name?: string + tool_id?: string + tool_call_id?: string + args?: unknown + arguments?: unknown + context?: string + input?: unknown + preview?: string + result?: unknown + summary?: string + error?: string | boolean + inline_diff?: string + duration_s?: number + todos?: unknown + model?: string + provider?: string + reasoning_effort?: string + service_tier?: string + fast?: boolean + approval_mode?: string + yolo?: boolean + running?: boolean + cwd?: string + branch?: string + credential_warning?: string + install_warning?: string + personality?: string + usage?: Partial<UsageStats> + // agent.terminal.output — live chunk for a read-only agent terminal tab + process_id?: string + chunk?: string + // clarify.request + request_id?: string + question?: string + choices?: string[] | null + // approval.request (dangerous command / execute_code) — session-keyed + command?: string + description?: string + // False when a tirith content-security warning forbids a permanent allow. + allow_permanent?: boolean + smart_denied?: boolean + // secret.request (skill credential capture) + env_var?: string + prompt?: string + // terminal.read.request / preview.read.request (GUI agent reading the + // in-app terminal pane or the browser/preview pane) + start?: number + count?: number + // status.update (kind=process → background process completion/watch-match) + kind?: string + // pane.reveal (agent focusing a desktop pane via the focus_pane tool) + pane?: string + // message.reaction (agent reacting via the react_to_message tool) — the + // durable messages.id, that row's full reaction list after the write, and + // the row's role so a live (not-yet-round-tripped) message can be matched. + row_id?: number + reactions?: MessageReaction[] + role?: string + // session.title (live auto-title push) — stored session id + generated title + session_id?: string + title?: string + // session.info — the stored (durable) session id for this runtime session. + // Lets the desktop app map runtime→stored for background sessions it hasn't + // opened, so the sidebar working indicator updates without opening the chat. + stored_session_id?: string + // moa.reference / moa.aggregating (Mixture of Agents per-model relay) + label?: string + index?: number + aggregator?: string + // moa.progress / moa.phase (Mixture of Agents fan-out progress relay) + refs_done?: number + refs_total?: number + phase?: string + // message.complete — signals the final text was already previewed via + // interim_assistant_callback, so the UI can settle instead of duplicating. + response_previewed?: boolean + // message.complete with status "error" — `text` is streamed partial output + // (keep it visible), not the error string. + partial?: boolean + // message.complete with status "error" — the failed turn was retained + // backend-side and will replay through session.resume's inflight payload. + recoverable?: boolean + // Structured billing wall forwarded on message.complete when a turn fails + // with FailoverReason.billing (shape mirrors @clawcodex/shared BillingBlock). + billing?: BillingBlock + failure_reason?: string +} + +export function textPart(text: string): ChatMessagePart { + return { type: 'text', text } +} + +export function reasoningPart(text: string): ChatMessagePart { + return { type: 'reasoning', text } +} + +const MEDIA_LINE_RE = /(^|\n)[\t ]*[`"']?MEDIA:\s*(?<line>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?[\t ]*(\n|$)/g + +const MEDIA_TAG_RE = /[`"']?MEDIA:\s*(?<inline>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?/g + +function unquoteMediaPath(value: string): string { + const trimmed = value.trim() + const quote = trimmed[0] + + return quote && quote === trimmed.at(-1) && ['"', "'", '`'].includes(quote) ? trimmed.slice(1, -1) : trimmed +} + +function mediaLink(value: string): string { + const path = unquoteMediaPath(value) + + return `[${mediaDisplayLabel(path)}](${mediaMarkdownHref(path)})` +} + +export function renderMediaTags(text: string): string { + return text + .replace( + MEDIA_LINE_RE, + (_match, lead: string, value: string, trailer: string) => `${lead}${mediaLink(value)}${trailer}` + ) + .replace(MEDIA_TAG_RE, (_match, value: string) => mediaLink(value)) + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') +} + +export function assistantTextPart(text: string): ChatMessagePart { + return textPart(renderMediaTags(text)) +} + +export function chatMessageText(message: ChatMessage): string { + return message.parts + .filter((part): part is Extract<ChatMessagePart, { type: 'text' }> => part.type === 'text') + .map(part => part.text) + .join('') +} + +export interface UnspokenTurnSpeech { + /** First unspoken assistant bubble — stable for the turn, the live speech session binds to it. */ + id: string + /** Whether the newest assistant bubble is still streaming. */ + pending: boolean + /** All unspoken assistant text in message order, bubbles joined on a blank line. */ + text: string +} + +/** + * Collect every unspoken assistant bubble after `lastSpokenId`, in order. + * + * A turn with tool calls produces several assistant bubbles — narration + * ("Let me check…") sealed as interims, then the final answer as a fresh + * bubble. Voice conversation speaks a turn through ONE live session bound to + * one response id, so it needs all of that text as a single growing string; + * selecting only one bubble silently drops everything after it. The blank-line + * join is a sentence boundary for the server's cutter, so a sealed bubble's + * tail is flushed as soon as the next bubble starts. + */ +export function collectUnspokenTurnSpeech( + messages: ChatMessage[], + lastSpokenId: string | null +): UnspokenTurnSpeech | null { + const spokenIndex = lastSpokenId ? messages.findLastIndex(m => m.id === lastSpokenId) : -1 + + let id: string | null = null + let pending = false + const parts: string[] = [] + + for (const message of messages.slice(spokenIndex + 1)) { + if (message.role !== 'assistant' || message.hidden) { + continue + } + + pending = Boolean(message.pending) + const text = chatMessageText(message).trim() + + if (!text) { + continue + } + + id ??= message.id + parts.push(text) + } + + if (!id) { + return null + } + + return { id, pending, text: parts.join('\n\n') } +} + +const normalizeWs = (value: string) => value.replace(/\s+/g, ' ').trim() + +/** + * Merge the final assistant text into a message's parts. + * + * - Removes all existing `text` parts (they were streamed deltas, now superseded + * by the authoritative final response). + * - Keeps `reasoning` parts, but drops one that the final text fully covers + * (reasoning ⊆ final) — the final restates it. A short final ("Done.") must + * NOT swallow a longer reasoning block that merely starts with it (#61447). + * - Keeps all other part types (tool-call, image, etc.). + * - Appends the final text as a new text part. + */ +export function mergeFinalAssistantText(parts: ChatMessagePart[], finalText: string): ChatMessagePart[] { + const dedupeReference = normalizeWs(finalText) + + const kept = parts.filter(part => { + if (part.type === 'text') { + // Sealed text parts were already finalized into their own bubbles — + // this filter only runs on the LAST streaming bubble, so there are no + // sealed parts here. All text parts are streamed deltas that get + // replaced by the authoritative final text. + return false + } + + if (part.type !== 'reasoning' || !dedupeReference) { + return true + } + + // Reasoning is a restatement only when the final FULLY covers it. + // The reverse direction is not considered — a short final must not + // swallow a longer reasoning block (#61447). + const r = normalizeWs(part.text) + + return !(r && dedupeReference.startsWith(r)) + }) + + return finalText ? [...kept, assistantTextPart(finalText)] : kept +} + +const ATTACHED_CONTEXT_MARKER_RE = /(?:^|\n)--- Attached Context ---\s*\n/ +const CONTEXT_WARNINGS_MARKER_RE = /(?:^|\n)--- Context Warnings ---[\s\S]*$/ +const CONTEXT_REF_RE = /@(file|folder|url|image|tool|terminal):(?:"[^"\n]+"|'[^'\n]+'|`[^`\n]+`|\S+)/g + +function textFromUnknown(value: unknown, depth = 0): string { + if (typeof value === 'string') { + return value + } + + if (value === null || value === undefined) { + return '' + } + + if (depth > 2) { + return '' + } + + if (Array.isArray(value)) { + return value.map(item => textFromUnknown(item, depth + 1)).join('') + } + + if (typeof value === 'object') { + const row = value as Record<string, unknown> + const textValue = row.text ?? row.output_text ?? row.content ?? row.message + const nestedText = textFromUnknown(textValue, depth + 1) + + if (nestedText) { + return nestedText + } + + try { + return JSON.stringify(value) + } catch { + return '' + } + } + + return String(value) +} + +function displayContentForMessage(role: SessionMessage['role'], content: unknown): string { + const textContent = textFromUnknown(content) + + if (role !== 'user') { + return textContent + } + + // A `/skill` turn is stored expanded (the whole skill body). Current + // gateways project it to the invocation before it ever reaches us; this is + // the fallback for an older backend that still ships the raw payload. + const invocation = skillInvocationText(textContent) + + if (invocation) { + return invocation + } + + const marker = textContent.match(ATTACHED_CONTEXT_MARKER_RE) + + if (!marker || marker.index === undefined) { + return textContent.replace(CONTEXT_WARNINGS_MARKER_RE, '').trim() + } + + const visibleText = textContent.slice(0, marker.index).replace(CONTEXT_WARNINGS_MARKER_RE, '').trim() + const attachedContext = textContent.slice(marker.index + marker[0].length) + const refs = [...new Set(Array.from(attachedContext.matchAll(CONTEXT_REF_RE)).map(match => match[0]))] + + // The prose keeps the `@file:` token the user typed, so it already chips in + // place. Only hoist a ref the prose is missing — a turn persisted by an older + // backend that stripped the tokens. Re-listing an inline ref would chip twice. + const missing = refs.filter(ref => !visibleText.includes(ref)) + + return [missing.join('\n'), visibleText].filter(Boolean).join('\n\n') || visibleText +} + +function transcriptContent(displayKind: SessionMessage['display_kind'], content: string): string | null { + return displayKind === 'hidden' ? null : content +} + +// A remote backend older than this app serves display_metadata as raw JSON text, +// and `in` throws on a primitive — which used to fail the whole session resume. +function parseDisplayMetadata(metadata: SessionMessage['display_metadata']): null | Record<string, unknown> { + let parsed: unknown = metadata + + if (typeof parsed === 'string') { + try { + parsed = JSON.parse(parsed) + } catch { + return null + } + } + + return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null +} + +function timelineTaskCount(metadata: SessionMessage['display_metadata']): number | undefined { + const count = parseDisplayMetadata(metadata)?.task_count + + return typeof count === 'number' ? count : undefined +} + +export function messageReactions(metadata: SessionMessage['display_metadata']): MessageReaction[] { + const reactions = parseDisplayMetadata(metadata)?.reactions + + if (!Array.isArray(reactions)) { + return [] + } + + return reactions.filter( + (r): r is MessageReaction => Boolean(r) && typeof r === 'object' && typeof (r as MessageReaction).emoji === 'string' + ) +} + +function timelineDisplayContent(message: SessionMessage, content: string): string { + if (message.display_kind === 'model_switch') { + return 'model changed' + } + + if (message.display_kind === 'auto_continue') { + return 'resumed interrupted turn' + } + + if (message.display_kind === 'async_delegation_complete') { + const count = timelineTaskCount(message.display_metadata) + + return count === undefined + ? 'background agent work finished' + : `${count} background agent${count === 1 ? '' : 's'} finished` + } + + return content +} + +const STREAM_PART: Record<'reasoning' | 'text', (text: string) => ChatMessagePart> = { + reasoning: reasoningPart, + text: textPart +} + +// Coalesce a streaming delta into the most recent same-type part within the +// current segment, where a segment is bounded by any non-streaming part (a +// tool call, image, …). The opposite streaming channel (text <-> reasoning) is +// transparent, so a reasoning burst between two content deltas can't shred one +// sentence into text / Thinking / text — the fragmentation models that +// interleave reasoning_content + content otherwise produce. Tool calls still +// open a fresh part, preserving narration order across steps. +function appendStreamPart( + parts: ChatMessagePart[], + type: 'reasoning' | 'text', + delta: string +): { index: number; parts: ChatMessagePart[] } { + const next = [...parts] + + for (let i = next.length - 1; i >= 0; i--) { + const part = next[i] + + if (part.type === type) { + next[i] = { ...part, text: `${(part as { text: string }).text}${delta}` } as ChatMessagePart + + return { index: i, parts: next } + } + + if (part.type !== 'text' && part.type !== 'reasoning') { + break + } + } + + next.push(STREAM_PART[type](delta)) + + return { index: next.length - 1, parts: next } +} + +export function appendTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] { + return appendStreamPart(parts, 'text', delta).parts +} + +export function appendReasoningPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] { + return appendStreamPart(parts, 'reasoning', delta).parts +} + +export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] { + const { index, parts: next } = appendStreamPart(parts, 'text', delta) + const part = next[index] + + if (part?.type !== 'text') { + return next + } + + const mayContainMedia = + delta.includes('MEDIA:') || delta.includes('DIA:') || delta.includes('EDIA:') || delta.includes('IA:') + + if (mayContainMedia || part.text.includes('MEDIA:')) { + const rendered = renderMediaTags(part.text) + + if (rendered !== part.text) { + next[index] = { ...part, text: rendered } + } + } + + return next +} + +export function hasToolPart(message: ChatMessage): boolean { + return message.parts.some(part => part.type === 'tool-call') +} + +function toolId(payload: GatewayEventPayload | undefined): string { + return payload?.tool_id || payload?.tool_call_id || payload?.id || '' +} + +let liveToolCounter = 0 + +function nextLiveToolId(name: string): string { + liveToolCounter += 1 + + return `live-tool:${name}:${liveToolCounter}` +} + +function firstStringField(record: Record<string, unknown>, keys: readonly string[]): string { + for (const key of keys) { + const value = record[key] + + if (typeof value === 'string' && value.trim()) { + return value.trim() + } + } + + return '' +} + +function normalizeToolMatchValue(value: string): string { + return normalize(value) +} + +function collectToolMatchValues(query: string, context: string, preview: string): string[] { + return [...new Set([query, context, preview].map(normalizeToolMatchValue).filter(Boolean))] +} + +function toolPayloadMatchValues(payload: GatewayEventPayload | undefined): string[] { + const payloadArgs = liveToolArgs(payload) + // `question` is clarify's identifying arg: a synthetic row hydrated from + // `clarify.request` (a fresh request id) must correlate with the `tool.start` + // row (the model's tool_call_id) so the two ids don't produce a duplicate + // clarify card — same correlation ClarifyToolPending uses for request↔args. + const query = firstStringField(payloadArgs, ['search_term', 'query', 'question', 'command', 'code', 'path']) + const context = typeof payload?.context === 'string' ? payload.context.trim() : '' + const preview = typeof payload?.preview === 'string' ? payload.preview.trim() : '' + + return collectToolMatchValues(query, context, preview) +} + +function toolPartMatchValues(part: ChatMessagePart): string[] { + if (part.type !== 'tool-call' || !part.args || typeof part.args !== 'object') { + return [] + } + + const args = part.args as Record<string, unknown> + const query = firstStringField(args, ['search_term', 'query', 'question', 'command', 'code', 'path']) + const context = typeof args.context === 'string' ? args.context.trim() : '' + const preview = typeof args.preview === 'string' ? args.preview.trim() : '' + + return collectToolMatchValues(query, context, preview) +} + +function hasToolMatchOverlap(left: string[], right: string[]): boolean { + if (!left.length || !right.length) { + return false + } + + const rightSet = new Set(right) + + return left.some(value => rightSet.has(value)) +} + +function findToolPartIndex( + parts: ChatMessagePart[], + name: string, + stableId: string, + payload: GatewayEventPayload | undefined, + phase: 'running' | 'complete' +): number { + const matchValues = toolPayloadMatchValues(payload) + const overlaps = (index: number) => hasToolMatchOverlap(matchValues, toolPartMatchValues(parts[index])) + + if (stableId) { + const stableIndex = parts.findIndex(part => part.type === 'tool-call' && part.toolCallId === stableId) + + if (stableIndex >= 0) { + return stableIndex + } + + // Some live streams start without an id, then complete with one. Fall + // through to pending same-name/context matching so the completion updates + // the synthetic live row instead of appending a duplicate completed row. + if (phase === 'running' && !matchValues.length) { + return -1 + } + } + + const pendingIndices = parts + .map((part, index) => ({ part, index })) + .filter(({ part }) => part.type === 'tool-call' && part.toolName === name && part.result === undefined) + .map(({ index }) => index) + + if (pendingIndices.length === 0) { + return -1 + } + + if (matchValues.length) { + const contextualIndex = pendingIndices.find(overlaps) + + if (contextualIndex !== undefined) { + return contextualIndex + } + } + + if (pendingIndices.length === 1) { + const [singlePendingIndex] = pendingIndices + + if (phase === 'running' && matchValues.length && !overlaps(singlePendingIndex)) { + return stableId ? singlePendingIndex : -1 + } + + return singlePendingIndex + } + + // Completion events without stable IDs frequently arrive after multiple + // same-name starts (parallel tool calls). Resolve them oldest-first so we + // don't collapse an entire burst into a single row. + if (phase === 'complete') { + return pendingIndices[0] + } + + if (stableId) { + return pendingIndices[0] + } + + // For progress/running events with no stable id, update the most-recent + // pending same-name tool instead of creating a phantom extra row. + return pendingIndices.at(-1) ?? -1 +} + +// Carry todo state across sparse progress payloads: if this todo event lacks +// a `todos` field, fall back to whatever we previously stored on the part. +function carryTodos(payload: GatewayEventPayload | undefined, ...prev: unknown[]): { todos: unknown } | undefined { + if (payload && Object.hasOwn(payload, 'todos')) { + const next = parseTodos(payload.todos) + + return next === null ? undefined : { todos: next } + } + + if (payload?.name !== 'todo') { + return undefined + } + + for (const p of prev) { + const carried = parseTodos(recordFromUnknown(p)?.todos) + + if (carried !== null) { + return { todos: carried } + } + } + + return undefined +} + +function toolArgs(payload: GatewayEventPayload | undefined, prevArgs?: unknown): Record<string, unknown> { + const prev = parseMaybeJsonObject(prevArgs) + const eventArgs = liveToolArgs(payload) + + return { + ...prev, + ...eventArgs, + ...(payload?.context ? { context: payload.context } : {}), + ...(payload?.preview ? { preview: payload.preview } : {}), + ...carryTodos(payload, prevArgs) + } +} + +function toolResult( + payload: GatewayEventPayload | undefined, + prevResult?: unknown, + prevArgs?: unknown +): Record<string, unknown> { + const parsedResult = parseMaybeJsonObject(payload?.result) + + return { + ...parsedResult, + ...(payload?.inline_diff ? { inline_diff: payload.inline_diff } : {}), + ...(payload?.summary ? { summary: payload.summary } : {}), + ...(payload?.message ? { message: payload.message } : {}), + ...(payload?.preview ? { preview: payload.preview } : {}), + ...(payload?.duration_s !== undefined ? { duration_s: payload.duration_s } : {}), + ...carryTodos(payload, prevResult, prevArgs), + ...(payload?.error ? { error: payload.error } : {}) + } +} + +export function upsertToolPart( + parts: ChatMessagePart[], + payload: GatewayEventPayload | undefined, + phase: 'running' | 'complete' +): ChatMessagePart[] { + const stableId = toolId(payload) + const name = payload?.name || 'tool' + const next = [...parts] + + const index = findToolPartIndex(next, name, stableId, payload, phase) + + const prev = index >= 0 ? next[index] : null + const prevArgs = prev && 'args' in prev ? prev.args : undefined + const prevResult = prev && 'result' in prev ? prev.result : undefined + const args = toolArgs(payload, prevArgs) + + const id = + stableId || + (prev && 'toolCallId' in prev && typeof prev.toolCallId === 'string' ? prev.toolCallId : '') || + nextLiveToolId(name) + + const base = { + type: 'tool-call' as const, + toolCallId: id, + toolName: name, + args: args as never, + argsText: JSON.stringify(args), + ...(phase === 'complete' && { result: toolResult(payload, prevResult, prevArgs), isError: Boolean(payload?.error) }) + } satisfies ChatMessagePart + + if (index === -1) { + return [...next, base] + } + + next[index] = { ...next[index], ...base } + + return next +} + +function recordFromUnknown(value: unknown): Record<string, unknown> | null { + return value && typeof value === 'object' ? (value as Record<string, unknown>) : null +} + +function parseMaybeJsonObject(value: unknown): Record<string, unknown> { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record<string, unknown> + } + + if (typeof value !== 'string' || !value.trim()) { + return {} + } + + try { + const parsed = JSON.parse(value) + + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {} + } catch { + return {} + } +} + +function firstNonEmptyObject(...values: unknown[]): Record<string, unknown> { + for (const value of values) { + const parsed = parseMaybeJsonObject(value) + + if (Object.keys(parsed).length > 0) { + return parsed + } + } + + return {} +} + +function liveToolArgs(payload: GatewayEventPayload | undefined): Record<string, unknown> { + const direct = firstNonEmptyObject(payload?.args, payload?.arguments) + const input = firstNonEmptyObject(payload?.input) + const fn = recordFromUnknown(input.function) + + const nested = firstNonEmptyObject( + input.args, + input.arguments, + input.parameters, + input.input, + fn?.arguments, + fn?.args, + fn?.parameters + ) + + return { + ...input, + ...nested, + ...direct + } +} + +function parseStoredToolResult(content: unknown): unknown { + if (content && typeof content === 'object') { + return content + } + + const textContent = textFromUnknown(content) + + if (!textContent.trim()) { + return '' + } + + try { + return JSON.parse(textContent) + } catch { + return textContent + } +} + +function toolPartFromStoredCall(call: unknown, fallbackIndex: number): ChatMessagePart { + const row = recordFromUnknown(call) ?? {} + const fn = recordFromUnknown(row.function) + const id = String(row.id || row.tool_call_id || `stored-tool-${fallbackIndex}`) + + const toolName = String( + row.name || row.tool_name || fn?.name || (recordFromUnknown(row.input)?.name as string | undefined) || 'tool' + ) + + const args = firstNonEmptyObject(fn?.arguments, row.arguments, row.args, row.input) + + return { + type: 'tool-call', + toolCallId: id, + toolName, + args: args as never, + argsText: Object.keys(args).length ? JSON.stringify(args) : '' + } +} + +function applyStoredToolResult(messages: ChatMessage[], toolMessage: SessionMessage): boolean { + const toolCallId = toolMessage.tool_call_id || undefined + const toolName = toolMessage.tool_name || toolMessage.name || 'tool' + const content = toolMessage.content || toolMessage.text || toolMessage.context || toolMessage.name + + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i] + + if (message.role !== 'assistant') { + continue + } + + const partIndex = message.parts.findIndex( + part => + part.type === 'tool-call' && + ((toolCallId && part.toolCallId === toolCallId) || (!toolCallId && part.toolName === toolName)) + ) + + if (partIndex < 0) { + continue + } + + const parts = [...message.parts] + const existing = parts[partIndex] + parts[partIndex] = { + ...existing, + result: parseStoredToolResult(content), + isError: false + } as ChatMessagePart + messages[i] = { ...message, parts } + + return true + } + + return false +} + +function applyStoredToolResultToParts(parts: ChatMessagePart[], toolMessage: SessionMessage): ChatMessagePart[] | null { + const toolCallId = toolMessage.tool_call_id || undefined + const toolName = toolMessage.tool_name || toolMessage.name || 'tool' + const content = toolMessage.content || toolMessage.text || toolMessage.context || toolMessage.name + + const partIndex = parts.findIndex( + part => + part.type === 'tool-call' && + ((toolCallId && part.toolCallId === toolCallId) || (!toolCallId && part.toolName === toolName)) + ) + + if (partIndex < 0) { + return null + } + + const next = [...parts] + const existing = next[partIndex] + next[partIndex] = { + ...existing, + result: parseStoredToolResult(content), + isError: false + } as ChatMessagePart + + return next +} + +function storedToolMessagePart(toolMessage: SessionMessage, fallbackIndex: number): ChatMessagePart { + const name = toolMessage.tool_name || toolMessage.name || 'tool' + const context = textFromUnknown(toolMessage.context || toolMessage.text || toolMessage.content || '') + const args = context ? { context } : {} + + return { + type: 'tool-call', + toolCallId: toolMessage.tool_call_id || `stored-tool-message-${fallbackIndex}`, + toolName: name, + args: args as never, + argsText: Object.keys(args).length ? JSON.stringify(args) : '', + result: context ? { context } : {}, + isError: false + } +} + +function withUniqueToolCallIds(messages: ChatMessage[]): ChatMessage[] { + const seen = new Set<string>() + + return messages.map(message => { + let changed = false + + const parts = message.parts.map((part, index) => { + if (part.type !== 'tool-call') { + return part + } + + const id = part.toolCallId || `${message.id}-tool-${index}` + + if (!seen.has(id)) { + seen.add(id) + + if (part.toolCallId) { + return part + } + + changed = true + + return { ...part, toolCallId: id } as ChatMessagePart + } + + changed = true + const uniqueId = `${id}-${message.id}-${index}` + seen.add(uniqueId) + + return { ...part, toolCallId: uniqueId } as ChatMessagePart + }) + + return changed ? { ...message, parts } : message + }) +} + +export function toChatMessages(messages: SessionMessage[]): ChatMessage[] { + const result: ChatMessage[] = [] + let pendingToolParts: ChatMessagePart[] = [] + let pendingToolTimestamp: number | undefined + let activeAssistantIndex: null | number = null + + const clearPendingTools = () => { + pendingToolParts = [] + pendingToolTimestamp = undefined + } + + const appendPartsToActiveAssistant = (parts: ChatMessagePart[], timestamp?: number): boolean => { + if (activeAssistantIndex === null) { + return false + } + + const active = result[activeAssistantIndex] + + if (!active || active.role !== 'assistant') { + activeAssistantIndex = null + + return false + } + + active.parts = [...active.parts, ...parts] + active.timestamp = timestamp ?? active.timestamp + + return true + } + + const flushPendingTools = (index: number) => { + if (!pendingToolParts.length) { + return + } + + if (!appendPartsToActiveAssistant(pendingToolParts, pendingToolTimestamp)) { + result.push({ + id: `${pendingToolTimestamp || Date.now()}-${index}-tools`, + role: 'assistant', + parts: pendingToolParts, + timestamp: pendingToolTimestamp + }) + activeAssistantIndex = result.length - 1 + } + + clearPendingTools() + } + + messages.forEach((message, index) => { + if (message.role === 'tool') { + const updatedPendingToolParts = applyStoredToolResultToParts(pendingToolParts, message) + + if (updatedPendingToolParts) { + pendingToolParts = updatedPendingToolParts + + return + } + + if (applyStoredToolResult(result, message)) { + return + } + + pendingToolParts = [...pendingToolParts, storedToolMessagePart(message, index)] + pendingToolTimestamp ??= message.timestamp + + return + } + + const content = message.content || message.text || message.context || message.name + + const rawDisplayContent = transcriptContent( + message.display_kind, + timelineDisplayContent(message, displayContentForMessage(message.role, content)) + ) + + const displayRole = + message.display_kind === 'model_switch' || + message.display_kind === 'async_delegation_complete' || + message.display_kind === 'auto_continue' + ? 'system' + : message.role + + // Persisted user turns carry `@image:<path>` directive lines inline in + // the text (see tui_gateway/server.py's persist-time rewrite). The + // read-only bubble clamps its body to ~2 lines, and a large inline image + // thumbnail pushes any caption text below the clamp's visible area — so + // pull image refs out into `attachmentRefs` (same shape the local + // optimistic composer already uses) and render them via the dedicated + // attachments row below the bubble instead. + const imageRefExtraction = displayRole === 'user' && rawDisplayContent ? extractImageRefs(rawDisplayContent) : null + const displayContent = imageRefExtraction ? imageRefExtraction.cleanedText : rawDisplayContent + const extractedAttachmentRefs = imageRefExtraction?.refs.length ? imageRefExtraction.refs : undefined + + const parts: ChatMessagePart[] = [] + + const reasoning = + message.reasoning || + message.reasoning_content || + (typeof message.reasoning_details === 'string' ? message.reasoning_details : '') + + if (reasoning && message.role === 'assistant') { + parts.push(reasoningPart(reasoning)) + } + + if (displayContent) { + parts.push(displayRole === 'assistant' ? assistantTextPart(displayContent) : textPart(displayContent)) + } + + if (message.role === 'assistant' && Array.isArray(message.tool_calls)) { + parts.push(...message.tool_calls.map((call, callIndex) => toolPartFromStoredCall(call, callIndex))) + } + + if (!parts.length && !extractedAttachmentRefs?.length) { + if (message.role !== 'assistant') { + flushPendingTools(index) + activeAssistantIndex = null + } + + return + } + + const isToolOnlyAssistant = + message.role === 'assistant' && parts.length > 0 && parts.every(part => part.type === 'tool-call') + + if (isToolOnlyAssistant) { + pendingToolParts = [...pendingToolParts, ...parts] + pendingToolTimestamp ??= message.timestamp + + return + } + + if (message.role === 'assistant') { + if (pendingToolParts.length) { + if (!appendPartsToActiveAssistant(pendingToolParts, message.timestamp ?? pendingToolTimestamp)) { + parts.unshift(...pendingToolParts) + } + + clearPendingTools() + } + + const activeAssistant = + activeAssistantIndex !== null && result[activeAssistantIndex]?.role === 'assistant' + ? result[activeAssistantIndex] + : null + + const currentHasToolCall = parts.some(part => part.type === 'tool-call') + const activeHasToolCall = Boolean(activeAssistant?.parts.some(part => part.type === 'tool-call')) + + if (activeAssistant && (currentHasToolCall || activeHasToolCall)) { + activeAssistant.parts = [...activeAssistant.parts, ...parts] + activeAssistant.timestamp = message.timestamp ?? activeAssistant.timestamp + + return + } + } else { + flushPendingTools(index) + } + + const reactions = messageReactions(message.display_metadata) + // Gateway resume names the durable row id `row_id`; the REST transcript + // prefetch ships the same messages.id as a numeric `id`. Either one lets + // reactions address this exact row later. + const rowId = message.row_id ?? (typeof message.id === 'number' ? message.id : undefined) + + result.push({ + id: `${message.timestamp || Date.now()}-${index}-${displayRole}`, + role: displayRole, + parts, + timestamp: message.timestamp, + ...(rowId !== undefined ? { rowId } : {}), + ...(reactions.length ? { reactions } : {}), + ...(extractedAttachmentRefs ? { attachmentRefs: extractedAttachmentRefs } : {}) + }) + + activeAssistantIndex = message.role === 'assistant' ? result.length - 1 : null + }) + flushPendingTools(messages.length) + + const withoutGeneratedImageEchoes = result.map(message => + message.role === 'assistant' ? { ...message, parts: dedupeGeneratedImageEchoesInParts(message.parts) } : message + ) + + return withUniqueToolCallIds( + withoutGeneratedImageEchoes.filter( + m => chatMessageText(m).trim() || m.parts.some(part => part.type !== 'text') || m.attachmentRefs?.length + ) + ) +} + +export function preserveLocalAssistantErrors( + nextMessages: ChatMessage[], + currentMessages: ChatMessage[] +): ChatMessage[] { + const localById = new Map(currentMessages.map(message => [message.id, message])) + + const mergedNextMessages = nextMessages.map(message => { + if (message.role !== 'assistant' || message.error || message.hidden) { + return message + } + + const local = localById.get(message.id) + + if (!local || local.role !== 'assistant' || !local.error || local.hidden) { + return message + } + + return { + ...message, + error: local.error, + pending: false + } + }) + + const existingIds = new Set(mergedNextMessages.map(message => message.id)) + const preserveIds = new Set<string>() + const normalize = (value: string) => value.replace(/\s+/g, ' ').trim() + const tailUserInNext = [...mergedNextMessages].reverse().find(message => message.role === 'user' && !message.hidden) + const tailUserText = tailUserInNext ? normalize(chatMessageText(tailUserInNext)) : '' + const tailUserRefs = tailUserInNext ? (tailUserInNext.attachmentRefs ?? []).join('\n') : '' + + const matchesTailUserInNext = (candidate: ChatMessage) => + Boolean(tailUserInNext) && + normalize(chatMessageText(candidate)) === tailUserText && + (candidate.attachmentRefs ?? []).join('\n') === tailUserRefs + + for (let index = 0; index < currentMessages.length; index += 1) { + const message = currentMessages[index] + + if (message.role !== 'assistant' || !message.error || message.hidden || existingIds.has(message.id)) { + continue + } + + preserveIds.add(message.id) + + for (let probe = index - 1; probe >= 0; probe -= 1) { + const candidate = currentMessages[probe] + + if (candidate.hidden) { + continue + } + + if (candidate.role === 'user' && !existingIds.has(candidate.id) && !matchesTailUserInNext(candidate)) { + preserveIds.add(candidate.id) + } + + break + } + } + + if (preserveIds.size === 0) { + return mergedNextMessages + } + + const preserved = currentMessages + .filter(message => preserveIds.has(message.id)) + .map(message => ({ ...message, pending: false })) + + return [...mergedNextMessages, ...preserved] +} + +export function branchGroupForUser(userMessage: ChatMessage): string { + return `branch:${userMessage.id}` +} diff --git a/ui-desktop/src/lib/chat-runtime.test.ts b/ui-desktop/src/lib/chat-runtime.test.ts new file mode 100644 index 00000000..7ec2aa4c --- /dev/null +++ b/ui-desktop/src/lib/chat-runtime.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from 'vitest' + +import type { ComposerAttachment } from '@/store/composer' + +import { + attachmentDisplayText, + attachmentId, + coerceThinkingText, + messageCreatedAt, + optimisticAttachmentRef, + parseCommandDispatch, + parseSlashCommand +} from './chat-runtime' + +const DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANS' + +function attachment(overrides: Partial<ComposerAttachment> & Pick<ComposerAttachment, 'kind'>): ComposerAttachment { + return { id: 'a', label: 'file.png', ...overrides } +} + +describe('optimisticAttachmentRef', () => { + it('renders an image from its in-hand base64 preview (no @image: path ref)', () => { + const ref = optimisticAttachmentRef(attachment({ kind: 'image', detail: '/tmp/shot.png', previewUrl: DATA_URL })) + + // The raw data URL flows through extractEmbeddedImages → inline thumbnail, + // dodging the remote /api/media 403 an @image:<localpath> ref would hit. + expect(ref).toBe(DATA_URL) + }) + + it('falls back to an @image: path ref when no preview is available', () => { + expect(optimisticAttachmentRef(attachment({ kind: 'image', detail: '/tmp/shot.png' }))).toBe('@image:/tmp/shot.png') + }) + + it('ignores a non-data preview url and uses the path ref', () => { + const ref = optimisticAttachmentRef( + attachment({ kind: 'image', detail: '/tmp/shot.png', previewUrl: 'https://example.com/x.png' }) + ) + + expect(ref).toBe('@image:/tmp/shot.png') + }) + + it('passes non-image attachments straight through to attachmentDisplayText', () => { + expect(optimisticAttachmentRef(attachment({ kind: 'file', refText: '@file:src/a.ts', previewUrl: DATA_URL }))).toBe( + '@file:src/a.ts' + ) + }) + + // Session switches / draft restores can leave undefined|null holes in the + // composer attachments array. AttachmentList already filters them (#49624), + // but the submit path maps the same array through these helpers — an unguarded + // hole threw "Cannot read properties of undefined (reading 'refText')", + // crashing the chat surface (blank pane). The helpers must no-op on holes. + it('returns null for an undefined attachment instead of throwing', () => { + expect(() => optimisticAttachmentRef(undefined as unknown as ComposerAttachment)).not.toThrow() + expect(optimisticAttachmentRef(undefined as unknown as ComposerAttachment)).toBeNull() + }) + + it('returns null for a null attachment instead of throwing', () => { + expect(optimisticAttachmentRef(null as unknown as ComposerAttachment)).toBeNull() + }) +}) + +describe('attachmentDisplayText', () => { + it('returns null for undefined|null instead of reading .kind/.refText on a hole', () => { + expect(() => attachmentDisplayText(undefined as unknown as ComposerAttachment)).not.toThrow() + expect(attachmentDisplayText(undefined as unknown as ComposerAttachment)).toBeNull() + expect(attachmentDisplayText(null as unknown as ComposerAttachment)).toBeNull() + }) + + it('still resolves a normal file ref', () => { + expect(attachmentDisplayText(attachment({ kind: 'file', refText: '@file:src/a.ts' }))).toBe('@file:src/a.ts') + }) +}) + +describe('coerceThinkingText', () => { + it('strips streaming status prefixes from thinking deltas', () => { + expect(coerceThinkingText("◉_◉ processing... checking the user's request")).toBe("checking the user's request") + expect(coerceThinkingText('(¬‿¬) analyzing... reading the file')).toBe('reading the file') + }) + + it('drops empty thinking rewrite placeholder text', () => { + expect( + coerceThinkingText( + "◉_◉ processing... I don't see any current rewritten thinking or next thinking to process. Could you provide the thinking content you'd like me to rewrite?" + ) + ).toBe('') + }) +}) + +describe('parseCommandDispatch', () => { + it('keeps the notice on a send directive (e.g. /goal set)', () => { + // The backend's /goal set returns {type:send, notice:"⊙ Goal set …", message}. + // Dropping the notice made /goal look like it did nothing in the desktop app. + const parsed = parseCommandDispatch({ type: 'send', notice: '⊙ Goal set', message: 'do the thing' }) + + expect(parsed).toEqual({ type: 'send', message: 'do the thing', notice: '⊙ Goal set' }) + }) + + it('keeps message-only send directives working (no notice)', () => { + expect(parseCommandDispatch({ type: 'send', message: 'hi' })).toEqual({ + type: 'send', + message: 'hi', + notice: undefined + }) + }) + + it('parses a prefill directive with its notice (e.g. /undo)', () => { + const parsed = parseCommandDispatch({ type: 'prefill', notice: 'backed up 1 turn', message: 'edit me' }) + + expect(parsed).toEqual({ type: 'prefill', message: 'edit me', notice: 'backed up 1 turn' }) + }) + + it('rejects a prefill directive missing its message', () => { + expect(parseCommandDispatch({ type: 'prefill', notice: 'x' })).toBeNull() + }) +}) + +describe('parseSlashCommand', () => { + it('parses a single-line command', () => { + expect(parseSlashCommand('/some-skill do something')).toEqual({ + arg: 'do something', + name: 'some-skill' + }) + }) + + it('keeps a multiline arg intact instead of failing the whole parse (#41323)', () => { + expect(parseSlashCommand('/goal Write a Python script\nthat prints Hello World')).toEqual({ + arg: 'Write a Python script\nthat prints Hello World', + name: 'goal' + }) + }) + + it('parses a skill command with a long pasted multi-paragraph context (#55510)', () => { + const context = 'summarize this:\n\nparagraph one\nparagraph two\n\nparagraph three' + + expect(parseSlashCommand(`/some-skill ${context}`)).toEqual({ + arg: context, + name: 'some-skill' + }) + }) + + it('takes the name across a newline boundary like the CLI and gateway (split on any whitespace)', () => { + expect(parseSlashCommand('/goal\npasted block')).toEqual({ arg: 'pasted block', name: 'goal' }) + }) + + it('keeps truly empty slash input empty', () => { + expect(parseSlashCommand('/')).toEqual({ arg: '', name: '' }) + expect(parseSlashCommand('/ ')).toEqual({ arg: '', name: '' }) + }) + + it('does not treat text after horizontal whitespace as a command name (CLI parity)', () => { + expect(parseSlashCommand('/ some words')).toEqual({ arg: '', name: '' }) + }) +}) + +describe('attachmentId', () => { + it('normalizes a trailing slash on a url so a re-attach dedupes (#59305 P2)', () => { + expect(attachmentId('url', 'https://example.com/a')).toBe(attachmentId('url', 'https://example.com/a/')) + }) + + it('falls back to the trimmed raw value for a malformed url instead of throwing', () => { + expect(() => attachmentId('url', 'not a url')).not.toThrow() + expect(attachmentId('url', ' not a url ')).toBe(attachmentId('url', 'not a url')) + }) + + it('normalizes backslash path separators so a Windows and posix path dedupe', () => { + expect(attachmentId('file', 'a\\b.ts')).toBe(attachmentId('file', 'a/b.ts')) + }) + + it('normalizes a trailing slash on a folder path', () => { + expect(attachmentId('folder', 'src/app/')).toBe(attachmentId('folder', 'src/app')) + }) + + it('does not collapse a bare root path to an empty id', () => { + expect(attachmentId('folder', '/')).toBe('folder:/') + }) + + it('keeps distinct urls distinct', () => { + expect(attachmentId('url', 'https://example.com/a')).not.toBe(attachmentId('url', 'https://example.com/b')) + }) +}) + +describe('messageCreatedAt', () => { + const NOW = Date.UTC(2026, 6, 28, 18, 0, 0) + + it('reads the authoritative Unix-seconds timestamp (not ms)', () => { + // 1785282262s → July 2026, not the 1970 epoch a *1000-less read would give. + expect(messageCreatedAt({ timestamp: 1785282262 }, NOW).getFullYear()).toBe(2026) + }) + + it('falls back to now — never digs digits out of the id → "20663d ago" (1970)', () => { + // The old fallback did `new Date(Number(id.match(/\d+/)))`, so a session-style + // id like 20260728_184420_05e697 parsed to 20260728 *ms* = Jan 1970, showing + // as an absurd 20663-day age. A timestamp-less message is freshly created. + expect(messageCreatedAt({ timestamp: undefined }, NOW).getTime()).toBe(NOW) + }) + + it('treats a zero / non-finite timestamp as absent', () => { + expect(messageCreatedAt({ timestamp: 0 }, NOW).getTime()).toBe(NOW) + expect(messageCreatedAt({ timestamp: Number.NaN }, NOW).getTime()).toBe(NOW) + }) +}) diff --git a/ui-desktop/src/lib/chat-runtime.ts b/ui-desktop/src/lib/chat-runtime.ts new file mode 100644 index 00000000..72ce1f70 --- /dev/null +++ b/ui-desktop/src/lib/chat-runtime.ts @@ -0,0 +1,497 @@ +import type { ThreadMessage } from '@assistant-ui/react' + +import type { QuickModelOption } from '@/app/chat/composer/types' +import type { ClientSessionState, CommandDispatchResponse } from '@/app/types' +import { formatRefValue } from '@/components/assistant-ui/directive-text' +import { type ChatMessage, type ChatMessagePart, chatMessageText, textPart } from '@/lib/chat-messages' +import { normalize } from '@/lib/text' +import type { ComposerAttachment } from '@/store/composer' +import type { ModelOptionsResponse, SessionInfo } from '@/types/clawcodex' + +export const SLASH_COMMAND_RE = /^\/[^\s/]*(?:\s|$)/ +export const BUILTIN_PERSONALITIES = [ + 'helpful', + 'concise', + 'technical', + 'creative', + 'teacher', + 'kawaii', + 'catgirl', + 'pirate', + 'shakespeare', + 'surfer', + 'noir', + 'uwu', + 'philosopher', + 'hype' +] + +const THINKING_STATUS_PREFIX_RE = + /^\s*(?:(?:[^\s.]{1,16})\s+)?(?:processing|thinking|reasoning|analyzing|pondering|contemplating|musing|cogitating|ruminating|deliberating|mulling|reflecting|computing|synthesizing|formulating|brainstorming)\.\.\.\s*/i + +const EMPTY_THINKING_PLACEHOLDER_RE = + /\b(?:current rewritten thinking|next thinking to process|provide the thinking content|don't see any .*thinking)\b/i + +export function createClientSessionState( + storedSessionId: string | null = null, + messages: ChatMessage[] = [] +): ClientSessionState { + return { + storedSessionId, + messages, + branch: '', + cwd: '', + model: '', + provider: '', + reasoningEffort: '', + serviceTier: '', + fast: false, + yolo: false, + personality: '', + busy: false, + awaitingResponse: false, + streamId: null, + sawAssistantPayload: false, + pendingBranchGroup: null, + interrupted: false, + interimBoundaryPending: false, + needsInput: false, + turnStartedAt: null, + usage: null + } +} + +export function sessionTitle(session: SessionInfo): string { + return session.title?.trim() || session.preview?.trim() || 'Untitled session' +} + +export function coerceGatewayText(value: unknown): string { + if (typeof value === 'string') { + return value + } + + if (value === null || value === undefined) { + return '' + } + + if (Array.isArray(value)) { + return value + .map(item => { + if (typeof item === 'string') { + return item + } + + if (item && typeof item === 'object') { + const row = item as Record<string, unknown> + + if (typeof row.text === 'string') { + return row.text + } + + if (typeof row.output_text === 'string') { + return row.output_text + } + } + + return '' + }) + .join('') + } + + if (typeof value === 'object') { + const row = value as Record<string, unknown> + + if (typeof row.text === 'string') { + return row.text + } + + if (typeof row.output_text === 'string') { + return row.output_text + } + + try { + return JSON.stringify(value) + } catch { + return '' + } + } + + return String(value) +} + +/** + * Normalize a reasoning/thinking text payload from the gateway. + * + * Only the leading status prefix (e.g. "ClawCodex is thinking...") and the + * obvious placeholder echoes are stripped. We deliberately do NOT trim + * the delta — reasoning streams as small chunks (often individual tokens + * with leading or trailing spaces), and trimming each chunk before + * concatenation collapses adjacent words together. Whitespace between + * tokens belongs to the data, not chrome. + */ +export function coerceThinkingText(value: unknown): string { + const raw = coerceGatewayText(value).replace(THINKING_STATUS_PREFIX_RE, '') + + return EMPTY_THINKING_PLACEHOLDER_RE.test(raw) ? '' : raw +} + +export function isImageGenerationTool(name?: string): boolean { + return name === 'image_generate' +} + +export function contextPath(path: string, cwd: string): string { + if (!cwd) { + return path + } + + const normalizedCwd = cwd.endsWith('/') ? cwd : `${cwd}/` + + return path.startsWith(normalizedCwd) ? path.slice(normalizedCwd.length) : path +} + +// IDs are content-derived (`kind:value`), not uuids, so upsertAttachment's +// exact-match dedupe only catches a re-attach when the raw value matches +// byte-for-byte. Normalize the value first so a trailing slash, a `\` path +// separator, etc. don't slip past dedupe as a "different" attachment. +function normalizeAttachmentValue(kind: ComposerAttachment['kind'], value: string): string { + const trimmed = value.trim() + + if (kind === 'url') { + try { + // The WHATWG URL parser only collapses an EMPTY path to '/' (bare + // origin) — it does not treat '/a' and '/a/' as equivalent, so strip a + // trailing slash ourselves once the URL is otherwise canonicalized + // (scheme/host case, default ports, etc.). + return new URL(trimmed).toString().replace(/\/+$/, '') + } catch { + return trimmed + } + } + + if (kind === 'file' || kind === 'folder' || kind === 'image') { + const posix = trimmed.replace(/\\/g, '/') + + // Don't collapse a bare root ('/' or 'C:/') down to an empty string. + return posix.length > 1 ? posix.replace(/\/+$/, '') : posix + } + + return trimmed +} + +export function attachmentId(kind: ComposerAttachment['kind'], value: string): string { + return `${kind}:${normalizeAttachmentValue(kind, value)}` +} + +export function pathLabel(path: string): string { + return path.split(/[\\/]/).filter(Boolean).pop() || path +} + +export function attachmentDisplayText(attachment: ComposerAttachment): string | null { + // Session switches / draft restores can leave undefined holes in the + // composer attachments array (see AttachmentList's filter(Boolean) + #49624). + // Every consumer funnels through here, so guard the chokepoint too. + if (!attachment) { + return null + } + + if (attachment.kind === 'terminal' && attachment.detail) { + return `\`\`\`terminal\n${attachment.detail.trim()}\n\`\`\`` + } + + if (attachment.refText) { + return attachment.refText + } + + if (attachment.kind === 'image') { + const id = attachment.detail || attachment.path || attachment.label + + return id ? `@image:${formatRefValue(id)}` : null + } + + return null +} + +/** + * Display ref for the optimistic (in-flight) user bubble. + * + * Images prefer their in-hand base64 preview (a `data:` URL) over a file path. + * `DirectiveContent` runs `extractEmbeddedImages` first, so a raw `data:` URL + * renders as an inline thumbnail with zero network. An `@image:<localpath>` ref + * would instead route through `/api/media`, which in remote mode 403s ("Path + * outside media roots") on a local path the gateway can't read yet — flashing a + * fallback chip until submit uploads the bytes. The preview also survives the + * post-sync rewrite (bytes go to the agent via the attached-image pipeline, not + * this display ref), so the thumbnail stays stable instead of remounting. + * + * Everything else (files, folders, terminals, post-sync `@file:` refs) falls + * through to `attachmentDisplayText`. + */ +export function optimisticAttachmentRef(attachment: ComposerAttachment): string | null { + if (!attachment) { + return null + } + + if (attachment.kind === 'image' && attachment.previewUrl?.startsWith('data:')) { + return attachment.previewUrl + } + + return attachmentDisplayText(attachment) +} + +export function personalityNamesFromConfig(config: unknown): string[] { + const root = config && typeof config === 'object' ? (config as Record<string, unknown>) : {} + const agent = root.agent && typeof root.agent === 'object' ? (root.agent as Record<string, unknown>) : {} + const personalities = agent.personalities + + return personalities && typeof personalities === 'object' && !Array.isArray(personalities) + ? Object.keys(personalities as Record<string, unknown>) + : [] +} + +export function normalizePersonalityValue(value: string): string { + const trimmed = normalize(value) + + return !trimmed || trimmed === 'default' || trimmed === 'none' ? '' : trimmed +} + +export function parseSlashCommand(command: string) { + // `[\s\S]*` (not `.*`): the arg may span newlines — `/goal <multi-line text>` + // or a skill command with a long pasted context. The old `.*$` regex failed + // the whole match on any newline, so every multiline slash command parsed as + // an empty name and got swallowed (#41323, #55510). The backend and CLI both + // split on any whitespace (`split(maxsplit=1)`), so this is the parity fix. + const match = command.replace(/^\/+/, '').match(/^(\S+)([\s\S]*)$/) + + return match ? { name: match[1], arg: match[2].trim() } : { name: '', arg: '' } +} + +export function parseCommandDispatch(raw: unknown): CommandDispatchResponse | null { + if (!raw || typeof raw !== 'object') { + return null + } + + const row = raw as Record<string, unknown> + const str = (value: unknown) => (typeof value === 'string' ? value : undefined) + + switch (row.type) { + case 'exec': + + case 'plugin': + return { type: row.type, output: str(row.output) } + + case 'alias': + return typeof row.target === 'string' ? { type: 'alias', target: row.target } : null + + case 'skill': + return typeof row.name === 'string' + ? { type: 'skill', name: row.name, message: str(row.message), display: str(row.display) } + : null + + case 'send': + return typeof row.message === 'string' + ? { type: 'send', message: row.message, notice: str(row.notice), display: str(row.display) } + : null + + case 'prefill': + return typeof row.message === 'string' ? { type: 'prefill', message: row.message, notice: str(row.notice) } : null + + default: + return null + } +} + +export function quickModelOptions( + data: ModelOptionsResponse | undefined, + currentProvider: string, + currentModel: string +): QuickModelOption[] { + const seen = new Set<string>() + const options: QuickModelOption[] = [] + + const providers = [...(data?.providers ?? [])].sort((a, b) => { + if (a.slug === currentProvider) { + return -1 + } + + if (b.slug === currentProvider) { + return 1 + } + + if (a.is_current) { + return -1 + } + + if (b.is_current) { + return 1 + } + + return 0 + }) + + const add = (provider: string, providerName: string, model: string) => { + const key = `${provider}:${model}` + + if (!model || seen.has(key)) { + return + } + + seen.add(key) + options.push({ provider, providerName, model }) + } + + if (currentProvider && currentModel) { + add(currentProvider, currentProvider, currentModel) + } + + for (const provider of providers) { + const models = [...(provider.models ?? [])].sort((a, b) => { + if (provider.slug === currentProvider && a === currentModel) { + return -1 + } + + if (provider.slug === currentProvider && b === currentModel) { + return 1 + } + + return 0 + }) + + for (const model of models) { + add(provider.slug, provider.name, model) + } + + if (options.length >= 8) { + break + } + } + + return options.slice(0, 8) +} + +// A message's display time. `timestamp` (Unix seconds) is authoritative when +// present. Without it we fall back to *now* rather than digging digits out of +// the id: message ids come in incompatible shapes — `assistant-<ms>`, +// `<seconds>-<i>-<role>`, session-style `20260728_184420_…` — and feeding any +// of them to `new Date()` (which reads ms) lands on the 1970 epoch, rendering +// as an absurd "20663d ago". A timestamp-less message is a freshly created +// optimistic/streaming one, so *now* is the right age anyway. +export function messageCreatedAt(message: Pick<ChatMessage, 'timestamp'>, nowMs = Date.now()): Date { + return typeof message.timestamp === 'number' && Number.isFinite(message.timestamp) && message.timestamp > 0 + ? new Date(message.timestamp * 1000) + : new Date(nowMs) +} + +export function toRuntimeMessage(message: ChatMessage): ThreadMessage { + const role = + message.role === 'user' || message.role === 'assistant' || message.role === 'system' ? message.role : 'assistant' + + const createdAt = messageCreatedAt(message) + + // Reactions and the durable row id ride metadata.custom for every role — the + // established channel for per-message extras (attachmentRefs below). + const reactionMeta = { + ...(message.rowId !== undefined ? { rowId: message.rowId } : {}), + ...(message.reactions?.length ? { reactions: message.reactions } : {}) + } + + if (role === 'user') { + return { + id: message.id, + role, + content: message.parts.filter((part): part is Extract<ChatMessagePart, { type: 'text' }> => part.type === 'text'), + attachments: [], + createdAt, + metadata: { custom: { attachmentRefs: message.attachmentRefs ?? [], ...reactionMeta } } + } as ThreadMessage + } + + if (role === 'system') { + const text = chatMessageText(message) + + return { + id: message.id, + role, + content: [textPart(text)], + createdAt, + metadata: { custom: {} } + } as ThreadMessage + } + + return { + id: message.id, + role, + content: message.parts as Extract<ThreadMessage, { role: 'assistant' }>['content'], + createdAt, + status: message.error + ? { type: 'incomplete', reason: 'error', error: message.error } + : message.pending + ? { type: 'running' } + : { type: 'complete', reason: 'stop' }, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + // Carries ChatMessage.interim to AssistantMessage's footer gate. + custom: { ...(message.interim ? { interim: true } : {}), ...reactionMeta } + } + } as ThreadMessage +} + +export type ToolMergeCache = WeakMap< + ChatMessage, + { merged: ChatMessage; parts: ChatMessagePart[]; prev: ChatMessage; prevParts: ChatMessagePart[] } +> + +export function createToolMergeCache(): ToolMergeCache { + return new WeakMap() +} + +// A settled assistant message with only tool calls — no prose, no reasoning. +// The model routinely emits a follow-up batch of calls as its own text-less +// message; on screen it looks like one continuous run, but assistant-ui can't +// group tool calls across a message boundary. +function isToolOnlyAssistant(message: ChatMessage): boolean { + return ( + message.role === 'assistant' && + !message.pending && + !message.error && + !message.hidden && + message.parts.length > 0 && + message.parts.every(part => part.type === 'tool-call') + ) +} + +/** + * Fold each settled tool-only assistant message into the preceding assistant + * message so its calls join that message's tool group (and can collapse into + * the auto-scrolling window). Render-only — never mutates the `$messages` store + * — and settle-only: pending messages are left alone, so a live turn is never + * merged/un-merged mid-stream. `cache` keys merged results by source identity, + * so a stable turn yields stable merged objects (no re-render churn). + */ +export function coalesceToolOnlyAssistants(messages: ChatMessage[], cache: ToolMergeCache): ChatMessage[] { + const out: ChatMessage[] = [] + + for (const message of messages) { + const prev = out.at(-1) + + if (prev && prev.role === 'assistant' && !prev.pending && !prev.hidden && isToolOnlyAssistant(message)) { + const cached = cache.get(message) + + const merged = + cached && cached.prev === prev && cached.prevParts === prev.parts && cached.parts === message.parts + ? cached.merged + : { ...prev, parts: [...prev.parts, ...message.parts] } + + cache.set(message, { merged, parts: message.parts, prev, prevParts: prev.parts }) + out[out.length - 1] = merged + + continue + } + + out.push(message) + } + + return out +} diff --git a/ui-desktop/src/lib/clipboard.ts b/ui-desktop/src/lib/clipboard.ts new file mode 100644 index 00000000..5e985bb3 --- /dev/null +++ b/ui-desktop/src/lib/clipboard.ts @@ -0,0 +1,28 @@ +// Routes `navigator.clipboard.writeText` through Electron IPC, since the +// renderer's clipboard API throws "Write permission denied" whenever the +// document loses focus (e.g. clicking a portaled Radix dropdown). The IPC +// path runs in the main process and is unconditional. + +export function installClipboardShim() { + const ipc = window.clawcodexDesktop?.writeClipboard + + if (!ipc || !navigator.clipboard) { + return + } + + const native = navigator.clipboard.writeText?.bind(navigator.clipboard) + + const writeText = async (text: string) => { + try { + await ipc(text) + } catch { + await native?.(text) + } + } + + try { + Object.defineProperty(navigator.clipboard, 'writeText', { configurable: true, value: writeText, writable: true }) + } catch { + // Browser refused override; primitives keep using the native API. + } +} diff --git a/ui-desktop/src/lib/commit-changelog.test.ts b/ui-desktop/src/lib/commit-changelog.test.ts new file mode 100644 index 00000000..22f3525c --- /dev/null +++ b/ui-desktop/src/lib/commit-changelog.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' + +import { buildCommitChangelog, parseCommitHeader } from './commit-changelog' + +describe('parseCommitHeader', () => { + it('extracts type, scope, and subject from a conventional header', () => { + expect(parseCommitHeader('feat(desktop): NSIS prereq detection page')).toEqual({ + breaking: false, + scope: 'desktop', + subject: 'NSIS prereq detection page', + type: 'feat' + }) + }) + + it('flags breaking changes via the `!` marker', () => { + expect(parseCommitHeader('feat(api)!: change endpoint shape')).toMatchObject({ + breaking: true, + type: 'feat' + }) + }) + + it('treats non-conventional commits as untyped with the full header as subject', () => { + expect(parseCommitHeader('Update README')).toEqual({ + breaking: false, + scope: null, + subject: 'Update README', + type: null + }) + }) + + it('ignores body lines and trims whitespace', () => { + expect(parseCommitHeader(' fix: handle null input \n\nMore detail')).toMatchObject({ + subject: 'handle null input', + type: 'fix' + }) + }) + + it('returns empty subject for blank input', () => { + expect(parseCommitHeader('')).toEqual({ breaking: false, scope: null, subject: '', type: null }) + }) +}) + +describe('buildCommitChangelog', () => { + it('groups commits into user-friendly buckets and capitalizes subjects', () => { + const groups = buildCommitChangelog([ + { summary: 'feat(desktop): add NSIS prereq detection page' }, + { summary: 'fix(sidebar): jitter when dragging' }, + { summary: 'perf: shave 200ms off cold start' }, + { summary: 'refactor: extract sidebar row component' } + ]) + + expect(groups.map(g => g.id)).toEqual(['new', 'fixed', 'faster']) + expect(groups[0]).toMatchObject({ label: "What's new" }) + expect(groups[0].items[0]).toBe('Add NSIS prereq detection page') + expect(groups[1].items[0]).toBe('Jitter when dragging') + }) + + it('hides chore/ci/docs/test commits', () => { + const groups = buildCommitChangelog([ + { summary: 'chore: bump deps' }, + { summary: 'ci: tweak workflow' }, + { summary: 'docs: spelling fix' }, + { summary: 'feat: real new feature' } + ]) + + expect(groups).toHaveLength(1) + expect(groups[0].items).toEqual(['Real new feature']) + }) + + it('routes unparseable commits to the "Other improvements" bucket', () => { + const groups = buildCommitChangelog([{ summary: 'Update sidebar styling' }]) + + expect(groups[0].id).toBe('other') + expect(groups[0].items).toEqual(['Update sidebar styling']) + }) + + it('falls back to a neutral placeholder when every commit is filtered or empty', () => { + const groups = buildCommitChangelog([{ summary: 'chore: bump' }, { summary: 'ci: stuff' }]) + + expect(groups).toEqual([{ id: 'other', items: ['Improvements and fixes'], label: 'In this update' }]) + }) + + it('dedupes identical subjects and caps the items per group', () => { + const groups = buildCommitChangelog( + [ + { summary: 'fix: thing A' }, + { summary: 'fix: thing A' }, + { summary: 'fix: thing B' }, + { summary: 'fix: thing C' }, + { summary: 'fix: thing D' }, + { summary: 'fix: thing E' } + ], + { maxPerGroup: 3, maxTotal: 10 } + ) + + expect(groups[0].items).toEqual(['Thing A', 'Thing B', 'Thing C']) + }) + + it('caps total entries across buckets', () => { + const groups = buildCommitChangelog( + [ + { summary: 'feat: a' }, + { summary: 'feat: b' }, + { summary: 'fix: c' }, + { summary: 'fix: d' }, + { summary: 'perf: e' } + ], + { maxTotal: 3 } + ) + + const totalItems = groups.reduce((sum, g) => sum + g.items.length, 0) + expect(totalItems).toBe(3) + }) +}) diff --git a/ui-desktop/src/lib/commit-changelog.ts b/ui-desktop/src/lib/commit-changelog.ts new file mode 100644 index 00000000..a6aa60be --- /dev/null +++ b/ui-desktop/src/lib/commit-changelog.ts @@ -0,0 +1,179 @@ +/** + * Tiny user-facing changelog builder. Takes a list of raw commit summaries, + * parses the Conventional Commits 1.0 header (`type(scope)!: subject`), + * filters internal noise (chore/ci/docs/...), and groups the rest into + * friendly buckets for end users (What's new, Fixed, Faster, Improved). + * + * Inlined (rather than depending on `conventional-commits-parser`) because + * that package's index re-exports a Node `stream` helper which won't load + * in the sandboxed Electron renderer, and its actual parse logic for the + * header is a small regex. + */ + +import { capitalize } from '@/lib/text' + +export type CommitGroupId = 'new' | 'fixed' | 'faster' | 'improved' | 'other' + +export interface CommitGroup { + id: CommitGroupId + label: string + items: string[] +} + +export interface ParsedCommit { + type: null | string + scope: null | string + breaking: boolean + subject: string +} + +export interface CommitChangelogInput { + summary?: string +} + +interface BuildOptions { + maxGroups?: number + maxPerGroup?: number + maxTotal?: number +} + +const GROUP_META: Record<CommitGroupId, { label: string; order: number }> = { + new: { label: "What's new", order: 0 }, + fixed: { label: 'Fixed', order: 1 }, + faster: { label: 'Faster', order: 2 }, + improved: { label: 'Improved', order: 3 }, + other: { label: 'Other improvements', order: 4 } +} + +const TYPE_TO_GROUP: Record<string, CommitGroupId> = { + feat: 'new', + feature: 'new', + fix: 'fixed', + bugfix: 'fixed', + hotfix: 'fixed', + revert: 'fixed', + perf: 'faster', + performance: 'faster', + refactor: 'improved', + a11y: 'improved', + ui: 'improved', + ux: 'improved' +} + +const HIDDEN_TYPES = new Set([ + 'build', + 'chore', + 'ci', + 'dep', + 'deps', + 'doc', + 'docs', + 'lint', + 'release', + 'style', + 'test', + 'tests', + 'wip' +]) + +const FALLBACK_GROUP: CommitGroup = { id: 'other', items: ['Improvements and fixes'], label: 'In this update' } + +const CONVENTIONAL_HEADER = /^(?<type>[a-zA-Z][a-zA-Z0-9_-]*)(?:\((?<scope>[^)]+)\))?(?<bang>!)?:\s+(?<subject>.+)$/ + +/** Parse a single commit header line per Conventional Commits 1.0. */ +export function parseCommitHeader(raw: string): ParsedCommit { + const header = (raw ?? '').split(/\r?\n/, 1)[0].trim() + + if (!header) { + return { breaking: false, scope: null, subject: '', type: null } + } + + const match = CONVENTIONAL_HEADER.exec(header) + + if (!match?.groups) { + return { breaking: false, scope: null, subject: header, type: null } + } + + return { + breaking: Boolean(match.groups.bang), + scope: match.groups.scope ?? null, + subject: match.groups.subject.trim(), + type: match.groups.type.toLowerCase() + } +} + +function tidySubject(subject: string): string { + const cleaned = subject + .replace(/\s+/g, ' ') + .replace(/[.;,\s]+$/, '') + .trim() + + if (!cleaned) { + return cleaned + } + + return capitalize(cleaned) +} + +/** + * Build a small grouped changelog from a list of raw commits. + * Always returns at least one group; falls back to a neutral placeholder + * when every commit was filtered or unparseable. + */ +export function buildCommitChangelog( + commits: readonly CommitChangelogInput[] | undefined, + options: BuildOptions = {} +): CommitGroup[] { + const { maxGroups = 3, maxPerGroup = 4, maxTotal = 6 } = options + const groups = new Map<CommitGroupId, string[]>() + const seen = new Set<string>() + let total = 0 + + for (const commit of commits ?? []) { + if (total >= maxTotal) { + break + } + + const parsed = parseCommitHeader(commit.summary ?? '') + + if (parsed.type && HIDDEN_TYPES.has(parsed.type)) { + continue + } + + const groupId: CommitGroupId = parsed.type ? (TYPE_TO_GROUP[parsed.type] ?? 'other') : 'other' + const subject = tidySubject(parsed.subject) + + if (!subject) { + continue + } + + const dedupeKey = subject.toLowerCase() + + if (seen.has(dedupeKey)) { + continue + } + + const bucket = groups.get(groupId) ?? [] + + if (bucket.length >= maxPerGroup) { + continue + } + + bucket.push(subject) + groups.set(groupId, bucket) + seen.add(dedupeKey) + total += 1 + } + + const result = Array.from(groups.entries()) + .map(([id, items]) => ({ id, items, label: GROUP_META[id].label, order: GROUP_META[id].order })) + .sort((a, b) => a.order - b.order) + .slice(0, maxGroups) + .map(({ id, items, label }): CommitGroup => ({ id, items, label })) + + if (result.length === 0) { + return [FALLBACK_GROUP] + } + + return result +} diff --git a/ui-desktop/src/lib/completion-sound.ts b/ui-desktop/src/lib/completion-sound.ts new file mode 100644 index 00000000..1557c58e --- /dev/null +++ b/ui-desktop/src/lib/completion-sound.ts @@ -0,0 +1,530 @@ +// Completion sound bank for agent turn-end cues. +// Fourteen curated presets for A/B in Settings → Appearance. Default is variant 1. + +import { ownsAmbientCue } from '@/store/ambient' +import { $completionSoundVariantId, resolveCompletionSoundVariantId } from '@/store/completion-sound' +import { $hapticsMuted } from '@/store/haptics' + +type OscType = OscillatorType + +let ctx: AudioContext | null = null + +function getCtx(): AudioContext | null { + if (typeof window === 'undefined') { + return null + } + + try { + if (!ctx) { + const Ctor = + window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + + if (!Ctor) { + return null + } + + ctx = new Ctor() + } + + // Autoplay policies can leave the context suspended until a gesture; a + // resume() here recovers it once the user has interacted with the window. + if (ctx.state === 'suspended') { + void ctx.resume().catch(() => undefined) + } + + return ctx + } catch { + return null + } +} + +// One enveloped oscillator voice → master. Linear attack into an exponential +// decay keeps the tail smooth and avoids the click you get ramping to zero. +function voice(ac: AudioContext, master: GainNode, t0: number, spec: ToneSpec) { + const osc = ac.createOscillator() + const env = ac.createGain() + const start = t0 + (spec.start ?? 0) + const peak = spec.gain ?? 0.5 + const attack = spec.attack ?? 0.006 + const end = start + spec.dur + + osc.type = spec.type ?? 'sine' + osc.frequency.setValueAtTime(spec.freq, start) + + env.gain.setValueAtTime(0.0001, start) + env.gain.exponentialRampToValueAtTime(Math.max(peak, 0.0002), start + attack) + env.gain.exponentialRampToValueAtTime(0.0001, end) + + osc.connect(env) + env.connect(master) + osc.start(start) + osc.stop(end + 0.02) +} + +// Soft pluck: brief triangle strike with an upward glide into the bloom. +function pluckVoice(ac: AudioContext, master: GainNode, t0: number, spec: PluckSpec) { + const osc = ac.createOscillator() + const env = ac.createGain() + const start = t0 + (spec.start ?? 0) + const attack = spec.attack ?? 0.004 + const glide = spec.glide ?? 0.16 + const end = start + spec.decay + + osc.type = 'triangle' + osc.frequency.setValueAtTime(spec.freqFrom, start) + osc.frequency.exponentialRampToValueAtTime(spec.freqTo, start + glide) + + env.gain.setValueAtTime(0.0001, start) + env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + attack) + env.gain.exponentialRampToValueAtTime(0.0001, end) + + osc.connect(env) + env.connect(master) + osc.start(start) + osc.stop(end + 0.02) +} + +// Slow-swell harmonic bloom — the dreamy tail after the pluck. +function bloomVoice(ac: AudioContext, master: GainNode, t0: number, spec: BloomSpec) { + const osc = ac.createOscillator() + const env = ac.createGain() + const start = t0 + (spec.start ?? 0) + const hold = spec.hold ?? 0.08 + const end = start + spec.attack + hold + spec.decay + + osc.type = spec.type ?? 'sine' + osc.frequency.setValueAtTime(spec.freq, start) + + if (spec.freqTo) { + osc.frequency.exponentialRampToValueAtTime(spec.freqTo, start + spec.attack + hold * 0.6) + } + + osc.detune.setValueAtTime(spec.detune ?? 0, start) + + env.gain.setValueAtTime(0.0001, start) + env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + spec.attack) + env.gain.setValueAtTime(Math.max(spec.gain, 0.0002), start + spec.attack + hold) + env.gain.exponentialRampToValueAtTime(0.0001, end) + + osc.connect(env) + env.connect(master) + osc.start(start) + osc.stop(end + 0.02) +} + +// One-shot white-noise source of a given length, the raw material for the +// bandpassed air/whoosh gestures below. +function noiseSource(ac: AudioContext, seconds: number): AudioBufferSourceNode { + const length = Math.floor(ac.sampleRate * seconds) + const buffer = ac.createBuffer(1, length, ac.sampleRate) + const data = buffer.getChannelData(0) + + for (let i = 0; i < length; i += 1) { + data[i] = Math.random() * 2 - 1 + } + + const source = ac.createBufferSource() + source.buffer = buffer + + return source +} + +// A whisper of bandpassed noise for PS5-menu airiness. +function airPuff(ac: AudioContext, master: GainNode, t0: number, spec: AirPuffSpec) { + const source = noiseSource(ac, 0.12) + const filter = ac.createBiquadFilter() + const env = ac.createGain() + const start = t0 + (spec.start ?? 0) + const end = start + spec.decay + + filter.type = 'bandpass' + filter.frequency.setValueAtTime(spec.freq, start) + filter.Q.setValueAtTime(spec.q ?? 1.2, start) + + env.gain.setValueAtTime(0.0001, start) + env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + 0.018) + env.gain.exponentialRampToValueAtTime(0.0001, end) + + source.connect(filter) + filter.connect(env) + env.connect(master) + source.start(start) + source.stop(end + 0.02) +} + +// Filtered noise sweep — soft send / whoosh gestures. +function whooshVoice(ac: AudioContext, master: GainNode, t0: number, spec: WhooshSpec) { + const source = noiseSource(ac, 0.4) + const filter = ac.createBiquadFilter() + const env = ac.createGain() + const start = t0 + (spec.start ?? 0) + const end = start + spec.decay + + filter.type = 'bandpass' + filter.frequency.setValueAtTime(spec.freqFrom, start) + filter.frequency.exponentialRampToValueAtTime(spec.freqTo, end) + filter.Q.setValueAtTime(spec.q ?? 0.8, start) + + env.gain.setValueAtTime(0.0001, start) + env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + 0.03) + env.gain.exponentialRampToValueAtTime(0.0001, end) + + source.connect(filter) + filter.connect(env) + env.connect(master) + source.start(start) + source.stop(end + 0.02) +} + +// Pitch-sweep chirp — modem / sci-fi gestures. +function sweepVoice(ac: AudioContext, master: GainNode, t0: number, spec: SweepSpec) { + const osc = ac.createOscillator() + const env = ac.createGain() + const start = t0 + (spec.start ?? 0) + const attack = spec.attack ?? 0.003 + const end = start + spec.decay + + osc.type = spec.type ?? 'triangle' + osc.frequency.setValueAtTime(spec.freqFrom, start) + osc.frequency.exponentialRampToValueAtTime(spec.freqTo, end - 0.02) + + env.gain.setValueAtTime(0.0001, start) + env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + attack) + env.gain.exponentialRampToValueAtTime(0.0001, end) + + osc.connect(env) + env.connect(master) + osc.start(start) + osc.stop(end + 0.02) +} + +let reverbImpulse: AudioBuffer | null = null + +// Subtle wet send so the chimes sit in a room rather than a tin can. The impulse +// is generated once and cached; each play gets a fresh, disposable convolver. +function makeReverb(ac: AudioContext): ConvolverNode { + if (!reverbImpulse) { + const seconds = 1.6 + const length = Math.floor(ac.sampleRate * seconds) + reverbImpulse = ac.createBuffer(2, length, ac.sampleRate) + + for (let channel = 0; channel < 2; channel += 1) { + const data = reverbImpulse.getChannelData(channel) + + for (let i = 0; i < length; i += 1) { + // White noise with a steep exponential decay → smooth, short tail. + data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 2.6 + } + } + } + + const convolver = ac.createConvolver() + convolver.buffer = reverbImpulse + + return convolver +} + +export interface CompletionSoundVariant { + id: number + name: string + // `master` is warm (runs through low-pass + room tail). + play: (ac: AudioContext, master: GainNode, t0: number) => void +} + +// Note frequencies (equal temperament). Everything lives in a low-mid register +// (C3–C5) so the chimes feel warm and "appy" rather than bright and arcade-y. +const A2 = 110 +const A3 = 220 +const A4 = 440 +const A5 = 880 +const B5 = 987.77 +const C3 = 130.81 +const C4 = 261.63 +const E4 = 329.63 +const E5 = 659.25 +const E6 = 1318.51 +const G4 = 392 +const G5 = 783.99 +const C5 = 523.25 +const C6 = 1046.5 + +export const COMPLETION_SOUND_VARIANTS: readonly CompletionSoundVariant[] = [ + { + id: 1, + name: 'Two-note comfort', + play: (ac, master, t0) => { + voice(ac, master, t0, { freq: E4, dur: 0.22, gain: 0.05, attack: 0.03, type: 'sine' }) + voice(ac, master, t0 + 0.08, { freq: C4, dur: 0.52, gain: 0.07, attack: 0.08, type: 'sine' }) + voice(ac, master, t0 + 0.08, { freq: C3, dur: 0.46, gain: 0.02, attack: 0.1, type: 'sine' }) + } + }, + { + id: 2, + name: 'Glass ping', + play: (ac, master, t0) => { + voice(ac, master, t0, { freq: C6, dur: 0.55, gain: 0.032, attack: 0.002, type: 'sine' }) + voice(ac, master, t0 + 0.01, { freq: E5, dur: 0.42, gain: 0.018, attack: 0.004, type: 'sine' }) + airPuff(ac, master, t0, { freq: 3200, gain: 0.004, decay: 0.1, q: 1.4 }) + } + }, + { + id: 3, + name: 'Soft marimba', + play: (ac, master, t0) => { + pluckVoice(ac, master, t0, { freqFrom: E5, freqTo: G5, gain: 0.03, decay: 0.14, glide: 0.08 }) + bloomVoice(ac, master, t0 + 0.04, { freq: C5, gain: 0.028, attack: 0.08, hold: 0.04, decay: 0.62 }) + bloomVoice(ac, master, t0 + 0.06, { freq: G4, gain: 0.014, attack: 0.12, hold: 0.06, decay: 0.55 }) + } + }, + { + id: 4, + name: 'Tri-tone message', + play: (ac, master, t0) => { + voice(ac, master, t0, { freq: C6, dur: 0.14, gain: 0.045, attack: 0.004, type: 'sine' }) + voice(ac, master, t0 + 0.1, { freq: A5, dur: 0.16, gain: 0.04, attack: 0.004, type: 'sine' }) + voice(ac, master, t0 + 0.2, { freq: G5, dur: 0.22, gain: 0.035, attack: 0.006, type: 'sine' }) + } + }, + { + id: 5, + name: 'Airy whoosh', + play: (ac, master, t0) => { + whooshVoice(ac, master, t0, { freqFrom: 4200, freqTo: 900, gain: 0.022, decay: 0.28, q: 0.7 }) + voice(ac, master, t0 + 0.12, { freq: A5, dur: 0.35, gain: 0.02, attack: 0.02, type: 'sine' }) + } + }, + { + id: 6, + name: 'Discovery cluster', + play: (ac, master, t0) => { + const clusterDetunes = [-14, -5, 0, 7, 12] + + clusterDetunes.forEach((detune, i) => { + bloomVoice(ac, master, t0 + i * 0.03, { + freq: A3, + gain: 0.012, + attack: 0.38, + hold: 0.12, + decay: 1.05, + detune + }) + }) + bloomVoice(ac, master, t0 + 0.1, { freq: E4, gain: 0.008, attack: 0.45, hold: 0.08, decay: 0.9, detune: 3 }) + } + }, + { + id: 7, + name: 'Systems online', + play: (ac, master, t0) => { + voice(ac, master, t0, { freq: C5, dur: 0.16, gain: 0.04, attack: 0.006, type: 'sine' }) + voice(ac, master, t0 + 0.09, { freq: G5, dur: 0.28, gain: 0.042, attack: 0.008, type: 'sine' }) + voice(ac, master, t0 + 0.09, { freq: C4, dur: 0.24, gain: 0.012, attack: 0.01, type: 'sine' }) + } + }, + { + id: 8, + name: 'IBM terminal', + play: (ac, master, t0) => { + voice(ac, master, t0, { freq: B5, dur: 0.12, gain: 0.038, attack: 0.002, type: 'square' }) + voice(ac, master, t0 + 0.14, { freq: E5, dur: 0.1, gain: 0.028, attack: 0.002, type: 'square' }) + } + }, + { + id: 9, + name: 'Modem chirp', + play: (ac, master, t0) => { + sweepVoice(ac, master, t0, { freqFrom: 320, freqTo: 2200, gain: 0.024, decay: 0.16, type: 'triangle' }) + sweepVoice(ac, master, t0 + 0.1, { freqFrom: 480, freqTo: 1400, gain: 0.014, decay: 0.12, type: 'sine' }) + } + }, + { + id: 10, + name: 'Wind chimes', + play: (ac, master, t0) => { + const chimes = [G5, C6, E5, A5] + + chimes.forEach((frequency, i) => { + voice(ac, master, t0 + i * 0.13, { + freq: frequency, + dur: 0.72, + gain: 0.028 - i * 0.003, + attack: 0.003, + type: 'sine' + }) + }) + } + }, + { + id: 11, + name: 'Singing bowl', + play: (ac, master, t0) => { + bloomVoice(ac, master, t0, { freq: A3, gain: 0.022, attack: 0.58, hold: 0.16, decay: 1.35 }) + bloomVoice(ac, master, t0 + 0.08, { freq: E4, gain: 0.01, attack: 0.62, hold: 0.12, decay: 1.2, detune: 4 }) + bloomVoice(ac, master, t0 + 0.14, { freq: A4, gain: 0.006, attack: 0.68, hold: 0.08, decay: 1.05, detune: -3 }) + } + }, + { + id: 12, + name: 'Harp lift', + play: (ac, master, t0) => { + const notes = [C5, E5, G5, C6] + + notes.forEach((frequency, i) => { + voice(ac, master, t0 + i * 0.075, { + freq: frequency, + dur: 0.38, + gain: 0.034 - i * 0.004, + attack: 0.012, + type: 'sine' + }) + }) + + bloomVoice(ac, master, t0 + 0.2, { freq: C4, gain: 0.01, attack: 0.18, hold: 0.06, decay: 0.7 }) + } + }, + { + id: 13, + name: 'Sonar ping', + play: (ac, master, t0) => { + voice(ac, master, t0, { freq: A2, dur: 0.95, gain: 0.036, attack: 0.008, type: 'sine' }) + voice(ac, master, t0 + 0.42, { freq: A3, dur: 0.55, gain: 0.014, attack: 0.01, type: 'sine' }) + airPuff(ac, master, t0, { freq: 600, gain: 0.005, decay: 0.2, q: 0.5 }) + } + }, + { + id: 14, + name: 'Music box', + play: (ac, master, t0) => { + const notes = [E6, C6, G5, E5] + + notes.forEach((frequency, i) => { + pluckVoice(ac, master, t0 + i * 0.09, { + freqFrom: frequency, + freqTo: frequency * 0.998, + gain: 0.02 - i * 0.002, + decay: 0.2, + glide: 0.06 + }) + }) + } + } +] as const + +function playVariant(variantId: number) { + const variant = COMPLETION_SOUND_VARIANTS.find(v => v.id === variantId) + + if (!variant) { + return + } + + const ac = getCtx() + + if (!ac) { + return + } + + // Signal path: voices → master → low-pass → (dry + reverb send) → out. + const master = ac.createGain() + const tone = ac.createBiquadFilter() + tone.type = 'lowpass' + tone.frequency.setValueAtTime(3800, ac.currentTime) + tone.Q.setValueAtTime(0.32, ac.currentTime) + master.gain.setValueAtTime(0.48, ac.currentTime) + master.connect(tone) + + const dry = ac.createGain() + dry.gain.setValueAtTime(0.88, ac.currentTime) + tone.connect(dry) + dry.connect(ac.destination) + + const reverb = makeReverb(ac) + const wet = ac.createGain() + wet.gain.setValueAtTime(0.34, ac.currentTime) + tone.connect(reverb) + reverb.connect(wet) + wet.connect(ac.destination) + + variant.play(ac, master, ac.currentTime + 0.01) +} + +// Audition the selected variant from settings. Bypasses the haptics mute toggle so +// sound design can be compared even when turn-end cues are silenced. +export function previewCompletionSound(variantId?: number) { + playVariant(resolveCompletionSoundVariantId(variantId ?? $completionSoundVariantId.get())) +} + +// Plays the selected completion cue on any `message.complete`. Pass a dedupeKey +// (the session id) so only one window beeps when several are open — the mute +// check runs first, so a muted window never claims the cue out from under an +// audible peer. +export function playCompletionSound(dedupeKey?: string) { + if ($hapticsMuted.get()) { + return + } + + const play = () => playVariant($completionSoundVariantId.get()) + + if (!dedupeKey) { + return play() + } + + void ownsAmbientCue(`sound:${dedupeKey}`).then(owns => owns && play()) +} + +interface AirPuffSpec { + decay: number + freq: number + gain: number + q?: number + start?: number +} + +interface BloomSpec { + attack: number + decay: number + detune?: number + freq: number + freqTo?: number + gain: number + hold?: number + start?: number + type?: OscType +} + +interface PluckSpec { + attack?: number + decay: number + freqFrom: number + freqTo: number + gain: number + glide?: number + start?: number +} + +interface SweepSpec { + attack?: number + decay: number + freqFrom: number + freqTo: number + gain: number + start?: number + type?: OscType +} + +interface ToneSpec { + attack?: number + dur: number + freq: number + gain?: number + start?: number + type?: OscType +} + +interface WhooshSpec { + decay: number + freqFrom: number + freqTo: number + gain: number + q?: number + start?: number +} diff --git a/ui-desktop/src/lib/composer-input-sanitize.test.ts b/ui-desktop/src/lib/composer-input-sanitize.test.ts new file mode 100644 index 00000000..2695f942 --- /dev/null +++ b/ui-desktop/src/lib/composer-input-sanitize.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' + +import { + collapseRepeatedInputArtifacts, + sanitizeComposerInput, + stripLeakedBracketedPasteWrappers +} from './composer-input-sanitize' + +describe('stripLeakedBracketedPasteWrappers', () => { + it('leaves plain text unchanged', () => { + expect(stripLeakedBracketedPasteWrappers('hello world')).toBe('hello world') + }) + + it('strips canonical escape wrappers', () => { + expect(stripLeakedBracketedPasteWrappers('\x1b[200~hello\x1b[201~')).toBe('hello') + }) + + it('keeps embedded literal bracket forms', () => { + const text = 'literal[200~tag and literal[201~tag should stay' + expect(stripLeakedBracketedPasteWrappers(text)).toBe(text) + }) +}) + +describe('collapseRepeatedInputArtifacts', () => { + it('removes the desktop corruption tail from #62557', () => { + const prefix = '需要时随时叫我。' + const tail = '[e~[[e' + '~[[e'.repeat(20) + expect(collapseRepeatedInputArtifacts(prefix + tail)).toBe(prefix) + }) + + it('preserves a mid-string marker followed by valid suffix', () => { + const text = 'notes ~[[e more text here' + expect(collapseRepeatedInputArtifacts(text)).toBe(text) + }) + + it('preserves trailing punctuation that is not the corruption signature', () => { + expect(collapseRepeatedInputArtifacts('wait....')).toBe('wait....') + }) + + it('does not strip when fewer than minRepeats markers appear at the tail', () => { + const text = 'hello~[[e~[[e' + expect(collapseRepeatedInputArtifacts(text)).toBe(text) + }) +}) + +describe('sanitizeComposerInput', () => { + it('normalizes wrappers and repeated artifact tails together', () => { + const corrupted = 'hello[' + '~[[e'.repeat(8) + expect(sanitizeComposerInput(corrupted)).toBe('hello') + }) +}) diff --git a/ui-desktop/src/lib/composer-input-sanitize.ts b/ui-desktop/src/lib/composer-input-sanitize.ts new file mode 100644 index 00000000..03175efa --- /dev/null +++ b/ui-desktop/src/lib/composer-input-sanitize.ts @@ -0,0 +1,74 @@ +/** + * Strip terminal bracketed-paste leaks and repeated artifact tails from composer + * text before it is shown in the UI or sent to the gateway. + * + * Mirrors clawcodex_cli/input_sanitize.py (CLI/TUI gateway defensive path). + */ + +const BRACKETED_PASTE_BOUNDARY_START = /(^|[\s\n>:\])])\[200~/g +const BRACKETED_PASTE_BOUNDARY_END = /\[201~(?=$|[\s\n<[():;.,!?])/g +const BRACKETED_PASTE_DEGRADED_START = /(^|[\s\n>:\])])00~/g +const BRACKETED_PASTE_DEGRADED_END = /01~(?=$|[\s\n<[():;.,!?])/g + +const DESKTOP_PASTE_ARTIFACT = '~[[e' + +/** Strip leaked bracketed-paste wrapper markers from user-visible text. */ +export function stripLeakedBracketedPasteWrappers(text: string): string { + if (!text) { + return text + } + + let cleaned = text + // eslint-disable-next-line no-control-regex -- terminal data may contain control chars + .replace(/\x1b\[200~/g, '') + // eslint-disable-next-line no-control-regex -- terminal data may contain control chars + .replace(/\x1b\[201~/g, '') + .replace(/\^\[\[200~/g, '') + .replace(/\^\[\[201~/g, '') + + cleaned = cleaned.replace(BRACKETED_PASTE_BOUNDARY_START, '$1') + cleaned = cleaned.replace(BRACKETED_PASTE_BOUNDARY_END, '') + cleaned = cleaned.replace(BRACKETED_PASTE_DEGRADED_START, '$1') + cleaned = cleaned.replace(BRACKETED_PASTE_DEGRADED_END, '') + + return cleaned +} + +/** Drop a trailing run of the desktop ~[[e corruption signature (#62557). */ +export function collapseRepeatedInputArtifacts(text: string, minRepeats = 4): string { + if (!text) { + return text + } + + const marker = DESKTOP_PASTE_ARTIFACT + let index = text.length + let repeatCount = 0 + + while (index >= marker.length && text.slice(index - marker.length, index) === marker) { + repeatCount += 1 + index -= marker.length + } + + if (repeatCount < minRepeats) { + return text + } + + let start = index + + if (start >= 2 && text.slice(start - 2, start) === '[e') { + start -= 2 + } else if (start >= 1 && text[start - 1] === '[') { + start -= 1 + } + + return text.slice(0, start) +} + +/** Normalize composer text before submit or draft persistence. */ +export function sanitizeComposerInput(text: string): string { + if (!text) { + return text + } + + return collapseRepeatedInputArtifacts(stripLeakedBracketedPasteWrappers(text)) +} diff --git a/ui-desktop/src/lib/desktop-fs.test.ts b/ui-desktop/src/lib/desktop-fs.test.ts new file mode 100644 index 00000000..0f9b2232 --- /dev/null +++ b/ui-desktop/src/lib/desktop-fs.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $connection } from '@/store/session' + +import { + desktopDefaultCwd, + desktopFileDiff, + desktopFsCacheKey, + desktopGitRoot, + readDesktopDir, + readDesktopFileDataUrl, + readDesktopFileText, + selectDesktopPaths, + setDesktopFsRemotePicker +} from './desktop-fs' + +const readDir = vi.fn(async () => ({ entries: [{ name: 'local', path: '/local', isDirectory: true }] })) +const readFileText = vi.fn(async () => ({ path: '/local/file.txt', text: 'local', byteSize: 5 })) +const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,bG9jYWw=') +const gitRoot = vi.fn(async () => '/local') +const selectPaths = vi.fn(async () => ['/local']) + +const api = vi.fn(async ({ path }: { path: string }) => { + if (path.startsWith('/api/fs/list?')) { + return { entries: [{ name: 'remote', path: '/remote', isDirectory: true }] } + } + + if (path.startsWith('/api/fs/read-text?')) { + return { path: '/remote/file.txt', text: 'remote', byteSize: 6 } + } + + if (path.startsWith('/api/fs/read-data-url?')) { + return { dataUrl: 'data:text/plain;base64,cmVtb3Rl' } + } + + if (path.startsWith('/api/fs/git-root?')) { + return { root: '/remote' } + } + + if (path === '/api/fs/default-cwd') { + return { cwd: '/backend/project', branch: 'main' } + } + + if (path.startsWith('/api/git/file-diff?')) { + return { diff: 'remote diff' } + } + + throw new Error(`unexpected path ${path}`) +}) + +function stubBridge() { + vi.stubGlobal('window', { + clawcodexDesktop: { + api, + gitRoot, + readDir, + readFileDataUrl, + readFileText, + selectPaths + } + }) +} + +describe('desktop filesystem facade', () => { + beforeEach(() => { + stubBridge() + $connection.set(null) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + $connection.set(null) + setDesktopFsRemotePicker(null) + }) + + it('uses local Electron filesystem methods in local mode', async () => { + $connection.set({ mode: 'local' } as never) + + await expect(readDesktopDir('/work')).resolves.toEqual({ + entries: [{ name: 'local', path: '/local', isDirectory: true }] + }) + await expect(readDesktopFileText('/work/file.txt')).resolves.toMatchObject({ text: 'local' }) + await expect(readDesktopFileDataUrl('/work/file.txt')).resolves.toBe('data:text/plain;base64,bG9jYWw=') + await expect(desktopGitRoot('/work')).resolves.toBe('/local') + await expect(selectDesktopPaths({ directories: true })).resolves.toEqual(['/local']) + + expect(readDir).toHaveBeenCalledWith('/work') + expect(readFileText).toHaveBeenCalledWith('/work/file.txt') + expect(readFileDataUrl).toHaveBeenCalledWith('/work/file.txt') + expect(gitRoot).toHaveBeenCalledWith('/work') + expect(selectPaths).toHaveBeenCalledWith({ directories: true }) + expect(api).not.toHaveBeenCalled() + }) + + it('routes filesystem reads through authenticated backend REST in remote mode', async () => { + $connection.set({ mode: 'remote' } as never) + + await expect(readDesktopDir('/home/user/project')).resolves.toMatchObject({ entries: [{ name: 'remote' }] }) + await expect(readDesktopFileText('/home/user/project/a b.txt')).resolves.toMatchObject({ text: 'remote' }) + await expect(readDesktopFileDataUrl('/home/user/project/a b.txt')).resolves.toBe('data:text/plain;base64,cmVtb3Rl') + await expect(desktopGitRoot('/home/user/project')).resolves.toBe('/remote') + await expect(desktopDefaultCwd()).resolves.toEqual({ cwd: '/backend/project', branch: 'main' }) + + expect(api).toHaveBeenCalledWith({ path: '/api/fs/list?path=%2Fhome%2Fuser%2Fproject' }) + expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-text?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' }) + expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-data-url?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' }) + expect(api).toHaveBeenCalledWith({ path: '/api/fs/git-root?path=%2Fhome%2Fuser%2Fproject' }) + expect(api).toHaveBeenCalledWith({ path: '/api/fs/default-cwd' }) + expect(readDir).not.toHaveBeenCalled() + expect(readFileText).not.toHaveBeenCalled() + expect(readFileDataUrl).not.toHaveBeenCalled() + expect(gitRoot).not.toHaveBeenCalled() + }) + + it('targets the active profile backend so a remote profile never reads local disk', async () => { + $connection.set({ mode: 'remote', profile: 'remote-docker' } as never) + + await readDesktopDir('/srv/project') + await desktopDefaultCwd() + + expect(api).toHaveBeenCalledWith({ path: '/api/fs/list?path=%2Fsrv%2Fproject', profile: 'remote-docker' }) + expect(api).toHaveBeenCalledWith({ path: '/api/fs/default-cwd', profile: 'remote-docker' }) + }) + + it('keys SSH filesystem caches by stable host identity instead of the forwarded port', () => { + $connection.set({ + mode: 'remote', + remoteKind: 'ssh', + remoteHost: 'operator@remote-box', + baseUrl: 'http://127.0.0.1:41001' + } as never) + const first = desktopFsCacheKey() + + $connection.set({ + mode: 'remote', + remoteKind: 'ssh', + remoteHost: 'operator@remote-box', + baseUrl: 'http://127.0.0.1:52002' + } as never) + + expect(desktopFsCacheKey()).toBe(first) + expect(first).toContain('operator@remote-box') + expect(first).not.toContain('41001') + }) + + it('separates SSH filesystem caches by ownership and profile', () => { + $connection.set({ + mode: 'remote', + remoteKind: 'ssh', + remoteHost: 'host-a', + remoteIdentity: 'owner-a', + profile: 'one' + } as never) + const first = desktopFsCacheKey() + $connection.set({ + mode: 'remote', + remoteKind: 'ssh', + remoteHost: 'host-a', + remoteIdentity: 'owner-b', + profile: 'one' + } as never) + const otherOwner = desktopFsCacheKey() + $connection.set({ + mode: 'remote', + remoteKind: 'ssh', + remoteHost: 'host-a', + remoteIdentity: 'owner-a', + profile: 'two' + } as never) + + expect(otherOwner).not.toBe(first) + expect(desktopFsCacheKey()).not.toBe(first) + }) + + it('routes file diffs through backend git in remote mode', async () => { + $connection.set({ mode: 'remote' } as never) + + await expect(desktopFileDiff('/repo', 'src/a b.ts')).resolves.toBe('remote diff') + expect(api).toHaveBeenCalledWith({ path: '/api/git/file-diff?path=%2Frepo&file=src%2Fa%20b.ts' }) + }) + + it('uses the registered in-app directory picker in remote mode', async () => { + const remoteSelect = vi.fn(async () => ['/remote/project']) + $connection.set({ mode: 'remote' } as never) + setDesktopFsRemotePicker({ selectPaths: remoteSelect }) + + await expect(selectDesktopPaths({ defaultPath: '/remote', directories: true, multiple: false })).resolves.toEqual([ + '/remote/project' + ]) + + expect(remoteSelect).toHaveBeenCalledWith({ defaultPath: '/remote', directories: true, multiple: false }) + expect(selectPaths).not.toHaveBeenCalled() + }) + + it('uses the local Electron picker for remote file selection', async () => { + const remoteSelect = vi.fn(async () => ['/remote/project']) + $connection.set({ mode: 'remote' } as never) + setDesktopFsRemotePicker({ selectPaths: remoteSelect }) + + await expect(selectDesktopPaths({ directories: false, multiple: false })).resolves.toEqual(['/local']) + + expect(selectPaths).toHaveBeenCalledWith({ directories: false, multiple: false }) + expect(remoteSelect).not.toHaveBeenCalled() + }) + + it('limits the remote picker to single-directory selection', async () => { + const remoteSelect = vi.fn(async () => ['/remote/project']) + $connection.set({ mode: 'remote' } as never) + setDesktopFsRemotePicker({ selectPaths: remoteSelect }) + + await expect(selectDesktopPaths({ directories: true })).resolves.toEqual(['/remote/project']) + + expect(remoteSelect).toHaveBeenCalledWith({ directories: true, multiple: false }) + expect(selectPaths).not.toHaveBeenCalled() + }) +}) diff --git a/ui-desktop/src/lib/desktop-fs.ts b/ui-desktop/src/lib/desktop-fs.ts new file mode 100644 index 00000000..5ca205dc --- /dev/null +++ b/ui-desktop/src/lib/desktop-fs.ts @@ -0,0 +1,191 @@ +import type { + ClawCodexConnection, + ClawCodexReadDirResult, + ClawCodexReadFileTextResult, + ClawCodexSelectPathsOptions +} from '@/global' +import { $connection } from '@/store/session' + +export interface DesktopFsRemotePicker { + selectPaths: (options?: ClawCodexSelectPathsOptions) => Promise<string[]> +} + +let remotePicker: DesktopFsRemotePicker | null = null + +export function setDesktopFsRemotePicker(next: DesktopFsRemotePicker | null) { + remotePicker = next +} + +function connectionCacheKey(connection: ClawCodexConnection | null) { + if (!connection) { + return 'local:' + } + + const target = + connection.remoteKind === 'ssh' + ? connection.remoteIdentity || connection.remoteHost || '' + : connection.baseUrl || '' + + return `${connection.mode || 'local'}:${connection.remoteKind || ''}:${connection.profile || ''}:${target}` +} + +export function desktopFsCacheKey(connection: ClawCodexConnection | null = $connection.get()) { + return connectionCacheKey(connection) +} + +export function isDesktopFsRemoteMode() { + return $connection.get()?.mode === 'remote' +} + +// Active profile for FS/git REST calls. Without it the Electron api bridge +// hits the primary (local) backend even when the user switched to a remote profile. +export function desktopFsProfile(): string | undefined { + return $connection.get()?.profile || undefined +} + +function fsPath(endpoint: string, filePath: string) { + return `/api/fs/${endpoint}?path=${encodeURIComponent(filePath)}` +} + +function bridge() { + const desktop = window.clawcodexDesktop + + if (!desktop) { + throw new Error('ClawCodex Desktop bridge is unavailable') + } + + return desktop +} + +function remoteFsApi<T>(path: string, body?: Record<string, unknown>): Promise<T> { + return bridge().api<T>( + body ? { body, method: 'POST', path, profile: desktopFsProfile() } : { path, profile: desktopFsProfile() } + ) +} + +export async function readDesktopDir(path: string): Promise<ClawCodexReadDirResult> { + if (!isDesktopFsRemoteMode()) { + return bridge().readDir(path) + } + + return remoteFsApi<ClawCodexReadDirResult>(fsPath('list', path)) +} + +export async function readDesktopFileText(path: string): Promise<ClawCodexReadFileTextResult> { + if (!isDesktopFsRemoteMode()) { + return bridge().readFileText(path) + } + + return remoteFsApi<ClawCodexReadFileTextResult>(fsPath('read-text', path)) +} + +// Save UTF-8 text back to a file. Local writes go through the hardened Electron +// IPC; remote writes hit the dashboard's POST /api/fs/write-text (same path +// hardening, parent-must-exist, size cap) so the editor behaves identically in +// both modes. Stale-on-disk detection is the caller's job (re-read before save). +export async function writeDesktopFileText(path: string, content: string): Promise<{ path: string }> { + const desktop = bridge() + + if (!isDesktopFsRemoteMode()) { + if (!desktop.writeTextFile) { + throw new Error('Saving is not available') + } + + return desktop.writeTextFile(path, content) + } + + const result = await remoteFsApi<{ ok?: boolean; path?: string }>('/api/fs/write-text', { content, path }) + + return { path: result.path || path } +} + +export async function readDesktopFileDataUrl(path: string): Promise<string> { + if (!isDesktopFsRemoteMode()) { + return bridge().readFileDataUrl(path) + } + + const result = await remoteFsApi<string | { dataUrl?: string }>(fsPath('read-data-url', path)) + + return typeof result === 'string' ? result : result.dataUrl || '' +} + +export async function desktopGitRoot(path: string): Promise<string | null> { + const desktop = bridge() + + if (!isDesktopFsRemoteMode()) { + return desktop.gitRoot ? desktop.gitRoot(path) : null + } + + return (await remoteFsApi<{ root: string | null }>(fsPath('git-root', path))).root +} + +export async function desktopDefaultCwd(): Promise<{ branch: string; cwd: string } | null> { + if (!isDesktopFsRemoteMode()) { + return null + } + + return remoteFsApi<{ branch: string; cwd: string }>('/api/fs/default-cwd') +} + +// Reveal a path in the OS file manager (Finder / Explorer / Files). Local only. +export async function revealDesktopPath(path: string): Promise<void> { + await bridge().revealPath?.(path) +} + +// Rename a file/folder in place; returns the new absolute path. Local only. +export async function renameDesktopPath(path: string, newName: string): Promise<string> { + const desktop = bridge() + + if (!desktop.renamePath) { + throw new Error('Rename is not available') + } + + const result = await desktop.renamePath(path, newName) + + return result.path +} + +// Move a file/folder to the OS trash (recoverable). Local only. +export async function trashDesktopPath(path: string): Promise<void> { + const desktop = bridge() + + if (!desktop.trashPath) { + throw new Error('Delete is not available') + } + + await desktop.trashPath(path) +} + +export async function copyTextToClipboard(text: string): Promise<void> { + await bridge().writeClipboard(text) +} + +// Working-tree-vs-HEAD diff for one file. Empty when unchanged / not a repo. +// Remote gateway → backend git (/api/git/file-diff); local → Electron git. +export async function desktopFileDiff(repoRoot: string, filePath: string): Promise<string> { + if (isDesktopFsRemoteMode()) { + const result = await remoteFsApi<{ diff: string }>( + `/api/git/file-diff?path=${encodeURIComponent(repoRoot)}&file=${encodeURIComponent(filePath)}` + ) + + return result.diff || '' + } + + const git = bridge().git + + return git?.fileDiff ? git.fileDiff(repoRoot, filePath) : '' +} + +export async function selectDesktopPaths(options?: ClawCodexSelectPathsOptions): Promise<string[]> { + const desktop = bridge() + + if (!isDesktopFsRemoteMode()) { + return desktop.selectPaths(options) + } + + if (!options?.directories) { + return desktop.selectPaths(options) + } + + return remotePicker ? remotePicker.selectPaths({ ...options, multiple: false }) : [] +} diff --git a/ui-desktop/src/lib/desktop-git.test.ts b/ui-desktop/src/lib/desktop-git.test.ts new file mode 100644 index 00000000..3f9823c4 --- /dev/null +++ b/ui-desktop/src/lib/desktop-git.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $connection } from '@/store/session' + +import { desktopGit } from './desktop-git' + +const repoStatus = vi.fn(async () => ({ branch: 'main' })) +const worktreeList = vi.fn(async () => [{ branch: 'main', detached: false, isMain: true, locked: false, path: '/r' }]) +const localGit = { repoStatus, review: { stage: vi.fn() }, worktreeList } + +const api = vi.fn(async ({ path }: { path: string }) => { + if (path.startsWith('/api/git/status')) { + return { branch: 'remote-main' } + } + + if (path.startsWith('/api/git/worktrees')) { + return { worktrees: [{ branch: 'main', detached: false, isMain: true, locked: false, path: '/srv/r' }] } + } + + if (path.startsWith('/api/git/review/diff')) { + return { diff: 'remote-diff' } + } + + return { ok: true } +}) + +describe('desktop git facade', () => { + beforeEach(() => { + vi.stubGlobal('window', { clawcodexDesktop: { api, git: localGit } }) + $connection.set(null) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + $connection.set(null) + }) + + it('returns undefined after the renderer global is torn down', () => { + vi.stubGlobal('window', undefined) + + expect(desktopGit()).toBeUndefined() + }) + + it('uses Electron git locally', async () => { + $connection.set({ mode: 'local' } as never) + + await expect(desktopGit()?.repoStatus('/work')).resolves.toEqual({ branch: 'main' }) + expect(repoStatus).toHaveBeenCalledWith('/work') + expect(api).not.toHaveBeenCalled() + }) + + it('routes reads through the backend REST mirror on a remote gateway', async () => { + $connection.set({ mode: 'remote' } as never) + + await expect(desktopGit()?.repoStatus('/srv/work')).resolves.toEqual({ branch: 'remote-main' }) + expect(api).toHaveBeenCalledWith({ path: '/api/git/status?path=%2Fsrv%2Fwork' }) + + // List endpoints unwrap their envelope to the bare array the bridge returns. + await expect(desktopGit()?.worktreeList('/srv/work')).resolves.toEqual([ + { branch: 'main', detached: false, isMain: true, locked: false, path: '/srv/r' } + ]) + + // review.diff unwraps { diff } to a string. + await expect(desktopGit()?.review.diff('/srv/work', 'a.txt', 'uncommitted', null, false)).resolves.toBe( + 'remote-diff' + ) + + expect(repoStatus).not.toHaveBeenCalled() + }) + + it('targets the active profile backend so a remote profile never touches the local repo', async () => { + $connection.set({ mode: 'remote', profile: 'remote-docker' } as never) + + await desktopGit()?.repoStatus('/srv/work') + await desktopGit()?.review.stage('/srv/work', 'a.txt') + + expect(api).toHaveBeenCalledWith({ path: '/api/git/status?path=%2Fsrv%2Fwork', profile: 'remote-docker' }) + expect(api).toHaveBeenCalledWith({ + body: { file: 'a.txt', path: '/srv/work' }, + method: 'POST', + path: '/api/git/review/stage', + profile: 'remote-docker' + }) + }) + + it('sends mutations as POST bodies on a remote gateway', async () => { + $connection.set({ mode: 'remote' } as never) + + await desktopGit()?.review.stage('/srv/work', 'a.txt') + + expect(api).toHaveBeenCalledWith({ + body: { file: 'a.txt', path: '/srv/work' }, + method: 'POST', + path: '/api/git/review/stage' + }) + expect(localGit.review.stage).not.toHaveBeenCalled() + }) +}) diff --git a/ui-desktop/src/lib/desktop-git.ts b/ui-desktop/src/lib/desktop-git.ts new file mode 100644 index 00000000..f1383cf5 --- /dev/null +++ b/ui-desktop/src/lib/desktop-git.ts @@ -0,0 +1,109 @@ +import type { + ClawCodexGitBaseBranch, + ClawCodexGitBranch, + ClawCodexGitWorktree, + ClawCodexRepoStatus, + ClawCodexReviewList, + ClawCodexReviewShipInfo +} from '@/global' + +import { desktopFsProfile, isDesktopFsRemoteMode } from './desktop-fs' + +// Remote-aware git facade. Locally the desktop runs git through Electron +// (window.clawcodexDesktop.git); on a remote gateway that's the wrong filesystem, +// so we mirror the same surface over the dashboard REST API (/api/git/*) — the +// coding rail, worktree lanes, review pane, and branch ops then act on the +// BACKEND repo where sessions actually run. Mirrors desktop-fs.ts. + +type GitBridge = NonNullable<NonNullable<Window['clawcodexDesktop']>['git']> + +function desktopApi<T>(path: string, body?: Record<string, unknown>): Promise<T> { + const desktop = window.clawcodexDesktop + + if (!desktop) { + throw new Error('ClawCodex Desktop bridge is unavailable') + } + + return desktop.api<T>( + body ? { body, method: 'POST', path, profile: desktopFsProfile() } : { path, profile: desktopFsProfile() } + ) +} + +function gitGet<T>(route: string, params: Record<string, boolean | null | string | undefined>): Promise<T> { + const query = new URLSearchParams() + + for (const [key, value] of Object.entries(params)) { + if (value !== null && value !== undefined) { + query.set(key, String(value)) + } + } + + return desktopApi<T>(`/api/git/${route}?${query.toString()}`) +} + +function gitPost<T>(route: string, body: Record<string, unknown>): Promise<T> { + return desktopApi<T>(`/api/git/${route}`, body) +} + +const remoteGit: GitBridge = { + worktreeList: async repoPath => + (await gitGet<{ worktrees: ClawCodexGitWorktree[] }>('worktrees', { path: repoPath })).worktrees, + + worktreeAdd: (repoPath, options) => gitPost('worktree/add', { path: repoPath, ...options }), + + worktreeRemove: (repoPath, worktreePath, options) => + gitPost('worktree/remove', { force: options?.force ?? false, path: repoPath, worktreePath }), + + branchSwitch: (repoPath, branch) => gitPost('branch/switch', { branch, path: repoPath }), + + branchList: async repoPath => + (await gitGet<{ branches: ClawCodexGitBranch[] }>('branches', { path: repoPath })).branches, + + baseBranchList: async repoPath => + (await gitGet<{ branches: ClawCodexGitBaseBranch[] }>('base-branches', { path: repoPath })).branches, + + repoStatus: repoPath => gitGet<ClawCodexRepoStatus | null>('status', { path: repoPath }), + + fileDiff: async (repoPath, filePath) => + (await gitGet<{ diff: string }>('file-diff', { file: filePath, path: repoPath })).diff, + + review: { + list: (repoPath, scope, baseRef) => + gitGet<ClawCodexReviewList>('review/list', { base: baseRef, path: repoPath, scope }), + + diff: async (repoPath, filePath, scope, baseRef, staged) => + (await gitGet<{ diff: string }>('review/diff', { base: baseRef, file: filePath, path: repoPath, scope, staged })) + .diff, + + stage: (repoPath, filePath) => gitPost('review/stage', { file: filePath ?? null, path: repoPath }), + + unstage: (repoPath, filePath) => gitPost('review/unstage', { file: filePath ?? null, path: repoPath }), + + revert: (repoPath, filePath) => gitPost('review/revert', { file: filePath ?? null, path: repoPath }), + + revParse: async (repoPath, ref) => + (await gitGet<{ sha: null | string }>('review/rev-parse', { path: repoPath, ref })).sha, + + commit: (repoPath, message, push) => gitPost('review/commit', { message, path: repoPath, push }), + + commitContext: repoPath => gitGet('review/commit-context', { path: repoPath }), + + push: repoPath => gitPost('review/push', { path: repoPath }), + + shipInfo: repoPath => gitGet<ClawCodexReviewShipInfo>('review/ship-info', { path: repoPath }), + + createPr: repoPath => gitPost('review/create-pr', { path: repoPath }) + }, + + // Repo discovery is a local-disk crawl; on a remote gateway the backend + // already merges session-derived repos, so this is a no-op. + scanRepos: async () => [] +} + +export function desktopGit(): GitBridge | undefined { + if (typeof window === 'undefined') { + return undefined + } + + return isDesktopFsRemoteMode() ? remoteGit : window.clawcodexDesktop?.git +} diff --git a/ui-desktop/src/lib/desktop-remote-auth.test.ts b/ui-desktop/src/lib/desktop-remote-auth.test.ts new file mode 100644 index 00000000..918f7e5f --- /dev/null +++ b/ui-desktop/src/lib/desktop-remote-auth.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' + +import { deriveRemoteAuthProviderShape } from './desktop-remote-auth' + +describe('deriveRemoteAuthProviderShape', () => { + it('uses fallback copy when the gateway has not reported providers', () => { + expect(deriveRemoteAuthProviderShape(null)).toEqual({ + isPassword: false, + providerLabel: 'your identity provider' + }) + expect(deriveRemoteAuthProviderShape([], 'the configured gateway')).toEqual({ + isPassword: false, + providerLabel: 'the configured gateway' + }) + }) + + it('marks providers as password-style only when every provider supports password login', () => { + expect( + deriveRemoteAuthProviderShape([{ name: 'basic', displayName: 'Username & Password', supportsPassword: true }]) + ).toEqual({ + isPassword: true, + providerLabel: 'Username & Password' + }) + }) + + it('keeps OAuth copy for redirect providers and mixed deployments', () => { + expect( + deriveRemoteAuthProviderShape([{ name: 'nous', displayName: 'ClawCodex contributors', supportsPassword: false }]) + ).toEqual({ + isPassword: false, + providerLabel: 'ClawCodex contributors' + }) + + expect( + deriveRemoteAuthProviderShape([ + { name: 'basic', displayName: 'Username & Password', supportsPassword: true }, + { name: 'nous', displayName: 'ClawCodex contributors', supportsPassword: false } + ]) + ).toEqual({ + isPassword: false, + providerLabel: 'Username & Password / ClawCodex contributors' + }) + }) + + it('falls back to provider names when display names are missing', () => { + expect(deriveRemoteAuthProviderShape([{ name: 'basic', displayName: '', supportsPassword: true }])).toEqual({ + isPassword: true, + providerLabel: 'basic' + }) + }) +}) diff --git a/ui-desktop/src/lib/desktop-remote-auth.ts b/ui-desktop/src/lib/desktop-remote-auth.ts new file mode 100644 index 00000000..5616c81e --- /dev/null +++ b/ui-desktop/src/lib/desktop-remote-auth.ts @@ -0,0 +1,26 @@ +import type { DesktopAuthProvider } from '@/global' + +export interface RemoteAuthProviderShape { + isPassword: boolean + providerLabel: string +} + +function providerDisplayName(provider: DesktopAuthProvider): string { + return provider.displayName || provider.name +} + +export function deriveRemoteAuthProviderShape( + providers: DesktopAuthProvider[] | null | undefined, + fallback = 'your identity provider' +): RemoteAuthProviderShape { + const list = providers ?? [] + + if (list.length === 0) { + return { isPassword: false, providerLabel: fallback } + } + + return { + isPassword: list.every(provider => Boolean(provider.supportsPassword)), + providerLabel: list.map(providerDisplayName).join(' / ') + } +} diff --git a/ui-desktop/src/lib/desktop-slash-commands.test.ts b/ui-desktop/src/lib/desktop-slash-commands.test.ts new file mode 100644 index 00000000..a3876087 --- /dev/null +++ b/ui-desktop/src/lib/desktop-slash-commands.test.ts @@ -0,0 +1,356 @@ +import { describe, expect, it } from 'vitest' + +import { + desktopSkinSlashCompletions, + desktopSlashCommandArgumentMode, + desktopSlashDescription, + desktopSlashUnavailableMessage, + filterDesktopCommandsCatalog, + isDesktopSlashCommand, + isDesktopSlashSuggestion, + isModelPickerCommand, + isPickerCommand, + rankSkillCommands, + resolveDesktopCommand +} from './desktop-slash-commands' + +describe('desktop slash command curation', () => { + it('keeps core desktop chat commands in suggestions', () => { + expect(isDesktopSlashSuggestion('/new')).toBe(true) + expect(isDesktopSlashSuggestion('/branch')).toBe(true) + expect(isDesktopSlashSuggestion('/skin')).toBe(true) + expect(isDesktopSlashSuggestion('/usage')).toBe(true) + expect(isDesktopSlashSuggestion('/version')).toBe(true) + expect(isDesktopSlashSuggestion('/yolo')).toBe(true) + expect(isDesktopSlashCommand('/yolo')).toBe(true) + expect(isDesktopSlashSuggestion('/approvals')).toBe(true) + expect(isDesktopSlashCommand('/approvals')).toBe(true) + expect(resolveDesktopCommand('/approvals')?.surface).toEqual({ kind: 'exec' }) + }) + + it('surfaces skill and quick commands (extensions) in suggestions and lets them run', () => { + expect(isDesktopSlashSuggestion('/my-skill')).toBe(true) + expect(isDesktopSlashSuggestion('/gif-search')).toBe(true) + expect(isDesktopSlashCommand('/my-skill')).toBe(true) + }) + + it('hides terminal, messaging, and dedicated-UI commands from suggestions', () => { + expect(isDesktopSlashSuggestion('/clear')).toBe(false) + expect(isDesktopSlashSuggestion('/density')).toBe(false) + expect(isDesktopSlashSuggestion('/redraw')).toBe(false) + expect(isDesktopSlashSuggestion('/approve')).toBe(false) + expect(isDesktopSlashSuggestion('/model')).toBe(false) + expect(isDesktopSlashSuggestion('/skills')).toBe(false) + expect(isDesktopSlashSuggestion('/voice')).toBe(false) + expect(isDesktopSlashSuggestion('/curator')).toBe(false) + }) + + it('routes /compact to /compress (context compression), not the TUI display toggle', () => { + expect(resolveDesktopCommand('/compact')?.name).toBe('/compress') + expect(isDesktopSlashCommand('/compact')).toBe(true) + // Alias stays out of the popover so /compress is the single visible entry. + expect(isDesktopSlashSuggestion('/compact')).toBe(false) + expect(isDesktopSlashSuggestion('/compress')).toBe(true) + }) + + it('surfaces /tools, /save, and /personality on the desktop', () => { + expect(isDesktopSlashSuggestion('/tools')).toBe(true) + expect(isDesktopSlashSuggestion('/save')).toBe(true) + expect(isDesktopSlashSuggestion('/personality')).toBe(true) + expect(isDesktopSlashCommand('/tools')).toBe(true) + expect(isDesktopSlashCommand('/save')).toBe(true) + expect(isDesktopSlashCommand('/personality')).toBe(true) + expect(desktopSlashUnavailableMessage('/tools')).toBeNull() + expect(desktopSlashUnavailableMessage('/save')).toBeNull() + expect(desktopSlashUnavailableMessage('/personality')).toBeNull() + }) + + it('routes /pet through the desktop action handler and drops /pets', () => { + expect(resolveDesktopCommand('/pet')?.surface).toEqual({ kind: 'action', action: 'pet' }) + expect(desktopSlashCommandArgumentMode('/pet')).toBe('options') + expect(isDesktopSlashSuggestion('/pet')).toBe(true) + expect(isDesktopSlashCommand('/pet')).toBe(true) + expect(resolveDesktopCommand('/pets')?.surface).toEqual({ kind: 'unavailable', reason: 'settings' }) + expect(isDesktopSlashSuggestion('/pets')).toBe(false) + expect(isDesktopSlashCommand('/pets')).toBe(false) + }) + + it('routes /wake through the desktop wake action instead of the slash worker', () => { + expect(resolveDesktopCommand('/wake')?.surface).toEqual({ kind: 'action', action: 'wake' }) + expect(desktopSlashCommandArgumentMode('/wake')).toBe('options') + expect(isDesktopSlashSuggestion('/wake')).toBe(true) + expect(isDesktopSlashCommand('/wake')).toBe(true) + expect(desktopSlashUnavailableMessage('/wake')).toBeNull() + }) + + it('treats /browser as an executable action command (local-gateway connect)', () => { + // /browser used to be terminal-only; it now resolves to a desktop action + // handler that routes browser.manage RPC when the gateway is local. + expect(isDesktopSlashCommand('/browser')).toBe(true) + expect(isDesktopSlashSuggestion('/browser')).toBe(true) + expect(desktopSlashUnavailableMessage('/browser')).toBeNull() + expect(resolveDesktopCommand('/browser')?.surface).toEqual({ kind: 'action', action: 'browser' }) + // Bare /browser expands to its sub-action options in the popover. + expect(desktopSlashCommandArgumentMode('/browser')).toBe('options') + }) + + it('routes /compress through the session-compression action', () => { + // /compress must be an action (session.compress RPC), not exec: the slash + // worker route times out on large sessions (#44456). + expect(resolveDesktopCommand('/compress')?.surface).toEqual({ kind: 'action', action: 'compress' }) + expect(desktopSlashCommandArgumentMode('/compress')).toBe('text') + expect(isDesktopSlashCommand('/compress')).toBe(true) + expect(isDesktopSlashSuggestion('/compress')).toBe(true) + expect(desktopSlashUnavailableMessage('/compress')).toBeNull() + // /compact is an alias — executes but stays out of the popover. + expect(resolveDesktopCommand('/compact')?.surface).toEqual({ kind: 'action', action: 'compress' }) + expect(isDesktopSlashCommand('/compact')).toBe(true) + expect(isDesktopSlashSuggestion('/compact')).toBe(false) + }) + + it('routes only stateless session commands through dedicated gateway RPCs', () => { + const expected = { + '/save': 'session.save', + '/status': 'session.status' + } as const + + for (const [name, rpcName] of Object.entries(expected)) { + const surface = resolveDesktopCommand(name)?.surface + expect(surface?.kind).toBe('rpc') + + if (surface?.kind !== 'rpc') { + continue + } + + expect(surface.rpc).toBe(rpcName) + expect(surface.buildParams({ arg: 'topic A', command: name, name: name.slice(1), sessionId: 's-1' })).toEqual({ + session_id: 's-1' + }) + } + }) + + it('keeps commands with richer CLI semantics on the slash worker', () => { + for (const name of ['/agents', '/steer', '/stop', '/usage']) { + expect(resolveDesktopCommand(name)?.surface).toEqual({ kind: 'exec' }) + } + }) + + it('still routes commands without dedicated RPCs through exec()', () => { + const execNames = [ + '/background', + '/debug', + '/goal', + '/personality', + '/queue', + '/retry', + '/rollback', + '/tools', + '/undo', + '/version' + ] + + for (const name of execNames) { + expect(resolveDesktopCommand(name)?.surface).toEqual({ kind: 'exec' }) + } + }) + + it('distinguishes free prose from finite slash option lists', () => { + expect(desktopSlashCommandArgumentMode('/goal')).toBe('mixed') + expect(desktopSlashCommandArgumentMode('/steer')).toBe('text') + expect(desktopSlashCommandArgumentMode('/queue')).toBe('text') + expect(desktopSlashCommandArgumentMode('/personality')).toBe('options') + expect(desktopSlashCommandArgumentMode('/handoff')).toBe('options') + expect(desktopSlashCommandArgumentMode('/version')).toBeNull() + }) + + it('routes /journey (and aliases) to the memory graph overlay action', () => { + expect(resolveDesktopCommand('/journey')?.surface).toEqual({ kind: 'action', action: 'journey' }) + expect(resolveDesktopCommand('/memory-graph')?.surface).toEqual({ kind: 'action', action: 'journey' }) + expect(resolveDesktopCommand('/learning')?.surface).toEqual({ kind: 'action', action: 'journey' }) + expect(isDesktopSlashCommand('/journey')).toBe(true) + expect(isDesktopSlashCommand('/memory-graph')).toBe(true) + expect(isDesktopSlashSuggestion('/journey')).toBe(true) + // Aliases execute but stay out of the popover. + expect(isDesktopSlashSuggestion('/memory-graph')).toBe(false) + expect(desktopSlashUnavailableMessage('/journey')).toBeNull() + }) + + it('allows aliases to execute without cluttering the popover', () => { + expect(isDesktopSlashSuggestion('/reset')).toBe(false) + expect(isDesktopSlashCommand('/reset')).toBe(true) + }) + + it('filters built-in catalog noise but keeps skill / quick-command extensions', () => { + const filtered = filterDesktopCommandsCatalog({ + categories: [ + { + name: 'Session', + pairs: [ + ['/new', 'Start a new session'], + ['/clear', 'Clear terminal screen'] + ] + }, + { + name: 'User commands', + pairs: [['/ship-it', 'Run release checklist']] + } + ], + pairs: [ + ['/new', 'Start a new session'], + ['/model', 'Switch model'], + ['/ship-it', 'Run release checklist'] + ], + skill_count: 2 + }) + + expect(filtered.categories).toEqual([ + { name: 'Session', pairs: [['/new', 'Start a new desktop chat']] }, + { name: 'User commands', pairs: [['/ship-it', 'Run release checklist']] } + ]) + expect(filtered.pairs).toEqual([ + ['/new', 'Start a new desktop chat'], + ['/ship-it', 'Run release checklist'] + ]) + // skill_count is recomputed from the filtered output (only /ship-it is an + // extension command — /new is a built-in) so the /help footer matches what + // the user actually sees rather than echoing the unfiltered backend total. + expect(filtered.skill_count).toBe(1) + }) + + it('recomputes skill_count to reflect only extensions surfaced on desktop', () => { + const filtered = filterDesktopCommandsCatalog({ + pairs: [ + ['/new', 'Start a new session'], + ['/clear', 'Clear terminal screen'], + ['/gif-search', 'Search for a gif'], + ['/ship-it', 'Run release checklist'] + ], + skill_count: 12 + }) + + expect(filtered.pairs?.map(([cmd]) => cmd)).toEqual(['/new', '/gif-search', '/ship-it']) + expect(filtered.skill_count).toBe(2) + }) + + it('uses desktop-specific labels for commands with different UI behavior', () => { + expect(desktopSlashDescription('/branch', 'Branch the current session')).toBe( + 'Branch the latest message into a new chat' + ) + expect(desktopSlashDescription('/skin', 'Show or change the display skin/theme')).toBe( + 'Switch desktop theme or cycle to the next one' + ) + }) + + it('builds /skin completions from desktop themes', () => { + const completions = desktopSkinSlashCompletions( + [ + { name: 'mono', label: 'Mono', description: 'Clean grayscale' }, + { name: 'midnight', label: 'Midnight', description: 'Deep blue' }, + { name: 'slate', label: 'Slate', description: 'Cool slate blue' } + ], + 'mono', + 'm' + ) + + expect(completions).toEqual([ + { + text: '/skin mono', + display: '/skin mono', + meta: 'Mono (current) - Clean grayscale' + }, + { + text: '/skin midnight', + display: '/skin midnight', + meta: 'Midnight - Deep blue' + } + ]) + }) + + it('explains known commands that desktop owns elsewhere', () => { + expect(desktopSlashUnavailableMessage('/model sonnet')).toContain('model picker') + expect(desktopSlashUnavailableMessage('/skills')).toContain('desktop sidebar') + expect(desktopSlashUnavailableMessage('/clear')).toContain('terminal interface') + }) + + it('flags /model as a picker-owned command so the desktop opens the overlay', () => { + expect(isModelPickerCommand('/model')).toBe(true) + expect(isModelPickerCommand('/model sonnet')).toBe(true) + expect(isModelPickerCommand('/new')).toBe(false) + expect(isModelPickerCommand('/skills')).toBe(false) + }) + + it('gives /resume (and its aliases) a first-class session picker surface', () => { + expect(isPickerCommand('/resume', 'session')).toBe(true) + expect(isPickerCommand('/sessions', 'session')).toBe(true) + expect(isPickerCommand('/switch', 'session')).toBe(true) + // Unlike /model, /resume shows in the popover; its aliases stay hidden. + expect(isDesktopSlashSuggestion('/resume')).toBe(true) + expect(isDesktopSlashSuggestion('/sessions')).toBe(false) + expect(isDesktopSlashCommand('/switch')).toBe(true) + // The session picker is distinct from the model picker. + expect(isModelPickerCommand('/resume')).toBe(false) + }) + + it('resolves commands and aliases to their declared surface', () => { + expect(resolveDesktopCommand('/new')?.surface).toEqual({ kind: 'action', action: 'new' }) + expect(resolveDesktopCommand('/reset')?.surface).toEqual({ kind: 'action', action: 'new' }) + expect(resolveDesktopCommand('/resume')?.surface).toEqual({ kind: 'picker', picker: 'session' }) + expect(resolveDesktopCommand('/usage')?.surface).toEqual({ kind: 'exec' }) + expect(resolveDesktopCommand('/clear')?.surface).toEqual({ kind: 'unavailable', reason: 'terminal' }) + // Skill / quick commands aren't in the registry. + expect(resolveDesktopCommand('/gif-search')).toBeNull() + }) +}) + +describe('rankSkillCommands', () => { + const rows = [ + { text: '/research' }, + { text: '/research-paper-writing' }, + { text: '/work' }, + { text: '/ship-it' }, + { text: '/manim-video' }, + { text: '/docx' } + ] + + const skills = { + '/research': { usage: 60, origin: 'local' as const }, + '/research-paper-writing': { usage: 0, origin: 'bundled' as const }, + '/work': { usage: 172, origin: 'local' as const }, + '/manim-video': { usage: 0, origin: 'bundled' as const }, + '/docx': { usage: 0, origin: 'local' as const } + } + + it('puts the most-used skill first and breaks ties alphabetically', () => { + expect(rankSkillCommands(rows, skills).map(row => row.text)).toEqual([ + '/work', + '/research', + '/docx', + '/manim-video', + '/research-paper-writing', + '/ship-it' + ]) + }) + + it('drops never-used built-ins when browsing, keeping everything else', () => { + const browsing = rankSkillCommands(rows, skills, { pruneUnusedBuiltins: true }).map(row => row.text) + + expect(browsing).toEqual(['/work', '/research', '/docx', '/ship-it']) + // A user's own unused skill survives — only shipped-and-ignored goes. + expect(browsing).toContain('/docx') + // Unclassified rows (quick commands, skills newer than the map) survive too. + expect(browsing).toContain('/ship-it') + }) + + it('leaves the backend order untouched when the catalog carries no usage', () => { + expect(rankSkillCommands(rows, undefined, { pruneUnusedBuiltins: true })).toEqual(rows) + }) + + it('ranks an alias by the canonical command it resolves to', () => { + const ranked = rankSkillCommands([{ text: '/sessions' }, { text: '/research' }], { + '/research': { usage: 5, origin: 'local' }, + '/resume': { usage: 900, origin: 'local' } + }) + + expect(ranked.map(row => row.text)).toEqual(['/sessions', '/research']) + }) +}) diff --git a/ui-desktop/src/lib/desktop-slash-commands.ts b/ui-desktop/src/lib/desktop-slash-commands.ts new file mode 100644 index 00000000..19e96ade --- /dev/null +++ b/ui-desktop/src/lib/desktop-slash-commands.ts @@ -0,0 +1,626 @@ +export interface CommandsCatalogSection { + name: string + pairs: [string, string][] +} + +export interface CommandsCatalogLike { + categories?: CommandsCatalogSection[] + pairs?: [string, string][] + skill_count?: number + skills?: SkillCatalogMap + warning?: string +} + +/** + * Per-skill ranking data from `commands.catalog`, keyed by slash command. + * Absent on older backends — every helper below degrades to "no ranking, + * hide nothing". + */ +export interface SkillCatalogEntry { + /** Where the skill came from; matches `/api/skills` provenance ('agent' = 'local'). */ + origin?: 'bundled' | 'hub' | 'local' + /** Observed activity (use + view + patch) — the same number Capabilities shows. */ + usage?: number +} + +export type SkillCatalogMap = Record<string, SkillCatalogEntry> + +export interface DesktopSlashCompletion { + display: string + meta: string + text: string +} + +export interface DesktopThemeCommandOption { + description: string + label: string + name: string +} + +/** + * Local client action a command resolves to. Each id maps to exactly one + * handler in the dispatcher (`use-prompt-actions`), so adding a command never + * means adding a branch to a switch ladder — you add a row here + a handler + * keyed by the id. + */ +export type DesktopActionId = + | 'branch' + | 'browser' + | 'compress' + | 'handoff' + | 'hatch' + | 'help' + | 'journey' + | 'new' + | 'pet' + | 'profile' + | 'skin' + | 'title' + | 'wake' + | 'yolo' + +/** A command fulfilled by opening a desktop overlay picker. */ +export type DesktopPickerId = 'model' | 'session' + +/** Why a known ClawCodex command has no desktop UI surface. */ +export type DesktopUnavailableReason = 'advanced' | 'messaging' | 'settings' | 'terminal' + +/** + * How the desktop fulfils a command. This is the single discriminator the + * dispatcher, popover, pills, and pickers all read — no parallel block-lists. + * + * - `action` → handled by a local client handler (new chat, branch, …) + * - `picker` → opens an overlay (`/model`, `/resume`); a typed arg is + * resolved by that picker instead of falling through + * - `rpc` → dedicated gateway RPC named on the surface. The dispatcher + * calls it directly with the params built by `buildParams`, + * bypassing `slash.exec` / `command.dispatch`. Reserved for + * commands that have a first-class RPC handler in + * `tui_gateway/server.py` (e.g. `/save` → session.save). + * - `exec` → runs on the backend via slash.exec / command.dispatch and + * renders its text output inline. Only commands WITHOUT a + * dedicated RPC should stay here. + * - `unavailable`→ a known command with genuinely no desktop UI (terminal-only, + * messaging-only, …); shows a reason instead of executing + */ +export type DesktopCommandSurface = + | { kind: 'action'; action: DesktopActionId } + | { kind: 'picker'; picker: DesktopPickerId } + | { + kind: 'rpc' + rpc: string + timeoutMs?: number + buildParams: (ctx: SlashCommandBuildCtx) => Record<string, unknown> + } + | { kind: 'exec' } + | { kind: 'unavailable'; reason: DesktopUnavailableReason } + +/** + * Inputs a `buildParams` function receives. The dispatcher passes session id, + * the typed arg, and the canonical command name so handlers can construct + * the exact JSON the gateway method expects. + */ +export interface SlashCommandBuildCtx { + arg: string + command: string + name: string + sessionId: string +} + +/** + * How arguments behave in the Desktop composer. + * + * - `options` → a finite completion list; picking or fully typing an option may + * commit the complete directive as a chip. + * - `text` → arbitrary prose; the command and its argument stay editable. + * - `mixed` → offers subcommand completions but also accepts arbitrary prose. + */ +export type DesktopSlashArgumentMode = 'mixed' | 'options' | 'text' + +export interface DesktopCommandSpec { + /** Canonical command, leading slash included (e.g. `/resume`). */ + name: string + /** Popover/help label; omitted for unavailable commands (never surfaced). */ + description?: string + aliases?: string[] + surface: DesktopCommandSurface + /** + * Hide from the slash popover / completions while still letting it execute. + * Used for picker commands reachable from chrome (the model picker lives on + * the status bar), so the popover doesn't dead-end on inline completion. + */ + hidden?: boolean + /** Composer behavior for text following the command token. */ + argumentMode?: DesktopSlashArgumentMode +} + +const exec = (): DesktopCommandSurface => ({ kind: 'exec' }) +const action = (id: DesktopActionId): DesktopCommandSurface => ({ kind: 'action', action: id }) +const picker = (id: DesktopPickerId): DesktopCommandSurface => ({ kind: 'picker', picker: id }) +const unavailable = (reason: DesktopUnavailableReason): DesktopCommandSurface => ({ kind: 'unavailable', reason }) + +/** + * Route a command directly to its dedicated gateway RPC. Prefer this over + * `exec()` whenever `tui_gateway/server.py` exposes a `@method(...)` for the + * command — bypassing `slash.exec` keeps the path short and the response + * structured. + * + * The dispatcher calls `requestGateway(surface.rpc, surface.buildParams(ctx))` + * and then runs `renderRpcResult` to format the response. + */ +const rpc = ( + rpcName: string, + buildParams: (ctx: SlashCommandBuildCtx) => Record<string, unknown>, + timeoutMs?: number +): DesktopCommandSurface => ({ kind: 'rpc', rpc: rpcName, timeoutMs, buildParams }) + +/** + * THE source of truth for desktop slash commands. Everything below — execution + * gating, popover suggestions, catalog filtering, pill grouping, and the + * dispatcher's behavior — derives from this one table. + */ +const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ + // Local client actions + { name: '/new', description: 'Start a new desktop chat', aliases: ['/reset'], surface: action('new') }, + { + name: '/branch', + description: 'Branch the latest message into a new chat', + aliases: ['/fork'], + surface: action('branch') + }, + { name: '/yolo', description: 'Toggle YOLO — auto-approve dangerous commands', surface: action('yolo') }, + { + name: '/wake', + description: 'Control the desktop wake-word listener [on|off|status]', + surface: action('wake'), + argumentMode: 'options' + }, + { + name: '/handoff', + description: 'Hand off this session to a messaging platform', + surface: action('handoff'), + argumentMode: 'options' + }, + { name: '/profile', description: 'Switch the active ClawCodex profile', surface: action('profile') }, + { + name: '/skin', + description: 'Switch desktop theme or cycle to the next one', + surface: action('skin'), + argumentMode: 'options' + }, + { name: '/title', description: 'Rename the current session', surface: action('title'), argumentMode: 'text' }, + { name: '/help', description: 'Show desktop slash commands', aliases: ['/commands'], surface: action('help') }, + { + name: '/browser', + description: 'Manage browser CDP connection [connect|disconnect|status] (local gateway only)', + surface: action('browser'), + argumentMode: 'options' + }, + { + name: '/journey', + description: 'Open the memory graph — skills + memories over time', + aliases: ['/learning', '/memory-graph'], + surface: action('journey') + }, + + // Overlay pickers + { name: '/model', description: 'Switch the model for this session', surface: picker('model'), hidden: true }, + { + name: '/resume', + description: 'Resume a saved session', + aliases: ['/sessions', '/switch'], + surface: picker('session'), + // `mixed`, not `options`: the argument is a free-text search the picker + // fuzzy-matches against titles and previews, so multi-word queries have to + // stay typeable. Its completion list also always carries a trailing + // "Browse all sessions…" action row, which meant Space-to-accept could + // never fall through — the first space wiped the composer and threw the + // user into the overlay. + argumentMode: 'mixed' + }, + + // Backend-executed commands that render useful inline output. + // Commands with a dedicated gateway RPC (@method in tui_gateway/server.py) + // route to it directly via `rpc(...)` — bypassing slash.exec avoids the + // slash-worker pipe timeout and the "not a quick/plugin/skill command" + // fallback noise for commands the dispatcher doesn't handle inline. + // These commands have gateway RPCs, but their established desktop behavior + // carries richer CLI semantics: /agents includes delegations, /stop cancels + // them, /steer falls back to a next-turn prompt, and /usage is a formatted + // live report. Keep them on slash.exec until their RPC contracts are fully + // equivalent. + { + name: '/approvals', + description: 'Show or set approval mode [manual|smart|off]', + surface: exec(), + argumentMode: 'options' + }, + { + name: '/agents', + description: 'Show active desktop sessions and running tasks', + aliases: ['/tasks'], + surface: exec() + }, + { + name: '/background', + description: 'Run a prompt in the background', + aliases: ['/bg', '/btw'], + surface: exec(), + argumentMode: 'text' + }, + // /compress must be an action (session.compress RPC), not exec: the slash + // worker route times out on large sessions (30s WS / 45s pipe) before the + // LLM summarise call finishes, then command.dispatch surfaces a bogus + // "not a quick/plugin/skill command: compress" (#44456). + { + name: '/compress', + description: 'Compress this conversation context', + aliases: ['/compact'], + surface: action('compress'), + argumentMode: 'text' + }, + { name: '/debug', description: 'Create a debug report', surface: exec() }, + { + name: '/goal', + description: 'Manage the standing goal for this session', + surface: exec(), + argumentMode: 'mixed' + }, + { + name: '/personality', + description: 'Switch personality for this session', + surface: exec(), + argumentMode: 'options' + }, + { + name: '/pet', + description: 'Toggle or adopt a petdex mascot (/pet, /pet list, /pet boba)', + surface: action('pet'), + argumentMode: 'options' + }, + { + name: '/hatch', + description: 'Generate a new pet (opens the pet generator)', + aliases: ['/generate-pet'], + surface: action('hatch') + }, + { + name: '/queue', + description: 'Queue a prompt for the next turn', + aliases: ['/q'], + surface: exec(), + argumentMode: 'text' + }, + { name: '/retry', description: 'Retry the last user message', surface: exec() }, + { name: '/rollback', description: 'List or restore filesystem checkpoints', surface: exec() }, + { + name: '/save', + description: 'Save the current transcript to JSON', + surface: rpc('session.save', ctx => ({ session_id: ctx.sessionId })) + }, + { + name: '/status', + description: 'Show current session status', + surface: rpc('session.status', ctx => ({ session_id: ctx.sessionId })) + }, + { + name: '/steer', + description: 'Steer the current run after the next tool call', + surface: exec(), + argumentMode: 'text' + }, + { name: '/stop', description: 'Stop running background processes', surface: exec() }, + { + name: '/tools', + description: 'List or toggle tools available to the agent', + surface: exec(), + argumentMode: 'options' + }, + { name: '/undo', description: 'Remove the last user/assistant exchange', surface: exec() }, + { name: '/usage', description: 'Show token usage for this session', surface: exec() }, + { name: '/version', description: 'Show ClawCodex version', surface: exec() }, + + // No desktop surface, but carry an alias (underscore spelling variants). + { name: '/reload-mcp', aliases: ['/reload_mcp'], surface: unavailable('advanced') }, + { name: '/reload-skills', aliases: ['/reload_skills'], surface: unavailable('advanced') } +] + +// Known commands with no desktop surface (and no alias) — a flat name list +// per reason beats 40 identical object literals. +const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> = { + terminal: [ + '/busy', + '/clear', + '/config', + '/copy', + '/cron', + '/density', + '/details', + '/exit', + '/footer', + '/gateway', + '/history', + '/image', + '/indicator', + '/logs', + '/mouse', + '/paste', + '/platforms', + '/plugins', + '/quit', + '/redraw', + '/reload', + '/restart', + '/sb', + '/set-home', + '/sethome', + '/snap', + '/snapshot', + '/statusbar', + '/toolsets', + '/update', + '/verbose' + ], + messaging: ['/approve', '/deny'], + settings: ['/skills', '/pets'], + advanced: ['/curator', '/fast', '/insights', '/kanban', '/reasoning', '/voice'] +} + +const ALL_SPECS: readonly DesktopCommandSpec[] = [ + ...DESKTOP_COMMAND_SPECS, + ...(Object.entries(NO_DESKTOP_SURFACE) as [DesktopUnavailableReason, readonly string[]][]).flatMap( + ([reason, names]) => names.map(name => ({ name, surface: unavailable(reason) })) + ) +] + +const SPEC_BY_NAME = new Map<string, DesktopCommandSpec>(ALL_SPECS.map(spec => [spec.name, spec])) + +const ALIAS_TO_CANONICAL = new Map<string, string>( + ALL_SPECS.flatMap(spec => (spec.aliases ?? []).map(alias => [alias, spec.name] as const)) +) + +const UNAVAILABLE_MESSAGE: Record<DesktopUnavailableReason, (command: string) => string> = { + advanced: command => + `${command} is not shown in the desktop slash palette. Use the relevant desktop control or terminal interface instead.`, + messaging: command => `${command} is only used from messaging platforms.`, + settings: command => `${command} is managed from the desktop sidebar.`, + terminal: command => `${command} is only available in the terminal interface.` +} + +const PICKER_UNAVAILABLE_MESSAGE: Record<DesktopPickerId, (command: string) => string> = { + model: command => `${command} uses the desktop model picker instead of a slash command.`, + session: command => `${command} uses the desktop session picker instead of a slash command.` +} + +function normalizeCommand(command: string): string { + const trimmed = command.trim() + const base = (trimmed.startsWith('/') ? trimmed : `/${trimmed}`).split(/\s+/, 1)[0]?.toLowerCase() || '' + + return base +} + +export function canonicalDesktopSlashCommand(command: string): string { + const normalized = normalizeCommand(command) + + return ALIAS_TO_CANONICAL.get(normalized) || normalized +} + +/** Resolve a command (or alias) to its desktop spec, or null for unknown/extension commands. */ +export function resolveDesktopCommand(command: string): DesktopCommandSpec | null { + return SPEC_BY_NAME.get(canonicalDesktopSlashCommand(command)) ?? null +} + +function isKnownClawCodexSlashCommand(command: string): boolean { + const normalized = normalizeCommand(command) + + return SPEC_BY_NAME.has(normalized) || ALIAS_TO_CANONICAL.has(normalized) +} + +/** + * An "extension" command is anything the backend surfaces that is NOT one of + * ClawCodex' built-in slash commands — i.e. skill commands (`/gif-search`, + * `/codex`, …) and user-defined quick commands. These are user-activated, so + * they appear in the desktop slash palette and execute when typed. + */ +export function isDesktopSlashExtensionCommand(command: string): boolean { + const normalized = normalizeCommand(command) + + if (!normalized || normalized === '/') { + return false + } + + return !isKnownClawCodexSlashCommand(normalized) +} + +/** Gates execution: true unless the command is a known no-desktop-surface command. */ +export function isDesktopSlashCommand(command: string): boolean { + const spec = resolveDesktopCommand(command) + + if (spec) { + return spec.surface.kind !== 'unavailable' + } + + return isDesktopSlashExtensionCommand(command) +} + +/** Gates discovery in the popover/completions. */ +export function isDesktopSlashSuggestion(command: string): boolean { + const normalized = normalizeCommand(command) + + // Aliases stay hidden so the popover isn't cluttered with duplicates. + if (ALIAS_TO_CANONICAL.has(normalized)) { + return false + } + + const spec = SPEC_BY_NAME.get(normalized) + + if (spec) { + return spec.surface.kind !== 'unavailable' && !spec.hidden + } + + // Skill / quick commands the backend provides. + return isDesktopSlashExtensionCommand(normalized) +} + +/** + * True for commands the desktop fulfils by opening an overlay picker + * (`/model`, `/resume`/`/sessions`/`/switch`). Optionally pin to one picker. + */ +export function isPickerCommand(command: string, picker?: DesktopPickerId): boolean { + const surface = resolveDesktopCommand(command)?.surface + + if (surface?.kind !== 'picker') { + return false + } + + return picker ? surface.picker === picker : true +} + +/** Back-compat shim for the model picker check. */ +export function isModelPickerCommand(command: string): boolean { + return isPickerCommand(command, 'model') +} + +export function desktopSlashUnavailableMessage(command: string): string | null { + const canonical = canonicalDesktopSlashCommand(command) + const surface = SPEC_BY_NAME.get(canonical)?.surface + + if (!surface) { + return null + } + + if (surface.kind === 'unavailable') { + return UNAVAILABLE_MESSAGE[surface.reason](canonical) + } + + if (surface.kind === 'picker') { + return PICKER_UNAVAILABLE_MESSAGE[surface.picker](canonical) + } + + return null +} + +export function desktopSlashDescription(command: string, fallback = ''): string { + return SPEC_BY_NAME.get(canonicalDesktopSlashCommand(command))?.description || fallback +} + +export function desktopSlashCommandArgumentMode(command: string): DesktopSlashArgumentMode | null { + return resolveDesktopCommand(command)?.argumentMode ?? null +} + +export function desktopSkinSlashCompletions( + themes: DesktopThemeCommandOption[], + activeThemeName: string, + argPrefix: string +): DesktopSlashCompletion[] { + const prefix = argPrefix.trim().toLowerCase() + + const commands: DesktopSlashCompletion[] = [ + { + text: '/skin list', + display: '/skin list', + meta: 'Show available desktop themes' + }, + { + text: '/skin next', + display: '/skin next', + meta: 'Cycle to the next desktop theme' + }, + ...themes.map(theme => ({ + text: `/skin ${theme.name}`, + display: `/skin ${theme.name}`, + meta: `${theme.label}${theme.name === activeThemeName ? ' (current)' : ''} - ${theme.description}` + })) + ] + + if (!prefix) { + return commands + } + + return commands.filter(item => item.text.slice('/skin '.length).toLowerCase().startsWith(prefix)) +} + +/** + * Order skill rows by how much the user actually uses them, most-used first, + * A–Z within a tie. A `/` menu sorted alphabetically buries the handful of + * skills someone reaches for daily under a hundred they have never opened. + * + * `pruneUnusedBuiltins` additionally drops bundled skills with no recorded + * activity — the ones that ship with ClawCodex and were never asked for. It is + * for BROWSING (a bare `/`) only: typing a query is a search, and a search + * must never hide a match. + * + * Older backends send no `skills` map; then nothing is reordered or dropped. + */ +export function rankSkillCommands<T extends { text: string }>( + rows: readonly T[], + skills: SkillCatalogMap | undefined, + { pruneUnusedBuiltins = false }: { pruneUnusedBuiltins?: boolean } = {} +): T[] { + if (!skills) { + return [...rows] + } + + const entryOf = (row: T): SkillCatalogEntry | undefined => skills[canonicalDesktopSlashCommand(row.text)] + const usageOf = (row: T): number => entryOf(row)?.usage ?? 0 + + const kept = pruneUnusedBuiltins + ? rows.filter(row => { + const entry = entryOf(row) + + // Unknown to the map (a quick command, a newer skill the catalog + // hasn't classified) stays — only a confirmed never-used built-in goes. + return !entry || entry.origin !== 'bundled' || (entry.usage ?? 0) > 0 + }) + : [...rows] + + return kept.sort((a, b) => usageOf(b) - usageOf(a) || a.text.localeCompare(b.text)) +} + +export function filterDesktopCommandsCatalog(catalog: CommandsCatalogLike): CommandsCatalogLike { + const categories = catalog.categories + ?.map(section => ({ + ...section, + pairs: section.pairs + .filter(([command]) => isDesktopSlashSuggestion(command)) + .map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string]) + })) + .filter(section => section.pairs.length > 0) + + const pairs = catalog.pairs + ?.filter(([command]) => isDesktopSlashSuggestion(command)) + .map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string]) + + // Recount skill commands from the filtered output so /help's footer reflects + // what the user actually sees. Backend's skill_count includes commands the + // desktop hides (terminal-only, picker-owned, advanced), producing a footer + // like "60 skill commands available" while only ~29 appear in the list. + const filteredCommands = new Set<string>() + + for (const section of categories ?? []) { + for (const [command] of section.pairs) { + filteredCommands.add(canonicalDesktopSlashCommand(command)) + } + } + + for (const [command] of pairs ?? []) { + filteredCommands.add(canonicalDesktopSlashCommand(command)) + } + + let skillCount = 0 + + for (const command of filteredCommands) { + if (isDesktopSlashExtensionCommand(command)) { + skillCount += 1 + } + } + + const hasSkillCount = catalog.skill_count !== undefined || skillCount > 0 + + return { + ...catalog, + ...(categories ? { categories } : {}), + ...(pairs ? { pairs } : {}), + ...(hasSkillCount ? { skill_count: skillCount } : {}) + } +} diff --git a/ui-desktop/src/lib/desktop-toolsets.test.ts b/ui-desktop/src/lib/desktop-toolsets.test.ts new file mode 100644 index 00000000..5e77333d --- /dev/null +++ b/ui-desktop/src/lib/desktop-toolsets.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' + +import { isDesktopToolsetVisible } from './desktop-toolsets' + +describe('isDesktopToolsetVisible', () => { + it('hides platform-coupled and internal toolsets', () => { + for (const name of ['discord', 'discord_admin', 'yuanbao', 'context_engine', 'moa']) { + expect(isDesktopToolsetVisible(name)).toBe(false) + } + }) + + it('keeps ordinary user-facing toolsets', () => { + for (const name of ['web', 'browser', 'terminal', 'file', 'memory', 'vision', 'image_gen']) { + expect(isDesktopToolsetVisible(name)).toBe(true) + } + }) +}) diff --git a/ui-desktop/src/lib/desktop-toolsets.ts b/ui-desktop/src/lib/desktop-toolsets.ts new file mode 100644 index 00000000..1a2edf98 --- /dev/null +++ b/ui-desktop/src/lib/desktop-toolsets.ts @@ -0,0 +1,24 @@ +// Curation for the desktop "Skills & Tools → Toolsets" list. +// +// `GET /api/tools/toolsets` returns the full CONFIGURABLE_TOOLSETS set with no +// desktop-specific filter — so it surfaces entries that don't belong in a flat +// per-user toggle list on the desktop: platform-coupled toolsets (which +// `clawcodex tools` already platform-restricts on the CLI) and internal plumbing +// that isn't a user-facing capability. Mirror the curation approach used for +// slash commands (`desktop-slash-commands.ts`): one documented block-list, one +// predicate. Hiding a toolset only removes its row — its enabled state and +// runtime gating are untouched. +const DESKTOP_HIDDEN_TOOLSETS = new Set([ + // Platform-coupled — only meaningful when that platform is the active + // adapter; `clawcodex tools` restricts these off the CLI too. + 'discord', + 'discord_admin', + 'yuanbao', + // Internal plumbing, not a user capability toggle. + 'context_engine', + 'moa' +]) + +export function isDesktopToolsetVisible(name: string): boolean { + return !DESKTOP_HIDDEN_TOOLSETS.has(name) +} diff --git a/ui-desktop/src/lib/display-path.test.ts b/ui-desktop/src/lib/display-path.test.ts new file mode 100644 index 00000000..b1dba0eb --- /dev/null +++ b/ui-desktop/src/lib/display-path.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' + +import { displayPath, normalizeDisplayPath, pathLeaf } from './display-path' + +describe('displayPath', () => { + it('collapses a macOS home prefix to ~', () => { + expect(displayPath('/Users/brooklyn/www/clawcodex')).toBe('~/www/clawcodex') + expect(displayPath('/Users/brooklyn')).toBe('~') + }) + + it('collapses a Linux home prefix to ~', () => { + expect(displayPath('/home/alice/src/app')).toBe('~/src/app') + }) + + it('collapses a Windows user profile to ~', () => { + expect(displayPath('C:\\Users\\brooklyn\\src')).toBe('~/src') + expect(displayPath('C:/Users/brooklyn')).toBe('~') + }) + + it('honours an explicit home override', () => { + expect(displayPath('/opt/work/repo', { home: '/opt/work' })).toBe('~/repo') + expect(displayPath('/elsewhere/repo', { home: '/opt/work' })).toBe('/elsewhere/repo') + }) + + it('leaves non-home absolute paths alone', () => { + expect(displayPath('/var/log/system.log')).toBe('/var/log/system.log') + expect(displayPath('/Users')).toBe('/Users') + }) + + it('normalizes separators and trailing slashes', () => { + expect(normalizeDisplayPath('C:\\Users\\me\\src\\')).toBe('C:/Users/me/src') + expect(displayPath('/Users/me/src/')).toBe('~/src') + }) + + it('keeps an already-tildified path', () => { + expect(displayPath('~/www/app')).toBe('~/www/app') + expect(displayPath('~')).toBe('~') + }) +}) + +describe('pathLeaf', () => { + it('returns the last segment', () => { + expect(pathLeaf('/Users/me/www/clawcodex')).toBe('clawcodex') + expect(pathLeaf('~/www/clawcodex')).toBe('clawcodex') + expect(pathLeaf('/')).toBe('/') + }) +}) diff --git a/ui-desktop/src/lib/display-path.ts b/ui-desktop/src/lib/display-path.ts new file mode 100644 index 00000000..977abed5 --- /dev/null +++ b/ui-desktop/src/lib/display-path.ts @@ -0,0 +1,157 @@ +/** + * One place to format filesystem paths for DISPLAY. + * + * Industry standard (shells, VS Code `tildify`, Finder path bar): collapse the + * user's home directory to `~`, keep forward slashes, leave everything else + * alone. Copy/reveal/IPC still use the real absolute path — this is paint only. + * + * When `home` is unknown (renderer has no `os.homedir()` and remote cwd may + * not match the local machine), a conservative heuristic still collapses the + * common `/Users/<name>`, `/home/<name>`, and `C:/Users/<name>` prefixes so a + * long absolute path never paints raw in chrome. + */ + +export interface DisplayPathOptions { + /** Explicit home directory to collapse (local machine home, remote $HOME). */ + home?: null | string +} + +/** Normalize separators and drop a trailing slash (except root / drive root). */ +export function normalizeDisplayPath(raw: string): string { + let path = (raw || '').trim().replace(/\\/g, '/') + + if (!path) { + return '' + } + + // Collapse repeated slashes, but keep a leading UNC `//server/...` pair. + if (path.startsWith('//')) { + path = `//${path.slice(2).replace(/\/{2,}/g, '/')}` + } else { + path = path.replace(/\/{2,}/g, '/') + } + + // Drop trailing slash except bare `/` or `C:/`. + if (path.length > 1 && path.endsWith('/') && !/^[A-Za-z]:\/$/.test(path)) { + path = path.replace(/\/+$/, '') + } + + return path +} + +function normalizeHome(home: string): string { + const normalized = normalizeDisplayPath(home) + + if (!normalized) { + return '' + } + + // Home itself should not keep a trailing slash for prefix checks. + return normalized.replace(/\/+$/, '') +} + +function startsWithHome(path: string, home: string, caseInsensitive: boolean): boolean { + if (!home) { + return false + } + + if (path === home) { + return true + } + + const prefix = `${home}/` + + return caseInsensitive + ? path.toLowerCase().startsWith(prefix.toLowerCase()) || path.toLowerCase() === home.toLowerCase() + : path.startsWith(prefix) || path === home +} + +/** + * Best-effort home prefix when callers don't pass one. Matches the usual + * single-user layouts; never collapses `/Users` or `/home` alone. + */ +function inferredHomePrefix(path: string): string { + // macOS: /Users/name[/...] + let match = path.match(/^(\/Users\/[^/]+)(?:\/|$)/) + + if (match) { + return match[1] + } + + // Linux (and most UNIX): /home/name[/...] + match = path.match(/^(\/home\/[^/]+)(?:\/|$)/) + + if (match) { + return match[1] + } + + // Windows user profile: C:/Users/name[/...] (also works after \ → /) + match = path.match(/^([A-Za-z]:\/Users\/[^/]+)(?:\/|$)/) + + if (match) { + return match[1] + } + + return '' +} + +/** + * Format a filesystem path for UI chrome. + * + * /Users/brooklyn/www/clawcodex → ~/www/clawcodex + * /Users/brooklyn → ~ + * C:\Users\brooklyn\src → ~/src + * /var/log → /var/log + * already/relative → already/relative + */ +export function displayPath(raw: null | string | undefined, options: DisplayPathOptions = {}): string { + const path = normalizeDisplayPath(raw || '') + + if (!path) { + return '' + } + + // Already tildified — normalize only. + if (path === '~' || path.startsWith('~/')) { + return path + } + + const explicitHome = options.home ? normalizeHome(options.home) : '' + // Windows paths are case-insensitive; POSIX paths with an explicit home keep + // case-sensitive matching (Linux). Heuristic homes on mac/win ignore case. + const home = explicitHome || inferredHomePrefix(path) + + if (!home) { + return path + } + + const caseInsensitive = !explicitHome || /^[A-Za-z]:\//.test(home) || home.startsWith('/Users/') + + if (!startsWithHome(path, home, caseInsensitive)) { + return path + } + + if (path.length === home.length) { + return '~' + } + + return `~${path.slice(home.length)}` +} + +/** Last path segment for compact labels (statusbar leaf, settings rows). */ +export function pathLeaf(raw: null | string | undefined): string { + const path = normalizeDisplayPath(raw || '') + + if (!path || path === '/' || path === '~') { + return path + } + + // `C:/` drive root + if (/^[A-Za-z]:\/$/.test(path) || /^[A-Za-z]:$/.test(path)) { + return path.endsWith('/') ? path : `${path}/` + } + + const leaf = path.split('/').filter(Boolean).pop() + + return leaf || path +} diff --git a/ui-desktop/src/lib/download-text.ts b/ui-desktop/src/lib/download-text.ts new file mode 100644 index 00000000..3e9091fc --- /dev/null +++ b/ui-desktop/src/lib/download-text.ts @@ -0,0 +1,16 @@ +/** Save generated text content to a file via a blob download. Electron's + * default will-download behavior shows the OS save dialog, so this works + * without a dedicated IPC handler (same pattern as use-image-download's + * browser fallback). */ +export function downloadTextFile(name: string, content: string, mimeType = 'text/plain') { + const blobUrl = URL.createObjectURL(new Blob([content], { type: `${mimeType};charset=utf-8` })) + const link = document.createElement('a') + + link.href = blobUrl + link.download = name + link.rel = 'noopener noreferrer' + document.body.appendChild(link) + link.click() + link.remove() + window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000) +} diff --git a/ui-desktop/src/lib/drag-ghost.ts b/ui-desktop/src/lib/drag-ghost.ts new file mode 100644 index 00000000..f07a9835 --- /dev/null +++ b/ui-desktop/src/lib/drag-ghost.ts @@ -0,0 +1,43 @@ +/** + * A flat, pointer-following drag chip — the shared "what am I holding" + * affordance for in-app pointer drags. Plain DOM (no React) so it survives a + * pointer-capture drag without re-renders and tears down synchronously on Esc. + * + * Flat by design: a solid app surface with the dragged item's label, no + * border / radius / shadow, dimmed — it copies the real row/tab it represents + * rather than reading as a separate pill. Any pointer drag whose source does + * not stay visibly "held" can reuse this (the drag primitive in + * `pane-shell/tree/renderer/drag-session.ts`, and anything built on it). + */ + +/** How far (px) the chip trails the pointer so it never sits under the cursor. */ +const OFFSET_X = 14 +const OFFSET_Y = 12 + +export interface DragGhost { + /** Reposition the chip near the current pointer point. */ + moveTo(x: number, y: number): void + /** Remove the chip from the DOM. Idempotent. */ + destroy(): void +} + +export function createDragGhost(label: string): DragGhost { + const el = document.createElement('div') + + el.textContent = label + el.style.cssText = + 'position:fixed;left:0;top:0;z-index:9999;pointer-events:none;max-width:16rem;overflow:hidden;' + + 'text-overflow:ellipsis;white-space:nowrap;padding:0.25rem 0.625rem;opacity:0.6;' + + 'background:var(--ui-sidebar-surface-background,var(--dt-card));color:var(--ui-text-primary);' + + 'font-size:0.75rem;font-weight:500;will-change:transform' + document.body.appendChild(el) + + return { + moveTo(x, y) { + el.style.transform = `translate3d(${x + OFFSET_X}px, ${y + OFFSET_Y}px, 0)` + }, + destroy() { + el.remove() + } + } +} diff --git a/ui-desktop/src/lib/embedded-images.test.ts b/ui-desktop/src/lib/embedded-images.test.ts new file mode 100644 index 00000000..c928c592 --- /dev/null +++ b/ui-desktop/src/lib/embedded-images.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' + +import { extractEmbeddedImages, extractImageRefs } from './embedded-images' + +const SAMPLE_PNG_DATA_URL = 'data:image/png;base64,' + 'A'.repeat(120) + +describe('extractEmbeddedImages', () => { + it('returns text untouched when no data URL is present', () => { + expect(extractEmbeddedImages('describe this')).toEqual({ cleanedText: 'describe this', images: [] }) + }) + + it('lifts a bare data:image URL out of prose', () => { + const result = extractEmbeddedImages(`describe this ${SAMPLE_PNG_DATA_URL}`) + + expect(result.cleanedText).toBe('describe this') + expect(result.images).toEqual([SAMPLE_PNG_DATA_URL]) + }) + + it('lifts a JSON-wrapped image_url envelope out of prose', () => { + const result = extractEmbeddedImages( + `describe this{"type":"image_url","image_url":{"url":"${SAMPLE_PNG_DATA_URL}"}}` + ) + + expect(result.cleanedText).toBe('describe this') + expect(result.images).toEqual([SAMPLE_PNG_DATA_URL]) + }) + + it('extracts multiple embedded images', () => { + const second = 'data:image/jpeg;base64,' + 'B'.repeat(96) + const result = extractEmbeddedImages(`first ${SAMPLE_PNG_DATA_URL} mid ${second} tail`) + + expect(result.cleanedText).toBe('first mid tail') + expect(result.images).toEqual([SAMPLE_PNG_DATA_URL, second]) + }) + + it('handles multi-megabyte data URLs without overflowing the JS stack', () => { + const hugeDataUrl = 'data:image/png;base64,' + 'A'.repeat(8_000_000) + const result = extractEmbeddedImages(`describe this ${hugeDataUrl} thanks`) + + expect(result.cleanedText).toBe('describe this thanks') + expect(result.images).toHaveLength(1) + expect(result.images[0]).toHaveLength(hugeDataUrl.length) + }) +}) + +describe('extractImageRefs', () => { + it('returns the text untouched and no refs when there are no directives', () => { + expect(extractImageRefs('a normal prompt')).toEqual({ cleanedText: 'a normal prompt', refs: [] }) + }) + + it('lifts leading @image directive lines into refs and clears the text', () => { + const result = extractImageRefs('@image:/tmp/cat.png\nwhat do you see?') + + expect(result).toEqual({ cleanedText: 'what do you see?', refs: ['@image:/tmp/cat.png'] }) + }) + + it('collects multiple refs in order', () => { + const result = extractImageRefs('@image:/tmp/a.png\n@image:/tmp/b.png\ncompare them') + + expect(result.cleanedText).toBe('compare them') + expect(result.refs).toEqual(['@image:/tmp/a.png', '@image:/tmp/b.png']) + }) + + it('keeps only the directive lines when there is no trailing text', () => { + const result = extractImageRefs('@image:/tmp/only.png') + + expect(result).toEqual({ cleanedText: '', refs: ['@image:/tmp/only.png'] }) + }) + + it('lifts a backtick-quoted ref so a path with spaces survives intact', () => { + const ref = '@image:`/Users/me/Library/Application Support/ClawCodex/composer-images/a.png`' + const result = extractImageRefs(`${ref}\nwhat is this?`) + + expect(result).toEqual({ cleanedText: 'what is this?', refs: [ref] }) + }) + + it('drops the [screenshot] placeholder a native-vision turn leaves behind', () => { + // Flattening a parts list replaces each image part with `[screenshot]`; the + // lifted ref already renders that same attachment. + const result = extractImageRefs('@image:/tmp/cat.png\nwhat is in this photo?\n[screenshot]') + + expect(result).toEqual({ cleanedText: 'what is in this photo?', refs: ['@image:/tmp/cat.png'] }) + }) + + it('keeps [screenshot] when the message carries no image refs', () => { + expect(extractImageRefs('[screenshot]\nlook at the attached capture')).toEqual({ + cleanedText: '[screenshot]\nlook at the attached capture', + refs: [] + }) + }) +}) diff --git a/ui-desktop/src/lib/embedded-images.ts b/ui-desktop/src/lib/embedded-images.ts new file mode 100644 index 00000000..57578724 --- /dev/null +++ b/ui-desktop/src/lib/embedded-images.ts @@ -0,0 +1,204 @@ +const DATA_URL_RE = /^data:([\w./+-]+);base64,(.*)$/i +const DATA_IMAGE_PREFIX = 'data:image/' +const BASE64_MARKER = ';base64,' +const MIN_EMBEDDED_IMAGE_BASE64_LENGTH = 64 +const JSON_IMAGE_OPEN_RE = /\{\s*"type"\s*:\s*"image_url"\s*,\s*"image_url"\s*:\s*\{\s*"url"\s*:\s*"$/ +const JSON_IMAGE_CLOSE_RE = /^"\s*\}\s*\}/ +const JSON_IMAGE_OPEN_MAX = 96 +const JSON_IMAGE_CLOSE_MAX = 16 + +export const DATA_IMAGE_URL_RE = /^data:image\/[\w.+-]+;base64,/i + +export interface EmbeddedImageExtraction { + cleanedText: string + images: string[] +} + +export function dataUrlToBlob(dataUrl: string): Blob | null { + const match = DATA_URL_RE.exec(dataUrl.trim()) + + if (!match) { + return null + } + + try { + const bytes = atob(match[2]) + const buffer = new Uint8Array(bytes.length) + + for (let i = 0; i < bytes.length; i += 1) { + buffer[i] = bytes.charCodeAt(i) + } + + return new Blob([buffer], { type: match[1] }) + } catch { + return null + } +} + +function isImageMimeCode(code: number): boolean { + return ( + (code >= 48 && code <= 57) || + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + code === 43 || + code === 45 || + code === 46 || + code === 95 + ) +} + +function isBase64Code(code: number): boolean { + return ( + (code >= 48 && code <= 57) || + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + code === 43 || + code === 47 || + code === 61 + ) +} + +function readDataImageUrl(text: string, start: number): { end: number; url: string } | null { + if (!text.startsWith(DATA_IMAGE_PREFIX, start)) { + return null + } + + let cursor = start + DATA_IMAGE_PREFIX.length + + while (cursor < text.length && isImageMimeCode(text.charCodeAt(cursor))) { + cursor += 1 + } + + if (cursor === start + DATA_IMAGE_PREFIX.length || !text.startsWith(BASE64_MARKER, cursor)) { + return null + } + + cursor += BASE64_MARKER.length + const base64Start = cursor + + while (cursor < text.length && isBase64Code(text.charCodeAt(cursor))) { + cursor += 1 + } + + if (cursor - base64Start < MIN_EMBEDDED_IMAGE_BASE64_LENGTH) { + return null + } + + return { end: cursor, url: text.slice(start, cursor) } +} + +function embeddedImageRemovalRange(text: string, dataStart: number, dataEnd: number): { end: number; start: number } { + let start = dataStart + let end = dataEnd + const openSearchStart = Math.max(0, dataStart - JSON_IMAGE_OPEN_MAX) + const openMatch = text.slice(openSearchStart, dataStart).match(JSON_IMAGE_OPEN_RE) + + if (openMatch?.index !== undefined) { + const close = text.slice(dataEnd, dataEnd + JSON_IMAGE_CLOSE_MAX).match(JSON_IMAGE_CLOSE_RE) + + if (close) { + start = openSearchStart + openMatch.index + end = dataEnd + close[0].length + } + } + + return { end, start } +} + +function normalizeCleanedText(text: string): string { + return text + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +export function extractEmbeddedImages(text: string): EmbeddedImageExtraction { + if (!text || !text.includes(DATA_IMAGE_PREFIX)) { + return { cleanedText: text, images: [] } + } + + const images: string[] = [] + const pieces: string[] = [] + let appendCursor = 0 + let searchCursor = 0 + + while (searchCursor < text.length) { + const dataStart = text.indexOf(DATA_IMAGE_PREFIX, searchCursor) + + if (dataStart === -1) { + break + } + + const dataUrl = readDataImageUrl(text, dataStart) + + if (!dataUrl) { + searchCursor = dataStart + DATA_IMAGE_PREFIX.length + + continue + } + + const range = embeddedImageRemovalRange(text, dataStart, dataUrl.end) + pieces.push(text.slice(appendCursor, range.start)) + images.push(dataUrl.url) + appendCursor = range.end + searchCursor = range.end + } + + if (!images.length) { + return { cleanedText: text, images: [] } + } + + pieces.push(text.slice(appendCursor)) + + return { cleanedText: normalizeCleanedText(pieces.join('')), images } +} + +export function embeddedImageUrls(text: string): string[] { + return extractEmbeddedImages(text).images +} + +export function textWithoutEmbeddedImages(text: string): string { + return extractEmbeddedImages(text).cleanedText +} + +// The gateway persists attached images as `@image:<path>` directive lines +// (see tui_gateway/server.py's persist-time rewrite), prepended before the +// user's own text. The composer's own optimistic/local turn never carries +// this prefix — it keeps the attachment as separate `attachmentRefs` +// metadata, not inline text. The turn-equality comparisons in +// preserveLocalPendingTurnMessages / appendLiveSessionProjection strip ALL +// reference-directive lines (not just images) via +// `textWithoutReferenceLines` in components/assistant-ui/reference-kinds.ts; +// IMAGE_REF_LINE_RE remains here for extractImageRefs below, which moves the +// image directives into attachmentRefs metadata. +const IMAGE_REF_LINE_RE = /^@image:[^\n]*\n?/gm + +// Same directive lines as IMAGE_REF_LINE_RE, but keeps them instead of +// discarding — used when converting persisted server messages into +// ChatMessage/ThreadMessageLike shape, where `@image:<path>` refs need to +// move from inline text into the `attachmentRefs` metadata field (mirroring +// how the local optimistic composer represents attachments) rather than stay +// embedded in the bubble's clamped text body, where a large inline thumbnail +// pushes the caption text out of the clamp's visible area. +// Native-vision turns are stored as a parts list, which the session store +// flattens by replacing each image part with a literal `[screenshot]` line. The +// `@image:` ref describes that same attachment, so keeping both renders the +// placeholder as stray text under the thumbnail. Drop it only when a ref was +// actually lifted, so a `[screenshot]` in a message without attachments stays. +const SCREENSHOT_PLACEHOLDER_LINE_RE = /^\[screenshot\]\n?/gm + +export function extractImageRefs(text: string): { cleanedText: string; refs: string[] } { + const refs: string[] = [] + + let cleanedText = text.replace(IMAGE_REF_LINE_RE, match => { + refs.push(match.trim()) + + return '' + }) + + if (refs.length) { + cleanedText = cleanedText.replace(SCREENSHOT_PLACEHOLDER_LINE_RE, '') + } + + return { cleanedText: cleanedText.trim(), refs } +} diff --git a/ui-desktop/src/lib/escape-layers.test.ts b/ui-desktop/src/lib/escape-layers.test.ts new file mode 100644 index 00000000..9c5d3af5 --- /dev/null +++ b/ui-desktop/src/lib/escape-layers.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' + +import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from './escape-layers' + +describe('escape-layers', () => { + it('reports top when nothing is registered', () => { + expect(isTopEscapeLayer(ESCAPE_PRIORITY.narrowOverlay)).toBe(true) + }) + + it('a lower layer yields to a higher open one, and reclaims top when it closes', () => { + const releaseOverlay = pushEscapeLayer(ESCAPE_PRIORITY.overlay) + + // Narrow overlay is open under a full-page overlay — it must not act. + expect(isTopEscapeLayer(ESCAPE_PRIORITY.narrowOverlay)).toBe(false) + // The overlay itself is top. + expect(isTopEscapeLayer(ESCAPE_PRIORITY.overlay)).toBe(true) + + releaseOverlay() + expect(isTopEscapeLayer(ESCAPE_PRIORITY.narrowOverlay)).toBe(true) + }) + + it('equal-or-higher priority counts as top (ties act)', () => { + const release = pushEscapeLayer(ESCAPE_PRIORITY.zoneEditor) + expect(isTopEscapeLayer(ESCAPE_PRIORITY.zoneEditor)).toBe(true) + expect(isTopEscapeLayer(ESCAPE_PRIORITY.layoutEdit)).toBe(false) + release() + }) + + it('tracks the max across several open layers', () => { + const releases = [ + pushEscapeLayer(ESCAPE_PRIORITY.narrowOverlay), + pushEscapeLayer(ESCAPE_PRIORITY.layoutEdit), + pushEscapeLayer(ESCAPE_PRIORITY.zoneEditor) + ] + + expect(isTopEscapeLayer(ESCAPE_PRIORITY.zoneEditor)).toBe(true) + expect(isTopEscapeLayer(ESCAPE_PRIORITY.layoutEdit)).toBe(false) + + // Close the zone editor — layout edit becomes top. + releases[2]() + expect(isTopEscapeLayer(ESCAPE_PRIORITY.layoutEdit)).toBe(true) + + releases.forEach(release => release()) + expect(isTopEscapeLayer(ESCAPE_PRIORITY.narrowOverlay)).toBe(true) + }) +}) diff --git a/ui-desktop/src/lib/escape-layers.ts b/ui-desktop/src/lib/escape-layers.ts new file mode 100644 index 00000000..bc9bb061 --- /dev/null +++ b/ui-desktop/src/lib/escape-layers.ts @@ -0,0 +1,53 @@ +/** + * Ordered Escape ownership for the app's transient window-level layers. + * + * Several surfaces bind their own `window` `keydown` Escape handler (narrow-pane + * reveal, layout edit mode, the zone editor, full-page overlays). Without a + * shared order a single Escape fired all of them at once — closing a pinned + * pane *and* exiting edit mode, or dismissing an overlay *and* the pane beneath + * it. Radix dialogs already stop propagation / preventDefault, so this is only + * about the app's own handlers. + * + * Contract for a layer handler: + * 1. bail if `event.defaultPrevented` (a higher, propagation-stopping layer + * — a Radix dialog — already handled it); + * 2. bail unless `isTopEscapeLayer(myPriority)` (a higher app layer is open); + * 3. otherwise act and `event.preventDefault()`. + * + * A layer registers its priority (via `pushEscapeLayer`) only while it's open. + */ + +// Higher number = closer to the user. Gaps leave room to slot new layers. +export const ESCAPE_PRIORITY = { + narrowOverlay: 10, + layoutEdit: 20, + zoneEditor: 30, + overlay: 40, + // An in-flight pane drag: Esc means "abort the drag", never ALSO exit edit + // mode / close the overlay the drag started over. Registered only for the + // drag's few-hundred-ms lifetime (drag-session.ts). + drag: 50 +} as const + +const active = new Map<symbol, number>() + +/** Register a layer as open; call the returned disposer when it closes. */ +export function pushEscapeLayer(priority: number): () => void { + const key = Symbol('escape-layer') + active.set(key, priority) + + return () => { + active.delete(key) + } +} + +/** True when no open layer outranks `priority`, so its handler should act. */ +export function isTopEscapeLayer(priority: number): boolean { + for (const p of active.values()) { + if (p > priority) { + return false + } + } + + return true +} diff --git a/ui-desktop/src/lib/excluded-paths.ts b/ui-desktop/src/lib/excluded-paths.ts new file mode 100644 index 00000000..ed21988a --- /dev/null +++ b/ui-desktop/src/lib/excluded-paths.ts @@ -0,0 +1,44 @@ +// Always hidden across the file tree and review (git) tree, regardless of +// .gitignore: the VCS internals, heavyweight dep/build/cache dirs, and OS noise. +// These bloat both trees and are never worth browsing or reviewing — even in +// repos that track them, and in plain non-git folders. +export const ALWAYS_EXCLUDED = new Set([ + '.git', + '.hg', + '.svn', + 'node_modules', + 'bower_components', + '.venv', + 'venv', + 'env', + '__pycache__', + '.mypy_cache', + '.pytest_cache', + '.ruff_cache', + '.tox', + '.gradle', + '.idea', + 'dist', + 'build', + 'out', + 'target', + 'vendor', + 'Pods', + '.next', + '.nuxt', + '.svelte-kit', + '.output', + '.turbo', + '.parcel-cache', + '.cache', + '.terraform', + '.expo', + '.angular', + 'coverage', + '.DS_Store', + 'Thumbs.db' +]) + +// True when any segment of a relative path is excluded (review rows like +// `node_modules/.bin/foo` or a bare `.DS_Store`). Handles `/` and `\`. +export const isExcludedPath = (relPath: string): boolean => relPath.split(/[/\\]/).some(seg => ALWAYS_EXCLUDED.has(seg)) diff --git a/ui-desktop/src/lib/external-link.test.tsx b/ui-desktop/src/lib/external-link.test.tsx new file mode 100644 index 00000000..4a4543ae --- /dev/null +++ b/ui-desktop/src/lib/external-link.test.tsx @@ -0,0 +1,275 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + __resetLinkTitleCache, + ExternalLink, + fetchLinkTitle, + hostPathLabel, + isTitleFetchable, + LinkifiedText, + PrettyLink, + urlSlugTitleLabel +} from './external-link' + +const desktopWindow = window as unknown as { clawcodexDesktop?: Window['clawcodexDesktop'] } +const initialClawCodexDesktop = desktopWindow.clawcodexDesktop + +function installDesktopBridge(partial: Partial<Window['clawcodexDesktop']> = {}) { + desktopWindow.clawcodexDesktop = { + fetchLinkTitle: vi.fn().mockResolvedValue(''), + openExternal: vi.fn().mockResolvedValue(undefined), + ...partial + } as unknown as Window['clawcodexDesktop'] +} + +const FORGEJO_URL = 'https://forgejo.home.example/homelab/homelab-ops/issues/101' + +function installTitleBridge(title: string) { + const bridge = vi.fn().mockResolvedValue(title) + + installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['clawcodexDesktop']['fetchLinkTitle'] }) + + return bridge +} + +afterEach(() => { + __resetLinkTitleCache() + vi.restoreAllMocks() + cleanup() + + if (initialClawCodexDesktop) { + desktopWindow.clawcodexDesktop = initialClawCodexDesktop + } else { + delete desktopWindow.clawcodexDesktop + } +}) + +describe('external link helpers', () => { + it('formats URL fallbacks as host + path', () => { + expect( + hostPathLabel( + 'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/' + ) + ).toBe('getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894') + }) + + it('derives readable title fallbacks from URL slugs', () => { + expect( + urlSlugTitleLabel( + 'https://www.getyourguide.com/fajardo-l882/from-fajardo-icacos-island-full-day-catamaran-trip-t19891/' + ) + ).toBe('From Fajardo Icacos Island Full Day Catamaran Trip') + }) + + it('filters out local/non-http targets for title fetches', () => { + expect(isTitleFetchable('https://www.expedia.com/things-to-do/foo')).toBe(true) + expect(isTitleFetchable('http://localhost:5174')).toBe(false) + expect(isTitleFetchable('file:///tmp/demo.html')).toBe(false) + expect(isTitleFetchable('mailto:hello@example.com')).toBe(false) + }) + + it('deduplicates in-flight title fetches and caches results', async () => { + const bridge = vi.fn().mockResolvedValue('El Yunque Tour Water Slide, Rope Swing & Pickup') + installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['clawcodexDesktop']['fetchLinkTitle'] }) + + const url = + 'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure-with-transport.a46272756.activity-details' + + const [first, second] = await Promise.all([fetchLinkTitle(url), fetchLinkTitle(url)]) + + expect(first).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup') + expect(second).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup') + expect(bridge).toHaveBeenCalledTimes(1) + + const third = await fetchLinkTitle(url) + + expect(third).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup') + expect(bridge).toHaveBeenCalledTimes(1) + }) + + it('shares cache across protocol/www URL variants', async () => { + const bridge = vi.fn().mockResolvedValue('Shared Canonical Title') + installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['clawcodexDesktop']['fetchLinkTitle'] }) + + const first = 'https://www.getyourguide.com/san-juan-puerto-rico-l355/sunset-tours-tc306/' + const second = 'http://getyourguide.com/san-juan-puerto-rico-l355/sunset-tours-tc306/' + + const [a, b] = await Promise.all([fetchLinkTitle(first), fetchLinkTitle(second)]) + + expect(a).toBe('Shared Canonical Title') + expect(b).toBe('Shared Canonical Title') + expect(bridge).toHaveBeenCalledTimes(1) + }) + + it('opens links via the desktop bridge', () => { + const openExternal = vi.fn().mockResolvedValue(undefined) + installDesktopBridge({ openExternal: openExternal as unknown as Window['clawcodexDesktop']['openExternal'] }) + + render(<ExternalLink href="https://example.com/path/to/resource">Example link</ExternalLink>) + + fireEvent.click(screen.getByRole('link', { name: 'Example link' })) + expect(openExternal).toHaveBeenCalledWith('https://example.com/path/to/resource') + }) + + it('hides the trailing external-link icon by default', () => { + installDesktopBridge() + + render(<ExternalLink href="https://example.com/path/to/resource">Example link</ExternalLink>) + + const link = screen.getByRole('link', { name: 'Example link' }) + expect(link.querySelector('svg')).toBeNull() + }) + + it('shows a trailing external-link icon when opted in', () => { + installDesktopBridge() + + render( + <ExternalLink href="https://example.com/path/to/resource" showExternalIcon> + Example link + </ExternalLink> + ) + + const link = screen.getByRole('link', { name: 'Example link' }) + expect(link.querySelector('svg')).toBeTruthy() + }) + + it('renders pretty links with fetched titles and no host suffix', async () => { + const bridge = vi.fn().mockResolvedValue('From Fajardo: Full-Day Culebra Islands Catamaran Tour') + installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['clawcodexDesktop']['fetchLinkTitle'] }) + + const url = + 'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/' + + render(<LinkifiedText text={`Read ${url}`} />) + + const link = screen.getByTitle(url) + expect(link.textContent).toContain('From Fajardo Full Day Cordillera Islands Catamaran Tour') + + await waitFor(() => { + expect(link.textContent).toContain('From Fajardo: Full-Day Culebra Islands Catamaran Tour') + }) + expect(link.textContent).not.toContain('getyourguide.com') + }) + + it('shows host/path fallback when title is unavailable', () => { + installDesktopBridge() + const url = 'https://www.expedia.com/things-to-do/puerto-rico-el-yunque' + + render(<PrettyLink href={url} />) + + const link = screen.getByTitle(url) + + expect(link.textContent).toBe('Puerto Rico El Yunque') + }) + + it('ignores error-like fetched titles and falls back to slug label', async () => { + const bridge = vi.fn().mockResolvedValue('GetYourGuide – Error') + installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['clawcodexDesktop']['fetchLinkTitle'] }) + + const url = + 'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/' + + render(<PrettyLink href={url} />) + + const link = screen.getByTitle(url) + await waitFor(() => { + expect(link.textContent).toBe('From Fajardo Full Day Cordillera Islands Catamaran Tour') + }) + }) + + it('treats not-found fetched titles as unusable', async () => { + const bridge = installTitleBridge('Page not found - Forgejo') + + await expect(fetchLinkTitle(FORGEJO_URL)).resolves.toBe('') + expect(bridge).toHaveBeenCalledTimes(1) + }) + + it('keeps an authored fallbackLabel ahead of a fetched title, and skips the fetch', async () => { + const bridge = installTitleBridge('Kinkolino Forgejo') + + // Chat markdown passes authored link text as `fallbackLabel`, not `label`. + render(<PrettyLink fallbackLabel="FJ #101" href={FORGEJO_URL} />) + + const link = screen.getByTitle(FORGEJO_URL) + + await waitFor(() => { + expect(link.textContent).toContain('FJ #101') + }) + expect(link.textContent).not.toContain('Kinkolino Forgejo') + expect(bridge).not.toHaveBeenCalled() + }) + + it('still resolves a title when no label was authored', async () => { + const bridge = installTitleBridge('Homelab Ops Issue 101') + + render(<PrettyLink href={FORGEJO_URL} />) + + await waitFor(() => { + expect(screen.getByTitle(FORGEJO_URL).textContent).toContain('Homelab Ops Issue 101') + }) + expect(bridge).toHaveBeenCalledTimes(1) + }) + + it('normalizes scheme-less links before opening', () => { + installDesktopBridge() + + render(<LinkifiedText text="Source expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure" />) + + const link = screen.getByRole('link') + expect(link.getAttribute('href')).toBe( + 'https://expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure' + ) + }) + + it('explicitOnly skips bare filename/domain tokens and only links explicit URLs', () => { + installDesktopBridge() + + render( + <LinkifiedText + explicitOnly + pretty={false} + text={'Report https://paste.rs/abc\nagent.log https://paste.rs/def\nerrors.log'} + /> + ) + + const links = screen.getAllByRole('link') + expect(links.map(a => a.getAttribute('href'))).toEqual(['https://paste.rs/abc', 'https://paste.rs/def']) + // Bare filename-shaped tokens stay as plain text, not links. + expect(screen.queryByText(content => content.includes('agent.log'))).toBeTruthy() + expect(links.some(a => (a.textContent ?? '').includes('.log'))).toBe(false) + }) + + it('without explicitOnly, bare filename tokens are still linkified (default behavior)', () => { + installDesktopBridge() + + render(<LinkifiedText pretty={false} text="open agent.log please" />) + + const link = screen.getByRole('link', { name: 'agent.log' }) + expect(link.getAttribute('href')).toBe('https://agent.log') + }) + + it('prefixes a pretty link to a known host with its brand glyph', () => { + installDesktopBridge() + + const url = 'https://github.com/agentforce314/clawcodex/pull/123' + + render(<PrettyLink fallbackLabel="#123" href={url} />) + + const link = screen.getByTitle(url) + + expect(link.querySelector('svg')).toBeTruthy() + // The glyph is decorative — it must not pollute the link's accessible name. + expect(link.textContent).toBe('#123') + }) + + it('renders no brand glyph for an unknown host', () => { + installDesktopBridge() + + const url = 'https://example.com/some/page' + + render(<PrettyLink fallbackLabel="Some Page" href={url} />) + + expect(screen.getByTitle(url).querySelector('svg')).toBeNull() + }) +}) diff --git a/ui-desktop/src/lib/external-link.tsx b/ui-desktop/src/lib/external-link.tsx new file mode 100644 index 00000000..009460bd --- /dev/null +++ b/ui-desktop/src/lib/external-link.tsx @@ -0,0 +1,331 @@ +import type { ComponentProps, ReactNode } from 'react' +import { useEffect, useMemo, useState } from 'react' + +import { ArrowUpRight } from '@/lib/icons' + +import { resolveBrandIcon } from './brand-icon' +import { cn } from './utils' + +const titleCache = new Map<string, string>() +const titleInflight = new Map<string, Promise<string>>() +const titleSubs = new Map<string, Set<(value: string) => void>>() + +const URL_RE = + /(?:https?:\/\/|www\.)[^\s<>"'`]+[^\s<>"'`.,;:!?)]|[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,}(?:\/[^\s<>"'`.,;:!?)]*)?/gi + +// Explicit-scheme / www. URLs only — no bare-domain matching. Used where the +// surrounding text is full of filename-shaped tokens (e.g. `agent.log`, +// `errors.log` in a /debug report) that the bare-domain branch of URL_RE would +// otherwise mistake for domains and linkify. +const EXPLICIT_URL_RE = /(?:https?:\/\/|www\.)[^\s<>"'`]+[^\s<>"'`.,;:!?)]/gi + +const DOMAIN_RE = /^(?:www\.)?[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,}(?::\d+)?(?:[/?#][^\s]*)?$/i +const SKIP_PROTO_RE = /^(?:file|data|mailto|javascript|blob|chrome|about|clawcodex):/i +const LOCAL_HOST_RE = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?$/i + +const ERROR_TITLE_RE = + /\b(?:access denied|attention required|captcha|error|forbidden|just a moment|not found|request blocked|too many requests)\b/i + +export function normalizeExternalUrl(value: string): string { + const trimmed = value.trim() + + if (!trimmed || /^https?:\/\//i.test(trimmed)) { + return trimmed + } + + return DOMAIN_RE.test(trimmed) ? `https://${trimmed}` : trimmed +} + +function parseUrl(value: string): null | URL { + try { + return new URL(normalizeExternalUrl(value)) + } catch { + return null + } +} + +function titleCacheKey(value: string): string { + const url = parseUrl(value) + + if (!url) { + return normalizeExternalUrl(value) + } + + const host = url.hostname.replace(/^www\./i, '').toLowerCase() + const pathname = url.pathname === '/' ? '/' : url.pathname.replace(/\/+$/, '') || '/' + + return `${host}${pathname}${url.search || ''}` +} + +export function shortHostLabel(value: string): string { + return parseUrl(value)?.hostname.replace(/^www\./, '') ?? value +} + +export function hostPathLabel(value: string): string { + const url = parseUrl(value) + + if (!url) { + return value + } + + const host = url.hostname.replace(/^www\./, '') + const path = url.pathname && url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : '' + + return `${host}${path}` +} + +function cleanSlug(segment: string): string { + try { + return decodeURIComponent(segment) + .replace(/\.a\d+\..*$/i, '') + .replace(/\.(?:html?|php|aspx?)$/i, '') + .replace(/(?:[-_.](?:[a-z]{1,3}\d{2,}|i\d{2,}))+$/i, '') + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + } catch { + return '' + } +} + +export function urlSlugTitleLabel(value: string): string { + const url = parseUrl(value) + + for (const segment of url?.pathname.split('/').filter(Boolean).reverse() ?? []) { + const cleaned = cleanSlug(segment) + + if (!cleaned || !/[a-z]/i.test(cleaned)) { + continue + } + + if (/^(?:[a-z]{1,3}\d+|\d+)$/i.test(cleaned.replace(/\s+/g, ''))) { + continue + } + + const titled = cleaned.replace(/\b[a-z]/g, c => c.toUpperCase()) + + if (titled.length >= 4) { + return titled + } + } + + return hostPathLabel(value) +} + +export function isTitleFetchable(value: string): boolean { + if (!value || SKIP_PROTO_RE.test(value)) { + return false + } + + const url = parseUrl(value) + + return Boolean(url && /^https?:$/.test(url.protocol) && !LOCAL_HOST_RE.test(url.host)) +} + +export function fetchLinkTitle(url: string): Promise<string> { + const normalizedUrl = normalizeExternalUrl(url) + const key = titleCacheKey(normalizedUrl) + + if (!isTitleFetchable(normalizedUrl)) { + return Promise.resolve('') + } + + if (titleCache.has(key)) { + return Promise.resolve(titleCache.get(key) ?? '') + } + + const pending = titleInflight.get(key) + + if (pending) { + return pending + } + + const bridge = typeof window === 'undefined' ? undefined : window.clawcodexDesktop?.fetchLinkTitle + + if (!bridge) { + titleCache.set(key, '') + + return Promise.resolve('') + } + + const promise = bridge(normalizedUrl) + .then(value => (value || '').replace(/\s+/g, ' ').trim()) + .then(clean => (clean && !ERROR_TITLE_RE.test(clean) ? clean : '')) + .catch(() => '') + .then(safe => { + titleCache.set(key, safe) + titleInflight.delete(key) + titleSubs.get(key)?.forEach(sub => sub(safe)) + + return safe + }) + + titleInflight.set(key, promise) + + return promise +} + +export function useLinkTitle(url?: null | string): string { + const normalizedUrl = useMemo(() => (url ? normalizeExternalUrl(url) : ''), [url]) + const key = useMemo(() => (normalizedUrl ? titleCacheKey(normalizedUrl) : ''), [normalizedUrl]) + const [title, setTitle] = useState(() => (key ? (titleCache.get(key) ?? '') : '')) + + useEffect(() => { + setTitle(key ? (titleCache.get(key) ?? '') : '') + + if (!key || !isTitleFetchable(normalizedUrl)) { + return + } + + const subs = titleSubs.get(key) ?? new Set<(value: string) => void>() + + subs.add(setTitle) + titleSubs.set(key, subs) + void fetchLinkTitle(normalizedUrl) + + return () => { + subs.delete(setTitle) + + if (!subs.size) { + titleSubs.delete(key) + } + } + }, [key, normalizedUrl]) + + return title +} + +export function openExternalLink(href: string): void { + if (href) { + void window.clawcodexDesktop?.openExternal?.(href) + } +} + +interface ExternalLinkProps extends Omit<ComponentProps<'a'>, 'href' | 'target'> { + href: string + children?: ReactNode + showExternalIcon?: boolean +} + +export function ExternalLinkIcon({ className }: { className?: string }) { + return <ArrowUpRight aria-hidden className={cn('ml-1 inline size-[0.78em] align-[-0.08em] opacity-70', className)} /> +} + +// Brand mark for a known host, sized in `em` so it tracks the surrounding text +// at any font size. It paints in `currentColor` rather than the brand hex — +// several brand colors (GitHub's near-black, Unity's white) vanish against one +// theme or the other. +// +// `title=""` is load-bearing: Simple Icons always renders a <title> defaulting +// to the brand name, which lands in the anchor's textContent and accessible +// name — a PR link would read "GitHub#123". +export function LinkBrandIcon({ className, href }: { className?: string; href: string }) { + const Icon = resolveBrandIcon(shortHostLabel(href)) + + return Icon ? ( + <Icon aria-hidden className={cn('mr-1 inline size-[0.85em] align-[-0.12em] opacity-80', className)} title="" /> + ) : null +} + +export function ExternalLink({ + children, + className, + href, + onClick, + showExternalIcon = false, + ...rest +}: ExternalLinkProps) { + const target = normalizeExternalUrl(href) + + return ( + <a + className={cn('ref', className)} + href={target} + onClick={event => { + event.stopPropagation() + onClick?.(event) + + if (event.defaultPrevented) { + return + } + + event.preventDefault() + openExternalLink(target) + }} + rel="noopener noreferrer" + target="_blank" + {...rest} + > + {children ?? urlSlugTitleLabel(target)} + {showExternalIcon && <ExternalLinkIcon />} + </a> + ) +} + +interface PrettyLinkProps extends Omit<ComponentProps<'a'>, 'href' | 'target'> { + href: string + label?: string + fallbackLabel?: string +} + +// Title resolution is a fallback, not an override. Both props carry authored +// text — chat markdown passes `fallbackLabel` — so either one skips the fetch. +export function PrettyLink({ className, fallbackLabel, href, label, ...rest }: PrettyLinkProps) { + const target = useMemo(() => normalizeExternalUrl(href), [href]) + const authoredLabel = label?.trim() || fallbackLabel?.trim() + const fetched = useLinkTitle(authoredLabel ? null : target) + const display = authoredLabel || fetched || urlSlugTitleLabel(target) + + return ( + <ExternalLink className={cn('wrap-break-word', className)} href={target} title={target} {...rest}> + <LinkBrandIcon href={target} /> + {display} + </ExternalLink> + ) +} + +interface LinkifiedTextProps { + className?: string + text: string + pretty?: boolean + explicitOnly?: boolean +} + +export function LinkifiedText({ className, explicitOnly = false, pretty = true, text }: LinkifiedTextProps) { + const nodes: ReactNode[] = [] + let cursor = 0 + + for (const match of text.matchAll(explicitOnly ? EXPLICIT_URL_RE : URL_RE)) { + const raw = match[0] + const url = normalizeExternalUrl(raw) + const index = match.index ?? 0 + + if (index > cursor) { + nodes.push(text.slice(cursor, index)) + } + + nodes.push( + pretty ? ( + <PrettyLink href={url} key={`${url}-${index}`} /> + ) : ( + <ExternalLink href={url} key={`${url}-${index}`}> + {raw} + </ExternalLink> + ) + ) + + cursor = index + raw.length + } + + if (cursor < text.length) { + nodes.push(text.slice(cursor)) + } + + return <span className={className}>{nodes.length ? nodes : text}</span> +} + +export function __resetLinkTitleCache(): void { + titleCache.clear() + titleInflight.clear() + titleSubs.clear() +} diff --git a/ui-desktop/src/lib/find-in-page.ts b/ui-desktop/src/lib/find-in-page.ts new file mode 100644 index 00000000..1890484c --- /dev/null +++ b/ui-desktop/src/lib/find-in-page.ts @@ -0,0 +1,109 @@ +// Pure logic for the find-in-page bar (⌘F). Kept out of the component so the +// match-counter projection and the in-bar key routing can be unit-tested +// without jsdom, a BrowserWindow, or the preload bridge. +// +// The Electron side of the feature lives in electron/find-in-page.ts; this is +// strictly renderer presentation logic. + +/** + * Counter shown next to the input, e.g. `"3/12"`. + * + * Three distinct states, and they are not the same thing: + * - No query → `''`. The counter hides entirely rather than claiming "0/0" + * before the user has asked anything. + * - A query with no matches → `'0/0'`. An explicit, honest zero. + * - A query with matches → `'<ordinal>/<count>'`. + * + * `activeMatchOrdinal` is 1-indexed and can legitimately arrive as 0 from + * Electron for the frame between issuing a search and the first match being + * selected, so the ordinal is clamped into `[0, count]` rather than trusted. + */ +export function formatMatchLabel(query: string, activeMatchOrdinal: number, matchCount: number): string { + if (!query) { + return '' + } + + const count = Number.isFinite(matchCount) && matchCount > 0 ? Math.floor(matchCount) : 0 + + if (count === 0) { + return '0/0' + } + + const raw = Number.isFinite(activeMatchOrdinal) ? Math.floor(activeMatchOrdinal) : 0 + const ordinal = Math.min(Math.max(raw, 0), count) + + return `${ordinal}/${count}` +} + +/** What a keypress means to an open find bar. `null` = not ours, let it through. */ +export type FindBarKeyAction = 'close' | 'next' | 'previous' | null + +/** The subset of a keyboard event the matcher needs — works for DOM and React events. */ +export interface FindBarKeyEvent { + key: string + shiftKey?: boolean + metaKey?: boolean + ctrlKey?: boolean + altKey?: boolean +} + +/** + * Map a keypress to a find-bar action while the bar is open. + * + * Two families, matching the platform convention Chrome/Safari/VS Code (and + * Claude Desktop's `findInPage` accelerators) all share: + * - Bare `Enter` / `Shift+Enter` step forward / backward. Only valid while + * focus is in the find input, so callers pass `inInput: true` there. + * - `⌘G` / `⌘⇧G` (Ctrl+G / Ctrl+Shift+G off macOS) step forward / backward + * from anywhere while the bar is open — that is the accelerator pair, and it + * must not require the input to hold focus. + * - `Escape` closes from anywhere. + * + * `Alt` is treated as disqualifying so ⌥⌘G and friends fall through to + * whatever else may want them instead of being silently swallowed. + */ +export function findBarKeyAction(event: FindBarKeyEvent, options: { inInput?: boolean } = {}): FindBarKeyAction { + if (event.altKey) { + return null + } + + const mod = Boolean(event.metaKey || event.ctrlKey) + + if (event.key === 'Escape') { + return mod ? null : 'close' + } + + // `event.key` for the G key is 'g' unshifted and 'G' with Shift held, so + // compare case-insensitively and read direction from `shiftKey` alone. + if (mod && event.key.toLowerCase() === 'g') { + return event.shiftKey ? 'previous' : 'next' + } + + if (event.key === 'Enter' && !mod && options.inInput) { + return event.shiftKey ? 'previous' : 'next' + } + + return null +} + +/** + * Combos the open find bar owns, in canonical `comboFromEvent` form. + * + * The global keybind dispatcher (app/hooks/use-keybinds.ts) consults this + * before routing a combo to the registry. Without it, three real collisions + * fire alongside the find bar: + * - `mod+g` → `view.toggleReview` (⌘G is the review pane's default). + * - `mod+shift+g` → whatever a user has bound there. + * - `escape` → `composer.cancel`, which would abort a running turn while the + * user only meant to dismiss the find bar. + * + * `stopPropagation` cannot solve this: both listeners sit on `window` in the + * capture phase, and propagation control does not suppress sibling listeners + * on the same target. Ownership has to be decided by the dispatcher, which is + * the documented single owner of combo dispatch. This matches the + * "keyboard ownership follows focus / one cancel gesture does one thing" + * invariant in apps/desktop/AGENTS.md. + */ +export function findBarClaimsCombo(combo: string): boolean { + return combo === 'mod+g' || combo === 'mod+shift+g' || combo === 'escape' +} diff --git a/ui-desktop/src/lib/format.ts b/ui-desktop/src/lib/format.ts new file mode 100644 index 00000000..3ecb762f --- /dev/null +++ b/ui-desktop/src/lib/format.ts @@ -0,0 +1,24 @@ +// THE compact-number formatter — every user-facing count/token figure goes +// through here. 999 → "999", 1000 → "1k", 1230 → "1.2k", 10000 → "10k", +// 1_500_000 → "1.5M". Do not hand-roll `/ 1000` display math elsewhere. +export function compactNumber(value: null | number | undefined): string { + const num = Number(value ?? 0) + + if (!Number.isFinite(num) || num <= 0) { + return '0' + } + + const scaled = (v: number, suffix: string) => `${v.toFixed(1).replace(/\.0$/, '')}${suffix}` + + // Thresholds sit just under the unit boundary so rounding can't produce + // "1000k" or "1000" — those promote to the next unit instead. + if (num >= 999_950) { + return scaled(num / 1_000_000, 'M') + } + + if (num >= 999.5) { + return scaled(num / 1_000, 'k') + } + + return `${Math.round(num)}` +} diff --git a/ui-desktop/src/lib/gateway-events.test.ts b/ui-desktop/src/lib/gateway-events.test.ts new file mode 100644 index 00000000..02c3f643 --- /dev/null +++ b/ui-desktop/src/lib/gateway-events.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' + +import { gatewayEventRequiresSessionId, resolveGatewayEventSessionId } from './gateway-events' + +describe('gateway event routing', () => { + it('drops only unscoped subagent events (genuinely background work)', () => { + expect(gatewayEventRequiresSessionId('subagent.progress')).toBe(true) + expect(gatewayEventRequiresSessionId('subagent.start')).toBe(true) + }) + + it('attributes unscoped foreground turn events to the active chat', () => { + // These must NOT be dropped when unscoped — they are the focused turn's own + // output, and dropping them loses the live response until a refetch (#42178). + expect(gatewayEventRequiresSessionId('message.delta')).toBe(false) + expect(gatewayEventRequiresSessionId('message.complete')).toBe(false) + expect(gatewayEventRequiresSessionId('message.interim')).toBe(false) + expect(gatewayEventRequiresSessionId('reasoning.delta')).toBe(false) + expect(gatewayEventRequiresSessionId('tool.start')).toBe(false) + expect(gatewayEventRequiresSessionId('approval.request')).toBe(false) + }) + + it('allows global events to remain unscoped', () => { + expect(gatewayEventRequiresSessionId('gateway.ready')).toBe(false) + expect(gatewayEventRequiresSessionId('preview.restart.progress')).toBe(false) + expect(gatewayEventRequiresSessionId('session.info')).toBe(false) + expect(gatewayEventRequiresSessionId(undefined)).toBe(false) + }) + + it('keeps unscoped stream events pinned to the session that started them', () => { + const started = resolveGatewayEventSessionId({ + activeSessionId: 'session-a', + eventType: 'message.start', + explicitSessionId: '', + unscopedStreamSessionId: null + }) + + expect(started).toEqual({ + drop: false, + nextUnscopedStreamSessionId: 'session-a', + sessionId: 'session-a' + }) + + const delta = resolveGatewayEventSessionId({ + activeSessionId: 'session-b', + eventType: 'message.delta', + explicitSessionId: '', + unscopedStreamSessionId: started.nextUnscopedStreamSessionId + }) + + expect(delta).toEqual({ + drop: false, + nextUnscopedStreamSessionId: 'session-a', + sessionId: 'session-a' + }) + + const completed = resolveGatewayEventSessionId({ + activeSessionId: 'session-b', + eventType: 'message.complete', + explicitSessionId: '', + unscopedStreamSessionId: delta.nextUnscopedStreamSessionId + }) + + expect(completed).toEqual({ + drop: false, + nextUnscopedStreamSessionId: null, + sessionId: 'session-a' + }) + }) + + it('routes a new unscoped stream start to the currently active session', () => { + const routed = resolveGatewayEventSessionId({ + activeSessionId: 'session-b', + eventType: 'message.start', + explicitSessionId: '', + unscopedStreamSessionId: 'session-a' + }) + + expect(routed).toEqual({ + drop: false, + nextUnscopedStreamSessionId: 'session-b', + sessionId: 'session-b' + }) + }) + + it('keeps explicit events scoped and clears a matching pinned stream on completion', () => { + const routed = resolveGatewayEventSessionId({ + activeSessionId: 'session-b', + eventType: 'message.complete', + explicitSessionId: 'session-a', + unscopedStreamSessionId: 'session-a' + }) + + expect(routed).toEqual({ + drop: false, + nextUnscopedStreamSessionId: null, + sessionId: 'session-a' + }) + }) +}) diff --git a/ui-desktop/src/lib/gateway-events.ts b/ui-desktop/src/lib/gateway-events.ts new file mode 100644 index 00000000..005e7970 --- /dev/null +++ b/ui-desktop/src/lib/gateway-events.ts @@ -0,0 +1,158 @@ +import type { StatusbarMenuItem } from '@/app/shell/statusbar-controls' + +const LOG_TAIL = 5 + +interface RpcEventLike { + payload?: unknown + type?: string +} + +function asRecord(payload: unknown): Record<string, unknown> { + return payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {} +} + +/** + * Unscoped stream events that must stay pinned to the session that received + * ``message.start`` after the user switches chats mid-turn (#47709 / #48281). + * Without this, ``explicitSid || activeSessionId`` reattributes live deltas to + * the newly focused chat. + */ +const UNSCOPED_STREAM_EVENT_TYPES = new Set([ + 'approval.request', + 'browser.progress', + 'clarify.request', + 'error', + 'message.complete', + 'message.delta', + 'message.interim', + 'message.start', + 'reasoning.available', + 'reasoning.delta', + 'secret.request', + 'status.update', + 'sudo.request', + 'thinking.delta', + 'tool.complete', + 'tool.generating', + 'tool.progress', + 'tool.start' +]) + +const UNSCOPED_STREAM_END_EVENT_TYPES = new Set(['error', 'message.complete']) + +/** + * Whether an unscoped event (no `session_id`) must be dropped rather than + * attributed to the focused chat. + * + * Only `subagent.*` qualifies: it describes background/async work that must + * never attach to whichever chat happens to be focused. Every other scoped + * event — message/reasoning/thinking/tool/status/prompt — is, when unscoped, + * the active turn's own output. The gateway always stamps a *background* + * session's events with that session's id, so a missing id can only mean "the + * focused turn". #42178 dropped those too, which silently swallowed the live + * answer; it then reappeared only after a transcript refetch (manual refresh). + */ +export function gatewayEventRequiresSessionId(eventType: string | undefined): boolean { + return eventType?.startsWith('subagent.') ?? false +} + +export interface GatewayEventSessionRouteInput { + activeSessionId: null | string + eventType: string | undefined + explicitSessionId: string + unscopedStreamSessionId: null | string +} + +export interface GatewayEventSessionRoute { + drop: boolean + nextUnscopedStreamSessionId: null | string + sessionId: null | string +} + +/** + * Resolve which runtime session owns a gateway event. + * + * Explicit ``session_id`` always wins. Unscoped stream events pin to the + * session that received ``message.start`` so a mid-turn chat switch cannot + * steal live deltas / tool events onto the newly focused transcript. + */ +export function resolveGatewayEventSessionId({ + activeSessionId, + eventType, + explicitSessionId, + unscopedStreamSessionId +}: GatewayEventSessionRouteInput): GatewayEventSessionRoute { + if (explicitSessionId) { + const nextUnscopedStreamSessionId = + eventType && UNSCOPED_STREAM_END_EVENT_TYPES.has(eventType) && explicitSessionId === unscopedStreamSessionId + ? null + : unscopedStreamSessionId + + return { + drop: false, + nextUnscopedStreamSessionId, + sessionId: explicitSessionId + } + } + + if (gatewayEventRequiresSessionId(eventType)) { + return { + drop: true, + nextUnscopedStreamSessionId: unscopedStreamSessionId, + sessionId: null + } + } + + const streamEvent = eventType ? UNSCOPED_STREAM_EVENT_TYPES.has(eventType) : false + + const sessionId = + eventType === 'message.start' + ? activeSessionId + : streamEvent + ? unscopedStreamSessionId || activeSessionId + : activeSessionId + + let nextUnscopedStreamSessionId = unscopedStreamSessionId + + if (eventType === 'message.start' && activeSessionId) { + nextUnscopedStreamSessionId = activeSessionId + } else if (eventType && UNSCOPED_STREAM_END_EVENT_TYPES.has(eventType)) { + nextUnscopedStreamSessionId = null + } + + return { + drop: false, + nextUnscopedStreamSessionId, + sessionId + } +} + +export function gatewayEventCompletedFileDiff(event: RpcEventLike): boolean { + if (event.type !== 'tool.complete') { + return false + } + + const diff = asRecord(event.payload).inline_diff + + return typeof diff === 'string' && diff.trim().length > 0 +} + +export function buildGatewayLogItems(lines: readonly string[]): readonly StatusbarMenuItem[] { + if (lines.length === 0) { + return [ + { + className: 'text-muted-foreground', + disabled: true, + id: 'gateway-log-empty', + label: 'No recent gateway log lines' + } + ] + } + + return lines.slice(-LOG_TAIL).map((line, index) => ({ + className: 'font-mono text-[0.68rem] text-muted-foreground', + disabled: true, + id: `gateway-log:${index}`, + label: line.trim().slice(0, 120) || '(blank log line)' + })) +} diff --git a/ui-desktop/src/lib/gateway-rpc.test.ts b/ui-desktop/src/lib/gateway-rpc.test.ts new file mode 100644 index 00000000..ba8967d0 --- /dev/null +++ b/ui-desktop/src/lib/gateway-rpc.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' + +import { isMissingPendingPromptRequest, isMissingRpcMethod } from './gateway-rpc' + +describe('isMissingRpcMethod', () => { + it('detects JSON-RPC method-not-found errors', () => { + expect(isMissingRpcMethod(new Error('unknown method: projects.create'))).toBe(true) + expect(isMissingRpcMethod(new Error('Method not found'))).toBe(true) + expect(isMissingRpcMethod(new Error('RPC failed: -32601'))).toBe(true) + }) + + it('ignores unrelated failures', () => { + expect(isMissingRpcMethod(new Error('ClawCodex gateway is not connected'))).toBe(false) + expect(isMissingRpcMethod(new Error('no such project'))).toBe(false) + }) +}) + +describe('isMissingPendingPromptRequest', () => { + it('detects stale prompt response errors from the gateway', () => { + expect(isMissingPendingPromptRequest(new Error('no pending password request'), 'password')).toBe(true) + expect(isMissingPendingPromptRequest(new Error('RPC failed: no pending value request'), 'value')).toBe(true) + }) + + it('ignores unrelated gateway failures', () => { + expect(isMissingPendingPromptRequest(new Error('gateway not connected'), 'password')).toBe(false) + expect(isMissingPendingPromptRequest(new Error('no pending value request'), 'password')).toBe(false) + }) +}) diff --git a/ui-desktop/src/lib/gateway-rpc.ts b/ui-desktop/src/lib/gateway-rpc.ts new file mode 100644 index 00000000..372cf0bc --- /dev/null +++ b/ui-desktop/src/lib/gateway-rpc.ts @@ -0,0 +1,21 @@ +/** True when a JSON-RPC call failed because the backend predates the method. */ +export function isMissingRpcMethod(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + + return /method not found|-32601|unknown method|no such method/i.test(message) +} + +/** True when a prompt response raced a backend-side timeout / completion. */ +export function isMissingPendingPromptRequest(error: unknown, key: string): boolean { + const message = error instanceof Error ? error.message : String(error) + + return message.toLowerCase().includes(`no pending ${key.toLowerCase()} request`) +} + +/** True when a pre-deferral backend refused a mid-turn model switch (4009). + * Current gateways park the pick and answer `scope: "pending"` instead. */ +export function isBusySessionModelSwitch(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + + return /session busy/i.test(message) && /switching models/i.test(message) +} diff --git a/ui-desktop/src/lib/gateway-ws-url.test.ts b/ui-desktop/src/lib/gateway-ws-url.test.ts new file mode 100644 index 00000000..b11f12ed --- /dev/null +++ b/ui-desktop/src/lib/gateway-ws-url.test.ts @@ -0,0 +1,116 @@ +import { GatewayReauthRequiredError, isGatewayReauthRequired, resolveGatewayWsUrl } from '@clawcodex/shared' +import { describe, expect, it, vi } from 'vitest' + +const oauthConn = { authMode: 'oauth' as const, wsUrl: 'ws://host/api/ws?ticket=stale' } +const tokenConn = { authMode: 'token' as const, wsUrl: 'ws://host/api/ws?token=abc' } + +describe('resolveGatewayWsUrl', () => { + describe('oauth mode', () => { + it('uses the freshly minted URL', async () => { + const getGatewayWsUrl = vi.fn().mockResolvedValue('ws://host/api/ws?ticket=fresh') + await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).resolves.toBe('ws://host/api/ws?ticket=fresh') + expect(getGatewayWsUrl).toHaveBeenCalledOnce() + }) + + it('uses the structured URL returned across the Electron IPC boundary', async () => { + const getGatewayWsUrl = vi.fn().mockResolvedValue({ ok: true, wsUrl: 'ws://host/api/ws?ticket=fresh' }) + + await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).resolves.toBe('ws://host/api/ws?ticket=fresh') + }) + + it('throws a reauth error when the main process reports an auth rejection', async () => { + const getGatewayWsUrl = vi.fn().mockResolvedValue({ + error: '401 cookie expired', + needsOauthLogin: true, + ok: false + }) + + await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).rejects.toBeInstanceOf( + GatewayReauthRequiredError + ) + }) + + it('preserves the main-process auth failure as the cause', async () => { + const getGatewayWsUrl = vi.fn().mockResolvedValue({ + error: '401 cookie expired', + needsOauthLogin: true, + ok: false + }) + + const error = await resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn).catch(e => e) + expect(error).toBeInstanceOf(GatewayReauthRequiredError) + expect((error as GatewayReauthRequiredError).cause).toMatchObject({ message: '401 cookie expired' }) + }) + + it('keeps a transport failure retryable instead of demanding sign-in', async () => { + const getGatewayWsUrl = vi.fn().mockResolvedValue({ error: 'gateway timed out', ok: false }) + const error = await resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn).catch(e => e) + + expect(error).toMatchObject({ message: 'gateway timed out' }) + expect(isGatewayReauthRequired(error)).toBe(false) + }) + + it('rethrows an unexpected transport rejection unchanged', async () => { + const cause = new Error('socket closed') + const getGatewayWsUrl = vi.fn().mockRejectedValue(cause) + + await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).rejects.toBe(cause) + }) + + it('reports a missing preload method as an app capability error, not reauth', async () => { + const error = await resolveGatewayWsUrl({}, oauthConn).catch(e => e) + + expect(error).toMatchObject({ message: expect.stringMatching(/cannot refresh OAuth WebSocket tickets/i) }) + expect(isGatewayReauthRequired(error)).toBe(false) + }) + + it('never returns the stale cached ticket on failure', async () => { + const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('boom')) + const result = await resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn).catch(() => 'threw') + expect(result).toBe('threw') + expect(result).not.toBe(oauthConn.wsUrl) + }) + }) + + describe('token / local mode', () => { + it('uses the minted URL when available', async () => { + const getGatewayWsUrl = vi.fn().mockResolvedValue('ws://host/api/ws?token=fresh') + await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe('ws://host/api/ws?token=fresh') + }) + + it('uses a structured refreshed token URL when available', async () => { + const getGatewayWsUrl = vi.fn().mockResolvedValue({ ok: true, wsUrl: 'ws://host/api/ws?token=fresh' }) + + await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe('ws://host/api/ws?token=fresh') + }) + + it('falls back to the cached URL when minting fails (token is long-lived)', async () => { + const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('transient')) + await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe(tokenConn.wsUrl) + }) + + it('falls back to the cached URL when the preload method is absent', async () => { + await expect(resolveGatewayWsUrl({}, tokenConn)).resolves.toBe(tokenConn.wsUrl) + }) + + it('treats a missing authMode as non-oauth (falls back safely)', async () => { + await expect(resolveGatewayWsUrl({}, { wsUrl: tokenConn.wsUrl })).resolves.toBe(tokenConn.wsUrl) + }) + }) +}) + +describe('isGatewayReauthRequired', () => { + it('detects the dedicated error class', () => { + expect(isGatewayReauthRequired(new GatewayReauthRequiredError('x'))).toBe(true) + }) + + it('detects plain objects tagged with needsOauthLogin (from the main process)', () => { + expect(isGatewayReauthRequired({ needsOauthLogin: true })).toBe(true) + }) + + it('rejects generic errors', () => { + expect(isGatewayReauthRequired(new Error('connection closed'))).toBe(false) + expect(isGatewayReauthRequired(null)).toBe(false) + expect(isGatewayReauthRequired('string')).toBe(false) + }) +}) diff --git a/ui-desktop/src/lib/generated-images.test.ts b/ui-desktop/src/lib/generated-images.test.ts new file mode 100644 index 00000000..1a069321 --- /dev/null +++ b/ui-desktop/src/lib/generated-images.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' + +import { + dedupeGeneratedImageEchoesInParts, + generatedImageEchoSources, + generatedImageFromResult, + stripGeneratedImageEchoes +} from './generated-images' + +describe('generatedImageFromResult', () => { + it('prefers the host-visible image path', () => { + expect( + generatedImageFromResult({ + agent_visible_image: '/container/cache/cat.png', + host_image: '/Users/me/.clawcodex/cache/images/cat.png', + image: '/Users/me/.clawcodex/cache/images/cat.png', + success: true + }) + ).toBe('/Users/me/.clawcodex/cache/images/cat.png') + }) + + it('ignores failed image generation results', () => { + expect(generatedImageFromResult({ image: 'https://cdn.example/cat.png', success: false })).toBeNull() + }) +}) + +describe('stripGeneratedImageEchoes', () => { + it('removes repeated generated image markdown without removing prose', () => { + expect( + stripGeneratedImageEchoes('Here you go.\n\n![Generated image](https://cdn.example/cat.png)', [ + 'https://cdn.example/cat.png' + ]) + ).toBe('Here you go.') + }) + + it('removes media links for generated local image paths', () => { + expect(stripGeneratedImageEchoes('Saved image: [Image: cat.png](#media:%2Ftmp%2Fcat.png)', ['/tmp/cat.png'])).toBe( + 'Saved image:' + ) + }) +}) + +describe('generatedImageEchoSources', () => { + it('collects every path variant the model might restate', () => { + expect( + generatedImageEchoSources([ + { + result: { + agent_visible_image: '/sandbox/cat.png', + host_image: '/host/cat.png', + image: '/host/cat.png', + success: true + }, + toolName: 'image_generate', + type: 'tool-call' + } + ]) + ).toEqual(['/host/cat.png', '/sandbox/cat.png']) + }) +}) + +describe('dedupeGeneratedImageEchoesInParts', () => { + it('keeps the agent prose while removing the duplicated image', () => { + expect( + dedupeGeneratedImageEchoesInParts([ + { text: 'Here is your peacock! ![peacock](/host/p.png) Enjoy.', type: 'text' }, + { + result: { host_image: '/host/p.png', image: '/host/p.png', success: true }, + toolName: 'image_generate', + type: 'tool-call' + } + ]) + ).toEqual([ + { text: 'Here is your peacock! Enjoy.', type: 'text' }, + { + result: { host_image: '/host/p.png', image: '/host/p.png', success: true }, + toolName: 'image_generate', + type: 'tool-call' + } + ]) + }) + + it('strips a sandbox path the model restated instead of the host path', () => { + expect( + dedupeGeneratedImageEchoesInParts([ + { text: '![cat](/sandbox/cat.png)', type: 'text' }, + { + result: { + agent_visible_image: '/sandbox/cat.png', + host_image: '/host/cat.png', + image: '/host/cat.png', + success: true + }, + toolName: 'image_generate', + type: 'tool-call' + } + ]) + ).toEqual([ + { + result: { + agent_visible_image: '/sandbox/cat.png', + host_image: '/host/cat.png', + image: '/host/cat.png', + success: true + }, + toolName: 'image_generate', + type: 'tool-call' + } + ]) + }) + + it('leaves pending generations untouched so the agent prose survives', () => { + const parts = [ + { text: 'Another peacock, coming up!', type: 'text' }, + { result: undefined, toolName: 'image_generate', type: 'tool-call' } + ] + + expect(dedupeGeneratedImageEchoesInParts(parts)).toEqual(parts) + }) +}) diff --git a/ui-desktop/src/lib/generated-images.ts b/ui-desktop/src/lib/generated-images.ts new file mode 100644 index 00000000..69b31573 --- /dev/null +++ b/ui-desktop/src/lib/generated-images.ts @@ -0,0 +1,114 @@ +type ToolLike = { + result?: unknown + toolName?: unknown + type?: unknown +} + +type TextLike = { + text?: unknown + type?: unknown +} + +// Path-ish result fields the model may echo into its prose. Display prefers the +// host path (gateway-deliverable); stripping must catch every variant so a +// sandbox path the model restated doesn't slip through as a duplicate image. +const DISPLAY_KEYS = ['host_image', 'image'] as const +const ECHO_KEYS = ['host_image', 'image', 'agent_visible_image'] as const + +function recordFromUnknown(value: unknown): Record<string, unknown> | null { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record<string, unknown> + } + + if (typeof value !== 'string' || !value.trim()) { + return null + } + + try { + const parsed = JSON.parse(value) + + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null + } catch { + return null + } +} + +function stringFields(record: Record<string, unknown>, keys: readonly string[]): string[] { + return keys.map(key => record[key]).filter((v): v is string => typeof v === 'string' && v.trim().length > 0) +} + +function regexEscape(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function unique(values: string[]): string[] { + return [...new Set(values.filter(Boolean))] +} + +function imageResult(part: ToolLike): Record<string, unknown> | null { + if (part.type !== 'tool-call' || part.toolName !== 'image_generate') { + return null + } + + const record = recordFromUnknown(part.result) + + return record && record.success !== false ? record : null +} + +/** Display source for a completed `image_generate` result (host path wins). */ +export function generatedImageFromResult(result: unknown): string | null { + const record = recordFromUnknown(result) + + if (!record || record.success === false) { + return null + } + + return stringFields(record, DISPLAY_KEYS)[0] ?? null +} + +/** Every path/URL a generated image might appear as in prose, for de-duping. */ +export function generatedImageEchoSources(parts: readonly ToolLike[]): string[] { + return unique(parts.flatMap(part => stringFields(imageResult(part) ?? {}, ECHO_KEYS))) +} + +/** Strip a generated image out of prose so it only ever shows in the tool slot. + * Once a generation succeeded (`sources` is non-empty) we drop every embedded + * image and media link from that message — the model frequently restates the + * remote URL while the result holds the local path, so matching the exact + * source is not enough. Bare occurrences of the known paths/URLs are removed + * too. Surrounding prose is preserved. */ +export function stripGeneratedImageEchoes(text: string, sources: readonly string[]): string { + if (!text || sources.length === 0) { + return text + } + + let next = text.replace(/!\[[^\]\n]*\]\([^)\n]*\)/g, '').replace(/\[[^\]\n]*\]\(\s*#media:[^)\n]*\)/g, '') + + for (const source of unique([...sources])) { + next = next.replace(new RegExp(String.raw`(^|[\s([{])<?${regexEscape(source)}>?(?=$|[\s)\]},.!?])`, 'g'), '$1') + } + + return next + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .replace(/[ \t]{2,}/g, ' ') + .trim() +} + +/** Strip generated-image echoes from text parts, dropping any part left empty. + * The image lives in the tool slot; prose keeps the agent's actual words. */ +export function dedupeGeneratedImageEchoesInParts<T extends TextLike & ToolLike>(parts: readonly T[]): T[] { + const sources = generatedImageEchoSources(parts) + + if (!sources.length) { + return [...parts] + } + + return parts + .map(part => + part.type === 'text' && typeof part.text === 'string' + ? { ...part, text: stripGeneratedImageEchoes(part.text, sources) } + : part + ) + .filter(part => part.type !== 'text' || (typeof part.text === 'string' && part.text.trim().length > 0)) +} diff --git a/ui-desktop/src/lib/haptics.ts b/ui-desktop/src/lib/haptics.ts new file mode 100644 index 00000000..83daf7be --- /dev/null +++ b/ui-desktop/src/lib/haptics.ts @@ -0,0 +1,129 @@ +import type { HapticInput, TriggerOptions } from 'web-haptics' + +import { $hapticsMuted } from '@/store/haptics' + +export type HapticIntent = + | 'cancel' + | 'close' + | 'crisp' + | 'error' + | 'open' + | 'selection' + | 'streamDone' + | 'streamStart' + | 'submit' + | 'success' + | 'tap' + | 'warning' + +interface HapticConfig { + options?: TriggerOptions + pattern: HapticInput +} + +const airyTap = [{ duration: 16, intensity: 0.52 }] + +const crispTap = [{ duration: 10, intensity: 0.92 }] + +const friendlySuccess = [ + { duration: 28, intensity: 0.5 }, + { delay: 42, duration: 30, intensity: 0.68 }, + { delay: 48, duration: 38, intensity: 0.86 } +] + +const softArrive = [ + { duration: 18, intensity: 0.42 }, + { delay: 36, duration: 22, intensity: 0.66 } +] + +const softLeave = [ + { duration: 22, intensity: 0.58 }, + { delay: 32, duration: 16, intensity: 0.34 } +] + +const HAPTIC_INTENTS: Record<HapticIntent, HapticConfig> = { + cancel: { + pattern: [ + { duration: 34, intensity: 0.72 }, + { delay: 54, duration: 26, intensity: 0.38 } + ] + }, + close: { pattern: softLeave }, + crisp: { pattern: crispTap }, + error: { + pattern: [ + { duration: 34, intensity: 0.82 }, + { delay: 42, duration: 34, intensity: 0.72 }, + { delay: 58, duration: 44, intensity: 0.86 } + ] + }, + open: { pattern: softArrive }, + selection: { pattern: airyTap }, + streamDone: { pattern: friendlySuccess }, + streamStart: { pattern: [{ duration: 10, intensity: 0.32 }] }, + submit: { + pattern: [ + { duration: 24, intensity: 0.58 }, + { delay: 48, duration: 36, intensity: 0.82 } + ] + }, + success: { pattern: friendlySuccess }, + tap: { + pattern: [ + { duration: 14, intensity: 0.58 }, + { delay: 30, duration: 12, intensity: 0.42 } + ] + }, + warning: { + pattern: [ + { duration: 34, intensity: 0.64 }, + { delay: 84, duration: 42, intensity: 0.5 } + ] + } +} + +export type HapticTrigger = (input?: HapticInput, options?: TriggerOptions) => Promise<void> | undefined + +let registeredTrigger: HapticTrigger | null = null +let lastSelectionAt = 0 + +// Global rolling rate-limit. A runaway upstream loop (auth-expiry error-toast +// storms, reconnect flaps) can request dozens of haptics a second, which the +// trackpad actuator renders as a frantic "clickity" buzz. Cap firings to +// RATE_LIMIT per RATE_WINDOW so no source can machine-gun the actuator; +// intentional UI haptics are human-paced and never approach the ceiling. +const RATE_WINDOW = 1000 +const RATE_LIMIT = 5 +let recentFires: number[] = [] + +export function registerHapticTrigger(trigger: HapticTrigger | null) { + registeredTrigger = trigger +} + +export function triggerHaptic(intent: HapticIntent = 'selection') { + if ($hapticsMuted.get() || !registeredTrigger) { + return + } + + const now = performance.now() + + if (intent === 'selection') { + if (now - lastSelectionAt < 50) { + return + } + + lastSelectionAt = now + } + + recentFires = recentFires.filter(t => now - t < RATE_WINDOW) + + if (recentFires.length >= RATE_LIMIT) { + return + } + + recentFires.push(now) + + const config = HAPTIC_INTENTS[intent] + + void registeredTrigger(config.pattern, config.options)?.catch(() => undefined) +} diff --git a/ui-desktop/src/lib/icons.ts b/ui-desktop/src/lib/icons.ts new file mode 100644 index 00000000..550c2a4b --- /dev/null +++ b/ui-desktop/src/lib/icons.ts @@ -0,0 +1,269 @@ +import { + IconActivity as Activity, + IconAlertCircle as AlertCircle, + IconAlertTriangle as AlertTriangle, + IconAppWindow as AppWindow, + IconArchive as Archive, + IconArchiveOff as ArchiveOff, + IconArrowUp as ArrowUp, + IconArrowUpRight as ArrowUpRight, + IconAt as AtSign, + IconWaveSine as AudioLines, + IconChartBar as BarChart3, + IconBell as Bell, + IconBookmark as Bookmark, + IconBookmarkFilled as BookmarkFilled, + IconBox as Box, + IconBrain as Brain, + IconBug as Bug, + IconCheck as Check, + IconCircleCheck as CheckCircle2, + IconCheck as CheckIcon, + IconChevronDown as ChevronDown, + IconChevronDown as ChevronDownIcon, + IconChevronLeft as ChevronLeft, + IconChevronLeft as ChevronLeftIcon, + IconChevronRight as ChevronRight, + IconChevronRight as ChevronRightIcon, + IconCircle as CircleIcon, + IconCircleLetterA as CircleLetterA, + IconClipboard as Clipboard, + IconClock as Clock, + IconCloud as Cloud, + IconCommand as Command, + IconCopy as Copy, + IconCopy as CopyIcon, + IconCornerDownLeft as CornerDownLeft, + IconCpu as Cpu, + IconCreditCard as CreditCard, + IconDownload as Download, + IconEar as Ear, + IconEarOff as EarOff, + IconEgg as Egg, + IconExternalLink as ExternalLink, + IconEye as Eye, + IconEyeOff as EyeOff, + IconPhoto as FileImage, + IconFileText as FileText, + IconFolderOpen as FolderOpen, + IconGitBranch as GitBranch, + IconGitBranch as GitBranchIcon, + IconGitFork as GitFork, + IconGitFork as GitForkIcon, + IconGlobe as Globe, + IconHash as Hash, + IconHelpCircle as HelpCircle, + IconPhoto as ImageIcon, + IconInfoCircle as Info, + IconKeyboard as Keyboard, + IconKey as KeyRound, + IconLayersIntersect2 as Layers3, + IconLayoutDashboard as LayoutDashboard, + IconLink as Link, + IconLink as Link2, + IconLink as LinkIcon, + IconLoader2 as Loader2, + IconLoader2 as Loader2Icon, + IconLock as Lock, + IconLogin as LogIn, + IconMail as Mail, + IconMaximize as Maximize, + IconMessageCircle as MessageCircle, + IconMessageQuestion as MessageQuestion, + IconMessage2 as MessageSquareText, + IconMicrophone as Mic, + IconMicrophoneOff as MicOff, + IconDeviceDesktop as Monitor, + IconDeviceDesktopAnalytics as MonitorPlay, + IconMoon as Moon, + IconDots as MoreHorizontal, + IconDots as MoreHorizontalIcon, + IconDotsVertical as MoreVertical, + IconNotebook as NotebookTabs, + IconPackage as Package, + IconPalette as Palette, + IconLayoutBottombar as PanelBottom, + IconLayoutSidebar as PanelLeftIcon, + IconPlayerPause as Pause, + IconPaw as PawPrint, + IconPencil as Pencil, + IconPencil as PencilIcon, + IconPencil as PencilLine, + IconPin as Pin, + IconPlayerPlay as Play, + IconPlus as Plus, + IconRefresh as RefreshCw, + IconRefresh as RefreshCwIcon, + IconDeviceFloppy as Save, + IconSearch as Search, + IconSearch as SearchIcon, + IconSend as Send, + IconSettings as Settings, + IconSettings2 as Settings2, + IconAdjustmentsHorizontal as SlidersHorizontal, + IconMoodPlus as SmilePlusIcon, + IconSquare as Square, + IconChartDots3 as Starmap, + IconSteeringWheel as SteeringWheel, + IconPlayerStopFilled as StopFilled, + IconSun as Sun, + IconTerminal2 as Terminal, + IconTrash as Trash2, + IconUpload as Upload, + IconUsers as Users, + IconVolume2 as Volume2, + IconVolume2 as Volume2Icon, + IconVolumeOff as VolumeX, + IconVolumeOff as VolumeXIcon, + IconTool as Wrench, + IconX as X, + IconX as XIcon, + IconBolt as Zap, + IconBoltFilled as ZapFilled, + IconZoomIn as ZoomIn, + IconZoomOut as ZoomOut +} from '@tabler/icons-react' + +export { + Activity, + AlertCircle, + AlertTriangle, + AppWindow, + Archive, + ArchiveOff, + ArrowUp, + ArrowUpRight, + AtSign, + AudioLines, + BarChart3, + Bell, + Bookmark, + BookmarkFilled, + Box, + Brain, + Bug, + Check, + CheckCircle2, + CheckIcon, + ChevronDown, + ChevronDownIcon, + ChevronLeft, + ChevronLeftIcon, + ChevronRight, + ChevronRightIcon, + CircleIcon, + CircleLetterA, + Clipboard, + Clock, + Cloud, + Command, + Copy, + CopyIcon, + CornerDownLeft, + Cpu, + CreditCard, + Download, + Ear, + EarOff, + Egg, + ExternalLink, + Eye, + EyeOff, + FileImage, + FileText, + FolderOpen, + GitBranch, + GitBranchIcon, + GitFork, + GitForkIcon, + Globe, + Hash, + HelpCircle, + ImageIcon, + Info, + Keyboard, + KeyRound, + Layers3, + LayoutDashboard, + Link, + Link2, + LinkIcon, + Loader2, + Loader2Icon, + Lock, + LogIn, + Mail, + Maximize, + MessageCircle, + MessageQuestion, + MessageSquareText, + Mic, + MicOff, + Monitor, + MonitorPlay, + Moon, + MoreHorizontal, + MoreHorizontalIcon, + MoreVertical, + NotebookTabs, + Package, + Palette, + PanelBottom, + PanelLeftIcon, + Pause, + PawPrint, + Pencil, + PencilIcon, + PencilLine, + Pin, + Play, + Plus, + RefreshCw, + RefreshCwIcon, + Save, + Search, + SearchIcon, + Send, + Settings, + Settings2, + SlidersHorizontal, + SmilePlusIcon, + Square, + Starmap, + SteeringWheel, + StopFilled, + Sun, + Terminal, + Trash2, + Upload, + Users, + Volume2, + Volume2Icon, + VolumeX, + VolumeXIcon, + Wrench, + X, + XIcon, + Zap, + ZapFilled, + ZoomIn, + ZoomOut +} + +export type { Icon as IconComponent } from '@tabler/icons-react' + +/** + * Named icon-size scale — the single source of truth for SVG icon dimensions, + * replacing ad-hoc `h-N w-N` / `size={N}`. Use `<Icon className={iconSize.sm} />` + * (Tailwind `size-*` sets w+h and beats the icon's default 24px); compose with + * `cn()` for colour/animation classes. + */ +export const iconSize = { + xs: 'size-3', // 12px + sm: 'size-3.5', // 14px + md: 'size-4', // 16px + lg: 'size-5', // 20px + xl: 'size-6' // 24px +} as const + +export type IconSize = keyof typeof iconSize diff --git a/ui-desktop/src/lib/incremental-external-store-runtime.test.ts b/ui-desktop/src/lib/incremental-external-store-runtime.test.ts new file mode 100644 index 00000000..0c50d333 --- /dev/null +++ b/ui-desktop/src/lib/incremental-external-store-runtime.test.ts @@ -0,0 +1,144 @@ +import { fromThreadMessageLike, getAutoStatus, MessageRepository } from '@assistant-ui/core/internal' +import type { ExportedMessageRepository, ThreadMessage } from '@assistant-ui/react' +import { describe, expect, it, vi } from 'vitest' + +import { syncRepositoryIncrementally } from './incremental-external-store-runtime' + +const STATUS = getAutoStatus(false, false, false, false, undefined) + +function message(id: string, text: string): ThreadMessage { + return fromThreadMessageLike({ role: 'assistant', content: [{ type: 'text', text }] }, id, STATUS) +} + +/** A real MessageRepository behind the same shape syncRepositoryIncrementally drives. */ +function runtimeWith(items: { message: ThreadMessage; parentId: string | null }[]) { + const repository = new MessageRepository() + + for (const { message: item, parentId } of items) { + repository.addOrUpdateMessage(parentId, item) + } + + if (items.length > 0) { + repository.resetHead(items.at(-1)?.message.id ?? null) + } + + return { repository } as unknown as Parameters<typeof syncRepositoryIncrementally>[0] +} + +function chain(messages: ThreadMessage[]) { + return messages.map((item, index) => ({ + message: item, + parentId: index === 0 ? null : messages[index - 1].id + })) +} + +function exported(items: { message: ThreadMessage; parentId: string | null }[]): ExportedMessageRepository { + return { headId: items.at(-1)?.message.id ?? null, messages: items } +} + +describe('syncRepositoryIncrementally', () => { + it('writes only the changed tail instead of the whole transcript', () => { + const settled = Array.from({ length: 200 }, (_, index) => message(`m-${index}`, `body ${index}`)) + const items = chain(settled) + const runtime = runtimeWith(items) + const repository = (runtime as unknown as { repository: MessageRepository }).repository + + const addOrUpdate = vi.spyOn(repository, 'addOrUpdateMessage') + const resetHead = vi.spyOn(repository, 'resetHead') + + // One streamed delta: the tail grows, every settled message keeps identity. + const nextTail = message('m-199', 'body 199 + delta') + const nextItems = [...items.slice(0, -1), { message: nextTail, parentId: 'm-198' }] + + const result = syncRepositoryIncrementally(runtime, exported(nextItems)) + + expect(addOrUpdate).toHaveBeenCalledTimes(1) + expect(addOrUpdate).toHaveBeenCalledWith('m-198', nextTail) + // The head did not move, so the descendant-pruning reset is skipped. + expect(resetHead).not.toHaveBeenCalled() + expect(result).toHaveLength(200) + expect(result.at(-1)).toBe(nextTail) + }) + + it('does nothing at all when the transcript is unchanged', () => { + const items = chain([message('a', 'one'), message('b', 'two')]) + const runtime = runtimeWith(items) + const repository = (runtime as unknown as { repository: MessageRepository }).repository + + const addOrUpdate = vi.spyOn(repository, 'addOrUpdateMessage') + const deleteMessage = vi.spyOn(repository, 'deleteMessage') + + syncRepositoryIncrementally(runtime, exported(items)) + + expect(addOrUpdate).not.toHaveBeenCalled() + expect(deleteMessage).not.toHaveBeenCalled() + }) + + it('appends a new message through the full path', () => { + const first = message('a', 'one') + const items = chain([first]) + const runtime = runtimeWith(items) + + const second = message('b', 'two') + const result = syncRepositoryIncrementally(runtime, exported(chain([first, second]))) + + expect(result.map(item => item.id)).toEqual(['a', 'b']) + }) + + it('honours an authoritative deletion', () => { + const a = message('a', 'one') + const b = message('b', 'two') + const c = message('c', 'three') + const runtime = runtimeWith(chain([a, b, c])) + + const result = syncRepositoryIncrementally(runtime, exported(chain([a, b]))) + + expect(result.map(item => item.id)).toEqual(['a', 'b']) + }) + + it('rebuilds cleanly when a disjoint transcript is swapped in', () => { + const runtime = runtimeWith(chain([message('old-1', 'one'), message('old-2', 'two')])) + + const next = chain([message('new-1', 'alpha'), message('new-2', 'beta')]) + const result = syncRepositoryIncrementally(runtime, exported(next)) + + expect(result.map(item => item.id)).toEqual(['new-1', 'new-2']) + }) + + it('re-parents a message when its branch parent changes', () => { + const root = message('root', 'root') + const a = message('a', 'a') + const b = message('b', 'b') + + const runtime = runtimeWith([ + { message: root, parentId: null }, + { message: a, parentId: 'root' }, + { message: b, parentId: 'a' } + ]) + + // Same ids and same message objects, but `b` moves onto a sibling branch. + const result = syncRepositoryIncrementally(runtime, { + headId: 'b', + messages: [ + { message: root, parentId: null }, + { message: a, parentId: 'root' }, + { message: b, parentId: 'root' } + ] + }) + + expect(result.map(item => item.id)).toEqual(['root', 'b']) + }) + + it('moves the head when an explicit headId rewinds the branch', () => { + const a = message('a', 'one') + const b = message('b', 'two') + const runtime = runtimeWith(chain([a, b])) + + const result = syncRepositoryIncrementally(runtime, { + headId: 'a', + messages: chain([a, b]) + }) + + expect(result.map(item => item.id)).toEqual(['a']) + }) +}) diff --git a/ui-desktop/src/lib/incremental-external-store-runtime.ts b/ui-desktop/src/lib/incremental-external-store-runtime.ts new file mode 100644 index 00000000..1ccc5121 --- /dev/null +++ b/ui-desktop/src/lib/incremental-external-store-runtime.ts @@ -0,0 +1,265 @@ +import { + AssistantRuntimeImpl, + BaseAssistantRuntimeCore, + ExternalStoreThreadListRuntimeCore, + ExternalStoreThreadRuntimeCore, + hasUpcomingMessage +} from '@assistant-ui/core/internal' +import { + type AssistantRuntime, + type ExternalStoreAdapter, + fromThreadMessageLike, + generateId, + type ThreadMessage, + useRuntimeAdapters +} from '@assistant-ui/react' +import { useEffect, useMemo, useState } from 'react' + +const EMPTY_ARRAY = Object.freeze([]) + +const shallowEqual = (a: object, b: object): boolean => { + const aKeys = Object.keys(a) + + if (aKeys.length !== Object.keys(b).length) { + return false + } + + for (const key of aKeys) { + if (a[key as keyof typeof a] !== b[key as keyof typeof b]) { + return false + } + } + + return true +} + +const getThreadListAdapter = (store: ExternalStoreAdapter) => store.adapters?.threadList ?? {} + +/** + * Write only the items whose (message, parentId) pair actually moved. + * + * `useRuntimeMessageRepository` caches normalized ThreadMessages by source + * identity, so a settled turn keeps the SAME object across renders. That makes + * an identity check a sound "did this change?" test: during streaming exactly + * one item — the growing tail — differs, and the other N-1 writes were pure + * overhead that grew with transcript length. + * + * Returns false when the export is stale (an id in `existing` is gone, or an + * incoming message has no repository entry yet), so the caller falls back to + * the full rebuild rather than guessing. + */ +function applyChangedMessages( + repository: ExternalStoreThreadRuntimeCore['repository'], + existing: readonly { message: ThreadMessage; parentId: string | null }[], + incoming: readonly { message: ThreadMessage; parentId: string | null }[] +): boolean { + if (existing.length !== incoming.length) { + return false + } + + const existingById = new Map(existing.map(item => [item.message.id, item])) + + for (const item of incoming) { + const current = existingById.get(item.message.id) + + if (!current) { + return false + } + + // Reference identity, not deep equality: the conversion cache guarantees a + // stable object for an unchanged turn, and a changed turn is a new object. + if (current.message !== item.message || current.parentId !== item.parentId) { + repository.addOrUpdateMessage(item.parentId, item.message) + } + } + + return true +} + +export function syncRepositoryIncrementally( + runtime: ExternalStoreThreadRuntimeCore, + messageRepository: NonNullable<ExternalStoreAdapter['messageRepository']> +): readonly ThreadMessage[] { + const repository = (runtime as unknown as { repository: ExternalStoreThreadRuntimeCore['repository'] }).repository + const incoming = messageRepository.messages + const existing = repository.export().messages + const headId = messageRepository.headId ?? incoming.at(-1)?.message.id ?? null + + // A thread switch swaps in a fully-DISJOINT transcript (no id carries over). + // Reconciling two unrelated trees in place — grafting the new chain onto the + // old one, then pruning — can strand a stale head/branch, so there's nothing + // to preserve: clear the tree first (leaves→root), then rebuild clean. + const incomingIds = new Set(incoming.map(({ message }) => message.id)) + const disjoint = existing.length > 0 && !existing.some(({ message }) => incomingIds.has(message.id)) + + // Steady-state streaming: same message set, one item changed. Skip the + // whole-transcript rewrite, the prune scan, and the second export. resetHead + // deletes the head's descendants, so it only runs when the head really moved. + if (!disjoint && applyChangedMessages(repository, existing, incoming)) { + if (repository.headId !== headId) { + repository.resetHead(headId) + } + + return repository.getMessages() + } + + if (disjoint) { + for (const { message } of [...existing].reverse()) { + repository.deleteMessage(message.id) + } + } + + for (const { message, parentId } of incoming) { + repository.addOrUpdateMessage(parentId, message) + } + + for (const { message } of repository.export().messages) { + if (!incomingIds.has(message.id)) { + repository.deleteMessage(message.id) + } + } + + repository.resetHead(headId) + + return repository.getMessages() +} + +class IncrementalExternalStoreThreadRuntimeCore extends ExternalStoreThreadRuntimeCore { + override __internal_setAdapter(store: ExternalStoreAdapter): void { + if (!store.messageRepository) { + super.__internal_setAdapter(store) + + return + } + + const self = this as unknown as { + _assistantOptimisticId: null | string + _capabilities: object + _messages: readonly ThreadMessage[] + _notifyEventSubscribers: (event: string, payload: object) => void + _notifySubscribers: () => void + _store?: ExternalStoreAdapter + } + + if (self._store === store) { + return + } + + const isRunning = store.isRunning ?? false + this.isDisabled = store.isDisabled ?? false + + const oldStore = self._store + self._store = store + + if (this.extras !== store.extras) { + this.extras = store.extras + } + + const newSuggestions = store.suggestions ?? EMPTY_ARRAY + + if (!shallowEqual(this.suggestions, newSuggestions)) { + this.suggestions = newSuggestions + } + + const newCapabilities = { + switchToBranch: store.setMessages !== undefined, + switchBranchDuringRun: false, + edit: store.onEdit !== undefined, + reload: store.onReload !== undefined, + cancel: store.onCancel !== undefined, + speech: store.adapters?.speech !== undefined, + dictation: store.adapters?.dictation !== undefined, + voice: store.adapters?.voice !== undefined, + unstable_copy: store.unstable_capabilities?.copy !== false, + attachments: !!store.adapters?.attachments, + feedback: !!store.adapters?.feedback, + queue: false + } + + if (!shallowEqual(self._capabilities, newCapabilities)) { + self._capabilities = newCapabilities + } + + if (oldStore && oldStore.isRunning === store.isRunning && oldStore.messageRepository === store.messageRepository) { + self._notifySubscribers() + + return + } + + if (self._assistantOptimisticId) { + this.repository.deleteMessage(self._assistantOptimisticId) + self._assistantOptimisticId = null + } + + const messages = syncRepositoryIncrementally(this, store.messageRepository) + + if (messages.length > 0) { + this.ensureInitialized() + } + + if ((oldStore?.isRunning ?? false) !== (store.isRunning ?? false)) { + self._notifyEventSubscribers(store.isRunning ? 'runStart' : 'runEnd', {}) + } + + // metadata.isOptimistic keeps this placeholder ephemeral: core evicts + // off-branch optimistic messages on head moves and omits them from export(). + if (hasUpcomingMessage(isRunning, messages)) { + const optimisticId = generateId() + this.repository.addOrUpdateMessage( + messages.at(-1)?.id ?? null, + fromThreadMessageLike({ role: 'assistant', content: [], metadata: { isOptimistic: true } }, optimisticId, { + type: 'running' + }) + ) + self._assistantOptimisticId = optimisticId + } + + this.repository.resetHead(self._assistantOptimisticId ?? messages.at(-1)?.id ?? null) + self._messages = this.repository.getMessages() + self._notifySubscribers() + } +} + +class IncrementalExternalStoreRuntimeCore extends BaseAssistantRuntimeCore { + threads: ExternalStoreThreadListRuntimeCore + + constructor(adapter: ExternalStoreAdapter) { + super() + + this.threads = new ExternalStoreThreadListRuntimeCore( + getThreadListAdapter(adapter), + () => new IncrementalExternalStoreThreadRuntimeCore(this._contextProvider, adapter) + ) + } + + setAdapter(adapter: ExternalStoreAdapter): void { + this.threads.__internal_setAdapter(getThreadListAdapter(adapter)) + this.threads.getMainThreadRuntimeCore().__internal_setAdapter(adapter) + } +} + +export function useIncrementalExternalStoreRuntime<T extends ThreadMessage>( + store: ExternalStoreAdapter<T> +): AssistantRuntime { + const [runtime] = useState(() => new IncrementalExternalStoreRuntimeCore(store as ExternalStoreAdapter)) + + // Re-sync the adapter only when it actually changes — a dep-less effect ran + // on EVERY render of the chat surface. `__internal_setAdapter` early-exits + // when the store is unchanged, so gating on [runtime, store] is behavior- + // preserving while skipping the per-render call entirely. + useEffect(() => { + runtime.setAdapter(store as ExternalStoreAdapter) + }, [runtime, store]) + + const { modelContext } = useRuntimeAdapters() ?? {} + + useEffect(() => { + if (!modelContext) { + return undefined + } + + return runtime.registerModelContextProvider(modelContext) + }, [modelContext, runtime]) + + return useMemo(() => new AssistantRuntimeImpl(runtime), [runtime]) +} diff --git a/ui-desktop/src/lib/inflight-turn-journal.test.ts b/ui-desktop/src/lib/inflight-turn-journal.test.ts new file mode 100644 index 00000000..5344b4f8 --- /dev/null +++ b/ui-desktop/src/lib/inflight-turn-journal.test.ts @@ -0,0 +1,321 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ChatMessage } from '@/lib/chat-messages' +import { + clearInFlightTurnJournal, + type JournalableSessionState, + mergeInFlightMessages, + persistInFlightTurnState, + readInFlightTurnJournal, + recoverInFlightTurnJournal +} from '@/lib/inflight-turn-journal' + +const STORAGE_KEY = 'clawcodex.desktop.inflightTurnJournal.v1' + +function user(id: string, text: string): ChatMessage { + return { id, role: 'user', parts: [{ type: 'text', text }] } +} + +function assistant(id: string, text: string, extra: Partial<ChatMessage> = {}): ChatMessage { + return { id, role: 'assistant', parts: [{ type: 'text', text }], ...extra } +} + +function assistantWithTool(id: string, text: string, extra: Partial<ChatMessage> = {}): ChatMessage { + return { + id, + role: 'assistant', + parts: [ + { type: 'tool-call', toolCallId: 'tc-1', toolName: 'terminal', args: { command: 'ls' } }, + { type: 'text', text } + ], + ...extra + } +} + +function journalState(overrides: Partial<JournalableSessionState> = {}): JournalableSessionState { + return { + awaitingResponse: false, + busy: true, + messages: [user('u1', 'do the thing'), assistant('assistant-stream-1', 'partial answer', { pending: true })], + storedSessionId: 'stored-1', + streamId: 'assistant-stream-1', + turnStartedAt: 1000, + ...overrides + } +} + +beforeEach(() => { + vi.useFakeTimers() + window.localStorage.clear() +}) + +afterEach(() => { + clearInFlightTurnJournal('stored-1') + vi.useRealTimers() +}) + +describe('persistInFlightTurnState', () => { + it('journals the running turn tail after the throttle window', () => { + persistInFlightTurnState(journalState()) + + expect(readInFlightTurnJournal('stored-1')).toBeNull() + + vi.advanceTimersByTime(400) + + const entry = readInFlightTurnJournal('stored-1') + expect(entry).not.toBeNull() + expect(entry?.streamId).toBe('assistant-stream-1') + expect(entry?.turnStartedAt).toBe(1000) + expect(entry?.messages.map(m => m.role)).toEqual(['user', 'assistant']) + }) + + it('coalesces rapid updates into one write carrying the latest state', () => { + persistInFlightTurnState(journalState()) + persistInFlightTurnState( + journalState({ + messages: [ + user('u1', 'do the thing'), + assistant('assistant-stream-1', 'partial answer grew', { pending: true }) + ] + }) + ) + + vi.advanceTimersByTime(400) + + const entry = readInFlightTurnJournal('stored-1') + const tail = entry?.messages.find(m => m.role === 'assistant') + expect(tail?.parts).toEqual([{ type: 'text', text: 'partial answer grew' }]) + }) + + it('clears the entry the moment the turn settles, cancelling pending writes', () => { + persistInFlightTurnState(journalState()) + vi.advanceTimersByTime(400) + expect(readInFlightTurnJournal('stored-1')).not.toBeNull() + + persistInFlightTurnState(journalState({ messages: [] })) + persistInFlightTurnState(journalState({ busy: false, awaitingResponse: false, streamId: null })) + + expect(readInFlightTurnJournal('stored-1')).toBeNull() + + vi.advanceTimersByTime(1000) + expect(readInFlightTurnJournal('stored-1')).toBeNull() + }) + + it('does not journal a turn with no recoverable assistant content yet', () => { + persistInFlightTurnState(journalState({ messages: [user('u1', 'do the thing')], streamId: null })) + + vi.advanceTimersByTime(400) + expect(readInFlightTurnJournal('stored-1')).toBeNull() + }) + + it('expires entries older than the max age', () => { + persistInFlightTurnState(journalState()) + vi.advanceTimersByTime(400) + + const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY)!) + raw.entries['stored-1'].updatedAt = Date.now() - 8 * 24 * 60 * 60 * 1000 + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(raw)) + + expect(readInFlightTurnJournal('stored-1')).toBeNull() + }) +}) + +describe('recoverInFlightTurnJournal', () => { + function journalEntry(messages: ChatMessage[]) { + persistInFlightTurnState(journalState({ messages, streamId: messages.at(-1)?.id ?? null })) + vi.advanceTimersByTime(400) + } + + it('is a reference-preserving no-op when nothing is journaled', () => { + const base = [user('u1', 'do the thing')] + const result = recoverInFlightTurnJournal('stored-1', base) + + expect(result.applied).toBe(false) + expect(result.messages).toBe(base) + }) + + it('appends the full tail when the base transcript never saw the turn', () => { + journalEntry([ + user('u1', 'do the thing'), + assistantWithTool('assistant-stream-1', 'working on it', { pending: true }) + ]) + + const base = [user('u0', 'earlier turn'), assistant('a0', 'earlier reply')] + const result = recoverInFlightTurnJournal('stored-1', base) + + expect(result.applied).toBe(true) + expect(result.messages.map(m => m.id)).toEqual(['u0', 'a0', 'u1', 'assistant-stream-1']) + expect(result.streamId).toBe('assistant-stream-1') + }) + + it('appends only the assistant tail when the user row was persisted', () => { + journalEntry([ + user('u1', 'do the thing'), + assistantWithTool('assistant-stream-1', 'working on it', { pending: true }) + ]) + + const base = [user('db-u1', 'do the thing')] + const result = recoverInFlightTurnJournal('stored-1', base, { keepPending: false }) + + expect(result.applied).toBe(true) + expect(result.messages.map(m => m.id)).toEqual(['db-u1', 'assistant-stream-1']) + const tail = result.messages.at(-1)! + expect(tail.pending).toBe(false) + expect(tail.parts[0]).toMatchObject({ type: 'tool-call' }) + }) + + it('detects a committed reply as caught up and clears the entry', () => { + journalEntry([user('u1', 'do the thing'), assistant('assistant-stream-1', 'partial', { pending: true })]) + + const base = [user('db-u1', 'do the thing'), assistant('db-a1', 'full committed reply')] + const result = recoverInFlightTurnJournal('stored-1', base) + + expect(result.applied).toBe(false) + expect(result.caughtUp).toBe(true) + expect(result.messages).toBe(base) + expect(readInFlightTurnJournal('stored-1')).toBeNull() + }) + + it('overlays the backend text-only projection instead of dropping local tool progress', () => { + // Sweeper regression on #44339: a backend `inflight` assistant snapshot + // (text only) used to mark the richer local tail "caught up" and delete + // locally recorded tool calls. After #76444, longer text wins only when it + // is a strict extension of the journal answer (flat thinking dumps must + // not replace structured answer text). + journalEntry([ + user('u1', 'do the thing'), + assistantWithTool('assistant-stream-old', 'local part', { pending: true }) + ]) + + const base = [ + user('db-u1', 'do the thing'), + assistant('assistant-stream-rt9', 'local part and more from the backend snapshot', { pending: true }) + ] + + const result = recoverInFlightTurnJournal('stored-1', base, { keepPending: true }) + + expect(result.applied).toBe(true) + expect(result.caughtUp).toBe(false) + expect(result.messages).toHaveLength(2) + + const merged = result.messages.at(-1)! + // Keeps the BASE projection row id so live deltas keep landing on it. + expect(merged.id).toBe('assistant-stream-rt9') + expect(result.streamId).toBe('assistant-stream-rt9') + // Journal structure survives; strict-extension backend text wins. + expect(merged.parts[0]).toMatchObject({ type: 'tool-call', toolName: 'terminal' }) + expect(merged.parts[1]).toMatchObject({ type: 'text', text: 'local part and more from the backend snapshot' }) + // Still in flight — the journal must NOT be cleared. + expect(readInFlightTurnJournal('stored-1')).not.toBeNull() + }) + + it('keeps journal answer text when a longer flat dump is not a strict extension (#76444)', () => { + journalEntry([user('u1', 'do the thing'), assistantWithTool('assistant-stream-old', 'partial', { pending: true })]) + + const base = [ + user('db-u1', 'do the thing'), + assistant( + 'assistant-stream-rt9', + 'thinking chatter\nRan terminal\npartial and unrelated dump longer than answer', + { pending: true } + ) + ] + + const result = recoverInFlightTurnJournal('stored-1', base, { keepPending: true }) + const merged = result.messages.at(-1)! + + expect(merged.parts[0]).toMatchObject({ type: 'tool-call', toolName: 'terminal' }) + expect(merged.parts[1]).toMatchObject({ type: 'text', text: 'partial' }) + }) + + it('keeps the journal text when it is longer than the projection text', () => { + journalEntry([ + user('u1', 'do the thing'), + assistantWithTool('assistant-stream-old', 'a much longer locally journaled partial answer', { pending: true }) + ]) + + const base = [user('db-u1', 'do the thing'), assistant('assistant-stream-rt9', 'thin', { pending: true })] + const result = recoverInFlightTurnJournal('stored-1', base, { keepPending: true }) + + const merged = result.messages.at(-1)! + expect(merged.id).toBe('assistant-stream-rt9') + expect(merged.parts[1]).toMatchObject({ type: 'text', text: 'a much longer locally journaled partial answer' }) + }) +}) + +describe('mergeInFlightMessages', () => { + it('treats an error-bearing assistant row as recoverable content', () => { + const tail = [user('u1', 'do the thing'), assistant('a-err', '', { error: 'provider exploded' })] + const result = mergeInFlightMessages([user('db-u1', 'do the thing')], tail) + + expect(result.applied).toBe(true) + expect(result.messages.at(-1)?.error).toBe('provider exploded') + }) + + it('ignores hidden rows when extracting nothing to recover', () => { + const result = mergeInFlightMessages([], [user('u1', 'x')]) + + expect(result.applied).toBe(false) + expect(result.caughtUp).toBe(false) + }) +}) + +describe('mid-turn redirect corrections', () => { + beforeEach(() => { + window.localStorage.clear() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + // A redirect inserts its correction as a second user row directly before the + // live reply, so the turn opens with a RUN of user rows. Journaling only back + // to the nearest one lost the prompt that actually started the turn — the + // vanishing user bubble. + it('journals the whole user run, not just the correction', () => { + persistInFlightTurnState({ + awaitingResponse: false, + busy: true, + messages: [ + user('user-1', 'remove the session counts'), + user('user-2', 'hurry up'), + assistant('assistant-stream-1', 'Moving.', { pending: true }) + ], + storedSessionId: 'stored-redirect', + streamId: 'assistant-stream-1', + turnStartedAt: Date.now() + }) + vi.advanceTimersByTime(400) + + const journaled = readInFlightTurnJournal('stored-redirect')?.messages ?? [] + + expect(journaled.map(message => message.parts.map(part => (part as { text: string }).text).join(''))).toEqual([ + 'remove the session counts', + 'hurry up', + 'Moving.' + ]) + }) + + it('still stops at an assistant boundary so prior turns are not journaled', () => { + persistInFlightTurnState({ + awaitingResponse: false, + busy: true, + messages: [ + user('user-old', 'an earlier turn'), + assistant('assistant-old', 'an earlier answer'), + user('user-1', 'the live prompt'), + assistant('assistant-stream-1', 'Moving.', { pending: true }) + ], + storedSessionId: 'stored-boundary', + streamId: 'assistant-stream-1', + turnStartedAt: Date.now() + }) + vi.advanceTimersByTime(400) + + const journaled = readInFlightTurnJournal('stored-boundary')?.messages ?? [] + + expect(journaled.map(message => message.id)).toEqual(['user-1', 'assistant-stream-1']) + }) +}) diff --git a/ui-desktop/src/lib/inflight-turn-journal.ts b/ui-desktop/src/lib/inflight-turn-journal.ts new file mode 100644 index 00000000..fa53eaed --- /dev/null +++ b/ui-desktop/src/lib/inflight-turn-journal.ts @@ -0,0 +1,545 @@ +import { type ChatMessage, type ChatMessagePart, chatMessageText } from '@/lib/chat-messages' + +/** + * Crash-survivable in-flight turn journal. + * + * While a session is busy, the visible tail of the running turn (user prompt + + * streamed assistant rows, tool calls included) is persisted to localStorage. + * If the renderer or the whole app dies mid-turn, session resume folds the + * journaled tail back onto the restored transcript, so streamed progress is + * not silently lost. The backend's own `inflight` snapshot (merged by + * `appendLiveSessionProjection`) covers reconnects while the backend is alive; + * this journal covers the cases where the backend died too — and it is richer, + * because the backend snapshot carries text only while the journal keeps the + * full part structure. + * + * Best-effort by design: storage failures must never break chat streaming. + */ + +const STORAGE_KEY = 'clawcodex.desktop.inflightTurnJournal.v1' +const STORE_VERSION = 1 +const MAX_ENTRIES = 24 +const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 +/** Streaming repaints arrive every ~33ms; localStorage writes are synchronous. + * Trailing-edge throttle keeps the journal off the hot path — a crash costs at + * most this much of the newest tail. */ +const PERSIST_THROTTLE_MS = 400 + +export interface InFlightTurnSnapshot { + messages: ChatMessage[] + streamId: null | string + turnStartedAt: null | number + updatedAt: number +} + +export interface JournalableSessionState { + awaitingResponse: boolean + busy: boolean + messages: ChatMessage[] + storedSessionId: null | string + streamId: null | string + turnStartedAt: null | number +} + +interface JournalStore { + entries: Record<string, InFlightTurnSnapshot> + version: typeof STORE_VERSION +} + +export interface InFlightRecoveryResult { + applied: boolean + /** The base transcript already contains the journaled turn's completed + * reply — the journal entry is stale and has been cleared. */ + caughtUp: boolean + messages: ChatMessage[] + streamId: null | string + turnStartedAt: null | number +} + +function storage(): Storage | null { + try { + return typeof window === 'undefined' ? null : window.localStorage + } catch { + return null + } +} + +function emptyStore(): JournalStore { + return { entries: {}, version: STORE_VERSION } +} + +function loadStore(): JournalStore { + const store = storage() + + if (!store) { + return emptyStore() + } + + try { + const raw = store.getItem(STORAGE_KEY) + + if (!raw) { + return emptyStore() + } + + const parsed = JSON.parse(raw) + + if ( + !parsed || + parsed.version !== STORE_VERSION || + typeof parsed.entries !== 'object' || + Array.isArray(parsed.entries) + ) { + return emptyStore() + } + + return { + entries: parsed.entries as Record<string, InFlightTurnSnapshot>, + version: STORE_VERSION + } + } catch { + return emptyStore() + } +} + +function saveStore(journal: JournalStore): void { + const store = storage() + + if (!store) { + return + } + + try { + const entries = Object.fromEntries( + Object.entries(journal.entries) + .filter(([, entry]) => !isExpired(entry)) + .sort((a, b) => b[1].updatedAt - a[1].updatedAt) + .slice(0, MAX_ENTRIES) + ) + + if (Object.keys(entries).length === 0) { + store.removeItem(STORAGE_KEY) + + return + } + + store.setItem(STORAGE_KEY, JSON.stringify({ entries, version: STORE_VERSION })) + } catch { + // Quota/private-mode failures: the journal is a recovery aid, not truth. + } +} + +function isExpired(entry: InFlightTurnSnapshot, now = Date.now()): boolean { + return now - entry.updatedAt > MAX_AGE_MS +} + +function cloneMessages(messages: ChatMessage[]): ChatMessage[] { + try { + return JSON.parse(JSON.stringify(messages)) as ChatMessage[] + } catch { + return [] + } +} + +function normalizedText(value: string): string { + return value.replace(/\s+/g, ' ').trim() +} + +function attachmentSignature(message: ChatMessage): string { + return (message.attachmentRefs ?? []).join('\n') +} + +function userMessagesMatch(left: ChatMessage, right: ChatMessage): boolean { + return ( + left.role === 'user' && + right.role === 'user' && + normalizedText(chatMessageText(left)) === normalizedText(chatMessageText(right)) && + attachmentSignature(left) === attachmentSignature(right) + ) +} + +function partHasRecoverableContent(part: ChatMessagePart): boolean { + if (part.type === 'text' || part.type === 'reasoning') { + return typeof part.text === 'string' && part.text.trim().length > 0 + } + + return part.type === 'tool-call' +} + +function assistantHasRecoverableContent(message: ChatMessage): boolean { + return message.role === 'assistant' && (Boolean(message.error) || message.parts.some(partHasRecoverableContent)) +} + +/** A live-turn projection row (backend `inflight` via appendLiveSessionProjection, + * or a still-streaming local bubble) — as opposed to a completed transcript row. */ +function isLiveProjectionRow(message: ChatMessage): boolean { + return ( + Boolean(message.pending) || + message.id.startsWith('assistant-stream-') || + message.id.startsWith('inflight-assistant-') + ) +} + +/** Visible tail of the running turn: the streaming assistant row (plus any + * interim rows sealed after it) back to the user prompt that started it. */ +function recoverableTail(messages: ChatMessage[], streamId: null | string): ChatMessage[] { + const visible = messages.filter(message => !message.hidden) + let assistantIndex = -1 + + if (streamId) { + assistantIndex = visible.findIndex(message => message.id === streamId && assistantHasRecoverableContent(message)) + } + + if (assistantIndex < 0) { + for (let index = visible.length - 1; index >= 0; index -= 1) { + const message = visible[index] + + if (message.role === 'user') { + break + } + + if (assistantHasRecoverableContent(message)) { + assistantIndex = index + + break + } + } + } + + if (assistantIndex < 0) { + return [] + } + + let start = assistantIndex + + for (let index = assistantIndex - 1; index >= 0; index -= 1) { + if (visible[index].role === 'user') { + start = index + + // A mid-turn redirect inserts its correction as another user row right + // before the live reply, so the turn can open with a RUN of user rows. + // Keep walking back over them: stopping at the nearest one journals the + // correction alone and loses the prompt that actually started the turn. + while (start > 0 && visible[start - 1].role === 'user') { + start -= 1 + } + + break + } + } + + return cloneMessages(visible.slice(start)) +} + +function normalizeRecoveredTail(tail: ChatMessage[], keepPending: boolean): ChatMessage[] { + return cloneMessages(tail).map(message => + message.role === 'assistant' + ? { + ...message, + pending: keepPending ? (message.pending ?? true) : false + } + : { ...message, pending: false } + ) +} + +function assistantTextLength(message: ChatMessage): number { + return chatMessageText(message).length +} + +/** Merge the journal's last assistant row into the base's live projection row. + * + * The journal carries structure (tool calls, reasoning) the backend snapshot + * lacks; the backend text may be newer than the journal's last throttled + * write. Keep the journal's parts, but let the longer text win — and keep the + * BASE row's id so live deltas keep appending to the row the stream handler + * already targets. + */ +function hasStructuralParts(message: ChatMessage): boolean { + return message.parts.some(part => part.type === 'reasoning' || part.type === 'tool-call') +} + +function overlayProjectionRow(projection: ChatMessage, journalRow: ChatMessage): ChatMessage { + // A projected error (retained failed turn) must survive the overlay. + const error = journalRow.error ?? projection.error + + const merged: ChatMessage = { + ...journalRow, + id: projection.id, + pending: projection.pending, + ...(error ? { error } : {}) + } + + if (assistantTextLength(projection) <= assistantTextLength(journalRow)) { + return merged + } + + // Backend text is newer than the journal's last throttled write — swap it + // into the journal's first text part, keeping tool calls and reasoning. + // When the journal already carries structure, only accept a *strict* + // extension of the answer text. A longer flat dump that starts with + // thinking chatter must not overwrite / insert as answer text (#76444). + const projectionText = chatMessageText(projection) + const journalText = chatMessageText(journalRow).trim() + + if (hasStructuralParts(journalRow)) { + const next = projectionText.trim() + + if (!journalText || !next.startsWith(journalText)) { + return merged + } + } + + const parts: ChatMessagePart[] = [] + let textReplaced = false + + for (const part of journalRow.parts) { + if (part.type !== 'text') { + parts.push(part) + } else if (!textReplaced) { + parts.push({ ...part, text: projectionText }) + textReplaced = true + } + } + + if (!textReplaced) { + parts.push({ type: 'text', text: projectionText }) + } + + return { ...merged, parts } +} + +/** Rows the base transcript doesn't already hold by id. The journal and the + * base can both carry the same row (a resume that replays a still-journaled + * turn), and appending it twice puts a duplicate id in the transcript — + * which assistant-ui's MessageRepository rejects by throwing. */ +function withoutBaseIds(rows: ChatMessage[], baseMessages: ChatMessage[]): ChatMessage[] { + const baseIds = new Set(baseMessages.map(message => message.id)) + + return rows.filter(row => !baseIds.has(row.id)) +} + +export function mergeInFlightMessages( + baseMessages: ChatMessage[], + tailMessages: ChatMessage[], + options: { keepPending?: boolean } = {} +): InFlightRecoveryResult { + const noop: InFlightRecoveryResult = { + applied: false, + caughtUp: false, + messages: baseMessages, + streamId: null, + turnStartedAt: null + } + + const tail = normalizeRecoveredTail(tailMessages, Boolean(options.keepPending)) + + if (!tail.some(assistantHasRecoverableContent)) { + return noop + } + + const tailUserIndex = tail.findIndex(message => message.role === 'user') + const tailUser = tailUserIndex >= 0 ? tail[tailUserIndex] : null + const tailAssistants = tail.slice(tailUserIndex + 1) + const lastJournalRow = tailAssistants.findLast(assistantHasRecoverableContent) ?? null + const matchingUserIndex = tailUser ? baseMessages.findLastIndex(message => userMessagesMatch(message, tailUser)) : -1 + + if (matchingUserIndex < 0) { + // Base doesn't know this turn at all (user row was never persisted): + // append the whole tail. + const streamId = lastJournalRow?.id ?? null + + return { + applied: true, + caughtUp: false, + messages: [...baseMessages, ...withoutBaseIds(tail, baseMessages)], + streamId, + turnStartedAt: null + } + } + + const afterUser = baseMessages.slice(matchingUserIndex + 1) + + const completedReply = afterUser.find( + message => assistantHasRecoverableContent(message) && !isLiveProjectionRow(message) + ) + + if (completedReply) { + // The transcript already holds this turn's committed reply — the journal + // entry is stale. + return { ...noop, caughtUp: true } + } + + const projectionIndex = baseMessages.findIndex( + (message, index) => index > matchingUserIndex && message.role === 'assistant' && isLiveProjectionRow(message) + ) + + if (projectionIndex < 0) { + if (tailAssistants.length === 0) { + return noop + } + + const streamId = lastJournalRow?.id ?? null + + return { + applied: true, + caughtUp: false, + messages: [...baseMessages, ...withoutBaseIds(tailAssistants, baseMessages)], + streamId, + turnStartedAt: null + } + } + + // Backend projection row present (text-only): overlay the journal's + // structure onto it instead of treating it as "caught up" — that is how + // locally recorded tool progress used to get dropped. + const projection = baseMessages[projectionIndex] + const merged = lastJournalRow ? overlayProjectionRow(projection, lastJournalRow) : projection + + const sealedRows = tailAssistants.filter( + message => message !== lastJournalRow && assistantHasRecoverableContent(message) + ) + + const messages = [ + ...baseMessages.slice(0, projectionIndex), + ...sealedRows, + merged, + ...baseMessages.slice(projectionIndex + 1) + ] + + return { applied: true, caughtUp: false, messages, streamId: merged.id, turnStartedAt: null } +} + +const persistTimers = new Map<string, ReturnType<typeof setTimeout>>() +const persistLatest = new Map<string, JournalableSessionState>() + +function writeSnapshot(storedSessionId: string, state: JournalableSessionState): void { + const tail = recoverableTail(state.messages, state.streamId) + + if (tail.length === 0) { + return + } + + const journal = loadStore() + + journal.entries[storedSessionId] = { + messages: tail, + streamId: state.streamId, + turnStartedAt: state.turnStartedAt, + updatedAt: Date.now() + } + saveStore(journal) +} + +/** Persist the running turn's visible tail (throttled), or clear the entry the + * moment the turn settles. Call on every session-state commit. */ +export function persistInFlightTurnState(state: JournalableSessionState): void { + const storedSessionId = state.storedSessionId + + if (!storedSessionId) { + return + } + + if (!state.busy && !state.awaitingResponse && !state.streamId) { + clearInFlightTurnJournal(storedSessionId) + + return + } + + persistLatest.set(storedSessionId, state) + + if (persistTimers.has(storedSessionId)) { + return + } + + persistTimers.set( + storedSessionId, + setTimeout(() => { + persistTimers.delete(storedSessionId) + const latest = persistLatest.get(storedSessionId) + + persistLatest.delete(storedSessionId) + + if (latest) { + writeSnapshot(storedSessionId, latest) + } + }, PERSIST_THROTTLE_MS) + ) +} + +export function readInFlightTurnJournal(storedSessionId: null | string): InFlightTurnSnapshot | null { + if (!storedSessionId) { + return null + } + + const journal = loadStore() + const entry = journal.entries[storedSessionId] + + if (!entry) { + return null + } + + if (isExpired(entry)) { + delete journal.entries[storedSessionId] + saveStore(journal) + + return null + } + + return entry +} + +/** Fold a journaled in-flight tail back onto a restored transcript. A no-op + * returns `baseMessages` by reference so callers keep their fast-path ref. */ +export function recoverInFlightTurnJournal( + storedSessionId: null | string, + baseMessages: ChatMessage[], + options: { keepPending?: boolean } = {} +): InFlightRecoveryResult { + const snapshot = readInFlightTurnJournal(storedSessionId) + + if (!snapshot) { + return { + applied: false, + caughtUp: false, + messages: baseMessages, + streamId: null, + turnStartedAt: null + } + } + + const recovered = mergeInFlightMessages(baseMessages, snapshot.messages, options) + + if (recovered.caughtUp) { + clearInFlightTurnJournal(storedSessionId) + } + + return { + ...recovered, + streamId: recovered.applied ? (recovered.streamId ?? snapshot.streamId) : null, + turnStartedAt: recovered.applied ? snapshot.turnStartedAt : null + } +} + +export function clearInFlightTurnJournal(storedSessionId: null | string): void { + if (!storedSessionId) { + return + } + + const timer = persistTimers.get(storedSessionId) + + if (timer) { + clearTimeout(timer) + persistTimers.delete(storedSessionId) + } + + persistLatest.delete(storedSessionId) + + const journal = loadStore() + + if (!(storedSessionId in journal.entries)) { + return + } + + delete journal.entries[storedSessionId] + saveStore(journal) +} diff --git a/ui-desktop/src/lib/input-modality.test.ts b/ui-desktop/src/lib/input-modality.test.ts new file mode 100644 index 00000000..ec510c68 --- /dev/null +++ b/ui-desktop/src/lib/input-modality.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' + +import { lastInputModality } from './input-modality' + +describe('lastInputModality', () => { + it('defaults to keyboard before any input', () => { + expect(lastInputModality()).toBe('keyboard') + }) + + it('tracks the device behind the last interaction', () => { + document.dispatchEvent(new Event('pointerdown')) + + expect(lastInputModality()).toBe('pointer') + + document.dispatchEvent(new Event('keydown')) + + expect(lastInputModality()).toBe('keyboard') + }) + + it('sees events a handler stops from bubbling (capture phase)', () => { + const target = document.createElement('button') + + document.body.append(target) + target.addEventListener('pointerdown', event => event.stopPropagation()) + target.dispatchEvent(new Event('pointerdown', { bubbles: true })) + + expect(lastInputModality()).toBe('pointer') + + target.remove() + }) +}) diff --git a/ui-desktop/src/lib/input-modality.ts b/ui-desktop/src/lib/input-modality.ts new file mode 100644 index 00000000..baa1a4aa --- /dev/null +++ b/ui-desktop/src/lib/input-modality.ts @@ -0,0 +1,26 @@ +/** Which input device drove the most recent interaction. + * + * Chromium's `:focus-visible` is not enough to tell a mouse pick from a Tab. + * Radix menus autofocus their content on open and keyboard-navigate their + * items, so by the time a mouse click closes the menu and restores focus to + * the trigger, Chromium has decided the page is in keyboard modality and + * matches `:focus-visible` on that restore (verified in Chrome — the model + * pill's tooltip popped open after every mouse pick). Track the device + * ourselves and use it to qualify `:focus-visible`. + */ + +export type InputModality = 'keyboard' | 'pointer' + +let modality: InputModality = 'keyboard' + +/** Capture-phase so a `stopPropagation` deeper in the tree can't blind us. */ +if (typeof document !== 'undefined') { + document.addEventListener('pointerdown', () => (modality = 'pointer'), { capture: true, passive: true }) + document.addEventListener('keydown', () => (modality = 'keyboard'), { capture: true, passive: true }) +} + +/** The device behind the last pointerdown/keydown. Defaults to `keyboard` so a + * surface that has seen no input yet keeps the accessible behavior. */ +export function lastInputModality(): InputModality { + return modality +} diff --git a/ui-desktop/src/lib/json-format.test.ts b/ui-desktop/src/lib/json-format.test.ts new file mode 100644 index 00000000..79942ff5 --- /dev/null +++ b/ui-desktop/src/lib/json-format.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' + +import { tryFormatJson } from './json-format' + +describe('tryFormatJson', () => { + it('pretty-prints compact JSON', () => { + expect(tryFormatJson('{"a":1,"b":[2,3]}')).toEqual({ + ok: true, + text: '{\n "a": 1,\n "b": [\n 2,\n 3\n ]\n}' + }) + }) + + it('leaves empty input unchanged', () => { + expect(tryFormatJson(' ')).toEqual({ ok: true, text: ' ' }) + }) + + it('reports parse errors', () => { + const result = tryFormatJson('{bad') + + expect(result.ok).toBe(false) + + if (!result.ok) { + expect(result.error.length).toBeGreaterThan(0) + } + }) +}) diff --git a/ui-desktop/src/lib/json-format.ts b/ui-desktop/src/lib/json-format.ts new file mode 100644 index 00000000..a24caeed --- /dev/null +++ b/ui-desktop/src/lib/json-format.ts @@ -0,0 +1,15 @@ +export type FormatJsonResult = { ok: true; text: string } | { ok: false; error: string } + +export function tryFormatJson(raw: string): FormatJsonResult { + const text = raw.trim() + + if (!text) { + return { ok: true, text: raw } + } + + try { + return { ok: true, text: JSON.stringify(JSON.parse(text) as unknown, null, 2) } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } +} diff --git a/ui-desktop/src/lib/json-rpc-gateway-url-guard.test.ts b/ui-desktop/src/lib/json-rpc-gateway-url-guard.test.ts new file mode 100644 index 00000000..9fb83174 --- /dev/null +++ b/ui-desktop/src/lib/json-rpc-gateway-url-guard.test.ts @@ -0,0 +1,65 @@ +// connect() must reject before WebSocket coerces garbage into +// `ws://<origin>/[object%20Object]` (#68250 stale-emit boot loop). + +import { JsonRpcGatewayClient } from '@clawcodex/shared' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +class FakeSocket { + static OPEN = 1 + readyState = 0 + addEventListener = vi.fn((type: string, handler: () => void) => { + if (type === 'open') { + setTimeout(() => { + this.readyState = FakeSocket.OPEN + handler() + }, 0) + } + }) + removeEventListener = vi.fn() + close = vi.fn() + send = vi.fn() +} + +describe('JsonRpcGatewayClient connect() URL guard', () => { + beforeEach(() => { + vi.stubGlobal('WebSocket', FakeSocket) // jsdom has none; class reads WebSocket.OPEN + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('rejects a non-string IPC result object', async () => { + const client = new JsonRpcGatewayClient() + await expect(client.connect({ ok: true, wsUrl: 'ws://127.0.0.1:1/api/ws' } as unknown as string)).rejects.toThrow( + /requires a ws:\/\/ or wss:\/\/ URL string, got type "object"/ + ) + }) + + it('rejects a non-ws URL string', async () => { + const client = new JsonRpcGatewayClient() + await expect(client.connect('http://127.0.0.1:1234/api/ws')).rejects.toThrow( + /requires a ws:\/\/ or wss:\/\/ URL string/ + ) + }) + + it('rejects a malformed ws URL before opening a socket', async () => { + const client = new JsonRpcGatewayClient() + await expect(client.connect('ws://')).rejects.toThrow(/requires a ws:\/\/ or wss:\/\/ URL string/) + expect(client.connectionState).toBe('idle') + }) + + it('keeps connection state idle on rejection', async () => { + const client = new JsonRpcGatewayClient() + await client.connect(undefined as unknown as string).catch(() => undefined) + expect(client.connectionState).toBe('idle') + }) + + it('accepts ws:// and wss://', async () => { + for (const url of ['ws://127.0.0.1:1234/api/ws?token=t', 'wss://gw.example.com/api/ws?ticket=t']) { + const client = new JsonRpcGatewayClient({ socketFactory: () => new FakeSocket() as unknown as WebSocket }) + await client.connect(url) + expect(client.connectionState).toBe('open') + } + }) +}) diff --git a/ui-desktop/src/lib/katex-memo.ts b/ui-desktop/src/lib/katex-memo.ts new file mode 100644 index 00000000..7143fbff --- /dev/null +++ b/ui-desktop/src/lib/katex-memo.ts @@ -0,0 +1,260 @@ +/** + * Memoizing wrapper around `rehype-katex`. + * + * Why: the default `@streamdown/math` plugin runs `rehype-katex` on every + * markdown commit. During streaming, that means each new token re-runs + * KaTeX on EVERY math node in the message — including equations that + * haven't changed since the last token. For math-heavy responses (a + * model deriving an equation step-by-step) this becomes a major source + * of jank: 20 unchanged equations each pay ~5–20ms of katex.renderToString + * work per token, adding up to hundreds of ms of CPU bound work that + * delays the next streaming update. + * + * What this plugin does: walk the hast tree looking for the math nodes + * that `remark-math` emits (`<code class="math-inline">…</code>` for + * inline and `<pre><code class="math-display">…</code></pre>` for + * display), key them by `(displayMode, value)`, and serve them from an + * in-memory LRU cache when we've rendered the same equation before. + * Cache misses still go through `katex.renderToString`; cache hits + * return the previously generated hast subtree. + * + * Result: each unique equation only pays the katex cost once. Adding + * one new equation to a paragraph re-renders just that one equation + * instead of all of them. The cache is process-global so it survives + * moves between messages (e.g., re-rendering a session). + * + * Compatibility: the produced hast structure matches what `rehype-katex` + * itself produces — we use the same `hast-util-from-html-isomorphic` + * fragment parsing and the same parent-splice semantics, including the + * `<pre>`-walk-up for display mode. Drop-in replacement for the math + * slot in streamdown's PluginConfig. + * + * Wire it in via `createMemoizedMathPlugin`: + * + * import { createMemoizedMathPlugin } from '@/lib/katex-memo' + * const math = createMemoizedMathPlugin({ singleDollarTextMath: true }) + * <Streamdown plugins={{ math }} ... /> + */ + +import type { Element, ElementContent, Parent, Root } from 'hast' +import { fromHtmlIsomorphic } from 'hast-util-from-html-isomorphic' +import { toText } from 'hast-util-to-text' +import katex from 'katex' +import remarkMath from 'remark-math' +import type { Pluggable } from 'unified' +import { SKIP, visitParents } from 'unist-util-visit-parents' +import type { VFile } from 'vfile' + +interface KatexMemoOptions { + /** + * Color used for KaTeX errors when we fall back to the lenient parser. + * Mirrors `@streamdown/math`'s default so the visual output is identical. + */ + errorColor?: string +} + +interface MathPluginConfig { + /** + * Match `singleDollarTextMath` from `@streamdown/math`. When true the + * remark-math parser treats `$x$` as inline math; when false it requires + * `$$x$$`. Models almost always emit the single-dollar form, so we + * default it to true at the createMemoizedMathPlugin call site. + */ + singleDollarTextMath?: boolean + errorColor?: string +} + +/** Cached rendered hast — children to splice into the math node's parent. */ +type CachedRender = ElementContent[] + +const CACHE_LIMIT = 512 + +class LruCache<K, V> { + private readonly map = new Map<K, V>() + + get(key: K): undefined | V { + const value = this.map.get(key) + + if (value === undefined) { + return undefined + } + + // Refresh recency by re-inserting at the tail. Map iteration order is + // insertion order, so the oldest entry is at the head. + this.map.delete(key) + this.map.set(key, value) + + return value + } + + set(key: K, value: V): void { + if (this.map.has(key)) { + this.map.delete(key) + } else if (this.map.size >= CACHE_LIMIT) { + const oldest = this.map.keys().next().value + + if (oldest !== undefined) { + this.map.delete(oldest) + } + } + + this.map.set(key, value) + } +} + +const cache = new LruCache<string, CachedRender>() + +function cacheKey(displayMode: boolean, value: string): string { + // `\u0001` is a control character that (a) won't appear in normal + // markdown and (b) is a single byte so the join is cheap. + return `${displayMode ? 'd' : 'i'}\u0001${value}` +} + +/** + * Render one math expression with the same two-pass strategy `rehype-katex` + * uses internally: try strict first (so genuine TeX errors get reported in + * the VFile message stream), and on failure fall back to lenient mode so + * the document still renders without a thrown exception. The lenient + * fallback paints the equation in `errorColor` instead of erroring out. + */ +function renderMath( + value: string, + displayMode: boolean, + errorColor: string, + file: VFile, + element: Element +): ElementContent[] { + let html: string + + try { + html = katex.renderToString(value, { displayMode, throwOnError: true }) + } catch (error) { + const cause = error as Error + + file.message('Could not render math with KaTeX', { + cause, + place: element.position, + ruleId: cause.name?.toLowerCase() ?? 'katex', + source: 'rehype-katex-memo' + }) + + try { + html = katex.renderToString(value, { + displayMode, + errorColor, + strict: 'ignore', + throwOnError: false + }) + } catch { + // Last-resort fallback — render the source text inside a styled span + // so the user at least sees what was supposed to be there. Mirrors + // rehype-katex's own escape hatch. + return [ + { + type: 'element', + tagName: 'span', + properties: { + className: ['katex-error'], + style: `color:${errorColor}`, + title: String(error) + }, + children: [{ type: 'text', value }] + } + ] + } + } + + const fragment = fromHtmlIsomorphic(html, { fragment: true }) + + return fragment.children as ElementContent[] +} + +/** + * The actual rehype plugin. Wraps `rehype-katex`'s logic with our LRU + * cache. Mirrors the upstream visitor exactly except for the cache lookup + * and an LRU.set on miss. + */ +function createMemoizedRehypeKatex(options: KatexMemoOptions = {}): Pluggable { + const errorColor = options.errorColor ?? 'var(--color-muted-foreground)' + + return () => + function transform(tree: Root, file: VFile): undefined { + visitParents(tree, 'element', (element, parents) => { + const classes = Array.isArray(element.properties?.className) ? (element.properties.className as string[]) : [] + + // Match the same class set rehype-katex looks for. `language-math` + // is the markdown ` ```math ` form, `math-inline` is what + // remark-math emits for `$x$`, `math-display` for `$$x$$`. + const languageMath = classes.includes('language-math') + const mathDisplay = classes.includes('math-display') + const mathInline = classes.includes('math-inline') + + if (!(languageMath || mathDisplay || mathInline)) { + return + } + + let displayMode = mathDisplay + let scope: Element = element + let parent: Parent | undefined = parents[parents.length - 1] + + // For ` ```math ` the scope walks up to the wrapping <pre> and + // we treat it as display math. Same logic rehype-katex uses. + if (languageMath && parent && parent.type === 'element' && (parent as Element).tagName === 'pre') { + scope = parent as Element + parent = parents[parents.length - 2] + displayMode = true + } + + // No parent means the math node is at the root — there's nothing + // to splice into, so bail. This shouldn't happen for properly + // nested markdown but is the same defensive guard rehype-katex has. + if (!parent) { + return + } + + const value = toText(scope, { whitespace: 'pre' }) + const key = cacheKey(displayMode, value) + let cached = cache.get(key) + + if (!cached) { + cached = renderMath(value, displayMode, errorColor, file, scope) + cache.set(key, cached) + } + + // Splice CLONES of the cached children into the parent. Reusing + // the same node instances across renders would let downstream + // rehype plugins or toJsxRuntime mutate the cached subtree — + // breaking the next cache hit. structuredClone is ~100µs per + // equation, well below the ~5–20ms katex.renderToString cost + // we're avoiding. + const clonedChildren = cached.map(child => structuredClone(child)) + const index = parent.children.indexOf(scope as ElementContent) + + if (index === -1) { + return + } + + parent.children.splice(index, 1, ...clonedChildren) + + return SKIP + }) + } +} + +/** + * Build a streamdown MathPlugin object that uses the memoized rehype-katex + * wrapper. Drop-in for `@streamdown/math`'s `createMathPlugin`. + */ +export function createMemoizedMathPlugin(config: MathPluginConfig = {}) { + const remarkPlugin: Pluggable = [remarkMath, { singleDollarTextMath: config.singleDollarTextMath ?? false }] + + const rehypePlugin = createMemoizedRehypeKatex({ errorColor: config.errorColor }) + + return { + name: 'katex' as const, + type: 'math' as const, + remarkPlugin, + rehypePlugin, + getStyles: () => 'katex/dist/katex.min.css' + } +} diff --git a/ui-desktop/src/lib/keybinds/actions.ts b/ui-desktop/src/lib/keybinds/actions.ts new file mode 100644 index 00000000..9d02969f --- /dev/null +++ b/ui-desktop/src/lib/keybinds/actions.ts @@ -0,0 +1,243 @@ +// The single source of truth for rebindable desktop hotkeys. +// +// Each entry is pure metadata: an id, a category, and the default combo(s). +// Handlers are wired separately in `use-keybinds.ts` (they need React context +// like navigate / theme); labels come from i18n (`t.keybinds.actions[id]`). To +// add a hotkey, add a row here and a handler there — nothing else. + +import { registry } from '@/contrib/registry' + +import { IS_MAC } from './combo' + +export type KeybindCategory = 'composer' | 'profiles' | 'session' | 'navigation' | 'view' + +// The self-referential opener — bound + dispatched like any action, but shown in +// the panel subtitle (not as its own row). +export const KEYBIND_PANEL_ACTION = 'keybinds.openPanel' + +// `composer` is read-only; the rest are rebindable. `view` is the catch-all for +// layout, appearance, and the panel-opener. +export const KEYBIND_CATEGORIES: readonly KeybindCategory[] = ['composer', 'profiles', 'session', 'navigation', 'view'] + +export interface KeybindActionMeta { + id: string + category: KeybindCategory + /** Default combos. Empty = shipped unbound (user can assign one). */ + defaults: readonly string[] + /** Display label for CONTRIBUTED actions (built-ins use i18n). */ + label?: string +} + +// Positional switch slots for *named* profiles: ⌘1…⌘9 for profiles 1-9, then +// ⌘⌥1…⌘⌥9 for 10-18. The default profile gets the two-key mnemonic ⌘D (see +// `profile.default`) — ⌘` is macOS-reserved (window cycling) and ⌘0 is reset-zoom. +export const PROFILE_SLOT_COUNT = 18 + +function comboForSlot(slot: number): string { + return slot <= 9 ? `mod+${slot}` : `mod+alt+${slot - 9}` +} + +const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE_SLOT_COUNT }, (_, i) => ({ + id: `profile.switch.${i + 1}`, + category: 'profiles' as const, + defaults: [comboForSlot(i + 1)] +})) + +// Positional jumps — ^1…^9, mirroring profiles' ⌘1…⌘9. +export const SESSION_SLOT_COUNT = 9 + +const SESSION_SLOT_ACTIONS: KeybindActionMeta[] = Array.from({ length: SESSION_SLOT_COUNT }, (_, i) => ({ + id: `session.slot.${i + 1}`, + category: 'session' as const, + defaults: [`ctrl+${i + 1}`] +})) + +export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [ + // ── Composer ───────────────────────────────────────────────────────────── + // Soft `/` / Enter focus (gated); other printables type-to-focus unbound. + { id: 'composer.focus', category: 'composer', defaults: ['/', 'enter'] }, + // ⌘⇧M — "m" for model; the convention chat apps converged on (LibreChat, + // Open WebUI, and Cherry Studio all ship the same chord). Opens the pill's + // live dropdown on the pane under the pointer, else the active composer. + { id: 'composer.modelPicker', category: 'composer', defaults: ['mod+shift+m'] }, + // Voice conversation toggle. Matches the documented `voice.record_key` + // (Ctrl+B). On macOS that's literally ⌃B — distinct from the ⌘B sidebar + // toggle. Off macOS `ctrl` folds to `mod`, which IS the ⌘B/Ctrl+B sidebar + // chord, so ship it unbound there (rebindable in the panel) rather than + // stealing the long-standing sidebar binding. + { id: 'composer.voice', category: 'composer', defaults: IS_MAC ? ['ctrl+b'] : [] }, + + // ── Profiles ───────────────────────────────────────────────────────────── + { id: 'profile.default', category: 'profiles', defaults: ['mod+d'] }, + ...PROFILE_SWITCH_ACTIONS, + { id: 'profile.next', category: 'profiles', defaults: ['mod+shift+]'] }, + { id: 'profile.prev', category: 'profiles', defaults: ['mod+shift+['] }, + { id: 'profile.toggleAll', category: 'profiles', defaults: ['mod+shift+0'] }, + { id: 'profile.create', category: 'profiles', defaults: [] }, + + // ── Session ────────────────────────────────────────────────────────────── + { id: 'session.new', category: 'session', defaults: ['mod+n', 'shift+n'] }, + { id: 'session.newTab', category: 'session', defaults: ['mod+t'] }, + { id: 'session.newWindow', category: 'session', defaults: ['mod+shift+n'] }, + // ⌃Tab / ⌃⇧Tab — the universal tab-cycle chord. Literally Control, not Cmd + // (macOS reserves Cmd+Tab for app switching); see `ctrl` in combo.ts. + { id: 'session.next', category: 'session', defaults: ['ctrl+tab'] }, + { id: 'session.prev', category: 'session', defaults: ['ctrl+shift+tab'] }, + ...SESSION_SLOT_ACTIONS, + { id: 'session.focusSearch', category: 'session', defaults: ['mod+shift+f'] }, + { id: 'session.togglePin', category: 'session', defaults: [] }, + // ⌘⇧B — "b" for branch: spin up a new git worktree from the active repo. + { id: 'workspace.newWorktree', category: 'session', defaults: ['mod+shift+b'] }, + // ⌘O — the editor-standard "open folder" chord (VS Code ⌘O, Zed's + // workspace::Open). Picks a folder and opens it as a project (upsert: + // enters the owning project when one exists, else creates one), landing on + // a fresh session anchored there. + { id: 'workspace.openFolder', category: 'session', defaults: ['mod+o'] }, + + // ── Navigation ─────────────────────────────────────────────────────────── + { id: 'nav.commandPalette', category: 'navigation', defaults: ['mod+k', 'mod+p'] }, + { id: 'nav.commandCenter', category: 'navigation', defaults: ['mod+.'] }, + { id: 'nav.settings', category: 'navigation', defaults: ['mod+,'] }, + { id: 'nav.profiles', category: 'navigation', defaults: [] }, + { id: 'nav.skills', category: 'navigation', defaults: [] }, + { id: 'nav.messaging', category: 'navigation', defaults: [] }, + { id: 'nav.artifacts', category: 'navigation', defaults: [] }, + { id: 'nav.cron', category: 'navigation', defaults: [] }, + { id: 'nav.agents', category: 'navigation', defaults: [] }, + + // ── View (layout + appearance + the shortcuts panel itself) ─────────────── + { id: 'view.toggleSidebar', category: 'view', defaults: ['mod+b'] }, + { id: 'view.toggleRightSidebar', category: 'view', defaults: ['mod+j'] }, + // ⌘⇧S — "s" for status bar. VS Code ships + // `workbench.action.toggleStatusbarVisibility` unbound (it's a chord-free + // gap in their View family) and ClawCodex has no chord dispatcher, so this + // takes the nearest free single combo instead of a ⌘K ⌘S two-stroke. + { id: 'view.toggleStatusbar', category: 'view', defaults: ['mod+shift+s'] }, + // ⌘G — "g" for git; the review pane is the source-control view. + { id: 'view.toggleReview', category: 'view', defaults: ['mod+g'] }, + { id: 'view.showFiles', category: 'view', defaults: [] }, + // Control+` everywhere (literal `ctrl`, NOT `mod`): ⌘` is macOS-reserved for + // cycling app windows, so VS Code/Cursor/Zed bind the terminal to Ctrl+` on + // every platform. Off macOS `ctrl` folds to `mod` (= Ctrl), so it's unchanged. + // Toggle reveals the terminal (opening one if none exist); Shift spawns a new one. + { id: 'view.showTerminal', category: 'view', defaults: ['ctrl+`'] }, + { id: 'view.newTerminal', category: 'view', defaults: ['ctrl+shift+`'] }, + // Same Ctrl(+Shift) terminal family: arrows walk the (vertical) tab rail, W + // kills the active one. ⌘W is taken (close preview tab) and ⌘⇧[ ] are profiles, + // so these stay on `ctrl` — distinct on macOS, folding to Ctrl elsewhere. + { id: 'view.nextTerminal', category: 'view', defaults: ['ctrl+shift+down'] }, + { id: 'view.prevTerminal', category: 'view', defaults: ['ctrl+shift+up'] }, + { id: 'view.closeTerminal', category: 'view', defaults: ['ctrl+shift+w'] }, + // ⌘\ — the backslash reads like a mirror line flipping the layout. + { id: 'view.flipPanes', category: 'view', defaults: ['mod+\\'] }, + // ⌘W closes the focused zone's active tab — its own tab strip (preview) or + // the tree tab (session tiles, files, terminal). The uncloseable workspace + // is a no-op. ⌘⇧T reopens the last closed tab where it was. + { id: 'view.closeTab', category: 'view', defaults: ['mod+w'] }, + { id: 'view.reopenTab', category: 'view', defaults: ['mod+shift+t'] }, + // ⌘F — open the find-in-page bar. `comboAllowedInInput` lets the combo + // fire from inside a textarea / contenteditable (matches browser behavior + // so typing in the composer and pressing ⌘F focuses find, not 'f'). + { id: 'view.findInPage', category: 'view', defaults: ['mod+f'] }, + // ⌘G / ⌘⇧G step matches — the platform-standard find-next/find-previous + // pair (Chrome, Safari, VS Code, and Claude Desktop all ship it). No + // `defaults` here on purpose: ⌘G already belongs to `view.toggleReview`, + // and shipping a duplicate default would flag a permanent conflict in the + // keybinds panel. While the find bar is OPEN, its capture-phase listener + // claims ⌘G/⌘⇧G and stops propagation (see components/find-bar.tsx), so + // stepping works out of the box and the review toggle keeps the key the + // rest of the time. These entries exist so the panel documents the pair + // and a user who prefers a dedicated chord can bind one. + { id: 'view.findNext', category: 'view', defaults: [] }, + { id: 'view.findPrevious', category: 'view', defaults: [] }, + { id: 'appearance.toggleMode', category: 'view', defaults: ['shift+x'] }, + { id: 'keybinds.openPanel', category: 'view', defaults: ['mod+/'] } +] + +export const KEYBIND_ACTION_IDS: readonly string[] = KEYBIND_ACTIONS.map(action => action.id) + +const ACTION_BY_ID = new Map(KEYBIND_ACTIONS.map(action => [action.id, action])) + +// ── Contributed actions — the `keybinds` registry area ────────────────────── +// Same declarative schema as every other surface: a data contribution carries +// the action's metadata AND its handler. Contributed actions are first-class: +// they dispatch, appear in the panel, are rebindable, and their overrides +// persist exactly like built-ins. Built-in ids can't be shadowed. + +export const KEYBINDS_AREA = 'keybinds' + +/** Payload of a `keybinds` data contribution. */ +export interface KeybindContribution { + id: string + /** Panel section. Defaults to `view`. */ + category?: KeybindCategory + /** Default combos (canonical form, e.g. `mod+shift+\\`). Empty = unbound. */ + defaults?: readonly string[] + label: string + run: () => void +} + +export function contributedKeybinds(): KeybindContribution[] { + return registry + .getArea(KEYBINDS_AREA) + .map(c => c.data as KeybindContribution) + .filter(k => Boolean(k?.id && k.label) && typeof k?.run === 'function' && !ACTION_BY_ID.has(k.id)) +} + +/** Built-ins + contributed, one metadata list (panel, bindings, conflicts). */ +export function allKeybindActions(): KeybindActionMeta[] { + return [ + ...KEYBIND_ACTIONS, + ...contributedKeybinds().map(k => ({ + id: k.id, + category: k.category ?? ('view' as const), + defaults: k.defaults ?? [], + label: k.label + })) + ] +} + +export function keybindAction(id: string): KeybindActionMeta | undefined { + return ACTION_BY_ID.get(id) ?? allKeybindActions().find(action => action.id === id) +} + +/** The contributed handler for an action id (built-ins wire theirs in use-keybinds). */ +export function contributedKeybindHandler(id: string): (() => void) | undefined { + return contributedKeybinds().find(k => k.id === id)?.run +} + +export type KeybindBindings = Record<string, string[]> + +export function defaultBindings(): KeybindBindings { + return Object.fromEntries(allKeybindActions().map(action => [action.id, [...action.defaults]])) +} + +// Fixed, non-rebindable shortcuts surfaced read-only in the panel so the map is +// complete. `keys` are canonical tokens run through `formatCombo` for display +// (single symbols like "@" / "/" pass through unchanged). Categories listed here +// render after the rebindable ones. +export interface KeybindReadonly { + id: string + category: KeybindCategory + keys: readonly string[] +} + +export const KEYBIND_READONLY: readonly KeybindReadonly[] = [ + { id: 'composer.send', category: 'composer', keys: ['enter'] }, + { id: 'composer.newline', category: 'composer', keys: ['shift+enter'] }, + { id: 'composer.steer', category: 'composer', keys: ['enter'] }, + { id: 'composer.queue', category: 'composer', keys: ['mod+enter'] }, + { id: 'composer.sendQueued', category: 'composer', keys: ['mod+shift+k'] }, + { id: 'composer.mention', category: 'composer', keys: ['@'] }, + { id: 'composer.slash', category: 'composer', keys: ['/'] }, + { id: 'composer.help', category: 'composer', keys: ['?'] }, + { id: 'composer.history', category: 'composer', keys: ['up', 'down'] }, + { id: 'composer.cancel', category: 'composer', keys: ['escape'] }, + // Fixed, context-local shortcuts surfaced for discoverability. + { id: 'view.terminalSelection', category: 'view', keys: ['mod+l'] }, + // Terminal clipboard. ⌘C/⌘V on macOS, Ctrl+Shift+C/V elsewhere — matching VS + // Code. Plain Ctrl+C also copies when text is selected (Windows Terminal / + // Tabby behavior); with no selection it stays SIGINT, so it isn't listed. + { id: 'view.terminalCopy', category: 'view', keys: IS_MAC ? ['mod+c'] : ['mod+shift+c'] }, + { id: 'view.terminalPaste', category: 'view', keys: IS_MAC ? ['mod+v'] : ['mod+shift+v'] } +] diff --git a/ui-desktop/src/lib/keybinds/combo.test.ts b/ui-desktop/src/lib/keybinds/combo.test.ts new file mode 100644 index 00000000..3147538a --- /dev/null +++ b/ui-desktop/src/lib/keybinds/combo.test.ts @@ -0,0 +1,130 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +// `IS_MAC` is resolved once at module load from `navigator`, so each platform +// case overrides the platform and re-imports the module fresh. +async function loadCombo(platform: string) { + Object.defineProperty(window.navigator, 'platform', { value: platform, configurable: true }) + vi.resetModules() + + return import('./combo') +} + +function keydown(init: KeyboardEventInit): KeyboardEvent { + return new KeyboardEvent('keydown', init) +} + +afterEach(() => { + vi.resetModules() +}) + +describe('comboFromEvent — ctrl as a distinct modifier on macOS', () => { + it('reports Control+Tab as "ctrl+tab" on macOS (not Cmd)', async () => { + const { comboFromEvent } = await loadCombo('MacIntel') + + expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true }))).toBe('ctrl+tab') + expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true, shiftKey: true }))).toBe('ctrl+shift+tab') + }) + + it('keeps Cmd as "mod" and distinct from Control on macOS', async () => { + const { comboFromEvent } = await loadCombo('MacIntel') + + expect(comboFromEvent(keydown({ code: 'KeyK', metaKey: true }))).toBe('mod+k') + expect(comboFromEvent(keydown({ code: 'KeyK', ctrlKey: true }))).toBe('ctrl+k') + }) + + it('uses layout-aware letters for Cmd shortcuts on non-QWERTY layouts', async () => { + const { comboFromEvent } = await loadCombo('MacIntel') + + expect(comboFromEvent(keydown({ code: 'KeyI', key: 'c', metaKey: true }))).toBe('mod+c') + expect(comboFromEvent(keydown({ code: 'KeyI', key: 'C', metaKey: true, shiftKey: true }))).toBe('mod+shift+c') + }) + + it('keeps shifted punctuation anchored to the physical key token', async () => { + const { comboFromEvent } = await loadCombo('MacIntel') + + expect(comboFromEvent(keydown({ code: 'Slash', key: '?', metaKey: true, shiftKey: true }))).toBe('mod+shift+/') + }) + + it('uses layout-aware punctuation for Cmd shortcuts on non-QWERTY layouts', async () => { + const { comboFromEvent } = await loadCombo('MacIntel') + + // Dvorak puts "." on the physical QWERTY V key — ⌘. must still reach the + // command center rather than resolving to the physical token. + expect(comboFromEvent(keydown({ code: 'KeyV', key: '.', metaKey: true }))).toBe('mod+.') + expect(comboFromEvent(keydown({ code: 'KeyW', key: ',', metaKey: true }))).toBe('mod+,') + expect(comboFromEvent(keydown({ code: 'BracketLeft', key: '/', metaKey: true }))).toBe('mod+/') + // AZERTY reaches "," from the physical QWERTY M key. + expect(comboFromEvent(keydown({ code: 'KeyM', key: ',', metaKey: true }))).toBe('mod+,') + }) + + it("keeps digits physical so AZERTY's shifted number row still binds", async () => { + const { comboFromEvent } = await loadCombo('MacIntel') + + // On AZERTY the unshifted "1" key types "&", and "1" only with Shift held. + // Both must resolve to the same `mod+1` the QWERTY user gets. + expect(comboFromEvent(keydown({ code: 'Digit1', key: '&', metaKey: true }))).toBe('mod+1') + expect(comboFromEvent(keydown({ code: 'Digit1', key: '1', metaKey: true }))).toBe('mod+1') + }) + + it('falls back to the physical key for glyphs we do not ship as tokens', async () => { + const { comboFromEvent } = await loadCombo('MacIntel') + + // Option-modified glyphs, dead keys, and non-Latin scripts are not combo + // tokens, so the physical code keeps the binding reachable. + expect(comboFromEvent(keydown({ code: 'KeyK', key: '˚', metaKey: true, altKey: true }))).toBe('mod+alt+k') + expect(comboFromEvent(keydown({ code: 'KeyN', key: 'Dead', metaKey: true, altKey: true }))).toBe('mod+alt+n') + expect(comboFromEvent(keydown({ code: 'KeyK', key: 'л', metaKey: true }))).toBe('mod+k') + }) + + it('treats Control as the "mod" accelerator off macOS', async () => { + const { comboFromEvent } = await loadCombo('Win32') + + expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true }))).toBe('mod+tab') + expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true, shiftKey: true }))).toBe('mod+shift+tab') + }) +}) + +describe('canonicalizeCombo', () => { + it('leaves "ctrl+…" untouched on macOS', async () => { + const { canonicalizeCombo } = await loadCombo('MacIntel') + + expect(canonicalizeCombo('ctrl+tab')).toBe('ctrl+tab') + expect(canonicalizeCombo('ctrl+shift+tab')).toBe('ctrl+shift+tab') + }) + + it('folds "ctrl+…" to "mod+…" off macOS so a real Control press resolves', async () => { + const { canonicalizeCombo } = await loadCombo('Win32') + + expect(canonicalizeCombo('ctrl+tab')).toBe('mod+tab') + expect(canonicalizeCombo('ctrl+shift+tab')).toBe('mod+shift+tab') + // Non-ctrl combos are unchanged. + expect(canonicalizeCombo('mod+k')).toBe('mod+k') + }) +}) + +describe('formatCombo — honest Control labels', () => { + it('renders the Control glyph on macOS', async () => { + const { formatCombo } = await loadCombo('MacIntel') + + expect(formatCombo('ctrl+tab')).toBe('⌃⇥') + expect(formatCombo('ctrl+shift+tab')).toBe('⌃⇧⇥') + }) + + it('renders "Ctrl+…" off macOS (base key keeps its glyph)', async () => { + const { formatCombo } = await loadCombo('Win32') + + expect(formatCombo('ctrl+tab')).toBe('Ctrl+⇥') + expect(formatCombo('ctrl+shift+tab')).toBe('Ctrl+Shift+⇥') + }) +}) + +describe('comboAllowedInInput', () => { + it('lets ctrl combos fire while typing (e.g. ⌃Tab from the composer)', async () => { + const { comboAllowedInInput } = await loadCombo('MacIntel') + + expect(comboAllowedInInput('ctrl+tab')).toBe(true) + expect(comboAllowedInInput('ctrl+shift+tab')).toBe(true) + expect(comboAllowedInInput('mod+k')).toBe(true) + expect(comboAllowedInInput('shift+x')).toBe(false) + }) +}) diff --git a/ui-desktop/src/lib/keybinds/combo.ts b/ui-desktop/src/lib/keybinds/combo.ts new file mode 100644 index 00000000..78f91e96 --- /dev/null +++ b/ui-desktop/src/lib/keybinds/combo.ts @@ -0,0 +1,230 @@ +// Keybind combo normalization + display. +// +// A combo is a canonical lowercase string like "mod+k", "mod+shift+]", "shift+x", +// or "r". `mod` is Cmd on macOS / Ctrl elsewhere, so a single binding works on +// both. We derive the base key from `event.key` where the layout matters +// (letters, unshifted punctuation) and from `event.code` otherwise, so a +// binding follows the character the user's layout actually types while Shift +// never mutates it ("shift+/" stays "shift+/" instead of becoming "shift+?"). +// +// `ctrl` is physical Control, distinct from `mod`. It only matters on macOS, +// where `mod` is Cmd and Cmd+Tab is OS-reserved — so `ctrl+tab` is literally +// Control+Tab. Off macOS, Control already *is* `mod`, so `canonicalizeCombo` +// folds `ctrl` → `mod`. + +export const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '') + +// event.code → canonical base token. Letters/digits map to their lowercase +// character; everything else uses an explicit name so combos read cleanly. +const CODE_TO_KEY: Record<string, string> = { + Backquote: '`', + Backslash: '\\', + BracketLeft: '[', + BracketRight: ']', + Comma: ',', + Equal: '=', + Minus: '-', + Period: '.', + Quote: "'", + Semicolon: ';', + Slash: '/', + Space: 'space', + Enter: 'enter', + Escape: 'escape', + Backspace: 'backspace', + Tab: 'tab', + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right' +} + +const MODIFIER_CODES = new Set([ + 'AltLeft', + 'AltRight', + 'ControlLeft', + 'ControlRight', + 'MetaLeft', + 'MetaRight', + 'ShiftLeft', + 'ShiftRight' +]) + +function baseKeyFromCode(code: string): string | null { + if (code.startsWith('Key')) { + return code.slice(3).toLowerCase() + } + + if (code.startsWith('Digit')) { + return code.slice(5) + } + + if (code.startsWith('Numpad')) { + const rest = code.slice(6) + + return /^[0-9]$/.test(rest) ? rest : null + } + + if (code.startsWith('F') && /^F\d{1,2}$/.test(code)) { + return code.toLowerCase() + } + + return CODE_TO_KEY[code] ?? null +} + +// Punctuation we ship as combo tokens, derived from CODE_TO_KEY so the two +// can't drift. Named tokens (space, tab, …) are excluded by the length check. +const PUNCTUATION_KEYS = new Set(Object.values(CODE_TO_KEY).filter(token => token.length === 1)) + +// The layout-aware half of the base key. `event.key` carries the character the +// user's layout actually produces, which is what a binding should match: +// +// - Letters always win, shifted or not — `toLowerCase` normalizes the case. +// - Punctuation only when Shift is UP, because a shifted `event.key` is the +// shifted glyph ("?" for "/"), and combos stay anchored to the unshifted +// token. Shifted punctuation falls through to `event.code` below. +// +// Digits deliberately stay physical: on AZERTY the number row is shifted, so +// `event.key` for the "1" key is "&" and only yields "1" with Shift held — +// `event.code` is what keeps `mod+1` reachable there. +// +// Anything else (Option glyphs like "˚", dead keys, non-Latin scripts) isn't a +// token we ship, so it fails both checks and falls back to the physical code. +function baseKeyFromEventKey(key: string, shiftKey: boolean): string | null { + if (/^[a-z]$/i.test(key)) { + return key.toLowerCase() + } + + return !shiftKey && PUNCTUATION_KEYS.has(key) ? key : null +} + +// Returns the canonical combo for a keydown, or null while only modifiers are +// held (so capture mode keeps waiting for a real key). +export function comboFromEvent(event: KeyboardEvent): string | null { + if (MODIFIER_CODES.has(event.code)) { + return null + } + + const base = baseKeyFromEventKey(event.key, event.shiftKey) ?? baseKeyFromCode(event.code) + + if (!base) { + return null + } + + const parts: string[] = [] + + // macOS reports Cmd (`mod`) and Control (`ctrl`) separately; elsewhere + // Control IS the accelerator, so it folds into `mod`. + if (event.metaKey || (event.ctrlKey && !IS_MAC)) { + parts.push('mod') + } + + if (event.ctrlKey && IS_MAC) { + parts.push('ctrl') + } + + if (event.altKey) { + parts.push('alt') + } + + if (event.shiftKey) { + parts.push('shift') + } + + parts.push(base) + + return parts.join('+') +} + +// Rewrites a binding to the form `comboFromEvent` emits, so it indexes under +// the same key a live keypress produces. Off macOS, `ctrl+…` and `mod+…` are +// the one Control chord, so a shipped `ctrl+tab` matches a real Control+Tab. +export function canonicalizeCombo(combo: string): string { + return IS_MAC ? combo : combo.replace(/\bctrl\b/g, 'mod') +} + +const TOKEN_LABELS: Record<string, string> = { + enter: '↵', + escape: 'Esc', + backspace: '⌫', + tab: '⇥', + space: 'Space', + up: '↑', + down: '↓', + left: '←', + right: '→' +} + +function labelForBase(base: string): string { + if (TOKEN_LABELS[base]) { + return TOKEN_LABELS[base] + } + + if (/^f\d{1,2}$/.test(base)) { + return base.toUpperCase() + } + + return base.length === 1 ? base.toUpperCase() : base +} + +function labelForMod(mod: string): string { + if (mod === 'mod') { + return IS_MAC ? '⌘' : 'Ctrl' + } + + if (mod === 'ctrl') { + return IS_MAC ? '⌃' : 'Ctrl' + } + + if (mod === 'alt') { + return IS_MAC ? '⌥' : 'Alt' + } + + if (mod === 'shift') { + return IS_MAC ? '⇧' : 'Shift' + } + + return mod +} + +// Per-key display tokens, e.g. ["⌘", "K"] on macOS, ["Ctrl", "K"] elsewhere — +// one cap per token for <KbdGroup>. +export function comboTokens(combo: string): string[] { + const parts = combo.split('+') + const base = parts.pop() ?? '' + + return [...parts.map(labelForMod), labelForBase(base)] +} + +// Human-readable label, e.g. "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere. +export function formatCombo(combo: string): string { + const tokens = comboTokens(combo) + + return IS_MAC ? tokens.join('') : tokens.join('+') +} + +// True when focus currently sits inside an element matching `selector`. The +// primitive for focus-scoped shortcuts — e.g. routing ⌘W to whichever surface +// (terminal, preview, …) owns focus. +export function isFocusWithin(selector: string): boolean { + return document.activeElement?.closest(selector) != null +} + +// True when focus is in a text-entry surface, so bare-key shortcuts don't fire +// while the user is typing. +export function isEditableTarget(target: EventTarget | null): boolean { + const el = target as HTMLElement | null + + return Boolean( + el?.isContentEditable || + el instanceof HTMLInputElement || + el instanceof HTMLTextAreaElement || + el instanceof HTMLSelectElement + ) +} + +// A primary modifier (Cmd/Ctrl/Control) fires even while typing (e.g. ⌘K or +// ⌃Tab from the composer); bare/Shift-only combos are suppressed in inputs. +export function comboAllowedInInput(combo: string): boolean { + return /^(?:mod|ctrl)(?:\+|$)/.test(combo) +} diff --git a/ui-desktop/src/lib/keybinds/composer-focus-keys.test.ts b/ui-desktop/src/lib/keybinds/composer-focus-keys.test.ts new file mode 100644 index 00000000..a75f649c --- /dev/null +++ b/ui-desktop/src/lib/keybinds/composer-focus-keys.test.ts @@ -0,0 +1,203 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $workspaceIsPage } from '@/app/routes' +import { $switcherOpen, closeSwitcher } from '@/store/session-switcher' + +import { + composerFocusBlockedBySurface, + composerFocusKeysAllowed, + isActivateOnEnterTarget, + typeToFocusChar +} from './composer-focus-keys' + +function keydown(init: KeyboardEventInit & { target?: EventTarget }): KeyboardEvent { + const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init }) + + if (init.target) { + Object.defineProperty(event, 'target', { value: init.target }) + } + + return event +} + +describe('isActivateOnEnterTarget', () => { + it('ignores body / null', () => { + expect(isActivateOnEnterTarget(document.body)).toBe(false) + expect(isActivateOnEnterTarget(null)).toBe(false) + }) + + it('detects buttons and walks to a wrapping activator', () => { + const button = document.createElement('button') + const wrap = document.createElement('div') + wrap.setAttribute('role', 'button') + const child = document.createElement('span') + wrap.append(child) + document.body.append(button, wrap) + + expect(isActivateOnEnterTarget(button)).toBe(true) + expect(isActivateOnEnterTarget(child)).toBe(true) + expect(isActivateOnEnterTarget(document.createElement('div'))).toBe(false) + }) +}) + +describe('composerFocusBlockedBySurface', () => { + beforeEach(() => { + $workspaceIsPage.set(false) + closeSwitcher() + document.body.replaceChildren() + }) + + afterEach(() => { + $workspaceIsPage.set(false) + closeSwitcher() + document.body.replaceChildren() + }) + + it('is clear on empty chat chrome', () => { + expect(composerFocusBlockedBySurface()).toBe(false) + }) + + it('blocks dialogs, the session switcher, and full pages', () => { + const dialog = document.createElement('div') + dialog.setAttribute('role', 'dialog') + document.body.append(dialog) + expect(composerFocusBlockedBySurface()).toBe(true) + + document.body.replaceChildren() + $switcherOpen.set(true) + expect(composerFocusBlockedBySurface()).toBe(true) + + closeSwitcher() + $workspaceIsPage.set(true) + expect(composerFocusBlockedBySurface()).toBe(true) + }) + + it('blocks while an overlay covers the chat (composer sits behind it)', () => { + const overlay = document.createElement('div') + overlay.setAttribute('data-overlay-surface', '') + document.body.append(overlay) + + expect(composerFocusBlockedBySurface()).toBe(true) + }) + + it('ignores a clarify card — it yields only its own keys, per-key', () => { + const card = document.createElement('div') + card.setAttribute('data-clarify-choices', '2') + document.body.append(card) + + expect(composerFocusBlockedBySurface()).toBe(false) + }) + + it('blocks when focus is inside a terminal', () => { + const term = document.createElement('div') + term.setAttribute('data-terminal', '') + const inner = document.createElement('div') + term.append(inner) + document.body.append(term) + Object.defineProperty(document, 'activeElement', { configurable: true, get: () => inner }) + + expect(composerFocusBlockedBySurface()).toBe(true) + + Object.defineProperty(document, 'activeElement', { + configurable: true, + get: () => document.body + }) + }) +}) + +describe('typeToFocusChar', () => { + it('returns printables (case/symbols via event.key)', () => { + expect(typeToFocusChar(keydown({ key: 'a', code: 'KeyA' }))).toBe('a') + expect(typeToFocusChar(keydown({ key: 'A', code: 'KeyA', shiftKey: true }))).toBe('A') + expect(typeToFocusChar(keydown({ key: '?', code: 'Slash', shiftKey: true }))).toBe('?') + expect(typeToFocusChar(keydown({ key: ' ', code: 'Space' }))).toBe(' ') + }) + + it('rejects non-printables and modified chords', () => { + expect(typeToFocusChar(keydown({ key: 'Enter', code: 'Enter' }))).toBeNull() + expect(typeToFocusChar(keydown({ key: 'a', code: 'KeyA', metaKey: true }))).toBeNull() + expect(typeToFocusChar(keydown({ key: 'a', code: 'KeyA', isComposing: true }))).toBeNull() + }) +}) + +describe('composerFocusKeysAllowed', () => { + beforeEach(() => { + $workspaceIsPage.set(false) + closeSwitcher() + document.body.replaceChildren() + vi.spyOn(document, 'activeElement', 'get').mockReturnValue(document.body) + }) + + afterEach(() => { + $workspaceIsPage.set(false) + closeSwitcher() + document.body.replaceChildren() + vi.restoreAllMocks() + }) + + it('passes rebound chords; allows soft keys on the transcript', () => { + expect(composerFocusKeysAllowed(keydown({ key: 'i', code: 'KeyI', metaKey: true }), 'mod+i')).toBe(true) + expect(composerFocusKeysAllowed(keydown({ key: '/', code: 'Slash', target: document.body }), '/')).toBe(true) + expect(composerFocusKeysAllowed(keydown({ key: 'Enter', code: 'Enter', target: document.body }), 'enter')).toBe( + true + ) + expect(composerFocusKeysAllowed(keydown({ key: 'h', code: 'KeyH', target: document.body }), 'type')).toBe(true) + }) + + it('refuses editables; refuses Enter on buttons but allows / and typing', () => { + const input = document.createElement('input') + const button = document.createElement('button') + document.body.append(input, button) + + expect(composerFocusKeysAllowed(keydown({ key: 'a', code: 'KeyA', target: input }), 'type')).toBe(false) + expect(composerFocusKeysAllowed(keydown({ key: 'Enter', code: 'Enter', target: button }), 'enter')).toBe(false) + expect(composerFocusKeysAllowed(keydown({ key: '/', code: 'Slash', target: button }), '/')).toBe(true) + expect(composerFocusKeysAllowed(keydown({ key: 'a', code: 'KeyA', target: button }), 'type')).toBe(true) + }) + + it('refuses when a dialog is open', () => { + const dialog = document.createElement('div') + dialog.setAttribute('role', 'dialog') + document.body.append(dialog) + + expect(composerFocusKeysAllowed(keydown({ key: 'a', code: 'KeyA', target: document.body }), 'type')).toBe(false) + }) + + it('yields only the keys a live clarify card actually binds', () => { + const card = document.createElement('div') + // Two choices → rows A/B plus the "Other" row C; 1/2/3 are the digit twins. + card.setAttribute('data-clarify-choices', '2') + document.body.append(card) + + const allowed = (key: string, combo: string) => + composerFocusKeysAllowed(keydown({ key, target: document.body }), combo) + + // The card's own shortcuts win over type-to-focus. + expect(allowed('a', 'type')).toBe(false) + expect(allowed('B', 'type')).toBe(false) + expect(allowed('c', 'type')).toBe(false) + expect(allowed('1', 'type')).toBe(false) + expect(allowed('3', 'type')).toBe(false) + expect(allowed('Enter', 'enter')).toBe(false) + + // Everything past the last row is not the card's — typing a real message + // instead of picking an option must still reach the composer. + expect(allowed('d', 'type')).toBe(true) + expect(allowed('z', 'type')).toBe(true) + expect(allowed('4', 'type')).toBe(true) + expect(allowed('?', 'type')).toBe(true) + expect(allowed(' ', 'type')).toBe(true) + }) + + it('leaves every key alone for a clarify card in a background tab', () => { + const tab = document.createElement('div') + tab.setAttribute('data-pane-hidden', '') + const card = document.createElement('div') + card.setAttribute('data-clarify-choices', '2') + tab.append(card) + document.body.append(tab) + + expect(composerFocusKeysAllowed(keydown({ key: 'a', target: document.body }), 'type')).toBe(true) + expect(composerFocusKeysAllowed(keydown({ key: 'Enter', target: document.body }), 'enter')).toBe(true) + }) +}) diff --git a/ui-desktop/src/lib/keybinds/composer-focus-keys.ts b/ui-desktop/src/lib/keybinds/composer-focus-keys.ts new file mode 100644 index 00000000..4660ed0f --- /dev/null +++ b/ui-desktop/src/lib/keybinds/composer-focus-keys.ts @@ -0,0 +1,147 @@ +/** + * Soft focus / type-to-focus for the chat composer. + * + * On empty chat chrome, Enter focuses the composer; printable keys focus and + * type. Bound shortcuts still win via the keybind index. Surfaces that own + * keys (dialogs, menus, terminal, …) are left alone. + */ + +import { $workspaceIsPage } from '@/app/routes' +import { queryVisible } from '@/components/pane-shell/pane-visibility' +import { switcherActive } from '@/store/session-switcher' + +import { isEditableTarget, isFocusWithin } from './combo' + +/** `composer.focus` defaults that need the surface/target gate. */ +export const isComposerFocusSoftCombo = (combo: string) => combo === '/' || combo === 'enter' + +const ENTER_ACTIVATES = [ + 'a[href]', + 'button', + 'summary', + 'input', + 'textarea', + 'select', + '[contenteditable=""]', + '[contenteditable="true"]', + '[role="button"]', + '[role="checkbox"]', + '[role="combobox"]', + '[role="link"]', + '[role="menuitem"]', + '[role="menuitemcheckbox"]', + '[role="menuitemradio"]', + '[role="option"]', + '[role="radio"]', + '[role="switch"]', + '[role="tab"]', + '[role="treeitem"]' +].join(',') + +// Overlays that cover the whole window (portaled to the body, or the overlay +// shell itself) — one anywhere means the composer is behind it. +const BLOCKING_OVERLAY = + '[role="dialog"],[role="alertdialog"],[role="menu"],[role="listbox"],[data-radix-popper-content-wrapper],[data-overlay-surface]' + +// Blockers that live INSIDE a chat surface. Inactive tabs stay mounted, so this +// one has to be visible-scoped: a clarify card waiting in a background thread +// must not take the foreground composer's letter keys. +const BLOCKING_IN_SURFACE = '[data-clarify-choices]' + +/** True when the focused control would normally handle Enter itself. */ +export function isActivateOnEnterTarget(target: EventTarget | null): boolean { + const el = target as HTMLElement | null + + return Boolean(el && el !== document.body && el !== document.documentElement && el.closest(ENTER_ACTIVATES)) +} + +/** + * True when a live clarify card binds THIS key, so type-to-focus must yield it. + * + * The card owns Enter plus the shortcuts it actually renders — `1..N+1` and + * `A..` for its N choices and the trailing "Other" row. It does NOT own the + * rest of the alphabet: typing a real message instead of picking an option is a + * legitimate answer ("none of these"), and blanket-blocking every printable + * left the user unable to start that message at all — the first letter vanished + * and the composer never focused. Out-of-range keys fall through to the + * composer, which skips the question on send. + * + * The choice count rides in the attribute's value, so this stays a DOM read + * with no store coupling. + */ +export function clarifyCardOwnsKey(event: KeyboardEvent): boolean { + const card = queryVisible(BLOCKING_IN_SURFACE) + + if (!card) { + return false + } + + if (event.key === 'Enter') { + return true + } + + // "Other" is the row past the last choice, hence the +1. + const rows = Number(card.getAttribute('data-clarify-choices')) + 1 + + if (!Number.isFinite(rows)) { + return false + } + + const key = event.key.toLowerCase() + + if (key.length !== 1) { + return false + } + + const index = /^[1-9]$/.test(key) ? Number(key) - 1 : key >= 'a' && key <= 'z' ? key.charCodeAt(0) - 97 : -1 + + return index >= 0 && index < rows +} + +/** + * Dialogs, menus, terminal, full pages, session switcher, and any open overlay — + * they keep their keys, so type-to-focus / soft `/` / Enter stand down rather + * than stealing keystrokes those surfaces own (or leaking them into the composer + * mounted behind an overlay). A live clarify card is handled per-key by + * `clarifyCardOwnsKey`, not here — it only owns its own shortcuts. + */ +export function composerFocusBlockedBySurface(): boolean { + return ( + switcherActive() || + $workspaceIsPage.get() || + isFocusWithin('[data-terminal]') || + Boolean(document.querySelector(BLOCKING_OVERLAY)) + ) +} + +/** Printable `event.key` for type-to-focus, or null (modifiers / non-printables / IME). */ +export function typeToFocusChar(event: KeyboardEvent): string | null { + if (event.defaultPrevented || event.isComposing || event.metaKey || event.ctrlKey || event.altKey) { + return null + } + + // Length 1 ⇒ letter/digit/punct/space; Enter/Tab/Arrows/Dead/F-keys are longer. + return event.key.length === 1 ? event.key : null +} + +/** + * Whether soft focus / type-to-focus may run. + * `combo` is `/` | `enter` | `'type'` (unbound printable); other chords pass. + */ +export function composerFocusKeysAllowed(event: KeyboardEvent, combo: string): boolean { + if (combo !== 'type' && !isComposerFocusSoftCombo(combo)) { + return true + } + + if ( + event.defaultPrevented || + event.isComposing || + isEditableTarget(event.target) || + composerFocusBlockedBySurface() || + clarifyCardOwnsKey(event) + ) { + return false + } + + return !(combo === 'enter' && isActivateOnEnterTarget(event.target)) +} diff --git a/ui-desktop/src/lib/keybinds/contributed-actions.test.ts b/ui-desktop/src/lib/keybinds/contributed-actions.test.ts new file mode 100644 index 00000000..0b822427 --- /dev/null +++ b/ui-desktop/src/lib/keybinds/contributed-actions.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' + +import { createPluginContext } from '@/contrib/plugin' +import { registry } from '@/contrib/registry' +import { allKeybindActions, contributedKeybindHandler, KEYBINDS_AREA } from '@/lib/keybinds/actions' +import { bindingsFor } from '@/store/keybinds' + +// The plugin-command contract: a plugin ships a hotkey through the `keybinds` +// area and it behaves like a built-in — it dispatches, it resolves a combo for +// the palette hint, and it survives the plugin being unloaded. These assert the +// relationship between the pieces, not the specific chord any one plugin picks. +describe('contributed keybind actions', () => { + it('dispatches, resolves its default combo, and disappears on unload', () => { + const ctx = createPluginContext('demo') + let ran = 0 + + const dispose = ctx.register({ + id: 'new-thing', + area: KEYBINDS_AREA, + data: { + id: 'demo.newThing', + category: 'view', + defaults: ['mod+alt+n'], + label: 'Demo: New thing', + run: () => void (ran += 1) + } + }) + + // Dispatch path: use-keybinds looks the handler up by action id. + contributedKeybindHandler('demo.newThing')?.() + expect(ran).toBe(1) + + // Hint path: $bindings was seeded before this action existed, so only the + // resolver (default fallback) finds the combo — a raw store lookup can't. + expect(bindingsFor('demo.newThing')).toEqual(['mod+alt+n']) + + // Panel path: it shows up as a rebindable row alongside the built-ins. + expect(allKeybindActions().find(a => a.id === 'demo.newThing')?.label).toBe('Demo: New thing') + + dispose() + + expect(contributedKeybindHandler('demo.newThing')).toBeUndefined() + expect(allKeybindActions().some(a => a.id === 'demo.newThing')).toBe(false) + }) + + it('cannot shadow a built-in action id', () => { + const ctx = createPluginContext('demo') + + const dispose = ctx.register({ + id: 'steal-new-session', + area: KEYBINDS_AREA, + data: { id: 'session.new', defaults: ['mod+alt+n'], label: 'Demo: hijack', run: () => undefined } + }) + + // The built-in keeps its own combo and its own (i18n) label — the + // contribution is filtered out rather than overriding core. + expect(bindingsFor('session.new')).toEqual(['mod+n', 'shift+n']) + expect(allKeybindActions().filter(a => a.id === 'session.new')).toHaveLength(1) + + dispose() + }) + + it('leaves no registry residue between plugin loads', () => { + expect(registry.getArea(KEYBINDS_AREA).filter(c => c.source === 'plugin:demo')).toHaveLength(0) + }) +}) diff --git a/ui-desktop/src/lib/keybinds/use-keybind-hint.ts b/ui-desktop/src/lib/keybinds/use-keybind-hint.ts new file mode 100644 index 00000000..263c169c --- /dev/null +++ b/ui-desktop/src/lib/keybinds/use-keybind-hint.ts @@ -0,0 +1,36 @@ +import { useStore } from '@nanostores/react' + +import { $registryVersion } from '@/contrib/registry' +import { $bindings, bindingsFor } from '@/store/keybinds' + +import { KEYBIND_READONLY } from './actions' +import { formatCombo } from './combo' + +// The formatted first combo for `actionId`, or null when unbound. Rebindable +// actions read live from the store; readonly shortcuts (e.g. `composer.steer`) +// fall back to their fixed combo. Returns null for unknown action ids so the +// tooltip shows just the text label with no trailing hint. +export function useKeybindHint(actionId: string): string | null { + const bindings = useStore($bindings) + + // `bindingsFor`, not a raw `bindings[id]`: $bindings is seeded at module init + // from the actions known THEN, so a plugin action contributed later isn't in + // it and a raw lookup renders no hint at all. The resolver falls through to + // the stored override and the action's own defaults. Subscribing to the + // registry version repaints the hint when that late registration lands. + useStore($registryVersion) + + const rebindable = bindingsFor(actionId, bindings)[0] + + if (rebindable) { + return formatCombo(rebindable) + } + + const readonly = KEYBIND_READONLY.find(entry => entry.id === actionId) + + if (readonly) { + return formatCombo(readonly.keys[0]) + } + + return null +} diff --git a/ui-desktop/src/lib/loadout.ts b/ui-desktop/src/lib/loadout.ts new file mode 100644 index 00000000..b687690c --- /dev/null +++ b/ui-desktop/src/lib/loadout.ts @@ -0,0 +1,279 @@ +import { deflateSync, inflateSync } from 'fflate' + +import { capitalize } from '@/lib/text' + +// ── Loadout codec ───────────────────────────────────────────────────────────── +// +// A generic, WoW-talent-loadout-style binary share codec: pack *bits and +// indices* (not JSON), DEFLATE the body, frame it with a version + checksum, and +// emit a short, opaque, clipboard-safe base64url string under a namespacing +// prefix. Domain code supplies only the body schema (`write`/`read` over the +// BitWriter/BitReader); everything else — compression, integrity, framing, +// whitespace tolerance, typed errors — lives here so a new shareable thing +// (e.g. an enabled-skills set) is just a new `createLoadout({ … })`. + +// ── Little-endian bit writer (WoW's WriteBits, low bit first) ──────────────── +export class BitWriter { + private bits: number[] = [] + + bit(v: 0 | 1 | boolean): void { + this.bits.push(v ? 1 : 0) + } + + uint(value: number, width: number): void { + let v = value >>> 0 + + for (let i = 0; i < width; i += 1) { + this.bits.push(v & 1) + v >>>= 1 + } + } + + // LEB128-style varint: 7 payload bits per group, high "continue" bit set while + // more groups follow. + varint(value: number): void { + let v = Math.max(0, Math.floor(value)) + + do { + const group = v & 0x7f + v = Math.floor(v / 128) + this.bit(v > 0 ? 1 : 0) + this.uint(group, 7) + } while (v > 0) + } + + str(s: string): void { + const bytes = new TextEncoder().encode(s) + this.varint(bytes.length) + + for (const b of bytes) { + this.uint(b, 8) + } + } + + bytes(): Uint8Array { + const out = new Uint8Array(Math.ceil(this.bits.length / 8)) + + for (let i = 0; i < this.bits.length; i += 1) { + if (this.bits[i]) { + out[i >> 3]! |= 1 << (i & 7) + } + } + + return out + } +} + +export class BitReader { + private pos = 0 + + constructor(private readonly buf: Uint8Array) {} + + bit(): number { + if (this.pos >= this.buf.length * 8) { + throw new RangeError('loadout truncated') + } + + const i = this.pos++ + + return (this.buf[i >> 3]! >> (i & 7)) & 1 + } + + uint(width: number): number { + let v = 0 + + for (let i = 0; i < width; i += 1) { + v |= this.bit() << i + } + + return v >>> 0 + } + + varint(): number { + let v = 0 + let shift = 0 + + for (;;) { + const cont = this.bit() + v += this.uint(7) * 2 ** shift + shift += 7 + + if (!cont) { + return v + } + } + } + + str(): string { + const len = this.varint() + const bytes = new Uint8Array(len) + + for (let i = 0; i < len; i += 1) { + bytes[i] = this.uint(8) + } + + return new TextDecoder().decode(bytes) + } +} + +// Interns repeated strings (labels, categories, …) so each record spends one +// varint id instead of the full string; DEFLATE then squeezes the dictionary. +export class Dict { + private readonly index = new Map<string, number>() + readonly list: string[] = [] + + id(s: string): number { + const hit = this.index.get(s) + + if (hit !== undefined) { + return hit + } + + const id = this.list.length + this.index.set(s, id) + this.list.push(s) + + return id + } +} + +// Index of `value` in a fixed enum table, clamped to 0 so an unknown value +// decodes to the table's first (default) member instead of throwing. +export const idxOf = <T extends readonly string[]>(table: T, value: string): number => { + const i = table.indexOf(value as T[number]) + + return i < 0 ? 0 : i +} + +// Bits needed to address `n` items positionally (fixed-width back-references). +export const indexBits = (n: number): number => (n <= 1 ? 1 : Math.ceil(Math.log2(n))) + +// ── base64url over the raw bytes (URL- and clipboard-safe, no padding) ──────── +function toBase64Url(buf: Uint8Array): string { + let bin = '' + + for (const b of buf) { + bin += String.fromCharCode(b) + } + + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function fromBase64Url(s: string): Uint8Array { + const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + const bin = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4)) + const out = new Uint8Array(bin.length) + + for (let i = 0; i < bin.length; i += 1) { + out[i] = bin.charCodeAt(i) + } + + return out +} + +// FNV-1a over the body bytes, low 16 bits — a tamper/corruption gate, not crypto. +function checksum16(buf: Uint8Array): number { + let h = 0x811c9dc5 + + for (const b of buf) { + h ^= b + h = Math.imul(h, 0x01000193) + } + + return (h >>> 0) & 0xffff +} + +export class LoadoutError extends Error {} + +export interface Loadout<T> { + decode(code: string): T + encode(value: T): string +} + +export interface LoadoutSpec<T> { + /** Namespacing prefix (like WoW's leading bytes), e.g. 'HML'. */ + prefix: string + /** Bumped whenever the body schema changes incompatibly. */ + version: number + /** Write the domain body; framing/compression/checksum are added around it. */ + write: (w: BitWriter, value: T) => void + /** Read the domain body back. May throw — it's wrapped as a LoadoutError. */ + read: (r: BitReader) => T + /** Noun for user-facing error messages, e.g. 'map code'. Default: 'code'. */ + noun?: string + /** Error subclass to throw, so callers can `instanceof` their own type. */ + error?: new (message: string) => LoadoutError +} + +const HEAD_BYTES = 3 // 8-bit version + 16-bit checksum + +// Build an encode/decode pair for a domain value. The body schema is the only +// thing a caller writes; everything else (deflate, version+checksum frame, +// base64url, whitespace tolerance, typed errors) is shared. +export function createLoadout<T>(spec: LoadoutSpec<T>): Loadout<T> { + const Err = spec.error ?? LoadoutError + const noun = spec.noun ?? 'code' + const Noun = capitalize(noun) + + const encode = (value: T): string => { + const body = new BitWriter() + spec.write(body, value) + const payload = deflateSync(body.bytes(), { level: 9 }) + + const head = new BitWriter() + head.uint(spec.version, 8) + head.uint(checksum16(payload), 16) + const headBytes = head.bytes() + + const framed = new Uint8Array(headBytes.length + payload.length) + framed.set(headBytes, 0) + framed.set(payload, headBytes.length) + + return spec.prefix + toBase64Url(framed) + } + + const decode = (code: string): T => { + // Strip ALL whitespace, not just the ends — a pasted code often picks up soft + // wraps / newlines, and base64 decoding chokes on any of it. + const cleaned = code.replace(/\s+/g, '') + const raw = cleaned.startsWith(spec.prefix) ? cleaned.slice(spec.prefix.length) : cleaned + + if (!raw) { + throw new Err(`That doesn't look like a ${noun}.`) + } + + let framed: Uint8Array + + try { + framed = fromBase64Url(raw) + } catch { + throw new Err(`That doesn't look like a ${noun}.`) + } + + if (framed.length <= HEAD_BYTES) { + throw new Err(`${Noun} is too short to be valid.`) + } + + const head = new BitReader(framed.subarray(0, HEAD_BYTES)) + const version = head.uint(8) + const storedSum = head.uint(16) + + if (version !== spec.version) { + throw new Err(`${Noun} is version ${version}; this build reads version ${spec.version}.`) + } + + const payload = framed.subarray(HEAD_BYTES) + + if (checksum16(payload) !== storedSum) { + throw new Err(`${Noun} looks corrupted (checksum mismatch).`) + } + + try { + return spec.read(new BitReader(inflateSync(payload))) + } catch (err) { + throw new Err(err instanceof Error ? `${Noun} is malformed: ${err.message}` : `${Noun} is malformed.`) + } + } + + return { decode, encode } +} diff --git a/ui-desktop/src/lib/local-preview.test.ts b/ui-desktop/src/lib/local-preview.test.ts new file mode 100644 index 00000000..c7aa2a63 --- /dev/null +++ b/ui-desktop/src/lib/local-preview.test.ts @@ -0,0 +1,199 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { readDesktopFileDataUrl } = vi.hoisted(() => ({ readDesktopFileDataUrl: vi.fn() })) + +vi.mock('@/lib/desktop-fs', () => ({ + isDesktopFsRemoteMode: () => true, + readDesktopFileDataUrl, + readDesktopFileText: vi.fn() +})) + +import { + localPreviewTarget, + normalizeOrLocalPreviewTarget, + openPreviewTargetInBrowser, + remoteHtmlPreviewDocument, + validatedRemoteHtmlDataUrl +} from './local-preview' + +const remoteTarget = { + kind: 'file' as const, + label: 'report.html', + path: '/srv/report.html', + previewKind: 'html' as const, + source: '/srv/report.html', + url: 'file:///srv/report.html' +} + +describe('remote HTML previews', () => { + beforeEach(() => { + vi.clearAllMocks() + window.clawcodexDesktop = { + normalizePreviewTarget: vi.fn(async () => remoteTarget) + } as never + }) + + it('loads bounded authenticated bytes while retaining the canonical file URL', async () => { + const dataUrl = `data:text/html;base64,${btoa('<h1>remote</h1>')}` + readDesktopFileDataUrl.mockResolvedValue(dataUrl) + + await expect(normalizeOrLocalPreviewTarget('/srv/report.html')).resolves.toEqual({ + ...remoteTarget, + dataUrl + }) + expect(readDesktopFileDataUrl).toHaveBeenCalledWith('/srv/report.html') + }) + + it('falls back to source mode when the transport is not canonical HTML', async () => { + readDesktopFileDataUrl.mockResolvedValue('data:text/plain;base64,SGVsbG8=') + + await expect(normalizeOrLocalPreviewTarget('/srv/report.html')).resolves.toMatchObject({ + renderMode: 'source', + transient: true, + url: 'file:///srv/report.html' + }) + }) + + it('rejects malformed base64', () => { + expect(validatedRemoteHtmlDataUrl('data:text/html;base64,SGVsbG8=')).toBe('data:text/html;base64,SGVsbG8=') + expect(validatedRemoteHtmlDataUrl('data:text/html;base64,SGVsbG8')).toBeNull() + expect(validatedRemoteHtmlDataUrl('data:text/html;base64,SGVsbG8%3D')).toBeNull() + }) + + it('wraps remote HTML in a deny-by-default content policy', () => { + const html = + '<meta http-equiv="refresh" content="0;url=https://example.test"><a href="https://example.test">remote</a>' + + const document = remoteHtmlPreviewDocument(`data:text/html;base64,${btoa(html)}`) + + expect(document).toContain(`default-src 'none'`) + expect(document).toContain(`form-action 'none'`) + expect(document).toContain('<a>remote</a>') + expect(document).not.toContain('refresh') + expect(document).not.toContain('https://example.test') + }) + + it('strips scripts from remote HTML', () => { + const html = + '<p>safe</p><script>document.body.textContent = "SCRIPT-RAN"</script><template><script>TEMPLATE-SCRIPT</script></template>' + + const document = remoteHtmlPreviewDocument(`data:text/html;base64,${btoa(html)}`) + + expect(document).toContain('<p>safe</p>') + expect(document).not.toContain('<script') + expect(document).not.toContain('SCRIPT-RAN') + expect(document).not.toContain('TEMPLATE-SCRIPT') + }) + + it('does not create scripts when sanitized HTML is reparsed', () => { + const html = '<form><math><mtext></form><form><mglyph><style></math><script>MUTATION-SCRIPT</script>' + + const sanitized = remoteHtmlPreviewDocument(`data:text/html;base64,${btoa(html)}`) + + expect(sanitized).not.toContain('<script') + expect(new DOMParser().parseFromString(sanitized ?? '', 'text/html').querySelector('script')).toBeNull() + }) + + it('decodes remote HTML as UTF-8', () => { + const html = '<p>café 😀</p>' + const payload = btoa(String.fromCharCode(...new TextEncoder().encode(html))) + + expect(remoteHtmlPreviewDocument(`data:text/html;base64,${payload}`)).toContain(html) + }) + + it('stages remote HTML before opening it in the system browser', async () => { + const dataUrl = `data:text/html;base64,${btoa('<h1>remote</h1>')}` + const saveImageBuffer = vi.fn(async (_data: ArrayBuffer | Uint8Array, _ext: string) => '/tmp/report #1?.html') + const openPreviewInBrowser = vi.fn(async () => undefined) + window.clawcodexDesktop = { openPreviewInBrowser, saveImageBuffer } as never + + await openPreviewTargetInBrowser({ ...remoteTarget, dataUrl }) + + expect(new TextDecoder().decode(saveImageBuffer.mock.calls[0]?.[0])).toBe('<h1>remote</h1>') + expect(saveImageBuffer).toHaveBeenCalledWith(expect.any(Uint8Array), '.html') + expect(openPreviewInBrowser).toHaveBeenCalledWith('file:///tmp/report%20%231%3F.html') + }) + + it('serializes UNC staging paths as file URLs', async () => { + const dataUrl = `data:text/html;base64,${btoa('<h1>remote</h1>')}` + const saveImageBuffer = vi.fn(async () => '\\\\server\\share\\report #1.html') + const openPreviewInBrowser = vi.fn(async () => undefined) + window.clawcodexDesktop = { openPreviewInBrowser, saveImageBuffer } as never + + await openPreviewTargetInBrowser({ ...remoteTarget, dataUrl }) + + expect(openPreviewInBrowser).toHaveBeenCalledWith('file://server/share/report%20%231.html') + }) + + it('preserves backslashes in POSIX local file paths', () => { + expect(localPreviewTarget('/tmp/report\\draft.html')?.url).toBe('file:///tmp/report%5Cdraft.html') + }) + + it('preserves POSIX double-slash file paths', () => { + expect(localPreviewTarget('//srv/share/report #1?.html')?.url).toBe('file:////srv/share/report%20%231%3F.html') + }) + + it('opens ordinary targets without staging them', async () => { + const openPreviewInBrowser = vi.fn(async () => undefined) + const saveImageBuffer = vi.fn() + window.clawcodexDesktop = { openPreviewInBrowser, saveImageBuffer } as never + + await openPreviewTargetInBrowser(remoteTarget) + + expect(saveImageBuffer).not.toHaveBeenCalled() + expect(openPreviewInBrowser).toHaveBeenCalledWith(remoteTarget.url) + }) + + it('keeps local HTML source browser opens on their existing path', async () => { + const openPreviewInBrowser = vi.fn(async () => undefined) + window.clawcodexDesktop = { openPreviewInBrowser } as never + + await openPreviewTargetInBrowser({ + ...remoteTarget, + renderMode: 'source', + source: '/tmp/local.html', + url: 'file:///tmp/local.html' + }) + + expect(openPreviewInBrowser).toHaveBeenCalledWith('file:///tmp/local.html') + }) + + it('does not send failed remote HTML paths to the local browser', async () => { + const openPreviewInBrowser = vi.fn(async () => undefined) + window.clawcodexDesktop = { openPreviewInBrowser } as never + + await expect( + openPreviewTargetInBrowser({ ...remoteTarget, renderMode: 'source', transient: true }) + ).rejects.toThrow('Remote HTML preview could not be loaded') + expect(openPreviewInBrowser).not.toHaveBeenCalled() + }) +}) + +describe('PDF previews', () => { + it('classifies PDF files as PDF previews', () => { + expect(localPreviewTarget('/tmp/spec.pdf')).toMatchObject({ + path: '/tmp/spec.pdf', + previewKind: 'pdf' + }) + }) + + it('keeps ordinary text files on the source-preview path', () => { + expect(localPreviewTarget('/tmp/spec.md')).toMatchObject({ + language: 'markdown', + previewKind: 'text' + }) + }) + + it('does not UTF-8-enrich remote PDFs before loading their bytes', async () => { + vi.clearAllMocks() + window.clawcodexDesktop = { + normalizePreviewTarget: vi.fn(async () => null) + } as never + + await expect(normalizeOrLocalPreviewTarget('/remote/spec.pdf')).resolves.toMatchObject({ + path: '/remote/spec.pdf', + previewKind: 'pdf' + }) + expect(readDesktopFileDataUrl).not.toHaveBeenCalled() + }) +}) diff --git a/ui-desktop/src/lib/local-preview.ts b/ui-desktop/src/lib/local-preview.ts new file mode 100644 index 00000000..11f7b05e --- /dev/null +++ b/ui-desktop/src/lib/local-preview.ts @@ -0,0 +1,276 @@ +import DOMPurify from 'dompurify' + +import { isDesktopFsRemoteMode, readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs' +import type { PreviewTarget } from '@/store/preview' + +const HTML_EXTENSIONS = new Set(['.htm', '.html']) +const IMAGE_EXTENSIONS = new Set(['.bmp', '.gif', '.jpeg', '.jpg', '.png', '.svg', '.webp']) +const PDF_EXTENSIONS = new Set(['.pdf']) +// Mirrors `_FS_DATA_URL_MAX_BYTES` in the backend filesystem endpoint. +const REMOTE_HTML_PREVIEW_MAX_BYTES = 16 * 1024 * 1024 +const REMOTE_HTML_PREVIEW_MAX_BASE64_BYTES = Math.ceil(REMOTE_HTML_PREVIEW_MAX_BYTES / 3) * 4 + +const LANGUAGE_BY_EXT: Record<string, string> = { + '.c': 'c', + '.conf': 'ini', + '.cpp': 'cpp', + '.css': 'css', + '.csv': 'csv', + '.go': 'go', + '.graphql': 'graphql', + '.h': 'c', + '.hpp': 'cpp', + '.html': 'html', + '.java': 'java', + '.js': 'javascript', + '.json': 'json', + '.jsx': 'jsx', + '.log': 'text', + '.lua': 'lua', + '.md': 'markdown', + '.mjs': 'javascript', + '.py': 'python', + '.rb': 'ruby', + '.rs': 'rust', + '.sh': 'shell', + '.sql': 'sql', + '.svg': 'xml', + '.toml': 'toml', + '.ts': 'typescript', + '.tsx': 'tsx', + '.txt': 'text', + '.xml': 'xml', + '.yaml': 'yaml', + '.yml': 'yaml', + '.zsh': 'shell' +} + +function basename(value: string) { + return value.split(/[\\/]/).filter(Boolean).pop() || value +} + +function extension(value: string) { + const clean = value.split(/[?#]/, 1)[0] || value + const idx = clean.lastIndexOf('.') + + return idx >= 0 ? clean.slice(idx).toLowerCase() : '' +} + +function joinPath(base: string, rel: string) { + if (!base) { + return rel + } + + return `${base.replace(/\/+$/, '')}/${rel.replace(/^\.?\//, '')}` +} + +function pathToFileUrl(path: string) { + const isWindowsUnc = path.startsWith('\\\\') + const normalized = isWindowsUnc || /^[a-z]:[\\/]/i.test(path) ? path.replace(/\\/g, '/') : path + + const encoded = normalized + .split('/') + .map(part => encodeURIComponent(part)) + .join('/') + + if (isWindowsUnc) { + return `file://${encoded.slice(2)}` + } + + return `file://${encoded.startsWith('/') ? encoded : `/${encoded}`}` +} + +export function validatedRemoteHtmlDataUrl(value: string): string | null { + const prefix = 'data:text/html;base64,' + + if (!value.startsWith(prefix)) { + return null + } + + const payload = value.slice(prefix.length) + + if (payload.length > REMOTE_HTML_PREVIEW_MAX_BASE64_BYTES || payload.length % 4 !== 0) { + return null + } + + try { + const decoded = atob(payload) + + return decoded.length <= REMOTE_HTML_PREVIEW_MAX_BYTES && btoa(decoded) === payload ? value : null + } catch { + return null + } +} + +export function remoteHtmlPreviewDocument(dataUrl: string): string | null { + const validated = validatedRemoteHtmlDataUrl(dataUrl) + + if (!validated) { + return null + } + + const csp = `default-src 'none'; base-uri 'none'; form-action 'none'; img-src data:; media-src data:; font-src data:; style-src 'unsafe-inline'` + + const html = new TextDecoder().decode( + Uint8Array.from(atob(validated.slice(validated.indexOf(',') + 1)), char => char.charCodeAt(0)) + ) + + const document = new DOMParser().parseFromString( + DOMPurify.sanitize(html, { + WHOLE_DOCUMENT: true, + FORBID_TAGS: ['script', 'template', 'iframe', 'frame', 'object', 'embed'], + FORBID_ATTR: ['href', 'xlink:href', 'action', 'formaction', 'target'] + }), + 'text/html' + ) + + document.querySelectorAll('meta[http-equiv]').forEach(element => { + if (element.getAttribute('http-equiv')?.toLowerCase() === 'refresh') { + element.remove() + } + }) + document.querySelectorAll('*').forEach(element => { + for (const attribute of Array.from(element.attributes)) { + if (attribute.localName === 'href' || attribute.localName === 'ping') { + element.removeAttributeNode(attribute) + } + } + }) + const policy = document.createElement('meta') + policy.httpEquiv = 'Content-Security-Policy' + policy.content = csp + document.head.prepend(policy) + + return `<!doctype html>${document.documentElement.outerHTML}` +} + +export async function openPreviewTargetInBrowser(target: PreviewTarget): Promise<void> { + const bridge = window.clawcodexDesktop + + if (!bridge?.openPreviewInBrowser) { + throw new Error('Desktop preview browser bridge is unavailable') + } + + const dataUrl = target.dataUrl && validatedRemoteHtmlDataUrl(target.dataUrl) + + if (!dataUrl) { + if (target.transient) { + throw new Error('Remote HTML preview could not be loaded') + } + + await bridge.openPreviewInBrowser(target.url) + + return + } + + if (!bridge.saveImageBuffer) { + throw new Error('Desktop preview buffer bridge is unavailable') + } + + const decoded = atob(dataUrl.slice(dataUrl.indexOf(',') + 1)) + const bytes = Uint8Array.from(decoded, char => char.charCodeAt(0)) + const filePath = await bridge.saveImageBuffer(bytes, '.html') + + if (!filePath) { + throw new Error('Could not stage remote HTML preview') + } + + await bridge.openPreviewInBrowser(pathToFileUrl(filePath)) +} + +export function localPreviewTarget(rawTarget: string, cwd?: string | null): PreviewTarget | null { + const raw = rawTarget.trim().replace(/^`|`$/g, '') + + if (!raw) { + return null + } + + if (/^https?:\/\//i.test(raw)) { + return { kind: 'url', label: basename(raw), source: raw, url: raw } + } + + let path = raw + + if (/^file:\/\//i.test(raw)) { + try { + path = decodeURIComponent(new URL(raw).pathname) + } catch { + path = raw.replace(/^file:\/\//i, '') + } + } else if (!raw.startsWith('/') && cwd) { + path = joinPath(cwd, raw) + } + + const ext = extension(path) + const isHtml = HTML_EXTENSIONS.has(ext) + const isImage = IMAGE_EXTENSIONS.has(ext) + const isPdf = PDF_EXTENSIONS.has(ext) + + return { + kind: 'file', + label: basename(path), + language: LANGUAGE_BY_EXT[ext] || 'text', + path, + // Renderer fallback can't stat/sniff without reading; assume text unless + // image/html/pdf extension says otherwise. LocalFilePreview still guards + // binary/large files when readFileText/readFileDataUrl returns metadata. + previewKind: isHtml ? 'html' : isImage ? 'image' : isPdf ? 'pdf' : 'text', + source: raw, + url: pathToFileUrl(path) + } +} + +async function enrichPreviewTarget(target: PreviewTarget | null): Promise<PreviewTarget | null> { + if ( + !isDesktopFsRemoteMode() || + !target || + target.kind !== 'file' || + target.previewKind === 'image' || + target.previewKind === 'pdf' + ) { + return target + } + + if (target.previewKind === 'html') { + try { + const dataUrl = validatedRemoteHtmlDataUrl(await readDesktopFileDataUrl(target.path || target.source)) + + return dataUrl ? { ...target, dataUrl } : { ...target, renderMode: 'source', transient: true } + } catch { + return { ...target, renderMode: 'source', transient: true } + } + } + + try { + const result = await readDesktopFileText(target.path || target.source) + + return { + ...target, + binary: result.binary, + byteSize: result.byteSize, + language: result.language || target.language, + large: (result.byteSize ?? 0) > 512 * 1024, + mimeType: result.mimeType + } + } catch { + return target + } +} + +export async function normalizeOrLocalPreviewTarget( + rawTarget: string, + cwd?: string | null +): Promise<PreviewTarget | null> { + try { + const normalized = await window.clawcodexDesktop?.normalizePreviewTarget?.(rawTarget, cwd || undefined) + + if (normalized) { + return enrichPreviewTarget(normalized) + } + } catch { + // Running Electron may still have the old HTML-only preview IPC. Fall + // through to renderer-side local classification so text/images still open. + } + + return enrichPreviewTarget(localPreviewTarget(rawTarget, cwd)) +} diff --git a/ui-desktop/src/lib/markdown-blocks.test.ts b/ui-desktop/src/lib/markdown-blocks.test.ts new file mode 100644 index 00000000..ff8d9495 --- /dev/null +++ b/ui-desktop/src/lib/markdown-blocks.test.ts @@ -0,0 +1,164 @@ +import { parseMarkdownIntoBlocks } from '@assistant-ui/react-streamdown' +import { describe, expect, it } from 'vitest' + +import { parseMarkdownIntoBlocksCached } from './markdown-blocks' + +// The contract: streaming through the cached splitter (one call per growing +// prefix, exactly how Streamdown calls it per flush) must produce, at every +// step, the same blocks as a fresh full lex of that prefix. Byte equality — +// a divergence would change what the memoized block renderer paints. + +const CORPUS = `# Heading + +Intro paragraph with **bold**, [a link](https://example.com), \`inline\` and $x^2$ math. + +- list item one +- list item two + - nested item + +1. ordered a + +2. loose ordered b + +\`\`\`python +def f(x): + return x * 2 # comment with \`\`\` inside string? no — fence chars below +\`\`\` + +A paragraph that will be followed by a setext underline +=== + +| col a | col b | +|---|---| +| 1 | 2 | +| 3 | 4 | + +> blockquote line one +> blockquote line two +with a lazy continuation line + +<div class="raw"> +html block content +</div> + +$$ +\\int_0^1 x\\,dx = \\tfrac12 +$$ + +Final paragraph after everything, long enough to stream in pieces so the tail +block keeps getting reinterpreted while earlier blocks stay settled. +` + +// Deterministic PRNG so failures reproduce. +function mulberry32(seed: number) { + let a = seed + + return () => { + a |= 0 + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +// Push the text past the cache MIN_LENGTH thresholds so the incremental +// path actually engages. +const LONG_CORPUS = Array.from({ length: 6 }, () => CORPUS).join('\n') + +describe('parseMarkdownIntoBlocksCached', () => { + it('matches a full lex at every random streaming cut (property)', () => { + for (let seed = 1; seed <= 5; seed++) { + const rand = mulberry32(seed) + let cursor = 0 + + while (cursor < LONG_CORPUS.length) { + cursor = Math.min(LONG_CORPUS.length, cursor + 1 + Math.floor(rand() * 120)) + const prefix = LONG_CORPUS.slice(0, cursor) + + expect(parseMarkdownIntoBlocksCached(prefix)).toEqual(parseMarkdownIntoBlocks(prefix)) + } + } + }) + + it('matches a full lex when streaming token-by-token through a fence boundary', () => { + const base = `${'settled paragraph one.\n\n'.repeat(100)}opening a fence now:\n` + const tail = '```js\nconst a = 1\nconst b = 2\n```\n\nafter the fence\n' + + for (let i = 1; i <= tail.length; i++) { + const text = base + tail.slice(0, i) + + expect(parseMarkdownIntoBlocksCached(text)).toEqual(parseMarkdownIntoBlocks(text)) + } + }) + + it('reconstructs the input exactly (join property the offsets rely on)', () => { + const blocks = parseMarkdownIntoBlocksCached(LONG_CORPUS) + + expect(blocks.join('')).toBe(LONG_CORPUS) + }) + + it('falls back to a full lex for non-append rewrites (edit / branch swap)', () => { + const grown = `${LONG_CORPUS}\n\nappended tail paragraph` + parseMarkdownIntoBlocksCached(grown) + + // A REWRITE that shares no prefix lineage must still be correct. + const rewritten = `completely different start\n\n${LONG_CORPUS.slice(500)}` + + expect(parseMarkdownIntoBlocksCached(rewritten)).toEqual(parseMarkdownIntoBlocks(rewritten)) + }) + + it('matches a full lex when a trailing setext underline merges the previous block (regression)', () => { + // A trailing `-`/`=` line is a setext underline of the block ABOVE it, so + // appending to it can retroactively merge the previous parse's LAST TWO + // blocks into one. Cached `"…#e\n5\n-"` lexes to [ …, "#e\n", "5\n-" ], but + // grown to `"…#e\n5\n-p2=kj:c"` collapses `#e`/`5\n-` into one block. The + // old boundary dropped only the single last content block, so it reused a + // `"#e\n"` block that no longer exists. `blocks.join('') === text` still + // holds for the wrong split, so the reconstruction guard cannot catch it. + // The settled prefix pushes the text past the append-cache threshold so + // the incremental path actually engages. + const settled = 'settled line paragraph text.\n\n'.repeat(80) + const prev = `${settled}#e\n5\n-` + const grown = `${prev}p2=kj:c` + + // Seed the append cache with `prev`, then grow it — the exact two-call + // sequence a streaming flush produces. + parseMarkdownIntoBlocksCached(prev) + + expect(parseMarkdownIntoBlocksCached(grown)).toEqual(parseMarkdownIntoBlocks(grown)) + }) + + // 12 seeds × 500 growing prefixes is ~6000 full+cached lexes; it first trips + // the pre-fix boundary at seed 11 / step 257, so the workload can't shrink + // without gutting the guard. The work is bounded but exceeds one test's 5s + // default budget, so raise the timeout rather than weaken the coverage. + it('matches a full lex at every char-level streaming cut over noisy markdown (property fuzz)', () => { + // Character-level append fuzz over the markdown control alphabet — the + // harness that surfaced the setext-underline merge above. Growing a single + // lineage one small chunk at a time keeps `startsWith` lineage intact so + // the incremental path runs on nearly every step; each prefix must + // deep-equal a fresh full lex. + const alphabet = '\n `#*-_>[]()|~:=abcdefghijklmnopqrstuvwxyz0123456789' + + for (let seed = 1; seed <= 12; seed++) { + const rand = mulberry32(seed) + // Seed past the append-cache threshold so the incremental path engages. + let text = `seed ${seed}\n\n`.repeat(180) + + for (let step = 0; step < 500; step++) { + const n = 1 + Math.floor(rand() * 24) + let chunk = '' + + for (let j = 0; j < n; j++) { + chunk += alphabet[Math.floor(rand() * alphabet.length)] + } + + text += chunk + + expect(parseMarkdownIntoBlocksCached(text)).toEqual(parseMarkdownIntoBlocks(text)) + } + } + }, 30_000) +}) diff --git a/ui-desktop/src/lib/markdown-blocks.ts b/ui-desktop/src/lib/markdown-blocks.ts new file mode 100644 index 00000000..8aac6a3a --- /dev/null +++ b/ui-desktop/src/lib/markdown-blocks.ts @@ -0,0 +1,138 @@ +import { parseMarkdownIntoBlocks } from '@assistant-ui/react-streamdown' + +/** + * Block splitting for the streaming markdown pipeline, without re-lexing the + * whole message on every token flush. + * + * `parseMarkdownIntoBlocks` is a full `marked` lex of the entire text — + * measured 3.4–9.6ms per call at 64–192KB. During streaming every flush is a + * new string, so the stock splitter pays that O(full-text) cost ~30×/s on + * long replies. Two caches remove it: + * + * 1. Exact-string cache — the same text always yields the SAME ARRAY. This is + * identity, not just cost: `parseMarkdownIntoBlocks` builds a fresh array + * every call, and Streamdown mirrors the block list into `useState`, so a + * new array identity for unchanged text makes every Streamdown re-render + * itself and re-render every Block under it. Short messages used to skip + * the cache on the theory that re-lexing them was cheap — the lex is, but + * the churn it caused was not (measured: ~105 self-renders of Streamdown + * across five idle tiles in six seconds, cascading into 800 Block renders + * with nothing streaming). Every length is cached now. + * 2. Streaming-append cache — when the new text starts with a recently parsed + * text (the token-append case), the previous parse's blocks are reused up + * to a settled boundary and only the suffix is lexed. The boundary drops + * the previous parse's trailing whitespace-only blocks AND its last content + * block, because appended text can retroactively change how that last + * block parses (open fence, list/table continuation, setext underline, a + * lazy blockquote line). Blocks before it are separated by settled blank + * lines and cannot be affected. Cross-block reference links can't regress: + * Streamdown renders each block as an independent markdown document + * already. Verified property: `blocks.join('') === text`, and incremental + * output is asserted byte-identical to a full lex in tests across fences, + * lists, tables, setext headings, blockquotes, and HTML blocks. + * + * Any doubt — no prefix match, reconstruction mismatch — falls back to the + * full lex, i.e. exactly the previous behavior. + */ + +const EXACT_CACHE_MAX = 256 +const exactCache = new Map<string, string[]>() + +// Streaming messages grow monotonically, and only a handful stream at once +// (main reply + reasoning part, maybe a tile). A tiny ring is enough; each +// entry holds the last parse for one growing text lineage. +const APPEND_CACHE_MAX = 4 +const APPEND_CACHE_MIN_LENGTH = 2048 +const appendCache: { blocks: string[]; text: string }[] = [] + +function rememberAppend(text: string, blocks: string[]): void { + if (text.length < APPEND_CACHE_MIN_LENGTH) { + return + } + + // Replace the lineage this text grew from (its cached prefix), else push. + const index = appendCache.findIndex(entry => text.startsWith(entry.text)) + + if (index !== -1) { + appendCache.splice(index, 1) + } + + appendCache.push({ blocks, text }) + + if (appendCache.length > APPEND_CACHE_MAX) { + appendCache.shift() + } +} + +function lexIncrementally(text: string): null | string[] { + const entry = appendCache.find(cached => text.length > cached.text.length && text.startsWith(cached.text)) + + if (!entry) { + return null + } + + // Settled boundary: drop the last TWO content blocks (skipping any + // whitespace-only blocks around them). Dropping only the single last content + // block is unsound: appended text can retroactively merge the previous + // parse's last two blocks into one. The trigger is a trailing Setext + // underline — `marked` only treats `-`/`=` as an underline for the paragraph + // ABOVE it, so a settled `"#e\n5\n-"` lexes as ["#e\n", "5\n-"], but growing + // the tail to `"#e\n5\n-p2=kj:c"` collapses both into one paragraph. The + // block before the last is the deepest an append can reach (the underline + // consumes exactly one preceding block), so re-lexing the last two is safe; + // earlier blocks are fenced off by settled blank lines. join('') === text + // still holds either way, so the reconstruction check below can't catch this. + let keep = entry.blocks.length + + for (let dropped = 0; dropped < 2 && keep > 0; dropped += 1) { + while (keep > 0 && !entry.blocks[keep - 1].trim()) { + keep -= 1 + } + + if (keep > 0) { + keep -= 1 + } + } + + if (keep === 0) { + return null + } + + const settled = entry.blocks.slice(0, keep) + let settledLength = 0 + + for (const block of settled) { + settledLength += block.length + } + + // Defensive reconstruction check — the splitter's join(blocks) === text + // property is what makes offsets exact. If it ever doesn't hold, full lex. + if (settledLength > entry.text.length || !text.startsWith(entry.text.slice(0, settledLength), 0)) { + return null + } + + return [...settled, ...parseMarkdownIntoBlocks(text.slice(settledLength))] +} + +export function parseMarkdownIntoBlocksCached(markdown: string): string[] { + const hit = exactCache.get(markdown) + + if (hit) { + // Refresh recency (Map iteration order is insertion order). + exactCache.delete(markdown) + exactCache.set(markdown, hit) + + return hit + } + + const blocks = lexIncrementally(markdown) ?? parseMarkdownIntoBlocks(markdown) + + rememberAppend(markdown, blocks) + exactCache.set(markdown, blocks) + + if (exactCache.size > EXACT_CACHE_MAX) { + exactCache.delete(exactCache.keys().next().value as string) + } + + return blocks +} diff --git a/ui-desktop/src/lib/markdown-code.test.ts b/ui-desktop/src/lib/markdown-code.test.ts new file mode 100644 index 00000000..f71f564c --- /dev/null +++ b/ui-desktop/src/lib/markdown-code.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' + +import { isLikelyProseCodeBlock } from './markdown-code' + +describe('isLikelyProseCodeBlock', () => { + it('detects prose that Streamdown mislabels as an unknown language', () => { + expect( + isLikelyProseCodeBlock( + 'heads', + [ + '- Pure white (`#ffffff`), roughness 0.55, no emissive', + '- Black wireframe edges at 35% opacity', + '', + 'Want the bunny gone, or want me to keep riffing on it?' + ].join('\n') + ) + ).toBe(true) + }) + + it('keeps real code blocks', () => { + expect(isLikelyProseCodeBlock('ts', 'const value = { bunny: true };\nreturn value')).toBe(false) + }) +}) diff --git a/ui-desktop/src/lib/markdown-code.ts b/ui-desktop/src/lib/markdown-code.ts new file mode 100644 index 00000000..4b1632b9 --- /dev/null +++ b/ui-desktop/src/lib/markdown-code.ts @@ -0,0 +1,328 @@ +import { normalize } from '@/lib/text' + +const VALID_LANGUAGE_RE = /^[a-z0-9][a-z0-9+#-]*$/i +const NON_CODE_FENCE_LANGUAGES = new Set(['', 'text', 'plain', 'plaintext', 'md', 'markdown']) + +const COMMON_CODE_LANGUAGES = new Set([ + 'bash', + 'c', + 'cpp', + 'css', + 'diff', + 'go', + 'html', + 'java', + 'javascript', + 'js', + 'json', + 'jsx', + 'markdown', + 'md', + 'php', + 'python', + 'py', + 'ruby', + 'rust', + 'rs', + 'sh', + 'sql', + 'swift', + 'tsx', + 'ts', + 'typescript', + 'xml', + 'yaml', + 'yml' +]) + +interface CodeSignals { + bulletLines: number + codeSignals: number + hasMarkdown: boolean + proseLines: number + trimmed: string + urlLines: number +} + +export function sanitizeLanguageTag(tag: string): string { + const trimmed = tag.trim() + const first = trimmed.split(/\s/, 1)[0] || '' + + return VALID_LANGUAGE_RE.test(first) && first.length <= 16 ? first.toLowerCase() : '' +} + +// Sanitized language tag → codicon glyph. Anything not listed falls back to +// the generic `code` glyph, which matches what the tool-row icons use. +const CODICON_BY_LANGUAGE: Record<string, string> = { + bash: 'terminal', + cmd: 'terminal', + console: 'terminal', + fish: 'terminal', + powershell: 'terminal', + ps1: 'terminal', + sh: 'terminal', + shell: 'terminal', + zsh: 'terminal', + + md: 'markdown', + markdown: 'markdown', + + json: 'json', + json5: 'json', + + ini: 'settings-gear', + toml: 'settings-gear', + yaml: 'settings-gear', + yml: 'settings-gear', + dotenv: 'settings-gear', + env: 'settings-gear', + + graphql: 'database', + gql: 'database', + mysql: 'database', + postgres: 'database', + postgresql: 'database', + sql: 'database', + sqlite: 'database', + + diff: 'diff', + patch: 'diff', + + css: 'symbol-color', + less: 'symbol-color', + sass: 'symbol-color', + scss: 'symbol-color', + svg: 'symbol-color', + + regex: 'regex', + regexp: 'regex', + + curl: 'globe', + http: 'globe', + + docker: 'package', + dockerfile: 'package', + + mermaid: 'graph' +} + +export function codiconForLanguage(language: string | undefined): string { + return CODICON_BY_LANGUAGE[sanitizeLanguageTag(language || '')] || 'code' +} + +// File extension → language tag, so a filename can resolve to the same icon a +// fenced code block of that language would get. Only extensions that map to a +// non-generic codicon need an entry; everything else falls through to `code`. +const LANGUAGE_BY_EXTENSION: Record<string, string> = { + bash: 'bash', + cfg: 'ini', + conf: 'ini', + css: 'css', + dockerfile: 'dockerfile', + env: 'env', + gql: 'graphql', + graphql: 'graphql', + ini: 'ini', + json: 'json', + json5: 'json', + less: 'less', + markdown: 'markdown', + md: 'markdown', + mdx: 'markdown', + mmd: 'mermaid', + ps1: 'powershell', + psql: 'sql', + sass: 'sass', + scss: 'scss', + sh: 'bash', + sql: 'sql', + svg: 'svg', + toml: 'toml', + yaml: 'yaml', + yml: 'yml', + zsh: 'zsh' +} + +// Pick an icon for a file path by its extension (or bare name like +// `Dockerfile`), reusing the language→codicon map so file-edit rows and code +// blocks share one visual vocabulary. Unknown / generic code files get `code`. +export function codiconForFilename(path: string | undefined): string { + const token = filenameExtToken(path) + const language = LANGUAGE_BY_EXTENSION[token] || token + + return codiconForLanguage(language) +} + +// Last path segment's extension (or the bare lowercased name for `Dockerfile`, +// `Makefile`, …). Shared by the icon and Shiki-language resolvers. +function filenameExtToken(path: string | undefined): string { + const base = normalize((path || '').replace(/\\/g, '/').split('/').pop()) + const dot = base.lastIndexOf('.') + + return dot > 0 ? base.slice(dot + 1) : base +} + +// File extension → Shiki bundled-language id, for syntax-highlighting diffs in +// the editing tool's own language. Unknown extensions return '' so callers fall +// back to the plain color-only diff renderer. +const SHIKI_LANGUAGE_BY_EXTENSION: Record<string, string> = { + astro: 'astro', + bash: 'bash', + c: 'c', + cc: 'cpp', + cjs: 'javascript', + clj: 'clojure', + cpp: 'cpp', + cs: 'csharp', + css: 'css', + cxx: 'cpp', + dart: 'dart', + dockerfile: 'docker', + ex: 'elixir', + exs: 'elixir', + fish: 'fish', + go: 'go', + gql: 'graphql', + graphql: 'graphql', + h: 'c', + hpp: 'cpp', + hs: 'haskell', + htm: 'html', + html: 'html', + ini: 'ini', + java: 'java', + jl: 'julia', + js: 'javascript', + json: 'json', + json5: 'json5', + jsonc: 'jsonc', + jsx: 'jsx', + kt: 'kotlin', + kts: 'kotlin', + less: 'less', + lua: 'lua', + makefile: 'make', + markdown: 'markdown', + md: 'markdown', + mdx: 'mdx', + mjs: 'javascript', + ml: 'ocaml', + mts: 'typescript', + nix: 'nix', + php: 'php', + pl: 'perl', + proto: 'proto', + ps1: 'powershell', + py: 'python', + pyi: 'python', + r: 'r', + rb: 'ruby', + rs: 'rust', + sass: 'sass', + scala: 'scala', + scss: 'scss', + sh: 'bash', + sql: 'sql', + svelte: 'svelte', + swift: 'swift', + tf: 'terraform', + toml: 'toml', + ts: 'typescript', + tsx: 'tsx', + vue: 'vue', + xml: 'xml', + yaml: 'yaml', + yml: 'yaml', + zig: 'zig', + zsh: 'bash' +} + +export function shikiLanguageForFilename(path: string | undefined): string { + return SHIKI_LANGUAGE_BY_EXTENSION[filenameExtToken(path)] || '' +} + +function proseLineCount(body: string): number { + return body.split('\n').filter(line => { + const trimmed = line.trim() + + return Boolean(trimmed) && /^[A-Za-z0-9"'`*-]/.test(trimmed) + }).length +} + +const CODE_SIGNAL_RE = [ + /(^|\s)(const|let|var|function|class|import|export|return|if|for|while|switch)\b/gim, + /=>|==|===|!=|!==|\{|\}|;|<\/?[a-z][^>]*>/gi, + /^\s*(#include|SELECT|INSERT|UPDATE|DELETE|CREATE|DROP)\b/gim +] + +function codeSignalCount(body: string): number { + return CODE_SIGNAL_RE.reduce((total, pattern) => total + (body.match(pattern)?.length ?? 0), 0) +} + +function codeSignals(body: string): CodeSignals { + const trimmed = body.trim() + const markdownSignals = (trimmed.match(/\*\*[^*]+\*\*/g) || []).length + (trimmed.match(/`[^`\n]+`/g) || []).length + + return { + bulletLines: (trimmed.match(/^\s*[-*]\s+\S+/gm) || []).length, + codeSignals: codeSignalCount(trimmed), + hasMarkdown: markdownSignals > 0, + proseLines: proseLineCount(trimmed), + trimmed, + urlLines: (trimmed.match(/^\s*https?:\/\/\S+\s*$/gim) || []).length + } +} + +export function isLikelyProseFence(info: string, body: string): boolean { + const trimmedInfo = info.trim() + const rawInfo = trimmedInfo.toLowerCase() + const language = sanitizeLanguageTag(info) + const infoToken = trimmedInfo.split(/\s+/, 1)[0] || '' + const hasInfoTail = Boolean(trimmedInfo) && trimmedInfo !== infoToken + + if (/^[-*+]\s/.test(rawInfo) || /^https?:\/\//.test(rawInfo)) { + return true + } + + const signals = codeSignals(body) + + if (!signals.trimmed) { + return false + } + + if ( + hasInfoTail && + signals.codeSignals <= 2 && + (signals.proseLines >= 2 || signals.bulletLines >= 1 || signals.urlLines >= 1) + ) { + return true + } + + if (!NON_CODE_FENCE_LANGUAGES.has(language)) { + return false + } + + return ( + (signals.bulletLines >= 2 && signals.hasMarkdown && signals.codeSignals <= 2) || + (signals.proseLines >= 3 && signals.codeSignals === 0) + ) +} + +export function isLikelyProseCodeBlock(language: string | undefined, code: string | undefined): boolean { + const cleanLanguage = sanitizeLanguageTag(language || '') + const signals = codeSignals(code || '') + + if (!signals.trimmed || signals.codeSignals >= 3) { + return false + } + + if (signals.bulletLines >= 1 && (signals.hasMarkdown || signals.proseLines >= 2)) { + return true + } + + if (NON_CODE_FENCE_LANGUAGES.has(cleanLanguage)) { + return signals.proseLines >= 3 && signals.codeSignals === 0 + } + + return !COMMON_CODE_LANGUAGES.has(cleanLanguage) && signals.proseLines >= 2 && signals.codeSignals <= 1 +} diff --git a/ui-desktop/src/lib/markdown-preprocess.ts b/ui-desktop/src/lib/markdown-preprocess.ts new file mode 100644 index 00000000..c4a8731f --- /dev/null +++ b/ui-desktop/src/lib/markdown-preprocess.ts @@ -0,0 +1,520 @@ +import { normalizeMathDelimiters } from '@assistant-ui/react-streamdown' + +import { isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code' +import { stripPreviewTargets } from '@/lib/preview-targets' +import { linkifySessionRefs } from '@/lib/session-refs' + +const REASONING_BLOCK_RE = /<(think|thinking|reasoning|scratchpad|analysis)>[\s\S]*?<\/\1>\s*/gi +const PREVIEW_MARKER_RE = /\[Preview:[^\]]+\]\(#preview[:/][^)]+\)/gi + +const FENCE_LINE_RE = /^([ \t]*)(`{3,}|~{3,})([^\n]*)$/ +const EMPTY_FENCE_BLOCK_RE = /(^|\n)[ \t]*(?:`{3,}|~{3,})[^\n]*\n[ \t]*(?:`{3,}|~{3,})[ \t]*(?=\n|$)/g +const CODE_FENCE_SPLIT_RE = /((?:```|~~~)[\s\S]*?(?:```|~~~))/g +const INLINE_CODE_SPLIT_RE = /(`[^`\n]+`)/g +const LATEX_DISPLAY_OPEN_LINE_RE = /^([ \t]*(?:>[ \t]*)*(?:(?:[-+*]|\d+[.)])[ \t]+)?[ \t]*)\\{1,2}\[[ \t]*\r?$/ +const LATEX_DISPLAY_CLOSE_LINE_RE = /^([ \t]*(?:>[ \t]*)*(?:(?:[-+*]|\d+[.)])[ \t]+)?[ \t]*)\\{1,2}\][ \t]*\r?$/ +const CUSTOM_DISPLAY_MATH_LINE_RE = /^([ \t]*(?:>[ \t]*)*(?:(?:[-+*]|\d+[.)])[ \t]+)?[ \t]*)\[\/math\][ \t]*\r?$/ +// Bare-URL autolink matcher. The character classes EXCLUDE `*` so a URL that +// abuts markdown emphasis with no separating space (e.g. `**label: https://x**`, +// a very common LLM pattern) doesn't swallow the trailing `**` into the href. +// `*` is never meaningful in a real URL path, and GFM's own autolink extension +// likewise strips trailing emphasis/punctuation — so dropping it here is safe +// and keeps the emphasis run intact. Other trailing punctuation is still peeled +// off by the final `[^\s<>"'`*.,;:!?]` class. +const RAW_URL_RE = /https?:\/\/[^\s<>"'`*]+[^\s<>"'`*.,;:!?]/g +const LOCAL_PREVIEW_URL_RE = /(^|\s)https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?\/?[^\s<>"'`]*/gi +const LOCAL_PREVIEW_ONLY_RE = /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?\/?$/i +const URL_ONLY_LINE_RE = /^\s*https?:\/\/\S+\s*$/i +const CITATION_MARKER_RE = /(?<=[\p{L}\p{N})\].,!?:;"'”’])\[(?:\d+(?:\s*,\s*\d+)*)\](?!\()/gu + +/** + * Returns true when `body` contains a line that's exactly `marker` (modulo + * leading/trailing horizontal whitespace) — i.e. an unambiguous close fence + * for an opening fence with the same marker. + * + * Implemented with string comparisons (not RegExp) so that input-derived + * `marker` values can never bleed into a regex pattern. This matters for + * CodeQL's `js/incomplete-hostname-regexp` dataflow, which would otherwise + * trace test-fixture URLs from the input through `marker` into the regex + * source, even though `marker` is captured by `(`{3,}|~{3,})` and can only + * ever be backticks or tildes. + */ +function hasCloseFenceLine(body: string, marker: string): boolean { + const lines = body.split('\n') + + // Original regex required `\n` immediately before the close fence, so the + // first line of `body` (which has no preceding newline within `body`) + // cannot itself be the close fence. + for (let i = 1; i < lines.length; i += 1) { + const line = lines[i] + let lo = 0 + let hi = line.length + + while (lo < hi && (line[lo] === ' ' || line[lo] === '\t')) { + lo += 1 + } + + while (hi > lo && (line[hi - 1] === ' ' || line[hi - 1] === '\t')) { + hi -= 1 + } + + if (line.slice(lo, hi) === marker) { + return true + } + } + + return false +} + +function scrubBacktickNoise(text: string): string { + const balancedFenceRe = /(^|\n)([ \t]*)(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)\n[ \t]*\3[ \t]*(?=\n|$)/g + const protectedRanges: { end: number; start: number }[] = [] + let match: RegExpExecArray | null + + while ((match = balancedFenceRe.exec(text)) !== null) { + const start = match.index + match[1].length + + protectedRanges.push({ end: balancedFenceRe.lastIndex, start }) + } + + const danglingCodeFenceRe = /(^|\n)[ \t]*(`{3,}|~{3,})([a-z0-9][a-z0-9+#-]{0,15})[ \t]*\n([\s\S]*)$/gi + + while ((match = danglingCodeFenceRe.exec(text)) !== null) { + const start = match.index + match[1].length + const marker = match[2] || '```' + const info = match[3] || '' + const body = match[4] || '' + + if (!hasCloseFenceLine(body, marker) && sanitizeLanguageTag(info) && !isLikelyProseFence(info, body)) { + protectedRanges.push({ end: text.length, start }) + + break + } + } + + protectedRanges.sort((a, b) => a.start - b.start) + + const fenceNoiseRe = /`{3,}/g + let out = '' + let cursor = 0 + + for (const range of protectedRanges) { + out += text.slice(cursor, range.start).replace(fenceNoiseRe, '') + out += text.slice(range.start, range.end) + cursor = range.end + } + + out += text.slice(cursor).replace(fenceNoiseRe, '') + + for (let pass = 0; pass < 2; pass += 1) { + // Match EXACTLY 2 backticks (not part of a longer run) on each side. + // Without the lookbehind/lookahead, two adjacent triple-backtick + // fences with only whitespace between them get spliced together — + // e.g. ```bash\n...\n```\n\n```latex matches the regex's + // last-2-of-bash-close + \n\n + first-2-of-latex-open and the + // surrounding fence markers collapse into a single longer block, + // which the markdown parser then treats as ONE giant code block. + out = out.replace(/(?<!`)``(?!`)\s*(?<!`)``(?!`)/g, '') + out = out.replace(/(^|[^`])``(?=\s|[.,;:!?)\]'"\u2014\u2013-]|$)/g, '$1') + } + + return out +} + +function stripEmptyFenceBlocks(text: string): string { + return text.replace(EMPTY_FENCE_BLOCK_RE, '$1') +} + +function isUrlOnlyBlock(lines: string[]): boolean { + const nonEmpty = lines.filter(line => line.trim()) + + return nonEmpty.length > 0 && nonEmpty.every(line => URL_ONLY_LINE_RE.test(line)) +} + +function autoLinkRawUrls(text: string): string { + return text.replace(RAW_URL_RE, (url: string, index: number) => { + const previous = text[index - 1] || '' + const beforePrevious = text[index - 2] || '' + + if (previous === '<' || (beforePrevious === ']' && previous === '(')) { + return url + } + + return `<${url}>` + }) +} + +function normalizeVisibleProse(text: string): string { + return text + .split(INLINE_CODE_SPLIT_RE) + .map(part => + part.startsWith('`') + ? part + : linkifySessionRefs( + autoLinkRawUrls( + part.replace(/`{3,}/g, '').replace(LOCAL_PREVIEW_URL_RE, '$1').replace(CITATION_MARKER_RE, '') + ) + ) + ) + .join('') +} + +function isEscapedAt(text: string, index: number): boolean { + let slashCount = 0 + + for (let cursor = index - 1; cursor >= 0 && text[cursor] === '\\'; cursor -= 1) { + slashCount += 1 + } + + return slashCount % 2 === 1 +} + +function findClosingSingleDollar(text: string, openingIndex: number): number { + for (let cursor = openingIndex + 1; cursor < text.length && text[cursor] !== '\n'; cursor += 1) { + if (text[cursor] !== '$' || isEscapedAt(text, cursor)) { + continue + } + + // A `$$` run belongs to display math, not to this inline candidate. + if (text[cursor - 1] === '$' || text[cursor + 1] === '$') { + continue + } + + return cursor + } + + return -1 +} + +function isLikelyNumericInlineMath(body: string, followingCharacter: string): boolean { + const value = body.trim() + + if (!/^\d/u.test(value)) { + return false + } + + // Currency ranges and prose fragments can sit between two price openers, + // e.g. `$5-$10` or `$5, then $10`. They are not balanced math spans. + if (/[+\-*/=<>^_,;:(]$/u.test(value)) { + return false + } + + if (/https?:\/\//iu.test(value)) { + return false + } + + // A dollar immediately followed by a letter/number is more likely the next + // opener in prose such as `$5 and $10` or `$5 and $x$`. Preserve it only + // when the candidate body itself carries an unambiguous math signal. + if (/^\p{N}/u.test(followingCharacter)) { + return false + } + + if (/^[\p{L}\\]/u.test(followingCharacter)) { + return /\\[A-Za-z]+|[+*/=<>^_{}]/u.test(value) + } + + return true +} + +function opensCompleteInlineMath(text: string, openingIndex: number): boolean { + const closingIndex = findClosingSingleDollar(text, openingIndex) + + if (closingIndex === -1) { + return false + } + + const body = text.slice(openingIndex + 1, closingIndex) + + return /^[\p{L}\p{N}\\{([|+\-=_^]/u.test(body) +} + +/** + * Escape price openers without corrupting balanced numeric inline math. + * + * The upstream helper deliberately treats every `$` followed by a digit as + * currency. That turns `$4\in A$` into `\$4\in A$`; remark-math then pairs + * the orphan closing dollar with a later formula and renders the intervening + * prose as math. We retain the price behavior for `$5 and $10` and `$5-$10`, + * but preserve balanced, same-line numeric math spans. + */ +function escapeCurrencyDollarsPreservingMath(text: string): string { + let out = '' + let copiedThrough = 0 + + for (let cursor = 0; cursor < text.length; cursor += 1) { + if ( + text[cursor] !== '$' || + !/\d/u.test(text[cursor + 1] || '') || + text[cursor - 1] === '$' || + isEscapedAt(text, cursor) + ) { + continue + } + + const closingIndex = findClosingSingleDollar(text, cursor) + + if ( + closingIndex !== -1 && + !opensCompleteInlineMath(text, closingIndex) && + isLikelyNumericInlineMath(text.slice(cursor + 1, closingIndex), text[closingIndex + 1] || '') + ) { + cursor = closingIndex + + continue + } + + out += `${text.slice(copiedThrough, cursor)}\\$` + copiedThrough = cursor + 1 + } + + return out + text.slice(copiedThrough) +} + +function normalizeDisplayMathForMarkdown(text: string): string { + const lines = text.split('\n') + + for (let index = 0; index < lines.length; index += 1) { + const latexMatch = lines[index].match(LATEX_DISPLAY_OPEN_LINE_RE) + const customMatch = lines[index].match(CUSTOM_DISPLAY_MATH_LINE_RE) + const openingMatch = latexMatch || customMatch + + if (!openingMatch) { + continue + } + + const prefix = openingMatch[1] || '' + const closingPattern = latexMatch ? LATEX_DISPLAY_CLOSE_LINE_RE : CUSTOM_DISPLAY_MATH_LINE_RE + + for (let closingIndex = index + 1; closingIndex < lines.length; closingIndex += 1) { + const closingMatch = lines[closingIndex].match(closingPattern) + + if (!closingMatch) { + continue + } + + const openingCarriageReturn = lines[index].endsWith('\r') ? '\r' : '' + const closingCarriageReturn = lines[closingIndex].endsWith('\r') ? '\r' : '' + const closingPrefix = closingMatch[1] || '' + + lines[index] = `${prefix}$$${openingCarriageReturn}` + lines[closingIndex] = `${closingPrefix}$$${closingCarriageReturn}` + index = closingIndex + + break + } + } + + return lines.join('\n') +} + +function normalizeProseMath(text: string): string { + // remark-math requires multiline display delimiters on their own lines. + // Normalize those locally before the dependency handles inline forms; + // its compact `$$body$$` rewrite makes the first equation line metadata + // and leaks the trailing `$$` into KaTeX's error fallback. + const normalized = normalizeMathDelimiters(normalizeDisplayMathForMarkdown(text)) + + return escapeCurrencyDollarsPreservingMath(normalized) +} + +function extend(out: string[], lines: string[]) { + for (const line of lines) { + out.push(line) + } +} + +function pushProseFence(out: string[], indent: string, info: string, lines: string[]) { + if (info) { + out.push(`${indent}${info}`.trimEnd()) + } + + extend(out, lines) +} + +function findClosingFence(lines: string[], start: number, marker: string): number { + for (let cursor = start + 1; cursor < lines.length; cursor += 1) { + const closeMatch = (lines[cursor] || '').match(FENCE_LINE_RE) + + if (!closeMatch) { + continue + } + + const closeMarker = closeMatch[2] || '' + const closeInfo = (closeMatch[3] || '').trim() + + if (!closeInfo && closeMarker[0] === marker[0] && closeMarker.length >= marker.length) { + return cursor + } + } + + return -1 +} + +// Languages that should be routed to the math (KaTeX) renderer instead of +// being shown as a syntax-highlighted code block. +// +// We deliberately recognize ONLY `math` here, not `latex` or `tex`. +// Reasoning: GitHub-style markdown uses ` ```math ` to mean "render as +// math" and ` ```latex `/` ```tex ` to mean "show LaTeX/TeX source code" +// (syntax highlighted). Conflating the two breaks code blocks where a +// user is *discussing* LaTeX rather than embedding it (e.g., +// ```latex\n\begin{equation}\n E = mc^2\n\end{equation}``` shown as a +// teaching example). Anyone who wants math rendered should use ```math. +const MATH_FENCE_LANGUAGES = new Set(['math']) + +function isMathFence(language: string): boolean { + return MATH_FENCE_LANGUAGES.has(language.toLowerCase()) +} + +function normalizeFenceBlocks(text: string): string { + const sourceLines = text.split('\n') + const out: string[] = [] + let index = 0 + + while (index < sourceLines.length) { + const line = sourceLines[index] || '' + const match = line.match(FENCE_LINE_RE) + + if (!match) { + out.push(line) + index += 1 + + continue + } + + const indent = match[1] || '' + const marker = match[2] || '```' + const infoRaw = (match[3] || '').trim() + const languageToken = infoRaw.split(/\s+/, 1)[0] || '' + const language = sanitizeLanguageTag(languageToken) + const openerValid = !infoRaw || Boolean(language) + + if (!openerValid) { + out.push(`${indent}${infoRaw}`.trimEnd()) + index += 1 + + continue + } + + const closeIndex = findClosingFence(sourceLines, index, marker) + const bodyLines = sourceLines.slice(index + 1, closeIndex === -1 ? sourceLines.length : closeIndex) + const body = bodyLines.join('\n') + + if (closeIndex !== -1 && !body.trim()) { + index = closeIndex + 1 + + continue + } + + if (closeIndex !== -1 && LOCAL_PREVIEW_ONLY_RE.test(body.trim())) { + index = closeIndex + 1 + + continue + } + + if (closeIndex !== -1 && isUrlOnlyBlock(bodyLines)) { + extend(out, bodyLines) + index = closeIndex + 1 + + continue + } + + if (closeIndex === -1) { + if (!body.trim()) { + index += 1 + + continue + } + + if (isLikelyProseFence(infoRaw, body)) { + pushProseFence(out, indent, infoRaw, bodyLines) + } else if (isMathFence(language)) { + // Streaming math fence — rewrite the language tag to "math". + // remark-math + rehype-katex pick up ```math fenced blocks via + // the language-math class on the resulting <code> element. We + // keep the fence intact (instead of converting to $$..$$) so + // any literal `$$` characters in the body don't collide with + // an outer math wrapper. No close emitted yet — streaming. + out.push(`${indent}${marker}math`) + extend(out, bodyLines) + } else { + out.push(`${indent}${marker}${language}`) + extend(out, bodyLines) + } + + break + } + + if (isLikelyProseFence(infoRaw, body)) { + pushProseFence(out, indent, infoRaw, bodyLines) + index = closeIndex + 1 + + continue + } + + if (isMathFence(language)) { + // Closed math fence — rewrite the language tag to "math" so + // rehype-katex's language-math class detection picks it up. + // Body stays untouched (no $$..$$ rewrite) so authors can write + // arbitrary LaTeX including `$$display$$` markers without them + // colliding with our wrapper. Without this rewrite the block + // would render as a syntax-highlighted "latex" code listing. + out.push(`${indent}${marker}math`) + extend(out, bodyLines) + out.push(`${indent}${marker}`) + index = closeIndex + 1 + + continue + } + + out.push(`${indent}${marker}${language}`) + extend(out, bodyLines) + out.push(`${indent}${marker}`) + index = closeIndex + 1 + } + + return out.join('\n') +} + +export function preprocessMarkdown(text: string): string { + const cleaned = text.replace(REASONING_BLOCK_RE, '').replace(PREVIEW_MARKER_RE, '') + const scrubbed = scrubBacktickNoise(cleaned) + const normalizedFences = normalizeFenceBlocks(scrubbed) + const strippedEmptyFences = stripEmptyFenceBlocks(normalizedFences) + + return strippedEmptyFences + .split(CODE_FENCE_SPLIT_RE) + .map(part => { + // Fence blocks pass through untouched. + if (/^(?:```|~~~)/.test(part)) { + return part + } + + // Whitespace-only segments (e.g. the `\n\n` between two adjacent + // fences) must NOT go through stripPreviewTargets — its internal + // .trim() would collapse them to '' and glue the surrounding + // fences together, producing things like ``````math which the + // markdown parser then reads as a single 6-backtick block. + if (!part.trim()) { + return part + } + + // Preserve leading/trailing whitespace around the prose body so + // that fence-prose-fence sequences keep their blank-line gaps. + // stripPreviewTargets internally calls .trim() on its result for + // the benefit of its other (single-segment) callers; here we're + // operating on a SEGMENT of a larger document where outer + // whitespace is structural and must survive. + const leading = part.match(/^\s*/)?.[0] ?? '' + const trailing = part.match(/\s*$/)?.[0] ?? '' + + // Run only on prose segments so `$5` literals and `\(` inside code + // blocks stay intact. + const transformed = normalizeVisibleProse(stripPreviewTargets(normalizeProseMath(part))) + + return leading + transformed + trailing + }) + .join('') + .replace(/[ \t]+\n/g, '\n') +} diff --git a/ui-desktop/src/lib/mcp-dashboard-oauth.test.ts b/ui-desktop/src/lib/mcp-dashboard-oauth.test.ts new file mode 100644 index 00000000..0c2e3151 --- /dev/null +++ b/ui-desktop/src/lib/mcp-dashboard-oauth.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' + +import { completeMcpDesktopOAuth } from './mcp-dashboard-oauth' + +describe('completeMcpDesktopOAuth', () => { + it('opens the returned authorization URL and polls through approval', async () => { + const openExternal = vi.fn().mockResolvedValue(undefined) + + const status = vi + .fn() + .mockResolvedValueOnce({ + flow_id: 'flow-1', + server_name: 'reports', + status: 'authorization_required', + authorization_url: 'https://idp.example/authorize', + error: null + }) + .mockResolvedValueOnce({ + flow_id: 'flow-1', + server_name: 'reports', + status: 'approved', + authorization_url: 'https://idp.example/authorize', + error: null, + tools: [{ name: 'list_reports', description: 'List reports' }] + }) + + const result = await completeMcpDesktopOAuth({ + serverName: 'reports', + start: vi.fn().mockResolvedValue({ + flow_id: 'flow-1', + server_name: 'reports', + status: 'authorization_required', + authorization_url: 'https://idp.example/authorize', + error: null + }), + status, + openExternal, + sleep: async () => {} + }) + + expect(openExternal).toHaveBeenCalledWith('https://idp.example/authorize') + expect(result.status).toBe('approved') + }) + + it('retries a transient status failure', async () => { + const status = vi.fn().mockRejectedValueOnce(new Error('temporary network failure')).mockResolvedValueOnce({ + flow_id: 'flow-2', + server_name: 'reports', + status: 'approved', + authorization_url: 'https://idp.example/authorize', + error: null, + tools: [] + }) + + const result = await completeMcpDesktopOAuth({ + serverName: 'reports', + start: vi.fn().mockResolvedValue({ + flow_id: 'flow-2', + server_name: 'reports', + status: 'authorization_required', + authorization_url: 'https://idp.example/authorize', + error: null + }), + status, + openExternal: vi.fn().mockResolvedValue(undefined), + sleep: async () => {} + }) + + expect(result.status).toBe('approved') + expect(status).toHaveBeenCalledTimes(2) + }) +}) diff --git a/ui-desktop/src/lib/mcp-dashboard-oauth.ts b/ui-desktop/src/lib/mcp-dashboard-oauth.ts new file mode 100644 index 00000000..b26f1df1 --- /dev/null +++ b/ui-desktop/src/lib/mcp-dashboard-oauth.ts @@ -0,0 +1,71 @@ +export interface McpOAuthFlow { + flow_id: string + server_name: string + status: 'starting' | 'authorization_required' | 'approved' | 'error' + authorization_url: string | null + error: string | null + tools?: Array<{ name: string; description: string }> +} + +interface CompleteOptions { + serverName: string + start: (name: string) => Promise<McpOAuthFlow> + status: (flowId: string) => Promise<McpOAuthFlow> + openExternal: (url: string) => Promise<void> + sleep?: (milliseconds: number) => Promise<void> + maxPollFailures?: number +} + +const defaultSleep = (milliseconds: number) => new Promise<void>(resolve => window.setTimeout(resolve, milliseconds)) + +export async function completeMcpDesktopOAuth({ + serverName, + start, + status, + openExternal, + sleep = defaultSleep, + maxPollFailures = 3 +}: CompleteOptions): Promise<McpOAuthFlow> { + const started = await start(serverName) + + if (started.status === 'error') { + throw new Error(started.error || 'OAuth failed to start') + } + + if (!started.authorization_url) { + throw new Error('OAuth server did not provide an authorization URL') + } + + await openExternal(started.authorization_url) + + let pollFailures = 0 + + for (;;) { + let current: McpOAuthFlow + + try { + current = await status(started.flow_id) + pollFailures = 0 + } catch (error) { + pollFailures += 1 + + if (pollFailures >= maxPollFailures) { + throw error + } + + await sleep(1000) + + continue + } + + if (current.status === 'approved') { + return current + } + + if (current.status === 'error') { + throw new Error(current.error || 'OAuth authorization failed') + } + + await sleep(1000) + } +} diff --git a/ui-desktop/src/lib/mcp-tool-filter.test.ts b/ui-desktop/src/lib/mcp-tool-filter.test.ts new file mode 100644 index 00000000..ade59df5 --- /dev/null +++ b/ui-desktop/src/lib/mcp-tool-filter.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' + +import { countEnabledTools, isToolEnabled, readToolsFilter, toggleToolInServer } from './mcp-tool-filter' + +describe('readToolsFilter', () => { + it('returns empty when no tools object', () => { + expect(readToolsFilter({ command: 'x' })).toEqual({ exclude: undefined, include: undefined }) + }) + + it('reads include/exclude and ignores non-string entries', () => { + expect(readToolsFilter({ tools: { exclude: ['c', 2], include: ['a', 'b', null] } })).toEqual({ + exclude: ['c'], + include: ['a', 'b'] + }) + }) +}) + +describe('isToolEnabled', () => { + it('enables everything with no filter', () => { + expect(isToolEnabled({ command: 'x' }, 'anything')).toBe(true) + }) + + it('include wins over exclude', () => { + const server = { tools: { exclude: ['a'], include: ['a'] } } + expect(isToolEnabled(server, 'a')).toBe(true) + expect(isToolEnabled(server, 'b')).toBe(false) + }) + + it('exclude disables listed tools', () => { + const server = { tools: { exclude: ['b'] } } + expect(isToolEnabled(server, 'a')).toBe(true) + expect(isToolEnabled(server, 'b')).toBe(false) + }) +}) + +describe('toggleToolInServer', () => { + it('adds a fresh tool to a new exclude denylist when disabled', () => { + const next = toggleToolInServer({ command: 'x' }, 'a') + expect(next.tools).toEqual({ exclude: ['a'] }) + }) + + it('re-enabling removes the tool and drops the empty exclude/tools', () => { + const next = toggleToolInServer({ command: 'x', tools: { exclude: ['a'] } }, 'a') + expect(next.tools).toBeUndefined() + }) + + it('respects include mode: toggling removes from include', () => { + const next = toggleToolInServer({ tools: { include: ['a', 'b'] } }, 'a') + expect(next.tools).toEqual({ include: ['b'] }) + }) + + it('respects include mode: re-enabling adds back to include', () => { + const next = toggleToolInServer({ tools: { include: ['b'] } }, 'a') + expect(next.tools).toEqual({ include: ['b', 'a'] }) + }) + + it('preserves sibling tools keys like resources/prompts', () => { + const next = toggleToolInServer({ tools: { resources: false } }, 'a') + expect(next.tools).toEqual({ exclude: ['a'], resources: false }) + }) + + it('does not mutate the input server', () => { + const server = { tools: { exclude: ['a'] } } + toggleToolInServer(server, 'b') + expect(server.tools.exclude).toEqual(['a']) + }) +}) + +describe('countEnabledTools', () => { + it('counts enabled tools out of a discovered list', () => { + const server = { tools: { exclude: ['b'] } } + expect(countEnabledTools(server, ['a', 'b', 'c'])).toBe(2) + }) +}) diff --git a/ui-desktop/src/lib/mcp-tool-filter.ts b/ui-desktop/src/lib/mcp-tool-filter.ts new file mode 100644 index 00000000..8653c32e --- /dev/null +++ b/ui-desktop/src/lib/mcp-tool-filter.ts @@ -0,0 +1,61 @@ +// Per-tool MCP gating. A server's optional `tools.include` (whitelist) / +// `tools.exclude` (denylist) decide which discovered tools the agent registers +// — `include` wins, no filter means all. Mirrors `_register_server_tools` in +// `tools/mcp_tool.py`. + +export interface McpToolsFilter { + exclude?: string[] + include?: string[] +} + +type ServerConfig = Record<string, unknown> + +const asNames = (value: unknown): string[] | undefined => + Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : undefined + +const toolsObject = (server: ServerConfig | null | undefined): Record<string, unknown> => { + const tools = server?.tools + + return tools && typeof tools === 'object' && !Array.isArray(tools) ? (tools as Record<string, unknown>) : {} +} + +export function readToolsFilter(server: ServerConfig | null | undefined): McpToolsFilter { + const tools = toolsObject(server) + + return { exclude: asNames(tools.exclude), include: asNames(tools.include) } +} + +export function isToolEnabled(server: ServerConfig | null | undefined, name: string): boolean { + const { exclude, include } = readToolsFilter(server) + + return include?.length ? include.includes(name) : !exclude?.includes(name) +} + +// Toggle one tool, preserving the config's mode (include if present, else an +// exclude denylist). Empty lists — and an emptied `tools` — are dropped. +export function toggleToolInServer(server: ServerConfig, name: string): ServerConfig { + const { exclude, include } = readToolsFilter(server) + const key = include?.length ? 'include' : 'exclude' + const current = (key === 'include' ? include : exclude) ?? [] + const names = current.includes(name) ? current.filter(n => n !== name) : [...current, name] + const tools = { ...toolsObject(server) } + + if (names.length) { + tools[key] = names + } else { + delete tools[key] + } + + const next = { ...server } + + if (Object.keys(tools).length) { + next.tools = tools + } else { + delete next.tools + } + + return next +} + +export const countEnabledTools = (server: ServerConfig | null | undefined, names: string[]): number => + names.filter(name => isToolEnabled(server, name)).length diff --git a/ui-desktop/src/lib/media.remote.test.ts b/ui-desktop/src/lib/media.remote.test.ts new file mode 100644 index 00000000..8140728d --- /dev/null +++ b/ui-desktop/src/lib/media.remote.test.ts @@ -0,0 +1,253 @@ +// @vitest-environment jsdom +// downloadGatewayMediaFile drives an <a download> click, so these need a DOM. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $connection } from '@/store/session' + +import { + downloadGatewayMediaFile, + filePathFromMediaPath, + gatewayMediaDataUrl, + isInlineMediaSrc, + isRemoteGateway, + mediaExternalUrl, + resolveMediaDisplaySrc, + resolveMediaPlaybackSrc +} from './media' + +describe('isRemoteGateway', () => { + afterEach(() => { + $connection.set(null) + }) + + it('is false with no connection', () => { + $connection.set(null) + expect(isRemoteGateway()).toBe(false) + }) + + it('is false in local mode', () => { + $connection.set({ mode: 'local' } as never) + expect(isRemoteGateway()).toBe(false) + }) + + it('is true in remote mode', () => { + $connection.set({ mode: 'remote' } as never) + expect(isRemoteGateway()).toBe(true) + }) +}) + +describe('filePathFromMediaPath', () => { + it('passes through a plain path', () => { + expect(filePathFromMediaPath('/home/u/.clawcodex/images/a.png')).toBe('/home/u/.clawcodex/images/a.png') + }) + + it('decodes a file:// URL with encoded characters', () => { + expect(filePathFromMediaPath('file:///tmp/a%20b.png')).toBe('/tmp/a b.png') + }) +}) + +describe('mediaExternalUrl', () => { + afterEach(() => { + $connection.set(null) + }) + + it('passes through http(s) URLs untouched', () => { + $connection.set({ mode: 'remote', baseUrl: 'https://gw', token: 't' } as never) + expect(mediaExternalUrl('https://example.com/a.png')).toBe('https://example.com/a.png') + }) + + it('keeps file:// form in local mode', () => { + $connection.set({ mode: 'local' } as never) + expect(mediaExternalUrl('/tmp/a.png')).toBe('file:///tmp/a.png') + expect(mediaExternalUrl('file:///tmp/a.png')).toBe('file:///tmp/a.png') + }) + + it('rewrites gateway-local paths to an authenticated download URL', () => { + $connection.set({ mode: 'remote', baseUrl: 'https://gw', token: 's e/cret' } as never) + expect(mediaExternalUrl('file:///tmp/a b.png')).toBe( + 'https://gw/api/files/download?path=%2Ftmp%2Fa%20b.png&token=s%20e%2Fcret' + ) + expect(mediaExternalUrl('/tmp/a b.png')).toBe( + 'https://gw/api/files/download?path=%2Ftmp%2Fa%20b.png&token=s%20e%2Fcret' + ) + }) + + it('falls back to file:// when remote connection lacks a token', () => { + $connection.set({ mode: 'remote', baseUrl: 'https://gw' } as never) + expect(mediaExternalUrl('/tmp/a.png')).toBe('file:///tmp/a.png') + }) +}) + +describe('resolveMediaDisplaySrc', () => { + const api = vi.fn(async ({ path }: { path: string }) => { + if (path.startsWith('/api/fs/read-data-url?')) { + return { dataUrl: 'data:image/png;base64,ZHVtbXk=' } + } + + throw new Error(`unexpected path ${path}`) + }) + + beforeEach(() => { + api.mockClear() + }) + + afterEach(() => { + vi.unstubAllGlobals() + $connection.set(null) + }) + + it('recognizes inline image URLs', () => { + expect(isInlineMediaSrc('https://example.com/a.png')).toBe(true) + expect(isInlineMediaSrc('data:image/png;base64,ZHVtbXk=')).toBe(true) + expect(isInlineMediaSrc('/Users/me/a.png')).toBe(false) + }) + + it('leaves web, data, and relative markdown image sources unchanged', async () => { + vi.stubGlobal('window', { clawcodexDesktop: { api } }) + $connection.set({ mode: 'remote', profile: 'remote-work' } as never) + + await expect(resolveMediaDisplaySrc('https://example.com/a.png')).resolves.toBe('https://example.com/a.png') + await expect(resolveMediaDisplaySrc('data:image/png;base64,ZHVtbXk=')).resolves.toBe( + 'data:image/png;base64,ZHVtbXk=' + ) + await expect(resolveMediaDisplaySrc('images/a.png')).resolves.toBe('images/a.png') + await expect(resolveMediaDisplaySrc('./images/a.png')).resolves.toBe('./images/a.png') + await expect(resolveMediaDisplaySrc('../images/a.png')).resolves.toBe('../images/a.png') + expect(api).not.toHaveBeenCalled() + }) + + it('reads remote gateway-local file paths through the desktop fs bridge', async () => { + vi.stubGlobal('window', { clawcodexDesktop: { api } }) + $connection.set({ mode: 'remote', profile: 'remote-work' } as never) + + await expect(resolveMediaDisplaySrc('/Users/me/project/a b.png')).resolves.toBe('data:image/png;base64,ZHVtbXk=') + expect(api).toHaveBeenCalledWith({ + path: '/api/fs/read-data-url?path=%2FUsers%2Fme%2Fproject%2Fa%20b.png', + profile: 'remote-work' + }) + }) + + it('reads local desktop file paths from the local desktop shell', async () => { + const readFileDataUrl = vi.fn(async () => 'data:image/png;base64,bG9jYWw=') + + vi.stubGlobal('window', { clawcodexDesktop: { readFileDataUrl } }) + $connection.set({ mode: 'local' } as never) + + await expect(resolveMediaDisplaySrc('file:///Users/me/project/a%20b.png')).resolves.toBe( + 'data:image/png;base64,bG9jYWw=' + ) + expect(readFileDataUrl).toHaveBeenCalledWith('/Users/me/project/a b.png') + }) +}) + +describe('resolveMediaPlaybackSrc', () => { + afterEach(() => { + vi.unstubAllGlobals() + $connection.set(null) + }) + + it('keeps a remote HTTPS video URL unchanged', async () => { + vi.stubGlobal('window', { clawcodexDesktop: { api: vi.fn() } }) + $connection.set({ mode: 'remote', baseUrl: 'https://gateway.test', token: 'secret' } as never) + + await expect(resolveMediaPlaybackSrc('https://cdn.example.com/render.mp4')).resolves.toBe( + 'https://cdn.example.com/render.mp4' + ) + }) + + it('routes gateway-local video through the authenticated download endpoint', async () => { + vi.stubGlobal('window', { clawcodexDesktop: { api: vi.fn() } }) + $connection.set({ mode: 'remote', baseUrl: 'https://gateway.test', token: 's e/cret' } as never) + + await expect(resolveMediaPlaybackSrc('/root/outputs/render.mp4')).resolves.toBe( + 'https://gateway.test/api/files/download?path=%2Froot%2Foutputs%2Frender.mp4&token=s%20e%2Fcret' + ) + }) + + it('uses the Electron streaming protocol for local desktop video', async () => { + vi.stubGlobal('window', { clawcodexDesktop: { api: vi.fn() } }) + $connection.set({ mode: 'local' } as never) + + await expect(resolveMediaPlaybackSrc('C:\\renders\\demo.mp4')).resolves.toBe( + 'clawcodex-media://stream/C%3A%5Crenders%5Cdemo.mp4' + ) + }) +}) + +describe('gatewayMediaDataUrl', () => { + const api = vi.fn(async ({ path }: { path: string }) => { + if (path.startsWith('/api/fs/read-data-url?')) { + return { dataUrl: 'data:image/png;base64,ZHVtbXk=' } + } + + throw new Error(`unexpected path ${path}`) + }) + + beforeEach(() => { + api.mockClear() + vi.stubGlobal('window', { clawcodexDesktop: { api } }) + $connection.set({ mode: 'remote' } as never) + }) + + afterEach(() => { + vi.unstubAllGlobals() + $connection.set(null) + }) + + it('reads gateway media through the desktop fs bridge instead of /api/media roots', async () => { + const url = await gatewayMediaDataUrl('/home/u/.clawcodex/skills/demo/images/a b.png') + + expect(url).toBe('data:image/png;base64,ZHVtbXk=') + expect(api).toHaveBeenCalledWith({ + path: '/api/fs/read-data-url?path=%2Fhome%2Fu%2F.clawcodex%2Fskills%2Fdemo%2Fimages%2Fa%20b.png' + }) + }) +}) + +describe('downloadGatewayMediaFile', () => { + const api = vi.fn(async ({ path }: { path: string }) => { + if (path.startsWith('/api/fs/read-data-url?')) { + return { dataUrl: 'data:text/markdown;base64,IyByZXBvcnQ=' } + } + + throw new Error(`unexpected path ${path}`) + }) + + let clickSpy: ReturnType<typeof vi.spyOn> + + beforeEach(() => { + api.mockClear() + vi.stubGlobal('window', { clawcodexDesktop: { api }, setTimeout: vi.fn() }) + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ blob: async () => new Blob(['# report'], { type: 'text/markdown' }) })) + ) + URL.createObjectURL = vi.fn(() => 'blob:remote-artifact') + URL.revokeObjectURL = vi.fn() + clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + $connection.set({ mode: 'remote' } as never) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + clickSpy.mockRestore() + $connection.set(null) + }) + + it('downloads gateway files through the desktop fs bridge', async () => { + await downloadGatewayMediaFile('file:///Users/me/project/report.md') + + expect(api).toHaveBeenCalledWith({ + path: '/api/fs/read-data-url?path=%2FUsers%2Fme%2Fproject%2Freport.md' + }) + expect(clickSpy).toHaveBeenCalledOnce() + }) + + it('rejects when the gateway refuses the file read', async () => { + api.mockRejectedValueOnce(new Error('403 File is not readable')) + + await expect(downloadGatewayMediaFile('/Users/me/project/report.md')).rejects.toThrow('403') + expect(clickSpy).not.toHaveBeenCalled() + }) +}) diff --git a/ui-desktop/src/lib/media.ts b/ui-desktop/src/lib/media.ts new file mode 100644 index 00000000..19eb2671 --- /dev/null +++ b/ui-desktop/src/lib/media.ts @@ -0,0 +1,194 @@ +import { readDesktopFileDataUrl } from '@/lib/desktop-fs' +import { capitalize } from '@/lib/text' +import { $connection } from '@/store/session' + +export type MediaKind = 'audio' | 'image' | 'video' | 'file' + +interface MediaInfo { + kind: MediaKind + mime: string +} + +const MEDIA_BY_EXT: Record<string, MediaInfo> = { + avi: { kind: 'video', mime: 'video/x-msvideo' }, + bmp: { kind: 'image', mime: 'image/bmp' }, + flac: { kind: 'audio', mime: 'audio/flac' }, + gif: { kind: 'image', mime: 'image/gif' }, + jpeg: { kind: 'image', mime: 'image/jpeg' }, + jpg: { kind: 'image', mime: 'image/jpeg' }, + m4a: { kind: 'audio', mime: 'audio/mp4' }, + mkv: { kind: 'video', mime: 'video/x-matroska' }, + mov: { kind: 'video', mime: 'video/quicktime' }, + mp3: { kind: 'audio', mime: 'audio/mpeg' }, + mp4: { kind: 'video', mime: 'video/mp4' }, + ogg: { kind: 'audio', mime: 'audio/ogg' }, + opus: { kind: 'audio', mime: 'audio/ogg; codecs=opus' }, + png: { kind: 'image', mime: 'image/png' }, + svg: { kind: 'image', mime: 'image/svg+xml' }, + wav: { kind: 'audio', mime: 'audio/wav' }, + webm: { kind: 'video', mime: 'video/webm' }, + webp: { kind: 'image', mime: 'image/webp' } +} + +function mediaInfo(path: string): MediaInfo | undefined { + const ext = path.split(/[?#]/, 1)[0]?.split('.').pop()?.toLowerCase() + + return ext ? MEDIA_BY_EXT[ext] : undefined +} + +export function mediaKind(path: string): MediaKind { + return mediaInfo(path)?.kind ?? 'file' +} + +export function mediaMime(path: string): string { + return mediaInfo(path)?.mime ?? 'application/octet-stream' +} + +export function mediaName(path: string): string { + try { + const url = new URL(path) + + return url.pathname.split('/').filter(Boolean).pop() || path + } catch { + return path.split(/[\\/]/).filter(Boolean).pop() || path + } +} + +export function mediaMarkdownHref(path: string): string { + return `#media:${encodeURIComponent(path)}` +} + +export function isInlineMediaSrc(path: string): boolean { + return /^(?:https?|data):/i.test(path) +} + +function isFileMediaPath(path: string): boolean { + return /^(?:file:|\/|~\/|[a-z]:[\\/]|\\\\)/i.test(path) +} + +export async function resolveMediaDisplaySrc(path: string): Promise<string> { + if (isInlineMediaSrc(path) || !isFileMediaPath(path)) { + return path + } + + if (window.clawcodexDesktop && isRemoteGateway()) { + return gatewayMediaDataUrl(path) + } + + if (!window.clawcodexDesktop?.readFileDataUrl) { + return mediaExternalUrl(path) + } + + return window.clawcodexDesktop.readFileDataUrl(filePathFromMediaPath(path)) +} + +// Audio/video need a seekable source instead of a whole-file data URL. Keep +// remote URLs untouched, route gateway-local files through the authenticated +// download endpoint, and reserve the Electron protocol for files on this +// desktop machine. +export async function resolveMediaPlaybackSrc(path: string): Promise<string> { + if (isInlineMediaSrc(path)) { + return path + } + + if (window.clawcodexDesktop && ['audio', 'video'].includes(mediaKind(path))) { + return isRemoteGateway() ? mediaExternalUrl(path) : mediaStreamUrl(path) + } + + return resolveMediaDisplaySrc(path) +} + +// Resolve a media path to a URL the shell can open. Remote mode rewrites +// gateway-local paths to an authenticated /api/files/download URL (the file +// lives on the gateway, not this disk); local mode keeps the file:// form. +export function mediaExternalUrl(path: string): string { + if (/^https?:/i.test(path)) { + return path + } + + if (isRemoteGateway()) { + const conn = $connection.get() + + if (conn?.baseUrl && conn.token) { + const file = encodeURIComponent(filePathFromMediaPath(path)) + + return `${conn.baseUrl}/api/files/download?path=${file}&token=${encodeURIComponent(conn.token)}` + } + } + + return /^file:/i.test(path) ? path : `file://${path}` +} + +// Custom Electron scheme (registered in electron/main.ts) that streams a local +// file with Range support. Used for audio/video so playback bypasses the data +// URL size cap and supports seeking. `path` may be a plain path or `file://…`. +export function mediaStreamUrl(path: string): string { + return `clawcodex-media://stream/${encodeURIComponent(filePathFromMediaPath(path))}` +} + +export function mediaPathFromMarkdownHref(href?: string): string | null { + if (!href?.startsWith('#media:')) { + return null + } + + try { + return decodeURIComponent(href.slice('#media:'.length)) + } catch { + return null + } +} + +export function filePathFromMediaPath(path: string): string { + if (!path.startsWith('file:')) { + return path + } + + try { + return decodeURIComponent(new URL(path).pathname) + } catch { + return path.replace(/^file:\/\//, '') + } +} + +// True when this desktop shell is wired to a remote gateway. Local media paths +// then live on the gateway machine, not this disk, so we fetch them over the API. +export function isRemoteGateway(): boolean { + return $connection.get()?.mode === 'remote' +} + +// Fetch gateway-local media as a data URL via the authenticated desktop FS +// bridge. Remote Desktop artifacts can live anywhere the gateway can read +// (workspace, skills, ~/.clawcodex/cache, etc.); /api/media is intentionally +// narrower and rejects non-images plus images outside its media roots. +export async function gatewayMediaDataUrl(path: string): Promise<string> { + return readDesktopFileDataUrl(filePathFromMediaPath(path)) +} + +// Remote-mode replacement for opening gateway-local file paths with file://. +// The file lives on the gateway, so fetch it over the authenticated fs bridge +// and hand the bytes to the local browser shell as a download. +export async function downloadGatewayMediaFile(path: string): Promise<void> { + const dataUrl = await readDesktopFileDataUrl(filePathFromMediaPath(path)) + + if (!dataUrl) { + throw new Error('Gateway returned no file data') + } + + const response = await fetch(dataUrl) + const blobUrl = URL.createObjectURL(await response.blob()) + const anchor = document.createElement('a') + anchor.href = blobUrl + anchor.download = mediaName(path) + anchor.rel = 'noopener noreferrer' + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000) +} + +export function mediaDisplayLabel(path: string): string { + const escaped = mediaName(path).replace(/[[\]\\]/g, '\\$&') + const kind = mediaKind(path) + + return `${capitalize(kind)}: ${escaped}` +} diff --git a/ui-desktop/src/lib/middle-click.test.tsx b/ui-desktop/src/lib/middle-click.test.tsx new file mode 100644 index 00000000..c7018259 --- /dev/null +++ b/ui-desktop/src/lib/middle-click.test.tsx @@ -0,0 +1,86 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { middleClickHandlers } from './middle-click' + +afterEach(cleanup) + +/** A middle click as a real three-button mouse delivers it. Chromium on + * Windows/Linux swallows the trailing `auxclick` when the press starts + * autoscroll, so the gesture may NOT depend on that event. */ +function middleClick(element: Element, upOn: Element = element) { + fireEvent.mouseDown(element, { button: 1 }) + fireEvent.pointerDown(element, { button: 1 }) + fireEvent.pointerUp(upOn, { button: 1 }) +} + +function Target({ action, id = 'target' }: { action?: () => void; id?: string }) { + return ( + <button {...middleClickHandlers(action)} id={id} type="button"> + {id} + </button> + ) +} + +describe('middleClickHandlers', () => { + it('fires without an auxclick — the event Chromium eats when autoscroll starts', () => { + const action = vi.fn() + render(<Target action={action} />) + + middleClick(screen.getByText('target')) + expect(action).toHaveBeenCalledTimes(1) + }) + + it('cancels mousedown so the autoscroll pan widget never appears', () => { + render(<Target action={vi.fn()} />) + + const down = fireEvent.mouseDown(screen.getByText('target'), { button: 1 }) + expect(down).toBe(false) // preventDefault() called + }) + + it('cancels the middle mousedown even with no action — the surface owns the button', () => { + render(<Target />) + + expect(fireEvent.mouseDown(screen.getByText('target'), { button: 1 })).toBe(false) + }) + + it('ignores left and right buttons', () => { + const action = vi.fn() + render(<Target action={action} />) + + const target = screen.getByText('target') + fireEvent.pointerDown(target, { button: 0 }) + fireEvent.pointerUp(target, { button: 0 }) + fireEvent.pointerDown(target, { button: 2 }) + fireEvent.pointerUp(target, { button: 2 }) + expect(action).not.toHaveBeenCalled() + }) + + it('does nothing when the release lands on a different element', () => { + const pressed = vi.fn() + const released = vi.fn() + render( + <> + <Target action={pressed} id="pressed" /> + <Target action={released} id="released" /> + </> + ) + + middleClick(screen.getByText('pressed'), screen.getByText('released')) + expect(pressed).not.toHaveBeenCalled() + expect(released).not.toHaveBeenCalled() + }) + + it('a press with no action cannot arm the NEXT element it releases over', () => { + const action = vi.fn() + render( + <> + <Target id="inert" /> + <Target action={action} id="live" /> + </> + ) + + middleClick(screen.getByText('inert'), screen.getByText('live')) + expect(action).not.toHaveBeenCalled() + }) +}) diff --git a/ui-desktop/src/lib/middle-click.ts b/ui-desktop/src/lib/middle-click.ts new file mode 100644 index 00000000..5d31cdac --- /dev/null +++ b/ui-desktop/src/lib/middle-click.ts @@ -0,0 +1,64 @@ +import type * as React from 'react' + +/** `MouseEvent.button` for the middle (wheel) button. */ +const MIDDLE_BUTTON = 1 + +/** ⌘-click (metaKey + primary button) — the Mac has no middle button, so this + * is the trackpad equivalent of middle-click-to-close. Guarded on metaKey so + * it never collides with left-click (activate/drag) or ⌃-click (macOS context + * menu). */ +export const isMetaClose = (event: { button: number; metaKey: boolean }) => event.button === 0 && event.metaKey + +/** Where the current middle press started. One pointer holds one button, so a + * single slot is the whole state, and it's only ever compared by identity in + * the pointerup right after — a value left behind by a press released + * elsewhere is inert, not stale. */ +let pressedOn: EventTarget | null = null + +/** + * Middle-click as a gesture that survives a real three-button mouse. + * + * `auxclick` is the obvious event and the wrong one to build on. Windows and + * Linux Chromium answer a middle press inside a scroller by starting the + * AUTOSCROLL pan, and the mouseup that ends the pan is spent stopping it + * instead of completing a click — so `auxclick` never arrives. Every surface + * carrying this gesture (tab strips, the session list, the terminal rail) is a + * scroller, which is why it only ever worked on macOS, where autoscroll + * doesn't exist. + * + * Pointer events fire either way, so the gesture arms on pointerdown and is + * spent on the pointerup over the SAME element — press one tab, release on + * another and nothing happens (Chrome / VS Code semantics). mousedown's default + * dies on every middle press, action or not, so the pan widget can't appear on + * a surface that owns the button. + * + * A plain factory, not a hook: tab strips call it inside `map()`. + */ +export function middleClickHandlers(action: (() => void) | undefined) { + return { + onMouseDown: (event: React.MouseEvent) => { + if (event.button === MIDDLE_BUTTON) { + event.preventDefault() + } + }, + + onPointerDown: (event: React.PointerEvent) => { + if (event.button === MIDDLE_BUTTON) { + pressedOn = action ? event.currentTarget : null + } + }, + + onPointerUp: (event: React.PointerEvent) => { + if (event.button !== MIDDLE_BUTTON) { + return + } + + const armed = pressedOn === event.currentTarget + pressedOn = null + + if (armed) { + action?.() + } + } + } +} diff --git a/ui-desktop/src/lib/model-options.test.ts b/ui-desktop/src/lib/model-options.test.ts new file mode 100644 index 00000000..81998f99 --- /dev/null +++ b/ui-desktop/src/lib/model-options.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { getGlobalModelOptions } from '@/clawcodex' + +import { manualPickRemoved, modelOptionsQueryKey, requestModelOptions } from './model-options' + +const globalOptions = { model: 'clawcodex-4', provider: 'nous', providers: [] } + +vi.mock('@/clawcodex', () => ({ + getGlobalModelOptions: vi.fn(() => Promise.resolve(globalOptions)) +})) + +describe('requestModelOptions', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + it('uses the connected gateway even before a session exists', async () => { + const gatewayPayload = { model: 'BeastMode', provider: 'moa', providers: [] } + + const gateway = { + request: vi.fn(() => Promise.resolve(gatewayPayload)) + } + + await expect(requestModelOptions({ gateway: gateway as never, sessionId: null })).resolves.toBe(gatewayPayload) + + expect(gateway.request).toHaveBeenCalledWith('model.options', { explicit_only: true }) + expect(getGlobalModelOptions).not.toHaveBeenCalled() + }) + + it('passes the active session id and refresh flag through the gateway', async () => { + const gateway = { + request: vi.fn(() => Promise.resolve(globalOptions)) + } + + await requestModelOptions({ gateway: gateway as never, refresh: true, sessionId: 'session-1' }) + + expect(gateway.request).toHaveBeenCalledWith('model.options', { + explicit_only: true, + refresh: true, + session_id: 'session-1' + }) + }) + + it('falls back to REST when no gateway is connected', async () => { + await requestModelOptions({ refresh: true }) + + expect(getGlobalModelOptions).toHaveBeenCalledWith({ explicitOnly: true, refresh: true }) + }) +}) + +describe('modelOptionsQueryKey', () => { + it('isolates new-chat catalogs by active gateway profile', () => { + expect(modelOptionsQueryKey('default')).toEqual(['model-options', 'default', 'global']) + expect(modelOptionsQueryKey('compass')).toEqual(['model-options', 'compass', 'global']) + expect(modelOptionsQueryKey('default')).not.toEqual(modelOptionsQueryKey('compass')) + }) + + it('keeps session catalogs inside the owning profile namespace', () => { + expect(modelOptionsQueryKey(' compass ', 'session-1')).toEqual(['model-options', 'compass', 'session-1']) + }) +}) + +describe('manualPickRemoved', () => { + const providers = [ + { name: 'OpenRouter', slug: 'openrouter', models: ['owl-alpha', 'gpt-5.5'] }, + { name: 'Nous', slug: 'nous', models: [] } // present but unconfigured / re-auth + ] + + it('flags a pick whose model was dropped from a populated provider', () => { + expect(manualPickRemoved(providers, 'openrouter', 'nemotron-removed')).toBe(true) + }) + + it('keeps a pick that is still in the catalog', () => { + expect(manualPickRemoved(providers, 'openrouter', 'gpt-5.5')).toBe(false) + }) + + it('matches the provider by name as well as slug', () => { + expect(manualPickRemoved(providers, 'OpenRouter', 'gpt-5.5')).toBe(false) + expect(manualPickRemoved(providers, 'OpenRouter', 'gone')).toBe(true) + }) + + it('never clobbers when the provider is absent (ambiguous / deauth)', () => { + expect(manualPickRemoved(providers, 'anthropic', 'claude-sonnet-4.6')).toBe(false) + }) + + it('never clobbers when the provider has an empty model list (re-auth)', () => { + expect(manualPickRemoved(providers, 'nous', 'clawcodex-4')).toBe(false) + }) + + it('never clobbers on a not-yet-loaded or empty catalog', () => { + expect(manualPickRemoved(undefined, 'openrouter', 'gpt-5.5')).toBe(false) + expect(manualPickRemoved([], 'openrouter', 'gpt-5.5')).toBe(false) + }) + + it('never clobbers when there is no pick', () => { + expect(manualPickRemoved(providers, '', '')).toBe(false) + }) +}) diff --git a/ui-desktop/src/lib/model-options.ts b/ui-desktop/src/lib/model-options.ts new file mode 100644 index 00000000..d409db81 --- /dev/null +++ b/ui-desktop/src/lib/model-options.ts @@ -0,0 +1,78 @@ +import { type ClawCodexGateway, getGlobalModelOptions, type ModelOptionsResponse } from '@/clawcodex' +import type { ModelOptionProvider } from '@/types/clawcodex' + +/** + * True only when a persisted **manual** composer pick has been removed from the + * catalog (its provider still ships models, but no longer this one) — so a new + * chat would keep 404'ing the dead model. Deliberately conservative to never + * clobber a still-valid pick: an unknown/absent provider, an empty model list + * (re-auth / unconfigured), or a not-yet-loaded catalog all return false. + */ +export function manualPickRemoved( + providers: ModelOptionProvider[] | undefined, + provider: string, + model: string +): boolean { + if (!providers?.length || !provider || !model) { + return false + } + + const row = providers.find(p => p.slug === provider || p.name === provider) + + if (!row) { + return false + } + + const models = row.models ?? [] + + // Empty list means the provider is present but unconfigured / awaiting + // re-auth, not that the model was dropped — leave the pick alone. + if (models.length === 0) { + return false + } + + return !models.includes(model) +} + +interface ModelOptionsRequest { + /** When false, include ambient/unconfigured providers (onboarding/setup + * surfaces). Chat pickers default to true so only explicitly configured + * providers are listed (#56974). */ + explicitOnly?: boolean + gateway?: ClawCodexGateway + refresh?: boolean + sessionId?: null | string +} + +export function modelOptionsQueryKey(profile: null | string | undefined, sessionId?: null | string) { + const profileKey = (profile ?? '').trim() || 'default' + + return ['model-options', profileKey, sessionId || 'global'] as const +} + +export function requestModelOptions({ + explicitOnly = true, + gateway, + refresh = false, + sessionId +}: ModelOptionsRequest): Promise<ModelOptionsResponse> { + if (gateway) { + const params: Record<string, unknown> = {} + + if (sessionId) { + params.session_id = sessionId + } + + if (refresh) { + params.refresh = true + } + + if (explicitOnly) { + params.explicit_only = true + } + + return gateway.request<ModelOptionsResponse>('model.options', params) + } + + return getGlobalModelOptions({ explicitOnly, ...(refresh ? { refresh: true } : {}) }) +} diff --git a/ui-desktop/src/lib/model-search-text.ts b/ui-desktop/src/lib/model-search-text.ts new file mode 100644 index 00000000..176ff532 --- /dev/null +++ b/ui-desktop/src/lib/model-search-text.ts @@ -0,0 +1,30 @@ +/** + * Extra tokens used only for model-picker search ranking. + * + * Wire IDs stay unchanged — some providers report short or brand-less ids + * (Kimi Coding's flagship is literally `k3`) that users still search for by + * the familiar `kimi-…` naming of sibling models. + * + * Keep in sync with ui-tui/src/lib/model-search-text.ts, + * web/src/lib/model-search-text.ts, and clawcodex_cli/model_search.py. + */ +const MODEL_SEARCH_ALIASES: Record<string, readonly string[]> = { + k3: ['kimi-k3', 'kimi'] +} + +/** Haystack for fuzzy/substring model search; never changes the wire id. */ +export function modelSearchText(model: string): string { + const id = model.trim() + + if (!id) { + return model + } + + const aliases = MODEL_SEARCH_ALIASES[id.toLowerCase()] + + if (!aliases?.length) { + return id + } + + return `${id} ${aliases.join(' ')}` +} diff --git a/ui-desktop/src/lib/model-status-label.test.ts b/ui-desktop/src/lib/model-status-label.test.ts new file mode 100644 index 00000000..086c19c5 --- /dev/null +++ b/ui-desktop/src/lib/model-status-label.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' + +import { currentPickerSelection, displayModelName, formatModelStatusLabel } from './model-status-label' +import { reasoningEffortLabel } from './reasoning-effort' + +describe('model-status-label', () => { + it('formats display names consistently', () => { + expect(displayModelName('anthropic/claude-opus-4.8-fast')).toBe('Opus 4.8') + expect(displayModelName('openai/gpt-5.5-fast')).toBe('GPT-5.5') + expect(displayModelName('deepseek/deepseek-v4-pro-thinking')).toBe('Deepseek V4 Pro') + expect(displayModelName('openai/gpt-5.5')).toBe('GPT-5.5') + }) + + it('strips trailing date-pin snapshots from the display name', () => { + expect(displayModelName('claude-opus-4-5-20251101')).toBe('Opus 4 5') + expect(displayModelName('anthropic/claude-haiku-4-5-20251001')).toBe('Haiku 4 5') + }) + + it('maps reasoning effort to compact labels', () => { + expect(reasoningEffortLabel('high')).toBe('High') + expect(reasoningEffortLabel('xhigh')).toBe('XHigh') + expect(reasoningEffortLabel('max')).toBe('Max') + expect(reasoningEffortLabel('ultra')).toBe('Ultra') + expect(reasoningEffortLabel('')).toBe('') + }) + + it('appends fast + effort session state to the status label', () => { + expect(formatModelStatusLabel('openai/gpt-5.5', { fastMode: true, reasoningEffort: 'high' })).toBe( + 'GPT-5.5 · Fast High' + ) + }) + + it('falls back to the profile default effort, then to medium', () => { + expect(formatModelStatusLabel('openai/gpt-5.5', { reasoningEffort: 'medium' })).toBe('GPT-5.5 · Med') + expect(formatModelStatusLabel('openai/gpt-5.5')).toBe('GPT-5.5 · Med') + // No session-level effort → the configured profile default is advertised, + // not ClawCodex' built-in medium. + expect(formatModelStatusLabel('openai/gpt-5.5', { defaultEffort: 'high' })).toBe('GPT-5.5 · High') + // An explicit session effort still wins over the profile default. + expect(formatModelStatusLabel('openai/gpt-5.5', { defaultEffort: 'high', reasoningEffort: 'low' })).toBe( + 'GPT-5.5 · Low' + ) + }) + + it('returns just the placeholder name when there is no model', () => { + expect(formatModelStatusLabel('')).toBe('No model') + }) + + describe('currentPickerSelection', () => { + const store = { model: 'opus', provider: 'anthropic' } + const options = { model: 'clawcodex-4', provider: 'nous' } + + it('prefers the sticky composer pick over the profile default pre-session', () => { + expect(currentPickerSelection(store, options)).toEqual(store) + }) + + it('keeps the SessionView selection when a stale options response disagrees', () => { + expect(currentPickerSelection(store, options)).toEqual(store) + }) + + it('falls back to options when the store is empty', () => { + expect(currentPickerSelection({ model: '', provider: '' }, options)).toEqual(options) + }) + + it('uses the complete options pair instead of mixing a partial store selection', () => { + expect(currentPickerSelection({ model: 'opus', provider: '' }, options)).toEqual(options) + }) + + it('falls back to the store while options are still loading', () => { + expect(currentPickerSelection(store, undefined)).toEqual(store) + }) + }) +}) diff --git a/ui-desktop/src/lib/model-status-label.ts b/ui-desktop/src/lib/model-status-label.ts new file mode 100644 index 00000000..5dde2074 --- /dev/null +++ b/ui-desktop/src/lib/model-status-label.ts @@ -0,0 +1,124 @@ +import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort' + +/** Which model/provider pair a picker should mark "current". SessionView state + * also drives the composer label, so a complete pair there wins over an older + * `model.options` response. During initial hydration (or pre-session startup), + * options remain the fallback. Pick one complete pair before mixing fields so + * a model is never shown under a different provider. */ +export function currentPickerSelection( + store: { model: string; provider: string }, + options?: { model?: string; provider?: string } +): { model: string; provider: string } { + const storeSelection = { + model: String(store.model || ''), + provider: String(store.provider || '') + } + + const optionsSelection = { + model: String(options?.model || ''), + provider: String(options?.provider || '') + } + + if (storeSelection.model && storeSelection.provider) { + return storeSelection + } + + if (optionsSelection.model && optionsSelection.provider) { + return optionsSelection + } + + return { + model: storeSelection.model || optionsSelection.model, + provider: storeSelection.provider || optionsSelection.provider + } +} + +/** Strip provider prefix and normalize for display. */ +export function modelBaseId(model: string): string { + const trimmed = model.trim() + const slash = trimmed.lastIndexOf('/') + + return slash >= 0 ? trimmed.slice(slash + 1) : trimmed +} + +// Trailing model-id variants that should render as a grayed tag beside the +// name (e.g. "Opus 4.8" + "Fast") rather than collapsing two distinct ids to +// the same display name. +const VARIANT_TAGS: ReadonlyArray<readonly [RegExp, string]> = [ + [/-fast$/i, 'Fast'], + [/-thinking$/i, 'Thinking'], + [/-preview$/i, 'Preview'], + [/-latest$/i, 'Latest'] +] + +const titleCase = (text: string): string => text.replace(/\b\w/g, char => char.toUpperCase()).trim() + +function prettifyBase(base: string): string { + if (/^claude-/i.test(base)) { + return titleCase(base.replace(/^claude-/i, '').replace(/-/g, ' ')) + } + + if (/^gpt-/i.test(base)) { + return base.replace(/^gpt-/i, 'GPT-') + } + + if (/^gemini-/i.test(base)) { + return base.replace(/^gemini-/i, 'Gemini ').replace(/-/g, ' ') + } + + return titleCase(base.replace(/-/g, ' ')) +} + +/** Split a model id into a clean display name plus an optional grayed variant + * tag, so distinct ids (e.g. `…-4.8` vs `…-4.8-fast`) don't collapse. */ +export function modelDisplayParts(model: string): { name: string; tag: string } { + let base = modelBaseId(model) + let tag = '' + + for (const [pattern, label] of VARIANT_TAGS) { + if (pattern.test(base)) { + tag = label + base = base.replace(pattern, '') + + break + } + } + + // Drop a trailing date-pin (`…-20251101`) — snapshot noise, not a name. + base = base.replace(/-\d{8}$/, '') + + return { name: prettifyBase(base) || model.trim() || 'No model', tag } +} + +/** Friendly one-line model name for menus and the status bar. */ +export function displayModelName(model: string): string { + return modelDisplayParts(model).name +} + +/** Status bar trigger label — model name plus the live session state (effort/fast). + * `defaultEffort` is the profile's configured level, used when the surface has + * no explicit effort so the label never advertises a default the agent won't use. */ +export function formatModelStatusLabel( + model: string, + options?: { defaultEffort?: string; fastMode?: boolean; reasoningEffort?: string } +): string { + const name = displayModelName(model) + + if (!model.trim()) { + return name + } + + const parts: string[] = [] + + // Fast is shown when the speed=fast param is on (options.fastMode) OR the + // active model is a `…-fast` variant (fast via a separate model id). + if (options?.fastMode || /-fast$/i.test(modelBaseId(model))) { + parts.push('Fast') + } + + // Always surface the effort so the current reasoning level is visible at a + // glance, not just when non-default. + parts.push(reasoningEffortLabel(options?.reasoningEffort || options?.defaultEffort || DEFAULT_REASONING_EFFORT)) + + return `${name} · ${parts.join(' ')}` +} diff --git a/ui-desktop/src/lib/mutable-ref.ts b/ui-desktop/src/lib/mutable-ref.ts new file mode 100644 index 00000000..12b1529b --- /dev/null +++ b/ui-desktop/src/lib/mutable-ref.ts @@ -0,0 +1,6 @@ +import type { MutableRefObject } from 'react' + +/** Imperative ref write — extracted so react-compiler doesn't flag hook-arg refs. */ +export function setMutableRef<T>(ref: MutableRefObject<T>, value: T) { + ref.current = value +} diff --git a/ui-desktop/src/lib/oneshot.ts b/ui-desktop/src/lib/oneshot.ts new file mode 100644 index 00000000..d2248a9f --- /dev/null +++ b/ui-desktop/src/lib/oneshot.ts @@ -0,0 +1,58 @@ +import { $gateway } from '@/store/gateway' +import { $activeSessionId } from '@/store/session' + +// Shared client for one-off ("one-shot") LLM requests: a single stateless model +// call that runs OUTSIDE the conversation. It never appends to session history, +// so prompt caching stays intact. Use it for small generative chores (commit +// messages, rename ideas, summaries) where an agent turn would be wrong. +// +// Pair with a registered backend template (agent/oneshot.py PROMPT_TEMPLATES) +// for reusable prompt engineering, or pass raw instructions/input ad hoc. + +export interface OneShotRequest { + /** Registered backend template id (e.g. 'commit_message'). */ + template?: string + /** Variables for the template. */ + variables?: Record<string, unknown> + /** Raw system prompt (used when no template is given). */ + instructions?: string + /** Raw user content (used when no template is given). */ + input?: string + /** Auxiliary task name for model routing (defaults backend-side). */ + task?: string + maxTokens?: number + temperature?: number + /** + * Session whose model to inherit. Defaults to the active session so output + * matches the model the user is coding with; pass null to force the + * configured auxiliary backend instead. + */ + sessionId?: string | null +} + +/** + * Send a one-off request to ClawCodex and return the generated text. + * Throws when the gateway is offline or the backend reports an error. + */ +export async function requestOneShot(req: OneShotRequest): Promise<string> { + const gateway = $gateway.get() + + if (!gateway) { + throw new Error('Gateway not connected') + } + + const sessionId = req.sessionId === undefined ? $activeSessionId.get() : req.sessionId + + const result = await gateway.request<{ text?: string }>('llm.oneshot', { + input: req.input, + instructions: req.instructions, + max_tokens: req.maxTokens, + session_id: sessionId ?? undefined, + task: req.task, + temperature: req.temperature, + template: req.template, + variables: req.variables + }) + + return (result?.text ?? '').trim() +} diff --git a/ui-desktop/src/lib/persisted.ts b/ui-desktop/src/lib/persisted.ts new file mode 100644 index 00000000..eaff1e38 --- /dev/null +++ b/ui-desktop/src/lib/persisted.ts @@ -0,0 +1,78 @@ +import { atom, type WritableAtom } from 'nanostores' + +import { readKey, writeKey } from './storage' + +// A nanostore that auto-persists. Reads its seed from localStorage through the +// storage choke point (so every read/write is observable in one place) and +// writes back on every change — no per-atom subscribe boilerplate. +// +// export const $foo = persistentAtom('clawcodex.desktop.foo', false, Codecs.bool) + +// Maps a value to/from its stored string form. `decode` only ever sees a real +// stored string (absence falls back); `encode` returning null removes the key. +export interface Codec<T> { + decode(raw: string): T + encode(value: T): null | string +} + +export const Codecs = { + bool: { decode: raw => raw === 'true', encode: (value: boolean) => String(value) } as Codec<boolean>, + nullableText: { decode: raw => raw, encode: value => value } as Codec<null | string>, + text: { decode: raw => raw, encode: (value: string) => value } as Codec<string>, + // Mirrors storedStringArray/persistStringArray: drops non-strings, empty → removed. + stringArray: { + decode: raw => { + const parsed = JSON.parse(raw) as unknown + + return Array.isArray(parsed) + ? parsed.filter((item): item is string => typeof item === 'string' && item.length > 0) + : [] + }, + encode: value => (value.length === 0 ? null : JSON.stringify(value)) + } as Codec<string[]>, + // Mirrors storedStringRecord/persistStringRecord: keeps only string values. + stringRecord: { + decode: raw => { + const parsed = JSON.parse(raw) as unknown + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {} + } + + return Object.fromEntries( + Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === 'string') + ) + }, + encode: value => JSON.stringify(value) + } as Codec<Record<string, string>>, + /** JSON with an optional sanitizer for untrusted persisted shapes. */ + json<T>(sanitize?: (value: unknown) => T): Codec<T> { + return { + decode: raw => { + const parsed = JSON.parse(raw) as unknown + + return sanitize ? sanitize(parsed) : (parsed as T) + }, + encode: value => JSON.stringify(value) + } + } +} + +export function persistentAtom<T>(key: string, fallback: T, codec: Codec<T> = Codecs.json<T>()): WritableAtom<T> { + const raw = readKey(key) + let initial = fallback + + if (raw !== null) { + try { + initial = codec.decode(raw) + } catch { + initial = fallback + } + } + + const $value = atom<T>(initial) + + $value.subscribe(value => writeKey(key, codec.encode(value))) + + return $value +} diff --git a/ui-desktop/src/lib/pool.test.ts b/ui-desktop/src/lib/pool.test.ts new file mode 100644 index 00000000..15aafa46 --- /dev/null +++ b/ui-desktop/src/lib/pool.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { mapPool } from './pool' + +describe('mapPool', () => { + it('preserves input order regardless of completion order', async () => { + const out = await mapPool([30, 10, 20], 3, async ms => { + await new Promise(r => setTimeout(r, ms)) + + return ms + }) + + expect(out).toEqual([30, 10, 20]) + }) + + it('never exceeds the concurrency limit', async () => { + let active = 0 + let peak = 0 + + await mapPool([...Array(10).keys()], 3, async () => { + active += 1 + peak = Math.max(peak, active) + await new Promise(r => setTimeout(r, 5)) + active -= 1 + }) + + expect(peak).toBeLessThanOrEqual(3) + }) +}) diff --git a/ui-desktop/src/lib/pool.ts b/ui-desktop/src/lib/pool.ts new file mode 100644 index 00000000..b396dc22 --- /dev/null +++ b/ui-desktop/src/lib/pool.ts @@ -0,0 +1,20 @@ +/** + * `Promise.all(items.map(fn))` with a concurrency cap: at most `limit` calls run + * at once, results stay in input order. Keeps a many-repo probe from spawning a + * `git` process per repo all at once. + */ +export async function mapPool<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> { + const out = new Array<R>(items.length) + let next = 0 + + const worker = async () => { + while (next < items.length) { + const i = next++ + out[i] = await fn(items[i]) + } + } + + await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker)) + + return out +} diff --git a/ui-desktop/src/lib/preview-targets.test.ts b/ui-desktop/src/lib/preview-targets.test.ts new file mode 100644 index 00000000..20a116f8 --- /dev/null +++ b/ui-desktop/src/lib/preview-targets.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' + +import { extractPreviewTargets, previewTargetFromMarkdownHref, stripPreviewTargets } from './preview-targets' + +describe('preview target detection', () => { + it('does not infer preview targets from raw paths or URLs', () => { + expect(extractPreviewTargets('Preview: http://localhost:5173/')).toEqual([]) + expect(extractPreviewTargets('Open index.html\n/tmp/demo.html\nhttp://localhost:5173/')).toEqual([]) + }) + + it('decodes preview markdown hrefs', () => { + expect(previewTargetFromMarkdownHref('#preview/%2Ftmp%2Fdemo.html')).toBe('/tmp/demo.html') + expect(previewTargetFromMarkdownHref('#preview:%2Ftmp%2Fdemo.html')).toBe('/tmp/demo.html') + expect(previewTargetFromMarkdownHref('#media:%2Ftmp%2Fdemo.mp4')).toBeNull() + }) + + it('extracts preview targets from already-rendered preview markers', () => { + expect(extractPreviewTargets('[Preview: demo.html](#preview:%2Ftmp%2Fdemo.html)')).toEqual(['/tmp/demo.html']) + }) + + it('strips preview targets from visible assistant text', () => { + expect(stripPreviewTargets('ready\n/tmp/mycelium-bunnies.html\nopen it')).toBe( + 'ready\n/tmp/mycelium-bunnies.html\nopen it' + ) + expect(stripPreviewTargets('[Preview: demo.html](#preview:%2Ftmp%2Fdemo.html)\nopen it')).toBe('open it') + }) +}) diff --git a/ui-desktop/src/lib/preview-targets.ts b/ui-desktop/src/lib/preview-targets.ts new file mode 100644 index 00000000..bc7108ab --- /dev/null +++ b/ui-desktop/src/lib/preview-targets.ts @@ -0,0 +1,63 @@ +const PREVIEW_MARKDOWN_RE = /\[Preview:[^\]]+\]\((?<href>#preview[:/][^)]+)\)/gi + +export function stripPreviewTargets(text: string): string { + return text + .replace(PREVIEW_MARKDOWN_RE, '') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +export function extractPreviewTargets(text: string): string[] { + const targets: string[] = [] + const seen = new Set<string>() + + for (const match of text.matchAll(PREVIEW_MARKDOWN_RE)) { + const target = previewTargetFromMarkdownHref(match.groups?.href) + + if (target && !seen.has(target)) { + seen.add(target) + targets.push(target) + } + } + + return targets +} + +export function previewMarkdownHref(target: string): string { + return `#preview/${encodeURIComponent(target)}` +} + +export function previewTargetFromMarkdownHref(href?: string): string | null { + if (!href?.startsWith('#preview:') && !href?.startsWith('#preview/')) { + return null + } + + try { + return decodeURIComponent(href.slice('#preview'.length + 1)) + } catch { + return null + } +} + +export function previewName(target: string): string { + try { + const url = new URL(target) + + if (url.protocol === 'file:') { + return decodeURIComponent(url.pathname).split(/[\\/]/).filter(Boolean).pop() || target + } + + const file = url.pathname.split('/').filter(Boolean).pop() + + return file || url.host + } catch { + return target.split(/[\\/]/).filter(Boolean).pop() || target + } +} + +export function previewDisplayLabel(target: string): string { + const escaped = previewName(target).replace(/[[\]\\]/g, '\\$&') + + return `Preview: ${escaped}` +} diff --git a/ui-desktop/src/lib/profile-color.ts b/ui-desktop/src/lib/profile-color.ts new file mode 100644 index 00000000..c804e7e4 --- /dev/null +++ b/ui-desktop/src/lib/profile-color.ts @@ -0,0 +1,55 @@ +// Deterministic per-profile color so a profile is glanceable across the app +// (the sidebar profile rail). The default/root profile has no color — named +// profiles get a stable hue derived from the name, so the same profile always +// reads the same color without persisting anything. + +const PROFILE_TAG_SATURATION = 68 +const PROFILE_TAG_LIGHTNESS = 58 + +function hashString(value: string): number { + let hash = 0 + + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) >>> 0 + } + + return hash +} + +// Returns an hsl() string for a named profile, or null for default/empty +// (rendered neutral / untagged). +export function profileColor(name: null | string | undefined): null | string { + const key = (name ?? '').trim() + + if (!key || key === 'default') { + return null + } + + const hue = hashString(key) % 360 + + return `hsl(${hue} ${PROFILE_TAG_SATURATION}% ${PROFILE_TAG_LIGHTNESS}%)` +} + +// A profile's effective color: a user-picked override wins, else the +// deterministic hue. Default/empty stays neutral (null) regardless. +export function resolveProfileColor(name: null | string | undefined, overrides: Record<string, string>): null | string { + const key = (name ?? '').trim() + + if (!key || key === 'default') { + return null + } + + return overrides[key] ?? profileColor(key) +} + +// Curated swatches for the rail color picker — evenly spaced hues at the same +// saturation/lightness as the deterministic palette, so picks stay cohesive. +export const PROFILE_SWATCHES: readonly string[] = Array.from( + { length: 12 }, + (_, index) => `hsl(${index * 30} ${PROFILE_TAG_SATURATION}% ${PROFILE_TAG_LIGHTNESS}%)` +) + +// Translucent fill derived from a profile color, for tag backgrounds. +export function profileColorSoft(color: string, percent = 16): string { + return `color-mix(in srgb, ${color} ${percent}%, transparent)` +} diff --git a/ui-desktop/src/lib/project-idea-templates.ts b/ui-desktop/src/lib/project-idea-templates.ts new file mode 100644 index 00000000..3c0df88c --- /dev/null +++ b/ui-desktop/src/lib/project-idea-templates.ts @@ -0,0 +1,116 @@ +// Fun starter ideas for the new-project dialog. Pills prefill IDEA.md; the set +// shown is a random handful from this pool (reshuffled on open / via the dice), +// so creating a project always feels a little playful. Pure content — edit +// freely, order doesn't matter. + +export interface ProjectIdeaTemplate { + emoji: string + label: string + idea: string +} + +export const PROJECT_IDEA_TEMPLATES: ProjectIdeaTemplate[] = [ + { + emoji: '🎮', + label: 'Game jam', + idea: 'A tiny browser game built in a weekend.\n\n- One core mechanic, juicy feedback\n- No build step — single HTML/JS file\n- Playable in under 60 seconds' + }, + { + emoji: '📚', + label: 'Novel', + idea: 'A novel-in-progress.\n\n- Track chapters, characters, and timeline\n- Daily word-count goal\n- Keep research notes beside the draft' + }, + { + emoji: '🤖', + label: 'Discord bot', + idea: 'A Discord bot for a small community.\n\n- Slash commands + a fun daily ritual\n- Lightweight persistence\n- Deploy somewhere free' + }, + { + emoji: '📊', + label: 'Data viz', + idea: 'An interactive visualization of a dataset I care about.\n\n- Pick the dataset and the one question it answers\n- Clean → chart → annotate\n- Shareable as a single page' + }, + { + emoji: '🎨', + label: 'Generative art', + idea: 'A generative art piece.\n\n- One algorithm, lots of seeds\n- Export high-res stills\n- A gallery of the best outputs' + }, + { + emoji: '🍳', + label: 'Recipe box', + idea: 'A personal recipe collection.\n\n- Searchable by ingredient and mood\n- Scale servings on the fly\n- Auto-build a shopping list' + }, + { + emoji: '🧪', + label: 'Research log', + idea: 'A research notebook for an open question.\n\n- Log experiments, results, and dead ends\n- Cite sources inline\n- Weekly synthesis of what I learned' + }, + { + emoji: '💸', + label: 'Budget tracker', + idea: 'A no-nonsense budget tracker.\n\n- Import transactions, tag them fast\n- Monthly burn vs. plan\n- One chart that tells the truth' + }, + { + emoji: '🌱', + label: 'Habit tracker', + idea: 'A habit tracker that actually sticks.\n\n- A handful of daily checkboxes\n- Streaks without guilt\n- A calm weekly review' + }, + { + emoji: '🗺️', + label: 'Trip planner', + idea: 'A trip planner for an upcoming adventure.\n\n- Day-by-day itinerary\n- Map of pins + notes\n- Packing + budget checklist' + }, + { + emoji: '🎵', + label: 'Music toy', + idea: 'A little music-making toy.\n\n- One instrument or sequencer\n- Web Audio, no installs\n- Record + share a loop' + }, + { + emoji: '🧩', + label: 'Puzzle maker', + idea: 'A generator for a puzzle I love.\n\n- Procedurally make solvable puzzles\n- Difficulty dial\n- Printable + playable' + }, + { + emoji: '📝', + label: 'Digital garden', + idea: 'A digital garden / personal wiki.\n\n- Atomic notes that link to each other\n- Grows over time, never "done"\n- Publish the public ones' + }, + { + emoji: '🛰️', + label: 'API wrapper', + idea: 'A clean wrapper around an API I keep reaching for.\n\n- Typed client + sensible defaults\n- One example per endpoint\n- Publish it' + }, + { + emoji: '🏋️', + label: 'Workout plan', + idea: 'A workout planner / logger.\n\n- Build a weekly split\n- Log sets fast on mobile\n- Track progress over months' + }, + { + emoji: '🧠', + label: 'Flashcards', + idea: 'A spaced-repetition flashcard app.\n\n- Quick card capture\n- Simple SM-2 scheduling\n- A daily review that fits in 5 minutes' + }, + { + emoji: '✍️', + label: 'Screenplay', + idea: 'A short screenplay.\n\n- Logline → beats → scenes\n- Proper format, distraction-free\n- A table read by the end' + }, + { + emoji: '🔭', + label: 'Learn-by-building', + idea: "A project to learn a thing I've been avoiding.\n\n- Smallest real thing that teaches it\n- Notes on every gotcha\n- A writeup when it works" + } +] + +// A shuffled slice of the pool — the pills shown at any moment. +export function randomIdeaTemplates(count = 6): ProjectIdeaTemplate[] { + const pool = [...PROJECT_IDEA_TEMPLATES] + + for (let i = pool.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + + ;[pool[i], pool[j]] = [pool[j], pool[i]] + } + + return pool.slice(0, Math.min(count, pool.length)) +} diff --git a/ui-desktop/src/lib/provider-setup-errors.test.ts b/ui-desktop/src/lib/provider-setup-errors.test.ts new file mode 100644 index 00000000..c146584b --- /dev/null +++ b/ui-desktop/src/lib/provider-setup-errors.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' + +import { isProviderSetupErrorMessage } from './provider-setup-errors' + +describe('isProviderSetupErrorMessage', () => { + it('matches generic missing-provider copy', () => { + expect(isProviderSetupErrorMessage('No inference provider configured. Run `clawcodex model` to choose one.')).toBe( + true + ) + expect(isProviderSetupErrorMessage('No inference provider is configured.')).toBe(true) + expect(isProviderSetupErrorMessage('No ClawCodex provider is configured.')).toBe(true) + expect(isProviderSetupErrorMessage('set an API key (OPENROUTER_API_KEY) in ~/.clawcodex/.env')).toBe(true) + }) + + it('matches the exact empty-key warning emitted in session.info', () => { + expect( + isProviderSetupErrorMessage("No API key configured for provider 'openrouter'. First message will fail.") + ).toBe(true) + }) + + it('does not match bare env var mentions from auxiliary warnings', () => { + expect(isProviderSetupErrorMessage('OPENROUTER_API_KEY not set')).toBe(false) + expect(isProviderSetupErrorMessage('Run `clawcodex setup` or set OPENROUTER_API_KEY.')).toBe(false) + expect( + isProviderSetupErrorMessage( + '⚠ No auxiliary LLM provider configured — context compression will drop middle turns without a summary. Run `clawcodex setup` or set OPENROUTER_API_KEY.' + ) + ).toBe(false) + expect(isProviderSetupErrorMessage('OPENAI_API_KEY missing')).toBe(false) + expect(isProviderSetupErrorMessage('ANTHROPIC_API_KEY not found')).toBe(false) + }) + + it('does not match non-provider runtime failures', () => { + expect( + isProviderSetupErrorMessage('Selected runtime is not available. setup.status reports configured credentials.') + ).toBe(false) + }) + + it('returns false for empty input', () => { + expect(isProviderSetupErrorMessage('')).toBe(false) + expect(isProviderSetupErrorMessage(null)).toBe(false) + expect(isProviderSetupErrorMessage(undefined)).toBe(false) + }) +}) diff --git a/ui-desktop/src/lib/provider-setup-errors.ts b/ui-desktop/src/lib/provider-setup-errors.ts new file mode 100644 index 00000000..51de0d56 --- /dev/null +++ b/ui-desktop/src/lib/provider-setup-errors.ts @@ -0,0 +1,14 @@ +const PROVIDER_SETUP_ERROR_RE = + /No (?:inference|ClawCodex) provider(?: is)? configured|no_provider_configured|set an API key/i + +const SESSION_INFO_CREDENTIAL_WARNING_RE = /^No API key configured for provider '[^']*'\. First message will fail\.$/ + +export function isProviderSetupErrorMessage(message: null | string | undefined): boolean { + const text = message?.trim() + + if (!text) { + return false + } + + return PROVIDER_SETUP_ERROR_RE.test(text) || SESSION_INFO_CREDENTIAL_WARNING_RE.test(text) +} diff --git a/ui-desktop/src/lib/query-client.test.ts b/ui-desktop/src/lib/query-client.test.ts new file mode 100644 index 00000000..d6d11105 --- /dev/null +++ b/ui-desktop/src/lib/query-client.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { invalidateProfileScopedQueries, queryClient } from './query-client' + +function invalidated(key: unknown[]): boolean { + return queryClient.getQueryState(key)?.isInvalidated ?? false +} + +describe('invalidateProfileScopedQueries', () => { + beforeEach(() => { + queryClient.clear() + }) + + it('invalidates profile-scoped caches and leaves account/global caches intact', () => { + const profileScoped = [ + ['clawcodex-config-record'], + ['clawcodex-config-schema'], + ['skills-list'], + ['toolsets-list'], + ['model-options', 'global'], + ['command-palette', 'sessions'], + ['session-picker', 'sessions'] + ] + + const global = [ + ['billing', 'state'], + ['billing', 'subscription'], + ['marketplace-themes', 'all'], + ['marketplace-themes-settings', 'x'], + ['onboarding-model-options', 'y'], + ['contrib-logs-tail'] + ] + + for (const key of [...profileScoped, ...global]) { + queryClient.setQueryData(key, { seeded: true }) + } + + invalidateProfileScopedQueries() + + for (const key of profileScoped) { + expect(invalidated(key), `${JSON.stringify(key)} should be invalidated`).toBe(true) + } + + for (const key of global) { + expect(invalidated(key), `${JSON.stringify(key)} should be left intact`).toBe(false) + } + }) + + it('invalidates unknown/non-string-rooted keys by default (correctness-safe)', () => { + queryClient.setQueryData(['some-future-profile-query'], 1) + queryClient.setQueryData([{ scope: 'weird' }], 1) + + invalidateProfileScopedQueries() + + expect(invalidated(['some-future-profile-query'])).toBe(true) + expect(invalidated([{ scope: 'weird' }])).toBe(true) + }) +}) diff --git a/ui-desktop/src/lib/query-client.ts b/ui-desktop/src/lib/query-client.ts new file mode 100644 index 00000000..cfea4f10 --- /dev/null +++ b/ui-desktop/src/lib/query-client.ts @@ -0,0 +1,48 @@ +import { QueryClient, type QueryKey } from '@tanstack/react-query' + +// Shared React Query client. Lives in its own module (not main.tsx) so non-React +// code — e.g. the profile store on a gateway swap — can invalidate cached, +// profile-scoped settings without importing the app entry point. +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + staleTime: 60_000 + } + } +}) + +// Curried, setState-shaped cache writer for optimistic write-through: keeps +// mutation sites terse (`setX(next)` or `setX(prev => …)`) over one query key. +export const writeCache = + <T>(key: QueryKey) => + (next: T | undefined | ((prev: T | undefined) => T | undefined)): void => + void queryClient.setQueryData<T>(key, next) + +// Query-key roots that are NOT profile-scoped: account/billing, the theme +// marketplace, onboarding, and contrib log tails all read global or +// account-level state, so a profile/gateway swap must not refetch them. Any +// other key is treated as profile-scoped and invalidated -- a denylist is +// correctness-safe here: a root we forget to list just gets refetched (a small +// cost), whereas an allowlist that misses a profile-scoped key would paint the +// previous profile's data (a bug). +const PROFILE_INDEPENDENT_QUERY_ROOTS = new Set<string>([ + 'billing', + 'marketplace-themes', + 'marketplace-themes-settings', + 'onboarding-model-options', + 'contrib-logs-tail' +]) + +// Invalidate profile-scoped query caches on a profile / gateway switch, leaving +// account/global caches intact. Replaces a keyless invalidateQueries() that +// refetched everything (billing, marketplace, onboarding) on every switch. +export function invalidateProfileScopedQueries(): void { + void queryClient.invalidateQueries({ + predicate: query => { + const root = query.queryKey[0] + + return typeof root !== 'string' || !PROFILE_INDEPENDENT_QUERY_ROOTS.has(root) + } + }) +} diff --git a/ui-desktop/src/lib/raf-coalesce.ts b/ui-desktop/src/lib/raf-coalesce.ts new file mode 100644 index 00000000..788b99d6 --- /dev/null +++ b/ui-desktop/src/lib/raf-coalesce.ts @@ -0,0 +1,34 @@ +/** Coalesce a stream of values (pointermove positions, resize deltas) to one + * `apply` per animation frame, so a drag can't drive several layouts per frame. + * `push` records the latest value and schedules a frame; `finish` commits the + * last value and cancels any pending frame (call it on pointerup/cancel). + * `null` is the empty sentinel, so `T` must never legitimately be `null`. */ +export function rafCoalesce<T>(apply: (value: T) => void): { finish: () => void; push: (value: T) => void } { + let frame: null | number = null + let pending: null | T = null + + const flush = () => { + frame = null + + if (pending !== null) { + apply(pending) + } + } + + return { + finish() { + if (frame !== null) { + cancelAnimationFrame(frame) + frame = null + } + + if (pending !== null) { + apply(pending) + } + }, + push(value) { + pending = value + frame ??= requestAnimationFrame(flush) + } + } +} diff --git a/ui-desktop/src/lib/reasoning-blocks.test.ts b/ui-desktop/src/lib/reasoning-blocks.test.ts new file mode 100644 index 00000000..cab4ab90 --- /dev/null +++ b/ui-desktop/src/lib/reasoning-blocks.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' + +import { separateGluedReasoningBlocks } from '@/lib/reasoning-blocks' + +describe('separateGluedReasoningBlocks', () => { + it('splits heading-onto-heading parts (the `****` run)', () => { + const glued = + '**Investigating likely culprit PRs****Inspecting message schema****Analyzing interrupted tool call impact**' + + expect(separateGluedReasoningBlocks(glued)).toBe( + [ + '**Investigating likely culprit PRs**', + '', + '**Inspecting message schema**', + '', + '**Analyzing interrupted tool call impact**' + ].join('\n') + ) + }) + + it('splits prose-onto-heading parts (vercel/ai#6742 repro)', () => { + const glued = + '**Simulating a greeting stream**\n\nIt feels like a streaming interaction!**Simulating a greeting stream**\n\nI want to meet the request.' + + expect(separateGluedReasoningBlocks(glued)).toContain('interaction!\n\n**Simulating') + expect(separateGluedReasoningBlocks(glued)).not.toContain('interaction!**') + }) + + it('is idempotent on already-separated text', () => { + const separated = '**One**\n\n**Two**' + + expect(separateGluedReasoningBlocks(separated)).toBe(separated) + }) + + it('leaves emphasis inside prose alone', () => { + const prose = 'Looking at the logs, the **signature** field is missing — so the replay 400s.' + + expect(separateGluedReasoningBlocks(prose)).toBe(prose) + }) + + it('leaves an unclosed emphasis run alone', () => { + expect(separateGluedReasoningBlocks('weighing options **')).toBe('weighing options **') + }) + + it('does not split a heading that already opens the text', () => { + expect(separateGluedReasoningBlocks('**Only one part**')).toBe('**Only one part**') + }) +}) diff --git a/ui-desktop/src/lib/reasoning-blocks.ts b/ui-desktop/src/lib/reasoning-blocks.ts new file mode 100644 index 00000000..a1122bd7 --- /dev/null +++ b/ui-desktop/src/lib/reasoning-blocks.ts @@ -0,0 +1,31 @@ +/** + * Reasoning-summary models (OpenAI's gpt-5.x family, and anything relaying the + * Responses API onto the OpenAI chat wire) emit one delta per *completed* + * summary part, each opening with a bold markdown heading: + * + * **Investigating likely culprit PRs** + * **Inspecting message schema** + * + * The Responses API delimits those parts with `summary_index`; the chat wire + * carries no such field, so concatenated deltas glue into + * `...PRs****Inspecting...` — a `****` run markdown reads as neither a bold + * close nor a bold open, leaving one unbroken, unspaced, half-bold paragraph. + * The AI SDK hit the same bug (vercel/ai#6742). + * + * The backend now inserts the break as the deltas arrive. This repairs the text + * we display: reasoning persisted before that fix, and any provider still + * gluing its parts. Idempotent — a break already present is left alone. + */ + +// A heading butting straight onto the previous part, in the two shapes the +// wire produces: +// 1. heading-onto-heading — `**One****Two**`, a bare `****` run. +// 2. prose-onto-heading — `interaction!**Two**`. +// Emphasis that legitimately follows whitespace is left alone, and a heading +// must close on its own line to count as a summary part. +const GLUED_HEADING_RUN = /(?<!\*)\*{4}(?!\*)/g +const GLUED_AFTER_PROSE = /(?<=[^\s*])(\*\*(?=[^\s*])[^\n]*?\*\*)/g + +export function separateGluedReasoningBlocks(text: string): string { + return text.replace(GLUED_HEADING_RUN, '**\n\n**').replace(GLUED_AFTER_PROSE, '\n\n$1') +} diff --git a/ui-desktop/src/lib/reasoning-effort.test.ts b/ui-desktop/src/lib/reasoning-effort.test.ts new file mode 100644 index 00000000..bc61b91f --- /dev/null +++ b/ui-desktop/src/lib/reasoning-effort.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' + +import { + DEFAULT_REASONING_EFFORT, + isReasoningEffort, + isThinkingEnabled, + REASONING_EFFORT_VALUES, + REASONING_EFFORTS, + reasoningEffortLabel, + resolveReasoningEffort +} from './reasoning-effort' + +describe('reasoning-effort', () => { + it('keeps the scale ascending and `none` off it', () => { + expect(REASONING_EFFORTS).not.toContain('none') + expect(REASONING_EFFORT_VALUES[0]).toBe('none') + expect(REASONING_EFFORT_VALUES).toHaveLength(REASONING_EFFORTS.length + 1) + }) + + it('labels every level it claims to support', () => { + for (const effort of REASONING_EFFORT_VALUES) { + expect(reasoningEffortLabel(effort)).not.toBe('') + } + + expect(reasoningEffortLabel('')).toBe('') + // Unknown values pass through rather than silently reading as a real level. + expect(reasoningEffortLabel('bogus')).toBe('bogus') + }) + + it('recognizes only real scale levels', () => { + expect(isReasoningEffort(DEFAULT_REASONING_EFFORT)).toBe(true) + expect(isReasoningEffort('HIGH')).toBe(true) + expect(isReasoningEffort('none')).toBe(false) + expect(isReasoningEffort('bogus')).toBe(false) + }) + + it('treats empty as inherit and only `none` as off', () => { + expect(isThinkingEnabled('none')).toBe(false) + expect(isThinkingEnabled('high')).toBe(true) + // Empty inherits the fallback, so an off fallback reads as off. + expect(isThinkingEnabled('', 'none')).toBe(false) + expect(isThinkingEnabled('', 'high')).toBe(true) + }) + + it('resolves a scale value: inherit, off, or clamp', () => { + expect(resolveReasoningEffort('high')).toBe('high') + // Empty inherits the profile default rather than snapping to medium. + expect(resolveReasoningEffort('', 'ultra')).toBe('ultra') + // Off selects nothing on the scale. + expect(resolveReasoningEffort('none')).toBe('') + expect(resolveReasoningEffort('bogus')).toBe(DEFAULT_REASONING_EFFORT) + }) +}) diff --git a/ui-desktop/src/lib/reasoning-effort.ts b/ui-desktop/src/lib/reasoning-effort.ts new file mode 100644 index 00000000..acef534a --- /dev/null +++ b/ui-desktop/src/lib/reasoning-effort.ts @@ -0,0 +1,54 @@ +import { normalize } from '@/lib/text' + +/** ClawCodex' reasoning levels, in ascending order — mirrors the backend's + * VALID_REASONING_EFFORTS (clawcodex_constants.py). `none` is not a level: it's + * thinking disabled, owned by the Thinking toggle rather than the scale. */ +export const REASONING_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const + +export type ReasoningEffort = (typeof REASONING_EFFORTS)[number] + +/** The scale plus the off state — the full set a config value may hold. */ +export const REASONING_EFFORT_VALUES = ['none', ...REASONING_EFFORTS] as const + +/** ClawCodex' built-in level when neither the surface nor the profile config + * specifies one (mirrors the backend's own fallback). */ +export const DEFAULT_REASONING_EFFORT: ReasoningEffort = 'medium' + +/** Compact labels for chrome where space is tight (pill, picker rows). Menus + * and settings use the translated `shell.modelOptions` strings instead. */ +const SHORT_LABELS: Record<string, string> = { + none: 'Off', + minimal: 'Min', + low: 'Low', + medium: 'Med', + high: 'High', + xhigh: 'XHigh', + max: 'Max', + ultra: 'Ultra' +} + +export function reasoningEffortLabel(effort: string): string { + const key = normalize(effort) + + return key ? (SHORT_LABELS[key] ?? effort) : '' +} + +export const isReasoningEffort = (value: string): value is ReasoningEffort => + REASONING_EFFORTS.includes(normalize(value) as ReasoningEffort) + +/** Thinking is on unless a level explicitly says otherwise; an empty value + * means "inherit", so it resolves through `fallback` first. */ +export const isThinkingEnabled = (effort: string, fallback: string = DEFAULT_REASONING_EFFORT): boolean => + normalize(effort || fallback) !== 'none' + +/** The level a scale control should show. Empty inherits `fallback`; `none` + * (thinking off) selects nothing; anything unrecognized clamps to the default. */ +export function resolveReasoningEffort(effort: string, fallback: string = DEFAULT_REASONING_EFFORT): string { + const value = normalize(effort || fallback) + + if (value === 'none') { + return '' + } + + return isReasoningEffort(value) ? value : DEFAULT_REASONING_EFFORT +} diff --git a/ui-desktop/src/lib/reconnect-backoff.test.ts b/ui-desktop/src/lib/reconnect-backoff.test.ts new file mode 100644 index 00000000..53e51601 --- /dev/null +++ b/ui-desktop/src/lib/reconnect-backoff.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest' + +import { reconnectBackoffDelayMs } from './reconnect-backoff' + +describe('reconnectBackoffDelayMs', () => { + it('increases the delay ceiling across consecutive failed attempts', () => { + // Pin Math.random so we can read the ceiling directly through the + // returned value instead of statistically sampling it. + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(1) + + try { + const delays = [0, 1, 2, 3, 4].map(attempt => reconnectBackoffDelayMs(attempt, { baseDelayMs: 300 })) + + expect(delays).toEqual([300, 600, 1200, 2400, 4800]) + + for (let i = 1; i < delays.length; i++) { + expect(delays[i]).toBeGreaterThan(delays[i - 1]) + } + } finally { + randomSpy.mockRestore() + } + }) + + it('caps the delay ceiling instead of growing unbounded', () => { + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(1) + + try { + // Attempt 10 would be 300 * 2**10 = 307_200ms uncapped — must clamp. + expect(reconnectBackoffDelayMs(10, { baseDelayMs: 300, capMs: 15_000 })).toBe(15_000) + expect(reconnectBackoffDelayMs(50, { baseDelayMs: 300, capMs: 15_000 })).toBe(15_000) + } finally { + randomSpy.mockRestore() + } + }) + + it('applies full jitter: delay is uniformly within [0, ceiling)', () => { + const randomSpy = vi.spyOn(Math, 'random') + + try { + randomSpy.mockReturnValue(0) + expect(reconnectBackoffDelayMs(3, { baseDelayMs: 300 })).toBe(0) + + randomSpy.mockReturnValue(0.5) + expect(reconnectBackoffDelayMs(3, { baseDelayMs: 300 })).toBe(1200) + + randomSpy.mockReturnValue(0.999) + expect(reconnectBackoffDelayMs(3, { baseDelayMs: 300 })).toBeCloseTo(2400 * 0.999, 5) + } finally { + randomSpy.mockRestore() + } + }) + + it('resets to the attempt-0 ceiling after a successful connection (caller passes attempt back to 0)', () => { + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(1) + + try { + // Simulates: fail, fail, fail (attempt climbs), succeed (caller resets + // its counter to 0), fail again — the very next delay must be back at + // the base ceiling, not continuing the climb. + reconnectBackoffDelayMs(0, { baseDelayMs: 300 }) + reconnectBackoffDelayMs(1, { baseDelayMs: 300 }) + const afterSeveralFailures = reconnectBackoffDelayMs(2, { baseDelayMs: 300 }) + const afterReset = reconnectBackoffDelayMs(0, { baseDelayMs: 300 }) + + expect(afterSeveralFailures).toBe(1200) + expect(afterReset).toBe(300) + } finally { + randomSpy.mockRestore() + } + }) + + it('treats negative attempt numbers as attempt 0 rather than throwing or returning a negative delay', () => { + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(1) + + try { + expect(reconnectBackoffDelayMs(-5, { baseDelayMs: 300 })).toBe(300) + } finally { + randomSpy.mockRestore() + } + }) + + it('uses sane defaults when no options are passed', () => { + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(1) + + try { + expect(reconnectBackoffDelayMs(0)).toBe(300) + expect(reconnectBackoffDelayMs(100)).toBe(15_000) + } finally { + randomSpy.mockRestore() + } + }) +}) diff --git a/ui-desktop/src/lib/reconnect-backoff.ts b/ui-desktop/src/lib/reconnect-backoff.ts new file mode 100644 index 00000000..c24ce9a2 --- /dev/null +++ b/ui-desktop/src/lib/reconnect-backoff.ts @@ -0,0 +1,45 @@ +/** + * Full-jitter exponential backoff for gateway WebSocket reconnects. + * + * A bare exponential backoff still lets every renderer in a fleet retry in + * lockstep — after a gateway restart (e.g. following an update), N desktop + * clients that all disconnected within the same instant all wake up and + * redial at the same instant too, which is a reconnect storm by another + * name. Full jitter (AWS's "Exponential Backoff And Jitter") spreads that + * out: each attempt sleeps a *random* duration between 0 and the exponential + * ceiling, so retries desynchronize instead of pulsing together. + * + * https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/ + */ + +export interface ReconnectBackoffOptions { + /** Ceiling on the exponential delay before jitter is applied, in ms. */ + capMs?: number + /** Delay for the first retry (attempt 0) before jitter is applied, in ms. */ + baseDelayMs?: number +} + +const DEFAULT_BASE_DELAY_MS = 300 +const DEFAULT_CAP_MS = 15_000 + +/** + * Delay before reconnect attempt number `attempt` (0-indexed: the first + * retry after the initial failure is `attempt = 0`). Returns a value in + * `[0, min(capMs, baseDelayMs * 2 ** attempt))` — full jitter, not + * "equal jitter" or "decorrelated jitter", so it can occasionally return a + * very small delay even at a high attempt count. That's intentional: it's + * the variant with the best-documented storm-avoidance behavior and no + * accumulated-delay state to track between calls. + */ +export function reconnectBackoffDelayMs(attempt: number, options: ReconnectBackoffOptions = {}): number { + const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS + const capMs = options.capMs ?? DEFAULT_CAP_MS + const safeAttempt = Math.max(0, attempt) + + // 2 ** attempt overflows to Infinity long before it matters (attempt would + // need to be ~1024), and Math.min against a finite cap keeps the ceiling + // sane regardless, so no extra clamping is needed here. + const ceiling = Math.min(capMs, baseDelayMs * 2 ** safeAttempt) + + return Math.random() * ceiling +} diff --git a/ui-desktop/src/lib/remote-url.test.ts b/ui-desktop/src/lib/remote-url.test.ts new file mode 100644 index 00000000..860f9fe0 --- /dev/null +++ b/ui-desktop/src/lib/remote-url.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' + +import { coerceRemoteUrlScheme } from './remote-url' + +describe('coerceRemoteUrlScheme', () => { + it('prepends http:// to scheme-less host:port input', () => { + expect(coerceRemoteUrlScheme('100.64.0.1:9119')).toBe('http://100.64.0.1:9119') + expect(coerceRemoteUrlScheme('mini.tailnet-1234.ts.net:9119')).toBe('http://mini.tailnet-1234.ts.net:9119') + expect(coerceRemoteUrlScheme('localhost:9119')).toBe('http://localhost:9119') + expect(coerceRemoteUrlScheme('gw.example.com')).toBe('http://gw.example.com') + }) + + it('leaves explicitly schemed URLs alone', () => { + expect(coerceRemoteUrlScheme('http://host:9119')).toBe('http://host:9119') + expect(coerceRemoteUrlScheme('https://gw.example.com/clawcodex')).toBe('https://gw.example.com/clawcodex') + expect(coerceRemoteUrlScheme('ws://host:9119')).toBe('ws://host:9119') + expect(coerceRemoteUrlScheme('ftp://host:21')).toBe('ftp://host:21') + }) + + it('trims and passes through empty input', () => { + expect(coerceRemoteUrlScheme('')).toBe('') + expect(coerceRemoteUrlScheme(' ')).toBe('') + expect(coerceRemoteUrlScheme(' host:9119 ')).toBe('http://host:9119') + }) +}) diff --git a/ui-desktop/src/lib/remote-url.ts b/ui-desktop/src/lib/remote-url.ts new file mode 100644 index 00000000..f45e7665 --- /dev/null +++ b/ui-desktop/src/lib/remote-url.ts @@ -0,0 +1,22 @@ +/** + * remote-url.ts + * + * Renderer-side twin of the scheme coercion in + * electron/connection-config.ts `normalizeRemoteBaseUrl()`. Users routinely + * paste scheme-less "host:port" (a Tailscale IP, a LAN hostname) into the + * remote-gateway URL field; without coercion the renderer's `^https?://` + * probe gates never fire and the field just sits idle with no feedback. + * + * Keep the opt-out regex in sync with the electron side: only a real + * `scheme://` prefix skips the http:// prepend, so explicit non-http schemes + * (ws://, ftp://) still reach main-process validation and get a clear error. + */ +export function coerceRemoteUrlScheme(rawUrl: string): string { + const value = String(rawUrl || '').trim() + + if (!value || /^[a-z][a-z0-9+.-]*:\/\//i.test(value)) { + return value + } + + return `http://${value}` +} diff --git a/ui-desktop/src/lib/render-weight.test.ts b/ui-desktop/src/lib/render-weight.test.ts new file mode 100644 index 00000000..4c036d48 --- /dev/null +++ b/ui-desktop/src/lib/render-weight.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' + +import { messagePaintWeight, messageStoreWeight, RENDER_WEIGHT_CHARS } from './render-weight' + +const bigResult = (chars: number) => ({ + type: 'tool-call', + toolName: 'skill_view', + args: { name: 'clawcodex' }, + result: { content: 'x'.repeat(chars) } +}) + +describe('messageStoreWeight', () => { + it('charges large text and tool results by character cost, not only part count', () => { + const text = [{ type: 'text', text: 'x'.repeat(RENDER_WEIGHT_CHARS * 3) }] + + expect(messageStoreWeight(text)).toBe(4) + expect(messageStoreWeight([bigResult(RENDER_WEIGHT_CHARS * 100)])).toBeGreaterThanOrEqual(101) + }) + + it('prices a 51KB tool output well above a plain exchange', () => { + const heavy = messageStoreWeight([bigResult(51_236)]) + const light = messageStoreWeight([{ type: 'text', text: 'ok' }]) + + expect(heavy).toBeGreaterThan(light * 50) + }) + + it('handles circular tool payloads without recursing forever', () => { + const result: { content: string; self?: unknown } = { content: 'ok' } + result.self = result + + expect(messageStoreWeight([{ type: 'tool-call', result }])).toBe(2) + }) + + it('bounds a single enormous payload', () => { + const enormous = messageStoreWeight([bigResult(RENDER_WEIGHT_CHARS * 10_000)]) + + expect(enormous).toBeLessThanOrEqual(302) + }) +}) + +describe('messagePaintWeight', () => { + it('prices a settled activity row as the one line it renders, not its payload', () => { + const heavy = messagePaintWeight([bigResult(RENDER_WEIGHT_CHARS * 100)]) + + // The whole point: a collapsed tool row costs the same whether it wraps + // 200 bytes or 50KB, because the payload sits behind a closed disclosure. + expect(heavy).toBe(messagePaintWeight([bigResult(200)])) + expect(heavy).toBeLessThan(messageStoreWeight([bigResult(RENDER_WEIGHT_CHARS * 100)])) + }) + + it('charges a reasoning block one collapsed header', () => { + const thought = [{ type: 'reasoning', text: 'x'.repeat(RENDER_WEIGHT_CHARS * 20) }] + + expect(messagePaintWeight(thought)).toBe(1) + }) + + it('charges rendered markdown its real character cost', () => { + const text = [{ type: 'text', text: 'x'.repeat(RENDER_WEIGHT_CHARS * 3) }] + + expect(messagePaintWeight(text)).toBe(4) + }) + + it('charges a diff by size — FileDiffPanel really does mount a row per line', () => { + const diff = Array.from({ length: 400 }, (_, i) => `+line ${i}`).join('\n') + + const patch = messagePaintWeight([ + { type: 'tool-call', toolName: 'patch', args: { path: 'a.ts' }, result: { inline_diff: diff } } + ]) + + expect(patch).toBeGreaterThan(5) + }) + + it('prices an image card flat, however long its data URL', () => { + const card = (chars: number) => [ + { + type: 'tool-call', + toolName: 'image_generate', + args: {}, + result: { image: `data:image/png;base64,${'A'.repeat(chars)}` } + } + ] + + expect(messagePaintWeight(card(10_000_000))).toBe(messagePaintWeight(card(80))) + }) + + it('charges nothing for a row that renders nothing', () => { + const hoisted = [ + { + type: 'tool-call', + toolName: 'todo', + args: { todos: Array.from({ length: 40 }, (_, i) => ({ content: `t${i}` })) } + }, + { type: 'tool-call', toolName: 'react_to_message', args: { emoji: '❤️' } } + ] + + // Floors at 1: a message always occupies at least a row of the transcript. + expect(messagePaintWeight(hoisted)).toBe(1) + }) + + it('keeps a tool-heavy turn far cheaper to paint than to hold', () => { + // The measured shape behind the bad threshold: a dozen collapsed activity + // rows and a little prose. It paints as ~a dozen lines and used to be + // priced as an entire DOM page. + const parts = Array.from({ length: 12 }, () => bigResult(4_000)).concat([ + { type: 'text', text: 'x'.repeat(600) } as unknown as ReturnType<typeof bigResult> + ]) + + expect(messagePaintWeight(parts)).toBeLessThan(messageStoreWeight(parts) / 5) + }) + + it('bounds a message of many enormous parts', () => { + const parts = Array.from({ length: 50 }, () => ({ + type: 'text', + text: 'x'.repeat(RENDER_WEIGHT_CHARS * 500) + })) + + // One ceiling for the whole message — not one per part. + expect(messagePaintWeight(parts)).toBeLessThanOrEqual(350) + }) +}) diff --git a/ui-desktop/src/lib/render-weight.ts b/ui-desktop/src/lib/render-weight.ts new file mode 100644 index 00000000..9626c2e1 --- /dev/null +++ b/ui-desktop/src/lib/render-weight.ts @@ -0,0 +1,218 @@ +import { isCardTool, isFileEditTool, isSilentTool } from '@/lib/tool-render-class' + +/** + * Render cost of one message's content parts, in budget units. + * + * Two layers bound long transcripts and both spend the same currency: the + * store window (how many messages reach assistant-ui at all) and the DOM page + * budget (how many of those actually render). Neither can be a message COUNT — + * counting only parts underpriced a 51KB tool result as "1", so a handful of + * huge results let a 600KB transcript through the old 300-part cap and drove + * Chromium's renderer into a GC crash (#55191). Characters approximate + * markdown parsing, text-node allocation, and tool-result formatting; parts + * approximate component/node count. + * + * The two layers do NOT price a part the same way, because they protect + * different things: + * + * - The STORE window protects the heap. Every message it admits is + * normalized into the runtime repository whether or not the transcript + * collapses it, so it prices the payload it has to hold: `messageStoreWeight`. + * - The DOM budget protects the paint, and what a turn paints is decided by + * the GROUPING, not by the bytes behind it. A settled run of twelve reads + * is one grey summary line, a thought is one collapsed disclosure, a + * `todo` is hoisted out of the transcript entirely, and a generated image + * is one `<img>` however long its data URL. Charging those their payload + * had the budget counting hundreds of units of work that never mounts, so + * "Show earlier" appeared after two or three tool-heavy turns of a session + * that was painting almost nothing: `messagePaintWeight`. + */ + +export const RENDER_WEIGHT_CHARS = 512 + +// Stop traversing once a single message has enough text to consume a complete +// DOM render page. Going further cannot change which whole turn crosses any +// budget, and avoiding an unbounded walk matters for deeply nested tool +// payloads. +const MAX_MEASURED_MESSAGE_CHARS = 300 * RENDER_WEIGHT_CHARS + +const storeWeightCache = new WeakMap<object, number>() +const paintWeightCache = new WeakMap<object, number>() +const NON_RENDERED_CONTENT_FIELDS = new Set(['id', 'role', 'toolCallId', 'toolName', 'type']) + +/** + * What a collapsed row costs the DOM: one line of scaffolding. + * + * A settled tool row is an icon, a title and maybe a count; a settled thought + * is a "Thought for 12s" header. Either one keeps its payload behind a + * disclosure, and an unopened disclosure mounts none of it. History always + * mounts collapsed, and history is exactly what "Show earlier" pages back + * through. + */ +const COLLAPSED_ROW_WEIGHT = 1 + +/** + * What a fixed-size card costs the DOM. + * + * A generated image is one `<img>` whether its result carries a path or a + * multi-megabyte data URL; a clarify question is a prompt and a few buttons; a + * delegation is a header over a one-line ticker. None of them scale with the + * payload, so charging characters priced a single image at more than a whole + * page of real turns. + */ +const CARD_WEIGHT = 6 + +function isRecord(value: unknown): value is Record<string, unknown> { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} + +/** + * Character cost of an arbitrary payload, bounded and cycle-safe. + * + * `budget` is the characters still worth measuring. It is threaded through a + * whole message rather than reset per part, so a message of many huge parts + * cannot walk past the ceiling one part at a time. + */ +function payloadCharacters(roots: readonly unknown[], budget: number): number { + const seen = new WeakSet<object>() + const pending: unknown[] = [...roots] + let characters = 0 + + while (pending.length > 0 && characters < budget) { + const value = pending.pop() + + if (typeof value === 'string') { + characters += Math.min(value.length, budget - characters) + + continue + } + + if (!value || typeof value !== 'object' || seen.has(value)) { + continue + } + + seen.add(value) + + if (Array.isArray(value)) { + for (const nested of value) { + pending.push(nested) + } + + continue + } + + for (const [key, nested] of Object.entries(value)) { + if (!NON_RENDERED_CONTENT_FIELDS.has(key)) { + pending.push(nested) + } + } + } + + return characters +} + +/** Payload price: one unit per part, plus one per 512 characters it carries. */ +function payloadWeight(parts: readonly unknown[], budget: number): number { + return parts.length + Math.ceil(payloadCharacters(parts, budget) / RENDER_WEIGHT_CHARS) +} + +/** + * Estimate the cost of holding one message's content array in the runtime. + * + * A WeakMap keeps settled history O(message count) on later store updates; + * both assistant-ui and the session store publish a new content array when a + * streaming message changes, so the live tail still receives a fresh weight. + */ +export function messageStoreWeight(content: unknown): number { + if (!Array.isArray(content)) { + return 1 + } + + const cached = storeWeightCache.get(content) + + if (cached !== undefined) { + return cached + } + + const weight = Math.max(1, payloadWeight(content, MAX_MEASURED_MESSAGE_CHARS)) + storeWeightCache.set(content, weight) + + return weight +} + +/** + * What one part mounts, priced the way `message-parts.tsx` renders it. + * + * `measure` prices a payload against the message's shared character ceiling — + * only the parts that actually paint their content spend from it. + */ +function partPaintWeight(part: unknown, measure: (parts: readonly unknown[]) => number): number { + if (!isRecord(part)) { + return 1 + } + + // A thought mounts its header; the reasoning text sits behind it. + if (part.type === 'reasoning') { + return COLLAPSED_ROW_WEIGHT + } + + if (part.type !== 'tool-call') { + // Text is markdown the DOM really builds, so it keeps the payload price. + return measure([part]) + } + + const toolName = typeof part.toolName === 'string' ? part.toolName : '' + + if (isSilentTool(toolName)) { + return 0 + } + + if (!isCardTool(toolName)) { + return COLLAPSED_ROW_WEIGHT + } + + // A diff is the one card that scales: `FileDiffPanel` mounts a row per line, + // and a big patch really is the expensive thing in the turn. + return isFileEditTool(toolName) ? measure([part]) : CARD_WEIGHT +} + +/** + * Estimate what one message's content array actually MOUNTS in the transcript. + * + * Cached like the store weight: a settled message keeps its number across + * later store updates, and a streaming one publishes a fresh array per delta + * so the live tail is always re-measured. + */ +export function messagePaintWeight(content: unknown): number { + if (!Array.isArray(content)) { + return 1 + } + + const cached = paintWeightCache.get(content) + + if (cached !== undefined) { + return cached + } + + // One character ceiling for the whole message, not one per part — otherwise a + // message of many huge parts walks past it one part at a time. + let remaining = MAX_MEASURED_MESSAGE_CHARS + + const measure = (parts: readonly unknown[]) => { + const characters = payloadCharacters(parts, remaining) + remaining -= characters + + return parts.length + Math.ceil(characters / RENDER_WEIGHT_CHARS) + } + + let weight = 0 + + for (const part of content) { + weight += partPaintWeight(part, measure) + } + + weight = Math.max(1, weight) + paintWeightCache.set(content, weight) + + return weight +} diff --git a/ui-desktop/src/lib/renderer-loop-pause.ts b/ui-desktop/src/lib/renderer-loop-pause.ts new file mode 100644 index 00000000..583ee094 --- /dev/null +++ b/ui-desktop/src/lib/renderer-loop-pause.ts @@ -0,0 +1,50 @@ +interface WindowStatePayload { + isMinimized?: boolean + isVisible?: boolean +} + +export function createRendererLoopPauseController(onChange: () => void, { pauseWhenUnfocused = true } = {}) { + let windowPaused = false + let windowFocused = document.hasFocus() + + const onVisibilityChange = () => onChange() + + const onBlur = () => { + if (windowFocused) { + windowFocused = false + onChange() + } + } + + const onFocus = () => { + if (!windowFocused) { + windowFocused = true + onChange() + } + } + + const offWindowState = window.clawcodexDesktop?.onWindowStateChanged?.((payload: WindowStatePayload) => { + const next = payload?.isMinimized === true || payload?.isVisible === false + + if (windowPaused === next) { + return + } + + windowPaused = next + onChange() + }) + + document.addEventListener('visibilitychange', onVisibilityChange) + window.addEventListener('blur', onBlur) + window.addEventListener('focus', onFocus) + + return { + dispose: () => { + document.removeEventListener('visibilitychange', onVisibilityChange) + window.removeEventListener('blur', onBlur) + window.removeEventListener('focus', onFocus) + offWindowState?.() + }, + isPaused: () => document.visibilityState === 'hidden' || (pauseWhenUnfocused && !windowFocused) || windowPaused + } +} diff --git a/ui-desktop/src/lib/reorder.ts b/ui-desktop/src/lib/reorder.ts new file mode 100644 index 00000000..6aad349d --- /dev/null +++ b/ui-desktop/src/lib/reorder.ts @@ -0,0 +1,33 @@ +/** + * THE reorder feel — one primitive for every horizontal drag-to-reorder strip + * (profile rail squares, pane tab chips, and whatever comes next). Reorder + * surfaces must read identically: + * + * - the dragged item GLIDES BETWEEN SNAPPED SLOTS (it steps cell-to-cell on + * the snappier drag transition, never floats freely), + * - displaced neighbors spring aside on the slower rail transition, + * - both use the same easeOutBack overshoot, + * - a haptic tick marks each slot crossing, a success pulse the commit. + * + * Consumers differ in machinery (dnd-kit for the profile rail, the layout + * tree's pointer-capture drag for tabs) but share these exact parameters. + */ + +import { triggerHaptic } from '@/lib/haptics' + +/** easeOutBack — a little overshoot so items spring into their slot. */ +export const REORDER_SPRING = 'cubic-bezier(0.34, 1.56, 0.64, 1)' + +/** Displaced neighbors reflow on this (dnd-kit object + CSS string forms). */ +export const REORDER_RAIL_DURATION_MS = 300 +export const REORDER_RAIL_TRANSITION = { duration: REORDER_RAIL_DURATION_MS, easing: REORDER_SPRING } +export const REORDER_RAIL_TRANSITION_CSS = `transform ${REORDER_RAIL_DURATION_MS}ms ${REORDER_SPRING}` + +/** The dragged item glides between snapped slots on this (snappier). */ +export const REORDER_DRAG_TRANSITION_CSS = `transform 200ms ${REORDER_SPRING}` + +/** Tick each time the drag crosses into a new slot. */ +export const reorderStepHaptic = () => triggerHaptic('selection') + +/** Satisfying confirm on a committed reorder. */ +export const reorderCommitHaptic = () => triggerHaptic('success') diff --git a/ui-desktop/src/lib/runtime-readiness.test.ts b/ui-desktop/src/lib/runtime-readiness.test.ts new file mode 100644 index 00000000..87c9e6d2 --- /dev/null +++ b/ui-desktop/src/lib/runtime-readiness.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' + +import { evaluateRuntimeReadiness, fetchRuntimeReadinessSignals, interpretRuntimeReadiness } from './runtime-readiness' + +describe('interpretRuntimeReadiness', () => { + it('prefers runtime_check when both signals exist', () => { + const result = interpretRuntimeReadiness({ + setup: { provider_configured: false }, + setupError: null, + runtime: { ok: true }, + runtimeError: null + }) + + expect(result).toEqual({ + checksDisagree: true, + ready: true, + reason: null, + source: 'runtime_check' + }) + }) + + it('surfaces runtime mismatch details when runtime_check fails', () => { + const result = interpretRuntimeReadiness({ + setup: { provider_configured: true }, + setupError: null, + runtime: { error: 'No provider can serve the selected model.', ok: false }, + runtimeError: null + }) + + expect(result.ready).toBe(false) + expect(result.source).toBe('runtime_check') + expect(result.checksDisagree).toBe(true) + expect(result.reason).toContain('No provider can serve the selected model.') + expect(result.reason).toContain('setup.status reports configured credentials') + }) + + it('falls back to setup.status when runtime_check has no boolean result', () => { + const result = interpretRuntimeReadiness({ + setup: { provider_configured: true }, + setupError: null, + runtime: null, + runtimeError: 'runtime check RPC unavailable' + }) + + expect(result).toEqual({ + checksDisagree: false, + ready: true, + reason: null, + source: 'setup_status' + }) + }) + + it('uses explicit fallback when both checks are missing', () => { + const result = interpretRuntimeReadiness({ + setup: null, + setupError: 'setup.status timeout', + runtime: null, + runtimeError: 'setup.runtime_check timeout' + }) + + expect(result.ready).toBe(false) + expect(result.source).toBe('fallback') + expect(result.reason).toBe('setup.runtime_check timeout') + }) +}) + +describe('fetchRuntimeReadinessSignals', () => { + it('scopes setup.runtime_check to the requested provider', async () => { + const calls: Array<{ method: string; params?: Record<string, unknown> }> = [] + + const requestGateway = async <T = unknown>(method: string, params?: Record<string, unknown>) => { + calls.push({ method, params }) + + if (method === 'setup.status') { + return { provider_configured: true } as T + } + + if (method === 'setup.runtime_check') { + return { ok: true } as T + } + + throw new Error(`unexpected method: ${method}`) + } + + await fetchRuntimeReadinessSignals(requestGateway, 'nous') + + expect(calls).toEqual([{ method: 'setup.status' }, { method: 'setup.runtime_check', params: { provider: 'nous' } }]) + }) +}) + +describe('evaluateRuntimeReadiness', () => { + it('forwards requestedProvider to setup.runtime_check', async () => { + const requestGateway = async <T = unknown>(method: string, params?: Record<string, unknown>) => { + if (method === 'setup.status') { + return { provider_configured: true } as T + } + + if (method === 'setup.runtime_check') { + expect(params).toEqual({ provider: 'nous' }) + + return { ok: true } as T + } + + throw new Error(`unexpected method: ${method}`) + } + + const result = await evaluateRuntimeReadiness(requestGateway, { requestedProvider: 'nous' }) + + expect(result.ready).toBe(true) + }) +}) diff --git a/ui-desktop/src/lib/runtime-readiness.ts b/ui-desktop/src/lib/runtime-readiness.ts new file mode 100644 index 00000000..8473fc82 --- /dev/null +++ b/ui-desktop/src/lib/runtime-readiness.ts @@ -0,0 +1,152 @@ +export interface SetupStatusSnapshot { + provider_configured?: boolean +} + +export interface RuntimeCheckSnapshot { + error?: string + ok?: boolean +} + +export interface RuntimeReadinessSignals { + setup: null | SetupStatusSnapshot + setupError: null | string + runtime: null | RuntimeCheckSnapshot + runtimeError: null | string +} + +export interface RuntimeReadinessOptions { + defaultReason?: string + requestedProvider?: string + unknownReady?: boolean +} + +export interface RuntimeReadinessResult { + checksDisagree: boolean + ready: boolean + reason: null | string + source: 'fallback' | 'runtime_check' | 'setup_status' +} + +export type RuntimeReadinessRequester = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T> + +const DEFAULT_NOT_READY_REASON = 'Add a provider credential before sending your first message.' + +function toErrorMessage(error: unknown): null | string { + if (error instanceof Error) { + return error.message + } + + if (typeof error === 'string') { + return error + } + + if (error === null || error === undefined) { + return null + } + + return String(error) +} + +function normalizeMessage(value: null | string | undefined): null | string { + const next = value?.trim() + + return next ? next : null +} + +async function requestWithFallback<T>( + requestGateway: RuntimeReadinessRequester, + method: string, + params?: Record<string, unknown> +): Promise<{ error: null | string; value: null | T }> { + try { + return { error: null, value: await requestGateway<T>(method, params) } + } catch (error) { + return { error: toErrorMessage(error), value: null } + } +} + +export async function fetchRuntimeReadinessSignals( + requestGateway: RuntimeReadinessRequester, + requestedProvider?: string +): Promise<RuntimeReadinessSignals> { + const runtimeParams = requestedProvider?.trim() ? { provider: requestedProvider.trim() } : undefined + + const [setup, runtime] = await Promise.all([ + requestWithFallback<SetupStatusSnapshot>(requestGateway, 'setup.status'), + requestWithFallback<RuntimeCheckSnapshot>(requestGateway, 'setup.runtime_check', runtimeParams) + ]) + + return { + setup: setup.value, + setupError: setup.error, + runtime: runtime.value, + runtimeError: runtime.error + } +} + +export function interpretRuntimeReadiness( + signals: RuntimeReadinessSignals, + options: RuntimeReadinessOptions = {} +): RuntimeReadinessResult { + const defaultReason = options.defaultReason ?? DEFAULT_NOT_READY_REASON + const unknownReady = options.unknownReady ?? false + + const setupConfigured = + typeof signals.setup?.provider_configured === 'boolean' ? Boolean(signals.setup.provider_configured) : undefined + + const runtimeOk = typeof signals.runtime?.ok === 'boolean' ? Boolean(signals.runtime.ok) : undefined + const runtimeFailure = normalizeMessage(signals.runtime?.error) ?? normalizeMessage(signals.runtimeError) + const setupFailure = normalizeMessage(signals.setupError) + + const checksDisagree = + typeof setupConfigured === 'boolean' && typeof runtimeOk === 'boolean' && setupConfigured !== runtimeOk + + if (typeof runtimeOk === 'boolean') { + if (runtimeOk) { + return { + checksDisagree, + ready: true, + reason: null, + source: 'runtime_check' + } + } + + let reason = runtimeFailure ?? defaultReason + + if (checksDisagree && setupConfigured) { + reason = `${reason} setup.status reports configured credentials, but runtime resolution still failed.` + } + + return { + checksDisagree, + ready: false, + reason, + source: 'runtime_check' + } + } + + if (typeof setupConfigured === 'boolean') { + return { + checksDisagree: false, + ready: setupConfigured, + reason: setupConfigured ? null : (runtimeFailure ?? setupFailure ?? defaultReason), + source: 'setup_status' + } + } + + return { + checksDisagree: false, + ready: unknownReady, + reason: unknownReady ? null : (runtimeFailure ?? setupFailure ?? defaultReason), + source: 'fallback' + } +} + +export async function evaluateRuntimeReadiness( + requestGateway: RuntimeReadinessRequester, + options: RuntimeReadinessOptions = {} +): Promise<RuntimeReadinessResult> { + const signals = await fetchRuntimeReadinessSignals(requestGateway, options.requestedProvider) + + return interpretRuntimeReadiness(signals, options) +} diff --git a/ui-desktop/src/lib/sanitize.test.ts b/ui-desktop/src/lib/sanitize.test.ts new file mode 100644 index 00000000..91147d85 --- /dev/null +++ b/ui-desktop/src/lib/sanitize.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' + +import { gitRef, slug } from './sanitize' + +describe('gitRef', () => { + it('turns spaces into hyphens and keeps slashes', () => { + expect(gitRef('beach vibes')).toBe('beach-vibes') + expect(gitRef('feat/cool thing')).toBe('feat/cool-thing') + }) + + it('drops chars git refs forbid and collapses separators', () => { + expect(gitRef('wip~^:?*[]')).toBe('wip') + expect(gitRef('a b///c..d')).toBe('a-b/c.d') + }) + + it('strips a leading separator but stays typeable (keeps a trailing one)', () => { + expect(gitRef('/foo')).toBe('foo') + expect(gitRef('feat/')).toBe('feat/') + }) +}) + +describe('slug', () => { + it('lowercases and kebabs runs of non-alphanumerics', () => { + expect(slug('My Profile')).toBe('my-profile') + expect(slug('a__b c')).toBe('a-b-c') + }) + + it('strips a leading separator but keeps a trailing one while typing', () => { + expect(slug('--x')).toBe('x') + expect(slug('work ')).toBe('work-') + }) +}) diff --git a/ui-desktop/src/lib/sanitize.ts b/ui-desktop/src/lib/sanitize.ts new file mode 100644 index 00000000..2417723a --- /dev/null +++ b/ui-desktop/src/lib/sanitize.ts @@ -0,0 +1,21 @@ +// Format enforcers for identifier-style inputs, applied live (per keystroke) via +// <SanitizedInput>. They're intentionally lenient on a trailing separator so a +// value stays typeable (e.g. "feat/" then keep going); the final trim happens on +// submit / in the backend. + +/** A git-ref-safe branch name: spaces → "-", drop chars git forbids, keep "/". */ +export const gitRef = (raw: string): string => + raw + .replace(/\s+/g, '-') + .replace(/[^\w./-]/g, '') // \w = [A-Za-z0-9_] + .replace(/-{2,}/g, '-') + .replace(/\/{2,}/g, '/') + .replace(/\.{2,}/g, '.') + .replace(/^[-./]+/, '') + +/** A kebab slug: lowercase, runs of non-alphanumerics → a single "-". */ +export const slug = (raw: string): string => + raw + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+/, '') diff --git a/ui-desktop/src/lib/selectable-card.ts b/ui-desktop/src/lib/selectable-card.ts new file mode 100644 index 00000000..617898b7 --- /dev/null +++ b/ui-desktop/src/lib/selectable-card.ts @@ -0,0 +1,31 @@ +import { cn } from '@/lib/utils' + +export interface SelectableCardState { + /** Currently selected / active — the strongest emphasis. */ + active?: boolean + /** + * Configured / installed / "you have this" — solid surface + border. When + * false the card renders muted (transparent, dimmed) until hovered, so the + * eye lands on what you already have. Ignored when `active` is set. + */ + prominent?: boolean +} + +/** + * Shared emphasis for selectable list cards across settings surfaces (theme + * picker, pet picker, Marketplace results, provider rows…). Three tiers: + * active > prominent > muted. Keeps the "installed = solid, not-installed = + * quiet" pattern consistent everywhere instead of each picker rolling its own. + * + * Callers own layout (padding, flex, width); this owns only border + surface. + */ +export function selectableCardClass({ active, prominent }: SelectableCardState): string { + return cn( + 'rounded-lg border transition-colors', + active + ? 'border-primary bg-primary/[0.06] ring-2 ring-primary/20' + : prominent + ? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) hover:bg-(--chrome-action-hover)' + : 'border-transparent bg-transparent text-(--ui-text-tertiary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-bg-quinary)' + ) +} diff --git a/ui-desktop/src/lib/session-branch-tree.test.ts b/ui-desktop/src/lib/session-branch-tree.test.ts new file mode 100644 index 00000000..12f4690c --- /dev/null +++ b/ui-desktop/src/lib/session-branch-tree.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' + +import type { SessionInfo } from '@/types/clawcodex' + +import { flattenSessionsWithBranches } from './session-branch-tree' + +const session = (id: string, overrides: Partial<SessionInfo> = {}): SessionInfo => + ({ + ended_at: null, + id, + input_tokens: 0, + is_active: false, + last_active: 0, + message_count: 1, + model: null, + output_tokens: 0, + preview: null, + source: 'cli', + started_at: 0, + title: id, + tool_call_count: 0, + ...overrides + }) as SessionInfo + +describe('flattenSessionsWithBranches', () => { + it('nests branch rows under their parent with tree stems', () => { + const parent = session('parent', { last_active: 20 }) + const branchA = session('branch-a', { last_active: 15, parent_session_id: 'parent' }) + const branchB = session('branch-b', { last_active: 10, parent_session_id: 'parent' }) + + expect(flattenSessionsWithBranches([parent, branchA, branchB])).toEqual([ + { session: parent }, + { branchStem: '├─ ', session: branchA }, + { branchStem: '└─ ', session: branchB } + ]) + }) + + it('follows a compressed parent via lineage root id', () => { + const tip = session('tip', { _lineage_root_id: 'root', last_active: 30 }) + const branch = session('branch', { parent_session_id: 'root', last_active: 10 }) + + expect(flattenSessionsWithBranches([tip, branch])).toEqual([ + { session: tip }, + { branchStem: '└─ ', session: branch } + ]) + }) + + it('keeps orphan branches at the top level when the parent is missing', () => { + const branch = session('branch', { parent_session_id: 'missing' }) + + expect(flattenSessionsWithBranches([branch])).toEqual([{ session: branch }]) + }) + + it('re-sorts roots by group recency by default (pinned-style jumps without preserveOrder)', () => { + // Stale important chat first in the caller's array; a recently-active + // background task second. Default path must lift the fresher root — that + // is what was scrambling the Pinned section before preserveOrder. + const important = session('important', { last_active: 10 }) + const background = session('background', { last_active: 99 }) + + expect(flattenSessionsWithBranches([important, background]).map(e => e.session.id)).toEqual([ + 'background', + 'important' + ]) + }) + + it("preserveOrder keeps the caller's root order even when activity is newer lower down", () => { + const important = session('important', { last_active: 10 }) + const background = session('background', { last_active: 99 }) + const branch = session('branch', { last_active: 50, parent_session_id: 'important' }) + + expect( + flattenSessionsWithBranches([important, background, branch], { preserveOrder: true }).map(e => ({ + id: e.session.id, + stem: e.branchStem + })) + ).toEqual([ + { id: 'important', stem: undefined }, + { id: 'branch', stem: '└─ ' }, + { id: 'background', stem: undefined } + ]) + }) +}) diff --git a/ui-desktop/src/lib/session-branch-tree.ts b/ui-desktop/src/lib/session-branch-tree.ts new file mode 100644 index 00000000..c4016d28 --- /dev/null +++ b/ui-desktop/src/lib/session-branch-tree.ts @@ -0,0 +1,124 @@ +import type { SessionInfo } from '@/types/clawcodex' + +export interface SidebarSessionEntry { + branchStem?: string + session: SessionInfo +} + +export interface FlattenSessionsOptions { + /** + * Keep the input root order instead of re-sorting by group recency. + * Use for hand-ordered surfaces (pinned ids, manual recents drag) so a + * turn completing can't float a row. Branch children still nest under + * their parent; sibling branches stay ordered by their own recency. + */ + preserveOrder?: boolean +} + +const recency = (session: SessionInfo): number => session.last_active || session.started_at || 0 + +/** Flat list with branch/fork sessions nested visually under their parent. */ +export function flattenSessionsWithBranches( + sessions: readonly SessionInfo[], + options: FlattenSessionsOptions = {} +): SidebarSessionEntry[] { + if (sessions.length < 2) { + return sessions.map(session => ({ session })) + } + + const byVisibleId = new Map<string, SessionInfo>() + + for (const session of sessions) { + byVisibleId.set(session.id, session) + const rootId = session._lineage_root_id?.trim() + + if (rootId) { + byVisibleId.set(rootId, session) + } + } + + const childrenByParent = new Map<string, SessionInfo[]>() + const nestedIds = new Set<string>() + + for (const session of sessions) { + const parentId = session.parent_session_id?.trim() + + if (!parentId) { + continue + } + + const parent = byVisibleId.get(parentId) + + if (!parent || parent.id === session.id) { + continue + } + + nestedIds.add(session.id) + const siblings = childrenByParent.get(parent.id) ?? [] + siblings.push(session) + childrenByParent.set(parent.id, siblings) + } + + for (const siblings of childrenByParent.values()) { + siblings.sort((left, right) => recency(right) - recency(left)) + } + + // A group sorts by its freshest member, so activity on any branch lifts the + // whole parent→branches cluster together instead of stranding the parent at + // its own stale timestamp. Memoized — each subtree is folded at most once. + // Skipped when preserveOrder is set: the caller already chose positions. + const groupRecencyMemo = new Map<string, number>() + + const groupRecency = (session: SessionInfo): number => { + const cached = groupRecencyMemo.get(session.id) + + if (cached !== undefined) { + return cached + } + + groupRecencyMemo.set(session.id, recency(session)) // cycle guard + + const max = (childrenByParent.get(session.id) ?? []).reduce( + (acc, child) => Math.max(acc, groupRecency(child)), + recency(session) + ) + + groupRecencyMemo.set(session.id, max) + + return max + } + + // Depth-first so a branch-of-a-branch still renders under its own parent. The + // `seen` set guards against pathological parent cycles, and the trailing sweep + // emits anything the walk somehow missed — nothing in the input is ever dropped. + const out: SidebarSessionEntry[] = [] + const seen = new Set<string>() + + const emit = (session: SessionInfo, branchStem?: string) => { + if (seen.has(session.id)) { + return + } + + seen.add(session.id) + out.push(branchStem ? { branchStem, session } : { session }) + + const children = childrenByParent.get(session.id) + children?.forEach((child, index) => emit(child, index === children.length - 1 ? '└─ ' : '├─ ')) + } + + const roots = sessions.filter(session => !nestedIds.has(session.id)).map((session, index) => ({ index, session })) + + if (!options.preserveOrder) { + roots.sort((a, b) => groupRecency(b.session) - groupRecency(a.session) || a.index - b.index) + } + + roots.forEach(({ session }) => emit(session)) + + for (const session of sessions) { + if (!seen.has(session.id)) { + out.push({ session }) + } + } + + return out +} diff --git a/ui-desktop/src/lib/session-date-groups.test.ts b/ui-desktop/src/lib/session-date-groups.test.ts new file mode 100644 index 00000000..27c86637 --- /dev/null +++ b/ui-desktop/src/lib/session-date-groups.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from 'vitest' + +import type { SessionInfo } from '@/types/clawcodex' + +import type { SidebarSessionEntry } from './session-branch-tree' +import { groupEntriesByRecency, toSessionRows } from './session-date-groups' + +const session = (id: string, overrides: Partial<SessionInfo> = {}): SessionInfo => + ({ + ended_at: null, + id, + input_tokens: 0, + is_active: false, + last_active: 0, + message_count: 1, + model: null, + output_tokens: 0, + preview: null, + source: 'cli', + started_at: 0, + title: id, + tool_call_count: 0, + ...overrides + }) as SessionInfo + +const entry = (s: SessionInfo, branchStem?: string): SidebarSessionEntry => + branchStem ? { branchStem, session: s } : { session: s } + +// Fixed "now": Thursday 18 Jun 2026, local noon (15 Jun 2026 is a Monday). +// All tests pin a Monday week start so calendar boundaries are deterministic. +const NOW = new Date(2026, 5, 18, 12, 0, 0).getTime() +const MONDAY = 1 + +const at = (year: number, month: number, day: number, hour = 10, minute = 0): number => + Math.floor(new Date(year, month, day, hour, minute, 0).getTime() / 1000) + +const group = (entries: SidebarSessionEntry[], nowMs = NOW) => groupEntriesByRecency(entries, nowMs, MONDAY) + +const dividerKeys = (rows: ReturnType<typeof groupEntriesByRecency>): string[] => + rows.flatMap(row => (row.kind === 'divider' ? [row.key] : [])) + +describe('groupEntriesByRecency', () => { + it('cuts the head after the most recent handful, then divides by coarse ranges', () => { + // The morning run (30m/30m/4h/30m gaps, then a 14h silence) is the + // unlabelled head; each older group gets one divider, coarsening with age. + const rows = group([ + entry(session('a', { last_active: at(2026, 5, 18, 11) })), + entry(session('b', { last_active: at(2026, 5, 18, 10, 30) })), + entry(session('c', { last_active: at(2026, 5, 18, 10) })), + entry(session('d', { last_active: at(2026, 5, 18, 6) })), + entry(session('e', { last_active: at(2026, 5, 18, 5, 30) })), + entry(session('f', { last_active: at(2026, 5, 17, 15) })), // yesterday + entry(session('g', { last_active: at(2026, 5, 16, 15) })), // Tue this week + entry(session('h', { last_active: at(2026, 5, 14) })), // Sun last week + entry(session('i', { last_active: at(2026, 5, 3) })), // earlier in June + entry(session('j', { last_active: at(2026, 4, 28) })), // May + entry(session('k', { last_active: at(2025, 11, 3) })) // December 2025 + ]) + + expect(rows.slice(0, 5).every(row => row.kind === 'session')).toBe(true) + expect(rows[5]).toMatchObject({ key: 'yesterday', kind: 'divider' }) + expect(dividerKeys(rows)).toEqual(['yesterday', 'this-week', 'last-week', 'this-month', 'm-2026-4', 'my-2025-11']) + }) + + it('labels the rest of the current day "earlier today" past a real break', () => { + // Five rapid-fire sessions, a ~4h pause, then more of the same day. + const rows = group([ + entry(session('a', { last_active: at(2026, 5, 18, 11) })), + entry(session('b', { last_active: at(2026, 5, 18, 10, 58) })), + entry(session('c', { last_active: at(2026, 5, 18, 10, 56) })), + entry(session('d', { last_active: at(2026, 5, 18, 10, 54) })), + entry(session('e', { last_active: at(2026, 5, 18, 10, 52) })), + entry(session('f', { last_active: at(2026, 5, 18, 7) })), + entry(session('g', { last_active: at(2026, 5, 18, 6, 58) })) + ]) + + expect(rows.findIndex(row => row.kind === 'divider')).toBe(5) + expect(dividerKeys(rows)).toEqual(['today']) + }) + + it('never slices a rapid-fire burst mid-run', () => { + // Eleven sessions two minutes apart: no gap qualifies as a break, so the + // whole burst stays in the head and the divider lands after it. + const burst = Array.from({ length: 11 }, (_, i) => + entry(session(`s${i}`, { last_active: at(2026, 5, 18, 11) - i * 120 })) + ) + + const rows = group([...burst, entry(session('old', { last_active: at(2026, 5, 17, 15) }))]) + + expect(rows.findIndex(row => row.kind === 'divider')).toBe(11) + expect(dividerKeys(rows)).toEqual(['yesterday']) + }) + + it('chains the head run across midnight', () => { + // Viewed at 00:58: tonight plus last evening is one run; yesterday's + // afternoon (a different nominal day) opens the labelled groups. + const smallHours = new Date(2026, 5, 19, 0, 58).getTime() + + const rows = group( + [ + entry(session('a', { last_active: at(2026, 5, 19, 0, 30) })), + entry(session('b', { last_active: at(2026, 5, 18, 23, 50) })), + entry(session('c', { last_active: at(2026, 5, 18, 23, 20) })), + entry(session('d', { last_active: at(2026, 5, 17, 20) })) + ], + smallHours + ) + + expect(rows.findIndex(row => row.kind === 'divider')).toBe(3) + expect(dividerKeys(rows)).toEqual(['yesterday']) + }) + + it('dissolves a stale head into its own calendar group (fuzzy merge)', () => { + // Newest session is 6 days old and the rows below it share its "last week" + // bucket: cutting there would strand near-identical neighbours around a + // divider, so no head is kept and the whole bucket leads unlabelled. + const rows = group([ + entry(session('a', { last_active: at(2026, 5, 12) })), + entry(session('b', { last_active: at(2026, 5, 11, 15) })), + entry(session('c', { last_active: at(2026, 5, 11, 10) })), + entry(session('d', { last_active: at(2026, 5, 3) })), + entry(session('e', { last_active: at(2026, 4, 20) })) + ]) + + expect(rows.slice(0, 3).every(row => row.kind === 'session')).toBe(true) + expect(dividerKeys(rows)).toEqual(['this-month', 'm-2026-4']) + }) + + it('keeps an isolated newest session as the head when its bucket differs', () => { + const rows = group([ + entry(session('a', { last_active: at(2026, 5, 18, 9) })), + entry(session('b', { last_active: at(2026, 5, 17, 15) })), + entry(session('c', { last_active: at(2026, 5, 3) })) + ]) + + expect(rows.findIndex(row => row.kind === 'divider')).toBe(1) + expect(dividerKeys(rows)).toEqual(['yesterday', 'this-month']) + }) + + it('emits no dividers when everything is one unbroken run', () => { + const rows = group([ + entry(session('a', { last_active: at(2026, 5, 18, 11) })), + entry(session('b', { last_active: at(2026, 5, 18, 10, 50) })), + entry(session('c', { last_active: at(2026, 5, 18, 10, 40) })) + ]) + + expect(rows.every(row => row.kind === 'session')).toBe(true) + }) + + it('collapses a big gap straight to the next month/year (empty ranges omitted)', () => { + const rows = group([ + entry(session('t', { last_active: at(2026, 5, 18, 11) })), + entry(session('t2', { last_active: at(2026, 5, 18, 10, 30) })), + entry(session('j1', { last_active: at(2026, 0, 5) })), + entry(session('j2', { last_active: at(2026, 0, 3) })), + entry(session('old', { last_active: at(2024, 2, 9) })) + ]) + + expect(dividerKeys(rows)).toEqual(['m-2026-0', 'my-2024-2']) + }) + + it('never labels the first rendered group, even when it is not recent', () => { + // Newest session is weeks old and alone in its month: it opens the list + // unlabelled; only the transitions below it are marked. + const rows = group([ + entry(session('a', { last_active: at(2026, 4, 20) })), + entry(session('b', { last_active: at(2026, 2, 3) })), + entry(session('c', { last_active: at(2025, 11, 3) })) + ]) + + expect(rows[0]).toMatchObject({ kind: 'session' }) + expect(dividerKeys(rows)).toEqual(['m-2026-2', 'my-2025-11']) + }) + + it('keeps branch children in their parent cluster without opening a new bucket', () => { + const parent = session('parent', { last_active: at(2026, 5, 18, 11) }) + const child = session('child', { last_active: at(2024, 0, 1), parent_session_id: 'parent' }) + + const rows = group([entry(parent), entry(child, '└─ ')]) + + expect(rows).toEqual([ + { entry: entry(parent), kind: 'session' }, + { entry: entry(child, '└─ '), kind: 'session' } + ]) + }) + + it('never emits a divider twice under a non-monotonic order', () => { + const rows = group([ + entry(session('a', { last_active: at(2026, 5, 16) })), // head run + entry(session('b', { last_active: at(2026, 4, 5) })), // May — divider + entry(session('c', { last_active: at(2026, 5, 16) })) // head again — no repeat + ]) + + expect(dividerKeys(rows)).toEqual(['m-2026-4']) + }) + + it('falls back to started_at when last_active is missing', () => { + const rows = group([ + entry(session('head', { last_active: at(2026, 5, 18, 11) })), + entry(session('s', { last_active: 0, started_at: at(2026, 5, 10) })) + ]) + + expect(dividerKeys(rows)).toEqual(['last-week']) + }) +}) + +describe('toSessionRows', () => { + it('wraps entries as session rows with no dividers', () => { + const entries = [entry(session('a')), entry(session('b'), '└─ ')] + + expect(toSessionRows(entries)).toEqual([ + { entry: entries[0], kind: 'session' }, + { entry: entries[1], kind: 'session' } + ]) + }) +}) diff --git a/ui-desktop/src/lib/session-date-groups.ts b/ui-desktop/src/lib/session-date-groups.ts new file mode 100644 index 00000000..776186cf --- /dev/null +++ b/ui-desktop/src/lib/session-date-groups.ts @@ -0,0 +1,150 @@ +import { type SidebarSessionEntry } from '@/lib/session-branch-tree' +import { calendarBucket, HOUR, localeWeekStartDay, MINUTE, SECOND, type SessionBucket } from '@/lib/time' + +// A flat list row is either a chronological date-bucket divider or a session +// entry. Interleaving these lets the flat list (and the virtualizer) render +// date separators inline without a second layer of nesting. +export type SidebarListRow = + { bucket: SessionBucket; key: string; kind: 'divider' } | { entry: SidebarSessionEntry; kind: 'session' } + +// The row's own age label reads from `last_active || started_at`; bucket off the +// same value so a divider lines up with what the row actually shows. +const recencyMs = (entry: SidebarSessionEntry): number => + (entry.session.last_active || entry.session.started_at || 0) * SECOND + +// Aim the head at "the most recent handful". A break shorter than +// MIN_RUN_BREAK_MS never counts as one — that would slice a rapid-fire burst — +// and a silence longer than MAX_RUN_GAP_MS always ends the run: without that +// bound a sparse list (a project lane) would chain weeks of stale sessions +// into one giant "recent" head. +const TARGET_HEAD_SESSIONS = 5 +const MIN_RUN_BREAK_MS = 30 * MINUTE +const MAX_RUN_GAP_MS = 8 * HOUR + +// The unlabelled head is the newest run of sessions, cut at a *real* break in +// activity. Candidate cut points are every gap of at least MIN_RUN_BREAK_MS +// inside the contiguous run (gaps ≤ MAX_RUN_GAP_MS), plus the run's own end; +// among them we pick the one whose head size lands closest (log-scale) to +// TARGET_HEAD_SESSIONS. So the first divider shows up after roughly the most +// recent five sessions — but only ever at a genuine pause, never mid-burst: a +// truly unbroken run stays whole, and an isolated newest session stands alone. +// Runs chain naturally across midnight. +// +// Fuzzy-merge rule: when the cut falls at the run's end and the sessions just +// below it share the head's calendar bucket, the head adds nothing — it's just +// the top of that group. Dissolve it (the first-group rule keeps the top +// unlabelled anyway) so a divider never strands near-identical neighbours, +// e.g. a lone 6-day-old session above a "Last week" label. +// +// Returns the oldest timestamp (ms) still inside the head; -Infinity means the +// whole list is one run, +Infinity means no head (calendar groups own it all). +function headRunCutoffMs(entries: readonly SidebarSessionEntry[], nowMs: number, weekStartsOn: number): number { + const times = entries + .filter(entry => !entry.branchStem) + .map(recencyMs) + .sort((a, b) => b - a) + + let bestIdx = -1 + let bestScore = Number.POSITIVE_INFINITY + let runEnded = false + + for (let i = 1; i < times.length; i++) { + const gap = times[i - 1] - times[i] + const endsRun = gap > MAX_RUN_GAP_MS + + if (gap >= MIN_RUN_BREAK_MS || endsRun) { + // `i` sessions would sit above a cut at this gap. + const score = Math.abs(Math.log(i / TARGET_HEAD_SESSIONS)) + + if (score < bestScore) { + bestScore = score + bestIdx = i + runEnded = endsRun + } + } + + if (endsRun) { + break + } + } + + if (bestIdx === -1) { + return Number.NEGATIVE_INFINITY + } + + if (runEnded) { + const headBucket = calendarBucket(times[0] / SECOND, nowMs, weekStartsOn) + const belowBucket = calendarBucket(times[bestIdx] / SECOND, nowMs, weekStartsOn) + + if (headBucket.key === belowBucket.key) { + return Number.POSITIVE_INFINITY + } + } + + return times[bestIdx - 1] +} + +// Insert a date divider before each labelled group. The unlabelled head is the +// newest run of sessions (see headRunCutoffMs); below it, groups are coarse +// calendar ranges — earlier today → yesterday → earlier this week → last week +// → earlier this month → month → month + year — one divider per range, never +// one per day. Whatever group happens to render first is also never labelled. +// Branch children inherit their parent cluster's group and never trigger a +// divider, so a parent→branches block never splits. +export function groupEntriesByRecency( + entries: readonly SidebarSessionEntry[], + nowMs = Date.now(), + weekStartsOn = localeWeekStartDay() +): SidebarListRow[] { + const rows: SidebarListRow[] = [] + const emitted = new Set<string>() + const cutoff = headRunCutoffMs(entries, nowMs, weekStartsOn) + let lastKey: null | string = null + + for (const entry of entries) { + // Nested branch rows travel with their parent cluster; they never open a new + // bucket or move the divider cursor. + if (entry.branchStem) { + rows.push({ entry, kind: 'session' }) + + continue + } + + const ms = recencyMs(entry) + + // Head-run sessions are never labelled. + if (ms >= cutoff) { + rows.push({ entry, kind: 'session' }) + lastKey = '__recent__' + + continue + } + + const bucket = calendarBucket(ms / SECOND, nowMs, weekStartsOn) + + if (bucket.key !== lastKey) { + lastKey = bucket.key + const alreadyEmitted = emitted.has(bucket.key) + + // Mark it emitted even when skipped so a non-monotonic order (possible + // inside a project lane) can't later re-label it or collide React keys. + emitted.add(bucket.key) + + // A divider only ever separates two groups — never label the very first + // rendered row, whatever group it belongs to. + if (rows.length > 0 && !alreadyEmitted) { + rows.push({ bucket, key: bucket.key, kind: 'divider' }) + } + } + + rows.push({ entry, kind: 'session' }) + } + + return rows +} + +// Wrap entries as plain session rows (no dividers) so the ungrouped path shares +// the same `SidebarListRow[]` shape as the grouped one. +export function toSessionRows(entries: readonly SidebarSessionEntry[]): SidebarListRow[] { + return entries.map(entry => ({ entry, kind: 'session' })) +} diff --git a/ui-desktop/src/lib/session-export.ts b/ui-desktop/src/lib/session-export.ts new file mode 100644 index 00000000..48e05e36 --- /dev/null +++ b/ui-desktop/src/lib/session-export.ts @@ -0,0 +1,59 @@ +import type { SessionInfo } from '@/clawcodex' +import { getSessionMessages } from '@/clawcodex' +import { translateNow } from '@/i18n' +import { notify, notifyError } from '@/store/notifications' + +interface ExportSessionParams { + sessionId: string + profile?: string | null + title?: string | null + session?: SessionInfo +} + +function sanitizeFilenamePart(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48) +} + +function sessionExportFilename(sessionId: string, title?: string | null) { + const titlePart = title ? sanitizeFilenamePart(title) : '' + const idPart = sanitizeFilenamePart(sessionId).slice(0, 8) || 'session' + + return `${titlePart || 'session'}-${idPart}.json` +} + +export async function exportSession(sessionId: string, params: Omit<ExportSessionParams, 'sessionId'> = {}) { + if (!sessionId) { + return + } + + try { + const profile = params.profile ?? params.session?.profile + const { messages } = await getSessionMessages(sessionId, profile) + + const payload = { + exported_at: new Date().toISOString(), + session_id: sessionId, + title: params.title ?? null, + session: params.session ?? null, + message_count: messages.length, + messages + } + + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }) + const downloadUrl = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = downloadUrl + anchor.download = sessionExportFilename(sessionId, params.title) + anchor.click() + URL.revokeObjectURL(downloadUrl) + + notify({ kind: 'success', message: translateNow('desktop.sessionExported'), durationMs: 2_000 }) + } catch (err) { + notifyError(err, translateNow('desktop.sessionExportFailed')) + } +} diff --git a/ui-desktop/src/lib/session-ids.test.ts b/ui-desktop/src/lib/session-ids.test.ts new file mode 100644 index 00000000..b5653c8e --- /dev/null +++ b/ui-desktop/src/lib/session-ids.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' + +import { storedSessionIdForNotification } from './session-ids' + +describe('storedSessionIdForNotification', () => { + it('translates a runtime id back to its stored id', () => { + // The route is keyed by the stored id, but notifications carry the runtime + // id. Resolving runtime -> stored keeps notification-click navigation from + // resuming a non-existent stored session ("session not found"). + const map = new Map([['stored-abc', 'runtime-123']]) + + expect(storedSessionIdForNotification('runtime-123', map)).toBe('stored-abc') + }) + + it('returns the id unchanged when no mapping is known', () => { + // A notification for a session this window never opened may already carry a + // stored id; let the resume/REST lookup handle it as-is. + const map = new Map([['stored-abc', 'runtime-123']]) + + expect(storedSessionIdForNotification('stored-xyz', map)).toBe('stored-xyz') + }) + + it('returns the id unchanged for an empty map', () => { + expect(storedSessionIdForNotification('runtime-123', new Map())).toBe('runtime-123') + }) + + it('resolves the correct stored id among several sessions', () => { + const map = new Map([ + ['stored-1', 'runtime-1'], + ['stored-2', 'runtime-2'], + ['stored-3', 'runtime-3'] + ]) + + expect(storedSessionIdForNotification('runtime-2', map)).toBe('stored-2') + }) + + it('does not treat a stored id as a runtime id (keys are not matched)', () => { + // The map is stored -> runtime. A value that only appears as a *key* must + // not be rewritten, otherwise an already-stored id could be mangled. + const map = new Map([['stored-1', 'runtime-1']]) + + expect(storedSessionIdForNotification('stored-1', map)).toBe('stored-1') + }) +}) diff --git a/ui-desktop/src/lib/session-ids.ts b/ui-desktop/src/lib/session-ids.ts new file mode 100644 index 00000000..c97cadc2 --- /dev/null +++ b/ui-desktop/src/lib/session-ids.ts @@ -0,0 +1,26 @@ +// The gateway tags every event — and therefore every native notification — +// with the *runtime* session id (the key under which the session lives in the +// gateway's in-memory `_sessions` map). The chat route, however, is keyed by +// the *stored* session id (`stored_session_id`), which is a different value: +// a brand-new chat gets a runtime id immediately but its stored id is assigned +// when the first turn persists. Navigating to a runtime id therefore tries to +// resume a stored session that does not exist ("session not found") and +// strands the user, who experiences it as the running session being destroyed. +// +// `runtimeIdByStoredSessionId` maps stored -> runtime; this resolves the +// reverse so notification-click navigation lands on the real route. The id is +// returned unchanged when no mapping is known — it may already be a stored id +// (e.g. a notification for a session this window never opened), in which case +// the normal resume/REST lookup handles it. +export function storedSessionIdForNotification( + id: string, + runtimeIdByStoredSessionId: ReadonlyMap<string, string> +): string { + for (const [storedId, runtimeId] of runtimeIdByStoredSessionId) { + if (runtimeId === id) { + return storedId + } + } + + return id +} diff --git a/ui-desktop/src/lib/session-link-title.test.ts b/ui-desktop/src/lib/session-link-title.test.ts new file mode 100644 index 00000000..08caceac --- /dev/null +++ b/ui-desktop/src/lib/session-link-title.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { getSession } from '@/clawcodex' +import { $sessions } from '@/store/session' +import type { SessionInfo } from '@/types/clawcodex' + +import { __resetSessionLinkTitleCache, fetchSessionLinkTitle, lookupLocalSessionTitle } from './session-link-title' +import { sessionRefCacheKey } from './session-refs' + +vi.mock('@/clawcodex', () => ({ + getSession: vi.fn() +})) + +function makeSession(overrides: Partial<SessionInfo> = {}): SessionInfo { + return { + ended_at: null, + id: '20260101_abc123', + input_tokens: 0, + is_active: false, + last_active: 1_000, + message_count: 1, + model: null, + output_tokens: 0, + preview: null, + profile: 'default', + source: 'cli', + started_at: 1_000, + title: 'Research notes', + tool_call_count: 0, + ...overrides + } +} + +afterEach(() => { + __resetSessionLinkTitleCache() + $sessions.set([]) + vi.mocked(getSession).mockReset() +}) + +describe('lookupLocalSessionTitle', () => { + it('reads from the in-memory session list', () => { + $sessions.set([makeSession({ profile: 'work', title: 'Branch plan' })]) + + expect(lookupLocalSessionTitle('work/20260101_abc123')).toBe('Branch plan') + }) + + it('matches the lineage root so a compressed chat still resolves', () => { + $sessions.set([makeSession({ _lineage_root_id: '20260101_abc123', id: '20260102_tip', title: 'Compressed chat' })]) + + expect(lookupLocalSessionTitle('20260101_abc123')).toBe('Compressed chat') + }) + + it('ignores a same-id row owned by another profile', () => { + $sessions.set([makeSession({ profile: 'work', title: 'Work chat' })]) + + expect(lookupLocalSessionTitle('personal/20260101_abc123')).toBe('') + }) + + it('returns empty for an untitled row so the caller can fall back to the id', () => { + $sessions.set([makeSession({ preview: null, title: null })]) + + expect(lookupLocalSessionTitle('default/20260101_abc123')).toBe('') + }) +}) + +describe('fetchSessionLinkTitle', () => { + it('dedupes concurrent lookups', async () => { + vi.mocked(getSession).mockResolvedValue(makeSession({ title: 'From API' })) + + const value = 'default/20260101_abc123' + const [first, second] = await Promise.all([fetchSessionLinkTitle(value), fetchSessionLinkTitle(value)]) + + expect(first).toBe('From API') + expect(second).toBe('From API') + expect(getSession).toHaveBeenCalledTimes(1) + expect(getSession).toHaveBeenCalledWith('20260101_abc123', 'default') + }) + + it('uses the local sidebar row before calling the API', async () => { + $sessions.set([makeSession({ title: 'Cached title' })]) + + await expect(fetchSessionLinkTitle('default/20260101_abc123')).resolves.toBe('Cached title') + expect(getSession).not.toHaveBeenCalled() + }) + + it('keeps separate cache entries per profile', async () => { + vi.mocked(getSession).mockImplementation(async (id, profile) => + makeSession({ id, profile: profile ?? 'default', title: profile === 'work' ? 'Work chat' : 'Home chat' }) + ) + + await expect(fetchSessionLinkTitle('default/20260101_abc123')).resolves.toBe('Home chat') + await expect(fetchSessionLinkTitle('work/20260101_abc123')).resolves.toBe('Work chat') + expect(sessionRefCacheKey('default/20260101_abc123')).not.toBe(sessionRefCacheKey('work/20260101_abc123')) + }) + + it('falls back to the preview when the session has no title', async () => { + vi.mocked(getSession).mockResolvedValue(makeSession({ preview: 'Summarize this repo', title: null })) + + await expect(fetchSessionLinkTitle('20260101_abc123')).resolves.toBe('Summarize this repo') + }) + + it('resolves empty when the id is not on this backend', async () => { + vi.mocked(getSession).mockRejectedValue(new Error('Session not found')) + + await expect(fetchSessionLinkTitle('default/missing')).resolves.toBe('') + }) + + it('resolves empty when the desktop bridge is unavailable', async () => { + vi.mocked(getSession).mockImplementation(() => { + throw new TypeError("Cannot read properties of undefined (reading 'api')") + }) + + await expect(fetchSessionLinkTitle('default/20260101_abc123')).resolves.toBe('') + }) +}) diff --git a/ui-desktop/src/lib/session-link-title.ts b/ui-desktop/src/lib/session-link-title.ts new file mode 100644 index 00000000..e16fc03d --- /dev/null +++ b/ui-desktop/src/lib/session-link-title.ts @@ -0,0 +1,143 @@ +/** + * Resolves `@session:<profile>/<id>` reference values to the session's title. + * + * Same shape as the external-link title resolver (`external-link.tsx`): a + * process-lifetime cache, in-flight dedupe, and subscribers so every chip for + * the same session repaints off one lookup. The sidebar list answers most + * lookups for free; only an unknown id costs a REST round-trip. + */ +import { useEffect, useMemo, useState } from 'react' + +import { getSession } from '@/clawcodex' +import { parseSessionRefValue, sessionRefCacheKey, sessionRefFallbackLabel } from '@/lib/session-refs' +import { $sessions, sessionMatchesStoredId } from '@/store/session' +import type { SessionInfo } from '@/types/clawcodex' + +const titleCache = new Map<string, string>() +const titleInflight = new Map<string, Promise<string>>() +const titleSubs = new Map<string, Set<(value: string) => void>>() + +/** Deliberately not `sessionTitle()` from chat-runtime: its "Untitled session" + * fallback is a worse chip label than the short id, so an untitled row + * resolves to empty and the caller's fallback wins. */ +function sessionRowTitle(row: SessionInfo): string { + return row.title?.trim() || row.preview?.trim() || '' +} + +function profileMatches(sessionProfile: null | string | undefined, target?: string): boolean { + if (!target) { + return true + } + + return ((sessionProfile ?? '').trim() || 'default') === (target.trim() || 'default') +} + +export function lookupLocalSessionTitle(value: string): string { + const { profile, sessionId } = parseSessionRefValue(value) + + if (!sessionId) { + return '' + } + + const row = $sessions + .get() + .find(session => sessionMatchesStoredId(session, sessionId) && profileMatches(session.profile, profile)) + + return row ? sessionRowTitle(row) : '' +} + +/** REST lookup that can't throw: the bridge is absent outside Electron, and a + * session id that isn't on this backend 404s. Both mean "no title". */ +function requestSessionRow(sessionId: string, profile?: string): Promise<null | SessionInfo> { + try { + return Promise.resolve(getSession(sessionId, profile ?? null)).catch(() => null) + } catch { + return Promise.resolve(null) + } +} + +export function fetchSessionLinkTitle(value: string): Promise<string> { + const key = sessionRefCacheKey(value) + + if (!key) { + return Promise.resolve('') + } + + const cached = titleCache.get(key) + + if (cached !== undefined) { + return Promise.resolve(cached) + } + + const inflight = titleInflight.get(key) + + if (inflight) { + return inflight + } + + const local = lookupLocalSessionTitle(value) + + if (local) { + titleCache.set(key, local) + + return Promise.resolve(local) + } + + const { profile, sessionId } = parseSessionRefValue(value) + + const promise = requestSessionRow(sessionId, profile) + .then(row => (row ? sessionRowTitle(row) : '')) + .then(title => { + titleCache.set(key, title) + titleInflight.delete(key) + titleSubs.get(key)?.forEach(notify => notify(title)) + + return title + }) + + titleInflight.set(key, promise) + + return promise +} + +export function useSessionLinkTitle(value: string, fallbackLabel?: string): string { + const key = useMemo(() => sessionRefCacheKey(value), [value]) + const fallback = fallbackLabel?.trim() || sessionRefFallbackLabel(value) + const [title, setTitle] = useState(() => (key ? titleCache.get(key) || lookupLocalSessionTitle(value) : '')) + + useEffect(() => { + if (!key) { + return + } + + const known = titleCache.get(key) || lookupLocalSessionTitle(value) + + setTitle(known) + + if (known) { + return + } + + const subs = titleSubs.get(key) ?? new Set<(resolved: string) => void>() + + subs.add(setTitle) + titleSubs.set(key, subs) + void fetchSessionLinkTitle(value) + + return () => { + subs.delete(setTitle) + + if (!subs.size) { + titleSubs.delete(key) + } + } + }, [key, value]) + + return title || fallback +} + +export function __resetSessionLinkTitleCache(): void { + titleCache.clear() + titleInflight.clear() + titleSubs.clear() +} diff --git a/ui-desktop/src/lib/session-refs.test.ts b/ui-desktop/src/lib/session-refs.test.ts new file mode 100644 index 00000000..5d4b1371 --- /dev/null +++ b/ui-desktop/src/lib/session-refs.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' + +import { preprocessMarkdown } from './markdown-preprocess' +import { + linkifySessionRefs, + parseSessionRefValue, + sessionMarkdownHref, + sessionRefCacheKey, + sessionRefFallbackLabel, + sessionRefFromMarkdownHref, + splitSessionRefValue +} from './session-refs' + +describe('parseSessionRefValue', () => { + it('splits profile and session id', () => { + expect(parseSessionRefValue('work/20260101_abc123')).toEqual({ + profile: 'work', + sessionId: '20260101_abc123' + }) + }) + + it('treats bare values as session ids', () => { + expect(parseSessionRefValue('20260101_abc123')).toEqual({ sessionId: '20260101_abc123' }) + }) +}) + +describe('sessionRefFallbackLabel', () => { + it('truncates long ids', () => { + expect(sessionRefFallbackLabel('default/20260610_120000_abcdef')).toBe('20260610…') + }) + + it('leaves short ids alone', () => { + expect(sessionRefFallbackLabel('work/abc123')).toBe('abc123') + }) +}) + +describe('sessionRefCacheKey', () => { + it('separates the same id across profiles', () => { + expect(sessionRefCacheKey('work/abc')).not.toBe(sessionRefCacheKey('home/abc')) + }) + + it('is empty for a valueless ref', () => { + expect(sessionRefCacheKey(' ')).toBe('') + }) +}) + +describe('splitSessionRefValue', () => { + it('peels prose punctuation off a bare value', () => { + expect(splitSessionRefValue('default/abc123.')).toEqual({ trailing: '.', value: 'default/abc123' }) + }) + + it('leaves a quoted value fenced', () => { + expect(splitSessionRefValue('`my session`')).toEqual({ trailing: '', value: 'my session' }) + }) +}) + +describe('session markdown hrefs', () => { + it('round-trips a value through the fragment href', () => { + const href = sessionMarkdownHref('work/20260101_abc123') + + expect(href).toBe('#session/work%2F20260101_abc123') + expect(sessionRefFromMarkdownHref(href)).toBe('work/20260101_abc123') + }) + + it('ignores hrefs that are not session fragments', () => { + expect(sessionRefFromMarkdownHref('#preview/foo')).toBeNull() + expect(sessionRefFromMarkdownHref('https://example.com')).toBeNull() + expect(sessionRefFromMarkdownHref(undefined)).toBeNull() + }) +}) + +describe('linkifySessionRefs', () => { + it('rewrites a bare ref into a session link', () => { + expect(linkifySessionRefs('see @session:work/20260101_abc123 next')).toBe( + 'see [20260101…](#session/work%2F20260101_abc123) next' + ) + }) + + it('keeps trailing prose punctuation outside the link', () => { + expect(linkifySessionRefs('see @session:default/abc123.')).toBe('see [abc123](#session/default%2Fabc123).') + }) + + it('leaves text without a ref untouched', () => { + const text = 'no references here' + + expect(linkifySessionRefs(text)).toBe(text) + }) + + it('does not match an email-like or path-embedded @session', () => { + expect(linkifySessionRefs('me@session:abc')).toBe('me@session:abc') + expect(linkifySessionRefs('a/@session:abc')).toBe('a/@session:abc') + }) + + it('leaves a ref a model already wrapped in a markdown link alone', () => { + const text = '[that chat](@session:work/abc123)' + + expect(linkifySessionRefs(text)).toBe(text) + }) +}) + +// The agent-authored path: assistant markdown runs through preprocessMarkdown +// before Streamdown, so a bare ref must survive as a link — and must NOT be +// rewritten inside code, where it is being discussed rather than referenced. +describe('preprocessMarkdown session refs', () => { + it('linkifies a ref in prose', () => { + expect(preprocessMarkdown('Context is in @session:work/20260101_abc123 there.')).toContain( + '[20260101…](#session/work%2F20260101_abc123)' + ) + }) + + it('leaves refs inside inline code alone', () => { + const out = preprocessMarkdown('Type `@session:work/20260101_abc123` to link a chat.') + + expect(out).toContain('`@session:work/20260101_abc123`') + expect(out).not.toContain('#session/') + }) + + it('leaves refs inside a fenced block alone', () => { + const out = preprocessMarkdown(['```text', '@session:work/20260101_abc123', '```'].join('\n')) + + expect(out).toContain('@session:work/20260101_abc123') + expect(out).not.toContain('#session/') + }) +}) diff --git a/ui-desktop/src/lib/session-refs.ts b/ui-desktop/src/lib/session-refs.ts new file mode 100644 index 00000000..3ec9fbc6 --- /dev/null +++ b/ui-desktop/src/lib/session-refs.ts @@ -0,0 +1,118 @@ +/** + * Pure helpers for `@session:<profile>/<id>` references. + * + * Kept free of React/store/API imports so the markdown preprocessor (a hot + * per-flush path) can use them without pulling in the resolver; the stateful + * title lookup lives in `session-link-title.ts`. + */ + +/** Mirrors the composer/transcript form in `directive-text.tsx`: a bare value, + * or one fenced in backticks/quotes so a value with spaces survives. The + * lookbehinds keep `foo@session:` and URL paths from matching, and skip a ref + * a model already wrapped in a markdown link — rewriting that would nest. */ +export const SESSION_REF_RE = /(?<![\w/])(?<!]\()@session:(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g + +const TRAILING_PUNCTUATION_RE = /[,.;:!?)\]}]+$/ + +function unwrapQuotes(raw: string): null | string { + if (raw.length < 2) { + return null + } + + const head = raw[0] + const tail = raw[raw.length - 1] + + if ((head === '`' && tail === '`') || (head === '"' && tail === '"') || (head === "'" && tail === "'")) { + return raw.slice(1, -1) + } + + return null +} + +/** Splits a matched value into the reference itself and any prose punctuation + * that the greedy `\S+` branch swallowed. Quoted values are already fenced. */ +export function splitSessionRefValue(raw: string): { trailing: string; value: string } { + const quoted = unwrapQuotes(raw) + + if (quoted !== null) { + return { trailing: '', value: quoted } + } + + const value = raw.replace(TRAILING_PUNCTUATION_RE, '') + + return { trailing: raw.slice(value.length), value } +} + +/** Session ids never contain a slash, so a slash unambiguously means + * `<profile>/<id>` — same split as `tools/session_search_tool.py`. */ +export function parseSessionRefValue(value: string): { profile?: string; sessionId: string } { + const trimmed = value.trim() + const slash = trimmed.indexOf('/') + + if (slash === -1) { + return { sessionId: trimmed } + } + + const profile = trimmed.slice(0, slash).trim() + const sessionId = trimmed.slice(slash + 1).trim() + + return sessionId ? { profile: profile || undefined, sessionId } : { sessionId: trimmed } +} + +export function sessionRefCacheKey(value: string): string { + const { profile, sessionId } = parseSessionRefValue(value) + + return sessionId ? `${profile ?? ''}/${sessionId}` : '' +} + +/** Chip label before (or without) a resolved title — a short, still-identifying id. */ +export function sessionRefFallbackLabel(value: string): string { + const { sessionId } = parseSessionRefValue(value) + + if (!sessionId) { + return value + } + + return sessionId.length > 10 ? `${sessionId.slice(0, 8)}…` : sessionId +} + +/** A fragment href, matching the `#preview/` convention in `preview-targets.ts` + * — no custom URL scheme to survive markdown sanitization. */ +export function sessionMarkdownHref(value: string): string { + return `#session/${encodeURIComponent(value)}` +} + +export function sessionRefFromMarkdownHref(href?: string): null | string { + if (!href?.startsWith('#session/')) { + return null + } + + try { + return decodeURIComponent(href.slice('#session/'.length)) || null + } catch { + return null + } +} + +/** + * Rewrites bare `@session:<profile>/<id>` tokens into markdown links so an + * agent-authored reference reaches `MarkdownLink` and renders as a chip. + * Callers must exclude code spans/fences — `preprocessMarkdown` already does. + */ +export function linkifySessionRefs(text: string): string { + if (!text.includes('@session:')) { + return text + } + + return text.replace(SESSION_REF_RE, (match, raw: string) => { + const { trailing, value } = splitSessionRefValue(raw) + + if (!parseSessionRefValue(value).sessionId) { + return match + } + + const label = sessionRefFallbackLabel(value).replace(/[[\]\\]/g, '\\$&') + + return `[${label}](${sessionMarkdownHref(value)})${trailing}` + }) +} diff --git a/ui-desktop/src/lib/session-search.test.ts b/ui-desktop/src/lib/session-search.test.ts new file mode 100644 index 00000000..d07fda88 --- /dev/null +++ b/ui-desktop/src/lib/session-search.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' + +import type { SessionInfo } from '@/types/clawcodex' + +import { sessionMatchesSearch } from './session-search' + +function makeSession(overrides: Partial<SessionInfo> = {}): SessionInfo { + return { + archived: false, + cwd: '/home/user/projects/clawcodex', + ended_at: null, + id: '20260603_090200_abcd12', + input_tokens: 0, + is_active: false, + last_active: 1_000, + message_count: 2, + model: 'claude', + output_tokens: 0, + preview: 'Fix Desktop session search', + source: 'cli', + started_at: 1_000, + title: 'Desktop Search Feature', + tool_call_count: 0, + ...overrides + } +} + +describe('sessionMatchesSearch', () => { + it('matches loaded sessions by full and partial session id', () => { + const session = makeSession() + + expect(sessionMatchesSearch(session, '20260603_090200_abcd12')).toBe(true) + expect(sessionMatchesSearch(session, '090200')).toBe(true) + expect(sessionMatchesSearch(session, 'ABCD12')).toBe(true) + }) + + it('matches projected compression sessions by lineage root id', () => { + const session = makeSession({ + _lineage_root_id: '20260602_235959_root99', + id: '20260603_010000_tip01' + }) + + expect(sessionMatchesSearch(session, 'root99')).toBe(true) + expect(sessionMatchesSearch(session, '20260602')).toBe(true) + }) + + it('preserves title, preview, and workspace matching', () => { + const session = makeSession() + + expect(sessionMatchesSearch(session, 'desktop search')).toBe(true) + expect(sessionMatchesSearch(session, 'session search')).toBe(true) + expect(sessionMatchesSearch(session, 'clawcodex')).toBe(true) + }) + + it('matches sessions by git branch', () => { + expect(sessionMatchesSearch(makeSession({ git_branch: 'feat/cool-thing' }), 'feat/cool-thing')).toBe(true) + expect(sessionMatchesSearch(makeSession({ git_branch: 'feat/cool-thing' }), 'cool')).toBe(true) + expect(sessionMatchesSearch(makeSession({ git_branch: 'main' }), 'main')).toBe(true) + }) + + it('matches sessions by source platform and aliases', () => { + expect(sessionMatchesSearch(makeSession({ source: 'telegram' }), 'Telegram')).toBe(true) + expect(sessionMatchesSearch(makeSession({ source: 'whatsapp' }), 'WhatsApp')).toBe(true) + expect(sessionMatchesSearch(makeSession({ source: 'whatsapp' }), 'wa')).toBe(true) + expect(sessionMatchesSearch(makeSession({ source: 'slack' }), 'slack')).toBe(true) + expect(sessionMatchesSearch(makeSession({ source: 'bluebubbles' }), 'imessage')).toBe(true) + }) + + it('does not match unrelated queries', () => { + expect(sessionMatchesSearch(makeSession(), 'totally-unrelated')).toBe(false) + }) +}) diff --git a/ui-desktop/src/lib/session-search.ts b/ui-desktop/src/lib/session-search.ts new file mode 100644 index 00000000..0a477219 --- /dev/null +++ b/ui-desktop/src/lib/session-search.ts @@ -0,0 +1,23 @@ +import { normalize } from '@/lib/text' +import type { SessionInfo } from '@/types/clawcodex' + +import { sessionTitle } from './chat-runtime' +import { sessionSourceSearchTerms } from './session-source' + +export function sessionMatchesSearch(session: SessionInfo, query: string): boolean { + const needle = normalize(query) + + if (!needle) { + return true + } + + return [ + session.id, + session._lineage_root_id ?? '', + sessionTitle(session), + session.preview ?? '', + session.cwd ?? '', + session.git_branch ?? '', + ...sessionSourceSearchTerms(session.source) + ].some(value => value.toLowerCase().includes(needle)) +} diff --git a/ui-desktop/src/lib/session-signatures.test.ts b/ui-desktop/src/lib/session-signatures.test.ts new file mode 100644 index 00000000..3a4a470f --- /dev/null +++ b/ui-desktop/src/lib/session-signatures.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' + +import type { SessionInfo } from '@/clawcodex' + +import { sameCronSignature, sessionMessagesSignature } from './session-signatures' + +const session = (id: string, title: string | null): SessionInfo => ({ id, title }) as SessionInfo + +describe('sameCronSignature', () => { + it('is false when the lengths differ', () => { + expect(sameCronSignature([session('a', 't')], [])).toBe(false) + }) + + it('is true when ids and titles match in order', () => { + const a = [session('a', 'one'), session('b', 'two')] + const b = [session('a', 'one'), session('b', 'two')] + expect(sameCronSignature(a, b)).toBe(true) + }) + + it('is false when a title changed', () => { + const a = [session('a', 'one')] + const b = [session('a', 'renamed')] + expect(sameCronSignature(a, b)).toBe(false) + }) + + it('is false when order differs', () => { + const a = [session('a', 't'), session('b', 't')] + const b = [session('b', 't'), session('a', 't')] + expect(sameCronSignature(a, b)).toBe(false) + }) +}) + +describe('sessionMessagesSignature', () => { + const msg = (role: string, content: string) => + ({ role, content }) as Parameters<typeof sessionMessagesSignature>[0][number] + + it('is stable for identical transcripts', () => { + expect(sessionMessagesSignature([msg('user', 'hi')])).toBe(sessionMessagesSignature([msg('user', 'hi')])) + }) + + it('changes when content changes', () => { + expect(sessionMessagesSignature([msg('user', 'hi')])).not.toBe(sessionMessagesSignature([msg('user', 'yo')])) + }) + + it('changes when a message is appended', () => { + const one = [msg('user', 'hi')] + expect(sessionMessagesSignature(one)).not.toBe(sessionMessagesSignature([...one, msg('assistant', 'hey')])) + }) +}) diff --git a/ui-desktop/src/lib/session-signatures.ts b/ui-desktop/src/lib/session-signatures.ts new file mode 100644 index 00000000..24dbf6f1 --- /dev/null +++ b/ui-desktop/src/lib/session-signatures.ts @@ -0,0 +1,54 @@ +/** + * Cheap signature compares for poll loops — swap the atom (and re-render) + * only when the rows/transcript actually changed. + */ + +import type { SessionInfo, SessionMessage } from '@/clawcodex' + +export function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean { + if (a.length !== b.length) { + return false + } + + return a.every((session, i) => { + const other = b[i] + + return ( + other != null && + session.id === other.id && + session._lineage_root_id === other._lineage_root_id && + session.title === other.title && + session.source === other.source && + session.profile === other.profile && + session.preview === other.preview && + session.message_count === other.message_count && + session.last_active === other.last_active && + session.ended_at === other.ended_at + ) + }) +} + +// FNV-1a over role/timestamp/content. +function hashString(hash: number, value: string): number { + let next = hash + + for (let i = 0; i < value.length; i++) { + next ^= value.charCodeAt(i) + next = Math.imul(next, 16777619) + } + + return next >>> 0 +} + +/** Transcript fingerprint for the active-messaging-session poll. */ +export function sessionMessagesSignature(messages: SessionMessage[]): string { + let hash = 2166136261 + + for (const m of messages) { + hash = hashString(hash, m.role) + hash = hashString(hash, String(m.timestamp ?? '')) + hash = hashString(hash, typeof m.content === 'string' ? m.content : (JSON.stringify(m.content) ?? '')) + } + + return `${messages.length}:${hash}` +} diff --git a/ui-desktop/src/lib/session-source.test.ts b/ui-desktop/src/lib/session-source.test.ts new file mode 100644 index 00000000..46dcef94 --- /dev/null +++ b/ui-desktop/src/lib/session-source.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' + +import { isMessagingSource, MESSAGING_SESSION_SOURCE_IDS, sessionSourceSearchTerms } from './session-source' + +// Regression guard for #46761 / PR #47395: Photon (iMessage) must keep its own +// sidebar section. refreshMessagingSessions() filters rows through +// isMessagingSource(), so this entry is the sole condition that keeps Photon +// sessions out of generic recents. A silent removal would regress the feature +// with no test failure — these asserts pin the contract. +describe('photon messaging source registration', () => { + it('treats photon as a messaging source (own sidebar section)', () => { + expect(isMessagingSource('photon')).toBe(true) + }) + + it('is case/space insensitive on the source id', () => { + expect(isMessagingSource('PHOTON')).toBe(true) + expect(isMessagingSource(' photon ')).toBe(true) + }) + + it('exposes the iMessage/messages search aliases so Photon sessions are findable', () => { + const terms = sessionSourceSearchTerms('photon') + expect(terms).toContain('imessage') + expect(terms).toContain('messages') + }) + + it('is registered in the messaging source id list', () => { + expect(MESSAGING_SESSION_SOURCE_IDS).toContain('photon') + }) + + it('does not flag local/CLI-ish sources as messaging (guard sanity)', () => { + expect(isMessagingSource('cli')).toBe(false) + expect(isMessagingSource(null)).toBe(false) + expect(isMessagingSource(undefined)).toBe(false) + }) +}) diff --git a/ui-desktop/src/lib/session-source.ts b/ui-desktop/src/lib/session-source.ts new file mode 100644 index 00000000..38fc60cd --- /dev/null +++ b/ui-desktop/src/lib/session-source.ts @@ -0,0 +1,130 @@ +import { normalize } from '@/lib/text' + +const SOURCE_LABELS: Record<string, string> = { + api_server: 'API', + bluebubbles: 'iMessage', + cli: 'CLI', + codex: 'Codex', + desktop: 'Desktop', + discord: 'Discord', + email: 'Email', + gateway: 'Gateway', + kanban: 'Kanban', + local: 'Local', + matrix: 'Matrix', + mattermost: 'Mattermost', + photon: 'Photon', + qqbot: 'QQ', + signal: 'Signal', + slack: 'Slack', + sms: 'SMS', + telegram: 'Telegram', + tui: 'TUI', + webhook: 'Webhook', + weixin: 'WeChat', + whatsapp: 'WhatsApp', + yuanbao: 'Yuanbao' +} + +const SOURCE_ALIASES: Record<string, string[]> = { + bluebubbles: ['apple messages', 'imessage'], + photon: ['imessage', 'messages'], + cli: ['terminal'], + desktop: ['app', 'gui'], + local: ['machine'], + qqbot: ['qq'], + telegram: ['tg'], + tui: ['terminal'], + weixin: ['wechat'], + whatsapp: ['wa'] +} + +// Sources that run on the local machine rather than an external messaging +// platform. A handoff *from* one of these isn't a platform origin worth a badge. +// Exported so the recents fetch can keep these in the main list while the +// messaging fetch excludes them. +export const LOCAL_SESSION_SOURCE_IDS = ['cli', 'codex', 'desktop', 'gateway', 'kanban', 'local', 'tui'] +const LOCAL_SOURCE_IDS = new Set(LOCAL_SESSION_SOURCE_IDS) + +// External messaging platforms that each get their own self-managed sidebar +// section (fetched separately from local recents). Mirrors the gateway platform +// adapters; keep in sync with PLATFORM_ICONS in app/messaging/platform-icon.tsx. +export const MESSAGING_SESSION_SOURCE_IDS = [ + 'telegram', + 'discord', + 'slack', + 'mattermost', + 'matrix', + 'signal', + 'whatsapp', + 'bluebubbles', + 'photon', + 'homeassistant', + 'email', + 'sms', + 'webhook', + 'api_server', + 'weixin', + 'wecom', + 'qqbot', + 'yuanbao', + 'dingtalk', + 'feishu' +] +const MESSAGING_SOURCE_IDS = new Set(MESSAGING_SESSION_SOURCE_IDS) + +/** True when a source id is an external messaging platform (gets its own + * sidebar section) rather than a local/CLI/desktop session. */ +export function isMessagingSource(source: null | string | undefined): boolean { + const id = normalizeSessionSource(source) + + return id != null && MESSAGING_SOURCE_IDS.has(id) +} + +export function normalizeSessionSource(source: null | string | undefined): string | null { + return normalize(source) || null +} + +/** + * Resolve the origin messaging platform for a handed-off session. Returns the + * normalized platform id (e.g. 'telegram') when the session completed a handoff + * from a real messaging platform, otherwise null. After a handoff the live + * source is local, so this is what drives the row's origin-platform badge. + */ +export function handoffOriginSource( + handoffState: null | string | undefined, + handoffPlatform: null | string | undefined +): string | null { + if (handoffState !== 'completed') { + return null + } + + const id = normalizeSessionSource(handoffPlatform) + + if (!id || LOCAL_SOURCE_IDS.has(id)) { + return null + } + + return id +} + +export function sessionSourceLabel(source: null | string | undefined): string | null { + const id = normalizeSessionSource(source) + + if (!id) { + return null + } + + return SOURCE_LABELS[id] || id.replace(/[_-]+/g, ' ').replace(/\b\w/g, char => char.toUpperCase()) +} + +export function sessionSourceSearchTerms(source: null | string | undefined): string[] { + const id = normalizeSessionSource(source) + const label = sessionSourceLabel(id) + + if (!id) { + return [] + } + + return [id, label ?? '', ...(SOURCE_ALIASES[id] ?? [])].filter(Boolean) +} diff --git a/ui-desktop/src/lib/slash-completion-cache.ts b/ui-desktop/src/lib/slash-completion-cache.ts new file mode 100644 index 00000000..7b87c4c7 --- /dev/null +++ b/ui-desktop/src/lib/slash-completion-cache.ts @@ -0,0 +1,107 @@ +import { atom } from 'nanostores' + +import { queryClient } from '@/lib/query-client' +import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' + +// Root for every cached `/` completion response — the bare-slash catalog and +// each typed query. Not in PROFILE_INDEPENDENT_QUERY_ROOTS, so a profile or +// gateway switch drops it with the rest of the profile-scoped cache. +const SLASH_COMPLETIONS_KEY = 'slash-completions' + +// The command catalog and its completions are a scan of the command registry +// plus the skills on disk: they change when a skill is added, removed, or +// toggled — not while the user types. Both gateway calls are expensive on the +// backend (a full skills-dir scan per request), so hold the answer for an hour +// and let the mutation sites invalidate it when the truth actually moves. +const SLASH_COMPLETIONS_TTL_MS = 60 * 60_000 + +/** Serve a `/` completion response from cache, fetching only when stale. */ +export function cachedSlashCompletion<T>(key: string, fetcher: () => Promise<T>): Promise<T> { + return queryClient.fetchQuery({ + queryKey: [SLASH_COMPLETIONS_KEY, key], + queryFn: fetcher, + gcTime: SLASH_COMPLETIONS_TTL_MS, + staleTime: SLASH_COMPLETIONS_TTL_MS, + // A completion is only worth having while the popover is open. Retrying a + // failed lookup with backoff would spend seconds answering a keystroke the + // user has already typed past; the caller falls back to an empty list and + // the next keystroke asks again. + retry: false + }) +} + +/** True when `cachedSlashCompletion(key)` will resolve without a round trip. */ +export function hasCachedSlashCompletion(key: string): boolean { + const state = queryClient.getQueryState([SLASH_COMPLETIONS_KEY, key]) + + return state?.data !== undefined && Date.now() - state.dataUpdatedAt < SLASH_COMPLETIONS_TTL_MS +} + +/** + * Read a cached completion response without fetching. For data that improves a + * response but must not cost a round trip to get — the catalog's per-skill + * usage map, which refines the ordering of a typed query but is not worth + * delaying that query for. + */ +export function peekCachedSlashCompletion<T>(key: string): T | undefined { + return hasCachedSlashCompletion(key) ? queryClient.getQueryData<T>([SLASH_COMPLETIONS_KEY, key]) : undefined +} + +// `@` path completions are a directory listing, which unlike the command +// catalog CAN change under the user (a build writes files, a branch switch +// rewrites a tree). They get the same de-duplication but a short TTL: long +// enough that walking back up a path you just walked down is instant, short +// enough that the listing never looks stale. +const PATH_COMPLETIONS_KEY = 'path-completions' +const PATH_COMPLETIONS_TTL_MS = 15_000 + +/** Serve an `@` path completion from cache, fetching only when stale. */ +export function cachedPathCompletion<T>(key: string, fetcher: () => Promise<T>): Promise<T> { + return queryClient.fetchQuery({ + queryKey: [PATH_COMPLETIONS_KEY, key], + queryFn: fetcher, + gcTime: PATH_COMPLETIONS_TTL_MS, + staleTime: PATH_COMPLETIONS_TTL_MS, + retry: false + }) +} + +/** True when `cachedPathCompletion(key)` will resolve without a round trip. */ +export function hasCachedPathCompletion(key: string): boolean { + const state = queryClient.getQueryState([PATH_COMPLETIONS_KEY, key]) + + return state?.data !== undefined && Date.now() - state.dataUpdatedAt < PATH_COMPLETIONS_TTL_MS +} + +/** + * Bumped on every invalidation. The composer's completion adapter de-dupes by + * query, so an unchanged `/` would never re-ask on its own — it watches this + * instead to know the answer it's holding is no longer current. + */ +export const $slashCompletionsEpoch = atom(0) + +/** + * Drop cached `/` completions. Called from every site that changes which + * skills exist or are enabled — install/uninstall/update from the hub, a + * skill toggle or delete in Capabilities — so the composer's list matches + * the backend without waiting out the TTL. + */ +export function invalidateSlashCompletions(): void { + void queryClient.invalidateQueries({ queryKey: [SLASH_COMPLETIONS_KEY] }) + $slashCompletionsEpoch.set($slashCompletionsEpoch.get() + 1) +} + +// Each profile has its own skills directory, so a cached catalog is only valid +// for the profile that produced it. Dropped at the source rather than in the +// composer so it holds whether or not a chat is mounted at switch time. +let cachedProfile: null | string = null + +$activeGatewayProfile.subscribe(value => { + const key = normalizeProfileKey(value) + + if (cachedProfile !== null && cachedProfile !== key) { + invalidateSlashCompletions() + } + + cachedProfile = key +}) diff --git a/ui-desktop/src/lib/speech-text.test.ts b/ui-desktop/src/lib/speech-text.test.ts new file mode 100644 index 00000000..5af35ce4 --- /dev/null +++ b/ui-desktop/src/lib/speech-text.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest' + +import { sanitizeTextForSpeech } from './speech-text' + +describe('sanitizeTextForSpeech', () => { + it('summarizes fenced code blocks instead of reading them literally', () => { + expect(sanitizeTextForSpeech('Here is code:\n```ts\nconst x = 1\n```\nDone.')).toBe( + 'Here is code: code block omitted Done.' + ) + }) + + it('still keeps normal prose and inline code readable', () => { + expect(sanitizeTextForSpeech('Use `git status` after the change.')).toBe('Use git status after the change.') + }) + + it('skips markdown table data while preserving surrounding human text', () => { + const text = `Here is the quick takeaway: the totals remain unchanged. + +| Item | Value | Notes | +| --- | ---: | --- | +| Example A | 10 | first row | +| Example B | 20 | second row | + +Full detail stays visible on screen.` + + expect(sanitizeTextForSpeech(text)).toBe( + 'Here is the quick takeaway: the totals remain unchanged. Full detail stays visible on screen.' + ) + }) + + it('does not strip prose that merely contains a pipe character', () => { + const text = 'Use the summary first | keep the table on screen when it matters.' + + expect(sanitizeTextForSpeech(text)).toBe('Use the summary first | keep the table on screen when it matters.') + }) + + it('does not duplicate punctuation across paragraph breaks', () => { + const text = `First sentence. + +Second sentence.` + + expect(sanitizeTextForSpeech(text)).toBe('First sentence. Second sentence.') + }) + + it.each([ + ['markdown emphasis', '**First sentence.**\n\nSecond sentence.', 'First sentence. Second sentence.'], + ['a closing quote', '“First sentence.”\n\nSecond sentence.', '“First sentence.” Second sentence.'], + ['a closing parenthesis', '(First sentence.)\n\nSecond sentence.', '(First sentence.) Second sentence.'] + ])('does not duplicate punctuation after %s', (_label, text, expected) => { + expect(sanitizeTextForSpeech(text)).toBe(expected) + }) + + it('skips markdown tables without leading and trailing pipes', () => { + const text = `Main takeaway: total is unchanged. + +Item | Value +--- | ---: +Example A | 10 +Example B | 20 + +Done.` + + expect(sanitizeTextForSpeech(text)).toBe('Main takeaway: total is unchanged. Done.') + }) + + it('skips markdown tables nested inside blockquotes', () => { + const text = `Before the table. + +> | Item | Value | +> | --- | ---: | +> | Example A | 10 | +> | Example B | 20 | + +After the table.` + + expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.') + }) + + it('allows marker padding plus three spaces in blockquoted tables', () => { + const text = `Before the table. + +> | Item | Value | +> | --- | ---: | +> | Example A | 10 | + +After the table.` + + expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.') + }) + + it('skips explicit single-column markdown tables', () => { + const text = `Before the table. + +| Item | +| --- | +| Example A | + +After the table.` + + expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.') + }) + + it('preserves rows outside a table blockquote', () => { + const text = `> | Item | Value | +> | --- | ---: | +> | Example A | 10 | +Outside | prose` + + expect(sanitizeTextForSpeech(text)).toBe('Outside | prose') + }) + + it('preserves malformed tables with mismatched column counts', () => { + const text = `Heading | Detail +--- | --- | --- +Keep this prose.` + + expect(sanitizeTextForSpeech(text)).toContain('Heading | Detail') + }) + + it('skips GFM body rows whose cell counts differ from the header', () => { + const text = `Before the table. + +| Item | Value | +| --- | ---: | +| Example A | +| Example B | 20 | ignored | + +After the table.` + + expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.') + }) + + it('skips tables containing escaped pipe characters', () => { + const text = `Before the table. + +| Item \\| detail | Value | +| --- | ---: | +| Example A | 10 | + +After the table.` + + expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.') + }) + + it('preserves indented code that resembles a table', () => { + const text = ` Item | Value + --- | --- + Example A | 10` + + expect(sanitizeTextForSpeech(text)).toContain('Item | Value') + }) +}) diff --git a/ui-desktop/src/lib/speech-text.ts b/ui-desktop/src/lib/speech-text.ts new file mode 100644 index 00000000..fa3e7a5e --- /dev/null +++ b/ui-desktop/src/lib/speech-text.ts @@ -0,0 +1,167 @@ +const EMOJI_RE = /(?:[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}]|[\u{FE0F}\u{200D}]|[\u{E0020}-\u{E007F}])+/gu + +const FENCED_CODE_RE = /```[\s\S]*?(?:```|$)/g +const CODE_BLOCK_SUMMARY = ' code block omitted ' +const INLINE_CODE_RE = /`([^`]+)`/g +const MARKDOWN_LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g +const PARAGRAPH_BREAK_RE = /[ \t]*\n{2,}[ \t]*/g +const PUNCTUATED_PARAGRAPH_BREAK_RE = /([.!?])([*_~`>"'’”)}\]]*)[ \t]*\n{2,}[ \t]*/g +const SOFT_BREAK_RE = /[ \t]*\n[ \t]*/g + +const THINKING_PREFIX_RE = + /^\s*(?:\([^)\n]{1,48}\)\s*)?(?:processing|thinking|reasoning|analyzing|pondering|contemplating|musing|cogitating|ruminating|deliberating|mulling|reflecting|computing|synthesizing|formulating|brainstorming)\.\.\.\s*/i + +const URL_RE = /\bhttps?:\/\/\S+/gi + +const MARKDOWN_TABLE_DELIMITER_CELL_RE = /^:?-{3,}:?$/ + +interface MarkdownTableRow { + blockquoteDepth: number + cells: string[] +} + +function isUnescapedPipe(row: string, index: number): boolean { + let backslashes = 0 + + for (let cursor = index - 1; cursor >= 0 && row[cursor] === '\\'; cursor -= 1) { + backslashes += 1 + } + + return backslashes % 2 === 0 +} + +function splitMarkdownTableCells(row: string): string[] { + const cells: string[] = [] + let cellStart = 0 + + for (let index = 0; index < row.length; index += 1) { + if (row[index] === '|' && isUnescapedPipe(row, index)) { + cells.push(row.slice(cellStart, index).trim()) + cellStart = index + 1 + } + } + + cells.push(row.slice(cellStart).trim()) + + return cells +} + +function parseMarkdownTableRow(line: string): MarkdownTableRow | null { + let row = line + let blockquoteDepth = 0 + + while (true) { + const indentation = row.match(/^[ \t]*/)?.[0] ?? '' + + if (indentation.includes('\t') || indentation.length > 3) { + return null + } + + row = row.slice(indentation.length) + + if (!row.startsWith('>')) { + break + } + + blockquoteDepth += 1 + row = row.slice(1) + + if (row.startsWith(' ')) { + row = row.slice(1) + } + } + + row = row.trimEnd() + + const pipeIndexes = [...row.matchAll(/\|/g)].map(match => match.index).filter(index => isUnescapedPipe(row, index)) + + if (pipeIndexes.length === 0) { + return null + } + + const hasLeadingPipe = pipeIndexes[0] === 0 + const hasTrailingPipe = pipeIndexes.at(-1) === row.length - 1 + + if (hasLeadingPipe) { + row = row.slice(1) + } + + if (hasTrailingPipe) { + row = row.slice(0, -1) + } + + const cells = splitMarkdownTableCells(row) + + if (cells.length < 2 && !(hasLeadingPipe && hasTrailingPipe && cells.length === 1)) { + return null + } + + return { blockquoteDepth, cells } +} + +function stripMarkdownTables(text: string): string { + const lines = text.replace(/\r\n?/g, '\n').split('\n') + const tableLines = new Set<number>() + + let index = 1 + + while (index < lines.length) { + const delimiterRow = parseMarkdownTableRow(lines[index]) + const headerRow = parseMarkdownTableRow(lines[index - 1]) + + if ( + !delimiterRow || + !headerRow || + !delimiterRow.cells.every(cell => MARKDOWN_TABLE_DELIMITER_CELL_RE.test(cell)) || + headerRow.cells.length !== delimiterRow.cells.length || + headerRow.blockquoteDepth !== delimiterRow.blockquoteDepth + ) { + index += 1 + + continue + } + + tableLines.add(index - 1) + tableLines.add(index) + + let rowIndex = index + 1 + + for (; rowIndex < lines.length; rowIndex += 1) { + const bodyRow = parseMarkdownTableRow(lines[rowIndex]) + + if (!bodyRow || bodyRow.blockquoteDepth !== delimiterRow.blockquoteDepth) { + break + } + + tableLines.add(rowIndex) + } + + index = rowIndex + } + + return lines.filter((_, index) => !tableLines.has(index)).join('\n') +} + +function normalizeLineBreaks(text: string): string { + return text + .replace(/\r\n?/g, '\n') + .replace(/(\p{L})-\n(\p{L})/gu, '$1$2') + .replace(PUNCTUATED_PARAGRAPH_BREAK_RE, '$1$2 ') + .replace(PARAGRAPH_BREAK_RE, '. ') + .replace(SOFT_BREAK_RE, ' ') +} + +export function sanitizeTextForSpeech(text: string): string { + return normalizeLineBreaks(stripMarkdownTables(text)) + .replace(FENCED_CODE_RE, CODE_BLOCK_SUMMARY) + .replace(THINKING_PREFIX_RE, ' ') + .replace(MARKDOWN_LINK_RE, '$1') + .replace(INLINE_CODE_RE, '$1') + .replace(URL_RE, ' link ') + .replace(EMOJI_RE, ' ') + .replace(/^#{1,6}\s+/gm, '') + .replace(/[*_~>#]/g, '') + .replace(/^\s*[-+*]\s+/gm, '') + .replace(/\s+/g, ' ') + .trim() +} diff --git a/ui-desktop/src/lib/stable-array.ts b/ui-desktop/src/lib/stable-array.ts new file mode 100644 index 00000000..b1e415ed --- /dev/null +++ b/ui-desktop/src/lib/stable-array.ts @@ -0,0 +1,7 @@ +/** Keep `prev`'s reference when it's element-equal to `next`, so a nanostores + * `computed` (notifies on `!==`) skips the emit when its projected list didn't + * actually change — e.g. status-id sets recomputed on every stream delta. + * `next` is frozen: the ref is shared across ticks, so an in-place mutation + * would corrupt the cache — fail loud instead. */ +export const stableArray = <T>(prev: readonly T[], next: T[]): readonly T[] => + prev.length === next.length && prev.every((v, i) => v === next[i]) ? prev : Object.freeze(next) diff --git a/ui-desktop/src/lib/statusbar.tsx b/ui-desktop/src/lib/statusbar.tsx new file mode 100644 index 00000000..4c726f9a --- /dev/null +++ b/ui-desktop/src/lib/statusbar.tsx @@ -0,0 +1,81 @@ +import { useEffect, useState } from 'react' + +import { StableText } from '@/components/chat/stable-text' +import { compactNumber } from '@/lib/format' +import type { UsageStats } from '@/types/clawcodex' + +export function formatDuration(elapsedMs: number): string { + const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000)) + const seconds = totalSeconds % 60 + const minutes = Math.floor(totalSeconds / 60) % 60 + const hours = Math.floor(totalSeconds / 3600) + const ss = String(seconds).padStart(2, '0') + const mm = String(minutes).padStart(2, '0') + + return hours > 0 ? `${hours}:${mm}:${ss}` : `${minutes}:${ss}` +} + +export function compactPath(path: string, max = 44): string { + const trimmed = path.trim() + + if (trimmed.length <= max) { + return trimmed + } + + const segments = trimmed.split('/').filter(Boolean) + + if (segments.length < 2) { + return `…${trimmed.slice(-(max - 1))}` + } + + const tail = segments.slice(-2).join('/') + + return tail.length + 2 >= max ? `…${tail.slice(-(max - 1))}` : `…/${tail}` +} + +export function contextBar(percent: number | undefined, width = 10): string { + const bounded = Math.max(0, Math.min(100, percent ?? 0)) + const filled = Math.round((bounded / 100) * width) + + return `${'█'.repeat(filled)}${'░'.repeat(width - filled)}` +} + +export function usageContextLabel(usage: UsageStats): string { + if (usage.context_max) { + return `${compactNumber(usage.context_used ?? 0)}/${compactNumber(usage.context_max)}` + } + + return usage.total > 0 ? `${compactNumber(usage.total)} tok` : '' +} + +export function contextBarLabel(usage: UsageStats): string { + if (!usage.context_max) { + return '' + } + + const pct = Math.max(0, Math.min(100, Math.round(usage.context_percent ?? 0))) + + return `[${contextBar(usage.context_percent)}] ${pct}%` +} + +export function LiveDuration({ since }: { since: number | null | undefined }) { + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + if (!since) { + return + } + + const tick = () => setNow(Date.now()) + tick() + const timer = window.setInterval(tick, 1000) + + return () => window.clearInterval(timer) + }, [since]) + + if (!since) { + return null + } + + return <StableText>{formatDuration(now - since)}</StableText> +} diff --git a/ui-desktop/src/lib/storage.test.ts b/ui-desktop/src/lib/storage.test.ts new file mode 100644 index 00000000..fa74102e --- /dev/null +++ b/ui-desktop/src/lib/storage.test.ts @@ -0,0 +1,25 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { persistStringArray, storedStringArray } from './storage' + +describe('string array storage', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('removes the key for an empty array', () => { + window.localStorage.setItem('test.order', JSON.stringify(['a'])) + + persistStringArray('test.order', []) + + expect(window.localStorage.getItem('test.order')).toBeNull() + expect(storedStringArray('test.order')).toEqual([]) + }) + + it('persists non-empty arrays', () => { + persistStringArray('test.order', ['a', 'b']) + + expect(window.localStorage.getItem('test.order')).toBe(JSON.stringify(['a', 'b'])) + expect(storedStringArray('test.order')).toEqual(['a', 'b']) + }) +}) diff --git a/ui-desktop/src/lib/storage.ts b/ui-desktop/src/lib/storage.ts new file mode 100644 index 00000000..bc3eb13a --- /dev/null +++ b/ui-desktop/src/lib/storage.ts @@ -0,0 +1,158 @@ +// ── Persistence choke point ───────────────────────────────────────────────── +// Every persisted read/write in the app funnels through readKey/writeKey, so a +// single subscriber (telemetry, cross-window sync, an audit log) can observe all +// of it without instrumenting each call site. No listeners by default → no cost. + +export interface PersistenceEvent { + key: string + op: 'read' | 'remove' | 'write' + value: null | string +} + +type PersistenceListener = (event: PersistenceEvent) => void + +const persistenceListeners = new Set<PersistenceListener>() + +/** Observe every persisted get/set (e.g. pipe into telemetry/sync). */ +export function onPersistenceEvent(listener: PersistenceListener): () => void { + persistenceListeners.add(listener) + + return () => void persistenceListeners.delete(listener) +} + +function emitPersistence(event: PersistenceEvent) { + for (const listener of persistenceListeners) { + listener(event) + } +} + +/** Raw read. Returns null when absent or storage is unavailable. */ +export function readKey(key: string): null | string { + let value: null | string = null + + try { + value = window.localStorage.getItem(key) + } catch { + // Restricted contexts (private mode, disabled storage) read as absent. + } + + emitPersistence({ key, op: 'read', value }) + + return value +} + +/** Raw write. A null value removes the key. Best-effort. */ +export function writeKey(key: string, value: null | string) { + try { + if (value === null) { + window.localStorage.removeItem(key) + } else { + window.localStorage.setItem(key, value) + } + } catch { + // Storage is best-effort; never let a quota/permission error break the UI. + } + + emitPersistence({ key, op: value === null ? 'remove' : 'write', value }) +} + +/** Parsed JSON read. Returns null on absence, unavailable storage, OR malformed + * JSON — callers layer their own shape validation on the parsed value. */ +export function readJson<T>(key: string): T | null { + const raw = readKey(key) + + if (raw === null) { + return null + } + + try { + return JSON.parse(raw) as T + } catch { + return null + } +} + +/** JSON write; a null value removes the key. Best-effort (see writeKey). */ +export function writeJson(key: string, value: unknown) { + writeKey(key, value === null ? null : JSON.stringify(value)) +} + +export function storedBoolean(key: string, fallback: boolean): boolean { + const value = readKey(key) + + return value === null ? fallback : value === 'true' +} + +export function persistBoolean(key: string, value: boolean) { + writeKey(key, String(value)) +} + +export function storedString(key: string): null | string { + return readKey(key) +} + +export function persistString(key: string, value: null | string) { + writeKey(key, value) +} + +export function storedStringArray(key: string): string[] { + const value = readKey(key) + + if (!value) { + return [] + } + + try { + const parsed = JSON.parse(value) + + if (!Array.isArray(parsed)) { + return [] + } + + return parsed.filter((item): item is string => typeof item === 'string' && item.length > 0) + } catch { + return [] + } +} + +export function persistStringArray(key: string, value: string[]) { + writeKey(key, value.length === 0 ? null : JSON.stringify(value)) +} + +export function storedStringRecord(key: string): Record<string, string> { + const value = readKey(key) + + if (!value) { + return {} + } + + try { + const parsed = JSON.parse(value) + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {} + } + + return Object.fromEntries( + Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === 'string') + ) + } catch { + return {} + } +} + +export function persistStringRecord(key: string, value: Record<string, string>) { + writeKey(key, JSON.stringify(value)) +} + +export function arraysEqual(left: string[], right: string[]) { + return left.length === right.length && left.every((item, index) => item === right[index]) +} + +export function insertUniqueId(ids: string[], id: string, index: number) { + const next = ids.filter(item => item !== id) + const boundedIndex = Math.min(Math.max(index, 0), next.length) + next.splice(boundedIndex, 0, id) + + return next +} diff --git a/ui-desktop/src/lib/summarize-command.test.ts b/ui-desktop/src/lib/summarize-command.test.ts new file mode 100644 index 00000000..6e2c7c85 --- /dev/null +++ b/ui-desktop/src/lib/summarize-command.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' + +import { summarizeShellCommand } from './summarize-command' + +describe('summarizeShellCommand', () => { + it('strips a leading cd and trailing tail + status echo', () => { + expect( + summarizeShellCommand( + 'cd /Users/me/www/bb-rainbows && pnpm run lint 2>&1 | tail -10; echo "lint_exit=${PIPESTATUS[0]}"' + ) + ).toBe('pnpm run lint') + }) + + it('keeps flags on the surviving command', () => { + expect(summarizeShellCommand('cd /x && pnpm run preview --port 4317 2>&1')).toBe('pnpm run preview --port 4317') + }) + + it('drops a source/activate prefix', () => { + expect(summarizeShellCommand('source .venv/bin/activate && pytest -q')).toBe('pytest -q') + }) + + it('skips leading env assignments', () => { + expect(summarizeShellCommand('cd /x && NODE_ENV=test FOO=bar vitest run 2>&1 | tail -5')).toBe( + 'NODE_ENV=test FOO=bar vitest run' + ) + }) + + it('compacts a genuine multi-command compound without listing every command', () => { + const compound = 'git add -A && git commit -m "wip"' + expect(summarizeShellCommand(compound)).toBe('git add -A + 1 command') + }) + + it('leaves a single bare command untouched', () => { + expect(summarizeShellCommand('git status --short')).toBe('git status --short') + }) + + it('does not split on operators inside quotes', () => { + const cmd = 'git commit -m "fix: a | b && c"' + expect(summarizeShellCommand(cmd)).toBe(cmd) + }) + + it('does not strip a redirection-looking char inside quotes', () => { + expect(summarizeShellCommand('cd /x && git commit -m "a > b"')).toBe('git commit -m "a > b"') + }) + + it('handles empty / whitespace input', () => { + expect(summarizeShellCommand('')).toBe('') + expect(summarizeShellCommand(' ')).toBe('') + }) + + it('returns the original when every segment is plumbing', () => { + const allSetup = 'cd /x && export FOO=1' + expect(summarizeShellCommand(allSetup)).toBe(allSetup) + }) + + it('collapses 2>&1 redirection on a plain pipeline', () => { + expect(summarizeShellCommand('cd /x && tsc --noEmit 2>&1 | tail -20')).toBe('tsc --noEmit') + }) + + it('drops a leading echo banner around a single command', () => { + expect( + summarizeShellCommand( + 'echo "--- proto pnpm direct ---"; ~/.proto/tools/node/24.11.0/bin/pnpm --version 2>&1 | tail -3' + ) + ).toBe('~/.proto/tools/node/24.11.0/bin/pnpm --version') + }) + + it('drops echo banners on both sides plus the trailing status echo', () => { + expect(summarizeShellCommand('echo "--- build ---"; npm run build 2>&1 | tail -5; echo "build_exit=$?"')).toBe( + 'npm run build' + ) + }) + + it('compacts a genuine multi-command probe from session 20260624_231846_bdbd1e', () => { + const probe = 'which node pnpm corepack; node -v; corepack --version 2>&1' + expect(summarizeShellCommand(probe)).toBe('which node pnpm corepack + 2 commands') + }) + + it('compacts the corepack diagnostic command from session 20260624_231846_bdbd1e', () => { + expect( + summarizeShellCommand( + 'which node pnpm corepack; node -v; echo "---"; corepack --version 2>&1; echo "---pnpm via corepack---"; pnpm --version 2>&1 | tail -5' + ) + ).toBe('which node pnpm corepack + 3 commands') + }) + + it('compacts the proto/cache probe from session 20260624_231846_bdbd1e', () => { + expect( + summarizeShellCommand( + 'echo "--- proto pnpm direct ---"; ~/.proto/tools/node/24.11.0/bin/pnpm --version 2>&1 | tail -3; echo "--- proto node ---"; ls ~/.proto/tools/node/ 2>&1; echo "--- corepack cache ---"; ls ~/.cache/node/corepack/v1/pnpm/ 2>&1' + ) + ).toBe('~/.proto/tools/node/24.11.0/bin/pnpm --version + 2 commands') + }) + + it('summarizes the successful lint command from session 20260624_231846_bdbd1e', () => { + expect( + summarizeShellCommand( + 'cd /Users/brooklyn/www/bb-rainbows && pnpm run lint 2>&1 | tail -20; echo "lint_exit=${PIPESTATUS[0]}"' + ) + ).toBe('pnpm run lint') + }) + + it('summarizes a background build command from session 20260624_231846_bdbd1e', () => { + expect( + summarizeShellCommand( + 'cd /Users/brooklyn/www/bb-rainbows && pnpm run build 2>&1 | tail -20; echo "build_exit=${PIPESTATUS[0]}"' + ) + ).toBe('pnpm run build') + }) +}) diff --git a/ui-desktop/src/lib/summarize-command.ts b/ui-desktop/src/lib/summarize-command.ts new file mode 100644 index 00000000..0030f214 --- /dev/null +++ b/ui-desktop/src/lib/summarize-command.ts @@ -0,0 +1,216 @@ +// Adapted from condensed-milk-pi's command dispatcher: split compounds first, +// strip pipe tails (`| head`, `| tail`, ...), then clean redirects/env prefixes +// before deciding which segment is meaningful. This is display-only; the full +// command remains available through Copy / detail. +const SILENT_HEADS = new Set(['cd', 'pushd', 'popd', 'export', 'set', 'unset', 'source', '.', 'true', 'false', ':']) +const PIPE_TAIL_HEADS = new Set(['head', 'tail', 'wc', 'sort', 'uniq']) + +const basename = (head: string): string => head.split('/').pop() || head + +// Split on command-chain separators, but NOT pipe. A pipe usually belongs to +// the segment's output plumbing (`cmd 2>&1 | tail -20`); condensed-milk strips +// that after segmenting instead of treating it as a separate producer. +function splitCompoundCommand(input: string): string[] { + const segments: string[] = [] + let buf = '' + let quote: '"' | "'" | null = null + + for (let i = 0; i < input.length; i += 1) { + const ch = input[i]! + + if (quote) { + buf += ch + + if (ch === quote && input[i - 1] !== '\\') { + quote = null + } + + continue + } + + if (ch === '"' || ch === "'") { + quote = ch + buf += ch + + continue + } + + const op = + input.startsWith('&&', i) || input.startsWith('||', i) + ? input.slice(i, i + 2) + : ch === ';' || ch === '\n' + ? ch + : '' + + if (op) { + segments.push(buf) + buf = '' + i += op.length - 1 + + continue + } + + buf += ch + } + + segments.push(buf) + + return segments.map(segment => stripPipeTail(segment.trim())).filter(Boolean) +} + +function splitWords(segment: string): string[] { + const words: string[] = [] + let buf = '' + let quote: '"' | "'" | null = null + + for (let i = 0; i < segment.length; i += 1) { + const ch = segment[i]! + + if (quote) { + buf += ch + + if (ch === quote && segment[i - 1] !== '\\') { + quote = null + } + + continue + } + + if (ch === '"' || ch === "'") { + quote = ch + buf += ch + + continue + } + + if (/\s/.test(ch)) { + if (buf) { + words.push(buf) + buf = '' + } + + continue + } + + buf += ch + } + + if (buf) { + words.push(buf) + } + + return words +} + +// The command word of a segment, skipping any `FOO=bar` env assignments. +function headWord(segment: string): string { + const tokens = splitWords(segment) + let index = 0 + + while (index < tokens.length && /^[A-Za-z_]\w*=/.test(tokens[index]!)) { + index += 1 + } + + return basename(tokens[index] ?? '') +} + +function stripPipeTail(segment: string): string { + const words = splitWords(segment) + const out: string[] = [] + + for (let i = 0; i < words.length; i += 1) { + const word = words[i]! + + if (word === '|' && PIPE_TAIL_HEADS.has(basename(words[i + 1] ?? ''))) { + break + } + + out.push(word) + } + + return out.join(' ').trim() +} + +function cleanSegment(segment: string): string { + const words = splitWords(segment) + const out: string[] = [] + + for (let i = 0; i < words.length; i += 1) { + const word = words[i]! + + if (/^\d*(?:>>?|<)$/.test(word)) { + i += 1 + + continue + } + + if (/^\d*(?:>&|<&)\d+$/.test(word) || /^\d*>&\d+$/.test(word)) { + continue + } + + out.push(word) + } + + return out.join(' ').trim() +} + +function isBoundaryEcho(segment: string): boolean { + const words = splitWords(segment) + + if (basename(words[0] ?? '') !== 'echo') { + return false + } + + // Banner/status echoes are UI plumbing. Do not treat arbitrary `echo $VALUE` + // as noise; it may be the command's actual output. + const rest = words.slice(1).join(' ') + + return /-{2,}|_exit=|(?:^|\s|=)\$[?{]|PIPESTATUS/.test(rest) +} + +/** + * Reduce a verbose shell command to the "main" command, for display only. + * + * Agents wrap real work in plumbing — `cd <dir> && <cmd> 2>&1 | tail -N; echo + * "x_exit=${PIPESTATUS[0]}"` — which buries the command the user actually cares + * about. This peels that wrapper off using small head-word allowlists instead of + * one giant regex: + * + * 1. split into segments on top-level `&&` `||` `;` (quote-aware) + * 2. strip trailing pipe tails (`| head`, `| tail`, `| wc`, ...) + * 3. clean env var prefixes / redirects + * 4. drop setup/banner/status segments + * + * If one real command survives, show it. If multiple real commands survive, + * show a short `first command + N commands` label instead of flooding the row + * with every probe. The full command is always still available via Copy/detail. + */ +export function summarizeShellCommand(raw: string): string { + const original = (raw ?? '').trim() + + if (!original) { + return '' + } + + const segments = splitCompoundCommand(original) + + if (segments.length <= 1) { + return cleanSegment(original) || original + } + + const core = segments.map(cleanSegment).filter(segment => { + const head = headWord(segment) + + return segment && !SILENT_HEADS.has(head) && !isBoundaryEcho(segment) + }) + + if (core.length === 0) { + return original + } + + if (core.length === 1) { + return core[0]! + } + + return `${core[0]} + ${core.length - 1} ${core.length === 2 ? 'command' : 'commands'}` +} diff --git a/ui-desktop/src/lib/svg-image.ts b/ui-desktop/src/lib/svg-image.ts new file mode 100644 index 00000000..77c2b075 --- /dev/null +++ b/ui-desktop/src/lib/svg-image.ts @@ -0,0 +1,56 @@ +// Rasterise an SVG string to PNG and copy it to the clipboard. Self-contained +// SVGs only (inline styles) — mermaid output qualifies. Falls back to copying +// the SVG markup as text where image clipboard writes aren't permitted. + +function svgSize(svg: string): { height: number; width: number } { + const el = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement + const width = parseFloat(el.getAttribute('width') || '') + const height = parseFloat(el.getAttribute('height') || '') + + if (width && height) { + return { height, width } + } + + const [, , vbW, vbH] = (el.getAttribute('viewBox') || '').split(/[\s,]+/).map(Number) + + return vbW && vbH ? { height: vbH, width: vbW } : { height: 600, width: 800 } +} + +export function svgToPngBlob(svg: string, scale = 2): Promise<Blob> { + const { height, width } = svgSize(svg) + + return new Promise((resolve, reject) => { + const image = new Image() + + image.onload = () => { + const canvas = document.createElement('canvas') + canvas.width = Math.max(1, Math.round(width * scale)) + canvas.height = Math.max(1, Math.round(height * scale)) + + const ctx = canvas.getContext('2d') + + if (!ctx) { + reject(new Error('no 2d context')) + + return + } + + ctx.scale(scale, scale) + ctx.drawImage(image, 0, 0, width, height) + canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error('toBlob failed'))), 'image/png') + } + + image.onerror = () => reject(new Error('svg load failed')) + image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` + }) +} + +export async function copySvgAsPng(svg: string): Promise<void> { + try { + const blob = await svgToPngBlob(svg) + + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]) + } catch { + await navigator.clipboard.writeText(svg) + } +} diff --git a/ui-desktop/src/lib/text.ts b/ui-desktop/src/lib/text.ts new file mode 100644 index 00000000..815a5d73 --- /dev/null +++ b/ui-desktop/src/lib/text.ts @@ -0,0 +1,15 @@ +// Canonical text micro-helpers. Do not redefine these per-page. + +export const asText = (v: unknown): string => (typeof v === 'string' ? v : v == null ? '' : String(v)) + +export const includesQuery = (v: unknown, q: string) => asText(v).toLowerCase().includes(q) + +export const prettyName = (v: string) => v.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) + +/** Search-key normalization: the exact `value.trim().toLowerCase()` idiom that + * was hand-written at ~30 filter/lookup sites. */ +export const normalize = (v: unknown): string => asText(v).trim().toLowerCase() + +/** Uppercase the first character, leave the rest. Matches the + * `s.charAt(0).toUpperCase() + s.slice(1)` idiom (empty-safe). */ +export const capitalize = (v: string): string => (v ? v.charAt(0).toUpperCase() + v.slice(1) : v) diff --git a/ui-desktop/src/lib/thinking-sound.test.ts b/ui-desktop/src/lib/thinking-sound.test.ts new file mode 100644 index 00000000..87291731 --- /dev/null +++ b/ui-desktop/src/lib/thinking-sound.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/store/haptics', () => ({ $hapticsMuted: { get: vi.fn(() => false) } })) +vi.mock('@/clawcodex', () => ({ + getClawCodexConfigRecord: vi.fn(async () => ({})), + saveClawCodexConfig: vi.fn(async () => undefined) +})) + +import { $hapticsMuted } from '@/store/haptics' +import { $thinkingSoundEnabled } from '@/store/voice-prefs' + +import { isThinkingSoundActive, startThinkingSound, stopThinkingSound } from './thinking-sound' + +class FakeOscillator { + type = 'sine' + frequency = { exponentialRampToValueAtTime: vi.fn(), setValueAtTime: vi.fn() } + connect = vi.fn() + start = vi.fn() + stop = vi.fn() +} + +class FakeGain { + gain = { exponentialRampToValueAtTime: vi.fn(), setValueAtTime: vi.fn() } + connect = vi.fn() +} + +const started: FakeOscillator[] = [] + +class FakeAudioContext { + currentTime = 0 + destination = {} + state = 'running' + + createOscillator() { + const osc = new FakeOscillator() + + started.push(osc) + + return osc + } + + createGain() { + return new FakeGain() + } + + resume() { + return Promise.resolve() + } +} + +describe('thinking-sound', () => { + beforeEach(() => { + vi.useFakeTimers() + started.length = 0 + $thinkingSoundEnabled.set(true) + vi.mocked($hapticsMuted.get).mockReturnValue(false) + vi.stubGlobal('AudioContext', FakeAudioContext) + }) + + afterEach(() => { + stopThinkingSound() + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + it('plays repeating blips while active and stops instantly', () => { + startThinkingSound() + expect(isThinkingSoundActive()).toBe(true) + + vi.advanceTimersByTime(5_000) + expect(started.length).toBeGreaterThanOrEqual(3) + + const count = started.length + + stopThinkingSound() + expect(isThinkingSoundActive()).toBe(false) + vi.advanceTimersByTime(5_000) + expect(started.length).toBe(count) // nothing after stop + }) + + it('respects the voice.thinking_sound config gate', () => { + $thinkingSoundEnabled.set(false) + startThinkingSound() + expect(isThinkingSoundActive()).toBe(false) + vi.advanceTimersByTime(3_000) + expect(started.length).toBe(0) + }) + + it('stays silent while sounds are muted but keeps the loop alive', () => { + vi.mocked($hapticsMuted.get).mockReturnValue(true) + startThinkingSound() + vi.advanceTimersByTime(3_000) + expect(started.length).toBe(0) + + // Unmute mid-loop → blips resume without a restart. + vi.mocked($hapticsMuted.get).mockReturnValue(false) + vi.advanceTimersByTime(3_000) + expect(started.length).toBeGreaterThan(0) + }) + + it('start is idempotent', () => { + startThinkingSound() + startThinkingSound() + vi.advanceTimersByTime(1_300) + + // One loop: at most ~2 blips in 1.3s (first at 400ms, next ≥800ms later). + expect(started.length).toBeLessThanOrEqual(2) + }) + + it('never throws when WebAudio is unavailable', () => { + vi.stubGlobal('AudioContext', undefined) + expect(() => { + startThinkingSound() + vi.advanceTimersByTime(2_000) + }).not.toThrow() + stopThinkingSound() + }) +}) diff --git a/ui-desktop/src/lib/thinking-sound.ts b/ui-desktop/src/lib/thinking-sound.ts new file mode 100644 index 00000000..6a4d2bd6 --- /dev/null +++ b/ui-desktop/src/lib/thinking-sound.ts @@ -0,0 +1,108 @@ +// Ambient "thinking" sound for the desktop voice conversation. While the agent +// works (status === 'thinking') no audio flows, which reads as "it died" during +// long thinking/tool stretches. A calm, quiet, repeating pair of soft bubble +// blips fills the gap — same WebAudio oscillator synthesis approach as +// wake-sound.ts / completion-sound.ts (no asset to ship), mirroring the +// backend's numpy-synthesized blips in tools/voice_mode.py so CLI and desktop +// sound alike. +// +// Honours the shared sound-mute toggle ($hapticsMuted) and the +// voice.thinking_sound config gate ($thinkingSoundEnabled). Stops instantly on +// stopThinkingSound() — callers fire it the moment TTS starts, the mic re-arms, +// or the conversation ends. + +import { $hapticsMuted } from '@/store/haptics' +import { $thinkingSoundEnabled } from '@/store/voice-prefs' + +let ctx: AudioContext | null = null +let timer: number | null = null +let blipIndex = 0 + +function getCtx(): AudioContext | null { + if (typeof window === 'undefined') { + return null + } + + try { + if (!ctx) { + const Ctor = + window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + + if (!Ctor) { + return null + } + + ctx = new Ctor() + } + + if (ctx.state === 'suspended') { + void ctx.resume().catch(() => undefined) + } + + return ctx + } catch { + return null + } +} + +// One soft "blub": short sine with a gentle downward pitch glide and a smooth +// attack into an exponential decay — no clicks, deliberately quiet. +function blub(ac: AudioContext, freq: number) { + const t0 = ac.currentTime + 0.01 + const dur = 0.16 + const osc = ac.createOscillator() + const env = ac.createGain() + + osc.type = 'sine' + osc.frequency.setValueAtTime(freq, t0) + osc.frequency.exponentialRampToValueAtTime(freq * 0.72, t0 + dur) + + env.gain.setValueAtTime(0.0001, t0) + env.gain.exponentialRampToValueAtTime(0.08, t0 + 0.02) + env.gain.exponentialRampToValueAtTime(0.0001, t0 + dur) + + osc.connect(env) + env.connect(ac.destination) + osc.start(t0) + osc.stop(t0 + dur + 0.02) +} + +export function isThinkingSoundActive(): boolean { + return timer !== null +} + +/** Start the repeating thinking blips (idempotent). Best-effort, never throws. */ +export function startThinkingSound(): void { + if (timer !== null || !$thinkingSoundEnabled.get()) { + return + } + + const tick = () => { + if ($hapticsMuted.get() === false) { + const ac = getCtx() + + if (ac) { + try { + // Alternate two calm pitches (G4 / E4), matching the backend blips. + blub(ac, blipIndex % 2 === 0 ? 392 : 329.6) + } catch { + // Audio backend unavailable — stay silent, keep the loop harmless. + } + } + } + + blipIndex += 1 + // ~0.8-1.2s spacing with slight randomization so it reads organic. + timer = window.setTimeout(tick, 800 + Math.random() * 400) + } + + timer = window.setTimeout(tick, 400) +} + +/** Stop the thinking blips instantly (idempotent). */ +export function stopThinkingSound(): void { + if (timer !== null) { + window.clearTimeout(timer) + timer = null + } +} diff --git a/ui-desktop/src/lib/time.test.ts b/ui-desktop/src/lib/time.test.ts new file mode 100644 index 00000000..f767934c --- /dev/null +++ b/ui-desktop/src/lib/time.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' + +import { calendarBucket, DAY, formatAgo, HOUR, MINUTE, nominalDayStart, SECOND, sessionBucketLabel } from './time' + +const labels = { + ageNow: 'now', + ageSeconds: (s: number) => `${s}s ago`, + ageMinutes: (m: number) => `${m}m ago`, + ageHours: (h: number) => `${h}h ago`, + ageDays: (d: number) => `${d}d ago` +} + +const now = 1_000 * DAY +const ago = (delta: number) => formatAgo(now - delta, labels, now) + +describe('formatAgo', () => { + it('reads "now" under two seconds, then seconds', () => { + expect(ago(0)).toBe('now') + expect(ago(1.5 * SECOND)).toBe('now') + expect(ago(5 * SECOND)).toBe('5s ago') + }) + + it('buckets to the coarsest unit, floored', () => { + expect(ago(3 * MINUTE)).toBe('3m ago') + expect(ago(2 * HOUR + 59 * MINUTE)).toBe('2h ago') + expect(ago(5 * DAY)).toBe('5d ago') + }) + + it('clamps future timestamps to "now"', () => { + expect(ago(-HOUR)).toBe('now') + }) +}) + +// Thursday 18 Jun 2026, local noon (15 Jun 2026 is a Monday). +const THU_NOON = new Date(2026, 5, 18, 12, 0, 0).getTime() + +const secondsAt = (year: number, month: number, day: number, hour = 10) => + Math.floor(new Date(year, month, day, hour, 0, 0).getTime() / 1000) + +describe('nominalDayStart', () => { + it('rolls the day boundary at 4 AM, not midnight', () => { + // 1 AM Saturday still belongs to Friday's run. + expect(nominalDayStart(new Date(2026, 5, 20, 1, 30).getTime())).toBe(new Date(2026, 5, 19).getTime()) + expect(nominalDayStart(new Date(2026, 5, 20, 4, 30).getTime())).toBe(new Date(2026, 5, 20).getTime()) + }) +}) + +describe('calendarBucket', () => { + // Monday week start: the current week began Mon 15 Jun, last week is Jun 8-14. + const MONDAY = 1 + + const kindAt = (year: number, month: number, day: number, hour = 10) => + calendarBucket(secondsAt(year, month, day, hour), THU_NOON, MONDAY).kind + + it('buckets the current day (and, defensively, the future) as today', () => { + // The head run normally absorbs these; "Earlier today" covers the rest. + expect(kindAt(2026, 5, 18, 5)).toBe('today') + expect(kindAt(2026, 5, 18, 23)).toBe('today') + expect(kindAt(2026, 5, 19)).toBe('today') + }) + + it('assigns the small hours to the previous evening', () => { + // 1 AM today (before the 4 AM rollover) is part of yesterday's run. + expect(kindAt(2026, 5, 18, 1)).toBe('yesterday') + + // And viewed at 00:58, last evening's sessions are still the current day. + const smallHours = new Date(2026, 5, 19, 0, 58).getTime() + + expect(calendarBucket(secondsAt(2026, 5, 18, 23), smallHours, MONDAY).kind).toBe('today') + expect(calendarBucket(secondsAt(2026, 5, 18, 10), smallHours, MONDAY).kind).toBe('today') + expect(calendarBucket(secondsAt(2026, 5, 17, 15), smallHours, MONDAY).kind).toBe('yesterday') + }) + + it('uses coarse, non-overlapping ranges that coarsen with age', () => { + expect(kindAt(2026, 5, 17)).toBe('yesterday') + expect(kindAt(2026, 5, 16)).toBe('thisWeek') // Tue this week + expect(kindAt(2026, 5, 15)).toBe('thisWeek') // Mon this week + expect(kindAt(2026, 5, 14)).toBe('lastWeek') // Sun last week + expect(kindAt(2026, 5, 8)).toBe('lastWeek') // Mon last week + expect(kindAt(2026, 5, 7)).toBe('thisMonth') // earlier in June + expect(kindAt(2026, 5, 1)).toBe('thisMonth') + expect(kindAt(2026, 4, 28)).toBe('month') // May, same year + expect(kindAt(2025, 11, 3)).toBe('monthYear') // December, prior year + }) + + it('respects a Sunday week start', () => { + // With the week starting Sun 14 Jun, that Sunday is this week, not last. + expect(calendarBucket(secondsAt(2026, 5, 14), THU_NOON, 0).kind).toBe('thisWeek') + expect(calendarBucket(secondsAt(2026, 5, 13), THU_NOON, 0).kind).toBe('lastWeek') + }) + + it('keys same-month sessions together and disambiguates across years', () => { + expect(calendarBucket(secondsAt(2026, 2, 3), THU_NOON, MONDAY).key).toBe('m-2026-2') + expect(calendarBucket(secondsAt(2026, 2, 20), THU_NOON, MONDAY).key).toBe('m-2026-2') + expect(calendarBucket(secondsAt(2025, 2, 3), THU_NOON, MONDAY).key).toBe('my-2025-2') + }) +}) + +describe('sessionBucketLabel', () => { + const labels = { + lastWeek: 'Last week', + thisMonth: 'Earlier this month', + thisWeek: 'Earlier this week', + today: 'Earlier today', + yesterday: 'Yesterday' + } + + const labelAt = (year: number, month: number, day: number) => + sessionBucketLabel(calendarBucket(secondsAt(year, month, day), THU_NOON, 1), labels) + + it('uses fixed labels for the relative buckets', () => { + expect(labelAt(2026, 5, 18)).toBe('Earlier today') + expect(labelAt(2026, 5, 17)).toBe('Yesterday') + expect(labelAt(2026, 5, 16)).toBe('Earlier this week') + expect(labelAt(2026, 5, 10)).toBe('Last week') + expect(labelAt(2026, 5, 2)).toBe('Earlier this month') + }) + + it('formats month (same year) and month + year (prior year) via Intl', () => { + // en-US default in the test env: month name, plus year for the prior year. + expect(labelAt(2026, 2, 3)).toBe('March') + expect(labelAt(2025, 11, 3)).toBe('December 2025') + }) +}) diff --git a/ui-desktop/src/lib/time.ts b/ui-desktop/src/lib/time.ts new file mode 100644 index 00000000..5329b183 --- /dev/null +++ b/ui-desktop/src/lib/time.ts @@ -0,0 +1,238 @@ +// Canonical time/date formatting. Shared `Intl` instances (created once, not +// per-render) + relative-time helpers. Every surface that shows a timestamp or +// an age pulls from here so the rendered strings stay consistent app-wide. + +export const SECOND = 1000 +export const MINUTE = 60_000 +export const HOUR = 3_600_000 +export const DAY = 86_400_000 + +// ── Absolute date/time formatters ────────────────────────────────────────── +// `hh:mm` clock (thread today/yesterday lines). +export const fmtClock = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }) + +// Compact "day + clock", no year/seconds (artifacts, thread fallback, cron runs). +export const fmtDayTime = new Intl.DateTimeFormat(undefined, { + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + month: 'short' +}) + +// Medium date + short time (command center session detail). +export const fmtDateTime = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }) + +// Date only, "5 Jun 2026" (starmap tooltip). +export const fmtDate = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) + +// Month name alone / with year — session-list date-bucket dividers ("September", +// "September 2025"). +export const fmtMonth = new Intl.DateTimeFormat(undefined, { month: 'long' }) +export const fmtMonthYear = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' }) + +// ── Relative time ────────────────────────────────────────────────────────── +const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' }) + +// Localized bidirectional "in 5 min" / "2 hr ago" — coarsest sensible unit so a +// daily job reads "in 14 hr", not "in 840 min". +export function relativeTime(targetMs: number, nowMs = Date.now()): string { + const diff = targetMs - nowMs + const abs = Math.abs(diff) + const sign = diff < 0 ? -1 : 1 + + if (abs < MINUTE) { + return rtf.format(sign * Math.round(abs / SECOND), 'second') + } + + if (abs < HOUR) { + return rtf.format(sign * Math.round(abs / MINUTE), 'minute') + } + + if (abs < DAY) { + return rtf.format(sign * Math.round(abs / HOUR), 'hour') + } + + return rtf.format(sign * Math.round(abs / DAY), 'day') +} + +// A dated divider bucket below the sidebar's unlabelled "recent" head cluster +// (see session-date-groups.ts for the clustering). Buckets are coarse, +// non-overlapping calendar ranges — one divider per *cluster* of activity, +// never one per day, and never a rolling window like "previous 7 days" that +// semantically overlaps the groups above it. `kind` drives the label; `at` is +// the session's nominal day start (ms) for month formatting. +export type SessionBucketKind = 'lastWeek' | 'month' | 'monthYear' | 'thisMonth' | 'thisWeek' | 'today' | 'yesterday' + +export interface SessionBucket { + at: number + key: string + kind: SessionBucketKind +} + +// Fixed divider labels, resolved from i18n (month labels come from Intl). +export interface SessionBucketLabels { + lastWeek: string + thisMonth: string + thisWeek: string + today: string + yesterday: string +} + +export const startOfLocalDay = (ms: number): number => { + const d = new Date(ms) + + return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() +} + +// The human day doesn't end at midnight — it ends when you sleep. Sessions +// from the small hours belong to the previous evening's run, so the day +// boundary sits at 4 AM local (same trick activity/sleep trackers use). +// A 12:30 AM session groups with 11:50 PM instead of splitting off. +export const DAY_ROLLOVER_HOUR = 4 + +// Start of the *nominal* local day a timestamp belongs to, honoring the 4 AM +// rollover: Saturday 1 AM → start of Friday. +export const nominalDayStart = (ms: number): number => startOfLocalDay(ms - DAY_ROLLOVER_HOUR * HOUR) + +// Locale-aware first day of week in JS getDay() convention (0=Sun … 6=Sat). +// Intl.Locale weekInfo reports 1=Mon … 7=Sun; unsupported → Monday. +export function localeWeekStartDay(): number { + try { + const locale = new Intl.Locale(new Intl.DateTimeFormat().resolvedOptions().locale) + const withWeekInfo = locale as { getWeekInfo?: () => { firstDay?: number }; weekInfo?: { firstDay?: number } } + const firstDay = (withWeekInfo.getWeekInfo?.() ?? withWeekInfo.weekInfo)?.firstDay + + return typeof firstDay === 'number' ? firstDay % 7 : 1 + } catch { + return 1 + } +} + +// Start of the local calendar week containing `ms` (DST-safe Date field math). +export function startOfLocalWeek(ms: number, weekStartsOn: number): number { + const d = new Date(startOfLocalDay(ms)) + const back = (d.getDay() - weekStartsOn + 7) % 7 + + return new Date(d.getFullYear(), d.getMonth(), d.getDate() - back).getTime() +} + +// Coarse calendar bucket for a Unix-seconds timestamp. Granularity coarsens +// with age: earlier today → yesterday → earlier this week → last week → +// earlier this month → month → month + year. Empty ranges simply never emit a +// bucket, so a sparse tail jumps straight to its month or month-year. The +// newest run of sessions never reaches here (it is the unlabelled head — see +// session-date-groups.ts), which is what makes "Earlier today" truthful. +export function calendarBucket( + seconds: number, + nowMs = Date.now(), + weekStartsOn = localeWeekStartDay() +): SessionBucket { + const nominal = nominalDayStart(seconds * SECOND) + const todayNominal = nominalDayStart(nowMs) + const dayDiff = Math.round((todayNominal - nominal) / DAY) + + if (dayDiff <= 0) { + return { at: nominal, key: 'today', kind: 'today' } + } + + if (dayDiff === 1) { + return { at: nominal, key: 'yesterday', kind: 'yesterday' } + } + + const weekStart = startOfLocalWeek(todayNominal, weekStartsOn) + + if (nominal >= weekStart) { + return { at: nominal, key: 'this-week', kind: 'thisWeek' } + } + + const ws = new Date(weekStart) + + if (nominal >= new Date(ws.getFullYear(), ws.getMonth(), ws.getDate() - 7).getTime()) { + return { at: nominal, key: 'last-week', kind: 'lastWeek' } + } + + const d = new Date(nominal) + const now = new Date(todayNominal) + const sameYear = d.getFullYear() === now.getFullYear() + + if (sameYear && d.getMonth() === now.getMonth()) { + return { at: nominal, key: 'this-month', kind: 'thisMonth' } + } + + const ym = `${d.getFullYear()}-${d.getMonth()}` + + return sameYear ? { at: nominal, key: `m-${ym}`, kind: 'month' } : { at: nominal, key: `my-${ym}`, kind: 'monthYear' } +} + +// Localized divider label for a bucket: fixed relative strings from i18n, +// Intl-formatted month / month-year for the rest. +export function sessionBucketLabel(bucket: SessionBucket, labels: SessionBucketLabels): string { + switch (bucket.kind) { + case 'today': + return labels.today + + case 'yesterday': + return labels.yesterday + + case 'thisWeek': + return labels.thisWeek + + case 'lastWeek': + return labels.lastWeek + + case 'thisMonth': + return labels.thisMonth + + case 'month': + return fmtMonth.format(bucket.at) + + case 'monthYear': + return fmtMonthYear.format(bucket.at) + } +} + +export type ElapsedUnit = 'day' | 'hour' | 'minute' | 'second' + +// Coarsest elapsed bucket for a (clamped-nonnegative) duration, floored. The +// caller owns rendering — compact "5m", "5m ago", etc. — so no format is baked +// in here. +export function coarseElapsed(deltaMs: number): { unit: ElapsedUnit; value: number } { + const ms = Math.max(0, deltaMs) + + if (ms >= DAY) { + return { unit: 'day', value: Math.floor(ms / DAY) } + } + + if (ms >= HOUR) { + return { unit: 'hour', value: Math.floor(ms / HOUR) } + } + + if (ms >= MINUTE) { + return { unit: 'minute', value: Math.floor(ms / MINUTE) } + } + + return { unit: 'second', value: Math.floor(ms / SECOND) } +} + +// Localized strings for `formatAgo`; shaped to accept `t.agents` directly. +export interface AgoLabels { + ageNow: string + ageSeconds: (seconds: number) => string + ageMinutes: (minutes: number) => string + ageHours: (hours: number) => string + ageDays: (days: number) => string +} + +// Compact localized "2h ago" / "3m ago" / "now" for a past timestamp, bucketed +// via `coarseElapsed` so every age label reads consistently. +export function formatAgo(fromMs: number, labels: AgoLabels, nowMs = Date.now()): string { + const { unit, value } = coarseElapsed(nowMs - fromMs) + + if (unit === 'second') { + return value < 2 ? labels.ageNow : labels.ageSeconds(value) + } + + const by = { day: labels.ageDays, hour: labels.ageHours, minute: labels.ageMinutes } + + return by[unit](value) +} diff --git a/ui-desktop/src/lib/todos.test.ts b/ui-desktop/src/lib/todos.test.ts new file mode 100644 index 00000000..a19752c7 --- /dev/null +++ b/ui-desktop/src/lib/todos.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' + +import { latestSessionTodos, parseTodos } from './todos' + +describe('parseTodos', () => { + it('parses todo arrays with valid ids, content, and statuses', () => { + expect( + parseTodos([ + { content: 'Gather ingredients', id: 'prep', status: 'completed' }, + { content: 'Boil water', id: 'boil', status: 'in_progress' }, + { content: 'Serve', id: 'serve', status: 'pending' } + ]) + ).toEqual([ + { content: 'Gather ingredients', id: 'prep', status: 'completed' }, + { content: 'Boil water', id: 'boil', status: 'in_progress' }, + { content: 'Serve', id: 'serve', status: 'pending' } + ]) + }) + + it('parses nested todo payloads from wrapped objects and JSON strings', () => { + expect(parseTodos({ todos: [{ content: 'Plate', id: 'plate', status: 'pending' }] })).toEqual([ + { content: 'Plate', id: 'plate', status: 'pending' } + ]) + + expect(parseTodos('{"todos":[{"id":"plate","content":"Plate","status":"pending"}]}')).toEqual([ + { content: 'Plate', id: 'plate', status: 'pending' } + ]) + }) + + it('returns null for non-todo payloads', () => { + expect(parseTodos(undefined)).toBeNull() + expect(parseTodos('not json')).toBeNull() + expect(parseTodos({ message: 'no todos here' })).toBeNull() + }) +}) + +describe('latestSessionTodos', () => { + const todoPart = (todos: unknown, extra: Record<string, unknown> = {}) => ({ + type: 'tool-call', + toolCallId: 't1', + toolName: 'todo', + args: { todos }, + ...extra + }) + + it('returns the last todo list across the transcript (result beats args)', () => { + const messages = [ + { parts: [todoPart([{ content: 'Old', id: 'a', status: 'pending' }])] }, + { parts: [{ type: 'text', text: 'hi' }] }, + { + parts: [ + todoPart([{ content: 'Stale', id: 'a', status: 'pending' }], { + result: { todos: [{ content: 'Fresh', id: 'a', status: 'completed' }] } + }) + ] + } + ] + + expect(latestSessionTodos(messages)).toEqual([{ content: 'Fresh', id: 'a', status: 'completed' }]) + }) + + it('prefers the live carried `todos` field over args', () => { + const messages = [ + { + parts: [ + todoPart([{ content: 'Args', id: 'a', status: 'pending' }], { + todos: [{ content: 'Live', id: 'a', status: 'in_progress' }] + }) + ] + } + ] + + expect(latestSessionTodos(messages)).toEqual([{ content: 'Live', id: 'a', status: 'in_progress' }]) + }) + + it('returns null when no todo tool calls exist', () => { + expect(latestSessionTodos([{ parts: [{ type: 'text', text: 'hi' }] }])).toBeNull() + expect(latestSessionTodos([])).toBeNull() + }) +}) diff --git a/ui-desktop/src/lib/todos.ts b/ui-desktop/src/lib/todos.ts new file mode 100644 index 00000000..6a5d8eea --- /dev/null +++ b/ui-desktop/src/lib/todos.ts @@ -0,0 +1,88 @@ +export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled' + +export interface TodoItem { + content: string + id: string + status: TodoStatus +} + +const STATUSES: readonly TodoStatus[] = ['pending', 'in_progress', 'completed', 'cancelled'] + +const isRecord = (v: unknown): v is Record<string, unknown> => Boolean(v && typeof v === 'object' && !Array.isArray(v)) +const isStatus = (v: unknown): v is TodoStatus => (STATUSES as readonly string[]).includes(v as string) + +function parseArray(value: unknown[]): TodoItem[] { + return value.flatMap(item => { + if (!isRecord(item) || !isStatus(item.status)) { + return [] + } + + const id = String(item.id ?? '').trim() + const content = String(item.content ?? '').trim() + + return id && content ? [{ content, id, status: item.status }] : [] + }) +} + +function parse(value: unknown, depth: number): null | TodoItem[] { + if (depth > 2) { + return null + } + + if (Array.isArray(value)) { + return parseArray(value) + } + + if (typeof value === 'string' && value.trim()) { + try { + return parse(JSON.parse(value), depth + 1) + } catch { + return null + } + } + + if (isRecord(value) && Object.hasOwn(value, 'todos')) { + return parse(value.todos, depth + 1) + } + + return null +} + +export const parseTodos = (value: unknown): null | TodoItem[] => parse(value, 0) + +/** Latest parseable todo list from one message's aui content parts (tool-call + * parts named `todo`; live parts carry `todos`, hydrated ones args/result). */ +export function todosFromMessageContent(content: unknown): null | TodoItem[] { + if (!Array.isArray(content)) { + return null + } + + let latest: null | TodoItem[] = null + + for (const part of content) { + if (!isRecord(part) || part.type !== 'tool-call' || part.toolName !== 'todo') { + continue + } + + const parsed = parseTodos(part.todos) ?? parseTodos(part.result) ?? parseTodos(part.args) + + if (parsed !== null) { + latest = parsed + } + } + + return latest +} + +/** Current todo state for a whole transcript — the last list wins. */ +export function latestSessionTodos(messages: readonly { parts?: unknown }[]): null | TodoItem[] { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const todos = todosFromMessageContent(messages[i]?.parts) + + if (todos !== null) { + return todos + } + } + + return null +} diff --git a/ui-desktop/src/lib/tool-render-class.ts b/ui-desktop/src/lib/tool-render-class.ts new file mode 100644 index 00000000..810b35fe --- /dev/null +++ b/ui-desktop/src/lib/tool-render-class.ts @@ -0,0 +1,44 @@ +/** + * Which surface a tool call renders as. + * + * Two consumers have to agree on this and they sit on opposite sides of the + * app: the transcript decides what to draw, and the DOM render budget decides + * how much of the transcript to mount. Pricing a turn correctly means pricing + * what the grouping actually renders, so the classification lives on its own + * rather than inside either one. + */ + +const FILE_EDIT_TOOL_NAMES = new Set(['edit_file', 'patch', 'write_file']) + +/** Renders a diff — the deliverable of the turn, and the one card whose cost scales. */ +export function isFileEditTool(toolName: string): boolean { + return FILE_EDIT_TOOL_NAMES.has(toolName) +} + +// Tools that draw their own surface and must never be folded into a run's +// summary. Two kinds, for the same reason — the thing on screen IS the point: +// +// - File edits are the deliverable, not scaffolding. The diff is what the +// user reviews, so it stays visible at its place in the turn, live and +// settled, the way a PR shows its changes. +// - `clarify`, `image_generate` and `delegate_task` bypass ToolEntry to +// render their own markup: a question the user has to answer, an image +// they asked for, the several agents a fan-out is running. +// +// Everything else is ephemeral activity — reads, searches, commands — which is +// what a run summarizes and what the live ticker cycles through. +const CARD_TOOL_NAMES = new Set(['clarify', 'delegate_task', 'image_generate']) + +export function isCardTool(toolName: string): boolean { + return CARD_TOOL_NAMES.has(toolName) || isFileEditTool(toolName) +} + +// Activity tools that render nothing at all: `todo` parts are hoisted to a +// dedicated panel above the message content, and a reaction's UI is the emoji +// landing on the bubble. Both still render when they FAIL, which is a bounded +// error row either way. +const SILENT_TOOL_NAMES = new Set(['react_to_message', 'todo']) + +export function isSilentTool(toolName: string): boolean { + return SILENT_TOOL_NAMES.has(toolName) +} diff --git a/ui-desktop/src/lib/tool-result-summary.test.ts b/ui-desktop/src/lib/tool-result-summary.test.ts new file mode 100644 index 00000000..fc095db6 --- /dev/null +++ b/ui-desktop/src/lib/tool-result-summary.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest' + +import { extractToolErrorMessage, formatToolResultSummary } from './tool-result-summary' + +describe('formatToolResultSummary', () => { + it('unwraps wrapper payloads into structured key-value lines', () => { + const summary = formatToolResultSummary({ + success: true, + result: { + data: { + path: '/tmp/demo.txt', + status: 'ok', + lines_written: 12, + checksum: 'abc123' + } + } + }) + + expect(summary).toContain('- Path: /tmp/demo.txt') + expect(summary).toContain('- Status: ok') + expect(summary).toContain('- Lines Written: 12') + expect(summary).not.toContain('"path"') + }) + + it('summarizes object arrays as readable list items', () => { + const summary = formatToolResultSummary([ + { title: 'First result', snippet: 'alpha preview text' }, + { title: 'Second result', status: 'cached' }, + { title: 'Third result', summary: 'more details' }, + { title: 'Fourth result', summary: 'line 4' }, + { title: 'Fifth result', summary: 'line 5' }, + { title: 'Sixth result', summary: 'line 6' }, + { title: 'Seventh result', summary: 'line 7' } + ]) + + expect(summary).toContain('- First result - alpha preview text') + expect(summary).toContain('- Second result (cached)') + expect(summary).toContain('- … 1 more item') + }) + + it('truncates long field values for compact display', () => { + const summary = formatToolResultSummary({ + message: 'ok', + details: `prefix ${'x'.repeat(500)}` + }) + + const detailsLine = summary.split('\n').find(line => line.startsWith('- Details:')) + + expect(detailsLine).toBeTruthy() + expect(detailsLine?.length).toBeLessThan(230) + expect(detailsLine).toContain('…') + }) + + it('formats stringified json payloads without raw dumps', () => { + const summary = formatToolResultSummary( + JSON.stringify({ + data: { + title: 'Build report', + completed: true + } + }) + ) + + expect(summary).toContain('- Title: Build report') + expect(summary).toContain('- Completed: true') + }) +}) + +describe('extractToolErrorMessage', () => { + it('finds nested error messages through wrappers', () => { + const error = extractToolErrorMessage({ + success: false, + result: { + output: { + error: { + message: 'Permission denied writing /tmp/demo.txt' + } + } + } + }) + + expect(error).toBe('Permission denied writing /tmp/demo.txt') + }) + + it('does not treat successful payload messages as errors', () => { + const error = extractToolErrorMessage({ + success: true, + message: 'Completed successfully', + data: { count: 3 } + }) + + expect(error).toBe('') + }) + + it('ignores placeholder error fields in successful payloads', () => { + const error = extractToolErrorMessage({ + success: true, + data: { + error: 'none', + status: 'ok' + } + }) + + expect(error).toBe('') + }) +}) diff --git a/ui-desktop/src/lib/tool-result-summary.ts b/ui-desktop/src/lib/tool-result-summary.ts new file mode 100644 index 00000000..39411062 --- /dev/null +++ b/ui-desktop/src/lib/tool-result-summary.ts @@ -0,0 +1,469 @@ +// Heuristic JSON → human summary for tool results. Default view; technical +// mode still gets the raw JSON section. + +import { capitalize, normalize } from '@/lib/text' + +const WRAPPER_KEYS = ['data', 'result', 'output', 'response', 'payload'] as const + +const PRIORITY_KEYS = [ + 'title', + 'name', + 'path', + 'file', + 'filepath', + 'url', + 'href', + 'link', + 'status', + 'id', + 'message', + 'summary', + 'description' +] as const + +const ERROR_KEYS = ['error', 'errors', 'failure', 'exception'] as const +// 'stderr' deliberately excluded: many CLIs emit informational lines on +// stderr (npm progress, git's hint:, gcc's `In file included from`) that +// aren't errors. Treating those as error signal flipped tool cards into +// destructive styling for healthy commands. +const ERROR_MSG_KEYS = ['message', 'reason', 'detail'] as const +const NON_ERROR_TEXT = new Set(['', '0', 'false', 'none', 'null', 'nil', 'ok', 'success', 'n/a', 'na']) + +type Json = Record<string, unknown> + +const isRecord = (v: unknown): v is Json => Boolean(v && typeof v === 'object' && !Array.isArray(v)) + +function tryJson(value: string): unknown { + const t = value.trim() + + if (!t) { + return '' + } + + if (!/^[{[]|^"/.test(t)) { + return value + } + + try { + return JSON.parse(t) + } catch { + return value + } +} + +const norm = (v: unknown): unknown => (typeof v === 'string' ? tryJson(v) : v) + +const titleCase = (k: string) => + k + .split(/[_\-.]+/) + .filter(Boolean) + .map(capitalize) + .join(' ') + +const pluralize = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}` + +function clipInline(value: string, max = 180): string { + const c = value.replace(/\s+/g, ' ').trim() + + return c.length > max ? `${c.slice(0, max - 1)}…` : c +} + +function clipBlock(value: string, maxChars = 1800, maxLines = 18): string { + const t = value.trim() + + if (!t) { + return '' + } + + const lines = t.split('\n') + let text = lines.slice(0, maxLines).join('\n') + const clipped = lines.length > maxLines || text.length > maxChars + + if (text.length > maxChars) { + text = text.slice(0, maxChars - 1).trimEnd() + } + + return clipped && !text.endsWith('…') ? `${text}…` : text +} + +function firstString(record: Json, keys: readonly string[]): string { + for (const k of keys) { + const v = record[k] + + if (typeof v === 'string' && v.trim()) { + return v.trim() + } + } + + return '' +} + +function orderedKeys(keys: string[]): string[] { + const priority = PRIORITY_KEYS.filter(k => keys.includes(k)) + const rest = keys.filter(k => !priority.includes(k as never)) + + return [...priority, ...rest] +} + +const isWrapperKey = (k: string) => (WRAPPER_KEYS as readonly string[]).includes(k) +const skipField = (k: string, v: unknown) => isWrapperKey(k) || ((k === 'success' || k === 'ok') && v === true) + +function summarizeScalar(v: unknown): string { + if (typeof v === 'string') { + return clipInline(v) + } + + if (typeof v === 'number' || typeof v === 'boolean') { + return String(v) + } + + return '' +} + +function summarizeRecordInline(record: Json, depth: number): string { + if (depth > 3) { + return pluralize(Object.keys(record).length, 'field') + } + + const title = firstString(record, ['title', 'name', 'path', 'file', 'filepath', 'url', 'href', 'link', 'id']) + const status = firstString(record, ['status', 'category', 'type']) + const message = firstString(record, ['snippet', 'summary', 'description', 'message']) + + if (title && status) { + return `${clipInline(title, 110)} (${clipInline(status, 54)})` + } + + if (title && message && title !== message) { + return `${clipInline(title, 90)} - ${clipInline(message, 84)}` + } + + if (title) { + return clipInline(title, 150) + } + + const pairs = orderedKeys(Object.keys(record)) + .filter(k => !skipField(k, record[k])) + .map(k => { + const s = summarizeScalar(record[k]) + + return s ? `${titleCase(k)}: ${s}` : '' + }) + .filter(Boolean) + .slice(0, 2) + + return pairs.length ? pairs.join(' · ') : pluralize(Object.keys(record).length, 'field') +} + +function summarizeListItem(item: unknown, depth: number): string { + const v = norm(item) + + if (typeof v === 'string') { + return clipInline(v) + } + + if (typeof v === 'number' || typeof v === 'boolean') { + return String(v) + } + + if (v == null) { + return '' + } + + if (Array.isArray(v)) { + return pluralize(v.length, 'item') + } + + if (isRecord(v)) { + return summarizeRecordInline(v, depth + 1) + } + + return clipInline(String(v)) +} + +function formatFieldValue(value: unknown, depth: number): string { + const v = norm(value) + const scalar = summarizeScalar(v) + + if (scalar) { + return scalar + } + + if (v == null) { + return '' + } + + if (Array.isArray(v)) { + if (!v.length) { + return '' + } + + const scalars = v.map(summarizeScalar).filter(Boolean) + + if (scalars.length === v.length && v.length <= 4) { + return clipInline(scalars.join(', ')) + } + + const first = summarizeListItem(v[0], depth + 1) + + return first ? `${pluralize(v.length, 'item')} (${first})` : pluralize(v.length, 'item') + } + + if (isRecord(v)) { + return summarizeRecordInline(v, depth + 1) + } + + return clipInline(String(v)) +} + +// "Returned N items" / "0 items" / "Returned an empty object" are all +// noise — better to render nothing and let the title carry the signal. +function formatArraySummary(value: unknown[], depth: number): string { + if (!value.length) { + return '' + } + + const max = 6 + + const lines = value + .slice(0, max) + .map(item => summarizeListItem(item, depth + 1)) + .filter(Boolean) + .map(l => `- ${l}`) + + if (!lines.length) { + return '' + } + + if (value.length > max) { + const remaining = value.length - max + lines.push(`- … ${remaining} more ${remaining === 1 ? 'item' : 'items'}`) + } + + return lines.join('\n') +} + +function formatRecordSummary(record: Json, depth: number): string { + const keys = Object.keys(record) + + if (!keys.length) { + return '' + } + + if (depth <= 2) { + const direct = firstString(record, ['message', 'summary', 'description', 'preview', 'text', 'content']) + const meaningful = keys.filter(k => !skipField(k, record[k]) && !isWrapperKey(k)) + + if (direct && meaningful.length <= 1) { + return clipBlock(direct) + } + } + + const candidates = orderedKeys(keys).filter(k => !skipField(k, record[k])) + const max = 8 + const lines: string[] = [] + + for (const k of candidates) { + const v = formatFieldValue(record[k], depth + 1) + + if (!v) { + continue + } + + lines.push(`- ${titleCase(k)}: ${v}`) + + if (lines.length >= max) { + break + } + } + + if (!lines.length) { + return '' + } + + if (candidates.length > lines.length) { + const remaining = candidates.length - lines.length + lines.push(`- … ${remaining} more ${remaining === 1 ? 'field' : 'fields'}`) + } + + return lines.join('\n') +} + +function formatSummaryValue(value: unknown, depth: number): string { + if (depth > 4) { + return '' + } + + const v = norm(value) + + if (typeof v === 'string') { + return clipBlock(v) + } + + if (typeof v === 'number' || typeof v === 'boolean') { + return String(v) + } + + if (v == null) { + return '' + } + + if (Array.isArray(v)) { + return formatArraySummary(v, depth + 1) + } + + if (isRecord(v)) { + return formatRecordSummary(v, depth + 1) + } + + return clipInline(String(v)) +} + +function unwrapPayload(value: unknown): unknown { + let cur: unknown = norm(value) + + for (let i = 0; i < 4; i += 1) { + if (!isRecord(cur)) { + return cur + } + + const record = cur + const key = WRAPPER_KEYS.find(k => record[k] != null) + + if (!key) { + return record + } + + cur = norm(record[key]) + } + + return cur +} + +function hasMeaningfulErrorValue(value: unknown): boolean { + const v = norm(value) + + if (v == null) { + return false + } + + if (typeof v === 'string') { + return !NON_ERROR_TEXT.has(normalize(v)) + } + + if (typeof v === 'boolean') { + return v + } + + if (typeof v === 'number') { + return v !== 0 + } + + if (Array.isArray(v)) { + return v.some(hasMeaningfulErrorValue) + } + + if (isRecord(v)) { + return Object.keys(v).length > 0 + } + + return true +} + +function hasErrorSignal(record: Json): boolean { + const status = typeof record.status === 'string' ? record.status : '' + + return ( + record.success === false || + record.ok === false || + /\b(error|failed|failure|fatal|exception)\b/i.test(status) || + ERROR_KEYS.some(k => hasMeaningfulErrorValue(record[k])) + ) +} + +function valueErrorText(value: unknown): string { + const v = norm(value) + + if (typeof v === 'string') { + return hasMeaningfulErrorValue(v) ? clipBlock(v, 700, 12) : '' + } + + if (Array.isArray(v)) { + return clipBlock(v.map(valueErrorText).filter(Boolean).slice(0, 3).join('; '), 700, 12) + } + + if (isRecord(v)) { + const direct = firstString(v, ERROR_MSG_KEYS) + + if (direct) { + return clipBlock(direct, 700, 12) + } + } + + return '' +} + +function findNestedError(value: unknown, depth: number, seen: Set<unknown>): string { + if (depth > 5) { + return '' + } + + const v = norm(value) + + if (!v || typeof v !== 'object' || seen.has(v)) { + return '' + } + + seen.add(v) + + if (Array.isArray(v)) { + for (const item of v) { + const nested = findNestedError(item, depth + 1, seen) + + if (nested) { + return nested + } + } + + return '' + } + + const record = v as Json + + for (const k of ERROR_KEYS) { + if (!hasMeaningfulErrorValue(record[k])) { + continue + } + + const text = valueErrorText(record[k]) + + if (text) { + return text + } + } + + if (hasErrorSignal(record)) { + const direct = firstString(record, ERROR_MSG_KEYS) + + if (direct) { + return clipBlock(direct, 700, 12) + } + } + + for (const k of [...ERROR_KEYS, ...WRAPPER_KEYS, 'details', 'meta']) { + const nested = findNestedError(record[k], depth + 1, seen) + + if (nested) { + return nested + } + } + + return '' +} + +export function formatToolResultSummary(value: unknown): string { + return formatSummaryValue(unwrapPayload(value), 0) || formatSummaryValue(value, 0) +} + +export function extractToolErrorMessage(value: unknown): string { + return findNestedError(value, 0, new Set()) +} diff --git a/ui-desktop/src/lib/tool-run-continuity.test.ts b/ui-desktop/src/lib/tool-run-continuity.test.ts new file mode 100644 index 00000000..04ed01b0 --- /dev/null +++ b/ui-desktop/src/lib/tool-run-continuity.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from 'vitest' + +import { isToolCallPart, type ToolCallLike } from '@/components/assistant-ui/tool/run-summary' +import type { SessionMessage } from '@/types/clawcodex' + +import type { ChatMessage, ChatMessagePart } from './chat-messages' +import { + appendAssistantTextPart, + appendReasoningPart, + assistantTextPart, + mergeFinalAssistantText, + toChatMessages, + upsertToolPart +} from './chat-messages' +import { coalesceToolOnlyAssistants, createToolMergeCache } from './chat-runtime' + +/** + * A turn described once, replayed two ways: as the gateway event stream the + * live view builds bubbles from, and as the persisted rows `toChatMessages` + * rehydrates on resume. Grouping is only stable if both produce the same runs. + */ +type TurnStep = + | { kind: 'interim'; text: string } + | { kind: 'final'; text: string } + | { kind: 'reasoning'; text: string } + | { kind: 'text'; text: string } + | { kind: 'tool'; id: string; name: string } + +// Mirrors use-message-stream: deltas and tool events accumulate on one pending +// bubble; `message.interim` seals it in place and starts a fresh one; and +// `message.complete` merges the final text onto whatever bubble is open. +function replayLive(steps: TurnStep[]): ChatMessage[] { + const messages: ChatMessage[] = [] + let streamIndex = 0 + let open: ChatMessage | null = null + + const openBubble = (): ChatMessage => { + if (open) { + return open + } + + streamIndex += 1 + open = { id: `assistant-stream-${streamIndex}`, role: 'assistant', parts: [], pending: true } + messages.push(open) + + return open + } + + const seal = (text: string, interim: boolean) => { + const bubble = open ?? openBubble() + + bubble.parts = mergeFinalAssistantText(bubble.parts, text) + bubble.pending = false + bubble.interim = interim + open = null + } + + for (const step of steps) { + switch (step.kind) { + case 'interim': + seal(step.text, true) + + break + + case 'final': + seal(step.text, false) + + break + case 'reasoning': { + const bubble = openBubble() + + bubble.parts = appendReasoningPart(bubble.parts, step.text) + + break + } + + case 'text': { + const bubble = openBubble() + + bubble.parts = appendAssistantTextPart(bubble.parts, step.text) + + break + } + + case 'tool': { + const bubble = openBubble() + + bubble.parts = upsertToolPart(bubble.parts, { tool_id: step.id, name: step.name }, 'complete') + + break + } + } + } + + return coalesceToolOnlyAssistants(messages, createToolMergeCache()) +} + +// The same turn as the gateway persists it: one row per agent iteration. A row +// is a single API response, so its reasoning and content always precede its own +// tool_calls — anything the agent says after a tool ran belongs to the next row. +function replayStored(steps: TurnStep[]): ChatMessage[] { + const rows: SessionMessage[] = [] + let timestamp = 0 + let row: (SessionMessage & { tool_calls?: unknown[] }) | null = null + + const openRow = (afterTools: boolean) => { + if (row && !(afterTools && row.tool_calls)) { + return row + } + + timestamp += 1 + row = { role: 'assistant', content: '', timestamp } + rows.push(row) + + return row + } + + for (const step of steps) { + switch (step.kind) { + case 'interim': + + case 'final': + case 'text': { + const current = openRow(true) + + current.content = `${current.content ?? ''}${step.text}` + + if (step.kind !== 'text') { + row = null + } + + break + } + + case 'reasoning': + openRow(true).reasoning = step.text + + break + case 'tool': { + const current = openRow(false) + + current.tool_calls = [ + ...(current.tool_calls ?? []), + { id: step.id, function: { name: step.name, arguments: '{}' } } + ] + timestamp += 1 + rows.push({ role: 'tool', tool_call_id: step.id, tool_name: step.name, content: '{}', timestamp }) + + break + } + } + } + + return coalesceToolOnlyAssistants(toChatMessages(rows), createToolMergeCache()) +} + +/** + * Maximal spans of back-to-back tool calls — the same rule assistant-ui applies + * when it hands `ToolGroupSlot` a range, restated here so these tests check our + * two part streams against each other rather than against the renderer. + */ +function toolRuns(parts: ChatMessagePart[]): ToolCallLike[][] { + const runs: ToolCallLike[][] = [] + let previousWasTool = false + + for (const part of parts) { + if (!isToolCallPart(part)) { + previousWasTool = false + + continue + } + + if (previousWasTool) { + runs[runs.length - 1].push(part) + } else { + runs.push([part]) + } + + previousWasTool = true + } + + return runs +} + +function runsAcross(messages: ChatMessage[]): string[][] { + return messages + .filter(message => message.role === 'assistant') + .flatMap(message => toolRuns(message.parts)) + .map(run => run.map(tool => tool.toolCallId ?? '')) +} + +const TURNS: Record<string, TurnStep[]> = { + 'narration between two tool runs': [ + { kind: 'interim', text: 'Let me check the config.' }, + { kind: 'tool', id: 'a', name: 'read_file' }, + { kind: 'tool', id: 'b', name: 'read_file' }, + { kind: 'interim', text: 'Now let me edit it.' }, + { kind: 'tool', id: 'c', name: 'write_file' }, + { kind: 'final', text: 'Done.' } + ], + 'reasoning between two tool runs': [ + { kind: 'tool', id: 'a', name: 'terminal' }, + { kind: 'reasoning', text: 'That failed, try the other path.' }, + { kind: 'tool', id: 'b', name: 'terminal' }, + { kind: 'final', text: 'Fixed.' } + ], + 'unbroken run of tool calls': [ + { kind: 'tool', id: 'a', name: 'read_file' }, + { kind: 'tool', id: 'b', name: 'read_file' }, + { kind: 'tool', id: 'c', name: 'search_files' }, + { kind: 'final', text: 'Here is what I found.' } + ], + 'reasoning then tools then final': [ + { kind: 'reasoning', text: 'The user wants the lint config.' }, + { kind: 'tool', id: 'a', name: 'search_files' }, + { kind: 'tool', id: 'b', name: 'read_file' }, + { kind: 'final', text: 'It lives in eslint.config.js.' } + ], + 'tools with no narration at all': [ + { kind: 'tool', id: 'a', name: 'terminal' }, + { kind: 'final', text: 'Clean.' } + ] +} + +describe('tool run segmentation survives rehydration', () => { + for (const [name, steps] of Object.entries(TURNS)) { + it(name, () => { + expect(runsAcross(replayLive(steps))).toEqual(runsAcross(replayStored(steps))) + }) + } +}) + +describe('run identity', () => { + const tool = (id: string, name: string): ChatMessagePart => + ({ args: {}, toolCallId: id, toolName: name, type: 'tool-call' }) as ChatMessagePart + + it('keeps the first tool call at the head as the run grows', () => { + const [run] = toolRuns([tool('a', 'read_file'), tool('b', 'read_file')]) + const [grown] = toolRuns([tool('a', 'read_file'), tool('b', 'read_file'), tool('c', 'terminal')]) + + expect(grown[0].toolCallId).toBe(run[0].toolCallId) + expect(grown).toHaveLength(3) + }) + + it('breaks a run on any non-tool part', () => { + const runs = toolRuns([tool('a', 'read_file'), assistantTextPart('Now editing.'), tool('b', 'write_file')]) + + expect(runs.map(run => run.map(t => t.toolCallId))).toEqual([['a'], ['b']]) + }) +}) diff --git a/ui-desktop/src/lib/trackpad-gestures.ts b/ui-desktop/src/lib/trackpad-gestures.ts new file mode 100644 index 00000000..c829086b --- /dev/null +++ b/ui-desktop/src/lib/trackpad-gestures.ts @@ -0,0 +1,50 @@ +// Trackpad / pointer gesture primitives shared across canvas + DOM surfaces. +// +// macOS quirk (Chromium/Electron): both pinch-zoom and "smart zoom" arrive as +// `wheel` events with `ctrlKey` synthetically set — there is no dedicated DOM +// event for either. They're disambiguated by their deltas: +// - pinch-to-zoom: ctrlKey + a non-zero delta +// - smart zoom: ctrlKey + zero deltas (the two-finger double-tap) +// Plain two-finger scroll has ctrlKey === false. Centralising this here keeps +// every zoom/pan surface from re-deriving the same OS trivia (and getting it +// wrong, which makes smart-zoom read as a zoom-in). + +export interface WheelLike { + ctrlKey: boolean + deltaX: number + deltaY: number +} + +/** macOS "smart zoom" (two-finger double-tap): a ctrl-wheel with no delta. */ +export function isSmartZoomWheel(e: WheelLike): boolean { + return e.ctrlKey && e.deltaX === 0 && e.deltaY === 0 +} + +/** Pinch-to-zoom (or ctrl + mouse wheel): a ctrl-wheel carrying a delta. */ +export function isPinchZoomWheel(e: WheelLike): boolean { + return e.ctrlKey && (e.deltaX !== 0 || e.deltaY !== 0) +} + +export const DOUBLE_TAP_MS = 300 + +/** + * Stateful double-tap detector for surfaces where a real `dblclick` may never + * fire (e.g. a trackpad with tap-to-click off). Call it once per discrete tap; + * it returns true when two taps land within `thresholdMs` of each other, then + * resets so a third tap starts a fresh pair. + */ +export function createDoubleTapDetector(thresholdMs: number = DOUBLE_TAP_MS): (now?: number) => boolean { + let last = 0 + + return (now: number = Date.now()): boolean => { + if (now - last < thresholdMs) { + last = 0 + + return true + } + + last = now + + return false + } +} diff --git a/ui-desktop/src/lib/update-copy.test.ts b/ui-desktop/src/lib/update-copy.test.ts new file mode 100644 index 00000000..874ccd63 --- /dev/null +++ b/ui-desktop/src/lib/update-copy.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import { resolveUpdateCopy } from './update-copy' + +const copy = { + availableTitle: 'New update available', + availableBody: 'A new version of ClawCodex is ready to install.', + availableTitleBackend: 'Backend update available', + availableBodyBackend: 'A newer version of the connected ClawCodex backend is ready to install.', + availableBodyNoChangelog: 'A newer version is ready. Release notes aren’t available for this install type.' +} + +describe('resolveUpdateCopy', () => { + it('client target with commits: client title + client body', () => { + const r = resolveUpdateCopy({ target: 'client', shownItems: 5, copy }) + expect(r.title).toBe('New update available') + expect(r.body).toBe('A new version of ClawCodex is ready to install.') + }) + + it('backend target with commits: names the backend in title and body', () => { + const r = resolveUpdateCopy({ target: 'backend', shownItems: 5, copy }) + expect(r.title).toBe('Backend update available') + expect(r.body).toContain('backend') + }) + + it('no changelog (pip/non-git backend): degrades honestly, still names backend target in title', () => { + const r = resolveUpdateCopy({ target: 'backend', shownItems: 0, copy }) + expect(r.title).toBe('Backend update available') + // Body must NOT pretend there are notes — it states they're unavailable. + expect(r.body).toBe(copy.availableBodyNoChangelog) + }) + + it('no changelog on client: same honest degrade', () => { + const r = resolveUpdateCopy({ target: 'client', shownItems: 0, copy }) + expect(r.title).toBe('New update available') + expect(r.body).toBe(copy.availableBodyNoChangelog) + }) +}) diff --git a/ui-desktop/src/lib/update-copy.ts b/ui-desktop/src/lib/update-copy.ts new file mode 100644 index 00000000..943ee24b --- /dev/null +++ b/ui-desktop/src/lib/update-copy.ts @@ -0,0 +1,44 @@ +/** + * Pure copy-selection for the updates overlay's "available" state. + * + * Names the update target (client vs the connected backend in remote mode) and + * degrades honestly when there's no commit changelog to show (e.g. a pip / + * non-git backend where `git log` yields nothing) instead of generic filler. + * + * Extracted from updates-overlay.tsx so the wording logic is unit-testable. + */ + +export type UpdateTarget = 'client' | 'backend' + +export interface UpdateCopyStrings { + availableTitle: string + availableBody: string + availableTitleBackend: string + availableBodyBackend: string + availableBodyNoChangelog: string +} + +export interface ResolveUpdateCopyInput { + target: UpdateTarget + /** Number of commit rows actually shown in the changelog. 0 → no notes. */ + shownItems: number + copy: UpdateCopyStrings +} + +export interface UpdateCopyResult { + title: string + body: string +} + +export function resolveUpdateCopy({ target, shownItems, copy }: ResolveUpdateCopyInput): UpdateCopyResult { + const title = target === 'backend' ? copy.availableTitleBackend : copy.availableTitle + + const body = + shownItems === 0 + ? copy.availableBodyNoChangelog + : target === 'backend' + ? copy.availableBodyBackend + : copy.availableBody + + return { title, body } +} diff --git a/ui-desktop/src/lib/use-enter-animation.test.tsx b/ui-desktop/src/lib/use-enter-animation.test.tsx new file mode 100644 index 00000000..936aeab2 --- /dev/null +++ b/ui-desktop/src/lib/use-enter-animation.test.tsx @@ -0,0 +1,82 @@ +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { useEnterAnimation } from './use-enter-animation' + +interface PlayedAnimation { + keyframes: Keyframe[] + options: KeyframeAnimationOptions +} + +/** + * Mounts one element through the hook and reports the animation it played, if + * any. jsdom has no Web Animations API, so `animate` is the seam. + */ +function mountAnimated(enabled: boolean, animationKey?: string): PlayedAnimation | undefined { + let played: PlayedAnimation | undefined + + function Probe() { + const ref = useEnterAnimation(enabled, animationKey) + + return <div ref={ref} /> + } + + // Defined rather than spied on: jsdom ships no Web Animations API at all, so + // there is no `animate` to wrap. + Object.defineProperty(HTMLElement.prototype, 'animate', { + configurable: true, + value: (keyframes: Keyframe[], options: KeyframeAnimationOptions) => { + played = { keyframes, options } + + return {} as Animation + }, + writable: true + }) + render(<Probe />) + + return played +} + +afterEach(() => { + cleanup() + Reflect.deleteProperty(HTMLElement.prototype, 'animate') +}) + +describe('useEnterAnimation', () => { + it('plays once on mount', () => { + const played = mountAnimated(true, 'plays-once') + + expect(played).toBeDefined() + expect(played?.keyframes[0]).toMatchObject({ opacity: 0 }) + }) + + it('stays out of the way when disabled', () => { + expect(mountAnimated(false, 'disabled')).toBeUndefined() + }) + + // A key is only banked once the node survives a microtask, so that a mount + // React immediately tears down doesn't burn it. + it('does not replay for a key that already animated', async () => { + expect(mountAnimated(true, 'replay')).toBeDefined() + await Promise.resolve() + + expect(mountAnimated(true, 'replay')).toBeUndefined() + }) + + /** + * The animation fills forwards, so any value in its last keyframe is held in + * the animation origin of the cascade for the life of the element — above + * the stylesheet. Naming an end opacity therefore doesn't just finish the + * fade, it permanently overrules whatever opacity CSS wants the element to + * rest at, and transcript scaffolding rests dimmed. Rows that animated in + * during the turn stayed bright while their rehydrated neighbours faded, and + * no hover could lift the bright ones because the sheet had lost the + * argument. Opacity has to be left to CSS at the end. + */ + it('leaves the resting opacity to the stylesheet', () => { + const played = mountAnimated(true, 'resting-opacity') + + expect(played?.options.fill).toBe('both') + expect(played?.keyframes.at(-1)).not.toHaveProperty('opacity') + }) +}) diff --git a/ui-desktop/src/lib/use-enter-animation.ts b/ui-desktop/src/lib/use-enter-animation.ts new file mode 100644 index 00000000..267eaa4c --- /dev/null +++ b/ui-desktop/src/lib/use-enter-animation.ts @@ -0,0 +1,110 @@ +import { useCallback, useRef } from 'react' + +/** + * One-shot enter animation via the Web Animations API. + * + * Returns a callback ref. The animation fires exactly once when the element + * first attaches to the DOM and never replays for an already-mounted node — + * this is deliberate. CSS-transition + `@starting-style` is fragile here + * because: + * - Streaming deltas constantly invalidate ancestor state, which can + * re-trigger transitions on unrelated descendants. + * - `@starting-style` only covers DOM insertion / first-match, but any + * style restart during the message lifecycle replays the transition. + * - Some Chromium versions reset transitions when an attribute on an + * ancestor toggles, even if the descendant's properties never change. + * + * `el.animate(...)` runs against the element directly and is independent of + * CSS rule churn — it plays once, finishes, and is done. If the element + * unmounts and re-mounts, the callback ref runs again and replays it + * (correct behaviour). + * + * `enabled` is captured at mount-time only — flipping it later doesn't + * suddenly play the animation on existing nodes. + */ +const playedAnimationKeys = new Set<string>() +const playedAnimationOrder: string[] = [] +const MAX_TRACKED_KEYS = 2048 + +function hasPlayedAnimation(key: string): boolean { + return playedAnimationKeys.has(key) +} + +function rememberPlayedAnimation(key: string): void { + if (playedAnimationKeys.has(key)) { + return + } + + playedAnimationKeys.add(key) + playedAnimationOrder.push(key) + + if (playedAnimationOrder.length > MAX_TRACKED_KEYS) { + const evicted = playedAnimationOrder.shift() + + if (evicted) { + playedAnimationKeys.delete(evicted) + } + } +} + +function scheduleMicrotask(cb: () => void): void { + if (typeof queueMicrotask === 'function') { + queueMicrotask(cb) + + return + } + + void Promise.resolve().then(cb) +} + +export function useEnterAnimation(enabled: boolean, animationKey?: string): (el: HTMLElement | null) => void { + const enabledRef = useRef(enabled) + const keyRef = useRef(animationKey) + + enabledRef.current = enabled + keyRef.current = animationKey + + return useCallback((el: HTMLElement | null) => { + if (!el || !enabledRef.current || typeof window === 'undefined') { + return + } + + if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) { + return + } + + const key = keyRef.current + + if (key && hasPlayedAnimation(key)) { + return + } + + el.animate( + [ + { opacity: 0, transform: 'translateY(0.375rem)' }, + // No `opacity` on the way out, deliberately. A filled animation holds + // its final value in the animation origin of the cascade, which + // outranks the stylesheet for as long as the element lives — naming 1 + // here permanently pinned full opacity onto everything the sheet dims. + // Transcript scaffolding is dimmed that way, so a tool row or thinking + // header kept whichever opacity it happened to mount with: full if it + // animated in during the turn, faded if it was rehydrated or remounted + // past its one-shot key. Adjacent identical rows disagreed, and hover + // couldn't lift the pinned ones. Left neutral, opacity rises to + // whatever CSS says it should be and answers hover afterwards. + { transform: 'translateY(0)' } + ], + { duration: 180, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', fill: 'both' } + ) + + if (key) { + // In React StrictMode the first mount can be immediately torn down. + // Only persist "played" once the element survives to the microtask tick. + scheduleMicrotask(() => { + if (el.isConnected) { + rememberPlayedAnimation(key) + } + }) + } + }, []) +} diff --git a/ui-desktop/src/lib/use-session-slice.ts b/ui-desktop/src/lib/use-session-slice.ts new file mode 100644 index 00000000..f96d0017 --- /dev/null +++ b/ui-desktop/src/lib/use-session-slice.ts @@ -0,0 +1,63 @@ +import { useCallback, useRef, useSyncExternalStore } from 'react' + +interface SliceStore<T> { + get(): Record<string, T[] | undefined> + listen(listener: () => void): () => void +} + +// Stable empty result so an absent key never yields a fresh array (which would +// defeat the snapshot bail-out and re-render on every store write). +const EMPTY: readonly never[] = [] + +/** + * Subscribe to ONE session's slice of a `Record<sessionId, T[]>` nanostore, + * re-rendering only when *that* slice's reference changes — not on writes to + * other sessions. The map reference churns on every cross-session update, so a + * plain `useStore(map)` re-renders all consumers globally; reading `map[key]` + * through `useSyncExternalStore` bails out whenever the keyed array is + * unchanged (the stores update immutably per key). Returns a shared empty array + * when the key is null/absent. + * + * Note: only helps stores whose per-key arrays are referentially stable across + * unrelated writes (plain atoms with immutable per-key updates). A `computed` + * that rebuilds the whole map churns every slice — use a presence/edge selector + * there instead. + */ +export function useSessionSlice<T>(store: SliceStore<T>, key: string | null): T[] { + return useSyncExternalStore( + onChange => store.listen(onChange), + () => (key ? (store.get()[key] ?? (EMPTY as unknown as T[])) : (EMPTY as unknown as T[])) + ) +} + +interface ReadableStore<T> { + get(): T + listen(listener: () => void): () => void +} + +/** + * Subscribe to a SCALAR derived from a hot store, re-rendering only when that + * scalar changes by `Object.is` — not on every write to the store it came from. + * + * `useStore($someHotStore)` bails out on reference equality alone, so a store + * republished per streaming token re-renders every consumer even when the two + * or three fields they actually read are identical. `$sessionStates` is the + * canonical case: it is republished on every message delta, so a component + * reading only `busy` or `turnStartedAt` off it pays for the whole transcript's + * churn. + * + * `select` must return a PRIMITIVE (or a referentially stable value). Returning + * a fresh object or array defeats the bail-out and reintroduces the churn this + * exists to remove — derive one scalar per call instead. + */ +export function useStoreSelector<T, S>(store: ReadableStore<T>, select: (value: T) => S): S { + // `select` is read through a ref so an inline arrow at the call site doesn't + // resubscribe on every render; useSyncExternalStore re-reads the snapshot on + // each render anyway, so the latest selector is always applied. + const selectRef = useRef(select) + selectRef.current = select + + const subscribe = useCallback((onChange: () => void) => store.listen(onChange), [store]) + + return useSyncExternalStore(subscribe, () => selectRef.current(store.get())) +} diff --git a/ui-desktop/src/lib/utils.ts b/ui-desktop/src/lib/utils.ts new file mode 100644 index 00000000..d32b0fe6 --- /dev/null +++ b/ui-desktop/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/ui-desktop/src/lib/version-status.test.ts b/ui-desktop/src/lib/version-status.test.ts new file mode 100644 index 00000000..47e7e6ea --- /dev/null +++ b/ui-desktop/src/lib/version-status.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' + +import { en } from '@/i18n/en' + +import { resolveVersionStatus } from './version-status' + +const copy = en.shell.statusbar + +const client = (over: Partial<Parameters<typeof resolveVersionStatus>[0]> = {}) => + resolveVersionStatus({ applying: false, copy, remote: false, restarting: false, target: 'client', ...over }) + +const backend = (over: Partial<Parameters<typeof resolveVersionStatus>[0]> = {}) => + resolveVersionStatus({ applying: false, copy, remote: true, restarting: false, target: 'backend', ...over }) + +describe('resolveVersionStatus', () => { + it('labels a current local client with its version and sha detail', () => { + const status = client({ sha: 'abc1234', version: '0.4.2' }) + + expect(status.label).toBe('v0.4.2') + expect(status.detail).toBe('abc1234') + expect(status.hasUpdate).toBe(false) + expect(status.unknown).toBe(false) + }) + + it('appends the commit diff when the client is behind', () => { + const status = client({ behind: 12, branch: 'main', version: '0.4.2' }) + + expect(status.label).toBe('v0.4.2 (+12)') + expect(status.hasUpdate).toBe(true) + expect(status.tooltip).toContain('12 commits behind main') + }) + + it('names the client as one of two versions in remote mode', () => { + expect(client({ remote: true, version: '0.4.2' }).label).toBe('client v0.4.2') + }) + + it('falls back to the sha, then to unknown, when there is no version', () => { + expect(client({ sha: 'abc1234' }).label).toBe('abc1234') + expect(client({ sha: 'abc1234' }).unknown).toBe(false) + expect(client().label).toBe(copy.unknown) + expect(client().unknown).toBe(true) + }) + + it('drops the diff and the sha detail while an apply is in flight', () => { + const applying = client({ applying: true, behind: 3, sha: 'abc1234', version: '0.4.2' }) + + expect(applying.label).toBe('v0.4.2 · update') + expect(applying.detail).toBeUndefined() + expect(applying.hasUpdate).toBe(false) + + expect(client({ applying: true, restarting: true, version: '0.4.2' }).label).toBe('v0.4.2 · restart') + }) + + it('leads the tooltip with the apply message while applying', () => { + expect(client({ applyMessage: 'Pulling…', applying: true, version: '0.4.2' }).tooltip).toBe( + 'Pulling… · ClawCodex Desktop v0.4.2' + ) + expect(client({ applying: true, version: '0.4.2' }).tooltip).toBe( + `${copy.updateInProgress} · ClawCodex Desktop v0.4.2` + ) + }) + + it('labels the backend target distinctly and never claims a client sha', () => { + const status = backend({ sha: 'abc1234', version: '0.4.2' }) + + expect(status.label).toBe('backend v0.4.2') + expect(status.detail).toBeUndefined() + expect(status.tooltip).toBe('Backend v0.4.2') + }) + + it('falls back to (update) for a backend that cannot count commits', () => { + const status = backend({ updateAvailable: true, version: '0.4.2' }) + + expect(status.label).toBe('backend v0.4.2 (update)') + expect(status.hasUpdate).toBe(true) + }) + + it('prefers the exact commit diff over the generic (update) hint', () => { + expect(backend({ behind: 4, updateAvailable: true, version: '0.4.2' }).label).toBe('backend v0.4.2 (+4)') + }) + + it('hides a backend row that has no version at all', () => { + expect(backend().unknown).toBe(true) + }) +}) diff --git a/ui-desktop/src/lib/version-status.ts b/ui-desktop/src/lib/version-status.ts new file mode 100644 index 00000000..030464e8 --- /dev/null +++ b/ui-desktop/src/lib/version-status.ts @@ -0,0 +1,106 @@ +/** + * Pure derivation of how the app names an update target: the label + * (`v0.4.2`, `backend v0.4.2 (+12)`, `v0.4.2 · update`), its tooltip, and + * whether an update is waiting. + * + * The statusbar and the command palette both name the same two targets, so the + * wording lives here once — a palette row and its statusbar item can't drift + * into describing the same install differently. + */ + +import type { UpdateTarget } from '@/lib/update-copy' + +export interface VersionStatusCopy { + backendLabel: (version: string) => string + backendVersion: (version: string) => string + branch: (branch: string) => string + clientLabel: (version: string) => string + commit: (sha: string) => string + commitsBehind: (count: number, branch: string) => string + desktopVersion: (version: string) => string + restart: string + unknown: string + update: string + updateInProgress: string +} + +export interface VersionStatusInput { + /** True while an apply is in flight (including the restart hand-off). */ + applying: boolean + /** Latest line from the apply stream — leads the tooltip while applying. */ + applyMessage?: string + behind?: number + branch?: string + copy: VersionStatusCopy + /** Remote mode: the client is one of two versions on screen, so it says so. */ + remote: boolean + /** The apply reached the restart stage — labels `restart`, not `update`. */ + restarting: boolean + /** Client only: short commit sha of the running build. */ + sha?: null | string + target: UpdateTarget + /** Backend only: an update the commit count can't express (pip installs). */ + updateAvailable?: boolean + version?: null | string +} + +export interface VersionStatusResult { + /** Secondary text beside the label — the commit sha, when it adds anything. */ + detail?: string + /** An update is waiting: callers tint the row with it. */ + hasUpdate: boolean + label: string + tooltip?: string + /** Nothing identifies this target yet — callers hide the row. */ + unknown: boolean +} + +export function resolveVersionStatus({ + applyMessage, + applying, + behind = 0, + branch, + copy, + remote, + restarting, + sha = null, + target, + updateAvailable, + version = null +}: VersionStatusInput): VersionStatusResult { + const client = target === 'client' + const busy = applying || restarting + const available = behind > 0 || (!client && !!updateAvailable) + + // A client with no version still identifies itself by sha; a backend can't. + const named = version ?? (client ? sha : null) ?? copy.unknown + + const base = !client + ? copy.backendLabel(named) + : remote + ? copy.clientLabel(named) + : (version && `v${version}`) || named + + // Commits behind is the precise diff; `(update)` is the fallback for a + // backend that knows it's stale but can't count (pip, non-git checkout). + const hint = busy ? '' : behind > 0 ? ` (+${behind})` : available ? ` (${copy.update})` : '' + + const tooltip = [ + busy && (applyMessage || copy.updateInProgress), + !busy && behind > 0 && copy.commitsBehind(behind, (client ? branch : 'main') || '...'), + !busy && behind <= 0 && available && copy.update, + version && (client ? copy.desktopVersion(version) : copy.backendVersion(version)), + client && sha && copy.commit(sha), + client && branch && copy.branch(branch) + ] + .filter(Boolean) + .join(' · ') + + return { + detail: client && version && sha && !busy && !remote ? sha : undefined, + hasUpdate: !busy && available, + label: busy ? `${base} · ${restarting ? copy.restart : copy.update}` : `${base}${hint}`, + tooltip: tooltip || undefined, + unknown: !version && !(client && sha) + } +} diff --git a/ui-desktop/src/lib/voice-barge-in.ts b/ui-desktop/src/lib/voice-barge-in.ts new file mode 100644 index 00000000..4d521f33 --- /dev/null +++ b/ui-desktop/src/lib/voice-barge-in.ts @@ -0,0 +1,326 @@ +// Full-duplex VAD monitor: watch the mic across the agent turn — while the +// model is generating (no audio yet) AND while TTS plays — fire the moment the +// user talks over either phase, and CAPTURE what they say. Detection alone +// loses the first words — by the time sustained speech trips the trigger and a +// fresh recorder spins up, "stop, actually—" has become "actually—". So a +// MediaRecorder runs on the monitor's stream the whole time (pre-roll), and +// once tripped it keeps rolling until the user goes quiet, delivering the +// complete utterance. +// +// Phase-aware trigger (mirrors tools/voice_mode.full_duplex_listen on the +// Python surfaces): +// - The noise floor is calibrated from QUIET samples only — while no TTS audio +// is flowing — and HELD through playback. Calibrating while the speaker is +// audible bakes bleed into the floor and makes the trigger unreachable +// (echoCancellation does not reliably cancel same-app playback on Windows). +// - During playback the trigger is additionally clamped up to a minimum so +// bleed alone can't trip it, and capped so speech always remains reachable. +// - A short grace window after playback onset suppresses the start transient. +// - Detection is a windowed majority (>=80% of the last SUSTAINED_MS above +// trigger) so intra-word energy dips don't reset progress. + +const CALIBRATION_MS = 400 +const SUSTAINED_MS = 300 +const SUSTAINED_MAJORITY = 0.8 +const MIN_TRIGGER_LEVEL = 0.075 // matches the voice loop's silenceLevel +const FLOOR_MULTIPLIER = 3.5 +// Playback clamps, scaled from the Python constants (int16 RMS 1500 / 4000 +// ≈ byte-domain level 0.14 / 0.37 with the /42 normalization below). +const PLAYBACK_MIN_TRIGGER_LEVEL = 0.14 +const TRIGGER_CEILING_LEVEL = 0.37 +const PLAYBACK_GRACE_MS = 500 +const PLAYBACK_GAP_FOR_GRACE_MS = 1_000 +const FLOOR_SAMPLE_CAP = 200 // ~3s of quiet-phase levels at rAF cadence +const PRE_ROLL_RESTART_MS = 5_000 // cap pre-roll: restart the recorder while quiet +const UTTERANCE_SILENCE_MS = 1_250 // matches the voice loop's silenceMs +const UTTERANCE_MAX_MS = 30_000 + +export interface BargeMonitorCallbacks { + /** Sustained speech detected — cut playback / interrupt the turn now. */ + onSpeech: () => void + /** + * The interrupting utterance, complete from its first syllable (pre-roll + * included), delivered once the user goes quiet. `null` when capture was + * unavailable — fall back to normal listening. + */ + onUtterance?: (audio: Blob | null) => void + /** + * Is TTS audio flowing RIGHT NOW? Drives the phase-aware trigger. Omitted + * (legacy playback-only callers) means "always playing", which preserves + * the old behavior of a monitor opened at playback start. + */ + isPlaying?: () => boolean +} + +export function monitorSpeechDuringPlayback(callbacks: BargeMonitorCallbacks): () => void { + let disposed = false + let stream: MediaStream | null = null + let context: AudioContext | null = null + let frame: number | null = null + let recorder: MediaRecorder | null = null + let chunks: Blob[] = [] + let mimeType = '' + + const cleanup = () => { + disposed = true + + if (frame !== null) { + window.cancelAnimationFrame(frame) + frame = null + } + + if (recorder && recorder.state !== 'inactive') { + recorder.ondataavailable = null + recorder.onstop = null + + try { + recorder.stop() + } catch { + // already stopped + } + } + + recorder = null + chunks = [] + void context?.close().catch(() => undefined) + context = null + stream?.getTracks().forEach(track => track.stop()) + stream = null + } + + const startSegment = () => { + if (!stream || typeof MediaRecorder === 'undefined') { + return + } + + mimeType = + ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus'].find(type => + MediaRecorder.isTypeSupported(type) + ) ?? '' + + try { + recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined) + } catch { + recorder = null + + return + } + + chunks = [] + + recorder.ondataavailable = event => { + if (event.data.size > 0) { + chunks.push(event.data) + } + } + + recorder.start(250) + } + + /** Restart the recorder to drop stale pre-roll — only valid while quiet. */ + const rotateSegment = () => { + if (!recorder || recorder.state === 'inactive') { + return + } + + recorder.ondataavailable = null + recorder.onstop = null + + try { + recorder.stop() + } catch { + // already stopped + } + + startSegment() + } + + const finishCapture = () => { + const active = recorder + const type = active?.mimeType || mimeType || 'audio/webm' + + if (!active || active.state === 'inactive') { + cleanup() + callbacks.onUtterance?.(chunks.length ? new Blob(chunks, { type }) : null) + + return + } + + active.onstop = () => { + const audio = chunks.length ? new Blob(chunks, { type }) : null + + cleanup() + callbacks.onUtterance?.(audio) + } + + active.stop() + } + void (async () => { + try { + stream = await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: true, noiseSuppression: true } + }) + + if (disposed) { + cleanup() + + return + } + + startSegment() + + context = new AudioContext() + const analyser = context.createAnalyser() + analyser.fftSize = 256 + context.createMediaStreamSource(stream).connect(analyser) + + const data = new Uint8Array(analyser.fftSize) + const floorSamples: number[] = [] + const recentAbove: { above: boolean; at: number }[] = [] + let calibratedSince: number | null = null + let floorLocked = false + let quietFloor = 0 + let segmentStartedAt = Date.now() + let wasPlaying = false + let playbackSeen = false + let lastPlayingAt = 0 + let graceUntil = 0 + let tripped = false + let trippedAt = 0 + let quietSince: number | null = null + + const pushFloorSample = (level: number) => { + floorSamples.push(level) + + if (floorSamples.length > FLOOR_SAMPLE_CAP) { + floorSamples.shift() + } + + quietFloor = [...floorSamples].sort((a, b) => a - b)[floorSamples.length >> 1] ?? 0 + } + + const tick = () => { + if (disposed) { + return + } + + analyser.getByteTimeDomainData(data) + + let sum = 0 + + for (const value of data) { + const centered = value - 128 + sum += centered * centered + } + + const level = Math.min(1, Math.sqrt(sum / data.length) / 42) + const now = Date.now() + const playing = callbacks.isPlaying ? callbacks.isPlaying() : true + + if (!tripped) { + // Quiet-floor calibration: quiet-phase samples only. The floor is + // HELD while audio plays — never recalibrated against speaker bleed. + if (!floorLocked) { + if (!playing) { + calibratedSince ??= now + pushFloorSample(level) + } + + if (playing || (calibratedSince !== null && now - calibratedSince >= CALIBRATION_MS)) { + floorLocked = true + } + } + + // Grace only when playback starts after a real gap, so flapping of + // the playing flag between sentences can't chain grace windows. + if (playing && !wasPlaying) { + if (!playbackSeen || now - lastPlayingAt >= PLAYBACK_GAP_FOR_GRACE_MS) { + graceUntil = now + PLAYBACK_GRACE_MS + } + + playbackSeen = true + } + + wasPlaying = playing + + if (playing) { + lastPlayingAt = now + } + + // Phase-aware trigger: quiet baseline x multiplier; playback clamps + // it up (bleed alone can't trip) but a ceiling keeps speech + // reachable even over loud playback. + let trigger = Math.max(MIN_TRIGGER_LEVEL, quietFloor * FLOOR_MULTIPLIER) + + if (playing) { + trigger = Math.min(Math.max(trigger, PLAYBACK_MIN_TRIGGER_LEVEL), TRIGGER_CEILING_LEVEL) + } + + // Track ambient drift while quiet and below trigger. + if (floorLocked && !playing && level < trigger) { + pushFloorSample(level) + } + + const above = floorLocked && level >= trigger && now >= graceUntil + + recentAbove.push({ above, at: now }) + + while (recentAbove.length && now - recentAbove[0].at > SUSTAINED_MS) { + recentAbove.shift() + } + + const aboveCount = recentAbove.reduce((count, sample) => count + (sample.above ? 1 : 0), 0) + const spanMs = recentAbove.length ? now - recentAbove[0].at : 0 + + if ( + above && + spanMs >= SUSTAINED_MS * SUSTAINED_MAJORITY && + aboveCount >= recentAbove.length * SUSTAINED_MAJORITY + ) { + tripped = true + trippedAt = now + quietSince = null + callbacks.onSpeech() + + if (!callbacks.onUtterance || !recorder) { + cleanup() + callbacks.onUtterance?.(null) + + return + } + } else if (!above) { + // Bound the pre-roll while quiet so the utterance blob doesn't + // accumulate the whole turn (rotating mid-speech would lose the + // onset — the whole point). + if (now - segmentStartedAt >= PRE_ROLL_RESTART_MS) { + rotateSegment() + segmentStartedAt = now + } + } + } else { + // Tripped: keep recording until the user goes quiet (endpoint). + // Playback/generation was already cut, so silence-vs-speech works. + if (level >= MIN_TRIGGER_LEVEL) { + quietSince = null + } else { + quietSince ??= now + } + + if ((quietSince && now - quietSince >= UTTERANCE_SILENCE_MS) || now - trippedAt >= UTTERANCE_MAX_MS) { + finishCapture() + + return + } + } + + frame = window.requestAnimationFrame(tick) + } + + tick() + } catch { + cleanup() + } + })() + + return cleanup +} diff --git a/ui-desktop/src/lib/voice-playback.ts b/ui-desktop/src/lib/voice-playback.ts new file mode 100644 index 00000000..df32573e --- /dev/null +++ b/ui-desktop/src/lib/voice-playback.ts @@ -0,0 +1,516 @@ +import { resolveGatewayWsUrl } from '@clawcodex/shared' + +import { getApiRequestProfile, speakText } from '@/clawcodex' +import { + $voicePlayback, + setVoicePlaybackState, + type VoicePlaybackSource, + type VoicePlaybackState +} from '@/store/voice-playback' + +import { sanitizeTextForSpeech } from './speech-text' + +// Free Edge TTS occasionally hands back audio that never fires `playing`/`ended` +// nor `error` — leaving voice mode stuck "speaking" forever. Reject if playback +// fails to start or stalls mid-stream for this long (rearmed on each progress +// tick, so legitimately long speech is never cut off). +const PLAYBACK_STALL_MS = 15_000 + +let currentAudio: HTMLAudioElement | null = null +let currentStop: (() => void) | null = null +let sequence = 0 + +// A shared, lazily-created AudioContext used only to nudge the browser's +// autoplay state out of "suspended". A wake-word-started voice turn has no +// preceding user gesture, so the first HTMLAudioElement.play() can be rejected +// with NotAllowedError. resume()-ing a context is the documented way to recover +// once the app is allowed to make sound; on Electron chat windows the +// no-user-gesture-required policy means this is already unlocked, so this is a +// cheap no-op fallback for other surfaces. +let unlockCtx: AudioContext | null = null + +async function unlockAutoplay(): Promise<void> { + if (typeof window === 'undefined') { + return + } + + const Ctor = + window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + + if (!Ctor) { + return + } + + if (!unlockCtx) { + unlockCtx = new Ctor() + } + + if (unlockCtx.state === 'suspended') { + await unlockCtx.resume() + } +} + +function currentState( + status: VoicePlaybackState['status'], + options?: VoicePlaybackOptions, + audioElement: HTMLAudioElement | null = null +): VoicePlaybackState { + return { + audioElement, + messageId: options?.messageId ?? null, + sequence, + source: options?.source ?? null, + status + } +} + +export interface VoicePlaybackOptions { + messageId?: string | null + source: VoicePlaybackSource +} + +export function stopVoicePlayback() { + sequence += 1 + currentStop?.() + currentStop = null + + if (currentAudio) { + currentAudio.pause() + currentAudio.src = '' + currentAudio.load() + currentAudio = null + } + + setVoicePlaybackState({ + audioElement: null, + messageId: null, + sequence, + source: null, + status: 'idle' + }) +} + +// --------------------------------------------------------------------------- +// Streaming path — /api/audio/speak-stream WebSocket, raw int16 PCM frames +// scheduled through Web Audio. Speech starts on the provider's first chunk +// instead of after full synthesis + base64 transfer. +// --------------------------------------------------------------------------- + +async function resolveSpeakStreamUrl(): Promise<null | string> { + const desktop = window.clawcodexDesktop + + if (!desktop?.getConnection) { + return null + } + + try { + // Mint a fresh credential (single-use ticket in OAuth mode) for the + // ACTIVE profile's backend, then swap the gateway endpoint for the PCM + // one — auth is shared across WS routes. + const profile = getApiRequestProfile() + const wsUrl = await resolveGatewayWsUrl(desktop, await desktop.getConnection(profile)) + const url = new URL(wsUrl) + + if (!url.pathname.endsWith('/api/ws')) { + return null + } + + url.pathname = url.pathname.replace(/\/api\/ws$/, '/api/audio/speak-stream') + + // The backend resolves the TTS provider chain from this profile's + // config/.env (same seam as /api/pty?profile=). + if (profile) { + url.searchParams.set('profile', profile) + } + + return url.toString() + } catch { + return null + } +} + +export interface SpeechStreamSession { + /** Feed more reply text as it streams in. Safe after `finish` (no-op). */ + append: (text: string) => void + /** No more text coming — resolves `done` once the audio drains. */ + finish: () => void + /** + * 'done' — audio fully played (or barged via stopVoicePlayback) + * 'fallback'— no audio ever produced; caller should speak the accumulated + * text through `playSpeechText` instead. + */ + done: Promise<'done' | 'fallback'> +} + +/** + * Open a live speech session: one WebSocket + one AudioContext for a whole + * reply. Text is appended as LLM deltas arrive; the server cuts sentences and + * streams PCM back while generation continues, so speech overlaps the text + * stream (ChatGPT-style) with no per-sentence connection or synthesis gaps. + */ +function openSpeechStream(wsUrl: string, options: VoicePlaybackOptions): SpeechStreamSession { + const ws = new WebSocket(wsUrl) + ws.binaryType = 'arraybuffer' + + let context: AudioContext | null = null + let streamRate = 24_000 + let nextStartAt = 0 + let carry: null | Uint8Array = null + let started = false + let settled = false + let finished = false + const pendingSends: string[] = [] + + let settle: (value: 'done' | 'fallback') => void = () => undefined + + const done = new Promise<'done' | 'fallback'>(resolve => { + settle = value => { + if (settled) { + return + } + + settled = true + currentStop = null + + try { + ws.close() + } catch { + // already closed + } + + void context?.close().catch(() => undefined) + context = null + resolve(value) + } + }) + + const send = (frame: object) => { + const data = JSON.stringify(frame) + + if (ws.readyState === WebSocket.OPEN) { + ws.send(data) + } else if (ws.readyState === WebSocket.CONNECTING) { + pendingSends.push(data) + } + } + + // stopVoicePlayback() → immediate barge-in: kill the socket (the server + // aborts synthesis on disconnect) and the audio context (cuts sound now). + currentStop = () => settle('done') + + const finishWhenDrained = () => { + const remainingMs = context ? Math.max(0, nextStartAt - context.currentTime) * 1_000 : 0 + window.setTimeout(() => settle('done'), remainingMs + 100) + } + + const schedule = (data: ArrayBuffer) => { + if (!context) { + return + } + + // Provider chunks are not sample-aligned — carry any odd byte over. + let bytes = new Uint8Array(data) + + if (carry) { + const joined = new Uint8Array(carry.length + bytes.length) + joined.set(carry) + joined.set(bytes, carry.length) + bytes = joined + carry = null + } + + const usable = bytes.length - (bytes.length % 2) + + if (bytes.length !== usable) { + carry = bytes.slice(usable) + } + + if (!usable) { + return + } + + const pcm = new Int16Array(bytes.buffer, bytes.byteOffset, usable / 2) + const buffer = context.createBuffer(1, pcm.length, streamRate) + const channel = buffer.getChannelData(0) + + for (let index = 0; index < pcm.length; index += 1) { + channel[index] = pcm[index] / 32_768 + } + + const source = context.createBufferSource() + source.buffer = buffer + source.connect(context.destination) + + const startAt = Math.max(context.currentTime + 0.05, nextStartAt) + source.start(startAt) + nextStartAt = startAt + buffer.duration + + if (!started) { + started = true + setVoicePlaybackState(currentState('speaking', options)) + } + } + + ws.onopen = () => { + pendingSends.splice(0).forEach(data => ws.send(data)) + } + + ws.onmessage = event => { + if (typeof event.data !== 'string') { + schedule(event.data as ArrayBuffer) + + return + } + + let frame: { channels?: number; sample_rate?: number; type?: string } + + try { + frame = JSON.parse(event.data) as typeof frame + } catch { + return + } + + if (frame.type === 'start') { + streamRate = frame.sample_rate || 24_000 + context = new AudioContext() + + // Autoplay policy can hand back a suspended context when playback wasn't + // started by a user gesture (e.g. a wake-word-started voice turn). Resume + // it so the first reply is audible instead of silently buffering. Electron + // chat windows also set autoplayPolicy: no-user-gesture-required, but the + // dashboard-embedded surface relies on this resume. + if (context.state === 'suspended') { + void context.resume().catch(() => undefined) + } + + nextStartAt = 0 + } else if (frame.type === 'end') { + finishWhenDrained() + } else if (frame.type === 'fallback') { + settle(started ? 'done' : 'fallback') + } + } + + // A drop before any audio means the endpoint is unavailable (old backend, + // auth, network) → fall back. After audio started, replaying the whole + // message via POST would stutter — treat what played as the playback. + ws.onerror = () => settle(started ? 'done' : 'fallback') + ws.onclose = () => (started ? finishWhenDrained() : settle('fallback')) + + return { + // Raw deltas — the server strips markdown/emoji per *sentence*, which is + // the only safe granularity when constructs span delta boundaries. + append: text => { + if (text && !finished && !settled) { + send({ text }) + } + }, + finish: () => { + if (!finished && !settled) { + finished = true + send({ done: true }) + } + }, + done + } +} + +/** + * Live-speak an in-progress reply: open a session, then `append` deltas and + * `finish` when generation completes. Resolves null when streaming is + * unavailable (old backend / non-chunked provider) — the caller falls back to + * whole-text `playSpeechText`. + */ +export async function startSpeechStream(options: VoicePlaybackOptions): Promise<null | SpeechStreamSession> { + const wsUrl = await resolveSpeakStreamUrl() + + if (!wsUrl) { + return null + } + + stopVoicePlayback() + setVoicePlaybackState(currentState('preparing', options)) + + const session = openSpeechStream(wsUrl, options) + + void session.done.then(outcome => { + if (outcome === 'done') { + setVoicePlaybackState(currentState('idle')) + } + }) + + return session +} + +/** One-shot playback of complete text over the streaming WS. */ +function playSpeechStream(wsUrl: string, text: string, options: VoicePlaybackOptions): Promise<'fallback' | 'played'> { + const session = openSpeechStream(wsUrl, options) + session.append(text) + session.finish() + + return session.done.then(outcome => (outcome === 'done' ? 'played' : 'fallback')) +} + +async function playSpeechDataUrl( + speakableText: string, + options: VoicePlaybackOptions, + isCurrent: () => boolean +): Promise<boolean> { + const response = await speakText(speakableText) + + if (!isCurrent()) { + return false + } + + const audio = new Audio(response.data_url) + currentAudio = audio + setVoicePlaybackState(currentState('speaking', options, audio)) + + await new Promise<void>((resolve, reject) => { + let stall: number | null = null + + const cleanup = () => { + if (stall !== null) { + window.clearTimeout(stall) + stall = null + } + + audio.removeEventListener('ended', onEnded) + audio.removeEventListener('error', onError) + audio.removeEventListener('timeupdate', armStall) + currentStop = null + } + + const armStall = () => { + if (stall !== null) { + window.clearTimeout(stall) + } + + stall = window.setTimeout(() => { + cleanup() + reject(new Error('Playback stalled')) + }, PLAYBACK_STALL_MS) + } + + const onEnded = () => { + cleanup() + resolve() + } + + const onError = () => { + cleanup() + reject(new Error('Playback failed')) + } + + currentStop = () => { + cleanup() + resolve() + } + + audio.addEventListener('ended', onEnded, { once: true }) + audio.addEventListener('error', onError, { once: true }) + audio.addEventListener('timeupdate', armStall) + armStall() + // A wake-word-started turn has no user gesture, so the autoplay policy can + // reject the first play() with NotAllowedError. Electron chat windows set + // autoplayPolicy: no-user-gesture-required to prevent this, but retry once + // after resuming a shared AudioContext as a fallback for other surfaces + // (dashboard-embedded) so the first reply isn't silently dropped. + void audio.play().catch(async () => { + try { + await unlockAutoplay() + await audio.play() + } catch { + onError() + } + }) + }) + + if (!isCurrent()) { + return false + } + + currentAudio = null + + return true +} + +export async function playSpeechText(text: string, options: VoicePlaybackOptions): Promise<boolean> { + stopVoicePlayback() + + const speakableText = sanitizeTextForSpeech(text) + + if (!speakableText) { + return false + } + + const ownSequence = sequence + const isCurrent = () => ownSequence === sequence + + setVoicePlaybackState(currentState('preparing', options)) + + try { + // Streaming first; the POST data-URL path is the fallback for backends + // without the WS endpoint or providers without a chunked API. + const streamUrl = await resolveSpeakStreamUrl() + + if (streamUrl && isCurrent()) { + const outcome = await playSpeechStream(streamUrl, speakableText, options) + + if (outcome === 'played') { + if (!isCurrent()) { + return false + } + + setVoicePlaybackState(currentState('idle')) + + return true + } + } + + if (!isCurrent()) { + return false + } + + const played = await playSpeechDataUrl(speakableText, options, isCurrent) + + if (played) { + setVoicePlaybackState(currentState('idle')) + } + + return played + } catch (error) { + if (isCurrent()) { + currentStop = null + currentAudio = null + setVoicePlaybackState(currentState('idle')) + } + + throw error + } +} + +export function isVoicePlaybackActive() { + return $voicePlayback.get().status !== 'idle' +} + +// --------------------------------------------------------------------------- +// Interruption latch — the next prompt.submit carries `interrupted: true` so +// the model knows its spoken reply was cut off (it can react: "rude!"). +// Marked by the barge-in paths (VAD, typing over playback); TTL'd so a stale +// barge never annotates an unrelated message minutes later. +// --------------------------------------------------------------------------- + +const INTERRUPT_TTL_MS = 120_000 +let interruptedAt: null | number = null + +export function markVoicePlaybackInterrupted() { + interruptedAt = Date.now() +} + +export function takeVoicePlaybackInterrupted(): boolean { + const at = interruptedAt + interruptedAt = null + + return at !== null && Date.now() - at < INTERRUPT_TTL_MS +} diff --git a/ui-desktop/src/lib/voice-stop-word.test.ts b/ui-desktop/src/lib/voice-stop-word.test.ts new file mode 100644 index 00000000..f757c44d --- /dev/null +++ b/ui-desktop/src/lib/voice-stop-word.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' + +import { interceptsTypedVoiceStop, isVoiceStopCommand } from './voice-stop-word' + +describe('isVoiceStopCommand', () => { + it('matches bare stop commands', () => { + for (const phrase of ['stop', 'Stop', 'STOP', 'stop.', 'stop!', ' stop ', 'stop…']) { + expect(isVoiceStopCommand(phrase)).toBe(true) + } + }) + + it('matches multi-word stop phrases', () => { + for (const phrase of [ + 'stop listening', + 'stop it', + 'please stop', + 'stop please', + "that's all", + 'that is all', + 'never mind', + 'nevermind', + 'end conversation', + 'end the conversation', + 'goodbye', + 'bye', + 'cancel' + ]) { + expect(isVoiceStopCommand(phrase)).toBe(true) + } + }) + + it('matches stop commands addressed to ClawCodex', () => { + for (const phrase of ['clawcodex stop', 'hey clawcodex stop', 'hey clawcodex, stop', 'ok stop', 'okay stop']) { + expect(isVoiceStopCommand(phrase)).toBe(true) + } + }) + + it('does NOT match substantive requests that merely contain "stop"', () => { + for (const phrase of [ + 'stop the docker container', + 'how do I stop a running process', + 'can you stop the deployment', + 'stop the music and play something else', + "don't stop now", + 'the bus stop is closed' + ]) { + expect(isVoiceStopCommand(phrase)).toBe(false) + } + }) + + it('does not match bare address words or empty input', () => { + for (const phrase of ['', ' ', 'clawcodex', 'hey clawcodex', 'ok', 'okay', 'hey']) { + expect(isVoiceStopCommand(phrase)).toBe(false) + } + }) + + it('does not match unrelated short utterances', () => { + for (const phrase of ['hello', 'yes', 'what time is it', 'thanks']) { + expect(isVoiceStopCommand(phrase)).toBe(false) + } + }) +}) + +describe('interceptsTypedVoiceStop', () => { + it('intercepts a typed bare stop command while the conversation is active', () => { + for (const text of ['stop', 'Stop.', 'never mind', 'hey clawcodex, stop']) { + expect(interceptsTypedVoiceStop(true, text)).toBe(true) + } + }) + + it('never intercepts when the voice conversation is inactive', () => { + for (const text of ['stop', 'never mind', 'goodbye']) { + expect(interceptsTypedVoiceStop(false, text)).toBe(false) + } + }) + + it('passes through substantive messages during a conversation', () => { + for (const text of ['stop the docker container', 'how do I stop a process', 'hello']) { + expect(interceptsTypedVoiceStop(true, text)).toBe(false) + } + }) + + it('passes through when attachments ride along (real payload)', () => { + expect(interceptsTypedVoiceStop(true, 'stop', 1)).toBe(false) + }) +}) diff --git a/ui-desktop/src/lib/voice-stop-word.ts b/ui-desktop/src/lib/voice-stop-word.ts new file mode 100644 index 00000000..b99f58da --- /dev/null +++ b/ui-desktop/src/lib/voice-stop-word.ts @@ -0,0 +1,105 @@ +// Spoken stop-word detection for the voice conversation loop. +// +// When someone is in a hands-free "Hey ClawCodex" voice chat, the natural way to +// end it is to SAY "stop" — not reach for the mouse. Without this, a spoken +// "stop" is just transcribed and sent to the agent as a normal turn, so the +// conversation never ends (the reported bug). This matcher recognises a short +// utterance whose entire content is a stop command and ends the conversation +// instead of submitting it. +// +// Deliberately conservative: it only fires when the WHOLE utterance is a stop +// phrase (optionally addressed to ClawCodex), so a real turn that merely contains +// the word "stop" — e.g. "stop the docker container" or "how do I stop a +// running process" — is never swallowed. + +// Canonical stop commands. Kept short and unambiguous; each must be the entire +// spoken utterance to match. +const STOP_PHRASES: readonly string[] = [ + 'stop', + 'stop listening', + 'stop it', + 'stop please', + 'please stop', + 'stop stop', + 'that is all', + "that's all", + 'never mind', + 'nevermind', + 'end conversation', + 'end the conversation', + 'goodbye', + 'good bye', + 'bye', + 'cancel' +] + +// Optional address prefixes so "clawcodex stop" / "ok stop" / "hey clawcodex, stop" +// still count. Stripped before matching the core phrase. +const ADDRESS_PREFIXES: readonly string[] = ['hey clawcodex', 'hey clawcodex,', 'clawcodex', 'clawcodex,', 'ok', 'okay', 'hey'] + +// Normalise: lowercase, strip surrounding punctuation/whitespace, collapse +// internal runs of spaces. Trailing punctuation (".", "!", "…") is common in +// STT output and must not defeat the match. +function normalize(text: string): string { + return text + .toLowerCase() + .replace(/[.,!?;:…]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function stripAddress(text: string): string { + for (const prefix of ADDRESS_PREFIXES) { + if (text === prefix) { + // Bare address ("clawcodex") is not a stop command on its own. + continue + } + + if (text.startsWith(`${prefix} `)) { + return text.slice(prefix.length + 1).trim() + } + } + + return text +} + +/** + * True when the entire spoken utterance is a stop command (optionally addressed + * to ClawCodex). Returns false for anything that merely contains "stop" as part of + * a longer, substantive request. + */ +export function isVoiceStopCommand(transcript: string): boolean { + if (!transcript) { + return false + } + + const normalized = normalize(transcript) + + if (!normalized) { + return false + } + + // Match with the address prefix stripped, and also as-is (so a bare "stop" + // with no prefix still matches, and "please stop" — where "please" isn't a + // prefix — matches directly). + const candidates = new Set([normalized, stripAddress(normalized)]) + + for (const candidate of candidates) { + if (STOP_PHRASES.includes(candidate)) { + return true + } + } + + return false +} + +/** + * Typed-stop interception decision for the composer: a bare stop command + * typed while the voice conversation is live ends the conversation instead of + * being sent as a turn. Attachments mean the message is a real payload — + * never intercepted. Outside a voice conversation typed text always passes + * through unchanged. + */ +export function interceptsTypedVoiceStop(conversationActive: boolean, text: string, attachmentCount = 0): boolean { + return conversationActive && attachmentCount === 0 && isVoiceStopCommand(text) +} diff --git a/ui-desktop/src/lib/wake-client-capture.ts b/ui-desktop/src/lib/wake-client-capture.ts new file mode 100644 index 00000000..cabcee90 --- /dev/null +++ b/ui-desktop/src/lib/wake-client-capture.ts @@ -0,0 +1,234 @@ +/** + * Client-side mic capture for remote wake word. + * + * When the backend arms with `capture: "client"`, PortAudio runs on a headless + * VM with no mic. The desktop opens getUserMedia here, resamples to 16 kHz + * mono int16 frames, and pushes them via `wake.feed` so openWakeWord still + * runs server-side without requiring a server sound device. + */ + +const TARGET_RATE = 16_000 +const DEFAULT_FRAME = 1280 // 80 ms @ 16 kHz — matches tools/wake_word.py + +export type WakeFeedRequester = (method: string, params?: Record<string, unknown>) => Promise<unknown> + +export interface ClientWakeCaptureOptions { + /** Samples per frame at 16 kHz (from wake.start response). */ + frameLength?: number + request: WakeFeedRequester + onError?: (error: Error) => void +} + +export interface ClientWakeCaptureHandle { + stop: () => void + readonly active: boolean +} + +function downsampleTo16k(input: Float32Array, inputRate: number): Float32Array { + if (inputRate === TARGET_RATE) { + return input + } + + if (inputRate <= 0) { + return new Float32Array(0) + } + + const ratio = inputRate / TARGET_RATE + const outLen = Math.max(1, Math.floor(input.length / ratio)) + const out = new Float32Array(outLen) + + for (let i = 0; i < outLen; i++) { + const start = Math.floor(i * ratio) + const end = Math.min(input.length, Math.floor((i + 1) * ratio)) + let sum = 0 + let count = 0 + + for (let j = start; j < end; j++) { + sum += input[j] ?? 0 + count++ + } + + out[i] = count > 0 ? sum / count : 0 + } + + return out +} + +function floatToInt16LE(input: Float32Array): ArrayBuffer { + const buf = new ArrayBuffer(input.length * 2) + const view = new DataView(buf) + + for (let i = 0; i < input.length; i++) { + const s = Math.max(-1, Math.min(1, input[i] ?? 0)) + view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true) + } + + return buf +} + +function bytesToBase64(buf: ArrayBuffer): string { + const bytes = new Uint8Array(buf) + let binary = '' + const chunk = 0x8000 + + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)) + } + + return btoa(binary) +} + +/** + * Start streaming the default microphone to `wake.feed`. + * Returns a handle whose `stop()` ends tracks + audio graph. + */ +export async function startClientWakeCapture(options: ClientWakeCaptureOptions): Promise<ClientWakeCaptureHandle> { + const frameLength = Math.max(160, Math.trunc(options.frameLength || DEFAULT_FRAME)) + const audioWindow = window as Window & { webkitAudioContext?: typeof AudioContext } + const AudioContextCtor = window.AudioContext || audioWindow.webkitAudioContext + + if (!AudioContextCtor) { + throw new Error('AudioContext unavailable for client wake capture') + } + + if (!navigator.mediaDevices?.getUserMedia) { + throw new Error('getUserMedia unavailable for client wake capture') + } + + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: 1, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true + }, + video: false + }) + + const context = new AudioContextCtor() + const source = context.createMediaStreamSource(stream) + // ScriptProcessor is deprecated but widely available and simple for PCM export. + // Buffer size 4096 keeps callback rate reasonable on desktop. + const processor = context.createScriptProcessor(4096, 1, 1) + const mute = context.createGain() + mute.gain.value = 0 + + let pending = new Float32Array(0) + let stopped = false + // Bounded ordered queue of 16 kHz frames. We never drop the frame that is + // currently being sent; under remote latency we drop the oldest queued + // frames so the detector still sees contiguous recent PCM rather than gaps + // from fire-and-forget discard-while-inflight. + const MAX_QUEUED_FRAMES = 24 // ~1.9s at 80 ms/frame + // Coalesce queued frames into one wake.feed call (backend splits them back + // into engine frames). 4 × 80 ms ≈ 3 RPCs/s steady-state instead of 12.5. + const MAX_FRAMES_PER_FEED = 4 + const queue: Float32Array[] = [] + let draining = false + + const drainQueue = async () => { + if (draining) { + return + } + + draining = true + + try { + while (!stopped && queue.length > 0) { + const batch = queue.splice(0, MAX_FRAMES_PER_FEED) + + if (batch.length === 0) { + break + } + + try { + const merged = new Float32Array(batch.length * frameLength) + batch.forEach((frame, i) => merged.set(frame, i * frameLength)) + const pcm = floatToInt16LE(merged) + await options.request('wake.feed', { + pcm: bytesToBase64(pcm), + sample_rate: TARGET_RATE + }) + } catch (error) { + options.onError?.(error instanceof Error ? error : new Error(String(error))) + // Keep draining later frames; one failed RPC should not freeze the ear. + } + } + } finally { + draining = false + + if (!stopped && queue.length > 0) { + void drainQueue() + } + } + } + + const enqueueFrame = (frame: Float32Array) => { + if (stopped) { + return + } + + queue.push(frame) + + while (queue.length > MAX_QUEUED_FRAMES) { + queue.shift() + } + + void drainQueue() + } + + processor.onaudioprocess = event => { + if (stopped) { + return + } + + const input = event.inputBuffer.getChannelData(0) + const at16k = downsampleTo16k(input, context.sampleRate) + // Append to pending and emit full frames + const merged = new Float32Array(pending.length + at16k.length) + merged.set(pending, 0) + merged.set(at16k, pending.length) + let offset = 0 + + while (offset + frameLength <= merged.length) { + const frame = merged.subarray(offset, offset + frameLength) + offset += frameLength + enqueueFrame(new Float32Array(frame)) + } + + pending = merged.subarray(offset) + } + + source.connect(processor) + processor.connect(mute) + mute.connect(context.destination) + + if (context.state === 'suspended') { + await context.resume().catch(() => undefined) + } + + return { + get active() { + return !stopped + }, + stop() { + if (stopped) { + return + } + + stopped = true + queue.length = 0 + + try { + processor.disconnect() + source.disconnect() + mute.disconnect() + } catch { + // ignore + } + + void context.close().catch(() => undefined) + stream.getTracks().forEach(t => t.stop()) + } + } +} diff --git a/ui-desktop/src/lib/wake-indicator.test.ts b/ui-desktop/src/lib/wake-indicator.test.ts new file mode 100644 index 00000000..ccc885ec --- /dev/null +++ b/ui-desktop/src/lib/wake-indicator.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('wake indicator lifecycle', () => { + beforeEach(() => { + vi.resetModules() + }) + + it('moves from detection to capture and hides when the wake-started session ends', async () => { + const setState = vi.fn() + vi.stubGlobal('window', { clawcodexDesktop: { wakeIndicator: { setState } } }) + const { activateWakeIndicator, syncWakeIndicatorWithVoice } = await import('./wake-indicator') + + activateWakeIndicator() + expect(syncWakeIndicatorWithVoice(false, 'idle')).toBe(false) + syncWakeIndicatorWithVoice(true, 'idle') + syncWakeIndicatorWithVoice(true, 'listening') + syncWakeIndicatorWithVoice(true, 'thinking') + syncWakeIndicatorWithVoice(false, 'idle') + + expect(setState.mock.calls.map(([state]) => state)).toEqual(['detected', 'capturing', 'detected', 'hidden']) + }) + + it('does not show for a manually started voice conversation', async () => { + const setState = vi.fn() + vi.stubGlobal('window', { clawcodexDesktop: { wakeIndicator: { setState } } }) + const { syncWakeIndicatorWithVoice } = await import('./wake-indicator') + + expect(syncWakeIndicatorWithVoice(true, 'listening')).toBe(false) + expect(syncWakeIndicatorWithVoice(false, 'idle')).toBe(false) + + expect(setState).not.toHaveBeenCalled() + }) + + it('deduplicates repeated visual states', async () => { + const setState = vi.fn() + vi.stubGlobal('window', { clawcodexDesktop: { wakeIndicator: { setState } } }) + const { activateWakeIndicator, clearWakeIndicator } = await import('./wake-indicator') + + activateWakeIndicator() + activateWakeIndicator() + clearWakeIndicator() + clearWakeIndicator() + + expect(setState.mock.calls.map(([state]) => state)).toEqual(['detected', 'hidden']) + }) +}) diff --git a/ui-desktop/src/lib/wake-indicator.ts b/ui-desktop/src/lib/wake-indicator.ts new file mode 100644 index 00000000..8b9652b2 --- /dev/null +++ b/ui-desktop/src/lib/wake-indicator.ts @@ -0,0 +1,55 @@ +export type WakeIndicatorState = 'capturing' | 'detected' | 'hidden' + +export type WakeIndicatorVoiceStatus = 'idle' | 'listening' | 'speaking' | 'thinking' | 'transcribing' + +let wakeStartedConversation = false +let voiceConversationStarted = false +let lastState: WakeIndicatorState = 'hidden' + +function pushState(state: WakeIndicatorState): void { + if (state === lastState) { + return + } + + lastState = state + window.clawcodexDesktop?.wakeIndicator?.setState(state) +} + +export function activateWakeIndicator(): void { + wakeStartedConversation = true + voiceConversationStarted = false + pushState('detected') +} + +export function syncWakeIndicatorWithVoice(active: boolean, status: WakeIndicatorVoiceStatus): boolean { + if (!wakeStartedConversation) { + return false + } + + if (!active && !voiceConversationStarted) { + return false + } + + if (!active) { + wakeStartedConversation = false + voiceConversationStarted = false + pushState('hidden') + + return true + } + + voiceConversationStarted = true + pushState(status === 'listening' ? 'capturing' : 'detected') + + return true +} + +export function clearWakeIndicator(): void { + if (!wakeStartedConversation && lastState === 'hidden') { + return + } + + wakeStartedConversation = false + voiceConversationStarted = false + pushState('hidden') +} diff --git a/ui-desktop/src/lib/wake-sound.test.ts b/ui-desktop/src/lib/wake-sound.test.ts new file mode 100644 index 00000000..0da3f3e3 --- /dev/null +++ b/ui-desktop/src/lib/wake-sound.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $hapticsMuted } from '@/store/haptics' + +import { playWakeSound } from './wake-sound' + +// Minimal WebAudio doubles: enough to record that playWakeSound wired +// oscillators to the destination when it should, and stayed silent when muted. +class FakeParam { + setValueAtTime = vi.fn() + exponentialRampToValueAtTime = vi.fn() +} + +class FakeOscillator { + type = 'sine' + frequency = new FakeParam() + connect = vi.fn() + start = vi.fn() + stop = vi.fn() +} + +class FakeGain { + gain = new FakeParam() + connect = vi.fn() +} + +let oscillators: FakeOscillator[] + +class FakeAudioContext { + state = 'running' + currentTime = 0 + destination = {} + resume = vi.fn().mockResolvedValue(undefined) + + createOscillator() { + const osc = new FakeOscillator() + oscillators.push(osc) + + return osc + } + + createGain() { + return new FakeGain() + } +} + +describe('playWakeSound', () => { + beforeEach(() => { + oscillators = [] + $hapticsMuted.set(false) + vi.stubGlobal('AudioContext', FakeAudioContext) + }) + + afterEach(() => { + vi.unstubAllGlobals() + $hapticsMuted.set(false) + }) + + it('plays a two-note rising chime when sound is on', () => { + playWakeSound() + + // G5 then C6 — two enveloped oscillators, both routed onward. + expect(oscillators).toHaveLength(2) + expect(oscillators[0].frequency.setValueAtTime).toHaveBeenCalledWith(783.99, expect.any(Number)) + expect(oscillators[1].frequency.setValueAtTime).toHaveBeenCalledWith(1046.5, expect.any(Number)) + + for (const osc of oscillators) { + expect(osc.start).toHaveBeenCalled() + expect(osc.stop).toHaveBeenCalled() + } + }) + + it('stays silent when the shared sound-mute toggle is on', () => { + $hapticsMuted.set(true) + playWakeSound() + expect(oscillators).toHaveLength(0) + }) + + it('never throws when WebAudio is unavailable', () => { + vi.stubGlobal('AudioContext', undefined) + expect(() => playWakeSound()).not.toThrow() + }) +}) diff --git a/ui-desktop/src/lib/wake-sound.ts b/ui-desktop/src/lib/wake-sound.ts new file mode 100644 index 00000000..dbec27f2 --- /dev/null +++ b/ui-desktop/src/lib/wake-sound.ts @@ -0,0 +1,88 @@ +// Wake-word activation chime. A short, bright, rising two-note "ding" that +// plays the moment "Hey ClawCodex" is detected, so it's obvious the wake +// registered before voice capture starts. Deliberately distinct from the +// turn-end completion cue (completion-sound.ts): this one RISES (open/ready), +// the completion cue settles (done). Reuses the same lightweight WebAudio +// synthesis approach — no asset file to ship. + +import { $hapticsMuted } from '@/store/haptics' + +let ctx: AudioContext | null = null + +function getCtx(): AudioContext | null { + if (typeof window === 'undefined') { + return null + } + + try { + if (!ctx) { + const Ctor = + window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + + if (!Ctor) { + return null + } + + ctx = new Ctor() + } + + // Autoplay policies can leave the context suspended until a gesture; a + // resume() here recovers it once the user has interacted with the window. + if (ctx.state === 'suspended') { + void ctx.resume().catch(() => undefined) + } + + return ctx + } catch { + return null + } +} + +// One enveloped sine voice → master. Linear-ish attack into an exponential +// decay keeps the tail smooth and avoids the click you get ramping to zero. +function ding(ac: AudioContext, master: GainNode, t0: number, freq: number, dur: number, gain: number) { + const osc = ac.createOscillator() + const env = ac.createGain() + const end = t0 + dur + + osc.type = 'sine' + osc.frequency.setValueAtTime(freq, t0) + + env.gain.setValueAtTime(0.0001, t0) + env.gain.exponentialRampToValueAtTime(Math.max(gain, 0.0002), t0 + 0.008) + env.gain.exponentialRampToValueAtTime(0.0001, end) + + osc.connect(env) + env.connect(master) + osc.start(t0) + osc.stop(end + 0.02) +} + +// Play the wake chime. Honours the shared sound-mute toggle ($hapticsMuted), +// the same gate the completion cue uses, so muting turn-end sounds also +// silences this. Best-effort: never throws into the wake-event handler. +export function playWakeSound(): void { + if ($hapticsMuted.get()) { + return + } + + const ac = getCtx() + + if (!ac) { + return + } + + try { + const master = ac.createGain() + master.gain.setValueAtTime(0.5, ac.currentTime) + master.connect(ac.destination) + + const t0 = ac.currentTime + 0.01 + // Rising perfect-fourth: G5 -> C6. Short and bright — "listening". + ding(ac, master, t0, 783.99, 0.12, 0.06) + ding(ac, master, t0 + 0.1, 1046.5, 0.28, 0.07) + } catch { + // WebAudio can throw if the context died mid-call; a missed chime must + // never break wake handling. + } +} diff --git a/ui-desktop/src/lib/yolo-session.ts b/ui-desktop/src/lib/yolo-session.ts new file mode 100644 index 00000000..a16fa505 --- /dev/null +++ b/ui-desktop/src/lib/yolo-session.ts @@ -0,0 +1,76 @@ +import { $gateway } from '@/store/gateway' +import { $activeSessionId, setYoloActive } from '@/store/session' + +export type GatewayRequester = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T> + +/** + * Toggle per-session YOLO (approval bypass) via gateway `config.set` — the same + * session-scoped flag as the TUI's Shift+Tab. It does NOT touch the global + * `approvals.mode` config, so CLI / TUI / cron behavior is unaffected. + */ +export async function setSessionYolo( + requestGateway: GatewayRequester, + sessionId: string, + enabled: boolean +): Promise<boolean> { + const result = await requestGateway<{ value?: string }>('config.set', { + key: 'yolo', + session_id: sessionId, + value: enabled ? '1' : '0' + }) + + const active = result?.value === '1' + + setYoloActive(active) + + return active +} + +/** + * Toggle GLOBAL YOLO (approval bypass) via gateway `config.set` with + * `scope: 'global'`. This flips the persistent `approvals.mode` in config.yaml + * between `off` (bypass on) and `manual` (bypass off), affecting every session, + * the CLI, the TUI, and cron — and it survives restarts. Triggered by + * Shift+clicking the status-bar zap. + */ +export async function setGlobalYolo(requestGateway: GatewayRequester, enabled: boolean): Promise<boolean> { + const result = await requestGateway<{ value?: string }>('config.set', { + key: 'yolo', + scope: 'global', + value: enabled ? '1' : '0' + }) + + const active = result?.value === '1' + + setYoloActive(active) + + return active +} + +/** + * Set YOLO to an explicit state from a surface that has no React context — the + * ⌘K rows. `useSlashCommand` keeps its own `requestGateway` (it already holds + * one, with the reconnect handling), so this reaches the active gateway + * directly rather than growing a second requester abstraction. + * + * With no session yet the flag is armed locally; the session-create path + * (use-session-actions) applies it on the first message, exactly as a bare + * `/yolo` in a fresh draft does. + */ +export async function setYoloEnabled(enabled: boolean): Promise<boolean> { + const sessionId = $activeSessionId.get() + + if (!sessionId) { + setYoloActive(enabled) + + return enabled + } + + const gateway = $gateway.get() + + if (!gateway) { + throw new Error('ClawCodex gateway unavailable') + } + + return setSessionYolo((method, params) => gateway.request(method, params), sessionId, enabled) +}