diff --git a/.agents/skills/settings-ui-patterns/SKILL.md b/.agents/skills/settings-ui-patterns/SKILL.md index 58a894f7..2c731346 100644 --- a/.agents/skills/settings-ui-patterns/SKILL.md +++ b/.agents/skills/settings-ui-patterns/SKILL.md @@ -23,7 +23,7 @@ divs — use the primitives, and extend them (in the shared file) when a new shape is genuinely missing. - Flat hierarchy through spacing and typography; no cards, boxed backgrounds, or row chrome. -- Secondary helper text is hidden behind an info icon (`info` prop); the default view stays quiet. +- Secondary helper text is hidden behind an info icon (`info` prop) by default; the default view stays quiet. - Controls have one standard size (`h-9` / select `size="settings"`) and capped widths — no full-bleed inputs. - Layouts respond to the settings pane width via container queries (`@xl:` / `@3xl:`), never viewport `sm:`/`lg:` breakpoints (the pane is much narrower than the viewport inside the dialog). - Checkbox/radio state comes before labels; selected states are subtle and never shift layout. @@ -57,7 +57,8 @@ Do not introduce raw ``-based info icons, direct Remixicon components, ## Description Policy (info hints) -- Explanatory prose (what a feature does, when it applies) goes behind the info icon via the `info` prop — never as always-visible `description`. +- Explanatory prose goes behind the info icon via the `info` prop by default. +- When labels alone cannot explain the differences, consequences, or conditions needed to choose a setting, use a title, a visible description, then checkbox or radio controls. Large-text paste modes and send shortcuts with expanded-composer exceptions need this explanation. Having multiple options or a group title alone does not require a description; see `references/controls.md` for composition. - Stays visible: security/data-loss warnings, destructive consequences, required syntax/placeholder lists the user reads while typing, dynamic status, empty states, validation errors, active-flow wizard instructions. - Mixed text: keep the warning sentence visible, move the explanation to `info`. @@ -80,7 +81,7 @@ Dynamic entity rows normally are not indexed. Load `references/search.md` for ex ## Completion Criteria - Built from shared primitives; no ad-hoc page/section/row markup. -- Explanatory text hidden behind `info`; warnings/syntax/status still visible. +- Description placement follows the policy above; warnings/syntax/status remain visible. - Container-query (`@xl:`/`@3xl:`) responsiveness — no viewport breakpoints in pane content. - Controls use the standard size and width caps; no stretched full-width inputs. - Localized visible and accessibility text everywhere. diff --git a/.agents/skills/settings-ui-patterns/references/controls.md b/.agents/skills/settings-ui-patterns/references/controls.md index 08b36ada..2a2c0806 100644 --- a/.agents/skills/settings-ui-patterns/references/controls.md +++ b/.agents/skills/settings-ui-patterns/references/controls.md @@ -38,6 +38,9 @@ cells or when the control is wide; same `info` / `settingsItem` props. ## Boolean +For a self-explanatory enable/disable setting, use only a checkbox and label; +no separate group title or description is needed. + ```tsx - - + + + + + ``` Skip per-option descriptions when labels are self-explanatory. For short diff --git a/.agents/skills/settings-ui-patterns/references/layout.md b/.agents/skills/settings-ui-patterns/references/layout.md index 36007eac..f23cbea0 100644 --- a/.agents/skills/settings-ui-patterns/references/layout.md +++ b/.agents/skills/settings-ui-patterns/references/layout.md @@ -29,7 +29,7 @@ All primitives and class constants below live in | L2 | `SettingsSection` title (`SETTINGS_SECTION_TITLE_CLASS`) | Section | | L3 | `SettingsControlGroup` title (`SETTINGS_GROUP_TITLE_CLASS`) | Sub-cluster inside a section | | L4 | `SETTINGS_FIELD_LABEL_CLASS` | Field / control labels | -| Helper | `SETTINGS_HELPER_CLASS`, `SETTINGS_DESCRIPTION_CLASS` | Rare visible helper text (most goes behind `info`) | +| Helper | `SETTINGS_HELPER_CLASS`, `SETTINGS_DESCRIPTION_CLASS` | Rare visible helper text (most goes behind `info`; see the skill's Description Policy) | ## Navigation Placement @@ -63,5 +63,6 @@ pattern when touching nav. - Sections own vertical rhythm: divider + `py-8` come from `SettingsSection`. - Fields inside a column: `SETTINGS_FIELDS_STACK_CLASS` (`space-y-4`). - Checkbox/radio lists: `SETTINGS_OPTION_STACK_CLASS` (`space-y-1.5`). +- Groups requiring a title and visible description: separate them from preceding controls with `space-y-6` on the parent. Keep simple checkbox/radio lists compact. The title sits closer to its own description and controls than to the preceding group; use `SettingsControlGroup`'s internal spacing. - Two-column areas: `SettingsTwoColumn` (`@3xl:grid-cols-2`); use `SettingsStackedField` inside cells (a `SettingsFieldRow` overflows half-width columns). - No elevated backgrounds, rounded rows, or hover fills without explicit UX value. diff --git a/.agents/skills/theme-system/SKILL.md b/.agents/skills/theme-system/SKILL.md index 89e49a36..cb8225b6 100644 --- a/.agents/skills/theme-system/SKILL.md +++ b/.agents/skills/theme-system/SKILL.md @@ -61,6 +61,14 @@ Use `Button` from `packages/ui/src/components/ui/button.tsx`. Do not hardcode button height/padding when a size variant exists. Do not recreate selection/destructive styling with ad-hoc classes. +## Keyboard Navigation Contract + +- Menus, selects, and autocomplete pickers with ArrowDown/ArrowUp navigation must also support Ctrl+N/Ctrl+P, including submenus and searchable lists. +- Keep this behavior in shared components so callers inherit it. Use the keyboard mapping in `packages/ui/src/components/ui/dropdown-navigation.ts`; feature code must not duplicate key detection. +- Lists that own their active option or stop keyboard propagation must call the shared navigation helper at their own event boundary. Wrapping a custom list in a dropdown does not guarantee that its navigation events reach the wrapper. +- Route both key pairs through the same selection logic, preserving disabled-item skipping, boundary or wrap behavior, highlight, and scroll visibility. Consume each navigation event once, only while the menu or picker is active; preserve IME text entry and other modifier chords. +- Verify Ctrl+N/P alongside arrow keys in the real component, including search-input focus, submenus, and closed state. A key-mapping unit test alone does not verify event propagation or focus behavior. + ## Icon Contract ```tsx @@ -82,6 +90,7 @@ For any other technique, load `performance-engineering` and `scripts/perf/DOCUME - Animations are limited to `transform` and `opacity`, or their cost was measured and accepted. - No hardcoded/palette colors were introduced. - Buttons use shared variants and sizes. +- Menus and pickers satisfy the keyboard navigation contract without caller-specific key handling for standard shared components. - Icons use `Icon`/`IconName`, and generated sprite changes are intentional. - Hover, selection, primary, and status semantics are distinct. - Light/dark/high-contrast and long-text states remain legible. diff --git a/.github/pr-evidence/ui-scale-100.png b/.github/pr-evidence/ui-scale-100.png new file mode 100644 index 00000000..ec326216 Binary files /dev/null and b/.github/pr-evidence/ui-scale-100.png differ diff --git a/.github/pr-evidence/ui-scale-80.png b/.github/pr-evidence/ui-scale-80.png new file mode 100644 index 00000000..b8cb602a Binary files /dev/null and b/.github/pr-evidence/ui-scale-80.png differ diff --git a/.gitignore b/.gitignore index f98f6cf0..e2ecdf5b 100644 --- a/.gitignore +++ b/.gitignore @@ -70,7 +70,7 @@ workspaces/ .worktrees/ # OpenChamber app runtime state (sessions db, screenshots) -.openchamber/ +.openchamber/screenshots/ # Marks a disposable clone dedicated to unattended maintenance tasks. .maintenance-clone diff --git a/.openchamber/project.json b/.openchamber/project.json new file mode 100644 index 00000000..855a6b8f --- /dev/null +++ b/.openchamber/project.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "setupWorktree": [ + "bun install" + ], + "projectActions": [ + { + "id": "5525a5ee-9c03-44d1-a401-1e4b9397765c", + "name": "DevHMR", + "command": "bun run dev:web:hmr", + "icon": "rocket" + } + ], + "plansDir": ".openchamber/plans" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a98f341..1a8262ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,82 @@ +## [1.23.0] - 2026-09-09 + +### New + +- **Git:** Stage, unstage, or discard individual blocks of changes with controls beside each block in the web and desktop Changes view (thanks to @LABCAT). +- **Turn stats:** The work status panel now shows response speed, model and tool time, tokens, and reported cost after a turn finishes. It's enabled by default (thanks to @alvins82). +- **Projects:** Move project actions, worktree setup commands, and draft starters into the repository for teammates to use. Repository commands ask for trust before running, and ask again when they change. +- Plans: Move plans into the repository, or point the Plans tab at an existing folder of Markdown files in your project. +- Git: Choose a recent commit to review in Changes or Walkthrough, with the same selection shared between both panels. +- Mobile: Compare branches and review individual commits from the Changes panel (thanks to @gaojunran). +- Mobile: Manage snippets, agents, commands, plugins, and skills from Settings on your phone. +- Mobile: Start a session in a project's root folder with the + beside its name in the sessions drawer. +- Sessions: Search projects by name or path in the new-session project picker on web and desktop (thanks to @maximtop). +- Sessions: Paste a full session ID into sidebar or archive search to find an exact match on web and desktop (thanks to @yulia-ivashko). +- Chat: Open and close collapsible Markdown sections in replies, including while the answer is still arriving. +- Usage: ClinePass shows five-hour, weekly, and monthly limits in Usage settings, with an option to show them in work status (thanks to @NemeZZiZZ). +- Usage: Charm Hyper now shows your remaining Hypercredits and their dollar value (thanks to @airtaxi). +- Settings: "Always show scrollbars" keeps scrollbars visible on this device when you move the pointer away. + +### Improvements + +- Chat: Completed live Activity can collapse into a summary of tools used and files changed, keeping the final answer visible. It follows your Activity Default setting. +- Chat: `/btw` now opens a separate composer with its own draft, model, and effort. Select message text and choose "By the way…" to ask about it, or use `/btw ` to send immediately (thanks to @ChangeHow). +- Files: Returning to a file restores your place in its code or Markdown preview, including the cursor position in the editor. +- Settings: Theme, fonts, and chat layout can differ between web, desktop, mobile, and VS Code. Panel sizes and other device choices stay on the device. +- Desktop: Zoom controls act on the focused browser, terminal, or file editor, and adjust interface scale when you're in chat or Mini Chat (thanks to @khafaji-ahmed). +- Mobile: Back steps through Settings from an item to its list, then to the settings menu. +- Mobile: Choose project sorting from the sessions drawer header. The drawer follows the same project order as desktop. +- Mobile: Close either drawer with the reverse edge swipe. Swipe session, project, and worktree rows right to reveal their actions. +- Comments: Enter attaches a code comment on desktop; Shift+Enter adds a newline. +- Terminal: Text renders consistently across tabs, borders and block graphics join cleanly, and touch users get a copy button beside the tabs. +- Chat: Ctrl+N/P navigation works across model lists, menus, and autocomplete. Reopening the model picker brings the selected model into view (thanks to @ChangeHow). +- Settings/Chat: Send-shortcut choices and large-text paste behavior have clearer descriptions (thanks to @ChangeHow). +- Chat: Tighter text and Activity spacing, stronger headings, and a softer divider make final answers easier to read. Message action buttons are smaller, with touch actions grouped in a menu. +- Chat: Selected text uses the same visible highlight in messages, file previews, and comments across themes. + +### Fixes + +- Chat: Queued messages already sent by the server disappear from the queue after reconnecting (thanks to @IbrahimKhan12). +- Sessions: Creating a session or opening a worktree session no longer shows a false history-loading error. +- Remote access: Large streamed replies no longer hold up other requests on slow tunnel connections, and broken connections stop leaving new requests hanging. +- Chat: Forking a user message restores its text and attachments in the new composer's draft and preserves the source draft (thanks to @karimodm). +- Chat: Interrupted tools stop showing an endless running timer after a reload (thanks to @alvins82). +- Chat: Attached images no longer appear twice just after sending. +- Chat: Opening panels or resizing the window keeps you at the end when following the latest reply. Sending or collapsing Activity no longer leaves a large blank area below it. +- Chat: Message details fit narrow columns without leaving gaps, keeping the model name readable as less important details disappear. +- Chat: Streaming Thinking stays inside its scroll box. Scrolling or dragging upward pauses its automatic scrolling so you can read earlier reasoning (thanks to @alvins82). +- Chat: Enter adds a newline in the expanded composer; Ctrl/Cmd+Enter sends. Keyboard selection of a project or worktree returns focus to the input (thanks to @ChangeHow). +- Chat: Narrow Markdown tables fit their columns, removing the empty bordered space on the right (thanks to @ChangeHow). +- Sessions: Opening or restoring a session whose worktree was deleted leaves moving it to another directory up to you. +- Mobile: The uncommitted-changes warning no longer flashes over the chat when starting a session. +- Mobile/Android: Settings, drawers, and chat controls stay clear of the system navigation bar. +- Terminal: Switching projects or tabs keeps each terminal's output separate. Reopening or resizing the panel no longer leaves stray prompt fragments. +- Terminal: Exiting Node-based commands on macOS and Linux no longer prints an empty IPC-channel warning. +- Updates: Updating a desktop host from the browser uses its native updater, confirms the installed version, and reports restart failures with a retry option (thanks to @ChangeHow). +- Git: Switching to a token-based identity no longer fails with a credential-helper permission error (thanks to @ICEY16360). +- Git: Branch comparisons include local edits and follow the selected base branch when you switch comparisons. +- Git: New-file diffs and walkthroughs still load when Git prints line-ending warnings (thanks to @jakoss). +- Usage: A failed refresh keeps the last known usage visible and shows the error without clearing other providers. +- Usage: OpenCode Go shows the correct reset countdowns for its usage limits. +- Usage: OpenRouter shows per-key spending and limits, or monthly spending for unlimited keys, fixing misleading zero balances (thanks to @leducmaxime). +- Usage: Ollama Cloud's dollar-based plans show monthly spending and extra credits, fixing missing usage and rejected credentials (thanks to @kydorn). +- Usage: NeuralWatt allowance rows show usage percentages and respond to the used/remaining toggle (thanks to @kydorn). +- Usage: Slow connections to providers such as z.ai no longer fail because the connection attempt ends too early (thanks to @ouyangjian28). +- Model tools: Summaries, titles, and walkthroughs use the selected model's connection details, fixing failures with providers whose models use different addresses (thanks to @mcowger). +- Desktop: Reachable instances no longer appear offline just because their connection check takes longer to respond (thanks to @jibanez-staticduo). +- Layout: Interface scaling keeps panels and controls usable, with room for macOS window buttons at smaller scales (thanks to @khafaji-ahmed). +- Sidebar: Closing and reopening the sidebar preserves the width you chose. +- Desktop/Linux: "Open in" no longer lists unrelated editors or launches the wrong app when an installed app has a non-Latin name (thanks to @ouyangjian28). +- Scrollbars: Hovering over a scrollable area reveals its scrollbar, including in Settings and dialogs, without shifting the content (thanks to @sergiofspedro). +- Language/Turkish: Agent and prompt labels use consistent terminology in Activity, turn stats, and input-history settings (thanks to @fitzgpt). + +### Misc + +- Server: `OPENCHAMBER_DATA_DIR` also covers project settings, themes, speech models, and new managed chats. Existing managed chats stay in their current location. + ## [1.22.2] - 2026-09-05 ### New diff --git a/README.md b/README.md index 8452e7ce..59bbe1d6 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ Special thanks to: - [OpenCode](https://opencode.ai) for the API and open-source architecture OpenChamber builds on - [Pierre](https://pierrejs-docs.vercel.app/) for the diff viewer and syntax highlighting -- [Ghostty-web](https://github.com/coder/ghostty-web) for its Ghostty web renderer +- The [T3 Code](https://github.com/pingdotgg/t3code) team for their browser adapter for [libghostty-vt](https://github.com/ghostty-org/ghostty), which our terminal is built on - [Yulia Ivashko](https://github.com/yulia-ivashko), who built the firework celebration that plays on every successful push - Everyone who contributed code, reported bugs, or shared ideas diff --git a/bun-patches/@legendapp%2Flist@3.3.10.patch b/bun-patches/@legendapp%2Flist@3.3.10.patch new file mode 100644 index 00000000..7d571a24 --- /dev/null +++ b/bun-patches/@legendapp%2Flist@3.3.10.patch @@ -0,0 +1,68 @@ +diff --git a/react-native.web.js b/react-native.web.js +index 5b171ebd6fa7ac86b0148fe17f897a0ca821d55e..954235cd2b970e0dbb6b78f650633e4f07242558 100644 +--- a/react-native.web.js ++++ b/react-native.web.js +@@ -6560,8 +6560,11 @@ function ScrollAdjust() { + window.getComputedStyle(contentNode)[axis.paddingEndProp] + ); + const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`; +- temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: temporaryPaddingEnd }; + contentNode.style[axis.paddingEndProp] = temporaryPaddingEnd; ++ // CSSOM rounds fractional pixels (2123.1875px becomes 2123.19px). ++ // Track the serialized value so ownership checks and cleanup ++ // match what the browser actually stored, not the input string. ++ temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: contentNode.style[axis.paddingEndProp] }; + void contentNode.offsetHeight; + scrollBy(); + if (resetPaddingRafRef.current !== void 0) { +diff --git a/react-native.web.mjs b/react-native.web.mjs +index cf549ca32946a9e35a883290c11423330bf8728b..d6508ee3b88e2f79c5ee9b994f6f93b6654e708c 100644 +--- a/react-native.web.mjs ++++ b/react-native.web.mjs +@@ -6539,8 +6539,11 @@ function ScrollAdjust() { + window.getComputedStyle(contentNode)[axis.paddingEndProp] + ); + const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`; +- temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: temporaryPaddingEnd }; + contentNode.style[axis.paddingEndProp] = temporaryPaddingEnd; ++ // CSSOM rounds fractional pixels (2123.1875px becomes 2123.19px). ++ // Track the serialized value so ownership checks and cleanup ++ // match what the browser actually stored, not the input string. ++ temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: contentNode.style[axis.paddingEndProp] }; + void contentNode.offsetHeight; + scrollBy(); + if (resetPaddingRafRef.current !== void 0) { +diff --git a/react.js b/react.js +index 5b171ebd6fa7ac86b0148fe17f897a0ca821d55e..954235cd2b970e0dbb6b78f650633e4f07242558 100644 +--- a/react.js ++++ b/react.js +@@ -6560,8 +6560,11 @@ function ScrollAdjust() { + window.getComputedStyle(contentNode)[axis.paddingEndProp] + ); + const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`; +- temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: temporaryPaddingEnd }; + contentNode.style[axis.paddingEndProp] = temporaryPaddingEnd; ++ // CSSOM rounds fractional pixels (2123.1875px becomes 2123.19px). ++ // Track the serialized value so ownership checks and cleanup ++ // match what the browser actually stored, not the input string. ++ temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: contentNode.style[axis.paddingEndProp] }; + void contentNode.offsetHeight; + scrollBy(); + if (resetPaddingRafRef.current !== void 0) { +diff --git a/react.mjs b/react.mjs +index cf549ca32946a9e35a883290c11423330bf8728b..d6508ee3b88e2f79c5ee9b994f6f93b6654e708c 100644 +--- a/react.mjs ++++ b/react.mjs +@@ -6539,8 +6539,11 @@ function ScrollAdjust() { + window.getComputedStyle(contentNode)[axis.paddingEndProp] + ); + const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`; +- temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: temporaryPaddingEnd }; + contentNode.style[axis.paddingEndProp] = temporaryPaddingEnd; ++ // CSSOM rounds fractional pixels (2123.1875px becomes 2123.19px). ++ // Track the serialized value so ownership checks and cleanup ++ // match what the browser actually stored, not the input string. ++ temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: contentNode.style[axis.paddingEndProp] }; + void contentNode.offsetHeight; + scrollBy(); + if (resetPaddingRafRef.current !== void 0) { diff --git a/bun.lock b/bun.lock index c5468b47..5f6b8cba 100644 --- a/bun.lock +++ b/bun.lock @@ -30,7 +30,7 @@ "@heroui/theme": "^2.4.23", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "1.18.29", + "@opencode-ai/sdk": "1.18.30", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -47,7 +47,6 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "express": "^5.1.0", - "ghostty-web": "0.4.0", "http-proxy-middleware": "^3.0.5", "next-themes": "^0.4.6", "node-pty": "1.2.0-beta.12", @@ -97,12 +96,13 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.22.1", + "version": "1.22.2", "dependencies": { "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", "electron-log": "^5.4.3", "electron-updater": "^6.8.3", + "zod": "^4.3.6", }, "devDependencies": { "@electron/rebuild": "^4.2.0", @@ -134,7 +134,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.22.1", + "version": "1.22.2", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", @@ -167,9 +167,9 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@legendapp/list": "3.3.8", + "@legendapp/list": "3.3.10", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "1.18.29", + "@opencode-ai/sdk": "1.18.30", "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.4.0", "@simplewebauthn/browser": "13.3.0", @@ -185,7 +185,6 @@ "express": "^5.1.0", "fflate": "^0.8.3", "fuse.js": "^7.1.0", - "ghostty-web": "^0.4.0", "heic2any": "^0.0.4", "html-to-image": "^1.11.13", "http-proxy-middleware": "^3.0.5", @@ -241,10 +240,10 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.22.1", + "version": "1.22.2", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "1.18.29", + "@opencode-ai/sdk": "1.18.30", "adm-zip": "^0.6.0", "jsonc-parser": "^3.3.1", "react": "^19.1.1", @@ -264,14 +263,14 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.22.1", + "version": "1.22.2", "bin": { "openchamber": "./bin/cli.js", }, "dependencies": { "@clack/prompts": "^1.1.0", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "1.18.29", + "@opencode-ai/sdk": "1.18.30", "@simplewebauthn/server": "13.3.1", "bun-pty": "^0.4.5", "compression": "^1.8.1", @@ -323,7 +322,6 @@ "eslint": "^9.33.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.5.0", - "ghostty-web": "0.4.0", "globals": "^16.3.0", "next-themes": "^0.4.6", "nodemon": "^3.1.7", @@ -353,6 +351,7 @@ ], "patchedDependencies": { "@tanstack/virtual-core@3.17.3": "bun-patches/@tanstack+virtual-core+3.17.3.patch", + "@legendapp/list@3.3.10": "bun-patches/@legendapp%2Flist@3.3.10.patch", "bun-pty@0.4.8": "bun-patches/bun-pty@0.4.8.patch", }, "overrides": { @@ -920,7 +919,7 @@ "@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="], - "@legendapp/list": ["@legendapp/list@3.3.8", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom", "react-native"] }, "sha512-GM4Hca/6WDvcY33XXCieR9MaG9CoZmACzwqQwRhKFSaNKfQV1lTLiTOpWuA9wnE8n8+6WeA52DwNKC9yrnPPeg=="], + "@legendapp/list": ["@legendapp/list@3.3.10", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom", "react-native"] }, "sha512-S8cwV11oJJD2m47JoRnCmlgbUrnY/f48TPL1zl1C3wwyrvru53Zf1NtYFlMtAU6lUKh/cU9RIffLI7unbf3kmg=="], "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], @@ -1008,7 +1007,7 @@ "@openchamber/web": ["@openchamber/web@workspace:packages/web"], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.29", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-4CS+FoLPkymTlcga8jxivGDDb2AbWMIIl3b8+myoe2wtv/1ANYCErslgz1xy5hTVHymWE6CtVNKzRuPU0ED57A=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.30", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-uviJ+PZLc/D1szt33WGzzt/rqJ+IaNmRGBb+UcOXrcQANl8XXKJrp4CNQQ2UAnUJ3Ek+VqcamIwjtKHUW/OjOQ=="], "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.78.0", "", { "os": "android", "cpu": "arm" }, "sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw=="], @@ -2122,8 +2121,6 @@ "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], - "ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="], - "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], "glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], diff --git a/changelog/1.23.0.md b/changelog/1.23.0.md new file mode 100644 index 00000000..3d7c69b7 --- /dev/null +++ b/changelog/1.23.0.md @@ -0,0 +1,121 @@ +--- +version: 1.23.0 +date: 2026-09-09 +title: Finer control over code changes +--- + +## App + +### New + +- **Git:** Stage, unstage, or discard individual blocks of changes with controls beside each block in the web and desktop Changes view (thanks to @LABCAT). +- **Turn stats:** The work status panel now shows response speed, model and tool time, tokens, and reported cost after a turn finishes. It's enabled by default (thanks to @alvins82). +- **Projects:** Move project actions, worktree setup commands, and draft starters into the repository for teammates to use. Repository commands ask for trust before running, and ask again when they change. +- Plans: Move plans into the repository, or point the Plans tab at an existing folder of Markdown files in your project. +- Git: Choose a recent commit to review in Changes or Walkthrough, with the same selection shared between both panels. +- Mobile: Compare branches and review individual commits from the Changes panel (thanks to @gaojunran). +- Mobile: Manage snippets, agents, commands, plugins, and skills from Settings on your phone. +- Mobile: Start a session in a project's root folder with the + beside its name in the sessions drawer. +- Sessions: Search projects by name or path in the new-session project picker on web and desktop (thanks to @maximtop). +- Sessions: Paste a full session ID into sidebar or archive search to find an exact match on web and desktop (thanks to @yulia-ivashko). +- Chat: Open and close collapsible Markdown sections in replies, including while the answer is still arriving. +- Usage: ClinePass shows five-hour, weekly, and monthly limits in Usage settings, with an option to show them in work status (thanks to @NemeZZiZZ). +- Usage: Charm Hyper now shows your remaining Hypercredits and their dollar value (thanks to @airtaxi). +- Settings: "Always show scrollbars" keeps scrollbars visible on this device when you move the pointer away. + +### Improvements + +- Chat: Completed live Activity can collapse into a summary of tools used and files changed, keeping the final answer visible. It follows your Activity Default setting. +- Chat: `/btw` now opens a separate composer with its own draft, model, and effort. Select message text and choose "By the way…" to ask about it, or use `/btw ` to send immediately (thanks to @ChangeHow). +- Files: Returning to a file restores your place in its code or Markdown preview, including the cursor position in the editor. +- Settings: Theme, fonts, and chat layout can differ between web, desktop, mobile, and VS Code. Panel sizes and other device choices stay on the device. +- Desktop: Zoom controls act on the focused browser, terminal, or file editor, and adjust interface scale when you're in chat or Mini Chat (thanks to @khafaji-ahmed). +- Mobile: Back steps through Settings from an item to its list, then to the settings menu. +- Mobile: Choose project sorting from the sessions drawer header. The drawer follows the same project order as desktop. +- Mobile: Close either drawer with the reverse edge swipe. Swipe session, project, and worktree rows right to reveal their actions. +- Comments: Enter attaches a code comment on desktop; Shift+Enter adds a newline. +- Terminal: Text renders consistently across tabs, borders and block graphics join cleanly, and touch users get a copy button beside the tabs. +- Chat: Ctrl+N/P navigation works across model lists, menus, and autocomplete. Reopening the model picker brings the selected model into view (thanks to @ChangeHow). +- Settings/Chat: Send-shortcut choices and large-text paste behavior have clearer descriptions (thanks to @ChangeHow). +- Chat: Tighter text and Activity spacing, stronger headings, and a softer divider make final answers easier to read. Message action buttons are smaller, with touch actions grouped in a menu. +- Chat: Selected text uses the same visible highlight in messages, file previews, and comments across themes. + +### Fixes + +- Chat: Queued messages already sent by the server disappear from the queue after reconnecting (thanks to @IbrahimKhan12). +- Sessions: Creating a session or opening a worktree session no longer shows a false history-loading error. +- Remote access: Large streamed replies no longer hold up other requests on slow tunnel connections, and broken connections stop leaving new requests hanging. +- Chat: Forking a user message restores its text and attachments in the new composer's draft and preserves the source draft (thanks to @karimodm). +- Chat: Interrupted tools stop showing an endless running timer after a reload (thanks to @alvins82). +- Chat: Attached images no longer appear twice just after sending. +- Chat: Opening panels or resizing the window keeps you at the end when following the latest reply. Sending or collapsing Activity no longer leaves a large blank area below it. +- Chat: Message details fit narrow columns without leaving gaps, keeping the model name readable as less important details disappear. +- Chat: Streaming Thinking stays inside its scroll box. Scrolling or dragging upward pauses its automatic scrolling so you can read earlier reasoning (thanks to @alvins82). +- Chat: Enter adds a newline in the expanded composer; Ctrl/Cmd+Enter sends. Keyboard selection of a project or worktree returns focus to the input (thanks to @ChangeHow). +- Chat: Narrow Markdown tables fit their columns, removing the empty bordered space on the right (thanks to @ChangeHow). +- Sessions: Opening or restoring a session whose worktree was deleted leaves moving it to another directory up to you. +- Mobile: The uncommitted-changes warning no longer flashes over the chat when starting a session. +- Mobile/Android: Settings, drawers, and chat controls stay clear of the system navigation bar. +- Terminal: Switching projects or tabs keeps each terminal's output separate. Reopening or resizing the panel no longer leaves stray prompt fragments. +- Terminal: Exiting Node-based commands on macOS and Linux no longer prints an empty IPC-channel warning. +- Updates: Updating a desktop host from the browser uses its native updater, confirms the installed version, and reports restart failures with a retry option (thanks to @ChangeHow). +- Git: Switching to a token-based identity no longer fails with a credential-helper permission error (thanks to @ICEY16360). +- Git: Branch comparisons include local edits and follow the selected base branch when you switch comparisons. +- Git: New-file diffs and walkthroughs still load when Git prints line-ending warnings (thanks to @jakoss). +- Usage: A failed refresh keeps the last known usage visible and shows the error without clearing other providers. +- Usage: OpenCode Go shows the correct reset countdowns for its usage limits. +- Usage: OpenRouter shows per-key spending and limits, or monthly spending for unlimited keys, fixing misleading zero balances (thanks to @leducmaxime). +- Usage: Ollama Cloud's dollar-based plans show monthly spending and extra credits, fixing missing usage and rejected credentials (thanks to @kydorn). +- Usage: NeuralWatt allowance rows show usage percentages and respond to the used/remaining toggle (thanks to @kydorn). +- Usage: Slow connections to providers such as z.ai no longer fail because the connection attempt ends too early (thanks to @ouyangjian28). +- Model tools: Summaries, titles, and walkthroughs use the selected model's connection details, fixing failures with providers whose models use different addresses (thanks to @mcowger). +- Desktop: Reachable instances no longer appear offline just because their connection check takes longer to respond (thanks to @jibanez-staticduo). +- Layout: Interface scaling keeps panels and controls usable, with room for macOS window buttons at smaller scales (thanks to @khafaji-ahmed). +- Sidebar: Closing and reopening the sidebar preserves the width you chose. +- Desktop/Linux: "Open in" no longer lists unrelated editors or launches the wrong app when an installed app has a non-Latin name (thanks to @ouyangjian28). +- Scrollbars: Hovering over a scrollable area reveals its scrollbar, including in Settings and dialogs, without shifting the content (thanks to @sergiofspedro). +- Language/Turkish: Agent and prompt labels use consistent terminology in Activity, turn stats, and input-history settings (thanks to @fitzgpt). + +### Misc + +- Server: `OPENCHAMBER_DATA_DIR` also covers project settings, themes, speech models, and new managed chats. Existing managed chats stay in their current location. + +## VS Code + +### New + +- Chat: Replies can contain collapsible Markdown sections that stay open as the answer streams. +- Projects: Store worktree setup commands and draft starters in the repository from Project settings. Repository commands require trust before running and after changes. +- Usage: ClinePass now shows five-hour, weekly, and monthly usage limits (thanks to @NemeZZiZZ). +- Usage: Charm Hyper shows your remaining Hypercredits and their dollar value (thanks to @airtaxi). +- Settings: "Always show scrollbars" keeps scrollbars visible when the pointer leaves a scrollable area. + +### Improvements + +- **Chat:** `/btw` now has a separate composer with its own draft, model, and effort. The "By the way…" text-selection action prefills a question with the selected passage (thanks to @ChangeHow). +- Chat: Completed live Activity can collapse into a tool and file-change summary while the final answer stays visible, following your Activity Default setting. +- Settings: VS Code keeps its own appearance and chat layout preferences, separate from web, desktop, and mobile. +- Settings: In narrow panels, Back returns from an item to its list before returning to the settings menu. +- Chat: Ctrl+N/P navigation works across model lists, menus, and autocomplete. The model picker reopens with your selected model in view (thanks to @ChangeHow). +- Settings/Chat: Send-shortcut and large-text paste options have clearer descriptions (thanks to @ChangeHow). +- Chat: More compact Markdown, smaller action buttons, and a softer final-answer divider make replies easier to scan. +- Chat: Text selection and comment highlights use a consistent, readable accent tint across themes. + +### Fixes + +- Sessions: New sessions and worktree sessions open without false history-loading errors. +- Settings: A failed screen load no longer triggers a broken reload of the chat. +- Chat: Forking a user message fills the destination composer with its prompt and attachments while keeping the original session's draft intact (thanks to @karimodm). +- Chat: Tools interrupted before a reload no longer keep a running timer indefinitely (thanks to @alvins82). +- Chat: Images attached to a sent message appear only once. +- Chat: Resizing the chat keeps the latest reply in view when following the end. Sending or collapsing Activity no longer creates a large blank space below it. +- Chat: Message details adapt to narrow panels without leaving gaps between the model, effort, and duration. +- Chat: Long Thinking output stays in a capped scroll box while streaming; scrolling upward pauses its automatic scrolling (thanks to @alvins82). +- Chat: Narrow tables keep their border and toolbar close to the columns (thanks to @ChangeHow). +- Usage: Failed refreshes keep the last known figures visible with an error, while other providers continue to load. +- Usage: OpenRouter reports key spending and limits accurately, including monthly spending for unlimited keys (thanks to @leducmaxime). +- Usage: Ollama Cloud dollar-based plans show monthly spending and extra credits; credential checks reject unreadable usage pages (thanks to @kydorn). +- Usage: NeuralWatt shows allowance percentages correctly in both used and remaining modes (thanks to @kydorn). +- Usage: Provider requests have enough time to connect on slower networks, fixing premature "fetch failed" errors (thanks to @ouyangjian28). +- Scrollbars: Hover reveals scrollbars in chat, Settings, and shared dialogs without moving the content sideways (thanks to @sergiofspedro). +- Language/Turkish: Activity and input-history settings use consistent agent and prompt terminology (thanks to @fitzgpt). diff --git a/changelog/index.json b/changelog/index.json index 48e1f10f..2626ee28 100644 --- a/changelog/index.json +++ b/changelog/index.json @@ -1,4 +1,120 @@ [ + { + "version": "1.23.0", + "date": "2026-09-09", + "title": "Finer control over code changes", + "intro": null, + "app": { + "new": [ + "**Git:** Stage, unstage, or discard individual blocks of changes with controls beside each block in the web and desktop Changes view (thanks to @LABCAT).", + "**Turn stats:** The work status panel now shows response speed, model and tool time, tokens, and reported cost after a turn finishes. It's enabled by default (thanks to @alvins82).", + "**Projects:** Move project actions, worktree setup commands, and draft starters into the repository for teammates to use. Repository commands ask for trust before running, and ask again when they change.", + "Plans: Move plans into the repository, or point the Plans tab at an existing folder of Markdown files in your project.", + "Git: Choose a recent commit to review in Changes or Walkthrough, with the same selection shared between both panels.", + "Mobile: Compare branches and review individual commits from the Changes panel (thanks to @gaojunran).", + "Mobile: Manage snippets, agents, commands, plugins, and skills from Settings on your phone.", + "Mobile: Start a session in a project's root folder with the + beside its name in the sessions drawer.", + "Sessions: Search projects by name or path in the new-session project picker on web and desktop (thanks to @maximtop).", + "Sessions: Paste a full session ID into sidebar or archive search to find an exact match on web and desktop (thanks to @yulia-ivashko).", + "Chat: Open and close collapsible Markdown sections in replies, including while the answer is still arriving.", + "Usage: ClinePass shows five-hour, weekly, and monthly limits in Usage settings, with an option to show them in work status (thanks to @NemeZZiZZ).", + "Usage: Charm Hyper now shows your remaining Hypercredits and their dollar value (thanks to @airtaxi).", + "Settings: \"Always show scrollbars\" keeps scrollbars visible on this device when you move the pointer away." + ], + "improvements": [ + "Chat: Completed live Activity can collapse into a summary of tools used and files changed, keeping the final answer visible. It follows your Activity Default setting.", + "Chat: `/btw` now opens a separate composer with its own draft, model, and effort. Select message text and choose \"By the way…\" to ask about it, or use `/btw ` to send immediately (thanks to @ChangeHow).", + "Files: Returning to a file restores your place in its code or Markdown preview, including the cursor position in the editor.", + "Settings: Theme, fonts, and chat layout can differ between web, desktop, mobile, and VS Code. Panel sizes and other device choices stay on the device.", + "Desktop: Zoom controls act on the focused browser, terminal, or file editor, and adjust interface scale when you're in chat or Mini Chat (thanks to @khafaji-ahmed).", + "Mobile: Back steps through Settings from an item to its list, then to the settings menu.", + "Mobile: Choose project sorting from the sessions drawer header. The drawer follows the same project order as desktop.", + "Mobile: Close either drawer with the reverse edge swipe. Swipe session, project, and worktree rows right to reveal their actions.", + "Comments: Enter attaches a code comment on desktop; Shift+Enter adds a newline.", + "Terminal: Text renders consistently across tabs, borders and block graphics join cleanly, and touch users get a copy button beside the tabs.", + "Chat: Ctrl+N/P navigation works across model lists, menus, and autocomplete. Reopening the model picker brings the selected model into view (thanks to @ChangeHow).", + "Settings/Chat: Send-shortcut choices and large-text paste behavior have clearer descriptions (thanks to @ChangeHow).", + "Chat: Tighter text and Activity spacing, stronger headings, and a softer divider make final answers easier to read. Message action buttons are smaller, with touch actions grouped in a menu.", + "Chat: Selected text uses the same visible highlight in messages, file previews, and comments across themes." + ], + "fixes": [ + "Chat: Queued messages already sent by the server disappear from the queue after reconnecting (thanks to @IbrahimKhan12).", + "Sessions: Creating a session or opening a worktree session no longer shows a false history-loading error.", + "Remote access: Large streamed replies no longer hold up other requests on slow tunnel connections, and broken connections stop leaving new requests hanging.", + "Chat: Forking a user message restores its text and attachments in the new composer's draft and preserves the source draft (thanks to @karimodm).", + "Chat: Interrupted tools stop showing an endless running timer after a reload (thanks to @alvins82).", + "Chat: Attached images no longer appear twice just after sending.", + "Chat: Opening panels or resizing the window keeps you at the end when following the latest reply. Sending or collapsing Activity no longer leaves a large blank area below it.", + "Chat: Message details fit narrow columns without leaving gaps, keeping the model name readable as less important details disappear.", + "Chat: Streaming Thinking stays inside its scroll box. Scrolling or dragging upward pauses its automatic scrolling so you can read earlier reasoning (thanks to @alvins82).", + "Chat: Enter adds a newline in the expanded composer; Ctrl/Cmd+Enter sends. Keyboard selection of a project or worktree returns focus to the input (thanks to @ChangeHow).", + "Chat: Narrow Markdown tables fit their columns, removing the empty bordered space on the right (thanks to @ChangeHow).", + "Sessions: Opening or restoring a session whose worktree was deleted leaves moving it to another directory up to you.", + "Mobile: The uncommitted-changes warning no longer flashes over the chat when starting a session.", + "Mobile/Android: Settings, drawers, and chat controls stay clear of the system navigation bar.", + "Terminal: Switching projects or tabs keeps each terminal's output separate. Reopening or resizing the panel no longer leaves stray prompt fragments.", + "Terminal: Exiting Node-based commands on macOS and Linux no longer prints an empty IPC-channel warning.", + "Updates: Updating a desktop host from the browser uses its native updater, confirms the installed version, and reports restart failures with a retry option (thanks to @ChangeHow).", + "Git: Switching to a token-based identity no longer fails with a credential-helper permission error (thanks to @ICEY16360).", + "Git: Branch comparisons include local edits and follow the selected base branch when you switch comparisons.", + "Git: New-file diffs and walkthroughs still load when Git prints line-ending warnings (thanks to @jakoss).", + "Usage: A failed refresh keeps the last known usage visible and shows the error without clearing other providers.", + "Usage: OpenCode Go shows the correct reset countdowns for its usage limits.", + "Usage: OpenRouter shows per-key spending and limits, or monthly spending for unlimited keys, fixing misleading zero balances (thanks to @leducmaxime).", + "Usage: Ollama Cloud's dollar-based plans show monthly spending and extra credits, fixing missing usage and rejected credentials (thanks to @kydorn).", + "Usage: NeuralWatt allowance rows show usage percentages and respond to the used/remaining toggle (thanks to @kydorn).", + "Usage: Slow connections to providers such as z.ai no longer fail because the connection attempt ends too early (thanks to @ouyangjian28).", + "Model tools: Summaries, titles, and walkthroughs use the selected model's connection details, fixing failures with providers whose models use different addresses (thanks to @mcowger).", + "Desktop: Reachable instances no longer appear offline just because their connection check takes longer to respond (thanks to @jibanez-staticduo).", + "Layout: Interface scaling keeps panels and controls usable, with room for macOS window buttons at smaller scales (thanks to @khafaji-ahmed).", + "Sidebar: Closing and reopening the sidebar preserves the width you chose.", + "Desktop/Linux: \"Open in\" no longer lists unrelated editors or launches the wrong app when an installed app has a non-Latin name (thanks to @ouyangjian28).", + "Scrollbars: Hovering over a scrollable area reveals its scrollbar, including in Settings and dialogs, without shifting the content (thanks to @sergiofspedro).", + "Language/Turkish: Agent and prompt labels use consistent terminology in Activity, turn stats, and input-history settings (thanks to @fitzgpt)." + ], + "misc": [ + "Server: `OPENCHAMBER_DATA_DIR` also covers project settings, themes, speech models, and new managed chats. Existing managed chats stay in their current location." + ] + }, + "vscode": { + "new": [ + "Chat: Replies can contain collapsible Markdown sections that stay open as the answer streams.", + "Projects: Store worktree setup commands and draft starters in the repository from Project settings. Repository commands require trust before running and after changes.", + "Usage: ClinePass now shows five-hour, weekly, and monthly usage limits (thanks to @NemeZZiZZ).", + "Usage: Charm Hyper shows your remaining Hypercredits and their dollar value (thanks to @airtaxi).", + "Settings: \"Always show scrollbars\" keeps scrollbars visible when the pointer leaves a scrollable area." + ], + "improvements": [ + "**Chat:** `/btw` now has a separate composer with its own draft, model, and effort. The \"By the way…\" text-selection action prefills a question with the selected passage (thanks to @ChangeHow).", + "Chat: Completed live Activity can collapse into a tool and file-change summary while the final answer stays visible, following your Activity Default setting.", + "Settings: VS Code keeps its own appearance and chat layout preferences, separate from web, desktop, and mobile.", + "Settings: In narrow panels, Back returns from an item to its list before returning to the settings menu.", + "Chat: Ctrl+N/P navigation works across model lists, menus, and autocomplete. The model picker reopens with your selected model in view (thanks to @ChangeHow).", + "Settings/Chat: Send-shortcut and large-text paste options have clearer descriptions (thanks to @ChangeHow).", + "Chat: More compact Markdown, smaller action buttons, and a softer final-answer divider make replies easier to scan.", + "Chat: Text selection and comment highlights use a consistent, readable accent tint across themes." + ], + "fixes": [ + "Sessions: New sessions and worktree sessions open without false history-loading errors.", + "Settings: A failed screen load no longer triggers a broken reload of the chat.", + "Chat: Forking a user message fills the destination composer with its prompt and attachments while keeping the original session's draft intact (thanks to @karimodm).", + "Chat: Tools interrupted before a reload no longer keep a running timer indefinitely (thanks to @alvins82).", + "Chat: Images attached to a sent message appear only once.", + "Chat: Resizing the chat keeps the latest reply in view when following the end. Sending or collapsing Activity no longer creates a large blank space below it.", + "Chat: Message details adapt to narrow panels without leaving gaps between the model, effort, and duration.", + "Chat: Long Thinking output stays in a capped scroll box while streaming; scrolling upward pauses its automatic scrolling (thanks to @alvins82).", + "Chat: Narrow tables keep their border and toolbar close to the columns (thanks to @ChangeHow).", + "Usage: Failed refreshes keep the last known figures visible with an error, while other providers continue to load.", + "Usage: OpenRouter reports key spending and limits accurately, including monthly spending for unlimited keys (thanks to @leducmaxime).", + "Usage: Ollama Cloud dollar-based plans show monthly spending and extra credits; credential checks reject unreadable usage pages (thanks to @kydorn).", + "Usage: NeuralWatt shows allowance percentages correctly in both used and remaining modes (thanks to @kydorn).", + "Usage: Provider requests have enough time to connect on slower networks, fixing premature \"fetch failed\" errors (thanks to @ouyangjian28).", + "Scrollbars: Hover reveals scrollbars in chat, Settings, and shared dialogs without moving the content sideways (thanks to @sergiofspedro).", + "Language/Turkish: Activity and input-history settings use consistent agent and prompt terminology (thanks to @fitzgpt)." + ], + "misc": [] + } + }, { "version": "1.22.2", "date": "2026-09-05", diff --git a/package.json b/package.json index dac923ce..d8dbdf0c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openchamber-monorepo", - "version": "1.22.2", + "version": "1.23.0", "description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes", "private": true, "type": "module", @@ -31,6 +31,7 @@ "type-check": "bun run --filter '*' type-check", "type-check:web": "bun run --cwd packages/web type-check", "type-check:ui": "bun run --cwd packages/ui type-check", + "settings-registry:generate": "bun run --cwd packages/ui src/lib/settings/registry-snapshot.ts", "type-check:electron": "bun run --cwd packages/electron type-check", "type-check:mobile": "bun run --cwd packages/mobile type-check", "lint": "bun run --filter '*' lint", @@ -119,7 +120,7 @@ "@heroui/theme": "^2.4.23", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "1.18.29", + "@opencode-ai/sdk": "1.18.30", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -136,7 +137,6 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "express": "^5.1.0", - "ghostty-web": "0.4.0", "http-proxy-middleware": "^3.0.5", "next-themes": "^0.4.6", "node-pty": "1.2.0-beta.12", @@ -199,6 +199,7 @@ }, "patchedDependencies": { "@tanstack/virtual-core@3.17.3": "bun-patches/@tanstack+virtual-core+3.17.3.patch", - "bun-pty@0.4.8": "bun-patches/bun-pty@0.4.8.patch" + "bun-pty@0.4.8": "bun-patches/bun-pty@0.4.8.patch", + "@legendapp/list@3.3.10": "bun-patches/@legendapp%2Flist@3.3.10.patch" } } diff --git a/packages/docs/content/docs/de/project-actions.mdx b/packages/docs/content/docs/de/project-actions.mdx index a0ef9b78..e274f0a6 100644 --- a/packages/docs/content/docs/de/project-actions.mdx +++ b/packages/docs/content/docs/de/project-actions.mdx @@ -25,4 +25,6 @@ Aktiviere **auto-open URL** für eine Aktion, die einen Server startet. OpenCham ## Verwandt +- [Repository-Konfiguration](/repository-config/) — Aktionen und Setup-Befehle im Repository für das ganze Team ablegen + - [Vorschau & Entwicklungsserver](/preview/) — einen laufenden Entwicklungsserver in OpenChamber öffnen diff --git a/packages/docs/content/docs/de/repository-config.mdx b/packages/docs/content/docs/de/repository-config.mdx new file mode 100644 index 00000000..58dea487 --- /dev/null +++ b/packages/docs/content/docs/de/repository-config.mdx @@ -0,0 +1,100 @@ +--- +title: Repository-Konfiguration +description: Projektaktionen, Worktree-Setup-Befehle, Starter und Pläne im Repository ablegen, damit alle sie bekommen, die es pullen. +--- + +# Repository-Konfiguration + +Projektaktionen, Worktree-Setup-Befehle und Entwurfs-Starter liegen standardmäßig in deinen eigenen OpenChamber-Einstellungen. Niemand sonst sieht sie. Wenn jemand im Team, der das Repository klont, dieselbe Dev-Server-Aktion und dasselbe `bun install` in jedem neuen Worktree bekommen soll, verschiebe diese Einträge ins Repository. + +OpenChamber legt sie in `.openchamber/project.json` im Wurzelverzeichnis des Repositorys ab. Die Datei entsteht erst, wenn du den ersten Eintrag dorthin verschiebst, und verschwindet wieder, wenn du den letzten herausnimmst. Committe sie wie jede andere Datei. + +## Was wohin gehört + +| Bleibt in deinen Einstellungen | Kann ins Repository | +|---|---| +| Notizen und Todos | Projektaktionen | +| Geplante Aufgaben | Worktree-Setup-Befehle | +| Welche Repository-Aktion du für dich ausgeblendet hast | Entwurfs-Starter (angeheftete Befehle und Skills) | +| Deine Vertrauensantwort für Repository-Befehle | Pläne | + +Notizen, Todos und geplante Aufgaben gehören dir. Sie landen nie im Repository. + +## Einen Eintrag verschieben + +Öffne **Settings → Projects** und wähle das Projekt. Jede Aktion und jeder Setup-Befehl hat einen Button **Move to repository**, und jeder Eintrag aus dem Repository hat **Move to my settings**. Starter auf dem Bildschirm für neue Sitzungen zeigen dasselbe Paar beim Überfahren. Pläne haben es in jeder Zeile des Tabs „Pläne“. + +Verschieben heißt verschieben. Der Eintrag verlässt den einen Ort und landet am anderen, nichts wird verdoppelt. + +Einträge aus dem Repository tragen das Abzeichen **In repo**. Repository-Aktionen lassen sich mit **Hide for me** aus deinem Menü ausblenden. Das ändert nur dein Menü, nicht die Datei. + +## Die Datei + +```json +{ + "version": 1, + "setupWorktree": [ + "bun install" + ], + "setupWorktreeWait": true, + "projectActions": [ + { + "id": "dev", + "name": "Dev server", + "command": "bun run dev", + "icon": "rocket", + "autoOpenUrl": true, + "platforms": ["macos", "linux"] + }, + { + "id": "test", + "name": "Tests", + "command": "bun test" + } + ], + "draftStarters": [ + { "type": "skill", "name": "triage-prs" } + ], + "plansDir": "docs/plans" +} +``` + +Nur `version` ist Pflicht. Alle anderen Schlüssel sind optional, und OpenChamber schreibt nur die, die etwas enthalten. + +`setupWorktree` ist die Liste der Shell-Befehle, die OpenChamber direkt nach dem Anlegen eines neuen Worktrees darin ausführt, der Reihe nach. Verwende `$ROOT_PROJECT_PATH` für den Pfad des Haupt-Checkouts. Mit `setupWorktreeWait: true` wartet OpenChamber auf diese Befehle, bevor es eine Sitzung im Worktree startet. + +`projectActions` ist die Liste der Aktionen im Kopfzeilenmenü. `id`, `name` und `command` sind Pflicht. `icon` ist optional und fällt auf ein Play-Symbol zurück; OpenChamber kennt die Namen `play`, `build`, `lint`, `terminal`, `tools`, `bug`, `flask`, `rocket`, `code`, `server`, `branch`, `search`, `settings`, `brain`, `stack`, `robot`, `command` und `file`. `autoOpenUrl: true` öffnet die Adresse, die der Befehl ausgibt, siehe [Vorschau und Entwicklungsserver](/preview/). `platforms` beschränkt die Aktion auf `macos`, `linux` oder `windows`. `runIn: "parent"` führt die Aktion im Haupt-Checkout statt im aktuellen Worktree aus. + +`draftStarters` heftet Befehle und Skills an den Bildschirm für neue Sitzungen. Jeder Eintrag hat die Form `{ "type": "command" | "skill", "name": "..." }`, und der Befehl oder Skill selbst muss in der OpenCode-Konfiguration des Repositorys existieren. + +`plansDir` ist der Ort der Repository-Pläne. Lass ihn weg, um `.openchamber/plans` zu verwenden. Siehe unten. + +Du kannst diese Datei von Hand schreiben. Ein Schlüssel mit falscher Form macht die ganze Datei ungültig, und die Seite Projects sagt dir warum, statt ihn stillschweigend zu ignorieren. + +## Wie sich Repository- und eigene Einträge verbinden + +Zuerst laufen die Setup-Befehle aus dem Repository, dann deine eigenen. Hake **Use only my setup commands** im Abschnitt Worktree an, um die Befehle des Repositorys ganz zu überspringen. + +Aktionen werden nach `id` zusammengeführt. Eine Aktion in deinen Einstellungen mit derselben id wie eine Repository-Aktion ersetzt diese. Starter werden nach Name zusammengeführt. + +Das Warte-Flag kommt aus deinen Einstellungen, wenn du es gesetzt hast, sonst aus dem Repository. + +## Vertrauen + +Setup-Befehle und Aktionen aus dem Repository laufen auf deinem Rechner, und ein `git pull` kann sie ändern. Deshalb zeigt OpenChamber beim ersten Mal, wenn einer davon ausgeführt werden soll, die genauen Befehle und fragt. **Trust and run** merkt sich deine Antwort auf dieser Instanz. **Not this time** führt nur deine eigenen Befehle aus. + +Die Antwort ist an die Befehle selbst gebunden. Wenn ein Pull einen Repository-Befehl ändert, kommt die Frage für den neuen Text zurück. Mit **reset trust** im Abschnitt Worktree der Projekteinstellungen vergisst OpenChamber die Antwort. + +Einen eigenen Befehl ins Repository zu verschieben gilt als Vertrauen, denn du hast ihn gerade gesehen. + +## Pläne im Repository + +Pläne aus dem Tab „Pläne“ können ebenfalls im Repository liegen, als Markdown-Dateien. Der Standardordner ist `.openchamber/plans`. Setze **Plans folder** in den Projekteinstellungen, um einen anderen Ordner im Repository zu verwenden, etwa `docs/plans`, wenn das Team seine Pläne schon dort hat. Ein eigener Ordner ersetzt den Standard vollständig: OpenChamber liest und schreibt nur diesen Ordner, verschiebe vorhandene Dateien beim Wechsel also selbst. + +Jede `.md`-Datei in diesem Ordner erscheint im Tab „Pläne“, auch Dateien aus anderen Werkzeugen. Beim Bearbeiten in OpenChamber wird die Datei so gespeichert, wie du sie getippt hast. Ein Plan, den du ins Repository verschiebst, behält seine Identität, sodass Sitzungen, die ihn angehängt hatten, ihn weiterhin finden. + +## Verwandt + +- [Projektaktionen](/project-actions/) +- [Worktrees](/worktrees/) +- [Projektnotizen, Todos und Pläne](/notes-todos-plans/) diff --git a/packages/docs/content/docs/environment.mdx b/packages/docs/content/docs/environment.mdx index 1a6ca40e..15e761aa 100644 --- a/packages/docs/content/docs/environment.mdx +++ b/packages/docs/content/docs/environment.mdx @@ -25,6 +25,8 @@ Starts OpenChamber in headless mode when set to `true` or `1`. API routes stay a Overrides the OpenChamber data directory. The default is `~/.config/openchamber`. +Everything OpenChamber stores lives under this directory: settings, auth, project configs, themes, plans, and speech models. An instance that used a custom directory before version 1.23 gets its `projects`, `themes`, and `speech-models` folders copied from `~/.config/openchamber` on the first start; the originals stay in place and nothing is merged. + ### `OPENCHAMBER_CHATS_DIR` Moves the managed chat directories that OpenChamber creates for chats without a project. The default is `~/.config/openchamber/chats`. Set it to a directory the OpenCode server can read when OpenChamber and OpenCode run as different users. Existing chats are not moved. diff --git a/packages/docs/content/docs/es/project-actions.mdx b/packages/docs/content/docs/es/project-actions.mdx index a7036656..644c651e 100644 --- a/packages/docs/content/docs/es/project-actions.mdx +++ b/packages/docs/content/docs/es/project-actions.mdx @@ -25,4 +25,6 @@ Activa **auto-open URL** para una acción que inicia un servidor. OpenChamber ob ## Relacionado +- [Configuración en el repositorio](/repository-config/) — guarda acciones y comandos de configuración en el repositorio para todo el equipo + - [Vista previa y servidores de desarrollo](/es/preview/) — abre un servidor de desarrollo en marcha dentro de OpenChamber diff --git a/packages/docs/content/docs/es/repository-config.mdx b/packages/docs/content/docs/es/repository-config.mdx new file mode 100644 index 00000000..163168b1 --- /dev/null +++ b/packages/docs/content/docs/es/repository-config.mdx @@ -0,0 +1,100 @@ +--- +title: Configuración en el repositorio +description: Guarda acciones del proyecto, comandos de configuración de worktree, arranques y planes en el repositorio para que los tenga todo el que lo clone. +--- + +# Configuración en el repositorio + +Las acciones del proyecto, los comandos de configuración de worktree y los arranques de borrador viven por defecto en tus propios ajustes de OpenChamber. Nadie más los ve. Si quieres que quien clone el repositorio tenga la misma acción de servidor de desarrollo y el mismo `bun install` en cada worktree nuevo, mueve esos elementos al repositorio. + +OpenChamber los guarda en `.openchamber/project.json` en la raíz del repositorio. El archivo aparece solo cuando mueves allí el primer elemento y desaparece cuando sacas el último. Haz commit como con cualquier otro archivo. + +## Qué va a cada sitio + +| Se queda en tus ajustes | Puede ir al repositorio | +|---|---| +| Notas y tareas | Acciones del proyecto | +| Tareas programadas | Comandos de configuración de worktree | +| Qué acción del repositorio has ocultado para ti | Arranques de borrador (comandos y skills fijados) | +| Tu respuesta de confianza para los comandos del repositorio | Planes | + +Las notas, las tareas y las tareas programadas son tuyas. Nunca acaban en el repositorio. + +## Mover un elemento + +Abre **Settings → Projects** y elige el proyecto. Cada acción y cada comando de configuración tiene un botón **Move to repository**, y cada elemento que viene del repositorio tiene **Move to my settings**. Los arranques de la pantalla de nueva sesión muestran el mismo par al pasar el cursor. Los planes lo tienen en cada fila de la pestaña Planes. + +Mover es exactamente eso. El elemento sale de un sitio y llega al otro, no se duplica nada. + +Los elementos del repositorio muestran la insignia **In repo**. Las acciones del repositorio también se pueden ocultar de tu menú con **Hide for me**. Eso solo cambia tu menú, no el archivo. + +## El archivo + +```json +{ + "version": 1, + "setupWorktree": [ + "bun install" + ], + "setupWorktreeWait": true, + "projectActions": [ + { + "id": "dev", + "name": "Dev server", + "command": "bun run dev", + "icon": "rocket", + "autoOpenUrl": true, + "platforms": ["macos", "linux"] + }, + { + "id": "test", + "name": "Tests", + "command": "bun test" + } + ], + "draftStarters": [ + { "type": "skill", "name": "triage-prs" } + ], + "plansDir": "docs/plans" +} +``` + +Solo `version` es obligatorio. El resto de claves son opcionales, y OpenChamber escribe solo las que contienen algo. + +`setupWorktree` es la lista de comandos de shell que OpenChamber ejecuta dentro de un worktree nuevo justo después de crearlo, en orden. Usa `$ROOT_PROJECT_PATH` para la ruta del checkout principal. `setupWorktreeWait: true` hace que OpenChamber espere a estos comandos antes de iniciar una sesión en el worktree. + +`projectActions` es la lista de acciones del menú de la cabecera. `id`, `name` y `command` son obligatorios. `icon` es opcional y por defecto es un icono de play; los nombres que OpenChamber conoce son `play`, `build`, `lint`, `terminal`, `tools`, `bug`, `flask`, `rocket`, `code`, `server`, `branch`, `search`, `settings`, `brain`, `stack`, `robot`, `command` y `file`. `autoOpenUrl: true` abre la dirección que imprime el comando, consulta [Vista previa y servidores de desarrollo](/preview/). `platforms` limita la acción a `macos`, `linux` o `windows`. `runIn: "parent"` ejecuta la acción en el checkout principal en lugar del worktree actual. + +`draftStarters` fija comandos y skills en la pantalla de nueva sesión. Cada entrada es `{ "type": "command" | "skill", "name": "..." }`, y el comando o skill tiene que existir en la configuración de OpenCode del repositorio. + +`plansDir` es donde viven los planes del repositorio. Omítelo para usar `.openchamber/plans`. Más abajo se explica. + +Puedes escribir este archivo a mano. Una clave con forma incorrecta invalida el archivo entero, y la página Projects te dice por qué en lugar de ignorarla en silencio. + +## Cómo se combinan los elementos del repositorio y los tuyos + +Primero se ejecutan los comandos de configuración del repositorio, después los tuyos. Marca **Use only my setup commands** en la sección Worktree para omitir por completo los del repositorio. + +Las acciones se combinan por `id`. Una acción de tus ajustes con el mismo id que una del repositorio la reemplaza. Los arranques se combinan por nombre. + +La marca de espera sale de tus ajustes cuando la has fijado, y si no, del repositorio. + +## Confianza + +Los comandos de configuración y las acciones del repositorio se ejecutan en tu máquina, y un `git pull` puede cambiarlos. Por eso, la primera vez que uno de ellos está a punto de ejecutarse, OpenChamber muestra los comandos exactos y pregunta. **Trust and run** recuerda tu respuesta en esta instancia. **Not this time** ejecuta solo tus propios comandos. + +La respuesta va ligada a los comandos en sí. Cuando un pull cambia un comando del repositorio, la pregunta vuelve para el texto nuevo. Puedes olvidar la respuesta con **reset trust** en la sección Worktree de los ajustes del proyecto. + +Mover un comando tuyo al repositorio cuenta como confiar en él, porque acabas de verlo. + +## Planes en el repositorio + +Los planes de la pestaña Planes también pueden vivir en el repositorio como archivos Markdown. La carpeta por defecto es `.openchamber/plans`. Define **Plans folder** en los ajustes del proyecto para usar otra carpeta dentro del repositorio, por ejemplo `docs/plans` si tu equipo ya guarda ahí los planes. Una carpeta propia reemplaza por completo la predeterminada: OpenChamber lee y escribe solo en esa carpeta, así que mueve tú mismo los archivos existentes cuando la cambies. + +Cada archivo `.md` de esa carpeta aparece en la pestaña Planes, incluidos los escritos por otras herramientas. Editar uno en OpenChamber guarda el archivo tal como lo escribiste. Un plan que mueves al repositorio conserva su identidad, así que las sesiones que lo tenían adjunto lo siguen encontrando. + +## Relacionado + +- [Acciones del proyecto](/project-actions/) +- [Worktrees](/worktrees/) +- [Notas, tareas y planes del proyecto](/notes-todos-plans/) diff --git a/packages/docs/content/docs/fr/project-actions.mdx b/packages/docs/content/docs/fr/project-actions.mdx index f61a903f..ef1b0301 100644 --- a/packages/docs/content/docs/fr/project-actions.mdx +++ b/packages/docs/content/docs/fr/project-actions.mdx @@ -25,4 +25,6 @@ Activez **auto-open URL** pour une action qui démarre un serveur. OpenChamber s ## Pages liées +- [Configuration du dépôt](/repository-config/) — garder les actions et commandes de configuration dans le dépôt pour toute l'équipe + - [Aperçu et serveurs de dev](/preview/) — ouvrir un serveur de dev en cours d’exécution dans OpenChamber diff --git a/packages/docs/content/docs/fr/repository-config.mdx b/packages/docs/content/docs/fr/repository-config.mdx new file mode 100644 index 00000000..d2701c7a --- /dev/null +++ b/packages/docs/content/docs/fr/repository-config.mdx @@ -0,0 +1,100 @@ +--- +title: Configuration du dépôt +description: Gardez les actions de projet, les commandes de configuration de worktree, les amorces et les plans dans le dépôt pour que tous ceux qui le récupèrent les aient. +--- + +# Configuration du dépôt + +Les actions de projet, les commandes de configuration de worktree et les amorces de brouillon vivent par défaut dans vos propres réglages OpenChamber. Personne d'autre ne les voit. Si vous voulez qu'un collègue qui clone le dépôt ait la même action de serveur de dev et le même `bun install` dans chaque nouveau worktree, déplacez ces éléments dans le dépôt. + +OpenChamber les enregistre dans `.openchamber/project.json` à la racine du dépôt. Le fichier n'apparaît que lorsque vous y déplacez le premier élément, et il disparaît quand vous retirez le dernier. Committez-le comme n'importe quel autre fichier. + +## Ce qui va où + +| Reste dans vos réglages | Peut aller dans le dépôt | +|---|---| +| Notes et tâches | Actions de projet | +| Tâches planifiées | Commandes de configuration de worktree | +| Les actions du dépôt que vous avez masquées pour vous | Amorces de brouillon (commandes et skills épinglés) | +| Votre réponse de confiance pour les commandes du dépôt | Plans | + +Les notes, les tâches et les tâches planifiées sont à vous. Elles n'arrivent jamais dans le dépôt. + +## Déplacer un élément + +Ouvrez **Settings → Projects** et choisissez le projet. Chaque action et chaque commande de configuration a un bouton **Move to repository**, et chaque élément venu du dépôt a **Move to my settings**. Les amorces de l'écran de nouvelle session montrent la même paire au survol. Les plans l'ont sur chaque ligne de l'onglet Plans. + +Déplacer, c'est déplacer. L'élément quitte un endroit et arrive à l'autre, rien n'est dupliqué. + +Les éléments du dépôt portent le badge **In repo**. Les actions du dépôt peuvent aussi être masquées de votre menu avec **Hide for me**. Cela ne change que votre menu, pas le fichier. + +## Le fichier + +```json +{ + "version": 1, + "setupWorktree": [ + "bun install" + ], + "setupWorktreeWait": true, + "projectActions": [ + { + "id": "dev", + "name": "Dev server", + "command": "bun run dev", + "icon": "rocket", + "autoOpenUrl": true, + "platforms": ["macos", "linux"] + }, + { + "id": "test", + "name": "Tests", + "command": "bun test" + } + ], + "draftStarters": [ + { "type": "skill", "name": "triage-prs" } + ], + "plansDir": "docs/plans" +} +``` + +Seul `version` est obligatoire. Toutes les autres clés sont facultatives, et OpenChamber n'écrit que celles qui contiennent quelque chose. + +`setupWorktree` est la liste des commandes shell qu'OpenChamber exécute dans un nouveau worktree juste après sa création, dans l'ordre. Utilisez `$ROOT_PROJECT_PATH` pour le chemin du checkout principal. `setupWorktreeWait: true` fait attendre OpenChamber la fin de ces commandes avant de démarrer une session dans le worktree. + +`projectActions` est la liste des actions du menu d'en-tête. `id`, `name` et `command` sont obligatoires. `icon` est facultatif et retombe sur une icône play ; les noms connus d'OpenChamber sont `play`, `build`, `lint`, `terminal`, `tools`, `bug`, `flask`, `rocket`, `code`, `server`, `branch`, `search`, `settings`, `brain`, `stack`, `robot`, `command` et `file`. `autoOpenUrl: true` ouvre l'adresse affichée par la commande, voir [Aperçu et serveurs de dev](/preview/). `platforms` limite l'action à `macos`, `linux` ou `windows`. `runIn: "parent"` exécute l'action dans le checkout principal plutôt que dans le worktree courant. + +`draftStarters` épingle des commandes et des skills sur l'écran de nouvelle session. Chaque entrée s'écrit `{ "type": "command" | "skill", "name": "..." }`, et la commande ou le skill doit exister dans la configuration OpenCode du dépôt. + +`plansDir` indique où vivent les plans du dépôt. Omettez-le pour utiliser `.openchamber/plans`. Voir plus bas. + +Vous pouvez écrire ce fichier à la main. Une clé de mauvaise forme rend tout le fichier invalide, et la page Projects vous dit pourquoi au lieu de l'ignorer en silence. + +## Comment les éléments du dépôt et les vôtres se combinent + +Les commandes de configuration du dépôt s'exécutent d'abord, puis les vôtres. Cochez **Use only my setup commands** dans la section Worktree pour ignorer complètement celles du dépôt. + +Les actions sont fusionnées par `id`. Une action de vos réglages avec le même id qu'une action du dépôt la remplace. Les amorces sont fusionnées par nom. + +L'indicateur d'attente vient de vos réglages quand vous l'avez défini, sinon du dépôt. + +## Confiance + +Les commandes de configuration et les actions du dépôt s'exécutent sur votre machine, et un `git pull` peut les changer. Donc la première fois que l'une d'elles est sur le point de s'exécuter, OpenChamber affiche les commandes exactes et demande. **Trust and run** mémorise votre réponse sur cette instance. **Not this time** n'exécute que vos propres commandes. + +La réponse est liée aux commandes elles-mêmes. Quand un pull modifie une commande du dépôt, la question revient pour le nouveau texte. Vous pouvez oublier la réponse avec **reset trust** dans la section Worktree des réglages du projet. + +Déplacer votre propre commande dans le dépôt vaut confiance, puisque vous venez de la voir. + +## Plans dans le dépôt + +Les plans de l'onglet Plans peuvent aussi vivre dans le dépôt, sous forme de fichiers Markdown. Le dossier par défaut est `.openchamber/plans`. Définissez **Plans folder** dans les réglages du projet pour utiliser un autre dossier du dépôt, par exemple `docs/plans` si votre équipe y garde déjà ses plans. Un dossier personnalisé remplace entièrement le dossier par défaut : OpenChamber ne lit et n'écrit que ce dossier, déplacez donc vous-même les fichiers existants quand vous le changez. + +Chaque fichier `.md` de ce dossier apparaît dans l'onglet Plans, y compris les fichiers écrits par d'autres outils. Modifier un plan dans OpenChamber enregistre le fichier tel que vous l'avez tapé. Un plan que vous déplacez dans le dépôt garde son identité, donc les sessions qui l'avaient attaché le retrouvent. + +## Pages liées + +- [Actions de projet](/project-actions/) +- [Worktrees](/worktrees/) +- [Notes, tâches et plans du projet](/notes-todos-plans/) diff --git a/packages/docs/content/docs/ja/environment.mdx b/packages/docs/content/docs/ja/environment.mdx index 260246ed..e38e81a4 100644 --- a/packages/docs/content/docs/ja/environment.mdx +++ b/packages/docs/content/docs/ja/environment.mdx @@ -25,6 +25,8 @@ OpenChamber Web サーバーのバインドアドレスです。他のマシン OpenChamber のデータディレクトリを上書きします。デフォルトは `~/.config/openchamber` です。 +OpenChamber が保存するものはすべてこのディレクトリ配下にあります: 設定、認証、プロジェクト設定、テーマ、プラン、音声モデル。バージョン 1.23 より前にカスタムディレクトリを使っていたインスタンスでは、初回起動時に `projects`、`themes`、`speech-models` フォルダーが `~/.config/openchamber` からここへコピーされます。元のフォルダーはそのまま残り、マージはされません。 + ### `OPENCHAMBER_CHATS_DIR` プロジェクトを持たないチャット用に OpenChamber が作成する管理チャットディレクトリの場所を変更します。デフォルトは `~/.config/openchamber/chats` です。OpenChamber と OpenCode を別のユーザーで実行している場合は、OpenCode サーバーが読み取れるディレクトリを指定してください。既存のチャットは移動されません。 diff --git a/packages/docs/content/docs/ja/project-actions.mdx b/packages/docs/content/docs/ja/project-actions.mdx index 9d10579f..ed16ddc5 100644 --- a/packages/docs/content/docs/ja/project-actions.mdx +++ b/packages/docs/content/docs/ja/project-actions.mdx @@ -25,4 +25,6 @@ description: よく実行するコマンドを保存し、ワンクリックで ## 関連 +- [リポジトリ設定](/repository-config/) — アクションとセットアップコマンドをリポジトリに置いてチーム全体で使う + - [プレビューと開発サーバー](/preview/) — 実行中の開発サーバーを OpenChamber 内で開く diff --git a/packages/docs/content/docs/ja/repository-config.mdx b/packages/docs/content/docs/ja/repository-config.mdx new file mode 100644 index 00000000..25125573 --- /dev/null +++ b/packages/docs/content/docs/ja/repository-config.mdx @@ -0,0 +1,100 @@ +--- +title: リポジトリ設定 +description: プロジェクトアクション、ワークツリーのセットアップコマンド、スターター、プランをリポジトリに置き、pull した全員が同じものを使えるようにします。 +--- + +# リポジトリ設定 + +プロジェクトアクション、ワークツリーのセットアップコマンド、下書きスターターは、デフォルトではあなた自身の OpenChamber 設定に保存されます。他の人には見えません。リポジトリをクローンしたチームメンバーにも同じ開発サーバーのアクションと、新しいワークツリーごとの同じ `bun install` を使ってほしいなら、それらの項目をリポジトリへ移動します。 + +OpenChamber はそれらをリポジトリ直下の `.openchamber/project.json` に保存します。このファイルは最初の項目を移動したときに初めて作られ、最後の項目を戻すと消えます。ほかのファイルと同じようにコミットしてください。 + +## 何がどこに入るか + +| あなたの設定に残るもの | リポジトリへ移動できるもの | +|---|---| +| ノートと Todo | プロジェクトアクション | +| スケジュールタスク | ワークツリーのセットアップコマンド | +| リポジトリのアクションのうち自分だけ非表示にしたもの | 下書きスターター(ピン留めしたコマンドとスキル) | +| リポジトリのコマンドに対する信頼の回答 | プラン | + +ノート、Todo、スケジュールタスクはあなたのものです。リポジトリに入ることはありません。 + +## 項目を移動する + +**Settings → Projects** を開き、プロジェクトを選びます。各アクションと各セットアップコマンドには **Move to repository** ボタンがあり、リポジトリ由来の各項目には **Move to my settings** があります。新規セッション画面のスターターはホバーで同じ 2 つを表示します。プランはプランタブの各行にあります。 + +移動は文字どおり移動です。項目は一方から消えてもう一方に現れ、複製はされません。 + +リポジトリ由来の項目には **In repo** バッジが付きます。リポジトリのアクションは **Hide for me** で自分のメニューから隠せます。これはあなたのメニューだけを変え、ファイルは変えません。 + +## ファイル + +```json +{ + "version": 1, + "setupWorktree": [ + "bun install" + ], + "setupWorktreeWait": true, + "projectActions": [ + { + "id": "dev", + "name": "Dev server", + "command": "bun run dev", + "icon": "rocket", + "autoOpenUrl": true, + "platforms": ["macos", "linux"] + }, + { + "id": "test", + "name": "Tests", + "command": "bun test" + } + ], + "draftStarters": [ + { "type": "skill", "name": "triage-prs" } + ], + "plansDir": "docs/plans" +} +``` + +必須なのは `version` だけです。ほかのキーはすべて省略可能で、OpenChamber は中身のあるキーだけを書き込みます。 + +`setupWorktree` は、新しいワークツリーを作成した直後にその中で OpenChamber が順番に実行するシェルコマンドの一覧です。メインのチェックアウトのパスには `$ROOT_PROJECT_PATH` を使います。`setupWorktreeWait: true` にすると、OpenChamber はこれらのコマンドの完了を待ってからワークツリーでセッションを開始します。 + +`projectActions` はヘッダーメニューのアクション一覧です。`id`、`name`、`command` は必須です。`icon` は省略可能で、省略時は play アイコンになります。OpenChamber が認識する名前は `play`、`build`、`lint`、`terminal`、`tools`、`bug`、`flask`、`rocket`、`code`、`server`、`branch`、`search`、`settings`、`brain`、`stack`、`robot`、`command`、`file` です。`autoOpenUrl: true` はコマンドが出力したアドレスを開きます([プレビューと開発サーバー](/preview/) を参照)。`platforms` はアクションを `macos`、`linux`、`windows` に限定します。`runIn: "parent"` は現在のワークツリーではなくメインのチェックアウトでアクションを実行します。 + +`draftStarters` はコマンドとスキルを新規セッション画面にピン留めします。各項目は `{ "type": "command" | "skill", "name": "..." }` の形で、コマンドやスキル自体はリポジトリの OpenCode 設定に存在している必要があります。 + +`plansDir` はリポジトリのプランを置く場所です。省略すると `.openchamber/plans` が使われます。後述します。 + +このファイルは手で書いても構いません。形の違うキーがあるとファイル全体が無効になり、Projects ページは黙って無視する代わりに理由を表示します。 + +## リポジトリの項目と自分の項目の組み合わせ + +セットアップコマンドはリポジトリのものが先に実行され、その後にあなたのものが実行されます。Worktree セクションの **Use only my setup commands** にチェックを入れると、リポジトリのコマンドを完全にスキップします。 + +アクションは `id` でマージされます。リポジトリのアクションと同じ id があなたの設定にあれば、そちらが優先されます。スターターは名前でマージされます。 + +待機フラグは、あなたが設定していればその値、なければリポジトリの値が使われます。 + +## 信頼 + +リポジトリのセットアップコマンドとアクションはあなたのマシンで実行され、`git pull` で内容が変わることがあります。そのため、いずれかが初めて実行されそうになったとき、OpenChamber は正確なコマンドを表示して確認します。**Trust and run** はこのインスタンスで回答を記憶します。**Not this time** はあなた自身のコマンドだけを実行します。 + +回答はコマンドそのものに結び付いています。pull でリポジトリのコマンドが変わると、新しい内容について再度確認されます。プロジェクト設定の Worktree セクションにある **reset trust** で回答を忘れさせることができます。 + +自分のコマンドをリポジトリへ移動することは、そのコマンドを信頼したものとみなされます。いま自分で見たばかりだからです。 + +## リポジトリ内のプラン + +プランタブのプランも、Markdown ファイルとしてリポジトリに置けます。デフォルトのフォルダーは `.openchamber/plans` です。チームがすでに `docs/plans` などにプランを置いているなら、プロジェクト設定の **Plans folder** でリポジトリ内の別のフォルダーを指定します。カスタムフォルダーはデフォルトを完全に置き換えます。OpenChamber はそのフォルダーだけを読み書きするので、変更時は既存ファイルを自分で移動してください。 + +そのフォルダー内のすべての `.md` ファイルがプランタブに表示されます。ほかのツールで書いたファイルも含みます。OpenChamber で編集すると、入力したとおりにファイルが保存されます。リポジトリへ移動したプランは同一性を保つため、そのプランを添付していたセッションからも引き続き見つかります。 + +## 関連 + +- [プロジェクトアクション](/project-actions/) +- [ワークツリー](/worktrees/) +- [プロジェクトのノート、Todo、プラン](/notes-todos-plans/) diff --git a/packages/docs/content/docs/ko/project-actions.mdx b/packages/docs/content/docs/ko/project-actions.mdx index 8eb7e9c5..3b582a1b 100644 --- a/packages/docs/content/docs/ko/project-actions.mdx +++ b/packages/docs/content/docs/ko/project-actions.mdx @@ -25,4 +25,6 @@ description: 자주 실행하는 명령을 저장하고 클릭 한 번으로 실 ## 관련 항목 +- [저장소 설정](/repository-config/) — 작업과 설정 명령을 저장소에 두어 팀 전체가 사용 + - [Preview & Dev Servers](/ko/preview/) — 실행 중인 개발 서버를 OpenChamber 안에서 여세요 diff --git a/packages/docs/content/docs/ko/repository-config.mdx b/packages/docs/content/docs/ko/repository-config.mdx new file mode 100644 index 00000000..92981f55 --- /dev/null +++ b/packages/docs/content/docs/ko/repository-config.mdx @@ -0,0 +1,100 @@ +--- +title: 저장소 설정 +description: 프로젝트 작업, 워크트리 설정 명령, 스타터, 플랜을 저장소에 두어 저장소를 받는 모든 사람이 같은 것을 사용하게 합니다. +--- + +# 저장소 설정 + +프로젝트 작업, 워크트리 설정 명령, 초안 스타터는 기본적으로 자신의 OpenChamber 설정에 저장됩니다. 다른 사람에게는 보이지 않습니다. 저장소를 클론한 팀원도 같은 개발 서버 작업과 새 워크트리마다 같은 `bun install`을 쓰게 하고 싶다면, 해당 항목을 저장소로 옮기세요. + +OpenChamber는 이를 저장소 루트의 `.openchamber/project.json`에 저장합니다. 이 파일은 첫 항목을 옮길 때 처음 만들어지고, 마지막 항목을 빼면 사라집니다. 다른 파일과 똑같이 커밋하면 됩니다. + +## 무엇이 어디에 있는가 + +| 내 설정에 남는 것 | 저장소로 옮길 수 있는 것 | +|---|---| +| 노트와 할 일 | 프로젝트 작업 | +| 예약 작업 | 워크트리 설정 명령 | +| 저장소 작업 중 나만 숨긴 것 | 초안 스타터(고정한 명령과 스킬) | +| 저장소 명령에 대한 신뢰 응답 | 플랜 | + +노트, 할 일, 예약 작업은 내 것입니다. 저장소에 들어가지 않습니다. + +## 항목 옮기기 + +**Settings → Projects**를 열고 프로젝트를 고릅니다. 각 작업과 각 설정 명령에는 **Move to repository** 버튼이 있고, 저장소에서 온 각 항목에는 **Move to my settings**가 있습니다. 새 세션 화면의 스타터는 마우스를 올리면 같은 두 버튼을 보여 줍니다. 플랜은 플랜 탭의 각 행에 있습니다. + +옮기기는 말 그대로 옮기기입니다. 항목이 한쪽에서 사라지고 다른 쪽에 나타나며, 복제되지 않습니다. + +저장소에서 온 항목에는 **In repo** 배지가 붙습니다. 저장소 작업은 **Hide for me**로 내 메뉴에서 숨길 수 있습니다. 이는 내 메뉴만 바꾸며 파일은 바꾸지 않습니다. + +## 파일 + +```json +{ + "version": 1, + "setupWorktree": [ + "bun install" + ], + "setupWorktreeWait": true, + "projectActions": [ + { + "id": "dev", + "name": "Dev server", + "command": "bun run dev", + "icon": "rocket", + "autoOpenUrl": true, + "platforms": ["macos", "linux"] + }, + { + "id": "test", + "name": "Tests", + "command": "bun test" + } + ], + "draftStarters": [ + { "type": "skill", "name": "triage-prs" } + ], + "plansDir": "docs/plans" +} +``` + +필수 키는 `version`뿐입니다. 나머지 키는 모두 선택이며, OpenChamber는 내용이 있는 키만 기록합니다. + +`setupWorktree`는 새 워크트리를 만든 직후 그 안에서 OpenChamber가 순서대로 실행하는 셸 명령 목록입니다. 메인 체크아웃 경로에는 `$ROOT_PROJECT_PATH`를 사용하세요. `setupWorktreeWait: true`로 두면 OpenChamber는 이 명령들이 끝난 뒤에 워크트리에서 세션을 시작합니다. + +`projectActions`는 헤더 메뉴의 작업 목록입니다. `id`, `name`, `command`는 필수입니다. `icon`은 선택이며 없으면 play 아이콘이 쓰입니다. OpenChamber가 아는 이름은 `play`, `build`, `lint`, `terminal`, `tools`, `bug`, `flask`, `rocket`, `code`, `server`, `branch`, `search`, `settings`, `brain`, `stack`, `robot`, `command`, `file`입니다. `autoOpenUrl: true`는 명령이 출력한 주소를 엽니다([미리보기 및 개발 서버](/preview/) 참고). `platforms`는 작업을 `macos`, `linux`, `windows`로 제한합니다. `runIn: "parent"`는 현재 워크트리 대신 메인 체크아웃에서 작업을 실행합니다. + +`draftStarters`는 명령과 스킬을 새 세션 화면에 고정합니다. 각 항목은 `{ "type": "command" | "skill", "name": "..." }` 형태이며, 해당 명령이나 스킬은 저장소의 OpenCode 설정에 있어야 합니다. + +`plansDir`는 저장소 플랜이 있는 곳입니다. 생략하면 `.openchamber/plans`를 사용합니다. 아래를 참고하세요. + +이 파일은 직접 써도 됩니다. 형태가 잘못된 키가 있으면 파일 전체가 무효가 되고, Projects 페이지는 조용히 무시하는 대신 이유를 알려 줍니다. + +## 저장소 항목과 내 항목이 합쳐지는 방식 + +설정 명령은 저장소의 것이 먼저, 내 것이 그다음에 실행됩니다. Worktree 섹션의 **Use only my setup commands**에 체크하면 저장소 명령을 완전히 건너뜁니다. + +작업은 `id`로 병합됩니다. 저장소 작업과 같은 id가 내 설정에 있으면 내 것이 대신합니다. 스타터는 이름으로 병합됩니다. + +대기 플래그는 내가 설정했으면 내 값, 아니면 저장소 값이 쓰입니다. + +## 신뢰 + +저장소의 설정 명령과 작업은 내 컴퓨터에서 실행되며, `git pull`로 내용이 바뀔 수 있습니다. 그래서 그중 하나가 처음 실행되려 할 때 OpenChamber는 정확한 명령을 보여 주고 묻습니다. **Trust and run**은 이 인스턴스에서 응답을 기억합니다. **Not this time**은 내 명령만 실행합니다. + +응답은 명령 자체에 묶여 있습니다. pull로 저장소 명령이 바뀌면 새 내용에 대해 다시 묻습니다. 프로젝트 설정의 Worktree 섹션에 있는 **reset trust**로 응답을 잊게 할 수 있습니다. + +내 명령을 저장소로 옮기는 것은 그 명령을 신뢰한 것으로 간주됩니다. 방금 직접 봤기 때문입니다. + +## 저장소의 플랜 + +플랜 탭의 플랜도 Markdown 파일로 저장소에 둘 수 있습니다. 기본 폴더는 `.openchamber/plans`입니다. 팀이 이미 `docs/plans` 같은 곳에 플랜을 두고 있다면 프로젝트 설정의 **Plans folder**에서 저장소 안의 다른 폴더를 지정하세요. 사용자 지정 폴더는 기본값을 완전히 대체합니다. OpenChamber는 그 폴더만 읽고 쓰므로, 변경할 때 기존 파일은 직접 옮기세요. + +그 폴더의 모든 `.md` 파일이 플랜 탭에 표시되며, 다른 도구로 쓴 파일도 포함됩니다. OpenChamber에서 편집하면 입력한 그대로 파일이 저장됩니다. 저장소로 옮긴 플랜은 정체성을 유지하므로, 그 플랜을 첨부했던 세션에서도 계속 찾을 수 있습니다. + +## 관련 항목 + +- [프로젝트 작업](/project-actions/) +- [워크트리](/worktrees/) +- [프로젝트 노트, 할 일, 플랜](/notes-todos-plans/) diff --git a/packages/docs/content/docs/pl/environment.mdx b/packages/docs/content/docs/pl/environment.mdx index 92623867..9db9592c 100644 --- a/packages/docs/content/docs/pl/environment.mdx +++ b/packages/docs/content/docs/pl/environment.mdx @@ -25,6 +25,8 @@ Uruchamia OpenChamber w trybie headless, gdy ustawione na `true` lub `1`. Trasy Nadpisuje katalog danych OpenChamber. Domyślnie jest to `~/.config/openchamber`. +Wszystko, co OpenChamber zapisuje, znajduje się w tym katalogu: ustawienia, dane logowania, konfiguracje projektów, motywy, plany i modele mowy. Instancja, która używała własnego katalogu przed wersją 1.23, przy pierwszym uruchomieniu otrzyma kopie folderów `projects`, `themes` i `speech-models` z `~/.config/openchamber`; oryginały pozostają na miejscu i nic nie jest scalane. + ### `OPENCHAMBER_CHATS_DIR` Przenosi katalogi zarządzanych czatów, które OpenChamber tworzy dla czatów bez projektu. Domyślnie jest to `~/.config/openchamber/chats`. Ustaw katalog, który serwer OpenCode może odczytać, gdy OpenChamber i OpenCode działają jako różni użytkownicy. Istniejące czaty nie są przenoszone. diff --git a/packages/docs/content/docs/pl/project-actions.mdx b/packages/docs/content/docs/pl/project-actions.mdx index 780efc5b..8e32f056 100644 --- a/packages/docs/content/docs/pl/project-actions.mdx +++ b/packages/docs/content/docs/pl/project-actions.mdx @@ -25,4 +25,6 @@ Włącz **auto-open URL** dla akcji, która uruchamia serwer. OpenChamber obserw ## Powiązane +- [Konfiguracja w repozytorium](/repository-config/) — trzymaj akcje i polecenia konfiguracji w repozytorium dla całego zespołu + - [Podgląd i serwery deweloperskie](/pl/preview/) — otwórz działający serwer deweloperski wewnątrz OpenChamber diff --git a/packages/docs/content/docs/pl/repository-config.mdx b/packages/docs/content/docs/pl/repository-config.mdx new file mode 100644 index 00000000..dee357cd --- /dev/null +++ b/packages/docs/content/docs/pl/repository-config.mdx @@ -0,0 +1,100 @@ +--- +title: Konfiguracja w repozytorium +description: Trzymaj akcje projektu, polecenia konfiguracji worktree, startery i plany w repozytorium, aby dostał je każdy, kto je pobierze. +--- + +# Konfiguracja w repozytorium + +Akcje projektu, polecenia konfiguracji worktree i startery szkicu domyślnie żyją w Twoich własnych ustawieniach OpenChamber. Nikt inny ich nie widzi. Jeśli chcesz, aby osoba z zespołu, która sklonuje repozytorium, dostała tę samą akcję serwera deweloperskiego i to samo `bun install` w każdym nowym worktree, przenieś te elementy do repozytorium. + +OpenChamber zapisuje je w pliku `.openchamber/project.json` w katalogu głównym repozytorium. Plik pojawia się dopiero wtedy, gdy przeniesiesz tam pierwszy element, i znika, gdy zabierzesz ostatni. Commituj go jak każdy inny plik. + +## Co gdzie trafia + +| Zostaje w Twoich ustawieniach | Można przenieść do repozytorium | +|---|---| +| Notatki i todo | Akcje projektu | +| Zaplanowane zadania | Polecenia konfiguracji worktree | +| Które akcje z repozytorium ukrywasz u siebie | Startery szkicu (przypięte polecenia i skille) | +| Twoja odpowiedź o zaufaniu do poleceń z repozytorium | Plany | + +Notatki, todo i zaplanowane zadania są Twoje. Nigdy nie trafiają do repozytorium. + +## Przenoszenie elementu + +Otwórz **Settings → Projects** i wybierz projekt. Każda akcja i każde polecenie konfiguracji ma przycisk **Move to repository**, a każdy element pochodzący z repozytorium ma **Move to my settings**. Startery na ekranie nowej sesji pokazują tę samą parę po najechaniu. Plany mają ją w każdym wierszu karty Plany. + +Przeniesienie to po prostu przeniesienie. Element znika z jednego miejsca i pojawia się w drugim, nic nie jest duplikowane. + +Elementy z repozytorium mają odznakę **In repo**. Akcje z repozytorium można ukryć ze swojego menu przyciskiem **Hide for me**. To zmienia tylko Twoje menu, nie plik. + +## Plik + +```json +{ + "version": 1, + "setupWorktree": [ + "bun install" + ], + "setupWorktreeWait": true, + "projectActions": [ + { + "id": "dev", + "name": "Dev server", + "command": "bun run dev", + "icon": "rocket", + "autoOpenUrl": true, + "platforms": ["macos", "linux"] + }, + { + "id": "test", + "name": "Tests", + "command": "bun test" + } + ], + "draftStarters": [ + { "type": "skill", "name": "triage-prs" } + ], + "plansDir": "docs/plans" +} +``` + +Wymagany jest tylko `version`. Wszystkie pozostałe klucze są opcjonalne, a OpenChamber zapisuje tylko te, które coś zawierają. + +`setupWorktree` to lista poleceń powłoki, które OpenChamber uruchamia w nowym worktree zaraz po jego utworzeniu, po kolei. Użyj `$ROOT_PROJECT_PATH` jako ścieżki do głównego checkoutu. `setupWorktreeWait: true` sprawia, że OpenChamber czeka na te polecenia, zanim uruchomi sesję w worktree. + +`projectActions` to lista akcji w menu nagłówka. `id`, `name` i `command` są wymagane. `icon` jest opcjonalna i domyślnie jest to ikona play; OpenChamber zna nazwy `play`, `build`, `lint`, `terminal`, `tools`, `bug`, `flask`, `rocket`, `code`, `server`, `branch`, `search`, `settings`, `brain`, `stack`, `robot`, `command` i `file`. `autoOpenUrl: true` otwiera adres wypisany przez polecenie, zobacz [Podgląd i serwery deweloperskie](/preview/). `platforms` ogranicza akcję do `macos`, `linux` lub `windows`. `runIn: "parent"` uruchamia akcję w głównym checkoucie zamiast w bieżącym worktree. + +`draftStarters` przypina polecenia i skille do ekranu nowej sesji. Każdy wpis ma postać `{ "type": "command" | "skill", "name": "..." }`, a samo polecenie lub skill musi istnieć w konfiguracji OpenCode tego repozytorium. + +`plansDir` to miejsce planów repozytorium. Pomiń go, aby używać `.openchamber/plans`. Zobacz niżej. + +Ten plik można pisać ręcznie. Klucz o złym kształcie unieważnia cały plik, a strona Projects mówi dlaczego, zamiast go po cichu zignorować. + +## Jak łączą się elementy z repozytorium i Twoje + +Najpierw uruchamiane są polecenia konfiguracji z repozytorium, potem Twoje. Zaznacz **Use only my setup commands** w sekcji Worktree, aby całkowicie pominąć polecenia z repozytorium. + +Akcje są łączone po `id`. Akcja w Twoich ustawieniach o tym samym id co akcja z repozytorium zastępuje ją. Startery są łączone po nazwie. + +Flaga oczekiwania pochodzi z Twoich ustawień, jeśli ją ustawisz, w przeciwnym razie z repozytorium. + +## Zaufanie + +Polecenia konfiguracji i akcje z repozytorium uruchamiają się na Twoim komputerze, a `git pull` może je zmienić. Dlatego za pierwszym razem, gdy któreś z nich ma się uruchomić, OpenChamber pokazuje dokładną treść poleceń i pyta. **Trust and run** zapamiętuje odpowiedź w tej instancji. **Not this time** uruchamia tylko Twoje własne polecenia. + +Odpowiedź jest związana z samymi poleceniami. Gdy pull zmieni polecenie z repozytorium, pytanie wraca dla nowej treści. Odpowiedź możesz zapomnieć przyciskiem **reset trust** w sekcji Worktree ustawień projektu. + +Przeniesienie własnego polecenia do repozytorium liczy się jako zaufanie, bo właśnie je widziałeś. + +## Plany w repozytorium + +Plany z karty Plany też mogą żyć w repozytorium jako pliki Markdown. Domyślny folder to `.openchamber/plans`. Ustaw **Plans folder** w ustawieniach projektu, aby użyć innego folderu w repozytorium, na przykład `docs/plans`, jeśli zespół już trzyma tam plany. Własny folder całkowicie zastępuje domyślny: OpenChamber czyta i zapisuje tylko w nim, więc przy zmianie przenieś istniejące pliki samodzielnie. + +Każdy plik `.md` w tym folderze pojawia się w karcie Plany, także pliki zapisane przez inne narzędzia. Edycja w OpenChamber zapisuje plik tak, jak go wpisałeś. Plan przeniesiony do repozytorium zachowuje tożsamość, więc sesje, do których był dołączony, nadal go znajdują. + +## Powiązane + +- [Akcje projektu](/project-actions/) +- [Worktrees](/worktrees/) +- [Notatki, todo i plany projektu](/notes-todos-plans/) diff --git a/packages/docs/content/docs/project-actions.mdx b/packages/docs/content/docs/project-actions.mdx index da1ab42e..58eddc23 100644 --- a/packages/docs/content/docs/project-actions.mdx +++ b/packages/docs/content/docs/project-actions.mdx @@ -25,4 +25,6 @@ Turn on **auto-open URL** for an action that starts a server. OpenChamber watche ## Related +- [Repository config](/repository-config/) — keep actions and setup commands in the repository for the whole team + - [Preview & Dev Servers](/preview/) — open a running dev server inside OpenChamber diff --git a/packages/docs/content/docs/pt-br/project-actions.mdx b/packages/docs/content/docs/pt-br/project-actions.mdx index ad7bebbe..aabcb8ae 100644 --- a/packages/docs/content/docs/pt-br/project-actions.mdx +++ b/packages/docs/content/docs/pt-br/project-actions.mdx @@ -25,4 +25,6 @@ Ative **auto-open URL** para uma ação que inicia um servidor. O OpenChamber ob ## Relacionado +- [Configuração no repositório](/repository-config/) — guarde ações e comandos de configuração no repositório para toda a equipe + - [Preview e Servidores de Desenvolvimento](/pt-br/preview/) — abra um servidor de desenvolvimento em execução dentro do OpenChamber diff --git a/packages/docs/content/docs/pt-br/repository-config.mdx b/packages/docs/content/docs/pt-br/repository-config.mdx new file mode 100644 index 00000000..662aec44 --- /dev/null +++ b/packages/docs/content/docs/pt-br/repository-config.mdx @@ -0,0 +1,100 @@ +--- +title: Configuração no repositório +description: Guarde ações do projeto, comandos de configuração de worktree, iniciadores e planos no repositório para que todos que o baixarem os tenham. +--- + +# Configuração no repositório + +Ações do projeto, comandos de configuração de worktree e iniciadores de rascunho ficam por padrão nas suas próprias configurações do OpenChamber. Ninguém mais os vê. Se você quer que quem clonar o repositório tenha a mesma ação de servidor de desenvolvimento e o mesmo `bun install` em cada worktree novo, mova esses itens para o repositório. + +O OpenChamber os guarda em `.openchamber/project.json` na raiz do repositório. O arquivo só aparece quando você move o primeiro item para lá e some quando você tira o último. Faça commit dele como de qualquer outro arquivo. + +## O que vai para onde + +| Fica nas suas configurações | Pode ir para o repositório | +|---|---| +| Notas e tarefas | Ações do projeto | +| Tarefas agendadas | Comandos de configuração de worktree | +| Quais ações do repositório você ocultou para si | Iniciadores de rascunho (comandos e skills fixados) | +| Sua resposta de confiança para os comandos do repositório | Planos | + +Notas, tarefas e tarefas agendadas são suas. Nunca vão parar no repositório. + +## Mover um item + +Abra **Settings → Projects** e escolha o projeto. Cada ação e cada comando de configuração tem um botão **Move to repository**, e cada item que veio do repositório tem **Move to my settings**. Os iniciadores da tela de nova sessão mostram o mesmo par ao passar o mouse. Os planos têm isso em cada linha da aba Planos. + +Mover é só isso. O item sai de um lugar e chega no outro, nada é duplicado. + +Itens do repositório mostram o selo **In repo**. Ações do repositório também podem ser ocultadas do seu menu com **Hide for me**. Isso muda só o seu menu, não o arquivo. + +## O arquivo + +```json +{ + "version": 1, + "setupWorktree": [ + "bun install" + ], + "setupWorktreeWait": true, + "projectActions": [ + { + "id": "dev", + "name": "Dev server", + "command": "bun run dev", + "icon": "rocket", + "autoOpenUrl": true, + "platforms": ["macos", "linux"] + }, + { + "id": "test", + "name": "Tests", + "command": "bun test" + } + ], + "draftStarters": [ + { "type": "skill", "name": "triage-prs" } + ], + "plansDir": "docs/plans" +} +``` + +Só `version` é obrigatório. Todas as outras chaves são opcionais, e o OpenChamber grava apenas as que têm algo. + +`setupWorktree` é a lista de comandos de shell que o OpenChamber roda dentro de um worktree novo logo depois de criá-lo, em ordem. Use `$ROOT_PROJECT_PATH` para o caminho do checkout principal. `setupWorktreeWait: true` faz o OpenChamber esperar esses comandos antes de iniciar uma sessão no worktree. + +`projectActions` é a lista de ações do menu do cabeçalho. `id`, `name` e `command` são obrigatórios. `icon` é opcional e cai no ícone de play; os nomes que o OpenChamber conhece são `play`, `build`, `lint`, `terminal`, `tools`, `bug`, `flask`, `rocket`, `code`, `server`, `branch`, `search`, `settings`, `brain`, `stack`, `robot`, `command` e `file`. `autoOpenUrl: true` abre o endereço que o comando imprime, veja [Pré-visualização e servidores de desenvolvimento](/preview/). `platforms` limita a ação a `macos`, `linux` ou `windows`. `runIn: "parent"` roda a ação no checkout principal em vez do worktree atual. + +`draftStarters` fixa comandos e skills na tela de nova sessão. Cada entrada é `{ "type": "command" | "skill", "name": "..." }`, e o comando ou skill precisa existir na configuração do OpenCode do repositório. + +`plansDir` é onde ficam os planos do repositório. Omita para usar `.openchamber/plans`. Veja abaixo. + +Você pode escrever esse arquivo à mão. Uma chave com formato errado invalida o arquivo inteiro, e a página Projects diz o motivo em vez de ignorar em silêncio. + +## Como itens do repositório e os seus se combinam + +Os comandos de configuração do repositório rodam primeiro, depois os seus. Marque **Use only my setup commands** na seção Worktree para pular por completo os comandos do repositório. + +As ações são combinadas por `id`. Uma ação nas suas configurações com o mesmo id de uma ação do repositório a substitui. Iniciadores são combinados por nome. + +A marca de espera vem das suas configurações quando você a definiu, senão do repositório. + +## Confiança + +Comandos de configuração e ações do repositório rodam na sua máquina, e um `git pull` pode mudá-los. Por isso, na primeira vez que um deles está prestes a rodar, o OpenChamber mostra os comandos exatos e pergunta. **Trust and run** guarda sua resposta nesta instância. **Not this time** roda só os seus próprios comandos. + +A resposta fica presa aos comandos em si. Quando um pull muda um comando do repositório, a pergunta volta para o texto novo. Você pode esquecer a resposta com **reset trust** na seção Worktree das configurações do projeto. + +Mover um comando seu para o repositório conta como confiar nele, já que você acabou de vê-lo. + +## Planos no repositório + +Os planos da aba Planos também podem ficar no repositório, como arquivos Markdown. A pasta padrão é `.openchamber/plans`. Defina **Plans folder** nas configurações do projeto para usar outra pasta dentro do repositório, por exemplo `docs/plans` se a equipe já guarda planos ali. Uma pasta própria substitui a padrão por completo: o OpenChamber lê e grava só nessa pasta, então mova você mesmo os arquivos existentes ao trocar. + +Todo arquivo `.md` dessa pasta aparece na aba Planos, inclusive os escritos por outras ferramentas. Editar um deles no OpenChamber salva o arquivo como você digitou. Um plano que você move para o repositório mantém a identidade, então as sessões que o tinham anexado continuam encontrando. + +## Relacionado + +- [Ações do projeto](/project-actions/) +- [Worktrees](/worktrees/) +- [Notas, tarefas e planos do projeto](/notes-todos-plans/) diff --git a/packages/docs/content/docs/repository-config.mdx b/packages/docs/content/docs/repository-config.mdx new file mode 100644 index 00000000..7c905567 --- /dev/null +++ b/packages/docs/content/docs/repository-config.mdx @@ -0,0 +1,100 @@ +--- +title: Repository config +description: Keep project actions, worktree setup commands, starters, and plans in the repository so everyone who pulls it gets them. +--- + +# Repository config + +Project actions, worktree setup commands, and draft starters live in your own OpenChamber settings by default. Nobody else sees them. If you want a teammate who clones the repository to get the same dev server action and the same `bun install` on every new worktree, move those items into the repository. + +OpenChamber stores them in `.openchamber/project.json` at the repository root. The file appears only when you move the first item there, and it goes away again when you move the last one out. Commit it like any other file. + +## What goes where + +| Stays in your settings | Can move to the repository | +|---|---| +| Notes and todos | Project actions | +| Scheduled tasks | Worktree setup commands | +| Which repository action is hidden for you | Draft starters (pinned commands and skills) | +| Your trust answer for repository commands | Plans | + +Notes, todos, and scheduled tasks are yours. They never end up in the repository. + +## Moving an item + +Open **Settings → Projects** and pick the project. Every action and setup command has a **Move to repository** button, and every item that came from the repository has **Move to my settings**. Starters on the new session screen show the same pair on hover. Plans have it on each row of the Plans tab. + +A move is just that. The item leaves one place and lands in the other, so nothing is duplicated. + +Items from the repository show an **In repo** badge. Repository actions can also be hidden from your menu with **Hide for me**. That only changes your menu, not the file. + +## The file + +```json +{ + "version": 1, + "setupWorktree": [ + "bun install" + ], + "setupWorktreeWait": true, + "projectActions": [ + { + "id": "dev", + "name": "Dev server", + "command": "bun run dev", + "icon": "rocket", + "autoOpenUrl": true, + "platforms": ["macos", "linux"] + }, + { + "id": "test", + "name": "Tests", + "command": "bun test" + } + ], + "draftStarters": [ + { "type": "skill", "name": "triage-prs" } + ], + "plansDir": "docs/plans" +} +``` + +Only `version` is required. Every other key is optional, and OpenChamber writes only the keys that carry something. + +`setupWorktree` is the list of shell commands OpenChamber runs inside a new worktree right after creating it, in order. Use `$ROOT_PROJECT_PATH` for the main checkout's path. `setupWorktreeWait: true` makes OpenChamber wait for these commands before it starts a session in the worktree. + +`projectActions` is the list of actions in the header menu. `id`, `name`, and `command` are required. `icon` is optional and falls back to a play icon; the names OpenChamber knows are `play`, `build`, `lint`, `terminal`, `tools`, `bug`, `flask`, `rocket`, `code`, `server`, `branch`, `search`, `settings`, `brain`, `stack`, `robot`, `command`, and `file`. `autoOpenUrl: true` opens the address the command prints, see [Preview & Dev Servers](/preview/). `platforms` limits the action to `macos`, `linux`, or `windows`. `runIn: "parent"` runs the action in the main checkout instead of the current worktree. + +`draftStarters` pins commands and skills to the new session screen. Each entry is `{ "type": "command" | "skill", "name": "..." }`, and the command or skill itself has to exist in the repository's OpenCode config. + +`plansDir` is where repository plans live. Leave it out to use `.openchamber/plans`. See below. + +You can write this file by hand. A key with the wrong shape makes the whole file invalid, and the Projects page tells you why instead of silently ignoring it. + +## How repository and personal items combine + +Repository setup commands run first, then your own. Tick **Use only my setup commands** in the Worktree section to skip the repository's commands altogether. + +Actions are merged by `id`. An action in your settings with the same id as a repository action replaces it. Starters are merged by name. + +The wait flag comes from your settings when you have set it, otherwise from the repository. + +## Trust + +Setup commands and actions from the repository run on your machine, and a `git pull` can change them. So the first time one of them is about to run, OpenChamber shows the exact commands and asks. **Trust and run** remembers your answer on this instance. **Not this time** runs only your own commands. + +The answer is tied to the commands themselves. When a pull changes a repository command, the question comes back for the new text. You can forget the answer with **reset trust** in the Worktree section of the project's settings. + +Moving your own command into the repository counts as trusting it, since you have just seen it. + +## Plans in the repository + +Plans on the Plans tab can also live in the repository, as Markdown files. The folder is `.openchamber/plans` by default. Set **Plans folder** in the project's settings to use another folder inside the repository, for example `docs/plans` if your team already keeps plans there. A custom folder replaces the default completely: OpenChamber reads and writes only that folder, so move existing files yourself when you change it. + +Every `.md` file in that folder shows on the Plans tab, including files written by other tools. Editing one in OpenChamber saves the file as you typed it. A plan you move into the repository keeps its identity, so sessions that had it attached still find it. + +## Related + +- [Project Actions](/project-actions/) +- [Worktrees](/worktrees/) +- [Project Notes, Todos & Plans](/notes-todos-plans/) diff --git a/packages/docs/content/docs/tr/project-actions.mdx b/packages/docs/content/docs/tr/project-actions.mdx index 1d5f5bc6..9796e7c0 100644 --- a/packages/docs/content/docs/tr/project-actions.mdx +++ b/packages/docs/content/docs/tr/project-actions.mdx @@ -25,4 +25,6 @@ Sunucu başlatan bir eylem için **auto-open URL** seçeneğini açın. OpenCham ## İlgili +- [Depo yapılandırması](/repository-config/) — eylemleri ve kurulum komutlarını tüm ekip için depoda tutun + - [Preview & Dev Servers](/preview/) — çalışan bir geliştirme sunucusunu OpenChamber içinde açın diff --git a/packages/docs/content/docs/tr/repository-config.mdx b/packages/docs/content/docs/tr/repository-config.mdx new file mode 100644 index 00000000..f965f6f0 --- /dev/null +++ b/packages/docs/content/docs/tr/repository-config.mdx @@ -0,0 +1,100 @@ +--- +title: Depo yapılandırması +description: Proje eylemlerini, worktree kurulum komutlarını, başlatıcıları ve planları depoda tutun; depoyu çeken herkes aynısını alsın. +--- + +# Depo yapılandırması + +Proje eylemleri, worktree kurulum komutları ve taslak başlatıcıları varsayılan olarak kendi OpenChamber ayarlarında yaşar. Başka kimse görmez. Depoyu klonlayan bir ekip arkadaşının aynı geliştirme sunucusu eylemini ve her yeni worktree'de aynı `bun install` komutunu almasını istiyorsan, bu öğeleri depoya taşı. + +OpenChamber bunları deponun kökündeki `.openchamber/project.json` dosyasında saklar. Dosya, ilk öğeyi oraya taşıdığında ortaya çıkar ve son öğeyi geri aldığında kaybolur. Diğer dosyalar gibi commit'le. + +## Ne nereye gider + +| Ayarlarında kalır | Depoya taşınabilir | +|---|---| +| Notlar ve yapılacaklar | Proje eylemleri | +| Zamanlanmış görevler | Worktree kurulum komutları | +| Kendin için gizlediğin depo eylemleri | Taslak başlatıcıları (sabitlenmiş komutlar ve skill'ler) | +| Depo komutları için güven yanıtın | Planlar | + +Notlar, yapılacaklar ve zamanlanmış görevler senindir. Asla depoya girmez. + +## Bir öğeyi taşıma + +**Settings → Projects** bölümünü aç ve projeyi seç. Her eylemin ve her kurulum komutunun bir **Move to repository** düğmesi, depodan gelen her öğenin de **Move to my settings** düğmesi vardır. Yeni oturum ekranındaki başlatıcılar üzerine gelince aynı ikiliyi gösterir. Planlarda bu, Planlar sekmesindeki her satırda bulunur. + +Taşımak tam olarak taşımaktır. Öğe bir yerden çıkar, diğerine gider; hiçbir şey çoğaltılmaz. + +Depodan gelen öğeler **In repo** rozeti taşır. Depo eylemleri **Hide for me** ile menünden gizlenebilir. Bu yalnızca senin menünü değiştirir, dosyayı değil. + +## Dosya + +```json +{ + "version": 1, + "setupWorktree": [ + "bun install" + ], + "setupWorktreeWait": true, + "projectActions": [ + { + "id": "dev", + "name": "Dev server", + "command": "bun run dev", + "icon": "rocket", + "autoOpenUrl": true, + "platforms": ["macos", "linux"] + }, + { + "id": "test", + "name": "Tests", + "command": "bun test" + } + ], + "draftStarters": [ + { "type": "skill", "name": "triage-prs" } + ], + "plansDir": "docs/plans" +} +``` + +Yalnızca `version` zorunludur. Diğer tüm anahtarlar isteğe bağlıdır ve OpenChamber yalnızca içinde bir şey olanları yazar. + +`setupWorktree`, OpenChamber'ın yeni bir worktree oluşturduktan hemen sonra içinde sırayla çalıştırdığı kabuk komutlarının listesidir. Ana checkout yolu için `$ROOT_PROJECT_PATH` kullan. `setupWorktreeWait: true`, OpenChamber'ın worktree'de oturum başlatmadan önce bu komutları beklemesini sağlar. + +`projectActions`, başlık menüsündeki eylemlerin listesidir. `id`, `name` ve `command` zorunludur. `icon` isteğe bağlıdır ve verilmezse play simgesi kullanılır; OpenChamber'ın bildiği adlar `play`, `build`, `lint`, `terminal`, `tools`, `bug`, `flask`, `rocket`, `code`, `server`, `branch`, `search`, `settings`, `brain`, `stack`, `robot`, `command` ve `file`. `autoOpenUrl: true`, komutun yazdırdığı adresi açar; bkz. [Önizleme ve geliştirme sunucuları](/preview/). `platforms`, eylemi `macos`, `linux` veya `windows` ile sınırlar. `runIn: "parent"`, eylemi geçerli worktree yerine ana checkout'ta çalıştırır. + +`draftStarters`, komutları ve skill'leri yeni oturum ekranına sabitler. Her giriş `{ "type": "command" | "skill", "name": "..." }` biçimindedir ve komutun ya da skill'in kendisi deponun OpenCode yapılandırmasında bulunmalıdır. + +`plansDir`, depo planlarının bulunduğu yerdir. `.openchamber/plans` kullanmak için atla. Aşağıya bak. + +Bu dosyayı elle yazabilirsin. Yanlış biçimli bir anahtar tüm dosyayı geçersiz kılar ve Projects sayfası sessizce yok saymak yerine nedenini söyler. + +## Depo öğeleriyle kendi öğelerin nasıl birleşir + +Önce depodaki kurulum komutları, sonra seninkiler çalışır. Depodakileri tamamen atlamak için Worktree bölümünde **Use only my setup commands** kutusunu işaretle. + +Eylemler `id` ile birleştirilir. Ayarlarında depo eylemiyle aynı id'ye sahip bir eylem varsa onun yerine geçer. Başlatıcılar ada göre birleştirilir. + +Bekleme bayrağı, ayarladıysan senin ayarlarından, yoksa depodan gelir. + +## Güven + +Depodaki kurulum komutları ve eylemler senin makinende çalışır ve bir `git pull` bunları değiştirebilir. Bu yüzden biri ilk kez çalışmak üzereyken OpenChamber komutları olduğu gibi gösterir ve sorar. **Trust and run**, yanıtını bu örnekte hatırlar. **Not this time** yalnızca senin komutlarını çalıştırır. + +Yanıt komutların kendisine bağlıdır. Bir pull depodaki bir komutu değiştirdiğinde soru yeni metin için geri gelir. Proje ayarlarının Worktree bölümündeki **reset trust** ile yanıtı unutturabilirsin. + +Kendi komutunu depoya taşımak ona güvenmek sayılır; onu az önce gördün. + +## Depodaki planlar + +Planlar sekmesindeki planlar da Markdown dosyaları olarak depoda yaşayabilir. Varsayılan klasör `.openchamber/plans`'tır. Ekibin planları zaten `docs/plans` gibi bir yerde tutuyorsa, proje ayarlarındaki **Plans folder** ile depo içinde başka bir klasör belirle. Özel bir klasör varsayılanı tamamen değiştirir: OpenChamber yalnızca o klasörü okur ve yazar, bu yüzden değiştirdiğinde mevcut dosyaları kendin taşı. + +O klasördeki her `.md` dosyası Planlar sekmesinde görünür; başka araçlarla yazılanlar da dahil. OpenChamber'da düzenlemek dosyayı yazdığın gibi kaydeder. Depoya taşıdığın bir plan kimliğini korur, böylece onu eklemiş oturumlar onu bulmaya devam eder. + +## İlgili + +- [Proje işlemleri](/project-actions/) +- [Worktree'ler](/worktrees/) +- [Proje notları, yapılacaklar ve planlar](/notes-todos-plans/) diff --git a/packages/docs/content/docs/uk/project-actions.mdx b/packages/docs/content/docs/uk/project-actions.mdx index ca5aea38..335f3c73 100644 --- a/packages/docs/content/docs/uk/project-actions.mdx +++ b/packages/docs/content/docs/uk/project-actions.mdx @@ -25,4 +25,6 @@ description: Зберігайте команди, які часто запуск ## Пов'язане +- [Конфіг у репозиторії](/repository-config/) — тримайте дії й команди налаштування в репозиторії для всієї команди + - [Перегляд і dev-сервери](/uk/preview/) — відкрийте запущений dev-сервер усередині OpenChamber diff --git a/packages/docs/content/docs/uk/repository-config.mdx b/packages/docs/content/docs/uk/repository-config.mdx new file mode 100644 index 00000000..e46a2ede --- /dev/null +++ b/packages/docs/content/docs/uk/repository-config.mdx @@ -0,0 +1,100 @@ +--- +title: Конфіг у репозиторії +description: Тримайте дії проєкту, команди налаштування worktree, стартери й плани в репозиторії, щоб їх отримував кожен, хто його клонує. +--- + +# Конфіг у репозиторії + +Дії проєкту, команди налаштування worktree і стартери чернетки за замовчуванням живуть у ваших власних налаштуваннях OpenChamber. Ніхто інший їх не бачить. Якщо ви хочете, щоб колега, який клонує репозиторій, отримав ту саму дію для dev-сервера і той самий `bun install` у кожному новому worktree, перенесіть ці елементи в репозиторій. + +OpenChamber зберігає їх у файлі `.openchamber/project.json` у корені репозиторію. Файл з'являється лише тоді, коли ви переносите туди перший елемент, і зникає, коли забираєте останній. Комітьте його як звичайний файл. + +## Що де лежить + +| Лишається у ваших налаштуваннях | Можна перенести в репозиторій | +|---|---| +| Нотатки й todo | Дії проєкту | +| Заплановані задачі | Команди налаштування worktree | +| Які дії з репозиторію ви сховали для себе | Стартери чернетки (закріплені команди й скіли) | +| Ваша відповідь про довіру до команд із репозиторію | Плани | + +Нотатки, todo і заплановані задачі ваші. Вони ніколи не потрапляють у репозиторій. + +## Перенесення елемента + +Відкрийте **Settings → Projects** і виберіть проєкт. У кожної дії та команди налаштування є кнопка **Move to repository**, а в кожного елемента з репозиторію — **Move to my settings**. Стартери на екрані нової сесії показують ту саму пару при наведенні. У планів вона є в кожному рядку вкладки «Плани». + +Перенесення — це саме перенесення. Елемент зникає з одного місця і з'являється в іншому, нічого не дублюється. + +Елементи з репозиторію мають бейдж **In repo**. Дії з репозиторію можна сховати зі свого меню кнопкою **Hide for me**. Це змінює лише ваше меню, не файл. + +## Файл + +```json +{ + "version": 1, + "setupWorktree": [ + "bun install" + ], + "setupWorktreeWait": true, + "projectActions": [ + { + "id": "dev", + "name": "Dev server", + "command": "bun run dev", + "icon": "rocket", + "autoOpenUrl": true, + "platforms": ["macos", "linux"] + }, + { + "id": "test", + "name": "Tests", + "command": "bun test" + } + ], + "draftStarters": [ + { "type": "skill", "name": "triage-prs" } + ], + "plansDir": "docs/plans" +} +``` + +Обов'язковий лише `version`. Усі інші ключі необов'язкові, і OpenChamber записує тільки ті, що щось містять. + +`setupWorktree` — список shell-команд, які OpenChamber виконує всередині нового worktree одразу після його створення, по порядку. Використовуйте `$ROOT_PROJECT_PATH` для шляху до основного checkout. `setupWorktreeWait: true` змушує OpenChamber дочекатися цих команд, перш ніж запускати сесію у worktree. + +`projectActions` — список дій у меню заголовка. `id`, `name` і `command` обов'язкові. `icon` необов'язкова і за замовчуванням це іконка play; OpenChamber знає такі назви: `play`, `build`, `lint`, `terminal`, `tools`, `bug`, `flask`, `rocket`, `code`, `server`, `branch`, `search`, `settings`, `brain`, `stack`, `robot`, `command` і `file`. `autoOpenUrl: true` відкриває адресу, яку виводить команда, див. [Перегляд і dev-сервери](/preview/). `platforms` обмежує дію до `macos`, `linux` або `windows`. `runIn: "parent"` виконує дію в основному checkout, а не в поточному worktree. + +`draftStarters` закріплює команди й скіли на екрані нової сесії. Кожен запис має вигляд `{ "type": "command" | "skill", "name": "..." }`, а сама команда чи скіл мають існувати в конфігу OpenCode цього репозиторію. + +`plansDir` — де лежать плани репозиторію. Пропустіть, щоб використовувати `.openchamber/plans`. Див. нижче. + +Цей файл можна писати руками. Ключ неправильної форми робить увесь файл недійсним, і сторінка Projects каже чому, замість того щоб мовчки його проігнорувати. + +## Як поєднуються елементи з репозиторію і ваші + +Спершу виконуються команди налаштування з репозиторію, потім ваші. Позначте **Use only my setup commands** у секції Worktree, щоб узагалі пропустити команди з репозиторію. + +Дії зливаються за `id`. Дія у ваших налаштуваннях із таким самим id, як у репозиторії, замінює її. Стартери зливаються за назвою. + +Прапорець очікування береться з ваших налаштувань, якщо ви його задали, інакше з репозиторію. + +## Довіра + +Команди налаштування й дії з репозиторію виконуються на вашому комп'ютері, а `git pull` може їх змінити. Тому першого разу, коли одна з них ось-ось виконається, OpenChamber показує точний текст команд і питає. **Trust and run** запам'ятовує вашу відповідь на цьому інстансі. **Not this time** виконує лише ваші власні команди. + +Відповідь прив'язана до самих команд. Коли pull змінює команду з репозиторію, питання повертається для нового тексту. Забути відповідь можна кнопкою **reset trust** у секції Worktree в налаштуваннях проєкту. + +Перенесення власної команди в репозиторій рахується як довіра до неї, адже ви її щойно бачили. + +## Плани в репозиторії + +Плани з вкладки «Плани» теж можуть жити в репозиторії як файли Markdown. За замовчуванням це тека `.openchamber/plans`. Задайте **Plans folder** у налаштуваннях проєкту, щоб використати іншу теку всередині репозиторію, наприклад `docs/plans`, якщо команда вже тримає плани там. Своя тека повністю замінює типову: OpenChamber читає й пише лише в неї, тож при зміні перенесіть наявні файли самі. + +Кожен файл `.md` у цій теці з'являється на вкладці «Плани», включно з файлами, які написали інші інструменти. Редагування в OpenChamber зберігає файл так, як ви його набрали. План, перенесений у репозиторій, зберігає свою ідентичність, тож сесії, до яких він був прикріплений, і далі його знаходять. + +## Пов'язане + +- [Дії проєкту](/project-actions/) +- [Worktrees](/worktrees/) +- [Нотатки, todo і плани проєкту](/notes-todos-plans/) diff --git a/packages/docs/content/docs/zh-cn/environment.mdx b/packages/docs/content/docs/zh-cn/environment.mdx index 7deb22f7..138ebce5 100644 --- a/packages/docs/content/docs/zh-cn/environment.mdx +++ b/packages/docs/content/docs/zh-cn/environment.mdx @@ -25,6 +25,8 @@ OpenChamber web 服务器监听的地址。使用 `0.0.0.0` 可允许其他机 覆盖 OpenChamber 数据目录。默认是 `~/.config/openchamber`。 +OpenChamber 存储的所有内容都位于此目录下:设置、认证、项目配置、主题、计划和语音模型。在 1.23 之前使用自定义目录的实例会在首次启动时将 `projects`、`themes` 和 `speech-models` 文件夹从 `~/.config/openchamber` 复制到此目录;原文件夹保持不变,不会合并任何内容。 + ### `OPENCHAMBER_CHATS_DIR` 更改 OpenChamber 为无项目聊天创建的托管聊天目录的位置。默认是 `~/.config/openchamber/chats`。当 OpenChamber 和 OpenCode 以不同用户运行时,请设置为 OpenCode 服务器可读取的目录。现有聊天不会被移动。 diff --git a/packages/docs/content/docs/zh-cn/project-actions.mdx b/packages/docs/content/docs/zh-cn/project-actions.mdx index 8da9bf17..56fc8156 100644 --- a/packages/docs/content/docs/zh-cn/project-actions.mdx +++ b/packages/docs/content/docs/zh-cn/project-actions.mdx @@ -25,4 +25,6 @@ description: 保存你经常运行的命令,一键启动它们。 ## 相关内容 +- [仓库配置](/repository-config/) — 把操作和设置命令放进仓库,供整个团队使用 + - [预览与开发服务器](/zh-cn/preview/) — 在 OpenChamber 内部打开正在运行的开发服务器 diff --git a/packages/docs/content/docs/zh-cn/repository-config.mdx b/packages/docs/content/docs/zh-cn/repository-config.mdx new file mode 100644 index 00000000..bca63815 --- /dev/null +++ b/packages/docs/content/docs/zh-cn/repository-config.mdx @@ -0,0 +1,100 @@ +--- +title: 仓库配置 +description: 把项目操作、工作树设置命令、启动项和计划放进仓库,让拉取仓库的每个人都能获得。 +--- + +# 仓库配置 + +项目操作、工作树设置命令和草稿启动项默认保存在你自己的 OpenChamber 设置里。别人看不到它们。如果你希望克隆仓库的队友也拥有同样的开发服务器操作,以及每个新工作树里同样的 `bun install`,就把这些项目移到仓库中。 + +OpenChamber 把它们存放在仓库根目录的 `.openchamber/project.json` 里。只有当你把第一个项目移进去时这个文件才会出现,移出最后一个项目时它会消失。像提交其他文件一样提交它即可。 + +## 什么放在哪里 + +| 留在你的设置里 | 可以移到仓库 | +|---|---| +| 笔记和待办 | 项目操作 | +| 定时任务 | 工作树设置命令 | +| 你为自己隐藏了哪些仓库操作 | 草稿启动项(固定的命令和技能) | +| 你对仓库命令的信任回答 | 计划 | + +笔记、待办和定时任务是你的。它们永远不会进入仓库。 + +## 移动项目 + +打开 **Settings → Projects** 并选择项目。每个操作和每条设置命令都有 **Move to repository** 按钮,每个来自仓库的项目都有 **Move to my settings**。新会话界面上的启动项在悬停时显示同样的一对按钮。计划则在“计划”标签的每一行里。 + +移动就是移动。项目离开一处,落到另一处,不会产生副本。 + +来自仓库的项目带有 **In repo** 徽章。仓库操作还可以用 **Hide for me** 从你的菜单中隐藏。这只改变你的菜单,不改变文件。 + +## 文件 + +```json +{ + "version": 1, + "setupWorktree": [ + "bun install" + ], + "setupWorktreeWait": true, + "projectActions": [ + { + "id": "dev", + "name": "Dev server", + "command": "bun run dev", + "icon": "rocket", + "autoOpenUrl": true, + "platforms": ["macos", "linux"] + }, + { + "id": "test", + "name": "Tests", + "command": "bun test" + } + ], + "draftStarters": [ + { "type": "skill", "name": "triage-prs" } + ], + "plansDir": "docs/plans" +} +``` + +只有 `version` 是必填的。其余键都是可选的,OpenChamber 只写入有内容的键。 + +`setupWorktree` 是 OpenChamber 在创建新工作树后立即在其中按顺序运行的 shell 命令列表。主检出路径用 `$ROOT_PROJECT_PATH` 表示。`setupWorktreeWait: true` 会让 OpenChamber 等这些命令完成后再在工作树中启动会话。 + +`projectActions` 是头部菜单中的操作列表。`id`、`name` 和 `command` 是必填的。`icon` 可选,缺省时使用 play 图标;OpenChamber 认识的名称有 `play`、`build`、`lint`、`terminal`、`tools`、`bug`、`flask`、`rocket`、`code`、`server`、`branch`、`search`、`settings`、`brain`、`stack`、`robot`、`command` 和 `file`。`autoOpenUrl: true` 会打开命令输出的地址,见[预览与开发服务器](/preview/)。`platforms` 把操作限制在 `macos`、`linux` 或 `windows`。`runIn: "parent"` 在主检出而不是当前工作树中运行操作。 + +`draftStarters` 把命令和技能固定到新会话界面。每一项形如 `{ "type": "command" | "skill", "name": "..." }`,命令或技能本身必须存在于仓库的 OpenCode 配置中。 + +`plansDir` 是仓库计划所在的位置。省略则使用 `.openchamber/plans`。见下文。 + +这个文件可以手写。某个键的形状不对会使整个文件无效,Projects 页面会告诉你原因,而不是悄悄忽略它。 + +## 仓库项目与你自己的项目如何合并 + +先运行仓库的设置命令,再运行你自己的。在 Worktree 区域勾选 **Use only my setup commands** 可以完全跳过仓库的命令。 + +操作按 `id` 合并。你设置中与仓库操作 id 相同的操作会取代它。启动项按名称合并。 + +等待标志在你设置了时取你的值,否则取仓库的值。 + +## 信任 + +仓库中的设置命令和操作会在你的机器上运行,而一次 `git pull` 就可能改变它们。因此,当其中某一条第一次即将运行时,OpenChamber 会显示完整的命令并询问你。**Trust and run** 会在此实例上记住你的回答。**Not this time** 只运行你自己的命令。 + +回答与命令本身绑定。当 pull 改变了仓库中的命令,会针对新内容再次询问。你可以在项目设置的 Worktree 区域用 **reset trust** 忘记回答。 + +把你自己的命令移到仓库视为信任它,因为你刚刚看过它。 + +## 仓库中的计划 + +“计划”标签中的计划也可以作为 Markdown 文件放在仓库里。默认文件夹是 `.openchamber/plans`。如果团队已经把计划放在例如 `docs/plans` 中,可在项目设置里的 **Plans folder** 指定仓库内的另一个文件夹。自定义文件夹会完全替代默认值:OpenChamber 只读写该文件夹,所以更改时请自行移动现有文件。 + +该文件夹中的每个 `.md` 文件都会显示在“计划”标签中,包括其他工具写的文件。在 OpenChamber 中编辑会按你输入的内容原样保存文件。移到仓库的计划保持其身份,之前附加了它的会话仍能找到它。 + +## 相关内容 + +- [项目操作](/project-actions/) +- [工作树](/worktrees/) +- [项目笔记、待办与计划](/notes-todos-plans/) diff --git a/packages/docs/sidebar.config.json b/packages/docs/sidebar.config.json index 31546d75..a81ae647 100644 --- a/packages/docs/sidebar.config.json +++ b/packages/docs/sidebar.config.json @@ -224,6 +224,22 @@ "tr": "Proje işlemleri" } }, + { + "label": "Repository config", + "link": "/repository-config/", + "translations": { + "uk": "Конфіг у репозиторії", + "zh-CN": "仓库配置", + "es": "Configuración en el repositorio", + "pt-BR": "Configuração no repositório", + "ko": "저장소 설정", + "pl": "Konfiguracja w repozytorium", + "fr": "Configuration du dépôt", + "ja": "リポジトリ設定", + "de": "Repository-Konfiguration", + "tr": "Depo yapılandırması" + } + }, { "label": "Preview & Dev Servers", "link": "/preview/", diff --git a/packages/electron/README.md b/packages/electron/README.md index 6cc110f9..c4bcb7bd 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -19,6 +19,8 @@ The preload bridge exposes desktop-only APIs to the web UI through `window.__OPE | File | Purpose | |------|---------| | `main.mjs` | Electron main process, app lifecycle, windows, menus, deep links, native IPC handlers, updates, local server startup | +| `electron-host-probe.mjs` | Chromium direct-host probes, identity checks, attempt deadlines, and response cleanup | +| `host-probe-policy.mjs` | Selector fast attempt and unreachable-only retry policy | | `startup-url-selection.mjs` | Pure bundled/HMR startup probe and loopback connection-limit policy | | `preload.mjs` | Safe bridge from the rendered UI to Electron IPC | | `ssh-manager.mjs` | SSH host import, connection lifecycle, tunnel/port forwarding helpers | @@ -33,6 +35,29 @@ The preload bridge exposes desktop-only APIs to the web UI through `window.__OPE ## Development +### Direct-host probe invariants + +After app readiness, direct-host probes use Chromium `net.fetch`, not Node fetch. +Each attempt shares one deadline across optional `/health` identity verification, +`/api/version`, and `/auth/session`, including JSON body reads. The fast attempt +has a 2-second budget. The selector retries once with a 10-second budget only +after Unreachable. Reported latency is the final attempt's application-probe +duration, excluding an earlier failed attempt. It is not raw network ping. + +Probes never follow redirects. A redirected identity check returns Wrong Service +before any bearer-bearing request. An explicit server ID mismatch also stops the +probe. Electron 43 reports a manual redirect as a rejected fetch rather than a +3xx response; the identity gate handles both forms. Identity requests carry +neither the client token nor custom headers; +version and session requests use sanitized custom headers and the client bearer +token. Older servers without identity metadata remain supported. HTTP 401 and +403 mean authentication is required, not that the instance is offline. + +Every exit aborts the attempt's requests and cancels unused response bodies before +clearing the deadline timer. This includes early HTTP classifications and a +successful session response whose body is not needed. TLS verification remains +enabled. These rules do not change relay probing or the preload/IPC contract. + From the repo root: ```bash @@ -96,7 +121,7 @@ Running a packaged Linux AppImage requires FUSE (`libfuse.so.2`, typically `libf Desktop clears AppImage `ARGV0` from `process.env` before probing the login shell and starting the in-process server. Leaving it set makes zsh rewrite argv[0] for integrated-terminal and managed-OpenCode child commands to the AppImage path. -Linux updates are supported only when the packaged app is running from a writable AppImage. Update checks, downloads, and installation report an actionable error when `APPIMAGE` is missing, invalid, or read-only; a missing release feed (`latest-linux.yml` 404 before the first Linux publish) is treated as “no update available”. macOS and Windows updater behavior is unchanged. Release builds keep `latest-linux.yml` (x64) and `latest-linux-arm64.yml` separate and validate each manifest against its AppImage before upload. Linux AppImages download full updates (no `.blockmap` differential channel yet). +Linux updates are supported only when the packaged app is running from a writable AppImage. Update checks, downloads, and installation report an actionable error when `APPIMAGE` is missing, invalid, or read-only; a missing release feed (`latest-linux.yml` 404 before the first Linux publish) is treated as “no update available”. Authenticated Web clients connected to the embedded Desktop Host use this same `electron-updater` check, download, and restart flow rather than a package-manager command. macOS and Windows updater behavior is unchanged. Release builds keep `latest-linux.yml` (x64) and `latest-linux-arm64.yml` separate and validate each manifest against its AppImage before upload. Linux AppImages download full updates (no `.blockmap` differential channel yet). `desktop_restart` does not answer the renderer before the install is decided. On the apply-update path it calls `quitAndInstall()` and keeps the IPC call open until the app quits or `autoUpdater` emits `error`, which the platform installers do asynchronously (a rejected code signature, or a Squirrel session disabled by an earlier failure). A failed install rejects the IPC call so the update dialog can show it, and the quit/install flags are rolled back because the app is staying up. A still-running app after the grace period resolves the call. @@ -145,6 +170,10 @@ Use an explicit override when testing a different OpenCode CLI build or when a u ## Native Features Owned Here - Floating Mini Chat windows. +- Mini Chat loads from the resolved local UI origin in HMR development, not the + API server origin. Bundled mode keeps `openchamber-ui://` assets. Native zoom + targets the focused window directly; composer focus adjusts interface scale, + while terminal and file-editor focus adjust their own font sizes. - New Mini Chat windows default to the managed Chats target. Explicit project/worktree drafts retain their target, existing managed chat sessions reopen in their own directory, and the compact header omits project/branch metadata for Chats. Opening a managed draft back in the main window preserves that target. - Multiple native windows. - Native notifications. diff --git a/packages/electron/electron-host-probe-server.test.mjs b/packages/electron/electron-host-probe-server.test.mjs new file mode 100644 index 00000000..e16f3e91 --- /dev/null +++ b/packages/electron/electron-host-probe-server.test.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import test from 'node:test'; +import { probeElectronHostWithDeadline } from './electron-host-probe.mjs'; + +const version = { status: 'ok', compatibility: { capabilities: ['api.runtime-url.v1'], apiVersion: 1, minClientApiVersion: 1 } }; + +const serve = async (t, handler) => { + const server = createServer(handler); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + t.after(() => { server.closeAllConnections(); server.close(); }); + return `http://127.0.0.1:${server.address().port}`; +}; +const probe = (url, options = {}) => probeElectronHostWithDeadline({ + url, timeoutMs: 150, chromiumFetch: fetch, isReady: () => true, ...options, +}); + +test('redirected identity cannot authorize the candidate or receive credentials', async (t) => { + let targetCalls = 0; + let credentialCalls = 0; + const target = await serve(t, (_req, res) => { targetCalls++; res.end(JSON.stringify({ serverId: 'expected' })); }); + const candidate = await serve(t, (req, res) => { + if (req.headers.authorization) credentialCalls++; + res.writeHead(302, { Location: `${target}/health` }); + res.end(); + }); + const result = await probe(candidate, { expectedServerId: 'expected', clientToken: 'fixture-only' }); + assert.equal(result.status, 'wrong-service'); + assert.equal(targetCalls, 0); + assert.equal(credentialCalls, 0); +}); + +for (const endpoint of ['/health', '/api/version']) { + test(`deadline aborts a stalled ${endpoint} body`, async (t) => { + let closed; + const bodyClosed = new Promise((resolve) => { closed = resolve; }); + const url = await serve(t, (req, res) => { + if (req.url === endpoint) { + res.on('close', closed); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.write('{'); + } else res.end(JSON.stringify({ serverId: 'expected' })); + }); + const result = await probe(url, { expectedServerId: endpoint === '/health' ? 'expected' : '' }); + assert.equal(result.status, 'unreachable'); + await bodyClosed; + }); +} + +for (const status of [200, 401, 403, 500]) { + test(`disposes unused session body after ${status} headers`, async (t) => { + let closed; + const bodyClosed = new Promise((resolve) => { closed = resolve; }); + const url = await serve(t, (req, res) => { + if (req.url === '/api/version') return res.end(JSON.stringify(version)); + res.on('close', closed); + res.writeHead(status); + res.write('unused'); + }); + const result = await probe(url, { timeoutMs: 1000 }); + assert.equal(result.status, status === 200 ? 'ok' : status === 500 ? 'unreachable' : 'auth'); + await bodyClosed; + }); +} + +test('readiness false starts no requests', async () => { + let calls = 0; + const result = await probe('https://instance.example', { + isReady: () => false, + chromiumFetch: () => { calls++; throw new Error('must not run'); }, + }); + assert.equal(result.status, 'unreachable'); + assert.equal(calls, 0); +}); diff --git a/packages/electron/electron-host-probe.mjs b/packages/electron/electron-host-probe.mjs new file mode 100644 index 00000000..3b6a035d --- /dev/null +++ b/packages/electron/electron-host-probe.mjs @@ -0,0 +1,134 @@ +import { sanitizeRuntimeRequestHeaders } from './runtime-request-headers.mjs'; +import { z } from 'zod'; + +const optionalIdentity = z.string().catch('').transform((value) => value.trim()); +const versionEnvelope = z.object({ + status: z.literal('ok'), + // Arrays historically classify as incompatible rather than wrong-service. + compatibility: z.union([z.looseObject({}), z.array(z.unknown())]), +}); + +const buildProbeUrl = (url, pathname) => { + try { + const parsed = new URL(url); + parsed.pathname = `${parsed.pathname.replace(/\/$/, '') || ''}${pathname}`; + return parsed.toString(); + } catch { + return null; + } +}; + +const classifyVersionPayload = (payload) => { + const parsed = versionEnvelope.safeParse(payload); + if (!parsed.success) { + return 'wrong-service'; + } + const { compatibility } = parsed.data; + if (!Array.isArray(compatibility.capabilities) || !compatibility.capabilities.includes('api.runtime-url.v1')) { + return 'incompatible'; + } + if (compatibility.apiVersion !== 1 || compatibility.minClientApiVersion > 1) { + return 'update-recommended'; + } + return 'ok'; +}; + +export const probeElectronHostWithDeadline = async ({ + url, + timeoutMs, + clientToken = '', + requestHeaders = {}, + expectedServerId = '', + chromiumFetch, + isReady, + now = Date.now, + scheduleTimeout = setTimeout, + cancelTimeout = clearTimeout, +}) => { + const started = now(); + const result = (status) => ({ status, latencyMs: now() - started }); + if (!isReady()) return result('unreachable'); + + const versionUrl = buildProbeUrl(url, '/api/version'); + const sessionUrl = buildProbeUrl(url, '/auth/session'); + if (!versionUrl || !sessionUrl) throw new Error('Invalid URL'); + + const controller = new AbortController(); + let rejectDeadline; + const deadline = new Promise((_, reject) => { + rejectDeadline = reject; + }); + const timer = scheduleTimeout(() => { + controller.abort(); + rejectDeadline(new Error('Host probe deadline exceeded')); + }, timeoutMs); + + const responses = new Set(); + const discardBody = async (response) => { + if (response.body && !response.body.locked) await response.body.cancel().catch(() => {}); + }; + const fetchProbe = async (requestUrl, headers) => { + controller.signal.throwIfAborted(); + const response = await chromiumFetch(requestUrl, { + headers, + signal: controller.signal, + redirect: 'manual', + }); + if (controller.signal.aborted) { + await discardBody(response); + controller.signal.throwIfAborted(); + } + responses.add(response); + return response; + }; + + const run = async () => { + const expectedIdentity = optionalIdentity.parse(expectedServerId); + if (expectedIdentity) { + const healthUrl = buildProbeUrl(url, '/health'); + if (healthUrl) { + try { + const response = await fetchProbe(healthUrl, { Accept: 'application/json' }); + // A redirected identity belongs to another candidate, even if its ID matches. + if (response.status >= 300 && response.status < 400 || response.redirected) return result('wrong-service'); + if (response.ok) { + const payload = await response.json().catch(() => null); + const reported = optionalIdentity.parse(payload?.serverId); + if (reported && reported !== expectedIdentity) return result('wrong-service'); + } + } catch (error) { + if (controller.signal.aborted) throw error; + // Electron 43 net.fetch rejects manual redirects instead of returning a 3xx response. + if (error instanceof Error && error.message === 'Redirect was cancelled') return result('wrong-service'); + // Identity is optional on older servers; the authenticated request remains authoritative. + } + } + } + + if (controller.signal.aborted) throw new Error('Host probe deadline exceeded'); + const headers = { ...sanitizeRuntimeRequestHeaders(requestHeaders), Accept: 'application/json' }; + const token = optionalIdentity.parse(clientToken); + if (token) headers.Authorization = `Bearer ${token}`; + + const versionResponse = await fetchProbe(versionUrl, headers); + if (versionResponse.status === 401 || versionResponse.status === 403) return result('auth'); + if (!versionResponse.ok) return result('unreachable'); + const versionStatus = classifyVersionPayload(await versionResponse.json().catch(() => null)); + if (versionStatus !== 'ok') return result(versionStatus); + + const sessionResponse = await fetchProbe(sessionUrl, headers); + if (sessionResponse.status === 401 || sessionResponse.status === 403) return result('auth'); + if (!sessionResponse.ok) return result('unreachable'); + return result('ok'); + }; + + try { + return await Promise.race([run(), deadline]); + } catch { + return result('unreachable'); + } finally { + controller.abort(); + await Promise.allSettled([...responses].map(discardBody)); + cancelTimeout(timer); + } +}; diff --git a/packages/electron/electron-host-probe.test.mjs b/packages/electron/electron-host-probe.test.mjs new file mode 100644 index 00000000..ab959e40 --- /dev/null +++ b/packages/electron/electron-host-probe.test.mjs @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { probeElectronHostWithDeadline } from './electron-host-probe.mjs'; + +const response = (status, payload = null) => ({ + status, + ok: status >= 200 && status < 300, + json: async () => payload, +}); + +const compatibleVersion = { + status: 'ok', + compatibility: { + capabilities: ['api.runtime-url.v1'], + apiVersion: 1, + minClientApiVersion: 1, + }, +}; + +const baseProbe = (overrides = {}) => probeElectronHostWithDeadline({ + url: 'https://instance.example', + timeoutMs: 2_000, + chromiumFetch: async (url) => url.endsWith('/api/version') + ? response(200, compatibleVersion) + : response(200, {}), + isReady: () => true, + ...overrides, +}); + +test('uses the Chromium transport as the authoritative ready-state transport', async () => { + const calls = []; + const result = await baseProbe({ + chromiumFetch: async (url) => { + calls.push(url); + return url.endsWith('/api/version') ? response(200, compatibleVersion) : response(200, {}); + }, + }); + + assert.equal(result.status, 'ok'); + assert.deepEqual(calls.map((url) => new URL(url).pathname), ['/api/version', '/auth/session']); +}); + +test('shares one absolute deadline across version and session requests', async () => { + let expire; + let scheduled = 0; + let clock = 0; + const signals = []; + const resultPromise = baseProbe({ + now: () => clock, + scheduleTimeout: (callback) => { + scheduled += 1; + expire = callback; + return 1; + }, + cancelTimeout: () => {}, + chromiumFetch: async (url, options) => { + signals.push(options.signal); + if (url.endsWith('/api/version')) return response(200, compatibleVersion); + return new Promise((_, reject) => options.signal.addEventListener('abort', () => reject(new Error('aborted')))); + }, + }); + + await Promise.resolve(); + await Promise.resolve(); + clock = 2_000; + expire(); + const result = await resultPromise; + assert.equal(scheduled, 1); + assert.equal(new Set(signals).size, 1); + assert.deepEqual(result, { status: 'unreachable', latencyMs: 2_000 }); +}); + +test('checks unauthenticated identity before sending sanitized bearer headers', async () => { + const calls = []; + const result = await baseProbe({ + expectedServerId: 'server-a', + clientToken: 'secret-token', + requestHeaders: { 'X-Instance': 'remote', Authorization: 'attacker' }, + chromiumFetch: async (url, options) => { + calls.push({ path: new URL(url).pathname, headers: options.headers }); + if (url.endsWith('/health')) return response(200, { serverId: 'server-a' }); + if (url.endsWith('/api/version')) return response(200, compatibleVersion); + return response(200, {}); + }, + }); + + assert.equal(result.status, 'ok'); + assert.deepEqual(calls.map((call) => call.path), ['/health', '/api/version', '/auth/session']); + assert.equal(calls[0].headers.Authorization, undefined); + assert.equal(calls[1].headers.Authorization, 'Bearer secret-token'); + assert.equal(calls[1].headers['X-Instance'], 'remote'); +}); + +for (const [name, versionResponse, expected] of [ + ['401 auth', response(401), 'auth'], + ['403 auth', response(403), 'auth'], + ['wrong service', response(200, {}), 'wrong-service'], + ['null payload', response(200, null), 'wrong-service'], + ['string compatibility', response(200, { status: 'ok', compatibility: 'yes' }), 'wrong-service'], + ['boolean compatibility', response(200, { status: 'ok', compatibility: true }), 'wrong-service'], + ['numeric compatibility', response(200, { status: 'ok', compatibility: 1 }), 'wrong-service'], + ['array compatibility', response(200, { status: 'ok', compatibility: [] }), 'incompatible'], + ['missing capability', response(200, { ...compatibleVersion, compatibility: { ...compatibleVersion.compatibility, capabilities: [] } }), 'incompatible'], + ['newer API', response(200, { ...compatibleVersion, compatibility: { ...compatibleVersion.compatibility, apiVersion: 2 } }), 'update-recommended'], + ['newer minimum client', response(200, { ...compatibleVersion, compatibility: { ...compatibleVersion.compatibility, minClientApiVersion: 2 } }), 'update-recommended'], +]) { + test(`preserves authoritative ${name} classification`, async () => { + const result = await baseProbe({ chromiumFetch: async () => versionResponse }); + assert.equal(result.status, expected); + }); +} + +test('rejects an explicit identity mismatch before bearer-bearing requests', async () => { + let calls = 0; + const result = await baseProbe({ + expectedServerId: 'server-a', + clientToken: 'secret-token', + chromiumFetch: async () => { + calls += 1; + return response(200, { serverId: 'server-b' }); + }, + }); + assert.equal(result.status, 'wrong-service'); + assert.equal(calls, 1); +}); + +test('rejects Electron manual-redirect errors before bearer-bearing requests', async () => { + let calls = 0; + const result = await baseProbe({ + expectedServerId: 'expected', + clientToken: 'fixture-only', + chromiumFetch: async (_url, options) => { + calls++; + assert.equal(options.redirect, 'manual'); + assert.equal(options.headers.Authorization, undefined); + throw new Error('Redirect was cancelled'); + }, + }); + assert.equal(result.status, 'wrong-service'); + assert.equal(calls, 1); +}); + +for (const serverId of [undefined, null, 123, true, {}, [], '', ' ', ' server-a ']) { + test(`preserves optional health identity parsing for ${JSON.stringify(serverId)}`, async () => { + const result = await baseProbe({ + expectedServerId: ' server-a ', + chromiumFetch: async (url) => url.endsWith('/health') + ? response(200, { serverId }) + : url.endsWith('/api/version') ? response(200, compatibleVersion) : response(200), + }); + assert.equal(result.status, 'ok'); + }); +} + +test('malformed version JSON remains wrong-service', async () => { + const result = await baseProbe({ chromiumFetch: async () => ({ + ...response(200), json: async () => { throw new SyntaxError('invalid fixture JSON'); }, + }) }); + assert.equal(result.status, 'wrong-service'); +}); + +test('non-string expected identity and token retain upstream ignore semantics', async () => { + const calls = []; + const result = await baseProbe({ + expectedServerId: 123, + clientToken: 123, + chromiumFetch: async (url, options) => { + calls.push(new URL(url).pathname); + assert.equal(options.headers.Authorization, undefined); + return url.endsWith('/api/version') ? response(200, compatibleVersion) : response(200); + }, + }); + assert.equal(result.status, 'ok'); + assert.deepEqual(calls, ['/api/version', '/auth/session']); +}); + +test('aborts requests and cancels unused bodies before clearing the timer', async () => { + const events = []; + const result = await baseProbe({ + scheduleTimeout: () => 1, + cancelTimeout: () => { events.push('timer-cleared'); }, + chromiumFetch: async (_url, { signal }) => { + signal.addEventListener('abort', () => events.push('aborted')); + return { + ...response(403), + body: { locked: false, cancel: async () => { events.push('body-cancelled'); } }, + }; + }, + }); + assert.equal(result.status, 'auth'); + assert.deepEqual(events, ['aborted', 'body-cancelled', 'timer-cleared']); +}); diff --git a/packages/electron/host-probe-policy.mjs b/packages/electron/host-probe-policy.mjs new file mode 100644 index 00000000..ee01026a --- /dev/null +++ b/packages/electron/host-probe-policy.mjs @@ -0,0 +1,10 @@ +export const FAST_HOST_PROBE_TIMEOUT_MS = 2_000; +export const RETRY_HOST_PROBE_TIMEOUT_MS = 10_000; + +export const probeDirectHostWithRetry = async (probe) => { + const fastResult = await probe(FAST_HOST_PROBE_TIMEOUT_MS); + if (fastResult.status !== 'unreachable') { + return fastResult; + } + return probe(RETRY_HOST_PROBE_TIMEOUT_MS); +}; diff --git a/packages/electron/host-probe-policy.test.mjs b/packages/electron/host-probe-policy.test.mjs new file mode 100644 index 00000000..25af5c80 --- /dev/null +++ b/packages/electron/host-probe-policy.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + FAST_HOST_PROBE_TIMEOUT_MS, + RETRY_HOST_PROBE_TIMEOUT_MS, + probeDirectHostWithRetry, +} from './host-probe-policy.mjs'; + +test('retries a fast unreachable direct-host probe with the slow timeout', async () => { + const timeouts = []; + const result = await probeDirectHostWithRetry(async (timeoutMs) => { + timeouts.push(timeoutMs); + return timeoutMs === FAST_HOST_PROBE_TIMEOUT_MS + ? { status: 'unreachable', latencyMs: timeoutMs } + : { status: 'ok', latencyMs: 2_500 }; + }); + + assert.deepEqual(timeouts, [FAST_HOST_PROBE_TIMEOUT_MS, RETRY_HOST_PROBE_TIMEOUT_MS]); + assert.deepEqual(result, { status: 'ok', latencyMs: 2_500 }); +}); + +for (const status of ['ok', 'auth', 'wrong-service', 'incompatible', 'update-recommended']) { + test(`does not retry an authoritative ${status} result`, async () => { + const timeouts = []; + const result = await probeDirectHostWithRetry(async (timeoutMs) => { + timeouts.push(timeoutMs); + return { status, latencyMs: 12 }; + }); + + assert.deepEqual(timeouts, [FAST_HOST_PROBE_TIMEOUT_MS]); + assert.equal(result.status, status); + }); +} + +test('stops after one unreachable retry', async () => { + const timeouts = []; + const result = await probeDirectHostWithRetry(async (timeoutMs) => { + timeouts.push(timeoutMs); + return { status: 'unreachable', latencyMs: timeoutMs }; + }); + assert.deepEqual(timeouts, [2_000, 10_000]); + assert.equal(result.status, 'unreachable'); +}); diff --git a/packages/electron/linux-app-discovery.mjs b/packages/electron/linux-app-discovery.mjs index c98947d4..8150c8fe 100644 --- a/packages/electron/linux-app-discovery.mjs +++ b/packages/electron/linux-app-discovery.mjs @@ -145,7 +145,11 @@ export const readLinuxDesktopEntries = async (options = {}) => { const desktopEntryMatchesApp = (entry, appName, appId = '') => { const needles = uniqueStrings([appName, appId]).flatMap((value) => [normalizeComparable(value), normalizeCompactComparable(value)]).filter(Boolean); const haystacks = [entry.name, entry.id, path.basename(entry.filePath || ''), entry.exec] - .flatMap((value) => [normalizeComparable(value), normalizeCompactComparable(value)]); + .flatMap((value) => [normalizeComparable(value), normalizeCompactComparable(value)]) + // A value with no ASCII letters or digits (e.g. a CJK-only Name) normalizes to the empty + // string, and needle.includes('') is true for every app — drop it so such entries can + // only match through a field that still carries comparable text. + .filter(Boolean); return needles.some((needle) => haystacks.some((haystack) => haystack === needle || haystack.includes(needle) || needle.includes(haystack))); }; diff --git a/packages/electron/linux-app-discovery.test.mjs b/packages/electron/linux-app-discovery.test.mjs new file mode 100644 index 00000000..d318f77f --- /dev/null +++ b/packages/electron/linux-app-discovery.test.mjs @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildLinuxOpenSpecs, + filterLinuxInstalledApps, + parseDesktopEntry, +} from './linux-app-discovery.mjs'; + +const NON_ASCII_ONLY_ENTRY = `[Desktop Entry] +Type=Application +Name=抖音 +Exec=/usr/bin/example --app-url=https://www.douyin.com/ +Icon=example`; + +const TEST_ENV = { PATH: '/nonexistent-openchamber-test-bin' }; + +const parseEntryAt = (content, id) => parseDesktopEntry(content, `/usr/share/applications/${id}.desktop`); + +test('still parses desktop entries whose Name has no ASCII letters or digits', () => { + const entry = parseEntryAt(NON_ASCII_ONLY_ENTRY, 'example'); + assert.ok(entry); + assert.equal(entry.name, '抖音'); + assert.equal(entry.exec, '/usr/bin/example --app-url=https://www.douyin.com/'); +}); + +test('a non-ASCII-only desktop entry does not mark every app as installed', async () => { + const entry = parseEntryAt(NON_ASCII_ONLY_ENTRY, 'example'); + const installed = await filterLinuxInstalledApps( + ['Visual Studio Code', 'Cursor', 'Sublime Text'], + { entries: [entry] }, + ); + assert.deepEqual(installed, []); +}); + +test('Open In specs never launch a non-ASCII-only entry for another app', () => { + const entry = parseEntryAt(NON_ASCII_ONLY_ENTRY, 'example'); + const specs = buildLinuxOpenSpecs({ + targetPath: '/tmp/project', + appId: 'vscode', + appName: 'Visual Studio Code', + entries: [entry], + env: TEST_ENV, + }); + assert.deepEqual(specs, []); +}); + +test('ASCII desktop entries still match their own app and build their own launch spec', async () => { + const entry = parseEntryAt(`[Desktop Entry] +Type=Application +Name=Visual Studio Code +Exec=/usr/bin/code %F +Icon=code`, 'code'); + const installed = await filterLinuxInstalledApps( + ['Visual Studio Code', 'Cursor'], + { entries: [entry] }, + ); + assert.deepEqual(installed, ['Visual Studio Code']); + + const specs = buildLinuxOpenSpecs({ + targetPath: '/tmp/project', + appId: 'vscode', + appName: 'Visual Studio Code', + entries: [entry], + env: TEST_ENV, + }); + assert.equal(specs.length, 1); + assert.equal(specs[0].program, '/usr/bin/code'); + assert.deepEqual(specs[0].args, ['/tmp/project']); +}); + +test('entries mixing ASCII and non-ASCII still match through their ASCII part', () => { + const entry = parseEntryAt(`[Desktop Entry] +Type=Application +Name=VSCode 抖音版 +Exec=/usr/local/bin/vscode-douyin %F +Icon=vscode-douyin`, 'vscode-douyin'); + const specs = buildLinuxOpenSpecs({ + targetPath: '/tmp/project', + appId: 'vscode', + appName: 'Visual Studio Code', + entries: [entry], + env: TEST_ENV, + }); + assert.equal(specs.length, 1); + assert.equal(specs[0].program, '/usr/local/bin/vscode-douyin'); +}); diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 0e7a922d..f65e2d90 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -16,6 +16,8 @@ import { createTrayController } from './tray.mjs'; import { resolveManagedOpenCodeCwd } from './opencode-cwd.mjs'; import { resolveStartupUrlProbePlan, shouldIgnoreLoopbackConnectionLimit } from './startup-url-selection.mjs'; import { sanitizeRuntimeRequestHeaders } from './runtime-request-headers.mjs'; +import { probeDirectHostWithRetry } from './host-probe-policy.mjs'; +import { probeElectronHostWithDeadline } from './electron-host-probe.mjs'; import { assertUpdaterCapability } from './updater-capability.mjs'; import { checkForDesktopUpdate } from './updater-check.mjs'; import { resolveUpdaterChannel } from './updater-channel.mjs'; @@ -37,6 +39,7 @@ import { createRelayDevTunnelBridge } from './relay-dev-tunnel.mjs'; import { attachRendererRecovery } from './renderer-recovery.mjs'; import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js'; import { fetchUpdateNotes } from '@openchamber/web/server/lib/changelog/update-notes.js'; +import { applyConnectAttemptTimeout } from '@openchamber/web/server/lib/network-defaults.js'; const execFileAsync = promisify(execFile); @@ -104,6 +107,11 @@ if (shouldIgnoreLoopbackConnectionLimit({ })) { app.commandLine.appendSwitch('ignore-connections-limit', '127.0.0.1,localhost'); } +// This process runs quota/provider fetches under Node/undici, whose happy-eyeballs +// default aborts each connect attempt after 250ms — distant provider endpoints +// routinely need longer handshakes, surfacing as "fetch failed" (#3399). No-op on +// runtimes without the setter. +applyConnectAttemptTimeout(); protocol.registerSchemesAsPrivileged([ { @@ -240,6 +248,9 @@ const GITHUB_FEATURE_REQUEST_URL = 'https://github.com/openchamber/openchamber/i const DISCORD_INVITE_URL = 'https://discord.gg/ZYRSdnwwKA'; const INSTALLED_APPS_CACHE_TTL_SECS = 60 * 60 * 24; const INSTALLED_APPS_CACHE_FILE = 'discovered-apps.json'; +// Bump when discovery results change shape or matching semantics change, so cached +// entries written by an older build are treated as stale and refresh immediately. +const INSTALLED_APPS_CACHE_VERSION = 2; const LINUX_DESKTOP_ENTRIES_CACHE_TTL_MS = 30_000; const OPENCODE_SHUTDOWN_GRACE_MS = 100; const { autoUpdater } = updaterPkg; @@ -247,6 +258,7 @@ const { autoUpdater } = updaterPkg; const state = { serverHandle: null, sidecarUrl: null, + localUiUrl: null, localOrigin: null, apiBaseUrl: null, clientToken: null, @@ -579,6 +591,30 @@ const readSettingsRoot = () => { return root && typeof root === 'object' && !Array.isArray(root) ? root : {}; }; +// The user's profile (theme mode among it) lives in preferences.json beside +// settings.json since the settings split; each entry is { value, updatedAt }. +// Installs that predate the split still carry those keys in settings.json, so +// readers merge both, preferences winning. +const readPreferencesValues = () => { + const root = readJsonFile(path.join(path.dirname(settingsFilePath()), 'preferences.json')); + const fields = root && typeof root === 'object' && root.version === 1 && root.fields && typeof root.fields === 'object' + ? root.fields + : {}; + // Per-surface keys (theme mode among them) are resolved for the desktop + // shell: its own value first, the base value otherwise. + const values = {}; + for (const [key, entry] of Object.entries(fields)) { + if (!entry || typeof entry !== 'object') continue; + const own = entry.surfaces && typeof entry.surfaces === 'object' ? entry.surfaces.desktop : undefined; + if (own && typeof own === 'object' && 'value' in own) { + values[key] = own.value; + } else if ('value' in entry) { + values[key] = entry.value; + } + } + return values; +}; + // Serializes read-modify-write of the settings file within this process. // Multiple call sites (spawnLocalServer, writeDesktopHostsConfig, theme // preference saves, ssh manager imports, etc.) would otherwise have their @@ -902,123 +938,16 @@ const buildHealthUrl = (url) => { } }; -const buildVersionUrl = (url) => { - try { - const parsed = new URL(url); - parsed.pathname = `${parsed.pathname.replace(/\/$/, '') || ''}/api/version`; - return parsed.toString(); - } catch { - return null; - } -}; - -const buildSessionStatusUrl = (url) => { - try { - const parsed = new URL(url); - parsed.pathname = `${parsed.pathname.replace(/\/$/, '') || ''}/auth/session`; - return parsed.toString(); - } catch { - return null; - } -}; - -const classifyVersionPayload = (payload) => { - const compatibility = payload?.compatibility; - if (!payload || payload.status !== 'ok' || !compatibility || typeof compatibility !== 'object') { - return 'wrong-service'; - } - - if (!Array.isArray(compatibility.capabilities) || !compatibility.capabilities.includes('api.runtime-url.v1')) { - return 'incompatible'; - } - - if (compatibility.apiVersion !== 1 || compatibility.minClientApiVersion > 1) { - return 'update-recommended'; - } - - return 'ok'; -}; - -const fetchVersionPayload = async (versionUrl, { headers, timeoutMs }) => { - const timeoutSignal = AbortSignal.timeout(timeoutMs); - try { - return await fetch(versionUrl, { signal: timeoutSignal, headers }); - } catch (error) { - if (timeoutSignal.aborted) { - throw error; - } - return await Promise.race([ - electronNet.fetch(versionUrl, { headers }), - new Promise((_, reject) => setTimeout(() => reject(error), timeoutMs)), - ]); - } -}; - const probeHostWithTimeout = async (url, timeoutMs, clientToken = '', requestHeaders = {}, expectedServerId = '') => { - const versionUrl = buildVersionUrl(url); - const sessionStatusUrl = buildSessionStatusUrl(url); - if (!versionUrl || !sessionStatusUrl) { - throw new Error('Invalid URL'); - } - - const started = Date.now(); - - // Identity gate for learned/untrusted addresses: verify the UNAUTHENTICATED - // /health identity before the token-carrying version fetch, so the bearer - // token is never sent to a re-assigned address that now belongs to a - // different machine. Older servers omit serverId from /health; only an - // explicit mismatch rejects. - if (typeof expectedServerId === 'string' && expectedServerId.trim()) { - const healthUrl = buildHealthUrl(url); - if (healthUrl) { - try { - const response = await fetch(healthUrl, { signal: AbortSignal.timeout(timeoutMs), headers: { Accept: 'application/json' } }); - if (response.ok) { - const payload = await response.json().catch(() => null); - const reported = typeof payload?.serverId === 'string' ? payload.serverId.trim() : ''; - if (reported && reported !== expectedServerId.trim()) { - return { status: 'wrong-service', latencyMs: Date.now() - started }; - } - } - } catch { - // Unreachable/timeout surfaces in the version fetch below. - } - } - } - - try { - const headers = { ...sanitizeRuntimeRequestHeaders(requestHeaders), Accept: 'application/json' }; - const token = typeof clientToken === 'string' ? clientToken.trim() : ''; - if (token) { - headers.Authorization = `Bearer ${token}`; - } - const response = await fetchVersionPayload(versionUrl, { headers, timeoutMs }); - const status = response.status; - if (status === 401 || status === 403) { - return { status: 'auth', latencyMs: Date.now() - started }; - } - if (status < 200 || status >= 300) { - return { status: 'unreachable', latencyMs: Date.now() - started }; - } - const payload = await response.json().catch(() => null); - const versionStatus = classifyVersionPayload(payload); - if (versionStatus !== 'ok') { - return { status: versionStatus, latencyMs: Date.now() - started }; - } - const sessionResponse = await fetchVersionPayload(sessionStatusUrl, { headers, timeoutMs }); - if (sessionResponse.status === 401 || sessionResponse.status === 403) { - return { status: 'auth', latencyMs: Date.now() - started }; - } - if (!sessionResponse.ok) { - return { status: 'unreachable', latencyMs: Date.now() - started }; - } - return { - status: versionStatus, - latencyMs: Date.now() - started, - }; - } catch { - return { status: 'unreachable', latencyMs: Date.now() - started }; - } + return probeElectronHostWithDeadline({ + url, + timeoutMs, + clientToken, + requestHeaders, + expectedServerId, + chromiumFetch: (requestUrl, options) => electronNet.fetch(requestUrl, options), + isReady: () => app.isReady(), + }); }; const resolveStoredClientTokenForUrl = (targetUrl, config = readDesktopHostsConfig()) => { @@ -1588,6 +1517,16 @@ const spawnLocalServer = async () => { apiBaseUrl: state.apiBaseUrl || '', requestHeaders: sanitizeRuntimeRequestHeaders(state.requestHeaders || {}), }), + desktopUpdater: { + check: () => handleInvoke(null, 'desktop_check_for_updates'), + install: async () => { + const updateInfo = await handleInvoke(null, 'desktop_check_for_updates'); + if (!updateInfo.available) return updateInfo; + await handleInvoke(null, 'desktop_download_and_install_update'); + return updateInfo; + }, + restart: () => handleInvoke(null, 'desktop_restart'), + }, }); const port = handle.getPort(); @@ -1784,12 +1723,24 @@ const computeBootOutcome = ({ envTargetUrl, probe, config, localAvailable }) => return { target: 'remote', status, hostId: host.id, url: host.apiUrl || host.url, ...availability }; }; +const readSplashColor = (settings, key, fallback) => { + // The renderer hands the colours over IPC (desktop_set_window_theme) and + // main stores them under `desktopSplashColors`; the flat `splash*` keys are + // what builds before the settings split wrote and are read as a fallback. + const owned = settings.desktopSplashColors && typeof settings.desktopSplashColors === 'object' + ? settings.desktopSplashColors[key] + : undefined; + const legacy = settings[`splash${key.charAt(0).toUpperCase()}${key.slice(1)}`]; + const value = typeof owned === 'string' ? owned : legacy; + return typeof value === 'string' && value.trim() ? value.trim() : fallback; +}; + const buildStartupSplashHtml = () => { const settings = readSettingsRoot(); - const splashBgLight = typeof settings.splashBgLight === 'string' ? settings.splashBgLight.trim() : '#f5f5f4'; - const splashFgLight = typeof settings.splashFgLight === 'string' ? settings.splashFgLight.trim() : '#1c1917'; - const splashBgDark = typeof settings.splashBgDark === 'string' ? settings.splashBgDark.trim() : '#0c0a09'; - const splashFgDark = typeof settings.splashFgDark === 'string' ? settings.splashFgDark.trim() : '#fafaf9'; + const splashBgLight = readSplashColor(settings, 'bgLight', '#f5f5f4'); + const splashFgLight = readSplashColor(settings, 'fgLight', '#1c1917'); + const splashBgDark = readSplashColor(settings, 'bgDark', '#0c0a09'); + const splashFgDark = readSplashColor(settings, 'fgDark', '#fafaf9'); return ` @@ -2358,6 +2309,13 @@ const getMenuTargetWindow = () => { const dispatchMenuAction = (action) => { const target = getMenuTargetWindow(); + // Zoom actions are consumed by the renderer's DOM listener. Sending them + // through both the IPC bridge and the DOM event would invoke the handler + // multiple times because preload fans the IPC event back into both paths. + if (action === 'zoom-in' || action === 'zoom-out' || action === 'zoom-reset') { + dispatchDomEventToWindow(target, 'openchamber:zoom', action); + return; + } emitToWindow(target, 'openchamber:menu-action', action); dispatchDomEventToWindow(target, 'openchamber:menu-action', action); }; @@ -2408,7 +2366,7 @@ const nextWindowLabel = () => { }; const readThemeSource = () => { - const settings = readSettingsRoot(); + const settings = { ...readSettingsRoot(), ...readPreferencesValues() }; // themeMode is the user's intent; themeVariant is only the resolved // concrete appearance at persist time. When mode === 'system', we must // follow the OS even if variant was saved as a specific value. @@ -2798,7 +2756,7 @@ const createAdditionalWindow = async (url, runtimeConfig = {}) => { const buildMiniChatUrl = ({ mode, sessionId, directory, projectId }) => { const base = shouldUsePackagedUi() ? buildPackagedUiUrl('/mini-chat.html') - : state.localOrigin || state.sidecarUrl; + : state.localUiUrl || state.localOrigin || state.sidecarUrl; if (!base) { throw new Error('Local UI is not available'); } @@ -3013,6 +2971,7 @@ const resolveInitialUrl = async () => { : localUrl; state.sidecarUrl = localUrl; + state.localUiUrl = localUiUrl; const localAvailable = Boolean(localUrl); const localOrigin = localUrl ? new URL(localUrl).origin : null; @@ -4386,11 +4345,13 @@ const handleInvoke = async (browserWindow, command, args = {}) => { } const cachedApps = Array.isArray(cache?.apps) ? cache.apps : []; const hasCache = Boolean(cache); - const isCacheStale = !cache || (now - Number(cache.updatedAt || 0)) > INSTALLED_APPS_CACHE_TTL_SECS; + const isCacheStale = !cache + || cache.version !== INSTALLED_APPS_CACHE_VERSION + || (now - Number(cache.updatedAt || 0)) > INSTALLED_APPS_CACHE_TTL_SECS; const refresh = async () => { const apps = await buildPlatformInstalledApps(Array.isArray(args.apps) ? args.apps : []); await fsp.mkdir(path.dirname(cachePath), { recursive: true }); - await fsp.writeFile(cachePath, JSON.stringify({ updatedAt: now, apps }, null, 2)); + await fsp.writeFile(cachePath, JSON.stringify({ version: INSTALLED_APPS_CACHE_VERSION, updatedAt: now, apps }, null, 2)); emitToAllWindows('openchamber:installed-apps-updated', apps); }; if (process.platform !== 'darwin' && process.platform !== 'win32' && process.platform !== 'linux') { @@ -4434,7 +4395,13 @@ const handleInvoke = async (browserWindow, command, args = {}) => { return getOrCreateDesktopInstallId(); case 'desktop_host_probe': - return probeHostWithTimeout(String(args.url || ''), 2_000, String(args.clientToken || ''), args.requestHeaders || {}, String(args.expectedServerId || '')); + return probeDirectHostWithRetry((timeoutMs) => probeHostWithTimeout( + String(args.url || ''), + timeoutMs, + String(args.clientToken || ''), + args.requestHeaders || {}, + String(args.expectedServerId || ''), + )); case 'desktop_remote_password_login': return loginRemoteAndIssueClientToken({ @@ -4447,6 +4414,21 @@ const handleInvoke = async (browserWindow, command, args = {}) => { case 'desktop_set_window_theme': { const mode = typeof args.themeMode === 'string' ? args.themeMode : ''; const variant = typeof args.themeVariant === 'string' ? args.themeVariant : ''; + const splash = args.splash && typeof args.splash === 'object' ? args.splash : null; + if (splash) { + const colors = {}; + for (const key of ['bgLight', 'fgLight', 'bgDark', 'fgDark']) { + if (typeof splash[key] === 'string' && splash[key].trim()) colors[key] = splash[key].trim(); + } + if (Object.keys(colors).length === 4) { + const current = readSettingsRoot().desktopSplashColors; + const unchanged = current && typeof current === 'object' + && ['bgLight', 'fgLight', 'bgDark', 'fgDark'].every((key) => current[key] === colors[key]); + if (!unchanged) { + void mutateSettingsRoot((root) => ({ ...root, desktopSplashColors: colors })); + } + } + } // Priority order: themeMode expresses the user's intent (including // "follow OS"). Variant is just the resolved variant at send time; // when mode === 'system' with variant === 'dark' (because OS is @@ -4891,6 +4873,10 @@ const buildMacMenu = () => { { role: 'minimize' }, { role: 'zoom' }, { type: 'separator' }, + { label: 'Zoom In', accelerator: 'CmdOrCtrl+=', click: () => dispatchAction('zoom-in') }, + { label: 'Zoom Out', accelerator: 'CmdOrCtrl+-', click: () => dispatchAction('zoom-out') }, + { label: 'Reset Zoom', accelerator: 'CmdOrCtrl+0', click: () => dispatchAction('zoom-reset') }, + { type: 'separator' }, { role: 'close' }, ], }, @@ -5004,6 +4990,9 @@ const buildAutoHiddenMenu = () => { label: 'Window', submenu: [ { role: 'minimize' }, + { label: 'Zoom In', accelerator: 'Ctrl+=', click: () => dispatchAction('zoom-in') }, + { label: 'Zoom Out', accelerator: 'Ctrl+-', click: () => dispatchAction('zoom-out') }, + { label: 'Reset Zoom', accelerator: 'Ctrl+0', click: () => dispatchAction('zoom-reset') }, { role: 'togglefullscreen' }, { type: 'separator' }, { role: 'close' }, diff --git a/packages/electron/package.json b/packages/electron/package.json index 19607a11..627f6aa9 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/electron", - "version": "1.22.2", + "version": "1.23.0", "private": true, "description": "Electron desktop runtime for OpenChamber", "author": "OpenChamber", @@ -10,7 +10,8 @@ "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", "electron-log": "^5.4.3", - "electron-updater": "^6.8.3" + "electron-updater": "^6.8.3", + "zod": "^4.3.6" }, "devDependencies": { "@electron/rebuild": "^4.2.0", diff --git a/packages/mobile/README.md b/packages/mobile/README.md index a06a96d8..da33229b 100644 --- a/packages/mobile/README.md +++ b/packages/mobile/README.md @@ -14,6 +14,7 @@ The mobile package reuses the web build, then rewrites `mobile.html` to `index.h - The tablet layout is a live size class (`useTabletLayout`), not a device check: any surface whose short side is at least 600px gets it, and the workspace only becomes a side panel where the width can host the sidebar, the panel and a readable chat at once. Book foldables therefore pick it up when unfolded, keep the portrait layout in both orientations (their long side is barely wider than a tablet's short one), and drop back to the phone layout when folded shut. The Android activity declares the matching `configChanges`, so folding resizes the WebView instead of recreating it. - Password-protected OpenChamber servers can be unlocked from the mobile app. The app stores the issued client token with the saved connection. - The Terminal workspace surface runs its PTY on the active OpenChamber server over the shared authenticated runtime transport; it never opens a local shell on the phone or tablet. Closing the surface detaches the renderer while the server session remains available for reattachment. On touch devices, dragging scrolls the buffer while long-pressing and dragging selects terminal text. +- The Changes workspace has a top-level Changes / Branch / Commit selector. Checkout, Sync, staging, and commit controls appear only under Changes; Branch and Commit show a read-only file list that opens one diff at a time. Their shared source pickers use bottom sheets on phones and anchored popovers on tablets. Commit lists the latest 50 commits of the checked-out branch. Closing the workspace preserves the current detail and suspends comparison reads; changing repository or instance resets navigation, and a new file link from chat opens the working-tree diff. ## Commands diff --git a/packages/ui/package.json b/packages/ui/package.json index 290a1c07..81a753f4 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,12 +1,13 @@ { "name": "@openchamber/ui", - "version": "1.22.2", + "version": "1.23.0", "private": true, "type": "module", "main": "src/main.tsx", "scripts": { "dev": "tsc --noEmit --watch", "build": "tsc --noEmit", + "build:ghostty-wasm": "bash scripts/build-libghostty-wasm.sh", "type-check": "tsc --noEmit", "lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js", "test": "node ../../scripts/run-isolated-tests.mjs src" @@ -43,9 +44,9 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@legendapp/list": "3.3.8", + "@legendapp/list": "3.3.10", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "1.18.29", + "@opencode-ai/sdk": "1.18.30", "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.4.0", "@simplewebauthn/browser": "13.3.0", @@ -61,7 +62,6 @@ "express": "^5.1.0", "fflate": "^0.8.3", "fuse.js": "^7.1.0", - "ghostty-web": "^0.4.0", "heic2any": "^0.0.4", "html-to-image": "^1.11.13", "http-proxy-middleware": "^3.0.5", diff --git a/packages/ui/scripts/build-libghostty-wasm.sh b/packages/ui/scripts/build-libghostty-wasm.sh new file mode 100755 index 00000000..15f15b91 --- /dev/null +++ b/packages/ui/scripts/build-libghostty-wasm.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# +# Rebuilds the vendored libghostty-vt WebAssembly artifact from the Ghostty +# revision pinned in src/lib/ghostty/vendor/VERSION, plus the PTY write +# trampoline whose bytes are embedded in src/lib/ghostty/runtime.ts. +# +# Usage: bun run --cwd packages/ui build:ghostty-wasm +# +# The build is reproducible: the same revision and Zig version produce a +# byte-identical ghostty-vt.wasm. Bump VERSION, run this script, and commit the +# new artifact together with any ABI changes in core.ts. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +UI_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +GHOSTTY_DIR="${UI_DIR}/src/lib/ghostty" +VENDOR_DIR="${GHOSTTY_DIR}/vendor" + +GHOSTTY_REVISION="$(tr -d '[:space:]' < "${VENDOR_DIR}/VERSION")" +CACHE_DIR="${OPENCHAMBER_GHOSTTY_CACHE:-${HOME}/.cache/openchamber-ghostty}" +GHOSTTY_SOURCE_DIR="${GHOSTTY_SOURCE_DIR:-${CACHE_DIR}/ghostty-${GHOSTTY_REVISION:0:8}}" +GHOSTTY_ZIG_VERSION="${GHOSTTY_ZIG_VERSION:-0.15.2}" +GHOSTTY_ZIG="${GHOSTTY_ZIG:-}" + +log() { + printf '[libghostty-vt-wasm] %s\n' "$*" +} + +die() { + printf '[libghostty-vt-wasm] error: %s\n' "$*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "missing required command: $1" +} + +ensure_zig() { + if [[ -n "${GHOSTTY_ZIG}" ]]; then + [[ -x "${GHOSTTY_ZIG}" ]] || die "GHOSTTY_ZIG is not executable: ${GHOSTTY_ZIG}" + return + fi + if command -v zig >/dev/null 2>&1 && [[ "$(zig version)" == "${GHOSTTY_ZIG_VERSION}" ]]; then + GHOSTTY_ZIG="$(command -v zig)" + return + fi + + local host_os host_arch zig_dir + host_os="$(uname -s | tr '[:upper:]' '[:lower:]')" + host_arch="$(uname -m)" + case "${host_os}" in + darwin) host_os="macos" ;; + linux) ;; + *) die "unsupported host OS for Zig download: ${host_os}" ;; + esac + case "${host_arch}" in + arm64) host_arch="aarch64" ;; + aarch64 | x86_64) ;; + *) die "unsupported host architecture: ${host_arch}" ;; + esac + + zig_dir="${CACHE_DIR}/zig-${GHOSTTY_ZIG_VERSION}" + GHOSTTY_ZIG="${zig_dir}/zig" + if [[ -x "${GHOSTTY_ZIG}" ]]; then + return + fi + + require_cmd curl + require_cmd tar + mkdir -p "${zig_dir}" + log "downloading Zig ${GHOSTTY_ZIG_VERSION}" + curl -fsSL \ + "https://ziglang.org/download/${GHOSTTY_ZIG_VERSION}/zig-${host_arch}-${host_os}-${GHOSTTY_ZIG_VERSION}.tar.xz" \ + | tar -xJ --strip-components=1 -C "${zig_dir}" +} + +# Zig 0.15.2 links its build runner against the macOS SDK's libSystem stub. +# SDKs shipped with Xcode 26.x and later list only `arm64e-macos` in that +# stub, which Zig rejects for an arm64 host, so every native link fails with +# "undefined symbol: _abort". The wasm target itself is unaffected. Work around +# it with a minimal SDK root whose stubs also declare `arm64-macos`, and an +# xcrun shim so Zig's SDK lookup lands on it. +ensure_macos_sdk_shim() { + [[ "$(uname -s)" == "Darwin" ]] || return 0 + local sdk_path + sdk_path="$(xcrun --sdk macosx --show-sdk-path 2>/dev/null || true)" + [[ -n "${sdk_path}" ]] || die "xcrun could not locate a macOS SDK; install the Command Line Tools" + if grep -q "arm64-macos" "${sdk_path}/usr/lib/libSystem.tbd" 2>/dev/null; then + return 0 + fi + + local shim_root="${CACHE_DIR}/sdk-shim" + local shim_sdk="${shim_root}/MacOSX.sdk" + rm -rf "${shim_root}" + mkdir -p "${shim_sdk}/usr/lib/system" "${shim_root}/bin" + cp "${sdk_path}"/SDKSettings.* "${shim_sdk}/" 2>/dev/null || true + ln -s "${sdk_path}/usr/include" "${shim_sdk}/usr/include" + cp "${sdk_path}"/usr/lib/*.tbd "${shim_sdk}/usr/lib/" + cp "${sdk_path}"/usr/lib/system/*.tbd "${shim_sdk}/usr/lib/system/" + local stub + for stub in "${shim_sdk}"/usr/lib/*.tbd "${shim_sdk}"/usr/lib/system/*.tbd; do + sed -i '' 's/arm64e-macos/arm64-macos, arm64e-macos/g' "${stub}" + done + cat > "${shim_root}/bin/xcrun" </dev/null || echo none)" + if [[ "${actual_revision}" != "${GHOSTTY_REVISION}" ]]; then + log "checking out Ghostty ${GHOSTTY_REVISION}" + git -C "${GHOSTTY_SOURCE_DIR}" fetch --depth=1 origin "${GHOSTTY_REVISION}" + git -C "${GHOSTTY_SOURCE_DIR}" checkout --detach "${GHOSTTY_REVISION}" + fi + + actual_revision="$(git -C "${GHOSTTY_SOURCE_DIR}" rev-parse HEAD)" + [[ "${actual_revision}" == "${GHOSTTY_REVISION}" ]] || \ + die "expected Ghostty ${GHOSTTY_REVISION}, found ${actual_revision}" +} + +ensure_zig +ensure_macos_sdk_shim +ensure_ghostty_source + +build_root="$(mktemp -d)" +trap 'rm -rf "${build_root}"' EXIT + +log "building ${GHOSTTY_REVISION} for wasm32-freestanding" +( + cd "${GHOSTTY_SOURCE_DIR}" + # The pinned revision rides along as semver build metadata so the artifact + # identifies its own provenance through ghostty_build_info(); VERSION stays + # the single source of truth for the pin and the ABI test checks the two agree. + "${GHOSTTY_ZIG}" build \ + -Demit-lib-vt \ + -Dtarget=wasm32-freestanding \ + -Doptimize=ReleaseSmall \ + -Dstrip=true \ + -Dlib-version-string="0.1.0-dev+${GHOSTTY_REVISION}" \ + -p "${build_root}" +) + +cp "${build_root}/bin/ghostty-vt.wasm" "${VENDOR_DIR}/ghostty-vt.wasm" +chmod 0644 "${VENDOR_DIR}/ghostty-vt.wasm" +log "wrote ${VENDOR_DIR}/ghostty-vt.wasm" + +"${GHOSTTY_ZIG}" build-exe \ + "${SCRIPT_DIR}/ghostty-write-pty.zig" \ + -target wasm32-freestanding \ + -O ReleaseSmall \ + -fno-entry \ + -rdynamic \ + -femit-bin="${build_root}/ghostty-write-pty.wasm" +log "PTY trampoline bytes for runtime.ts (WRITE_PTY_TRAMPOLINE):" +od -An -v -tu1 "${build_root}/ghostty-write-pty.wasm" | tr -s ' \n' ' ' | sed 's/^ //; s/ $//; s/ /, /g' +echo diff --git a/packages/ui/scripts/ghostty-write-pty.zig b/packages/ui/scripts/ghostty-write-pty.zig new file mode 100644 index 00000000..466524fe --- /dev/null +++ b/packages/ui/scripts/ghostty-write-pty.zig @@ -0,0 +1,12 @@ +// Callback trampoline for libghostty-vt's write-PTY option. +// +// libghostty-vt calls the PTY writer through its indirect function table, so +// the JavaScript host cannot pass a closure directly. This 112-byte module +// exports one function whose only job is to forward the call to an import the +// host implements. `build-libghostty-wasm.sh` compiles it and prints the bytes +// that `runtime.ts` embeds, so the browser never fetches it separately. +extern "env" fn openchamber_write_pty(terminal: u32, userdata: u32, data: u32, len: u32) void; + +export fn ghostty_write_pty(terminal: u32, userdata: u32, data: u32, len: u32) void { + openchamber_write_pty(terminal, userdata, data, len); +} diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 21581e28..1e1cf14e 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { MainLayout } from '@/components/layout/MainLayout'; import { ChatView } from '@/components/views/ChatView'; import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog'; +import { SharedTrustConfirmDialog } from '@/components/projects/SharedTrustConfirmDialog'; import { FireworksProvider } from '@/contexts/FireworksContext'; import { Toaster } from '@/components/ui/sonner'; import { Button } from '@/components/ui/button'; @@ -913,6 +914,7 @@ function App({ apis }: AppProps) { embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} /> + @@ -957,6 +959,7 @@ function App({ apis }: AppProps) { + {!isBootShell && ( <> diff --git a/packages/ui/src/apps/ElectronMiniChatApp.tsx b/packages/ui/src/apps/ElectronMiniChatApp.tsx index 4b59a36e..804d8cb7 100644 --- a/packages/ui/src/apps/ElectronMiniChatApp.tsx +++ b/packages/ui/src/apps/ElectronMiniChatApp.tsx @@ -6,6 +6,7 @@ import { TooltipProvider } from '@/components/ui/tooltip'; import { Toaster } from '@/components/ui/sonner'; import { MiniChatLayout } from '@/components/mini-chat/MiniChatLayout'; import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog'; +import { SharedTrustConfirmDialog } from '@/components/projects/SharedTrustConfirmDialog'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; import { useWindowTitle } from '@/hooks/useWindowTitle'; import { useRootScrollLock } from '@/hooks/useRootScrollLock'; @@ -329,6 +330,7 @@ export function ElectronMiniChatApp({ apis }: ElectronMiniChatAppProps) {
+
diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 0190da3f..4ecf947c 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -10,6 +10,7 @@ import { ChatView } from '@/components/views/ChatView'; import { PlanView } from '@/components/views/PlanView'; import { SettingsView } from '@/components/views/SettingsView'; import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog'; +import { SharedTrustConfirmDialog } from '@/components/projects/SharedTrustConfirmDialog'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider'; import { useAuthSessionStore } from '@/lib/runtime-auth-expiry'; @@ -83,8 +84,14 @@ const MOBILE_SETTINGS_PAGES = [ 'sessions', 'git', 'magic-prompts', + 'snippets', 'behavior', + 'agents', + 'commands', 'mcp', + 'plugins', + 'skills.installed', + 'skills.catalog', 'providers', 'usage', 'voice', @@ -295,6 +302,13 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc onRightEdgeSwipe: () => setWorkspaceOpen(true), }); + // Settings owns a drill-down of its own (nav → page list → item), so the + // hardware back button asks it to step up before the shell closes it. + const settingsBackRef = React.useRef<(() => boolean) | null>(null); + const registerSettingsBackHandler = React.useCallback((handler: (() => boolean) | null) => { + settingsBackRef.current = handler; + }, []); + // Top-most layer first: a plan or fullscreen surface can sit ABOVE a drawer // (opened from the drawer footer / workspace tabs), so they close before the // drawers underneath. @@ -303,6 +317,9 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc setOpenPlan(null); return true; } + if (activeSurface === 'settings' && settingsBackRef.current?.()) { + return true; + } if (activeSurface) { closeSurface(); return true; @@ -589,6 +606,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc forceMobile isWindowed initialMobileStage={settingsInitialMobileStage} + registerBackHandler={registerSettingsBackHandler} // About exists for server updates — meaningful in a browser // (hosted mobile), not in the Capacitor shell (store updates). visiblePageSlugs={MOBILE_SETTINGS_PAGES.filter( @@ -1288,6 +1306,7 @@ export function MobileApp({ apis }: MobileAppProps) { setConnectionEpoch((value) => value + 1); }} /> + {isInitialized ? : null} diff --git a/packages/ui/src/apps/MobileChangesSurface.test.tsx b/packages/ui/src/apps/MobileChangesSurface.test.tsx new file mode 100644 index 00000000..cc7bdd94 --- /dev/null +++ b/packages/ui/src/apps/MobileChangesSurface.test.tsx @@ -0,0 +1,194 @@ +import React, { act } from 'react'; +import { expect, test } from 'bun:test'; +import { Window } from 'happy-dom'; +import type { GitLogEntry, GitStatus } from '@/lib/api/types'; + +test('mobile comparisons drill into files, retry, resume, change source, and yield to external working diffs', async () => { + const dom = new Window({ url: 'http://localhost' }); + dom.happyDOM.setWindowSize({ width: 390, height: 844 }); + const originals = new Map(); + const globals = { + window: dom, document: dom.document, navigator: dom.navigator, location: dom.location, localStorage: dom.localStorage, + Element: dom.Element, HTMLElement: dom.HTMLElement, HTMLInputElement: dom.HTMLInputElement, Node: dom.Node, + customElements: dom.customElements, CSSStyleSheet: dom.CSSStyleSheet, + Event: dom.Event, CustomEvent: dom.CustomEvent, KeyboardEvent: dom.KeyboardEvent, MouseEvent: dom.MouseEvent, + MutationObserver: dom.MutationObserver, ResizeObserver: dom.ResizeObserver, + getComputedStyle: dom.getComputedStyle.bind(dom), requestAnimationFrame: dom.requestAnimationFrame.bind(dom), + cancelAnimationFrame: dom.cancelAnimationFrame.bind(dom), IS_REACT_ACT_ENVIRONMENT: true, + }; + for (const [name, value] of Object.entries(globals)) { + originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); + Object.defineProperty(globalThis, name, { configurable: true, writable: true, value }); + } + const commits: GitLogEntry[] = ['a', 'b'].map((letter) => ({ + hash: letter.repeat(40), date: '2026-09-09T09:22:00Z', message: `Commit ${letter}`, + refs: '', body: '', author_name: 'Test Author', author_email: 'test@example.com', + filesChanged: 1, insertions: 0, deletions: 0, parents: [], + })); + const requests: URL[] = []; + const originalFetch = globalThis.fetch; + let failBranchDiff = true; + globalThis.fetch = Object.assign(async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input), 'http://localhost'); + if (url.pathname === '/api/fs/home' || url.pathname === '/api/session-folders') return new Promise(() => {}); + requests.push(url); + switch (url.pathname) { + case '/api/git/remotes': return Response.json([]); + case '/api/git/remote-url': return Response.json({ url: null }); + case '/api/git/branch-base': return Response.json({ base: null }); + case '/api/git/range-files': return Response.json({ files: [{ path: url.searchParams.get('base') === 'refs/heads/parent' ? 'parent.png' : 'branch.png', status: 'M' }] }); + case '/api/git/range-diff': + return failBranchDiff + ? Response.json({ error: 'Branch diff failed' }, { status: 500 }) + : Response.json({ diff: 'Binary files a/branch.png and b/branch.png differ' }); + case '/api/git/log': return Response.json({ all: commits, latest: commits[0], total: commits.length }); + case '/api/git/commit-files': return Response.json({ files: [{ path: `commit-${url.searchParams.get('hash')?.[0]}.png`, previousPath: 'old.png', changeType: 'R', insertions: 0, deletions: 0, isBinary: true }] }); + case '/api/git/commit-diff': return Response.json({ diff: 'Binary files a/old.png and b/commit.png differ' }); + case '/api/git/file-diff': return Response.json({ path: 'working.png', original: '', modified: '', isBinary: true }); + default: throw new Error(`Unexpected request ${url.pathname}`); + } + }, originalFetch); + + const { createRoot } = await import('react-dom/client'); + const { I18nProvider } = await import('@/lib/i18n'); + const { RuntimeAPIContext } = await import('@/contexts/runtimeAPIContext'); + const { createWebAPIs } = await import('../../../web/src/api/index'); + const { useGitStore } = await import('@/stores/useGitStore'); + const { MobileChangesPane } = await import('./MobileChangesSurface'); + const apis = createWebAPIs(); + const status: GitStatus = { current: 'feature', tracking: null, ahead: 0, behind: 0, files: [], isClean: true, diffStats: {} }; + const seed = (directory: string, nextStatus = status) => { + useGitStore.getState().setActiveDirectory(directory); + const previous = useGitStore.getState().getDirectoryState(directory); + if (!previous) throw new Error('Missing repository state'); + const now = Date.now(); + const directories = new Map(useGitStore.getState().directories); + directories.set(directory, { + ...previous, status: nextStatus, isGitRepo: true, + branches: { all: ['feature', 'main', 'parent', 'remotes/origin/main'], current: 'feature', branches: {}, defaultBranches: { origin: 'main' } }, + log: { all: commits, latest: commits[0], total: 2 }, identity: { userName: 'Test Author', userEmail: 'test@example.com', sshCommand: null }, + lastStatusFetch: now, lastBranchesFetch: now, lastLogFetch: now, lastIdentityFetch: now, lastRepoCheckAt: now, + }); + useGitStore.setState({ directories }); + }; + seed('/repo'); + let directory = '/repo'; + let visible = true; + let initialDiff: { path: string; staged: boolean } | null = null; + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + const render = () => act(async () => { + root.render( + + ); + }); + const click = async (selector: string) => { + const element = document.querySelector(selector); + if (!element) throw new Error(`Missing ${selector}`); + await act(async () => { element.click(); }); + }; + const chooseMode = async (label: string) => { + await click('[aria-label="Select change mode"]'); + const option = [...document.querySelectorAll('[role="menuitemradio"]')].find((element) => element.textContent === label); + if (!option) throw new Error(`Missing mode ${label}`); + await act(async () => { option.click(); }); + }; + const openFile = async (path: string) => { + const button = container.querySelector(`[title="${path}"]`)?.closest('button'); + if (!button) throw new Error(`Missing file ${path}`); + await act(async () => { button.click(); }); + }; + const comparisonRequests = () => requests.filter((url) => /\/(range-files|range-diff|commit-files|commit-diff)$/.test(url.pathname)); + const checkoutControl = () => [...container.querySelectorAll('button')].find((button) => button.textContent?.trim() === 'feature'); + + try { + await render(); + const modeTrigger = container.querySelector('[aria-label="Select change mode"]'); + const syncButton = container.querySelector('[aria-label="Sync Changes"]'); + if (!modeTrigger || !syncButton) throw new Error('Missing Changes controls'); + expect(modeTrigger?.textContent).toBe('Changes'); + expect(container.querySelector('h2')).toBeNull(); + expect(checkoutControl()).toBeDefined(); + expect(syncButton).not.toBeNull(); + expect(modeTrigger?.closest('header')?.contains(syncButton)).toBe(false); + expect(modeTrigger && syncButton && (modeTrigger.compareDocumentPosition(syncButton) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + await chooseMode('Branch'); + expect(container.querySelector('[aria-label="Sync Changes"]')).toBeNull(); + expect(checkoutControl()).toBeUndefined(); + expect(container.textContent).toContain('Select a base branch'); + await click('[aria-label="Base branch"]'); + await click('[data-value="refs/heads/main"]'); + expect(container.querySelector('[title="branch.png"]')).not.toBeNull(); + await openFile('branch.png'); + expect(container.textContent).toContain('Branch diff failed'); + expect(requests.filter((url) => url.pathname === '/api/git/file-diff')).toHaveLength(0); + failBranchDiff = false; + const retry = [...container.querySelectorAll('button')].find((button) => button.textContent === 'Retry'); + if (!retry) throw new Error('Missing diff retry'); + await act(async () => { retry.click(); }); + expect(container.textContent).toContain('Content of this file cannot be viewed.'); + expect(container.textContent).toContain('Branch · main'); + + const beforeHide = comparisonRequests().length; + visible = false; + await render(); + await act(async () => { seed('/repo'); }); + expect(comparisonRequests()).toHaveLength(beforeHide); + visible = true; + await render(); + expect(container.querySelector('h2')?.textContent).toBe('branch.png'); + await click('[aria-label="Back"]'); + await click('[aria-label="Base branch"]'); + await click('[data-value="refs/heads/parent"]'); + expect(container.querySelector('[title="branch.png"]')).toBeNull(); + expect(container.querySelector('[title="parent.png"]')).not.toBeNull(); + + await chooseMode('Commit'); + expect(container.querySelector('[aria-label="Sync Changes"]')).toBeNull(); + expect(checkoutControl()).toBeUndefined(); + expect(container.querySelector('[title="commit-a.png"]')).not.toBeNull(); + await click('[aria-label="Select commit"]'); + await click(`[data-value="${'b'.repeat(40)}"]`); + expect(container.querySelector('[title="commit-a.png"]')).toBeNull(); + await openFile('commit-b.png'); + expect(container.textContent).toContain('Commit · bbbbbbbb'); + const commitRequest = [...requests].reverse().find((url) => url.pathname === '/api/git/commit-diff'); + expect(commitRequest?.searchParams.get('hash')).toBe('b'.repeat(40)); + expect(commitRequest?.searchParams.get('previousPath')).toBe('old.png'); + expect(requests.filter((url) => url.pathname === '/api/git/range-diff').every((url) => url.searchParams.get('includeWorkingTree') === 'true')).toBe(true); + + await act(async () => { seed('/repo', { ...status, isClean: false, files: [{ path: 'working.png', index: 'M', working_dir: ' ' }] }); }); + initialDiff = { path: 'working.png', staged: true }; + await render(); + expect(container.querySelector('h2')?.textContent).toBe('working.png'); + const workingRequest = [...requests].reverse().find((url) => url.pathname === '/api/git/file-diff'); + expect(workingRequest?.searchParams.get('staged')).toBe('true'); + await click('[aria-label="Back"]'); + expect(container.querySelector('[aria-label="Select change mode"]')?.textContent).toBe('Changes'); + expect(container.querySelector('[aria-label="Sync Changes"]')).not.toBeNull(); + expect(checkoutControl()).toBeDefined(); + + await chooseMode('Branch'); + initialDiff = { path: 'working.png', staged: true }; + await render(); + expect(container.querySelector('h2')?.textContent).toBe('working.png'); + + await act(async () => { seed('/repo-two'); }); + directory = '/repo-two'; + await render(); + expect(container.querySelector('[aria-label="Select change mode"]')?.textContent).toBe('Changes'); + expect(container.querySelector('h2')).toBeNull(); + expect(requests.some((url) => url.pathname === '/api/git/file-diff' && url.searchParams.get('directory') === '/repo-two')).toBe(false); + } finally { + await act(async () => root.unmount()); + globalThis.fetch = originalFetch; + await dom.happyDOM.abort(); + for (const [name, descriptor] of originals) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else Reflect.deleteProperty(globalThis, name); + } + } +}); diff --git a/packages/ui/src/apps/MobileChangesSurface.tsx b/packages/ui/src/apps/MobileChangesSurface.tsx index 50c9c7b3..62e6037b 100644 --- a/packages/ui/src/apps/MobileChangesSurface.tsx +++ b/packages/ui/src/apps/MobileChangesSurface.tsx @@ -3,9 +3,15 @@ import { Icon } from '@/components/icon/Icon'; import { toast } from '@/components/ui'; import { Button } from '@/components/ui/button'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { ChangesPanel, type ChangesGroupConfig } from '@/components/views/git/ChangesPanel'; import { BranchSelector } from '@/components/views/git/BranchSelector'; +import { BranchComparisonSelector } from '@/components/views/git/BranchComparisonSelector'; +import { CommitComparisonSelector } from '@/components/views/git/CommitComparisonSelector'; +import { branchRefLabel } from '@/components/views/git/baseBranch'; +import { isBranchScopeAvailable, isBranchScopeDefinitelyUnavailable, useRangeKeyedCache } from '@/components/views/branchDiffScope'; import { CommitSection } from '@/components/views/git/CommitSection'; import { DirtyBranchSwitchDialog } from '@/components/views/git/DirtyBranchSwitchDialog'; import { SyncActions } from '@/components/views/git/SyncActions'; @@ -13,6 +19,13 @@ import { PierreDiffViewer } from '@/components/views/PierreDiffViewer'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory'; +import { useBranchComparisonBase } from '@/hooks/useBranchComparisonBase'; +import { useCommitComparison } from '@/hooks/useCommitComparison'; +import { useGitComparison, type GitComparisonFile, type GitComparisonSource } from '@/hooks/useGitComparison'; +import { useGitBaseBranchStore } from '@/stores/useGitBaseBranchStore'; +import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; +import { fileDiffFromPatch, isBinaryPatch } from '@/lib/diff/patchFileDiff'; +import type { FileDiffMetadata } from '@pierre/diffs'; import type { GitStatus } from '@/lib/api/types'; import { useI18n } from '@/lib/i18n'; import { generateCommitMessage, stageGitFile, stageGitFiles, unstageGitFile, unstageGitFiles } from '@/lib/gitApi'; @@ -32,6 +45,29 @@ import { getRuntimeKey } from '@/lib/runtime-switch'; type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null; type CommitAction = 'commit' | 'commitAndPush' | null; +type ChangesMode = 'working' | 'branch' | 'commit'; +type ChangesRoute = + | { type: 'list' } + | { type: 'diff'; path: string; staged: boolean } + | { type: 'comparison'; path: string; sourceKey: string }; +interface ChangesNavigation { + ownerKey: string; + mode: ChangesMode; + route: ChangesRoute; +} +interface MobileDiffData { + original: string; + modified: string; + isBinary?: boolean; + fileDiff?: FileDiffMetadata; +} +type ComparisonDiff = + | { status: 'loading' } + | { status: 'ready'; diff: MobileDiffData } + | { status: 'error'; message: string }; +const LOADING_COMPARISON_DIFF: ComparisonDiff = { status: 'loading' }; +const LIST_ROUTE: ChangesRoute = { type: 'list' }; + const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/g, ''); const isStagedStatusFile = (file: GitStatus['files'][number]): boolean => { @@ -51,21 +87,32 @@ type MobileChangesSurfaceProps = { /** When provided, the list header gets a close X that calls this. */ onClose?: () => void; /** - * When set (and non-null), the surface opens directly into the per-file diff view for this - * relative path. Updating it (incl. setting it to a different path while open) routes the - * surface to that diff. Setting it back to null leaves the user on the current internal route. + * A new request object opens its working-tree diff, including repeated requests + * for the same path. Reopening the drawer keeps the same object and navigation. */ - initialDiffPath?: string | null; - initialDiffStaged?: boolean; + initialDiff?: { path: string; staged: boolean } | null; + /** The workspace drawer keeps visited panes mounted while hidden. */ + visible?: boolean; }; -export const MobileChangesSurface: React.FC = ({ onClose, initialDiffPath, initialDiffStaged = false }) => { +export const MobileChangesSurface: React.FC = (props) => { + const rootDirectory = normalizePath(useEffectiveDirectory() ?? null); + const repository = useNestedGitDirectory(rootDirectory || null, { enabled: props.visible ?? true }); + return ; +}; + +interface MobileChangesPaneProps extends MobileChangesSurfaceProps { + rootDirectory: string; + repository: ReturnType; +} + +/** Repository-scoped navigation and actions, separate from session directory resolution. */ +export const MobileChangesPane: React.FC = ({ rootDirectory, repository, onClose, initialDiff, visible = true }) => { const { t } = useI18n(); const { git } = useRuntimeAPIs(); - const rootDirectory = normalizePath(useEffectiveDirectory() ?? null); // When the root is not itself a repository, changes come from the resolved // nested repository instead. - const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(rootDirectory || null); + const { rootIsGitRepo, gitDirectory, nestedRepos } = repository; const currentDirectory = gitDirectory ?? rootDirectory; const status = useGitStatus(currentDirectory || null); const branches = useGitBranches(currentDirectory || null); @@ -81,21 +128,41 @@ export const MobileChangesSurface: React.FC = ({ onCl const getDiff = useGitStore((state) => state.getDiff); const setDiff = useGitStore((state) => state.setDiff); - const [route, setRoute] = React.useState<{ type: 'list' } | { type: 'diff'; path: string; staged: boolean }>( - () => (initialDiffPath ? { type: 'diff', path: initialDiffPath, staged: initialDiffStaged } : { type: 'list' }), - ); + const runtimeKey = useGitStore((state) => state.runtimeKey); + const ownerKey = JSON.stringify([runtimeKey, currentDirectory]); + const ownerKeyRef = React.useRef(ownerKey); + ownerKeyRef.current = ownerKey; + const [navigation, setNavigation] = React.useState(() => ({ + ownerKey, + mode: 'working', + route: initialDiff?.path ? { type: 'diff', path: initialDiff.path, staged: initialDiff.staged } : LIST_ROUTE, + })); + const mode = navigation.ownerKey === ownerKey ? navigation.mode : 'working'; + const route = navigation.ownerKey === ownerKey ? navigation.route : LIST_ROUTE; + const [modeMenuOpen, setModeMenuOpen] = React.useState(false); + const changeMode = React.useCallback((nextMode: ChangesMode) => { + setNavigation({ ownerKey, mode: nextMode, route: LIST_ROUTE }); + setModeMenuOpen(false); + }, [ownerKey]); + const setRoute = React.useCallback((nextRoute: ChangesRoute) => { + setNavigation((current) => ({ ownerKey, mode: current.ownerKey === ownerKey ? current.mode : 'working', route: nextRoute })); + }, [ownerKey]); + + React.useEffect(() => { + setNavigation((current) => current.ownerKey === ownerKey ? current : { ownerKey, mode: 'working', route: LIST_ROUTE }); + setModeMenuOpen(false); + }, [ownerKey]); + React.useEffect(() => { if (!visible) setModeMenuOpen(false); }, [visible]); // Allow the host (MobileApp) to push us into a specific diff when the surface // is reopened or when an external trigger (e.g. PendingChangesBar tap) requests // a different file mid-session. React.useEffect(() => { - if (!initialDiffPath) return; - setRoute((current) => ( - current.type === 'diff' && current.path === initialDiffPath && current.staged === initialDiffStaged - ? current - : { type: 'diff', path: initialDiffPath, staged: initialDiffStaged } - )); - }, [initialDiffPath, initialDiffStaged]); + if (!initialDiff?.path) return; + // A new external target is a working-tree diff, regardless of the last + // comparison mode. Changing directories alone must not replay this target. + setNavigation({ ownerKey: ownerKeyRef.current, mode: 'working', route: { type: 'diff', path: initialDiff.path, staged: initialDiff.staged } }); + }, [initialDiff]); const [syncAction, setSyncAction] = React.useState(null); const [commitAction, setCommitAction] = React.useState(null); const [commitMessage, setCommitMessage] = React.useState(''); @@ -110,6 +177,54 @@ export const MobileChangesSurface: React.FC = ({ onCl const [diffRetryNonce, setDiffRetryNonce] = React.useState(0); const [pendingDirtySwitchBranch, setPendingDirtySwitchBranch] = React.useState(null); + const currentBranch = status?.current ?? null; + const trackingRemote = status?.tracking?.trim().split('/')[0]; + const defaultBranch = (trackingRemote && branches?.defaultBranches?.[trackingRemote]) ?? branches?.defaultBranches?.origin ?? null; + const showBranchOption = isBranchScopeAvailable(currentBranch, defaultBranch); + const branchUnavailable = isGitRepo === false || isBranchScopeDefinitelyUnavailable(currentBranch, defaultBranch, status !== null, branches !== null); + const setBaseOverride = useGitBaseBranchStore((state) => state.setOverride); + const branchComparison = useBranchComparisonBase(currentDirectory || null, currentBranch, visible && mode === 'branch' && showBranchOption); + const commitComparison = useCommitComparison(currentDirectory || null, currentBranch, visible && mode === 'commit' && isGitRepo === true); + const selectedCommitHash = commitComparison.selectedCommit?.hash ?? null; + const comparisonSource = React.useMemo(() => { + if (mode === 'branch' && currentBranch && branchComparison.base) return { kind: 'branch', baseRef: branchComparison.base, headRef: currentBranch }; + if (mode === 'commit' && selectedCommitHash) return { kind: 'commit', hash: selectedCommitHash }; + return null; + }, [branchComparison.base, currentBranch, mode, selectedCommitHash]); + const comparisonRevision = mode === 'branch' ? branchComparison.revision : ''; + const comparison = useGitComparison(currentDirectory || null, comparisonSource, visible && isGitRepo === true, comparisonRevision); + const { fetchDiff: loadComparisonDiff } = comparison; + const comparisonFiles = React.useMemo(() => comparison.files ? [...comparison.files].sort((a, b) => a.path.localeCompare(b.path)) : null, [comparison.files]); + const activeComparisonPath = route.type === 'comparison' && route.sourceKey === comparison.key ? route.path : null; + const [comparisonRetry, setComparisonRetry] = React.useState(0); + const fetchComparisonDiff = React.useCallback(async (path: string): Promise => { + try { + const { diff: patch } = await loadComparisonDiff(path); + const diff: MobileDiffData = { original: '', modified: '', isBinary: isBinaryPatch(patch) }; + if (!diff.isBinary) diff.fileDiff = fileDiffFromPatch(path, patch); + return { status: 'ready', diff }; + } catch (error) { + return { status: 'error', message: error instanceof Error ? error.message : t('diffView.state.failedToLoadDiff') }; + } + }, [loadComparisonDiff, t]); + const comparisonDiffs = useRangeKeyedCache( + comparison.files ? comparison.key : null, + visible && activeComparisonPath ? activeComparisonPath : '', + visible ? fetchComparisonDiff : null, + LOADING_COMPARISON_DIFF, + JSON.stringify([comparisonRevision, comparisonRetry]), + ); + const activeComparisonDiff = activeComparisonPath ? comparisonDiffs.get(activeComparisonPath) ?? LOADING_COMPARISON_DIFF : null; + + React.useEffect(() => { + if (mode === 'branch' && branchUnavailable) changeMode('working'); + }, [branchUnavailable, changeMode, mode]); + React.useEffect(() => { + setNavigation((current) => current.ownerKey === ownerKey && current.route.type === 'comparison' && current.route.sourceKey !== comparison.key + ? { ...current, route: LIST_ROUTE } + : current); + }, [comparison.key, ownerKey]); + const changeEntries = React.useMemo(() => { const files = status?.files ?? []; const unique = new Map(); @@ -199,7 +314,7 @@ export const MobileChangesSurface: React.FC = ({ onCl const handleCreateBranch = React.useCallback(async (branch: string, remote?: GitRemote) => { if (!currentDirectory) return; try { - await git.createBranch(currentDirectory, branch, status?.current ?? 'HEAD'); + await git.createBranch(currentDirectory, branch, currentBranch ?? 'HEAD'); await git.checkoutBranch(currentDirectory, branch); if (remote) { await git.gitPush(currentDirectory, { remote: remote.name, branch, options: ['--set-upstream'] }); @@ -209,7 +324,7 @@ export const MobileChangesSurface: React.FC = ({ onCl toast.error(error instanceof Error ? error.message : t('gitView.toast.createBranchFailed')); throw error; } - }, [currentDirectory, git, refreshStatusAndBranches, status?.current, t]); + }, [currentBranch, currentDirectory, git, refreshStatusAndBranches, t]); const refreshRemotes = React.useCallback(async () => { if (!currentDirectory) { @@ -231,17 +346,17 @@ export const MobileChangesSurface: React.FC = ({ onCl }, [currentDirectory, git]); React.useEffect(() => { - if (!currentDirectory) return; + if (!currentDirectory || !visible) return; setActiveDirectory(currentDirectory); void ensureAll(currentDirectory, git); - }, [currentDirectory, ensureAll, git, setActiveDirectory]); + }, [currentDirectory, ensureAll, git, setActiveDirectory, visible]); React.useEffect(() => { - void refreshRemotes(); - }, [refreshRemotes]); + if (visible) void refreshRemotes(); + }, [refreshRemotes, visible]); React.useEffect(() => { - if (!currentDirectory || changeEntries.length === 0) return; + if (!visible || mode !== 'working' || !currentDirectory || changeEntries.length === 0) return; const orderedPaths = Array.from(new Set([ ...stagedChangeEntries.map((entry) => entry.path), ...visibleChangePaths, @@ -252,9 +367,10 @@ export const MobileChangesSurface: React.FC = ({ onCl void prefetchDiffs(currentDirectory, git, orderedPaths, { maxFiles: 40 }); }, 120); return () => window.clearTimeout(timeoutId); - }, [changeEntries, currentDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]); + }, [changeEntries, currentDirectory, git, mode, prefetchDiffs, stagedChangeEntries, visibleChangePaths, visible]); React.useEffect(() => { + if (!visible) return; if (route.type !== 'diff') { setDiffLoadError(null); return; @@ -285,7 +401,7 @@ export const MobileChangesSurface: React.FC = ({ onCl return () => { cancelled = true; }; - }, [currentDirectory, diffRetryNonce, getDiff, git, route, setDiff]); + }, [currentDirectory, diffRetryNonce, getDiff, git, route, setDiff, visible]); const handleSyncAction = async (action: Exclude, remote?: GitRemote) => { if (!currentDirectory) return; @@ -349,7 +465,7 @@ export const MobileChangesSurface: React.FC = ({ onCl const handleViewChangeDiff = React.useCallback((path: string, staged = false) => { setRoute({ type: 'diff', path, staged }); - }, []); + }, [setRoute]); const handleRevertFile = React.useCallback(async (filePath: string) => { if (!currentDirectory) return; @@ -580,9 +696,62 @@ export const MobileChangesSurface: React.FC = ({ onCl ); } + const modeLabel = mode === 'branch' ? t('diffView.scope.branch') : mode === 'commit' ? t('commitComparison.mode') : t('mobile.nav.changes'); + const sourceLabel = mode === 'branch' && branchComparison.base + ? branchRefLabel(branchComparison.base) + : mode === 'commit' ? selectedCommitHash?.slice(0, 8) : null; + if (activeComparisonPath && activeComparisonDiff) { + return ( + file.path === activeComparisonPath)} + error={comparison.error ?? (activeComparisonDiff.status === 'error' ? activeComparisonDiff.message : null)} + onBack={() => setRoute(LIST_ROUTE)} + onRetry={() => { + if (comparison.error) void comparison.refresh(); + setComparisonRetry((value) => value + 1); + }} + /> + ); + } + + const renderComparison = () => { + if (mode === 'branch' && !branchComparison.base) { + return ; + } + if (mode === 'commit' && !selectedCommitHash) { + return
+ {commitComparison.loading && } +

{commitComparison.loading ? t('diffView.state.loadingChanges') : commitComparison.error ?? t('commitComparison.noCommits')}

+ {commitComparison.error && } +
; + } + if (comparison.error) { + return
+

{t('diffView.state.failedToLoadDiff')}

+

{comparison.error}

+ +
; + } + if (!comparisonFiles) return ; + if (comparisonFiles.length === 0) { + return ; + } + return { + if (comparison.key) setRoute({ type: 'comparison', path, sourceKey: comparison.key }); + }} />; + }; + return (
-
+
{onClose ? ( ) : null} -
-

{t('mobile.nav.changes')}

- void handleCheckoutBranch(branch)} - onCreate={handleCreateBranch} + + + + + + { + if (value === 'working' || value === 'branch' || value === 'commit') changeMode(value); + }}> + {t('mobile.nav.changes')} + {showBranchOption && {t('diffView.scope.branch')}} + {t('commitComparison.mode')} + + + + {visible && mode === 'branch' && ( + { if (currentBranch) setBaseOverride(currentDirectory, currentBranch, base); }} /> + )} + {visible && mode === 'commit' && ( + void commitComparison.refresh()} /> + )} +
+ {mode === 'working' && ( +
+
+ void handleCheckoutBranch(branch)} + onCreate={handleCreateBranch} + remotes={effectiveRemotes} + disabled={isLoadingStatus} + directory={currentDirectory} + switchBlockedNotice={(status?.files?.length ?? 0) > 0 ? t('gitView.branch.switchBlockedNotice') : null} + /> +
+ 0 ? t('gitView.branch.switchBlockedNotice') : null} + onFetch={(remote) => void handleSyncAction('fetch', remote)} + onSync={(remote) => void handleSyncAction('sync', remote)} + disabled={commitAction !== null || isLoadingStatus} + aheadCount={status?.ahead ?? 0} + behindCount={status?.behind ?? 0} + trackingRemoteName={status?.tracking?.split('/')[0]} + hasUncommittedChanges={changeEntries.length > 0} />
- void handleSyncAction('fetch', remote)} - onSync={(remote) => void handleSyncAction('sync', remote)} - disabled={commitAction !== null || isLoadingStatus} - aheadCount={status?.ahead ?? 0} - behindCount={status?.behind ?? 0} - trackingRemoteName={status?.tracking?.split('/')[0]} - hasUncommittedChanges={changeEntries.length > 0} - /> -
- {changeEntries.length > 0 ? ( + )} + {mode !== 'working' ? ( +
{renderComparison()}
+ ) : changeEntries.length > 0 ? (
{/* File list scrolls inside ChangesPanel; the commit footer stays pinned. */}
@@ -735,12 +937,13 @@ const MobileChangesState: React.FC<{ const MobileDiffDetail: React.FC<{ path: string; - diff: { original: string; modified: string; isBinary?: boolean } | null; + subtitle?: string; + diff: MobileDiffData | null; fileExists: boolean; error: string | null; onBack: () => void; onRetry: () => void; -}> = ({ path, diff, fileExists, error, onBack, onRetry }) => { +}> = ({ path, subtitle, diff, fileExists, error, onBack, onRetry }) => { const { t } = useI18n(); const language = React.useMemo(() => getLanguageFromExtension(path) || 'text', [path]); @@ -757,6 +960,7 @@ const MobileDiffDetail: React.FC<{

{path}

+ {subtitle &&

{subtitle}

}
@@ -774,7 +978,7 @@ const MobileDiffDetail: React.FC<{ ) : diff.isBinary ? ( - ) : isImageFile(path) ? ( + ) : isImageFile(path) && !diff.fileDiff ? ( ) : ( ); }; + +const MobileComparisonFileList: React.FC<{ files: GitComparisonFile[]; onSelect: (path: string) => void }> = ({ files, onSelect }) => { + const { t } = useI18n(); + return ( + +
    + {files.map((file) => { + const statusLabel = file.status === 'A' ? t('diffView.change.new') + : file.status === 'D' ? t('diffView.change.deleted') + : file.status === 'R' ? t('diffView.change.renamed') + : file.status === 'C' ? t('diffView.change.copied') : t('diffView.change.modified'); + const statusColor = file.status === 'A' ? 'var(--status-success)' : file.status === 'D' ? 'var(--status-error)' + : file.status === 'R' || file.status === 'C' ? 'var(--status-info)' : 'var(--status-warning)'; + return
  • + +
  • ; + })} +
+
+ ); +}; diff --git a/packages/ui/src/apps/MobileFullscreenSurface.tsx b/packages/ui/src/apps/MobileFullscreenSurface.tsx index 26d58bdd..4c5a4034 100644 --- a/packages/ui/src/apps/MobileFullscreenSurface.tsx +++ b/packages/ui/src/apps/MobileFullscreenSurface.tsx @@ -175,7 +175,7 @@ export const MobileFullscreenSurface: React.FC = ( 'flex flex-col bg-background text-foreground', isDialog ? 'h-[min(88dvh,860px)] w-full max-w-[720px] overflow-hidden rounded-2xl border border-border/70 shadow-[0_24px_64px_rgb(0_0_0_/_0.32)]' - : 'oc-keyboard-inset-surface fixed inset-0 z-50', + : 'oc-keyboard-inset-surface oc-bottom-safe-surface fixed inset-0 z-50', )} style={isDialog ? { // Scale/fade instead of the push slide: the card is not a navigation @@ -249,7 +249,7 @@ export const MobileFullscreenSurface: React.FC = ( return createPortal(
state.results); + const quotaRefreshErrors = useQuotaStore((state) => state.refreshErrors); + const quotaRefreshAttempted = React.useRef(false); const loadQuotaSettings = useQuotaStore((state) => state.loadSettings); const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas); const isQuotaLoading = useQuotaStore((state) => state.isLoading); @@ -350,13 +352,20 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta }, [dropdownProviderIds]); React.useEffect(() => { - if (!open || isQuotaLoading) return; + if (!open) { + quotaRefreshAttempted.current = false; + return; + } + if (quotaRefreshAttempted.current || isQuotaLoading) return; const missingEnabledProvider = dropdownProviderIds.some((providerId) => ( - !quotaResults.some((result) => result.providerId === providerId) + !quotaResults.some((result) => result.providerId === providerId) || quotaRefreshErrors[providerId] )); if (!missingEnabledProvider) return; + // Trigger at most one attempt per opening. A failed first load remains + // unknown, not an empty result that can suppress retries. + quotaRefreshAttempted.current = true; void fetchAllQuotas(); - }, [dropdownProviderIds, fetchAllQuotas, isQuotaLoading, open, quotaResults]); + }, [dropdownProviderIds, fetchAllQuotas, isQuotaLoading, open, quotaResults, quotaRefreshErrors]); const latestMessageModel = React.useMemo(() => { for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) { diff --git a/packages/ui/src/apps/MobileSessionsSheet.tsx b/packages/ui/src/apps/MobileSessionsSheet.tsx index a2fe76e3..f81b2031 100644 --- a/packages/ui/src/apps/MobileSessionsSheet.tsx +++ b/packages/ui/src/apps/MobileSessionsSheet.tsx @@ -36,6 +36,7 @@ import { DirectoryExplorerDialog } from '@/components/session/DirectoryExplorerD import { Icon } from '@/components/icon/Icon'; import { NewWorktreeDialog } from '@/components/session/NewWorktreeDialog'; import { Button } from '@/components/ui/button'; +import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { Input } from '@/components/ui/input'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { toast } from '@/components/ui'; @@ -43,9 +44,11 @@ import { useThemeSystem } from '@/contexts/useThemeSystem'; import { getProjectLabel, normalizePath } from './mobilePaths'; import { CHAT_DRAFT_PROJECT_ID, isChatDirectoryPath } from '@/lib/chatDirectories'; import { partitionSidebarSessions } from '@/components/session/sidebar/list/sessionCollection'; +import { sortProjectsByOrder } from '@/components/session/sidebar/list/projectSort'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useI18n } from '@/lib/i18n'; import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch'; +import { updateDesktopSettings } from '@/lib/persistence'; import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta'; import { cn } from '@/lib/utils'; import { @@ -57,6 +60,7 @@ import { mergeLiveSessionWithGlobalSession, refreshGlobalSessions, useGlobalSess import { useMobileSessionExpansionStore } from '@/stores/useMobileSessionExpansionStore'; import { useMobileSessionTreeStore } from '@/stores/useMobileSessionTreeStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useSessionDisplayStore, type ProjectSortOrder } from '@/stores/useSessionDisplayStore'; import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore'; import { orderWorktrees, useWorktreeOrderStore } from '@/stores/useWorktreeOrderStore'; import { @@ -73,6 +77,7 @@ import type { WorktreeMetadata } from '@/types/worktree'; import { MobileDeleteWorktreeDialog } from './MobileDeleteWorktreeDialog'; import { MobileProjectEditSurface } from './MobileProjectEditSurface'; +import { useEdgeSwipe } from './useEdgeSwipe'; type MobileSessionsSheetProps = { open: boolean; @@ -94,6 +99,16 @@ type MobileSessionsSheetProps = { const EMPTY_PINNED_SESSION_IDS = new Set(); +// Same orders, same labels as the desktop sidebar's sort menu — the setting +// itself is shared, so the two surfaces must offer the same choices. +const PROJECT_SORT_OPTIONS = [ + ['manual', 'sessions.sidebar.header.projectSort.manual'], + ['a-z', 'sessions.sidebar.header.projectSort.aToZ'], + ['z-a', 'sessions.sidebar.header.projectSort.zToA'], + ['date-added', 'sessions.sidebar.header.projectSort.dateAdded'], + ['recent', 'sessions.sidebar.header.projectSort.recent'], +] as const; + // Pseudo-project key for the collapsible "recent" group's persisted expansion. type ProjectMeta = { @@ -106,6 +121,9 @@ type ProjectMeta = { iconBackground?: string | null; isGitRepo: boolean; worktrees: WorktreeMetadata[]; + /** Read by the 'date-added' / 'recent' project orders. */ + addedAt?: number; + lastOpenedAt?: number; }; type WorktreeBucket = { @@ -268,13 +286,40 @@ const NewWorktreeIconButton: React.FC<{ ); }; +/** Starts a session draft already pointed at this project — the mobile twin of + the desktop sidebar's per-project "+". */ +const NewSessionIconButton: React.FC<{ + label: string; + onClick: () => void; + className?: string; +}> = ({ label, onClick, className }) => ( + +); + // Width of the swipe-revealed action area (rename + archive + delete buttons). const ROW_ACTIONS_WIDTH = 144; const ROW_SWIPE_SNAP_MS = 180; -/** Generic swipe-left-to-reveal wrapper for drawer rows (projects, worktrees). +/** Generic swipe-right-to-reveal wrapper for drawer rows (projects, worktrees). Same gesture mechanics as SessionRow's swipe actions: horizontal intent - detection, imperative transform during the drag, snap on release. */ + detection, imperative transform during the drag, snap on release. The + actions sit on the LEFT so the opposite direction stays free for the + drawer's own close swipe. */ const MobileSwipeActionsRow: React.FC<{ actionsWidth: number; actions: React.ReactNode; @@ -298,7 +343,7 @@ const MobileSwipeActionsRow: React.FC<{ React.useEffect(() => { revealedRef.current = revealed; - applyOffset(revealed ? -actionsWidth : 0, true); + applyOffset(revealed ? actionsWidth : 0, true); }, [actionsWidth, applyOffset, revealed]); const handleTouchStart = (event: React.TouchEvent) => { @@ -317,16 +362,16 @@ const MobileSwipeActionsRow: React.FC<{ if (Math.abs(dx) < 8 || Math.abs(dx) <= Math.abs(dy)) return; draggingRef.current = true; } - const base = revealedRef.current ? -actionsWidth : 0; - applyOffset(Math.min(0, Math.max(-actionsWidth, base + dx)), false); + const base = revealedRef.current ? actionsWidth : 0; + applyOffset(Math.max(0, Math.min(actionsWidth, base + dx)), false); }; const handleTouchEnd = () => { startRef.current = null; if (!draggingRef.current) return; draggingRef.current = false; - const shouldReveal = offsetRef.current < -actionsWidth / 2; - applyOffset(shouldReveal ? -actionsWidth : 0, true); + const shouldReveal = offsetRef.current > actionsWidth / 2; + applyOffset(shouldReveal ? actionsWidth : 0, true); if (shouldReveal !== revealedRef.current) onRevealedChange(shouldReveal); }; @@ -339,7 +384,7 @@ const MobileSwipeActionsRow: React.FC<{ onTouchCancel={handleTouchEnd} style={{ touchAction: 'pan-y' }} > -
+
{actions}
@@ -445,7 +490,7 @@ const SessionRow: React.FC<{ expanded?: boolean; onToggleChildren?: () => void; onSelect: () => void; - /** Swipe-left actions. When omitted, the row is a plain non-swipeable row. */ + /** Swipe-right actions. When omitted, the row is a plain non-swipeable row. */ revealed?: boolean; onRevealedChange?: (revealed: boolean) => void; confirmingDelete?: boolean; @@ -508,7 +553,7 @@ const SessionRow: React.FC<{ React.useEffect(() => { revealedRef.current = revealed; - applyOffset(revealed ? -ROW_ACTIONS_WIDTH : 0, true); + applyOffset(revealed ? ROW_ACTIONS_WIDTH : 0, true); }, [applyOffset, revealed]); const handleTouchStart = (event: React.TouchEvent) => { @@ -527,8 +572,8 @@ const SessionRow: React.FC<{ if (Math.abs(dx) < 8 || Math.abs(dx) <= Math.abs(dy)) return; draggingRef.current = true; } - const base = revealedRef.current ? -ROW_ACTIONS_WIDTH : 0; - const next = Math.min(0, Math.max(-ROW_ACTIONS_WIDTH, base + dx)); + const base = revealedRef.current ? ROW_ACTIONS_WIDTH : 0; + const next = Math.max(0, Math.min(ROW_ACTIONS_WIDTH, base + dx)); applyOffset(next, false); }; @@ -536,8 +581,8 @@ const SessionRow: React.FC<{ startRef.current = null; if (!draggingRef.current) return; draggingRef.current = false; - const shouldReveal = offsetRef.current < -ROW_ACTIONS_WIDTH / 2; - applyOffset(shouldReveal ? -ROW_ACTIONS_WIDTH : 0, true); + const shouldReveal = offsetRef.current > ROW_ACTIONS_WIDTH / 2; + applyOffset(shouldReveal ? ROW_ACTIONS_WIDTH : 0, true); if (shouldReveal !== revealedRef.current) onRevealedChange?.(shouldReveal); }; @@ -554,32 +599,14 @@ const SessionRow: React.FC<{ > {swipeEnabled ? (
{/* Icon-only actions on the row's own background — they read as the - row extending to reveal extra controls, not a separate panel. */} - - + row extending to reveal extra controls, not a separate panel. + Ordered outward from the content, so a partial drag exposes + delete first, exactly as the right-side version did. */} + +
) : null}
= ({ open, const setActiveProject = useProjectsStore((state) => state.setActiveProject); const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly); const reorderProjects = useProjectsStore((state) => state.reorderProjects); + const manualProjectOrder = useProjectsStore((state) => state.manualProjectOrder); + const projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder); + const setProjectSortOrder = useSessionDisplayStore((state) => state.setProjectSortOrder); const removeProject = useProjectsStore((state) => state.removeProject); const projectExpandedMap = useMobileSessionTreeStore((state) => state.projectExpanded); const worktreeExpandedMap = useMobileSessionTreeStore((state) => state.worktreeExpanded); @@ -895,12 +945,12 @@ export const MobileSessionsSheet: React.FC = ({ open, const toggleParent = useMobileSessionExpansionStore((state) => state.toggleParent); const [query, setQuery] = React.useState(''); const [editingProjectId, setEditingProjectId] = React.useState(null); - // Swipe-left actions: which row has its actions revealed, and whether its + // Swipe-right actions: which row has its actions revealed, and whether its // delete button is armed (two-step). One row at a time. const [revealedSessionId, setRevealedSessionId] = React.useState(null); const [confirmingDeleteSessionId, setConfirmingDeleteSessionId] = React.useState(null); const [renamingSessionId, setRenamingSessionId] = React.useState(null); - // Swipe-left actions on group headers (`project:{id}` / `wt:{bucketKey}`) — + // Swipe-right actions on group headers (`project:{id}` / `wt:{bucketKey}`) — // separate from session rows, but mutually exclusive with them. const [revealedRowId, setRevealedRowId] = React.useState(null); const [confirmingRemoveProjectId, setConfirmingRemoveProjectId] = React.useState(null); @@ -910,6 +960,7 @@ export const MobileSessionsSheet: React.FC = ({ open, } | null>(null); // Bumped to force a re-list of worktrees (e.g. after one is deleted in the editor). const [worktreeRefreshKey, setWorktreeRefreshKey] = React.useState(0); + const [sortPanelOpen, setSortPanelOpen] = React.useState(false); const [directoryDialogOpen, setDirectoryDialogOpen] = React.useState(false); const [newWorktreeDialogOpen, setNewWorktreeDialogOpen] = React.useState(false); const [worktreeDialogProjectId, setWorktreeDialogProjectId] = React.useState(null); @@ -995,21 +1046,27 @@ export const MobileSessionsSheet: React.FC = ({ open, const projectsMeta = React.useMemo( () => - projects.map((project) => ({ - id: project.id, - label: project.label?.trim() || getProjectLabel(project.path), - path: normalizePath(project.path), - icon: project.icon, - color: project.color, - iconImage: project.iconImage, - iconBackground: project.iconBackground, - isGitRepo: gitProjectPaths.has(normalizePath(project.path)), - worktrees: orderWorktrees( - worktreeOrderByProject[project.id], - worktreesByProject.get(normalizePath(project.path)) ?? [], - ), - })), - [gitProjectPaths, projects, worktreeOrderByProject, worktreesByProject], + sortProjectsByOrder( + projects.map((project) => ({ + id: project.id, + label: project.label?.trim() || getProjectLabel(project.path), + path: normalizePath(project.path), + icon: project.icon, + color: project.color, + iconImage: project.iconImage, + iconBackground: project.iconBackground, + isGitRepo: gitProjectPaths.has(normalizePath(project.path)), + worktrees: orderWorktrees( + worktreeOrderByProject[project.id], + worktreesByProject.get(normalizePath(project.path)) ?? [], + ), + addedAt: project.addedAt, + lastOpenedAt: project.lastOpenedAt, + })), + projectSortOrder, + manualProjectOrder, + ), + [gitProjectPaths, manualProjectOrder, projectSortOrder, projects, worktreeOrderByProject, worktreesByProject], ); /** @@ -1345,6 +1402,16 @@ export const MobileSessionsSheet: React.FC = ({ open, useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); + // The order is a shared setting, so persist it the same way the desktop + // sidebar does — picking it here follows the user to their other surfaces. + const handleProjectSortChange = (order: ProjectSortOrder) => { + setProjectSortOrder(order); + void updateDesktopSettings({ sidebarProjectSortOrder: order }); + // Dragging projects rewrites the manual order; it means nothing while the + // list is sorted by something else. + if (order !== 'manual') setEditingOrder(false); + }; + const handleReorderDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; @@ -1382,6 +1449,15 @@ export const MobileSessionsSheet: React.FC = ({ open, onOpenChange(false); }; + // Same contract as the desktop sidebar's per-project "+": the draft carries + // the project and its directory, so the app's current directory is not + // switched out from under the session that is still open behind the drawer. + const handleNewSessionInProject = (project: ProjectMeta) => { + setActiveProjectIdOnly(project.id); + openNewSessionDraft({ selectedProjectId: project.id, directoryOverride: project.path }); + onOpenChange(false); + }; + const filteredNodes = React.useMemo(() => { if (!normalizedQuery) return projectNodes; return projectNodes.filter((node) => { @@ -1413,22 +1489,33 @@ export const MobileSessionsSheet: React.FC = ({ open, ); }, [normalizedQuery, pinnedSessionIds, projectsMeta, sessionOrderRanks, sessions]); - const searchProjectMatches = React.useMemo(() => { - if (!normalizedQuery) return [] as Array; - return rankByQuery(projectsMeta, normalizedQuery, (project) => [project.label, project.path]) - .map((project) => ({ - ...project, - sessionCount: sessions.filter((session) => { - if (getParentId(session)) return false; - const directory = normalizePath(getSessionDirectory(session)); - return projectMatchesExactDirectory(project, directory); - }).length, - })); - }, [normalizedQuery, projectsMeta, sessions]); + const searchProjectMatches = React.useMemo(() => { + if (!normalizedQuery) return []; + return rankByQuery(projectsMeta, normalizedQuery, (project) => [project.label, project.path]); + }, [normalizedQuery, projectsMeta]); const hasNoMatches = normalizedQuery && searchSessionMatches.length === 0 && searchProjectMatches.length === 0; - const canEditOrder = !normalizedQuery && projectsMeta.length > 1; + // Drag order IS the manual order: offering it under another sort would let + // the user rearrange a list that is about to be re-sorted anyway. + const canEditOrder = !normalizedQuery && projectsMeta.length > 1 && projectSortOrder === 'manual'; + + // Sorting lives in the header next to reordering — the two answer the same + // question about the list, and a permanent row of modes above it would cost + // a project row for a setting touched once a month. + const sortToggle = !editingOrder && !normalizedQuery && projectsMeta.length > 1 ? ( + + ) : null; const editToggle = canEditOrder ? ( ) : null; + // The new-session button keeps the outer right edge whatever else is showing: + // it is the one action people reach for without looking, so it must not slide + // around as the icons beside it come and go. const trailingActions = - newChatButton || addProjectButton || editToggle ? ( + newChatButton || addProjectButton || sortToggle || editToggle ? ( <> - {newChatButton} {addProjectButton} + {sortToggle} {editToggle} + {newChatButton} ) : null; @@ -1587,16 +1678,15 @@ export const MobileSessionsSheet: React.FC = ({ open, {project.label} - - {project.sessionCount} - {project.isGitRepo ? ( - handleNewWorktree(project.id)} - /> + handleNewWorktree(project.id)} /> ) : null} + handleNewSessionInProject(project)} + />
))}
@@ -1703,19 +1793,6 @@ export const MobileSessionsSheet: React.FC = ({ open, onRevealedChange={(nextRevealed) => handleRowKeyRevealedChange(`project:${node.project.id}`, nextRevealed)} actions={( <> - + )} > @@ -1768,17 +1858,15 @@ export const MobileSessionsSheet: React.FC = ({ open, {node.project.label} - {node.isActive ? : null} - - {node.totalSessions} - {node.project.isGitRepo ? ( - handleNewWorktree(node.project.id)} - /> + handleNewWorktree(node.project.id)} /> ) : null} + handleNewSessionInProject(node.project)} + />
@@ -1965,6 +2053,33 @@ export const MobileSessionsSheet: React.FC = ({ open, onClose={() => setEditingProjectId(null)} onWorktreesChanged={() => setWorktreeRefreshKey((value) => value + 1)} /> + setSortPanelOpen(false)} + title={t('sessions.sidebar.header.actions.sortProjects')} + > +
+ {PROJECT_SORT_OPTIONS.map(([order, labelKey]) => ( + + ))} +
+
+ {worktreeToDelete ? ( = ({ open, onOpenChange(false)} + // The mirror of the swipe that opened the drawer closes it again — + // except while a row has its actions out: then the same swipe is the + // user putting those away, so it only clears them. + onSwipeClose={() => { + if (revealedSessionId || revealedRowId) { + setRevealedSessionId(null); + setRevealedRowId(null); + setConfirmingDeleteSessionId(null); + setConfirmingRemoveProjectId(null); + return; + } + onOpenChange(false); + }} ariaLabel={t('mobile.sessions.sheet.title')} >
@@ -2030,8 +2158,9 @@ const DRAWER_ENTER_DURATION_MS = 320; const DRAWER_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; /** Full-width left drawer for the phone sessions list: covers the whole app - and slides in from the left edge. Closes via the header X, Escape, or the - Android back button (handled by MobileShell). + and slides in from the left edge. Closes via the header X, a right-edge + swipe back toward the left (the mirror of the gesture that opened it), + Escape, or the Android back button (handled by MobileShell). Stays MOUNTED while closed (parked off-screen, hidden): the sessions sheet's project/worktree state stays warm, so reopening shows the tree @@ -2040,10 +2169,14 @@ const DRAWER_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; const MobileSessionsDrawerContainer: React.FC<{ open: boolean; onClose: () => void; + /** What the closing edge swipe does; the drawer's owner may want it to undo + a lighter state first. Falls back to `onClose`. */ + onSwipeClose?: () => void; ariaLabel: string; children: React.ReactNode; -}> = ({ open, onClose, ariaLabel, children }) => { +}> = ({ open, onClose, onSwipeClose, ariaLabel, children }) => { const rootRef = React.useRef(null); + const drawerRef = React.useRef(null); const [entered, setEntered] = React.useState(false); // Kept visible through the exit slide; flipped to hidden once it finishes. const [visible, setVisible] = React.useState(open); @@ -2051,6 +2184,18 @@ const MobileSessionsDrawerContainer: React.FC<{ React.useEffect(() => { onCloseRef.current = onClose; }, [onClose]); + const onSwipeCloseRef = React.useRef(onSwipeClose); + React.useEffect(() => { + onSwipeCloseRef.current = onSwipeClose; + }, [onSwipeClose]); + + // Swipe from the drawer's right edge back toward the left = close, the + // reverse of the left-edge swipe that opened it from the chat. Rows inside + // reveal their actions in the opposite direction, so the two never fight. + useEdgeSwipe(drawerRef, { + enabled: open, + onRightEdgeSwipe: () => (onSwipeCloseRef.current ?? onCloseRef.current)(), + }); if (typeof document !== 'undefined' && !rootRef.current) { let root = document.getElementById(DRAWER_ROOT_ID); @@ -2091,6 +2236,7 @@ const MobileSessionsDrawerContainer: React.FC<{ return createPortal(
void }> = ({ onOpenM beside the chat (tablet, landscape). The caller owns the width and the open/close animation there; this component only fills it. - Closes via the header X, Escape (unless the terminal tab owns the keys), or - the Android back button (handled by MobileShell). */ + Closes via the header X, a left-edge swipe back toward the right (the + mirror of the gesture that opened it), Escape (unless the terminal tab owns + the keys), or the Android back button (handled by MobileShell). */ export const MobileWorkspaceDrawer: React.FC<{ open: boolean; onClose: () => void; @@ -113,6 +115,7 @@ export const MobileWorkspaceDrawer: React.FC<{ }> = ({ open, onClose, tab, onTabChange, pendingChangesDiff, onOpenPlan, onOpenMcpSettings, variant = 'drawer' }) => { const { t } = useI18n(); const rootRef = React.useRef(null); + const drawerRef = React.useRef(null); const [entered, setEntered] = React.useState(false); // Kept visible through the exit slide; flipped to hidden once it finishes. const [visible, setVisible] = React.useState(open); @@ -125,6 +128,15 @@ export const MobileWorkspaceDrawer: React.FC<{ tabRef.current = tab; }, [tab]); + // Swipe from the drawer's left edge back toward the right = close, the + // reverse of the right-edge swipe that opened it from the chat. Only the + // full-cover drawer has an edge to grab; the tablet panel is closed from the + // header instead. + useEdgeSwipe(drawerRef, { + enabled: variant === 'drawer' && open, + onLeftEdgeSwipe: () => onCloseRef.current(), + }); + // Tabs the user has actually opened — their panes stay mounted afterwards. const [visitedTabs, setVisitedTabs] = React.useState>(() => new Set()); React.useEffect(() => { @@ -224,14 +236,14 @@ export const MobileWorkspaceDrawer: React.FC<{ {visitedTabs.has('changes') ? (
@@ -278,11 +290,12 @@ export const MobileWorkspaceDrawer: React.FC<{ return createPortal(
+
@@ -134,6 +136,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) { + diff --git a/packages/ui/src/apps/useEdgeSwipe.ts b/packages/ui/src/apps/useEdgeSwipe.ts index 5aa1d5d8..f78ad946 100644 --- a/packages/ui/src/apps/useEdgeSwipe.ts +++ b/packages/ui/src/apps/useEdgeSwipe.ts @@ -1,11 +1,12 @@ import React from 'react'; /** - * Native-feeling edge swipes on the mobile chat: start a horizontal swipe from + * Native-feeling edge swipes on the mobile shell: start a horizontal swipe from * the very left/right screen edge and drag toward the centre. * - * - Left edge → centre = open the sessions drawer - * - Right edge → centre = open the most recent overflow surface + * On the chat that opens a drawer (left edge → sessions, right edge → + * workspace); on an open drawer the mirrored swipe closes it (sessions drawer + * closes from the right edge, workspace drawer from the left edge). * * Only `touchstart`/`touchend` are observed (both passive), so this never * interferes with vertical chat scrolling or the horizontal scroll inside code @@ -27,6 +28,9 @@ export interface EdgeSwipeOptions { onLeftEdgeSwipe?: () => void; /** Swipe that started at the right edge and travelled left. */ onRightEdgeSwipe?: () => void; + /** Defaults to on. Flipping it re-attaches the listeners, which is what a + drawer needs: its element only exists (or only matters) while open. */ + enabled?: boolean; } export const useEdgeSwipe = ( @@ -37,7 +41,10 @@ export const useEdgeSwipe = ( const optionsRef = React.useRef(options); optionsRef.current = options; + const enabled = options.enabled ?? true; + React.useEffect(() => { + if (!enabled) return; const element = ref.current; if (!element) return; const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.(); @@ -87,5 +94,5 @@ export const useEdgeSwipe = ( element.removeEventListener('touchstart', onTouchStart); element.removeEventListener('touchend', onTouchEnd); }; - }, [ref]); + }, [enabled, ref]); }; diff --git a/packages/ui/src/assets/provider-logos/cline.svg b/packages/ui/src/assets/provider-logos/cline.svg new file mode 100644 index 00000000..ebda2239 --- /dev/null +++ b/packages/ui/src/assets/provider-logos/cline.svg @@ -0,0 +1,4 @@ + + Cline + + \ No newline at end of file diff --git a/packages/ui/src/components/browser/BrowserPane.tsx b/packages/ui/src/components/browser/BrowserPane.tsx index 638b9547..b8961bca 100644 --- a/packages/ui/src/components/browser/BrowserPane.tsx +++ b/packages/ui/src/components/browser/BrowserPane.tsx @@ -111,6 +111,7 @@ const WebviewBrowser: React.FC = ({ initialUrl, directory, tab const [isAnnotating, setIsAnnotating] = React.useState(false); const [isWaitingForServer, setIsWaitingForServer] = React.useState(false); const [zoomLevel, setZoomLevel] = React.useState(0); + const zoomLevelRef = React.useRef(0); const [showDeviceBar, setShowDeviceBar] = React.useState(false); const [viewport, setViewport] = React.useState(FILL_VIEWPORT); // Read inside agent actions, which are not re-created when the viewport @@ -580,6 +581,7 @@ const WebviewBrowser: React.FC = ({ initialUrl, directory, tab const applyZoom = React.useCallback((level: number) => { const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, level)); + zoomLevelRef.current = next; setZoomLevel(next); try { webviewRef.current?.setZoomLevel(next); @@ -588,6 +590,20 @@ const WebviewBrowser: React.FC = ({ initialUrl, directory, tab } }, []); + React.useEffect(() => { + const handleZoom = (event: Event) => { + if (!(event instanceof CustomEvent)) return; + const action = event.detail; + const webview = webviewRef.current; + if (!webview || document.activeElement !== webview) return; + if (action === 'zoom-in') applyZoom(zoomLevelRef.current + ZOOM_STEP); + else if (action === 'zoom-out') applyZoom(zoomLevelRef.current - ZOOM_STEP); + else if (action === 'zoom-reset') applyZoom(0); + }; + window.addEventListener('openchamber:zoom', handleZoom); + return () => window.removeEventListener('openchamber:zoom', handleZoom); + }, [applyZoom]); + const clearBrowsingData = React.useCallback((what: 'cookies' | 'cache') => { void invokeDesktopCommand('desktop_browser_clear_data', { partition: BROWSER_PARTITION, diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index ef0ab2b3..75025b37 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -171,10 +171,8 @@ type ChatViewportProps = { scrollRef: React.RefObject; messageListRef: React.RefObject; registerList: (list: TimelineListHandle | null) => void; - anchorMessageId: string | null; - onAnchorReady: (messageId: string, anchorIndex: number) => void; - onAnchorSizeChanged: (messageId: string) => void; onIsAtEndChange: (isAtEnd: boolean) => void; + onListMetricsChange: (metrics: { readonly footerSize: number }) => void; onTimelineDataChange: () => void; renderedMessages: SessionMessageRecord[]; isLoadingOlder: boolean; @@ -215,10 +213,8 @@ const ChatViewport = React.memo(({ scrollRef, messageListRef, registerList, - anchorMessageId, - onAnchorReady, - onAnchorSizeChanged, onIsAtEndChange, + onListMetricsChange, onTimelineDataChange, renderedMessages, isLoadingOlder, @@ -499,14 +495,12 @@ const ChatViewport = React.memo(({ endPinningReleased={endPinningReleased} directory={directory} registerList={registerList} - anchorMessageId={anchorMessageId} - onAnchorReady={onAnchorReady} - onAnchorSizeChanged={onAnchorSizeChanged} // Zero end inset: the footer spacer already reserves the // zone the floating status row covers; adding its height // again produced a double-tall blank band at rest. composerOverlayHeight={0} onIsAtEndChange={onIsAtEndChange} + onListMetricsChange={onListMetricsChange} onTimelineDataChange={onTimelineDataChange} listHeader={listHeader} listFooter={listFooter} @@ -543,6 +537,7 @@ const ChatViewport = React.memo(({ && prev.activeStreamingPhase === next.activeStreamingPhase && prev.retryOverlay === next.retryOverlay && prev.scrollToBottom === next.scrollToBottom + && prev.onListMetricsChange === next.onListMetricsChange && prev.endPinningReleased === next.endPinningReleased && prev.revealWaited === next.revealWaited && prev.revealGate === next.revealGate @@ -1106,24 +1101,12 @@ export const ChatContainer: React.FC = ({ statusOverlayObserverRef.current?.disconnect(); statusOverlayObserverRef.current = null; }, []); - const lastUserMessageId = React.useMemo(() => { - for (let index = sessionMessages.length - 1; index >= 0; index -= 1) { - const message = sessionMessages[index]; - if (message.info.role === 'user') { - return message.info.id; - } - } - return null; - }, [sessionMessages]); - const { scrollRef, scrollNode, registerList, - anchorMessageId, - onAnchorReady, - onAnchorSizeChanged, onIsAtEndChange, + onListMetricsChange, onManualNavigation, onTimelineDataChange, goToBottom, @@ -1138,7 +1121,6 @@ export const ChatContainer: React.FC = ({ currentSessionKey, sessionMessageCount, composerOverlayHeight, - lastUserMessageId, sessionIsWorking, revealGate, onActiveTurnChange: handleActiveTurnChange, @@ -1549,10 +1531,8 @@ export const ChatContainer: React.FC = ({ directory={effectiveSessionDirectory} scrollRef={scrollRef} registerList={registerList} - anchorMessageId={anchorMessageId} - onAnchorReady={onAnchorReady} - onAnchorSizeChanged={onAnchorSizeChanged} onIsAtEndChange={onIsAtEndChange} + onListMetricsChange={onListMetricsChange} onTimelineDataChange={onTimelineDataChange} messageListRef={messageListRef} renderedMessages={timelineController.renderedMessages} diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index be013f1b..d280cb75 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -26,16 +26,18 @@ import { getRuntimeKey } from '@/lib/runtime-switch'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { createChatDraftIdentity, + getChatDraftIdentityKey, + clearChatDraft, readChatDraft, - writeChatDraft, type ChatDraftIdentity, type ChatDraftSnapshot, } from '@/lib/chatDraftPersistence'; import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog'; import { BtwPanel } from './btw/BtwPanel'; import { useBtwPanelState } from './btw/useBtwPanelState'; +import { resolveBtwSelection, useBtwStore } from '@/stores/useBtwStore'; import { wasPromotedBtwSession } from '@/lib/sessionBtwMetadata'; -import { buildBtwSyntheticTexts, destroyBtwSession, startBtwSession, type BtwSessionRef } from '@/lib/btw'; +import { buildBtwSyntheticTexts, preparePendingBtwSend, startBtwSession } from '@/lib/btw'; import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import type { ToolPopupContent } from './message/types'; @@ -47,10 +49,12 @@ import type { SkillAutocompleteHandle } from './SkillAutocomplete'; import type { SnippetAutocompleteHandle } from './SnippetAutocomplete'; import { cn } from "@/lib/utils"; import { ModelControls } from './ModelControls'; +import { focusChatInput } from './composer/editor/dom'; import { parseAgentMentions } from '@/lib/messages/agentMentions'; import { CONTEXT_METADATA_KEY, draftFromContextPayload } from '@/lib/messages/contextParts'; import { ComposerStatusBar } from './ComposerStatusBar'; import { shouldSubmitEnter } from './composer/keyboardPolicy'; +import { getDropdownNavigationKey } from '@/components/ui/dropdown-navigation'; import { PendingChangesBar } from './PendingChangesBar'; import { useChatColumnSession } from './chatColumnSession'; import { useChatSurfaceMode } from './useChatSurfaceMode'; @@ -87,6 +91,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { usePermissionStore } from '@/stores/permissionStore'; import { togglePermissionAutoAccept } from './permissionAutoAccept'; import { useKeybind } from '@/hooks/useKeybind'; +import { hasOpenDropdown } from '@/hooks/keyboard-shortcut-dom'; import { useAuthSessionStore } from '@/lib/runtime-auth-expiry'; import { extractGitChangedFiles } from './changedFiles'; import { useI18n } from '@/lib/i18n'; @@ -205,6 +210,7 @@ const MAX_MOBILE_COMPOSER_LINES = 16; */ const MOBILE_COMPOSER_BOUND_GAP_PX = 4; const EMPTY_QUEUE: QueuedMessage[] = []; +const EMPTY_ATTACHMENTS: AttachedFile[] = []; const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560; const renameFileForAttachmentCitation = (file: File, filename: string): File => { if (file.name === filename) { @@ -369,7 +375,8 @@ const ChatInputComponent: React.FC = ({ return snapshot.text; }); const confirmedMentionsRef = React.useRef>(initialDraftSnapshotRef.current.confirmedMentions); - const [inputMode, setInputMode] = React.useState<'normal' | 'shell'>('normal'); + const [storedInputMode, setInputMode] = React.useState<'normal' | 'shell'>('normal'); + const inputModeParentRef = React.useRef(null); const [isDragging, setIsDragging] = React.useState(false); const [isInternalDrag, setIsInternalDrag] = React.useState(false); // At most one picker is open at a time; the prompt language decides which. @@ -424,6 +431,12 @@ const ChatInputComponent: React.FC = ({ const liveSessionId = useSessionUIStore((s) => s.currentSessionId); const chatColumnSession = useChatColumnSession(); const currentSessionId = chatColumnSession ? chatColumnSession.sessionId : liveSessionId; + React.useEffect(() => { + if (inputModeParentRef.current !== null && inputModeParentRef.current !== currentSessionId) { + setInputMode('normal'); + } + inputModeParentRef.current = currentSessionId; + }, [currentSessionId]); const fallbackDirectory = useDirectoryStore((s) => s.currentDirectory); const liveEffectiveDirectory = useEffectiveDirectory(); const currentDirectory = (chatColumnSession?.sessionId ? chatColumnSession.directory : null) @@ -439,13 +452,13 @@ const ChatInputComponent: React.FC = ({ const btwPanel = useBtwPanelState(currentSessionId, currentSessionDirectoryForSync ?? currentDirectory ?? undefined); const btwSessionId = btwPanel.btwSessionId; const btwDirectory = btwPanel.btwDirectory; - const btwSessionRef = React.useMemo( - () => (currentSessionId && btwSessionId && btwDirectory - ? { parentSessionId: currentSessionId, btwSessionId, directory: btwDirectory } - : null), - [btwDirectory, btwSessionId, currentSessionId], - ); - const isBtwActive = Boolean(btwSessionRef) && !btwPanel.collapsed; + const btwComposerSessionId = btwPanel.pending && currentSessionId + ? `btw-pending:${currentSessionId}` + : btwSessionId; + const isBtwActive = Boolean(btwComposerSessionId) && !btwPanel.collapsed; + const immediateBtwSubmitRef = React.useRef<{ identity: ChatDraftIdentity; text: string } | null>(null); + const draftCaretModeRef = React.useRef({ btw: isBtwActive, atEnd: isBtwActive }); + const inputMode = isBtwActive ? 'normal' : storedInputMode; // A session promoted out of `/btw` keeps the boundary instructions in its // transcript — there is no way to delete a message part — so it has to say // they no longer apply. @@ -455,12 +468,13 @@ const ChatInputComponent: React.FC = ({ () => createChatDraftIdentity( activeRuntimeKey, currentSessionDirectoryForSync ?? currentDirectory, - currentSessionId, + isBtwActive ? btwComposerSessionId : currentSessionId, ), - [activeRuntimeKey, currentDirectory, currentSessionDirectoryForSync, currentSessionId], + [activeRuntimeKey, btwComposerSessionId, currentDirectory, currentSessionDirectoryForSync, currentSessionId, isBtwActive], ); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); const newSessionDraftOpen = Boolean(newSessionDraft?.open); + const newSessionDraftAnnouncesDirtyState = newSessionDraftOpen && newSessionDraft?.openedAutomatically !== true; const draftPermissionAutoAcceptEnabled = useSessionUIStore((s) => ( s.newSessionDraft?.open ? s.newSessionDraft.permissionAutoAcceptEnabled === true : false )); @@ -470,11 +484,21 @@ const ChatInputComponent: React.FC = ({ const prepareChatDraftDirectory = useSessionUIStore((s) => s.prepareChatDraftDirectory); const abortPromptSessionId = useSessionUIStore((s) => s.abortPromptSessionId); const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt); - const attachedFiles = useInputStore((s) => s.attachedFiles); + const attachedFiles = useInputStore((s) => isBtwActive ? EMPTY_ATTACHMENTS : s.attachedFiles); const addAttachedFile = useInputStore((s) => s.addAttachedFile); const clearAttachedFiles = useInputStore((s) => s.clearAttachedFiles); const saveSessionAgentSelection = useSelectionStore((s) => s.saveSessionAgentSelection); + const btwModelSelection = useSelectionStore(React.useCallback( + (s) => btwComposerSessionId ? s.sessionModelSelections.get(btwComposerSessionId) ?? null : null, + [btwComposerSessionId], + )); + const btwAgentSelection = useSelectionStore(React.useCallback( + (s) => btwComposerSessionId ? s.sessionAgentSelections.get(btwComposerSessionId) ?? null : null, + [btwComposerSessionId], + )); const consumePendingInputText = useInputStore((s) => s.consumePendingInputText); + const consumePendingBtwComposerRequest = useInputStore((s) => s.consumePendingBtwComposerRequest); + const pendingBtwComposerRequest = useInputStore((s) => s.pendingBtwComposerRequest); const pendingPresetSubmit = useInputStore((s) => s.pendingPresetSubmit); const setPendingInputText = useInputStore((s) => s.setPendingInputText); const pendingInputText = useInputStore((s) => s.pendingInputText); @@ -503,10 +527,40 @@ const ChatInputComponent: React.FC = ({ ? getModelMetadata(currentProviderId, currentModelId) : undefined; const currentVariant = useConfigStore((state) => state.currentVariant); + const currentVariantSelection = useConfigStore((state) => state.currentVariantSelection); const currentAgentName = useConfigStore((state) => state.currentAgentName); const setAgent = useConfigStore((state) => state.setAgent); const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents); const agents = getVisibleAgents(); + const btwSavedVariant = useSelectionStore(React.useCallback( + (state) => btwComposerSessionId && btwAgentSelection && btwModelSelection + ? state.getAgentModelVariantForSession( + btwComposerSessionId, + btwAgentSelection, + btwModelSelection.providerId, + btwModelSelection.modelId, + ) + : undefined, + [btwAgentSelection, btwComposerSessionId, btwModelSelection], + )); + const effectiveBtwSelection = resolveBtwSelection({ + agents, + savedAgent: btwAgentSelection, + savedModel: btwModelSelection, + savedVariant: btwSavedVariant, + composerModel: currentProviderId && currentModelId ? { providerId: currentProviderId, modelId: currentModelId } : null, + composerVariant: currentVariantSelection.override === null ? null : currentVariantSelection.override ?? currentVariant, + }); + React.useEffect(() => { + const { model, agent, variant } = effectiveBtwSelection; + if (!isBtwActive || !btwComposerSessionId || !model || !agent) return; + const selections = useSelectionStore.getState(); + if (selections.getSessionModelSelection(btwComposerSessionId)) return; + selections.saveSessionAgentSelection(btwComposerSessionId, agent); + selections.saveSessionModelSelection(btwComposerSessionId, model.providerId, model.modelId); + selections.saveAgentModelForSession(btwComposerSessionId, agent, model.providerId, model.modelId); + selections.saveAgentModelVariantForSession(btwComposerSessionId, agent, model.providerId, model.modelId, variant); + }, [btwComposerSessionId, effectiveBtwSelection, isBtwActive]); const isMobile = useUIStore((state) => state.isMobile); const hasHardwareKeyboard = useHardwareKeyboard(); const enterToSend = useUIStore((state) => state.enterToSend); @@ -517,7 +571,8 @@ const ChatInputComponent: React.FC = ({ const persistChatDraft = useUIStore((state) => state.persistChatDraft); const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled); const largeTextPasteBehavior = useUIStore((state) => state.largeTextPasteBehavior); - const isExpandedInput = useUIStore((state) => state.isExpandedInput); + const persistedExpandedInput = useUIStore((state) => state.isExpandedInput); + const isExpandedInput = !isBtwActive && persistedExpandedInput; const setExpandedInput = useUIStore((state) => state.setExpandedInput); const setTimelineDialogOpen = useUIStore((state) => state.setTimelineDialogOpen); const { git: runtimeGit, vscode: vscodeApi, linear: runtimeLinear } = useRuntimeAPIs(); @@ -536,6 +591,10 @@ const ChatInputComponent: React.FC = ({ const fetchGitStatus = useGitStore((state) => state.fetchStatus); const clearGitDiffCache = useGitStore((state) => state.clearDiffCache); const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept); + const pendingBtwAutoAccept = useBtwStore(React.useCallback( + (state) => currentSessionId ? state.byParent[currentSessionId]?.pendingAutoAccept === true : false, + [currentSessionId], + )); const [isNarrowComposer, setIsNarrowComposer] = React.useState(false); const [attachmentPreview, setAttachmentPreview] = React.useState({ open: false, @@ -558,6 +617,7 @@ const ChatInputComponent: React.FC = ({ }); React.useEffect(() => { + if (isBtwActive) return; const modelKey = `${currentProviderId ?? ''}/${currentModelId ?? ''}`; const inputModalities = currentModelMetadata?.modalities?.input; const modalitySignature = inputModalities?.slice().sort().join(',') ?? null; @@ -597,7 +657,7 @@ const ChatInputComponent: React.FC = ({ modalities: unsupportedModalities.map((modality) => modalityLabels[modality]).join(', '), files: fileSummary, }), { id: `attachment-modalities:${modelKey}` }); - }, [attachedFiles, currentModelId, currentModelMetadata, currentProviderId, t]); + }, [attachedFiles, currentModelId, currentModelMetadata, currentProviderId, isBtwActive, t]); const handleShowAttachmentPreview = React.useCallback((content: ToolPopupContent) => { if (!content.image) return; @@ -873,7 +933,7 @@ const ChatInputComponent: React.FC = ({ const [linkedLinearIssue, setLinkedLinearIssue] = React.useState(null); // Message queue - const messageQueueTarget = currentSessionId + const messageQueueTarget = !isBtwActive && currentSessionId ? createMessageQueueTarget(currentSessionId, currentSessionDirectoryForSync ?? currentDirectory) : null; const messageQueueKey = messageQueueTarget ? getMessageQueueKey(messageQueueTarget) : null; @@ -891,7 +951,7 @@ const ChatInputComponent: React.FC = ({ const takeForSend = useMessageQueueStore((state) => state.takeForSend); // Inline comment drafts - const inlineDraftSessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : ''); + const inlineDraftSessionKey = isBtwActive ? btwComposerSessionId ?? '' : currentSessionId ?? (newSessionDraftOpen ? 'draft' : ''); const inlineDraftDirectory = currentSessionDirectoryForSync ?? currentDirectory; const inlineDraftTarget = React.useMemo( () => inlineDraftSessionKey && inlineDraftDirectory @@ -916,9 +976,9 @@ const ChatInputComponent: React.FC = ({ () => createInputHistoryIdentity( activeRuntimeKey, currentSessionDirectoryForSync ?? currentDirectory ?? '', - currentSessionId ?? 'draft', + inlineDraftSessionKey || 'draft', ), - [activeRuntimeKey, currentDirectory, currentSessionDirectoryForSync, currentSessionId], + [activeRuntimeKey, currentDirectory, currentSessionDirectoryForSync, inlineDraftSessionKey], ); const inputHistoryEntries = useInputHistoryStore(React.useCallback( (state) => selectInputHistoryEntries(state, inputHistoryIdentity), @@ -926,7 +986,7 @@ const ChatInputComponent: React.FC = ({ )); // Session scope also reads the visible transcript, so sessions older than // the persisted history still recall their prompts. - const transcriptPrompts = useUserMessageHistory(currentSessionId ?? ''); + const transcriptPrompts = useUserMessageHistory((isBtwActive ? btwSessionId : currentSessionId) ?? ''); const historyValues = React.useMemo( () => (inputHistoryScope === 'session' ? mergeSessionInputHistory(transcriptPrompts, inputHistoryEntries) @@ -950,7 +1010,12 @@ const ChatInputComponent: React.FC = ({ // Draft persistence: identity switching, debounced writes and the // flush-on-hide edges live in the hook. - const { persistNow: persistDraftImmediately } = useComposerDraft({ + const { + persistNow: persistDraftImmediately, + handoffDraft, + restoreDraft, + migrateDraft, + } = useComposerDraft({ message, messageRef, setMessage, @@ -961,10 +1026,60 @@ const ChatInputComponent: React.FC = ({ text: initialDraftRef.current ?? '', identity: initialDraftIdentityRef.current, }, - onIdentityChange: () => setInputMode('normal'), - onDraftRestored: () => composerRef.current?.selectAll(), + onIdentityChange: () => { + setInputMode('normal'); + draftCaretModeRef.current.atEnd = isBtwActive || draftCaretModeRef.current.btw; + draftCaretModeRef.current.btw = isBtwActive; + }, + onDraftRestored: (source) => { + const editor = composerRef.current; + if (!editor) return; + if (source === 'fork') editor.focus(); + if (source !== 'fork' && draftCaretModeRef.current.atEnd) { + editor.setSelection(editor.getValue().length); + } else { + editor.selectAll(); + } + }, }); + const handleExitBtw = React.useCallback(() => { + if (!currentSessionId) return; + immediateBtwSubmitRef.current = null; + const panels = useBtwStore.getState(); + const pending = panels.byParent[currentSessionId]; + if (pending?.pending && !pending.creating && !btwSessionId) { + const pendingSessionId = `btw-pending:${currentSessionId}`; + const identity = createChatDraftIdentity(activeRuntimeKey, currentSessionDirectoryForSync ?? currentDirectory, pendingSessionId); + if (identity) { + clearChatDraft(identity, true); + useInlineCommentDraftStore.getState().clearDrafts({ directory: identity.directory, sessionKey: pendingSessionId }); + } + useSelectionStore.getState().clearSessionSelections(pendingSessionId); + useInputStore.getState().consumePendingBtwComposerRequest(currentSessionId); + panels.clearPanelState(currentSessionId); + return; + } + panels.setPanelState(currentSessionId, { collapsed: true }); + }, [activeRuntimeKey, btwSessionId, currentDirectory, currentSessionDirectoryForSync, currentSessionId]); + + React.useEffect(() => { + const request = pendingBtwComposerRequest; + if (!request || request.parentSessionId !== currentSessionId) return; + if (!isBtwActive) { + useBtwStore.getState().setPanelState( + request.parentSessionId, + btwSessionId ? { collapsed: false } : { pending: true, collapsed: false }, + ); + return; + } + if (!chatDraftIdentity) return; + const consumed = consumePendingBtwComposerRequest(currentSessionId); + if (!consumed) return; + restoreDraft(chatDraftIdentity, consumed.text, new Set()); + queueMicrotask(() => focusChatInput()); + }, [btwSessionId, chatDraftIdentity, consumePendingBtwComposerRequest, currentSessionId, isBtwActive, pendingBtwComposerRequest, restoreDraft]); + // Focus textarea when new session draft is opened const prevNewSessionDraftOpenRef = React.useRef(newSessionDraftOpen); React.useEffect(() => { @@ -1009,7 +1124,7 @@ const ChatInputComponent: React.FC = ({ // Consume pending input text (e.g., from revert action) React.useEffect(() => { - if (pendingInputText !== null) { + if (!isBtwActive && pendingInputText !== null) { const pending = consumePendingInputText(); if (pending?.text) { if (pending.mode === 'append') { @@ -1029,11 +1144,12 @@ const ChatInputComponent: React.FC = ({ }, 0); } } - }, [pendingInputText, consumePendingInputText]); + }, [isBtwActive, pendingInputText, consumePendingInputText]); const hasContent = message.trim().length > 0 || attachedFiles.length > 0 || hasDrafts; - const hasQueuedMessages = queuedMessages.length > 0; - const canSend = hasContent || hasQueuedMessages; + const hasQueuedMessages = !isBtwActive && queuedMessages.length > 0; + const preparingBtwSend = useBtwStore((state) => Boolean(currentSessionId && state.byParent[currentSessionId]?.pendingSend)); + const canSend = (hasContent || hasQueuedMessages) && !(isBtwActive && (btwPanel.creating || preparingBtwSend)); const canAbort = sessionPhase !== 'idle'; @@ -1176,7 +1292,7 @@ const ChatInputComponent: React.FC = ({ return; } recordLinkedReferences(queueSessionId, queueTarget.directory, linked); - }, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, inputMode, hasDrafts, attachedFiles, sanitizeAttachmentsForSend, prepareDocumentMentions, extractInlineFileMentions, agents, currentDirectory, consumePendingSyntheticParts, inlineDraftTarget, consumeDrafts, linkedIssue, linkedPr, linkedLinearIssue, scrollToLatest, clearAttachedFiles, isMobile, addToQueue, currentProviderId, currentModelId, currentAgentName, currentVariant, t]); + }, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, inputMode, hasDrafts, attachedFiles, sanitizeAttachmentsForSend, prepareDocumentMentions, extractInlineFileMentions, agents, currentDirectory, consumePendingSyntheticParts, inlineDraftTarget, consumeDrafts, linkedIssue, linkedPr, linkedLinearIssue, scrollToLatest, clearAttachedFiles, isMobile, addToQueue, currentProviderId, currentModelId, currentAgentName, currentVariant, t]); /** Put the context a queued message was captured with back on the composer chips. */ const restoreQueuedContext = React.useCallback((context: readonly QueuedContextPart[]) => { @@ -1243,8 +1359,9 @@ const ChatInputComponent: React.FC = ({ }, []); const handleToggleExpandedInput = React.useCallback(() => { + if (isBtwActive) return; setExpandedInput(!isExpandedInput); - }, [isExpandedInput, setExpandedInput]); + }, [isBtwActive, isExpandedInput, setExpandedInput]); const openIssuePicker = React.useCallback(() => { if (gitProvider === 'gitlab') { @@ -1278,19 +1395,12 @@ const ChatInputComponent: React.FC = ({ }; const handleSubmit = async (options?: SubmitOptions) => { + if (isBtwActive && currentSessionId && (btwPanel.creating || useBtwStore.getState().byParent[currentSessionId]?.pendingSend)) return; const submitRuntimeKey = getRuntimeKey(); const queuedOnly = options?.queuedOnly ?? false; const queuedMessageId = options?.queuedMessageId; const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined; const capturedTarget = messageQueueTarget; - // An expired session cannot deliver anything: keep the prompt in the - // composer and point at the login banner instead of burning the send - // on a guaranteed 401. - if (useAuthSessionStore.getState().state !== 'ok') { - toast.error(t('sessionAuth.expired.sendBlocked')); - return; - } - // Snapshot the draft and current-session identity before the first // async gap so a later sidebar selection cannot reroute the send. const capturedDraftSnapshot = newSessionDraftOpen ? { ...newSessionDraft } : null; @@ -1330,6 +1440,32 @@ const ChatInputComponent: React.FC = ({ } if (commandPlan?.command.name === 'handoff-review' && (isMobile || isVSCodeRuntime())) commandPlan = null; + // Enter BTW before sending so the question uses its isolated selections. + // A bare command waits for input; an argument requests one immediate send. + if (commandPlan?.kind === 'prompt' && commandPlan.command.name === 'btw' && currentSessionId) { + const targetComposerId = btwSessionId ?? `btw-pending:${currentSessionId}`; + const targetIdentity = createChatDraftIdentity( + activeRuntimeKey, + btwDirectory ?? currentSessionDirectoryForSync ?? currentDirectory, + targetComposerId, + ); + const argument = commandPlan.command.argument.trim(); + handoffDraft(targetIdentity, isBtwActive ? argument : argument || null); + if (argument && targetIdentity) immediateBtwSubmitRef.current = { identity: targetIdentity, text: argument }; + if (btwSessionId) { + useBtwStore.getState().setPanelState(currentSessionId, { collapsed: false }); + return; + } + useBtwStore.getState().setPanelState(currentSessionId, { pending: true, creating: false, collapsed: false }); + return; + } + + // Opening BTW is local and still works while authentication is expired. + if (useAuthSessionStore.getState().state !== 'ok') { + toast.error(t('sessionAuth.expired.sendBlocked')); + return; + } + // A failed send returns the typed prompt no matter WHY it failed — // auth, network, server, anything. Losing a long prompt to a toast is // the one outcome this handler must never produce. The mentions are @@ -1337,22 +1473,7 @@ const ChatInputComponent: React.FC = ({ const confirmedMentionsSnapshot = new Set(confirmedMentionsRef.current); const restoreComposerText = () => { if (queuedOnly || !inputSnapshot.message) return; - for (const mention of confirmedMentionsSnapshot) confirmedMentionsRef.current.add(mention); - if (currentChatDraftIdentityRef.current !== chatDraftIdentity) { - // The user switched sessions mid-send: restore into that - // session's persisted draft, not the visible composer. - writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current); - return; - } - const currentInput = composerRef.current?.getValue() ?? messageRef.current; - if (!currentInput || currentInput === inputSnapshot.message) { - setMessage(inputSnapshot.message); - writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current); - } else { - // New typing already lives in the composer; the failed prompt - // joins it instead of clobbering either text. - useInputStore.getState().setPendingInputText(inputSnapshot.message, 'append'); - } + restoreDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsSnapshot); }; // The projection knows the captured send configuration; the full @@ -1362,10 +1483,10 @@ const ChatInputComponent: React.FC = ({ ? queuedMessages.filter((message) => message.id === queuedMessageId) : queuedMessages; const capturedSendConfig = queuedOnly ? queuedProjection[0]?.sendConfig : undefined; - const providerIdToSend = capturedSendConfig?.providerID ?? currentProviderId; - const modelIdToSend = capturedSendConfig?.modelID ?? currentModelId; - const agentNameToSend = capturedSendConfig?.agent ?? currentAgentName; - const variantToSend = capturedSendConfig?.variant ?? currentVariant; + const providerIdToSend = capturedSendConfig?.providerID ?? (isBtwActive ? effectiveBtwSelection.model?.providerId : currentProviderId); + const modelIdToSend = capturedSendConfig?.modelID ?? (isBtwActive ? effectiveBtwSelection.model?.modelId : currentModelId); + const agentNameToSend = capturedSendConfig?.agent ?? (isBtwActive ? effectiveBtwSelection.agent : currentAgentName); + const variantToSend = capturedSendConfig?.variant ?? (isBtwActive ? effectiveBtwSelection.variant : currentVariant); if (!providerIdToSend || !modelIdToSend) { console.warn('Cannot send message: provider or model not selected'); @@ -1419,7 +1540,7 @@ const ChatInputComponent: React.FC = ({ confirmedMentionsRef.current.clear(); persistDraftImmediately(chatDraftIdentity, ''); messageHistory.reset(); - setExpandedInput(false); + if (!isBtwActive) setExpandedInput(false); if (isMobile) composerRef.current?.blur(); try { if (actionName === 'undo') { @@ -1472,7 +1593,7 @@ const ChatInputComponent: React.FC = ({ ...queuedProjection.flatMap((queued) => queued.attachments?.map((attachment) => attachment.filename) ?? []), ]); const documentMentions = await prepareDocumentMentions( - !queuedOnly && inputSnapshot.hasContent ? [inputSnapshot.message] : [], + !isBtwActive && !queuedOnly && inputSnapshot.hasContent ? [inputSnapshot.message] : [], reservedFilenames, submitRuntimeKey, ); @@ -1515,7 +1636,7 @@ const ChatInputComponent: React.FC = ({ // Inline review comments and synthetic context are consumed before // assembly so a failed send can restore exactly what it took. What is // here belongs to this send: queueing took its own context with it. - const syntheticParts = consumePendingSyntheticParts(); + const syntheticParts = isBtwActive ? [] : consumePendingSyntheticParts(); const consumedDraftTarget = inlineDraftTarget; const drafts: InlineCommentDraft[] = consumedDraftTarget ? consumeDrafts(consumedDraftTarget) @@ -1555,21 +1676,23 @@ const ChatInputComponent: React.FC = ({ ...buildBtwSyntheticTexts({ isBtwActive, isPromotedBtwSession }), ...(syntheticParts?.map((part) => part.text) ?? []), ], - linkedIssue: linkedIssue + linkedIssue: !isBtwActive && linkedIssue ? { number: linkedIssue.number, title: linkedIssue.title, url: linkedIssue.url, contextText: linkedIssue.contextText } : null, - linkedPr: linkedPr + linkedPr: !isBtwActive && linkedPr ? { number: linkedPr.number, title: linkedPr.title, url: linkedPr.url, instructions: linkedPr.instructionsText, context: linkedPr.contextText } : null, - linkedLinearIssue: linkedLinearIssue + linkedLinearIssue: !isBtwActive && linkedLinearIssue ? { identifier: linkedLinearIssue.identifier, title: linkedLinearIssue.title, url: linkedLinearIssue.url, contextText: linkedLinearIssue.contextText } : null, }, { parseAgentMention: (text) => { + if (isBtwActive) return { text }; const { sanitizedText, mention } = parseAgentMentions(text, agents); return { text: sanitizedText, agentName: mention?.name }; }, extractFileMentions: (text) => { + if (isBtwActive) return { text, attachments: [] }; const { sanitizedText, attachments } = extractInlineFileMentions(text, preparedDocumentMentions); return { text: sanitizedText, attachments }; }, @@ -1586,6 +1709,7 @@ const ChatInputComponent: React.FC = ({ // Clear input (the queue was taken above) if (!queuedOnly) { setMessage(''); + messageRef.current = ''; confirmedMentionsRef.current.clear(); // Clear per-session draft on submit persistDraftImmediately(chatDraftIdentity, ''); @@ -1594,58 +1718,19 @@ const ChatInputComponent: React.FC = ({ clearAttachedFiles(); } // Close expanded input overlay when submitting - setExpandedInput(false); + if (!isBtwActive) setExpandedInput(false); } if (isMobile) { composerRef.current?.blur(); } - // Prompt commands render a visible prompt (or fork a btw question) and - // send it with everything the composer had attached. + // Prompt commands render a visible prompt and send it with everything + // the composer had attached. `/btw` was handled above as a composer + // transition and never reaches this sending path. if (commandPlan?.kind === 'prompt') { const { name: commandName, argument } = commandPlan.command; - if (commandName === 'btw' && currentSessionId) { - const question = argument.trim(); - if (!question) { - restoreConsumedInput(); - toast.error(t('chat.btw.toast.emptyArgument')); - return; - } - const targetDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) - || currentDirectory - || null; - if (!targetDirectory) { - restoreConsumedInput(); - toast.error(t('chat.btw.toast.createFailed')); - return; - } - try { - // A new btw replaces this session's current one: destroy - // the previous fork first so forks never accumulate. - if (btwSessionRef) { - await destroyBtwSession(btwSessionRef); - } - await startBtwSession({ - parentSessionId: currentSessionId, - question, - directory: targetDirectory, - providerID: providerIdToSend, - modelID: modelIdToSend, - agent: agentNameToSend, - variant: variantToSend, - attachments: primaryAttachments, - additionalParts, - }); - scrollToBottom?.(); - } catch (error) { - restoreConsumedInput(); - toast.error(getSubmitErrorMessage(error, t('chat.btw.toast.createFailed'))); - } - return; - } - // The rest render a visible prompt plus synthetic instructions and // send them as one message, the attached context riding along. const command = findMagicPromptCommand(commandName); @@ -1690,15 +1775,29 @@ const ChatInputComponent: React.FC = ({ } } - try { - const expandText = useSnippetsStore.getState().expandText; - primaryText = await expandText(primaryText); - for (const part of additionalParts) { - if (!part.synthetic) part.text = await expandText(part.text); + const expandOutgoingSnippets = async () => { + try { + const expandText = useSnippetsStore.getState().expandText; + primaryText = await expandText(primaryText); + for (const part of additionalParts) { + if (!part.synthetic) part.text = await expandText(part.text); + } + } catch (error) { + console.warn('[ChatInput] Failed to expand snippets, sending original text:', error); } - } catch (error) { - console.warn('[ChatInput] Failed to expand snippets, sending original text:', error); + }; + let pendingBtwSend: symbol | null = null; + if (isBtwActive && btwPanel.pending && currentSessionId) { + pendingBtwSend = await preparePendingBtwSend(currentSessionId, submitRuntimeKey, expandOutgoingSnippets); + if (!pendingBtwSend) { + if (getRuntimeKey() !== submitRuntimeKey) restoreComposerText(); + return; + } + } else { + await expandOutgoingSnippets(); } + const ownsPendingBtwSend = () => Boolean(pendingBtwSend && currentSessionId + && useBtwStore.getState().byParent[currentSessionId]?.pendingSend === pendingBtwSend); // Collect all attachments for error recovery const allAttachments = [ @@ -1711,6 +1810,59 @@ const ChatInputComponent: React.FC = ({ // never claims the new message. scrollToBottom?.(); + if (isBtwActive && btwPanel.pending && currentSessionId && btwComposerSessionId) { + const targetDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) + || currentDirectory + || null; + if (!targetDirectory) { + useBtwStore.getState().setPanelState(currentSessionId, { pendingSend: undefined }); + restoreConsumedInput(); + toast.error(t('chat.btw.toast.createFailed')); + return; + } + try { + const fork = await startBtwSession({ + parentSessionId: currentSessionId, + expectedRuntimeKey: submitRuntimeKey, + question: primaryText, + directory: targetDirectory, + providerID: providerIdToSend, + modelID: modelIdToSend, + agent: agentNameToSend, + variant: variantToSend, + attachments: primaryAttachments, + additionalParts, + permissionAutoAccept: pendingBtwAutoAccept, + }); + if (!ownsPendingBtwSend()) return; + if (getRuntimeKey() !== submitRuntimeKey) { + useBtwStore.getState().clearPanelState(currentSessionId); + return; + } + const forkDirectory = fork.directory ?? targetDirectory; + migrateDraft(chatDraftIdentity, createChatDraftIdentity(activeRuntimeKey, forkDirectory, fork.id)); + if (inlineDraftTarget) { + const drafts = useInlineCommentDraftStore.getState(); + drafts.restoreDrafts({ directory: forkDirectory, sessionKey: fork.id }, drafts.consumeDrafts(inlineDraftTarget)); + } + useBtwStore.getState().setPanelState(currentSessionId, { pending: false, creating: false, pendingSend: undefined }); + scrollToBottom?.(); + } catch (error) { + if (!ownsPendingBtwSend()) return; + if (getRuntimeKey() !== submitRuntimeKey) { + useBtwStore.getState().clearPanelState(currentSessionId); + restoreComposerText(); + return; + } + // Preserve the pending owner before restoring text so a failed + // first send never drops back into the parent draft. + useBtwStore.getState().setPanelState(currentSessionId, { pending: true, creating: false, collapsed: false, pendingSend: undefined }); + restoreConsumedInput(); + toast.error(getSubmitErrorMessage(error, t('chat.btw.toast.createFailed'))); + } + return; + } + const sendPromise = sendMessage( primaryText, providerIdToSend, @@ -1724,6 +1876,7 @@ const ChatInputComponent: React.FC = ({ sendMessageOptions, ); void sendPromise.then(() => { + if (isBtwActive) return; // On a draft there is no session yet in this closure: the send path // creates one and makes it current before resolving, so the id is // read from the store. The fallback is used only when the closure @@ -1853,6 +2006,18 @@ const ChatInputComponent: React.FC = ({ void handleSubmitRef.current({ presetText: next }); }, []); + // A command with an argument sends once the isolated composer owns its draft. + React.useEffect(() => { + const pending = immediateBtwSubmitRef.current; + if (!pending || !isBtwActive || !chatDraftIdentity) return; + if (getChatDraftIdentityKey(pending.identity) !== getChatDraftIdentityKey(chatDraftIdentity)) { + immediateBtwSubmitRef.current = null; + return; + } + immediateBtwSubmitRef.current = null; + void handleSubmit({ presetText: pending.text }); + }); + // Preset chips rendered outside this component (e.g. under the welcome // message on narrow surfaces) request a submit via the input store; consume // it here so it routes through the same command-aware submit path. @@ -1870,7 +2035,7 @@ const ChatInputComponent: React.FC = ({ // Enter shell mode before CodeMirror inserts the trigger. Keeping the // document unchanged also keeps the caret at the start for the first // command character. - if (inputMode === 'normal' && e.key === '!') { + if (!isBtwActive && inputMode === 'normal' && e.key === '!') { const selection = composerRef.current?.getSelection(); if (selection?.start === 0 && selection.end === 0) { e.preventDefault(); @@ -1892,40 +2057,24 @@ const ChatInputComponent: React.FC = ({ return; } - if (openAutocomplete === 'command' && commandRef.current) { - if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { - e.preventDefault(); - e.stopPropagation(); - commandRef.current.handleKeyDown(e.key); - return; - } + const autocomplete = openAutocomplete === 'command' ? commandRef.current + : openAutocomplete === 'skill' ? skillRef.current + : openAutocomplete === 'snippet' ? snippetRef.current + : openAutocomplete === 'mention' ? mentionRef.current + : null; + const autocompleteKey = getDropdownNavigationKey(e) ?? e.key; + if (autocomplete && (autocompleteKey === 'Enter' || autocompleteKey === 'ArrowUp' || autocompleteKey === 'ArrowDown' || autocompleteKey === 'Escape' || autocompleteKey === 'Tab')) { + e.preventDefault(); + e.stopPropagation(); + autocomplete.handleKeyDown(autocompleteKey); + return; } - if (openAutocomplete === 'skill' && skillRef.current) { - if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { - e.preventDefault(); - e.stopPropagation(); - skillRef.current.handleKeyDown(e.key); - return; - } - } - - if (openAutocomplete === 'snippet' && snippetRef.current) { - if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { - e.preventDefault(); - e.stopPropagation(); - snippetRef.current.handleKeyDown(e.key); - return; - } - } - - if (openAutocomplete === 'mention' && mentionRef.current) { - if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { - e.preventDefault(); - e.stopPropagation(); - mentionRef.current.handleKeyDown(e.key); - return; - } + if (isBtwActive && currentSessionId && e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + handleExitBtw(); + return; } if (isDesktopExpanded && e.key === 'Escape') { @@ -1943,7 +2092,7 @@ const ChatInputComponent: React.FC = ({ ? 1 : 0; - if (cycleAgentDirection !== 0 && openAutocomplete === null) { + if (!isBtwActive && cycleAgentDirection !== 0 && openAutocomplete === null) { e.preventDefault(); e.stopPropagation(); handleCycleAgent(cycleAgentDirection); @@ -1986,7 +2135,7 @@ const ChatInputComponent: React.FC = ({ const recalled = messageHistory.older({ text: message, attachments: attachedFiles }); if (recalled !== null) { setMessage(recalled.text); - useInputStore.getState().setAttachedFiles([...recalled.attachments]); + if (!isBtwActive) useInputStore.getState().setAttachedFiles([...recalled.attachments]); // Caret to the start, so the recalled message reads from its // beginning rather than from wherever the draft's caret was. requestAnimationFrame(() => composerRef.current?.setSelection(0, 0)); @@ -1999,7 +2148,7 @@ const ChatInputComponent: React.FC = ({ const recalled = messageHistory.newer({ text: message, attachments: attachedFiles }); if (recalled !== null) { setMessage(recalled.text); - useInputStore.getState().setAttachedFiles([...recalled.attachments]); + if (!isBtwActive) useInputStore.getState().setAttachedFiles([...recalled.attachments]); requestAnimationFrame(() => composerRef.current?.setSelection(recalled.text.length, recalled.text.length)); } return; @@ -2091,12 +2240,13 @@ const ChatInputComponent: React.FC = ({ ) => { const trigger = resolveAutocompleteTrigger(value, cursorPosition, { inputMode, + mentionsEnabled: !isBtwActive, inputSource, insertedText, }); setOpenAutocomplete(trigger?.kind ?? null); setAutocompleteQuery(trigger?.query ?? ''); - }, [inputMode]); + }, [inputMode, isBtwActive]); const insertTextAtSelection = React.useCallback(( text: string, @@ -2194,7 +2344,7 @@ const ChatInputComponent: React.FC = ({ // Mobile keyboards and paste may update the document without a usable // keydown, so consume the trigger in the same editor transaction rather // than moving the caret in a later frame against stale text. - if (inputMode === 'normal' && value.startsWith('!')) { + if (!isBtwActive && inputMode === 'normal' && value.startsWith('!')) { const shellCommand = value.slice(1); const nextCursor = Math.max(0, selection.start - 1); setInputMode('shell'); @@ -2221,6 +2371,10 @@ const ChatInputComponent: React.FC = ({ }, [clearDropTextSuppression, clearFileMentionPasteSuppression]); const handlePaste = React.useCallback(async (event: ClipboardEvent) => { + if (isBtwActive && event.clipboardData?.files.length) { + event.preventDefault(); + return; + } const clipboardData = event.clipboardData; if (!clipboardData) return; // Narrowed alias so the rest of the handler reads as it did when this @@ -2274,6 +2428,7 @@ const ChatInputComponent: React.FC = ({ const behavior: LargeTextPasteBehavior = largeTextPasteBehavior; const shouldOfferLargePaste = sessionReady && inputMode === 'normal' + && !isBtwActive && behavior !== 'inline' && isLargePlainTextPaste(pastedText); @@ -2433,7 +2588,7 @@ const ChatInputComponent: React.FC = ({ pendingPastedAttachmentFilenamesRef.current.delete(filename); } } - }, [addAttachedFile, attachedFiles, currentSessionId, inputMode, largeTextPasteBehavior, markFileMentionPasteSuppression, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]); + }, [addAttachedFile, attachedFiles, currentSessionId, inputMode, isBtwActive, largeTextPasteBehavior, markFileMentionPasteSuppression, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]); const handleFileSelect = (file: { name: string; path: string; relativePath?: string }) => { @@ -2568,7 +2723,11 @@ const ChatInputComponent: React.FC = ({ }; const handleCommandSelect = (command: CommandInfo) => { - + if (command.name === 'btw' && currentSessionId) { + closeAutocomplete(); + void handleSubmitRef.current({ presetText: '/btw' }); + return; + } setMessage(`/${command.name} `); closeAutocomplete(); @@ -2695,6 +2854,10 @@ const ChatInputComponent: React.FC = ({ }; const handleDrop = async (e: React.DragEvent) => { + if (isBtwActive) { + e.preventDefault(); + return; + } dragEnterCountRef.current = 0; const draggedFiles = hasDraggedFiles(e.dataTransfer); if (!draggedFiles) { @@ -2780,6 +2943,7 @@ const ChatInputComponent: React.FC = ({ const fileInputRef = React.useRef(null); const attachFiles = React.useCallback(async (files: FileList | File[]) => { + if (isBtwActive) return; const list = Array.isArray(files) ? files : Array.from(files); let attached = false; @@ -2793,9 +2957,10 @@ const ChatInputComponent: React.FC = ({ if (list.length > 0 && !attached) { toast.error(t('chat.chatInput.toast.attachFileFailed')); } - }, [addAttachedFile, t]); + }, [addAttachedFile, isBtwActive, t]); const handleVSCodePickFiles = React.useCallback(async () => { + if (isBtwActive) return; try { const data = (await vscodeApi?.pickFiles?.({ extensions: ACCEPTED_ATTACHMENT_EXTENSIONS })) as { files?: Array<{ name: string; mimeType?: string; dataUrl?: string }>; @@ -2839,22 +3004,27 @@ const ChatInputComponent: React.FC = ({ console.error('VS Code file pick failed', error); toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.vscodePickFailed')); } - }, [attachFiles, t, vscodeApi]); + }, [attachFiles, isBtwActive, t, vscodeApi]); const handlePickLocalFiles = React.useCallback(() => { + if (isBtwActive) return; if (isVSCodeRuntime()) { void handleVSCodePickFiles(); return; } fileInputRef.current?.click(); - }, [handleVSCodePickFiles]); + }, [handleVSCodePickFiles, isBtwActive]); const handleLocalFileSelect = React.useCallback(async (event: React.ChangeEvent) => { + if (isBtwActive) { + event.target.value = ''; + return; + } const files = event.target.files; if (!files) return; await attachFiles(files); event.target.value = ''; - }, [attachFiles]); + }, [attachFiles, isBtwActive]); const footerGapClass = 'gap-x-1.5 gap-y-0'; const isVSCode = isVSCodeRuntime(); @@ -3002,8 +3172,9 @@ const ChatInputComponent: React.FC = ({ const iconButtonBaseClass = 'flex cursor-pointer items-center justify-center text-foreground transition-none outline-none focus:outline-none flex-shrink-0 disabled:cursor-not-allowed'; const footerIconButtonClass = cn(iconButtonBaseClass, buttonSizeClass); - const permissionScopeSessionId = currentSessionId ?? currentManagementSessionId; + const permissionScopeSessionId = isBtwActive ? btwSessionId : currentSessionId ?? currentManagementSessionId; const permissionAutoAcceptEnabled = usePermissionStore((state) => { + if (isBtwActive && !btwSessionId) return pendingBtwAutoAccept; if (!permissionScopeSessionId) { return draftPermissionAutoAcceptEnabled; } @@ -3012,6 +3183,10 @@ const ChatInputComponent: React.FC = ({ const isPermissionAutoAcceptInteractive = Boolean(permissionScopeSessionId || newSessionDraftOpen); const handlePermissionAutoAcceptToggle = React.useCallback(() => { + if (isBtwActive && !btwSessionId && currentSessionId) { + useBtwStore.getState().setPanelState(currentSessionId, { pendingAutoAccept: !pendingBtwAutoAccept }); + return; + } togglePermissionAutoAccept({ permissionScopeSessionId, newSessionDraftOpen, @@ -3027,6 +3202,10 @@ const ChatInputComponent: React.FC = ({ newSessionDraftOpen, permissionAutoAcceptEnabled, permissionScopeSessionId, + isBtwActive, + btwSessionId, + currentSessionId, + pendingBtwAutoAccept, setDraftPermissionAutoAcceptEnabled, setSessionAutoAccept, t, @@ -3051,6 +3230,15 @@ const ChatInputComponent: React.FC = ({ <>
{ + if (!isBtwActive || event.key !== 'Escape' || isIMECompositionEvent(event) || hasOpenDropdown()) return; + if (!(event.target instanceof Element) || !event.target.closest('[data-chat-input-footer]')) return; + // Footer tooltips must not consume the only exit key for a pending BTW. + event.preventDefault(); + event.stopPropagation(); + handleExitBtw(); + }} onSubmit={(e) => { e.preventDefault(); handlePrimaryAction(); }} className={cn( "relative w-full pt-0 pb-4", @@ -3073,11 +3261,11 @@ const ChatInputComponent: React.FC = ({
) : null}
- - : null} + {!isBtwActive ? + /> : null} {hasDrafts ? ( = ({ selectedBranchLabel={selectedDraftBranchLabel} selectedBranchIsKnown={selectedDraftBranchIsKnown} hasUncommittedChanges={selectedDraftDirectoryHasUncommittedChanges} + announceDirtyState={newSessionDraftAnnouncesDirtyState} projectRootBranchOption={projectRootBranchOption} worktreeBranchOptions={worktreeBranchOptions} branchItems={draftBranchItems} @@ -3160,7 +3349,6 @@ const ChatInputComponent: React.FC = ({ = ({ isMobileExpanded && 'flex min-h-0 flex-1 flex-col', )} > - {isMobile && !mobileComposerExpanded ? ( + {isMobile && !mobileComposerExpanded && !isBtwActive ? ( = ({ /> ) : ( <> - - : null} + {!isBtwActive ?
- + {!selectedFile ? (
{t('filesView.editor.pickFileFromTree')}
) : (fileLoading || isPdfAssetAuthLoading) ? ( @@ -3968,10 +4052,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { // plain div never holds focus. -1 keeps it out of the tab order. tabIndex={-1} onMouseDown={focusMdPreviewContainer} - ref={(node) => { - markdownPreviewRef.current = node; - mdPreviewContainerRef.current = node; - }} + ref={setMainMarkdownScroller} > = ({ mode = 'full' }) => {
) ) : selectedFile && canUseShikiFileView && textViewMode === 'view' ? ( - renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, mainViewVirtualizer) + renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, mainViewVirtualizer, restoreMainCodeScroll) ) : (
- = ({ mode = 'full' }) => {
{renderFloatingFileControls({ exitFullscreenOnly: true })}
- + {(fileLoading || isPdfAssetAuthLoading) ? ( suppressFileLoadingIndicator ?
@@ -4360,14 +4443,11 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { className="oc-file-preview h-full overflow-auto p-4 outline-none" tabIndex={-1} onMouseDown={focusMdPreviewContainer} - ref={(node) => { - markdownPreviewRef.current = node; - mdFullscreenPreviewContainerRef.current = node; - }} + ref={setFullscreenMarkdownScroller} > {selectedFile ? ( @@ -4404,11 +4484,13 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { />
) : canUseShikiFileView && textViewMode === 'view' ? ( - renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, fullscreenViewVirtualizer) + renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, fullscreenViewVirtualizer, restoreFullscreenCodeScroll) ) : (
- = ({ isActive }) => { ); React.useEffect(() => { - if (!gitDirectory || changeEntries.length === 0) { + if (!isActive || !gitDirectory || changeEntries.length === 0) { return; } @@ -1075,7 +1075,7 @@ export const GitView: React.FC = ({ isActive }) => { return () => { window.clearTimeout(timeoutId); }; - }, [changeEntries, gitDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]); + }, [isActive, changeEntries, gitDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]); const getPushedRemoteName = (result?: Awaited>) => { return result?.pushed[0]?.remote diff --git a/packages/ui/src/components/views/PierreDiffViewer.tsx b/packages/ui/src/components/views/PierreDiffViewer.tsx index a1009bff..767fa130 100644 --- a/packages/ui/src/components/views/PierreDiffViewer.tsx +++ b/packages/ui/src/components/views/PierreDiffViewer.tsx @@ -1,4 +1,5 @@ import React, { useMemo, useRef, useCallback, useEffect } from 'react'; +import { createPortal } from 'react-dom'; import { areFilesEqual, areOptionsEqual, @@ -29,6 +30,20 @@ import { getDefaultTheme } from '@/lib/theme/themes'; import { useDeviceInfo } from '@/lib/device'; import { cn } from '@/lib/utils'; +import type { PatchHunkAnchor } from '@/lib/diff/patchFileDiff'; + +export interface DiffHunkActions { + anchors: readonly PatchHunkAnchor[]; + render: (index: number) => React.ReactNode; +} + +type DiffAnnotation = PierreAnnotationData | { type: 'hunk-action'; index: number }; +const EMPTY_HUNK_ANCHORS: readonly PatchHunkAnchor[] = []; + +const HUNK_ACTION_OVERLAY_CSS = ` + [data-gutter-buffer="annotation"] { min-height: 0; } + [data-code] { min-height: 2.5rem; align-content: start; } +`; // Threshold (bytes) above which syntax highlighting is degraded for performance @@ -44,6 +59,7 @@ interface PierreDiffViewerProps { wrapLines?: boolean; layout?: 'fill' | 'inline'; enableComments?: boolean; + hunkActions?: DiffHunkActions; } /** @@ -444,7 +460,7 @@ function acquireSharedVirtualizer(container: HTMLElement): SharedVirtualizer | n } const wakeVirtualizer = ( - instance: PierreFileDiff, + instance: PierreFileDiff, sharedVirtualizer: SharedVirtualizer | null, forceUpdate: () => void, ): (() => void) => { @@ -491,6 +507,7 @@ export const PierreDiffViewer: React.FC = ({ wrapLines, layout = 'fill', enableComments = true, + hunkActions, }) => { const themeContext = useOptionalThemeSystem(); @@ -499,6 +516,12 @@ export const PierreDiffViewer: React.FC = ({ const darkTheme = themeContext?.availableThemes.find(t => t.metadata.id === themeContext.darkThemeId) ?? getDefaultTheme(true); const { isMobile } = useDeviceInfo(); + const hunkAnchors = hunkActions?.anchors ?? EMPTY_HUNK_ANCHORS; + const [hunkTargets, setHunkTargets] = React.useState<{ + fileDiff: FileDiffMetadata | undefined; + anchors: readonly PatchHunkAnchor[]; + targets: ReadonlyMap; + }>(() => ({ fileDiff: undefined, anchors: EMPTY_HUNK_ANCHORS, targets: new Map() })); const diffCommentController = useInlineCommentController({ source: 'diff', @@ -587,10 +610,16 @@ export const PierreDiffViewer: React.FC = ({ cancel(); }, [cancel]); - const renderAnnotation = useCallback((annotation: DiffLineAnnotation) => { + const renderAnnotation = useCallback((annotation: DiffLineAnnotation) => { const div = document.createElement('div'); div.style.position = 'relative'; + if (annotation.metadata.type === 'hunk-action') { + div.dataset.hunkActionTarget = String(annotation.metadata.index); + div.style.height = '0px'; + return div; + } + const id = toPierreAnnotationId(annotation.metadata); div.dataset.annotationId = id; @@ -599,6 +628,52 @@ export const PierreDiffViewer: React.FC = ({ return div; }, []); + const captureHunkTargets = useCallback['onPostRender']>>((node, instance, phase) => { + const targets = new Map(); + if (phase !== 'unmount') { + const capsuleHeight = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) * 2; + const columns = new Map(); + const placements: Array<{ target: HTMLElement; offset: number }> = []; + // Only mounted virtual rows have slots. Avoid creating React controls + // for off-screen hunks or measuring every line on a scroll event. + for (const slot of node.shadowRoot?.querySelectorAll('slot') ?? []) { + for (const wrapper of slot.assignedElements()) { + const target = wrapper.querySelector('[data-hunk-action-target]'); + const index = Number(target?.dataset.hunkActionTarget); + if (!target || !Number.isInteger(index) || index < 0) continue; + targets.set(index, target); + const column = slot.closest('[data-code]'); + if (!column) continue; + let bounds = columns.get(column); + if (!bounds) { + bounds = column.getBoundingClientRect(); + columns.set(column, bounds); + } + const markerTop = target.getBoundingClientRect().top; + // Float over the following context. At EOF, lift the capsule inside + // the code column so its vertical clipping cannot hide the buttons. + const top = Math.max(bounds.top + 4, Math.min(markerTop + 4, bounds.bottom - capsuleHeight - 4)); + placements.push({ target, offset: top - markerTop }); + } + } + // Finish all geometry reads before writing offsets to avoid layout + // recalculation between neighboring hunks. + for (const { target, offset } of placements) { + const value = `${offset}px`; + if (target.style.getPropertyValue('--oc-hunk-action-offset') !== value) { + target.style.setProperty('--oc-hunk-action-offset', value); + } + } + } + const renderedDiff = instance.fileDiff; + setHunkTargets((previous) => { + if (previous.fileDiff === renderedDiff && previous.anchors === hunkAnchors + && previous.targets.size === targets.size + && [...targets].every(([index, target]) => previous.targets.get(index) === target)) return previous; + return { fileDiff: renderedDiff, anchors: hunkAnchors, targets }; + }); + }, [hunkAnchors]); + const handleSaveComment = useCallback((textToSave: string, rangeOverride?: SelectedLineRange) => { saveComment(textToSave, rangeOverride ?? selection ?? undefined); }, [saveComment, selection]); @@ -851,11 +926,11 @@ export const PierreDiffViewer: React.FC = ({ const diffRootRef = useRef(null); const diffContainerRef = useRef(null); - const diffInstanceRef = useRef | null>(null); + const diffInstanceRef = useRef | null>(null); const sharedVirtualizerRef = useRef(null); const instanceVirtualizerRef = useRef(null); const instanceWorkerPoolRef = useRef(null); - const instanceVirtualHunkSeparatorsRef = useRef['hunkSeparators'] | undefined>(undefined); + const instanceVirtualHunkSeparatorsRef = useRef['hunkSeparators'] | undefined>(undefined); const instanceFileDiffRef = useRef(undefined); const instanceOldFileRef = useRef(undefined); const instanceNewFileRef = useRef(undefined); @@ -940,46 +1015,47 @@ export const PierreDiffViewer: React.FC = ({ }, [darkResolvedTheme, diffThemeKey, isDark, lightResolvedTheme]); - const options = useMemo(() => ({ + const options = useMemo>(() => ({ theme: { dark: darkTheme.metadata.id, light: lightTheme.metadata.id, }, - themeType: isDark ? ('dark' as const) : ('light' as const), - diffStyle: renderSideBySide ? ('split' as const) : ('unified' as const), - diffIndicators: 'none' as const, - hunkSeparators: 'line-info-basic' as const, + themeType: isDark ? 'dark' : 'light', + diffStyle: renderSideBySide ? 'split' : 'unified', + diffIndicators: 'none', + hunkSeparators: 'line-info-basic', // Perf: disable intra-line diff (word-level) globally. - lineDiffType: 'none' as const, + lineDiffType: 'none', // Perf: degrade tokenization/highlighting for large files (>500KB) maxLineDiffLength: isLargeContent ? 0 : 1000, maxLineLengthForHighlighting: isLargeContent ? 1 : 1000, tokenizeMaxLineLength: isLargeContent ? 1 : 1000, collapsedContextThreshold: 0, expansionLineCount: 20, - overflow: wrapLines ? ('wrap' as const) : ('scroll' as const), + overflow: wrapLines ? 'wrap' : 'scroll', disableFileHeader: true, enableLineSelection: enableComments, enableGutterUtility: enableComments, onGutterUtilityClick: enableComments ? handleGutterUtilityClick : undefined, onLineClick: enableComments ? handleLineClick : undefined, onLineSelected: enableComments ? handleSelectionChange : undefined, - unsafeCSS: WEBKIT_SCROLL_FIX_CSS, - renderAnnotation: enableComments ? renderAnnotation : undefined, - }), [darkTheme.metadata.id, enableComments, isDark, isLargeContent, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange, handleGutterUtilityClick, handleLineClick, renderAnnotation]); + unsafeCSS: hunkAnchors.length > 0 ? `${WEBKIT_SCROLL_FIX_CSS}\n${HUNK_ACTION_OVERLAY_CSS}` : WEBKIT_SCROLL_FIX_CSS, + renderAnnotation: enableComments || hunkAnchors.length > 0 ? renderAnnotation : undefined, + onPostRender: hunkAnchors.length > 0 ? captureHunkTargets : undefined, + }), [captureHunkTargets, hunkAnchors.length, darkTheme.metadata.id, enableComments, isDark, isLargeContent, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange, handleGutterUtilityClick, handleLineClick, renderAnnotation]); - const lineAnnotations = useMemo(() => { - if (!enableComments) { - return []; - } - - return buildPierreLineAnnotations({ + const lineAnnotations = useMemo[]>(() => { + const annotations: DiffLineAnnotation[] = enableComments ? buildPierreLineAnnotations({ drafts: fileDrafts, editingDraftId, selection, - }); - }, [editingDraftId, enableComments, fileDrafts, selection]); + }) : []; + for (const anchor of hunkAnchors) { + annotations.push({ side: anchor.side, lineNumber: anchor.lineNumber, metadata: { type: 'hunk-action', index: anchor.index } }); + } + return annotations; + }, [editingDraftId, enableComments, fileDrafts, hunkAnchors, selection]); const lineAnnotationsRef = useRef(lineAnnotations); @@ -1068,17 +1144,17 @@ export const PierreDiffViewer: React.FC = ({ : false; if (!instance) { instance = sharedVirtualizer - ? new VirtualizedFileDiff( - options as FileDiffOptions, + ? new VirtualizedFileDiff( + options, sharedVirtualizer.virtualizer, VIRTUAL_METRICS, workerPool, ) - : new PierreFileDiff(options as FileDiffOptions, workerPool); + : new PierreFileDiff(options, workerPool); diffInstanceRef.current = instance; lastAppliedSelectionRef.current = null; } else { - instance.setOptions(options as FileDiffOptions); + instance.setOptions(options); } instanceVirtualizerRef.current = virtualizer; @@ -1291,6 +1367,12 @@ export const PierreDiffViewer: React.FC = ({ /> ) : null; + // A new action snapshot must never land in annotation nodes belonging to + // the previously rendered diff, even for one frame before Pierre updates. + const hunkActionPortals = hunkActions && fileDiff && hunkTargets.fileDiff === fileDiff && hunkTargets.anchors === hunkAnchors + ? [...hunkTargets.targets].map(([index, target]) => createPortal(hunkActions.render(index), target, `hunk-${index}`)) + : null; + if (layout === 'fill') { return (
@@ -1306,6 +1388,7 @@ export const PierreDiffViewer: React.FC = ({
{commentOverlays} + {hunkActionPortals}
); @@ -1318,6 +1401,7 @@ export const PierreDiffViewer: React.FC = ({
{commentOverlays} + {hunkActionPortals}
); }; diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index 740fcd01..39a61f6d 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -19,6 +19,7 @@ import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore'; import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore'; import { Tooltip, TooltipTrigger } from '@/components/ui/tooltip'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { AgentsSidebar } from '@/components/sections/agents/AgentsSidebar'; import { AgentsPage } from '@/components/sections/agents/AgentsPage'; import { BehaviorPage } from '@/components/sections/behavior/BehaviorPage'; @@ -91,6 +92,9 @@ interface SettingsViewProps { isWindowed?: boolean; /** Restrict top-level settings navigation to a specific product surface. */ visiblePageSlugs?: SettingsPageSlug[]; + /** Lets a native shell hand its hardware back button to the mobile stages: + the handler steps one level up and reports whether it consumed the press. */ + registerBackHandler?: (handler: (() => boolean) | null) => void; initialMobileStage?: MobileStage; } @@ -185,7 +189,7 @@ function getCurrentHistoryState(): Record { } -export const SettingsView: React.FC = ({ onClose, forceMobile, isWindowed, visiblePageSlugs, initialMobileStage = 'nav' }) => { +export const SettingsView: React.FC = ({ onClose, forceMobile, isWindowed, visiblePageSlugs, initialMobileStage = 'nav', registerBackHandler }) => { const { t } = useI18n(); const deviceInfo = useDeviceInfo(); const isMobile = forceMobile ?? deviceInfo.isMobile; @@ -738,10 +742,12 @@ export const SettingsView: React.FC = ({ onClose, forceMobile }, [isMobile, mobileStage, settingsSlug]); const showBackButton = isMobile && mobileStage !== 'nav'; - const backButtonTargetsPageSidebar = isMobile && mobileStage === 'page-content' && settingsSlug === 'skills.installed'; - const showOpenPageSidebarButton = mobileStage === 'page-content' - && activePageMeta?.kind === 'split' - && !backButtonTargetsPageSidebar; + // Split pages drill down on mobile: nav → the page's own list → the item. + // Back walks that path in reverse, so it takes one tap to reach the next + // item instead of a round trip through the settings root. + const backButtonTargetsPageSidebar = isMobile + && mobileStage === 'page-content' + && activePageMeta?.kind === 'split'; const mobileBackButtonLabel = backButtonTargetsPageSidebar ? t('settings.view.actions.back') : showBackButton @@ -780,9 +786,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile const handleMobilePageSidebarItemSelect = React.useCallback(() => { shouldFocusMobilePageContentRef.current = true; setMobileStage('page-content'); - if (settingsSlug === 'skills.installed') { - pushMobileSplitDetailHistory(settingsSlug); - } + pushMobileSplitDetailHistory(settingsSlug); }, [pushMobileSplitDetailHistory, settingsSlug]); React.useEffect(() => { @@ -818,18 +822,35 @@ export const SettingsView: React.FC = ({ onClose, forceMobile setMobileStage('nav'); }, [backButtonTargetsPageSidebar, runtimeCtx.isVSCode, settingsSlug]); + // The Android hardware back button belongs to the same ladder as the header's + // back arrow: one level up per press, and only the press at the root falls + // through to the shell, which closes Settings. + React.useEffect(() => { + if (!registerBackHandler) { + return; + } + registerBackHandler(() => { + if (!isMobile || mobileStage === 'nav') { + return false; + } + handleBack(); + return true; + }); + return () => registerBackHandler(null); + }, [handleBack, isMobile, mobileStage, registerBackHandler]); + React.useEffect(() => { if (!isMobile || runtimeCtx.isVSCode) { return; } const handlePopState = (event: PopStateEvent) => { - if (settingsSlug !== 'skills.installed') { + if (getSettingsPageMeta(settingsSlug)?.kind !== 'split') { return; } const detail = getSettingsDetailHistoryEntry(event.state); - if (detail?.page === 'skills.installed') { + if (detail?.page === settingsSlug) { setMobileStage('page-content'); return; } @@ -843,10 +864,6 @@ export const SettingsView: React.FC = ({ onClose, forceMobile }; }, [isMobile, runtimeCtx.isVSCode, settingsSlug]); - const handleOpenPageSidebar = React.useCallback(() => { - setMobileStage('page-sidebar'); - }, []); - const renderSettingsNav = () => { const hasSearchQuery = settingsSearchQuery.trim().length > 0; @@ -877,7 +894,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile
{/* Scrollable nav items */} -
+
{hasSearchQuery ? ( settingsSearchResults.length > 0 ? (() => { @@ -992,7 +1009,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile )); })()}
-
+ {/* Footer */}
@@ -1026,17 +1043,17 @@ export const SettingsView: React.FC = ({ onClose, forceMobile // No sidebar available; fall back to direct content. const fallback = renderPageContent(settingsSlug); return ( -
+ {fallback} -
+ ); } return ( -
+ {renderPageSidebar(settingsSlug, { onItemSelect: handleMobilePageSidebarItemSelect })} -
+ ); } @@ -1044,9 +1061,9 @@ export const SettingsView: React.FC = ({ onClose, forceMobile const content = renderPageContent(settingsSlug); return ( -
+ {content} -
+ ); }; @@ -1061,17 +1078,17 @@ export const SettingsView: React.FC = ({ onClose, forceMobile
{renderPageSidebar(settingsSlug, {})}
-
+ {renderPageContent(settingsSlug)} -
+
); } return ( -
+ {renderPageContent(settingsSlug)} -
+ ); }; @@ -1106,17 +1123,6 @@ export const SettingsView: React.FC = ({ onClose, forceMobile : (activePageMeta ? getPageTitle(activePageMeta.slug) : t('settings.view.home.title'))}
- {showOpenPageSidebarButton && ( - - )} - {onClose && ( + {previewUrl ? ( + ); + const picker = ( + { + if (event.key !== 'Escape') event.stopPropagation(); + }}> + + + {t('gitView.branch.empty')} + + {open && rankByQuery( + [...new Set(branches)] + .filter((name) => name !== currentBranch) + .sort() + .map((name) => ({ + ref: name.startsWith('remotes/') ? `refs/${name}` : `refs/heads/${name}`, + label: branchRefLabel(name), + })), + search, + (branch) => [branch.label], + ).map((branch) => ( + { + onSelect(branch.ref); + changeOpen(false); + }}> + {branch.label} + {(branch.ref === base || branch.label === base) && } + + ))} + + + + ); + if (useSheet) { + return <> + {trigger} + changeOpen(false)}> + {picker} + + ; + } + return ( + + {trigger} + + {picker} + + + ); +} diff --git a/packages/ui/src/components/views/git/CommitComparisonSelector.test.tsx b/packages/ui/src/components/views/git/CommitComparisonSelector.test.tsx new file mode 100644 index 00000000..da73285d --- /dev/null +++ b/packages/ui/src/components/views/git/CommitComparisonSelector.test.tsx @@ -0,0 +1,99 @@ +import React, { act, useState } from 'react'; +import { expect, test } from 'bun:test'; +import { Window } from 'happy-dom'; +import type { GitLogEntry } from '@/lib/api/types'; + +const checkCommitSelection = async (mobile: boolean, tablet = false) => { + const dom = new Window({ url: 'http://localhost' }); + if (mobile && !tablet) dom.happyDOM.setWindowSize({ width: 390, height: 844 }); + const originals = new Map(); + const globals = { + window: dom, document: dom.document, navigator: dom.navigator, location: dom.location, + Element: dom.Element, HTMLElement: dom.HTMLElement, HTMLInputElement: dom.HTMLInputElement, + Node: dom.Node, Event: dom.Event, KeyboardEvent: dom.KeyboardEvent, MouseEvent: dom.MouseEvent, + MutationObserver: dom.MutationObserver, ResizeObserver: dom.ResizeObserver, + getComputedStyle: dom.getComputedStyle.bind(dom), requestAnimationFrame: dom.requestAnimationFrame.bind(dom), + cancelAnimationFrame: dom.cancelAnimationFrame.bind(dom), IS_REACT_ACT_ENVIRONMENT: true, + }; + for (const [name, value] of Object.entries(globals)) { + originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); + Object.defineProperty(globalThis, name, { configurable: true, writable: true, value }); + } + const { createRoot } = await import('react-dom/client'); + const { I18nProvider } = await import('@/lib/i18n'); + const { CommitComparisonSelector } = await import('./CommitComparisonSelector'); + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + const commits: GitLogEntry[] = ['a', 'b'].map((letter, index) => ({ + hash: letter.repeat(40), message: index === 0 ? 'fix: first commit' : 'feat: second commit', + author_name: 'Test Author', author_email: 'test@example.com', date: '2026-09-09T09:22:00Z', + body: '', refs: '', parents: [], filesChanged: 1, insertions: 2, deletions: 1, + })); + const selected: string[] = []; + let refreshes = 0; + function Harness() { + const [hash, setHash] = useState(null); + return <>{['changes', 'walkthrough'].map((name) =>
+ { refreshes += 1; }} + onSelect={(commit) => { selected.push(commit.hash); setHash(commit.hash); }} /> +
)}; + } + const trigger = (name: string) => { + const button = container.querySelector(`[data-picker="${name}"] button`); + if (!button) throw new Error('Missing commit picker'); + return button; + }; + const input = () => { + const value = document.querySelector('input'); + if (!value) throw new Error('Missing commit search'); + return value; + }; + const press = async (key: string, ctrlKey = false) => { + await act(async () => { input().dispatchEvent(new KeyboardEvent('keydown', { key, ctrlKey, bubbles: true, cancelable: true })); }); + }; + try { + await act(async () => root.render()); + await act(async () => trigger('changes').click()); + if (mobile) { + if (tablet) expect(document.querySelector('[role="dialog"]')).toBeNull(); + else expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + if (!tablet) expect(document.activeElement).not.toBe(input()); + } + const first = document.querySelector('[cmdk-item]'); + expect(first?.textContent).toContain('fix: first commit'); + expect(first?.textContent).toContain('Test Author'); + expect(first?.textContent).toContain('2026'); + expect(first?.textContent).toContain('aaaaaaaa'); + await press('ArrowDown'); + expect(document.querySelector('[cmdk-item][data-selected="true"]')?.getAttribute('data-value')).toBe('b'.repeat(40)); + await press('p', true); + await press('n', true); + await press('Enter'); + expect(trigger('walkthrough').textContent).toContain('bbbbbbbb'); + + await act(async () => trigger('walkthrough').click()); + await act(async () => { + Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, 'value')?.set?.call(input(), 'first'); + input().dispatchEvent(new Event('input', { bubbles: true })); + }); + expect(document.querySelectorAll('[cmdk-item]')).toHaveLength(1); + await press('Enter'); + expect(trigger('changes').textContent).toContain('aaaaaaaa'); + expect(selected).toEqual(['b'.repeat(40), 'a'.repeat(40)]); + expect(refreshes).toBe(2); + } finally { + await act(async () => root.unmount()); + await dom.happyDOM.abort(); + for (const [name, descriptor] of originals) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else Reflect.deleteProperty(globalThis, name); + } + } +}; + +for (const mobile of [false, true]) { + test(`shows commit metadata and shares repeated searched selections between two pickers (mobile=${mobile})`, () => checkCommitSelection(mobile)); +} +test('keeps the mobile commit picker anchored on tablets', () => checkCommitSelection(true, true)); diff --git a/packages/ui/src/components/views/git/CommitComparisonSelector.tsx b/packages/ui/src/components/views/git/CommitComparisonSelector.tsx new file mode 100644 index 00000000..e936abd6 --- /dev/null +++ b/packages/ui/src/components/views/git/CommitComparisonSelector.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react'; +import type { GitLogEntry } from '@/lib/api/types'; +import { Icon } from '@/components/icon/Icon'; +import { Button } from '@/components/ui/button'; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger'; +import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; +import { useI18n } from '@/lib/i18n'; +import { useTabletLayout } from '@/lib/device'; +import { rankByQuery } from '@/lib/search/fuzzySearch'; +import { formatDateTimeForPreference } from '@/lib/timeFormat'; +import { useUIStore } from '@/stores/useUIStore'; +import { cn } from '@/lib/utils'; + +interface CommitComparisonSelectorProps { + commits: readonly GitLogEntry[]; + selectedHash: string | null; + loading: boolean; + error: string | null; + onSelect: (commit: GitLogEntry) => void; + onRefresh: () => void; + mobile?: boolean; +} + +export function CommitComparisonSelector({ commits, selectedHash, loading, error, onSelect, onRefresh, mobile = false }: CommitComparisonSelectorProps) { + const { t } = useI18n(); + const tabletLayout = useTabletLayout(); + const useSheet = mobile && !tabletLayout.enabled; + const timeFormat = useUIStore((state) => state.timeFormatPreference); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const changeOpen = (value: boolean) => { + setOpen(value); + if (!value) setSearch(''); + else if (!loading) onRefresh(); + }; + const trigger = ( + + ); + const picker = ( + { if (event.key !== 'Escape') event.stopPropagation(); }}> + + {loading ? ( +
+ {t('diffView.state.loadingChanges')} +
+ ) : error ? ( +
+ {t('commitComparison.loadError')} + {error} + +
+ ) : ( + + {t('commitComparison.noCommits')} + + {open && rankByQuery(commits, search, (commit) => [commit.message, commit.author_name, commit.hash]).map((commit) => ( + { onSelect(commit); changeOpen(false); }}> +
+
{commit.message}
+
+ + {commit.author_name} · {Number.isNaN(new Date(commit.date).getTime()) ? commit.date : formatDateTimeForPreference(new Date(commit.date), timeFormat, { + year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', + })} + + · {commit.hash.slice(0, 8)} +
+
+ {commit.hash === selectedHash && } +
+ ))} +
+
+ )} +
+ ); + if (useSheet) { + return <> + {trigger} + changeOpen(false)}> + {picker} + + ; + } + return ( + + {trigger} + + {picker} + + + ); +} diff --git a/packages/ui/src/components/views/git/HunkActions.test.tsx b/packages/ui/src/components/views/git/HunkActions.test.tsx new file mode 100644 index 00000000..75fbf960 --- /dev/null +++ b/packages/ui/src/components/views/git/HunkActions.test.tsx @@ -0,0 +1,61 @@ +import React, { act } from 'react'; +import { expect, test } from 'bun:test'; +import { Window } from 'happy-dom'; +import type { HunkBusyState } from './HunkActions'; + +test('each compact capsule acts on its own hunk and shares the mutation lock', async () => { + const dom = new Window({ url: 'http://localhost' }); + const originals = new Map(); + for (const [name, value] of Object.entries({ + window: dom, document: dom.document, navigator: dom.navigator, + Element: dom.Element, HTMLElement: dom.HTMLElement, Node: dom.Node, + Event: dom.Event, MouseEvent: dom.MouseEvent, IS_REACT_ACT_ENVIRONMENT: true, + })) { + originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); + Object.defineProperty(globalThis, name, { configurable: true, writable: true, value }); + } + const { createRoot } = await import('react-dom/client'); + const { I18nProvider } = await import('@/lib/i18n'); + const { HunkActions } = await import('./HunkActions'); + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + const actions: string[] = []; + const render = (staged = false, busyHunk: HunkBusyState = null) => act(async () => root.render( + {[0, 1, 2].map((index) => actions.push(`${action}:${hunk}`)} />)} + )); + const button = (label: string) => { + const target = container.querySelector(`button[aria-label="${label}"]`); + if (!target) throw new Error(`Missing ${label}`); + return target; + }; + try { + await render(); + expect(container.querySelectorAll('[data-hunk-actions]')).toHaveLength(3); + expect(container.querySelector('[role="menu"]')).toBeNull(); + expect(button('Stage hunk 2').closest('[data-hunk-actions]')?.getAttribute('data-hunk-actions')).toBe('1'); + await act(async () => button('Stage hunk 2').click()); + await act(async () => button('Discard hunk 3').click()); + expect(actions).toEqual(['stage:1', 'discard:2']); + + await render(false, { index: 1, action: 'stage' }); + expect([...container.querySelectorAll('button')].every((entry) => entry.disabled)).toBe(true); + expect(button('Stage hunk 2').querySelector('.animate-spin')).not.toBeNull(); + await act(async () => button('Discard hunk 1').click()); + expect(actions).toHaveLength(2); + + await render(true); + expect(container.querySelectorAll('button')).toHaveLength(3); + expect(container.querySelector('button[aria-label="Stage hunk 1"]')).toBeNull(); + await act(async () => button('Unstage hunk 1').click()); + expect(actions.at(-1)).toBe('unstage:0'); + } finally { + await act(async () => root.unmount()); + for (const [name, descriptor] of originals) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else Reflect.deleteProperty(globalThis, name); + } + await dom.happyDOM.close(); + } +}); diff --git a/packages/ui/src/components/views/git/HunkActions.tsx b/packages/ui/src/components/views/git/HunkActions.tsx new file mode 100644 index 00000000..32315087 --- /dev/null +++ b/packages/ui/src/components/views/git/HunkActions.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { Button } from '@/components/ui/button'; +import { useI18n } from '@/lib/i18n'; +import { cn } from '@/lib/utils'; + +export type HunkDiffAction = 'stage' | 'unstage' | 'discard'; + +export type HunkBusyState = { + index: number; + action: HunkDiffAction; +} | null; + +interface HunkActionsProps { + index: number; + staged: boolean; + busyHunk: HunkBusyState; + disabled: boolean; + onAction: (hunkIndex: number, action: HunkDiffAction) => void; +} + +export const HunkActions = React.memo(function HunkActions({ + index, staged, busyHunk, disabled, onAction, +}) { + const { t } = useI18n(); + const actions: HunkDiffAction[] = staged ? ['unstage'] : ['discard', 'stage']; + return ( +
+
+ {actions.map((action) => { + const label = t(action === 'stage' ? 'diffView.hunk.stageTitle' + : action === 'unstage' ? 'diffView.hunk.unstageTitle' : 'diffView.hunk.discardTitle', { index: index + 1 }); + const busy = busyHunk?.index === index && busyHunk.action === action; + return ( + + ); + })} +
+
+ ); +}); diff --git a/packages/ui/src/components/views/git/baseBranch.ts b/packages/ui/src/components/views/git/baseBranch.ts index c9937ca2..02964258 100644 --- a/packages/ui/src/components/views/git/baseBranch.ts +++ b/packages/ui/src/components/views/git/baseBranch.ts @@ -1,3 +1,5 @@ +export const branchRefLabel = (ref: string): string => ref.replace(/^refs\/(heads|remotes)\//, '').replace(/^remotes\//, ''); + /** * Derives the base ("target") branch a feature branch should compare and * merge against. Shared by GitView and the standalone pull-request surface so diff --git a/packages/ui/src/components/views/useFilePreviewScrollPosition.test.tsx b/packages/ui/src/components/views/useFilePreviewScrollPosition.test.tsx new file mode 100644 index 00000000..00859be0 --- /dev/null +++ b/packages/ui/src/components/views/useFilePreviewScrollPosition.test.tsx @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { Window } from 'happy-dom'; +import React, { act, useLayoutEffect } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { VirtualizedFile, Virtualizer } from '@pierre/diffs'; + +import { useFilePreviewScrollPosition } from './useFilePreviewScrollPosition'; + +type RestorePreview = ReturnType['restore']; + +class MeasuredPreviewFile extends VirtualizedFile { + lineTop = 1000; + + override getLinePosition() { + return { top: this.lineTop, height: 20 }; + } + + override getNumericScrollAnchor() { + return { lineNumber: 51, top: this.lineTop }; + } +} + +function Preview({ positionKey, element, onReady }: { + positionKey: string | null; + element: HTMLElement; + onReady: (restore: RestorePreview) => void; +}) { + const { setScroller, restore } = useFilePreviewScrollPosition(positionKey); + useLayoutEffect(() => { + setScroller(element); + return () => setScroller(null); + }, [element, setScroller]); + useLayoutEffect(() => onReady(restore), [onReady, restore]); + return null; +} + +describe('file preview scroll positions', () => { + let windowInstance: Window; + let root: Root; + let scroller: HTMLDivElement; + let content: HTMLDivElement; + let height: number; + let top: number; + let left: number; + let restore: RestorePreview; + let prefix: string; + let sequence = 0; + const onReady = (callback: RestorePreview) => { restore = callback; }; + + beforeEach(() => { + windowInstance = new Window(); + Object.assign(globalThis, { + window: windowInstance, + document: windowInstance.document, + HTMLElement: windowInstance.HTMLElement, + Event: windowInstance.Event, + DOMRect: windowInstance.DOMRect, + MutationObserver: windowInstance.MutationObserver, + ResizeObserver: windowInstance.ResizeObserver, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const host = document.createElement('div'); + scroller = document.createElement('div'); + content = document.createElement('div'); + scroller.append(content); + document.body.append(host, scroller); + root = createRoot(host); + height = 2000; + top = 0; + left = 0; + prefix = `preview-test-${sequence++}`; + Object.defineProperties(scroller, { + clientHeight: { value: 100 }, + scrollTop: { + get: () => top, + set: (value: number) => { top = Math.max(0, Math.min(value, height - 100)); }, + }, + scrollLeft: { + get: () => left, + set: (value: number) => { left = Math.max(0, Math.min(value, 500)); }, + }, + }); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + await windowInstance.happyDOM.close(); + }); + + const render = async (key: string | null) => { + await act(async () => { + root.render(); + }); + }; + const scroll = (nextTop: number, nextLeft = 0) => { + scroller.scrollTop = nextTop; + scroller.scrollLeft = nextLeft; + scroller.dispatchEvent(new Event('scroll')); + }; + + test('keeps positions independent across files, runtimes, modes and surfaces', async () => { + const keys = ['runtime-a:file-a:code', 'runtime-a:file-b:code', 'runtime-b:file-a:code', 'runtime-a:file-a:markdown', 'runtime-a:file-a:code:fullscreen']; + for (const [index, key] of keys.entries()) { + await render(key); + expect(top).toBe(0); + scroll((index + 1) * 150, (index + 1) * 20); + } + for (const [index, key] of keys.entries()) { + await render(key); + expect(top).toBe((index + 1) * 150); + expect(left).toBe((index + 1) * 20); + } + }); + + test('ignores scroll collapse during loading and restores after a full view unmount', async () => { + await render('file'); + scroll(900, 120); + await render(null); + scroll(0); + await act(async () => root.render(null)); + await render('file'); + expect(top).toBe(900); + expect(left).toBe(120); + }); + + test('waits for asynchronous code rendering without saving its clamped offset', async () => { + await render('code'); + scroll(1200); + await render(null); + height = 200; + await render('code'); + expect(top).toBe(100); + scroll(100); + height = 2000; + restore(); + expect(top).toBe(1200); + scroll(700); + restore(); + expect(top).toBe(700); + await render(null); + await render('code'); + expect(top).toBe(700); + }); + + test('restores when lazy Markdown content mounts', async () => { + await render('markdown'); + scroll(1000); + await render(null); + height = 100; + await render('markdown'); + expect(top).toBe(0); + height = 2000; + content.append(document.createElement('p')); + await windowInstance.happyDOM.waitUntilComplete(); + expect(top).toBe(1000); + }); + + test('stops pending restoration when the user starts scrolling', async () => { + await render('markdown'); + scroll(1000); + await render(null); + height = 300; + await render('markdown'); + scroller.dispatchEvent(new Event('wheel')); + scroll(80); + height = 2000; + restore(); + expect(top).toBe(80); + await render(null); + await render('markdown'); + expect(top).toBe(80); + }); + + test('disconnects late content callbacks when leaving the file', async () => { + await render('first'); + scroll(1000); + await render(null); + height = 200; + await render('first'); + await render('second'); + scroll(50); + height = 2000; + content.append(document.createElement('p')); + await windowInstance.happyDOM.waitUntilComplete(); + restore(); + expect(top).toBe(50); + }); + + test('restores the same virtualized line after Pierre reconciles different height estimates', async () => { + const file = new MeasuredPreviewFile({}, new Virtualizer()); + const node = document.createElement('div'); + const line = document.createElement('div'); + line.dataset.line = ''; + line.dataset.lineIndex = '50'; + node.attachShadow({ mode: 'open' }).append(line); + content.append(node); + line.getBoundingClientRect = () => new DOMRect(0, file.lineTop - top + 8, 100, 20); + + await render('virtual'); + scroll(1000); + restore(node, file); + await Promise.resolve(); + expect(line.getBoundingClientRect().top).toBe(8); + + await render(null); + scroll(0); + await render('virtual'); + restore(node, file); + // onPostRender runs before Pierre's synchronous height reconciliation. + file.lineTop = 1500; + scroll(800); + await Promise.resolve(); + expect(top).toBe(1500); + expect(line.getBoundingClientRect().top).toBe(8); + file.cleanUp(); + }); +}); diff --git a/packages/ui/src/components/views/useFilePreviewScrollPosition.ts b/packages/ui/src/components/views/useFilePreviewScrollPosition.ts new file mode 100644 index 00000000..bd0cbd3b --- /dev/null +++ b/packages/ui/src/components/views/useFilePreviewScrollPosition.ts @@ -0,0 +1,133 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { VirtualizedFile, type File } from '@pierre/diffs'; + +type PreviewScrollPosition = { + top: number; + left: number; + line?: { number: number; offset: number }; +}; + +// Runtime, directory, file, preview mode and surface are supplied by FilesView. +// Retain coordinates across view unmounts, without retaining DOM or file data. +const positions = new Map(); +const MAX_POSITIONS = 100; + +export function useFilePreviewScrollPosition(positionKey: string | null) { + const [scroller, setScroller] = useState(null); + const restoreRef = useRef<(() => void) | null>(null); + const rememberRef = useRef<(() => void) | null>(null); + const virtualFileRef = useRef<{ key: string | null; file: VirtualizedFile; node: HTMLElement } | null>(null); + const restore = useCallback((node?: HTMLElement, instance?: File) => { + if (node && instance instanceof VirtualizedFile) { + virtualFileRef.current = { key: positionKey, file: instance, node }; + } + const restorePosition = restoreRef.current; + const rememberPosition = rememberRef.current; + const finishRender = () => { + if (restoreRef.current !== restorePosition) return; + restorePosition?.(); + rememberPosition?.(); + }; + // Pierre calls onPostRender before reconciling measured heights and applying + // its own scroll correction. Finish after that synchronous render pass, + // before paint, rather than saving estimates or having our restore undone. + if (instance) queueMicrotask(finishRender); + else finishRender(); + }, [positionKey]); + + useLayoutEffect(() => { + if (!scroller || !positionKey) return; + + const target: PreviewScrollPosition = positions.get(positionKey) ?? { top: 0, left: 0 }; + let pending = true; + const getVirtualFile = () => virtualFileRef.current?.key === positionKey ? virtualFileRef.current.file : null; + const getLineElement = (number: number) => virtualFileRef.current?.key === positionKey + ? virtualFileRef.current.node.shadowRoot?.querySelector(`[data-line][data-line-index="${number - 1}"]`) + : null; + + const stopObserving = () => { + resizeObserver.disconnect(); + mutationObserver.disconnect(); + }; + const restorePosition = () => { + if (!pending) return; + const file = getVirtualFile(); + const linePosition = target.line && file?.getLinePosition(target.line.number); + const lineElement = target.line && getLineElement(target.line.number); + // Estimated heights locate the virtual window; the mounted row supplies + // the final visual offset, including wrapping and Pierre's padding. + let targetTop = target.top; + if (target.line && linePosition) { + targetTop = (file?.top ?? 0) + linePosition.top - target.line.offset; + } + if (target.line && lineElement) { + targetTop = scroller.scrollTop + lineElement.getBoundingClientRect().top - scroller.getBoundingClientRect().top - target.line.offset; + } + scroller.scrollTop = targetTop; + scroller.scrollLeft = target.left; + if ((!target.line || lineElement) && Math.abs(scroller.scrollTop - targetTop) < 1 && Math.abs(scroller.scrollLeft - target.left) < 1) { + pending = false; + stopObserving(); + } + }; + // Markdown can mount through Suspense; code may render asynchronously in + // Pierre's worker. Retry only on content/layout changes, until reachable. + const resizeObserver = new ResizeObserver(restorePosition); + const mutationObserver = new MutationObserver(() => { + if (!pending) return; + for (const child of scroller.children) resizeObserver.observe(child); + restorePosition(); + }); + resizeObserver.observe(scroller); + for (const child of scroller.children) resizeObserver.observe(child); + mutationObserver.observe(scroller, { childList: true, subtree: true }); + + const rememberPosition = () => { + if (pending) return; + const file = getVirtualFile(); + const anchor = file?.getNumericScrollAnchor(scroller.scrollTop - (file.top ?? 0)); + const lineElement = anchor && getLineElement(anchor.lineNumber); + const offset = lineElement ? lineElement.getBoundingClientRect().top - scroller.getBoundingClientRect().top : null; + positions.delete(positionKey); + positions.set(positionKey, { + top: scroller.scrollTop, + left: scroller.scrollLeft, + line: anchor && offset !== null && offset >= 0 && offset < scroller.clientHeight + ? { number: anchor.lineNumber, offset } + : undefined, + }); + if (positions.size > MAX_POSITIONS) { + const oldestKey = positions.keys().next().value; + if (oldestKey !== undefined) positions.delete(oldestKey); + } + }; + const cancelRestoration = () => { + pending = false; + stopObserving(); + }; + + restoreRef.current = restorePosition; + rememberRef.current = rememberPosition; + restorePosition(); + scroller.addEventListener('scroll', rememberPosition, { passive: true }); + scroller.addEventListener('wheel', cancelRestoration, { passive: true }); + scroller.addEventListener('touchstart', cancelRestoration, { passive: true }); + scroller.addEventListener('pointerdown', cancelRestoration, { passive: true }); + scroller.addEventListener('keydown', cancelRestoration); + + return () => { + restoreRef.current = null; + rememberRef.current = null; + if (virtualFileRef.current?.key === positionKey) virtualFileRef.current = null; + stopObserving(); + scroller.removeEventListener('scroll', rememberPosition); + scroller.removeEventListener('wheel', cancelRestoration); + scroller.removeEventListener('touchstart', cancelRestoration); + scroller.removeEventListener('pointerdown', cancelRestoration); + scroller.removeEventListener('keydown', cancelRestoration); + // Keep the last scroll event, not offsets collapsed by DOM teardown. + }; + }, [positionKey, scroller]); + + return { setScroller, restore }; +} diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx index 02cad5be..8a4e7794 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx @@ -19,7 +19,11 @@ import { useGiteaPrForBranch } from '@/lib/giteaPrStatus'; import { buildWalkthroughView } from '@/lib/walkthrough/model'; import type { WalkthroughSource, WalkthroughWorkingTreeScope } from '@/lib/walkthrough/types'; import { ModelSelector } from '@/components/sections/agents/ModelSelector'; -import { deriveBaseBranch, hasResolvableBaseBranch } from '@/components/views/git/baseBranch'; +import { useBranchComparisonBase } from '@/hooks/useBranchComparisonBase'; +import { useCommitComparison } from '@/hooks/useCommitComparison'; +import { CommitComparisonSelector } from '@/components/views/git/CommitComparisonSelector'; +import { BranchComparisonSelector } from '@/components/views/git/BranchComparisonSelector'; +import { useGitBaseBranchStore } from '@/stores/useGitBaseBranchStore'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { useConfigStore } from '@/stores/useConfigStore'; import { useGitBranches, useGitStatus, useGitStore, useIsGitRepo } from '@/stores/useGitStore'; @@ -162,6 +166,7 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa [setStoredTocWidth, tocWidth] ); const [scope, setScope] = useState('all'); + const [pendingSourceSelection, setPendingSourceSelection] = useState<{ directory: string; kind: 'branch' | 'commit' } | null>(null); const [activeStopId, setActiveStopId] = useState(null); const [scrollToStopId, setScrollToStopId] = useState(null); const [visitedStopIds, setVisitedStopIds] = useState>(() => new Set()); @@ -178,6 +183,7 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa const status = useGitStatus(directory || null); const branches = useGitBranches(directory || null); + const setBaseOverride = useGitBaseBranchStore((state) => state.setOverride); const ensureAll = useGitStore((state) => state.ensureAll); const { github, git } = useRuntimeAPIs(); @@ -185,37 +191,18 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa if (directory) void ensureAll(directory, git); }, [directory, ensureAll, git]); - // The branch source reviews everything on this branch that is not on its - // base. Three-dot semantics server-side mean merges from the base are - // already excluded. + // Changes and walkthrough share the explicit base choice and reflog detection. const currentBranch = status?.current ?? null; + const choosingCommit = pendingSourceSelection?.directory === directory && pendingSourceSelection.kind === 'commit' && !requestedSource; + const isCommitScope = choosingCommit || requestedSource?.kind === 'commit'; + const commitComparison = useCommitComparison(directory || null, currentBranch, visible && isCommitScope, + requestedSource?.kind === 'commit' ? requestedSource.hash : undefined); + const selectedCommitHash = commitComparison.selectedCommit?.hash ?? (requestedSource?.kind === 'commit' ? requestedSource.hash : null); + const { base: comparisonBase, resolved: comparisonBaseResolved, revision: branchRevision } = useBranchComparisonBase(directory || null, currentBranch, visible); const branchSource = useMemo(() => { - const headRef = currentBranch; - if (!headRef) return null; - const all = branches?.all ?? []; - const localBranches = all.filter((name) => !name.startsWith('remotes/')); - const remoteBranches = all - .filter((name) => name.startsWith('remotes/')) - .map((name) => name.slice('remotes/'.length)); - const remoteNames = new Set( - remoteBranches - .map((name) => name.split('/')[0]) - .filter(Boolean) - ); - const trackingRemote = status?.tracking?.split('/')[0]; - const defaultBranch = (trackingRemote && branches?.defaultBranches?.[trackingRemote]) - ?? branches?.defaultBranches?.origin; - const baseRef = deriveBaseBranch({ - remoteNames, - localBranches, - defaultBranch, - headBranch: headRef, - }); - if (!baseRef || baseRef === headRef || !hasResolvableBaseBranch({ baseBranch: baseRef, localBranches, remoteBranches })) { - return null; - } - return { kind: 'branch', baseRef, headRef }; - }, [branches, currentBranch, status?.tracking]); + if (!currentBranch || !comparisonBase || comparisonBase === currentBranch) return null; + return { kind: 'branch', baseRef: comparisonBase, headRef: currentBranch }; + }, [comparisonBase, currentBranch]); // The pull request for this branch used to appear only after visiting the PR // panel, because nothing else asked GitHub about it. Ask here too: the status @@ -265,9 +252,18 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa )); const source = useMemo( - () => requestedSource ?? { kind: 'working-tree', scope }, - [requestedSource, scope] + () => isCommitScope && selectedCommitHash + ? { kind: 'commit', hash: selectedCommitHash } + : requestedSource?.kind === 'branch' + ? branchSource ?? requestedSource + : requestedSource ?? { kind: 'working-tree', scope }, + [branchSource, isCommitScope, requestedSource, scope, selectedCommitHash] ); + const choosingBranchBase = pendingSourceSelection?.directory === directory && pendingSourceSelection.kind === 'branch' && !requestedSource; + const isBranchScope = choosingBranchBase || source.kind === 'branch'; + const branchNeedsBase = choosingBranchBase || (source.kind === 'branch' && !branchSource); + const commitNeedsSelection = choosingCommit || (isCommitScope && !selectedCommitHash); + const needsSourceSelection = branchNeedsBase || commitNeedsSelection; // Offer whichever pull request or merge request we know about: the one // already selected, or the one this branch has. GitLab repos get their MR @@ -287,6 +283,7 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa const selectWorkingTree = useCallback( (value: WalkthroughWorkingTreeScope) => { + setPendingSourceSelection(null); clearRequestedSource(directory); setScope(value); }, @@ -297,6 +294,16 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa const generate = useWalkthroughStore((state) => state.generate); const cancel = useWalkthroughStore((state) => state.cancel); const requestSource = useWalkthroughStore((state) => state.requestSource); + useEffect(() => { + if (!choosingBranchBase || !branchSource) return; + requestSource(directory, branchSource); + setPendingSourceSelection(null); + }, [branchSource, choosingBranchBase, directory, requestSource]); + useEffect(() => { + if (!choosingCommit || !selectedCommitHash) return; + requestSource(directory, { kind: 'commit', hash: selectedCommitHash }); + setPendingSourceSelection(null); + }, [choosingCommit, directory, requestSource, selectedCommitHash]); const selectModel = useWalkthroughStore((state) => state.selectModel); const selectedModel = useWalkthroughStore((state) => state.getSelectedModel(directory, source)); const selectLanguage = useWalkthroughStore((state) => state.selectLanguage); @@ -318,11 +325,12 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa // Reloads on a model or language change: whether this diff fits, and whether // the model can produce structured output, are answers about a specific // request — and the language instruction is part of that request. + const sourceRevision = source.kind === 'branch' ? branchRevision : ''; useEffect(() => { - void load(directory, source, { language: activeLanguage }); - }, [activeLanguage, directory, load, source, selectedModel]); + if (visible && !needsSourceSelection) void load(directory, source, { language: activeLanguage }); + }, [activeLanguage, needsSourceSelection, directory, load, source, selectedModel, sourceRevision, visible]); - const view = useMemo(() => buildWalkthroughView(entry.result), [entry.result]); + const view = useMemo(() => needsSourceSelection ? null : buildWalkthroughView(entry.result), [needsSourceSelection, entry.result]); // A new walkthrough is a new reading path: keeping the old progress would // mark stops as visited that the user has never seen. @@ -365,8 +373,8 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa const [sourceMenuOpen, setSourceMenuOpen] = useState(false); const [languageMenuOpen, setLanguageMenuOpen] = useState(false); - const sourceValue = source.kind === 'working-tree' ? source.scope : source.kind; - const sourceLabel = source.kind === 'branch' + const sourceValue = isCommitScope ? 'commit' : isBranchScope ? 'branch' : source.kind === 'working-tree' ? source.scope : source.kind; + const sourceLabel = isCommitScope ? t('commitComparison.mode') : isBranchScope ? t('walkthrough.scope.branch') : source.kind === 'pr' ? gitProvider === 'gitlab' @@ -507,7 +515,7 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa // Not ready, or no usable selected model, means Generate must not look // actionable — including when the resolved model has no login. - const generateDisabled = !activeModel || Boolean(entry.readiness && !entry.readiness.ready); + const generateDisabled = needsSourceSelection || !activeModel || Boolean(entry.readiness && !entry.readiness.ready); const handleGenerate = useCallback( (force: boolean) => { @@ -565,11 +573,23 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa value={sourceValue} onValueChange={(value) => { setSourceMenuOpen(false); + if (value === 'commit') { + clearRequestedSource(directory); + setPendingSourceSelection({ directory, kind: 'commit' }); + return; + } if (value === 'branch') { - if (branchSource) requestSource(directory, branchSource); + if (branchSource) { + setPendingSourceSelection(null); + requestSource(directory, branchSource); + } else { + clearRequestedSource(directory); + setPendingSourceSelection({ directory, kind: 'branch' }); + } return; } if (value === 'pr') { + setPendingSourceSelection(null); if (prSource) requestSource(directory, prSource); return; } @@ -591,19 +611,18 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa : t('walkthrough.scope.working')} ))} - {(branchSource || prSource) && ( + {currentBranch && ( <> - - {t('walkthrough.scope.group.committed')} - + + {t('walkthrough.scope.branch')} + )} - {branchSource && ( - - {t('walkthrough.scope.branch')} - - )} + + + {t('commitComparison.mode')} + {prSource && ( {gitProvider === 'gitlab' @@ -615,6 +634,30 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa + {isBranchScope && ( + { + if (directory && currentBranch) setBaseOverride(directory, currentBranch, base); + }} + /> + )} + + {isCommitScope && ( + void commitComparison.refresh()} + /> + )} +
@@ -834,7 +877,22 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa )}
- {blockedReason ? ( + {commitNeedsSelection ? ( +
+ {commitComparison.loading ? + : commitComparison.error ?? t('commitComparison.noCommits')} +
+ ) : branchNeedsBase ? ( +
+ {!comparisonBaseResolved && currentBranch ? ( + + ) : ( +

+ {t('gitView.pr.toast.baseBranchRequired')} +

+ )} +
+ ) : blockedReason ? ( isVSCodeRuntime(), []); const isDesktopShell = useMemo(() => detectDesktopShell(), []); const customThemesRequestRef = useRef(0); + // Set only by the handlers a person reaches through the UI. The persist + // effect below writes to the server only while this is raised, so a mount, + // a runtime switch, an OS light/dark flip, or a settings sync adopting + // another window's theme never produce a write (see the 2026-08-30 theme + // flip-flop: a fresh client used to PUT its default theme on load). + const themeWriteIntentRef = useRef(false); const receivesParentThemeSync = useMemo(() => { if (typeof window === 'undefined') { return false; @@ -556,12 +562,10 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro }, [applyIncomingThemeSync]); useEffect(() => { - if (receivesParentThemeSync) { + if (receivesParentThemeSync || !themeWriteIntentRef.current) { return; } - - const lightTheme = ensureThemeById(preferences.lightThemeId, 'light'); - const darkTheme = ensureThemeById(preferences.darkThemeId, 'dark'); + themeWriteIntentRef.current = false; void updateDesktopSettings({ themeId: currentTheme.metadata.id, @@ -569,22 +573,27 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro useSystemTheme: preferences.themeMode === 'system', lightThemeId: preferences.lightThemeId, darkThemeId: preferences.darkThemeId, - splashBgLight: lightTheme.colors.surface.background, - splashFgLight: lightTheme.colors.surface.foreground, - splashBgDark: darkTheme.colors.surface.background, - splashFgDark: darkTheme.colors.surface.foreground, }); - }, [currentTheme.metadata.id, currentTheme.metadata.variant, ensureThemeById, preferences.themeMode, preferences.lightThemeId, preferences.darkThemeId, receivesParentThemeSync]); + }, [currentTheme.metadata.id, currentTheme.metadata.variant, preferences.themeMode, preferences.lightThemeId, preferences.darkThemeId, receivesParentThemeSync]); useEffect(() => { if (receivesParentThemeSync || !isDesktopShell) { return; } + // The shell paints the next startup splash from these; they are this + // install's cosmetics, so they go to main directly, not to the server. + const lightTheme = ensureThemeById(preferences.lightThemeId, 'light'); + const darkTheme = ensureThemeById(preferences.darkThemeId, 'dark'); void (async () => { - await setDesktopWindowTheme(preferences.themeMode, currentTheme.metadata.variant); + await setDesktopWindowTheme(preferences.themeMode, currentTheme.metadata.variant, { + bgLight: lightTheme.colors.surface.background, + fgLight: lightTheme.colors.surface.foreground, + bgDark: darkTheme.colors.surface.background, + fgDark: darkTheme.colors.surface.foreground, + }); })(); - }, [currentTheme.metadata.variant, isDesktopShell, preferences.themeMode, receivesParentThemeSync]); + }, [currentTheme.metadata.variant, ensureThemeById, isDesktopShell, preferences.themeMode, preferences.lightThemeId, preferences.darkThemeId, receivesParentThemeSync]); useEffect(() => { if (typeof window === 'undefined' || receivesParentThemeSync) { @@ -617,6 +626,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro if (prev.darkThemeId === theme.metadata.id && prev.themeMode === 'dark') { return prev; } + themeWriteIntentRef.current = true; return { ...prev, darkThemeId: theme.metadata.id, @@ -628,6 +638,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro return prev; } + themeWriteIntentRef.current = true; return { ...prev, lightThemeId: theme.metadata.id, @@ -643,18 +654,12 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro return; } + themeWriteIntentRef.current = true; setPreferences((prev) => ({ ...prev, themeMode: mode, })); - - if (!receivesParentThemeSync) { - void updateDesktopSettings({ - themeVariant: mode === 'system' ? currentTheme.metadata.variant : mode, - useSystemTheme: mode === 'system', - }); - } - }, [currentTheme.metadata.variant, preferences.themeMode, receivesParentThemeSync]); + }, [preferences.themeMode]); const setSystemPreferenceHandler = useCallback( (use: boolean) => { @@ -663,6 +668,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro if (prev.themeMode === 'system') { return prev; } + themeWriteIntentRef.current = true; return { ...prev, themeMode: 'system', @@ -677,6 +683,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro if (prev.themeMode === fallbackMode) { return prev; } + themeWriteIntentRef.current = true; return { ...prev, themeMode: fallbackMode, @@ -700,6 +707,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro if (prev.lightThemeId === theme.metadata.id) { return prev; } + themeWriteIntentRef.current = true; return { ...prev, lightThemeId: theme.metadata.id, @@ -723,6 +731,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro if (prev.darkThemeId === theme.metadata.id) { return prev; } + themeWriteIntentRef.current = true; return { ...prev, darkThemeId: theme.metadata.id, diff --git a/packages/ui/src/hooks/keyboard-shortcut-dom.ts b/packages/ui/src/hooks/keyboard-shortcut-dom.ts index 271685cd..901bb9df 100644 --- a/packages/ui/src/hooks/keyboard-shortcut-dom.ts +++ b/packages/ui/src/hooks/keyboard-shortcut-dom.ts @@ -7,6 +7,10 @@ export function hasOpenDropdown(root: ParentNode = document): boolean { return Boolean(root.querySelector(OPEN_DROPDOWN_SELECTOR)); } +export function hasActiveBtwComposer(root: ParentNode = document): boolean { + return Boolean(root.querySelector('[data-btw-composer="true"]')); +} + export function shouldStopDropdownImeEscape( event: Pick, dropdownOpen: boolean, diff --git a/packages/ui/src/hooks/useBranchComparisonBase.ts b/packages/ui/src/hooks/useBranchComparisonBase.ts new file mode 100644 index 00000000..041fc16a --- /dev/null +++ b/packages/ui/src/hooks/useBranchComparisonBase.ts @@ -0,0 +1,40 @@ +import { useEffect, useState } from 'react'; +import { getBranchBase } from '@/lib/gitApi'; +import { getRuntimeKey } from '@/lib/runtime-switch'; +import { gitBaseBranchEntryKey, useGitBaseBranchStore } from '@/stores/useGitBaseBranchStore'; +import { useGitStore } from '@/stores/useGitStore'; + +/** Shared base and freshness identity for Changes and the current-branch walkthrough. */ +export function useBranchComparisonBase(directory: string | null, branch: string | null, enabled: boolean) { + const runtimeKey = useGitStore((state) => state.runtimeKey); + const statusFetchedAt = useGitStore((state) => enabled && directory + ? state.directories.get(directory)?.lastStatusFetch ?? 0 + : 0); + const key = JSON.stringify([runtimeKey, directory, branch]); + const overrideKey = directory && branch ? gitBaseBranchEntryKey(directory, branch) : null; + const override = useGitBaseBranchStore((state) => overrideKey ? state.overrides[overrideKey] ?? null : null); + const [detected, setDetected] = useState<{ key: string; base: string | null } | null>(null); + + useEffect(() => { + if (!enabled || !directory || !branch || override) return; + let cancelled = false; + const requestRuntime = getRuntimeKey(); + getBranchBase(directory, branch) + .then(({ base }) => { + if (cancelled || getRuntimeKey() !== requestRuntime) return; + setDetected((previous) => previous?.key === key && previous.base === base ? previous : { key, base }); + }) + .catch(() => { + if (cancelled || getRuntimeKey() !== requestRuntime) return; + // Keep a same-branch answer on transient failure; a new branch needs a choice. + setDetected((previous) => previous?.key === key ? previous : { key, base: null }); + }); + return () => { cancelled = true; }; + }, [branch, directory, enabled, key, override, statusFetchedAt]); + + return { + base: override ?? (detected?.key === key ? detected.base : null), + resolved: Boolean(override) || detected?.key === key, + revision: JSON.stringify([runtimeKey, statusFetchedAt]), + }; +} diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index 5ca016e2..23703827 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -6,8 +6,6 @@ import { useViewportStore } from '@/sync/viewport-store'; import { useUIStore } from '@/stores/useUIStore'; import type { TimelineRevealGate } from '@/components/chat/timelineRevealGate'; import { - CHAT_LIST_ANCHOR_OFFSET, - getAnchoredTurnMetrics, getRowBottom, resolveRealContentEndOffset, resolveTimelineIsAtEnd, @@ -25,16 +23,11 @@ import { // Chat timeline scroll ownership. // // The virtualized list owns the scroll position; this hook only decides which -// of three mutually exclusive modes is active and, when a mode calls for it, +// of two mutually exclusive modes is active and, when a mode calls for it, // issues ONE deterministic scroll command: // // • `following-end` — pinned to the live edge. The list keeps us there // through `maintainScrollAtEnd`; we only re-assert after a data change. -// • `anchoring-new-turn` — the just-sent user message is parked near the TOP -// of the viewport and the reply streams into the reserved end space below -// it. The viewport does NOT move while the turn still fits; once the turn -// outgrows the usable viewport we scroll by the exact delta needed to keep -// its end visible. // • `free-scrolling` — the user took over. Nothing moves until they opt // back in by returning to the end. // @@ -74,9 +67,6 @@ interface UseChatTimelineScrollOptions { currentSessionKey: string | null; sessionMessageCount: number; composerOverlayHeight: number; - // Id of the newest user message in the rendered timeline. When a send has - // armed the anchor, the next new id here becomes the anchored row. - lastUserMessageId: string | null; // True while the session is producing output. Follow corrections glide // only then. Outside a live stream — entering a session, a tab becoming // active, rows re-measuring after a switch — the viewport must land on @@ -98,10 +88,8 @@ export interface UseChatTimelineScrollResult { scrollNode: HTMLDivElement | null; isPinned: boolean; registerList: (list: TimelineListHandle | null) => void; - anchorMessageId: string | null; - onAnchorReady: (messageId: string, anchorIndex: number) => void; - onAnchorSizeChanged: (messageId: string) => void; onIsAtEndChange: (isAtEnd: boolean) => void; + onListMetricsChange: (metrics: { readonly footerSize: number }) => void; onManualNavigation: () => void; onTimelineDataChange: () => void; showScrollButton: boolean; @@ -119,21 +107,12 @@ export interface UseChatTimelineScrollResult { // Hiding is always immediate. const SHOW_SCROLL_BUTTON_DELAY_MS = 150; const SAVE_DEBOUNCE_MS = 150; -// The anchor scroll is animated; `scrollend` is the authoritative completion -// signal, and this bounds the wait for browsers that drop it. -const ANCHOR_SETTLE_FALLBACK_MS = 750; -// Re-running the anchor positioning while the list is still mounting rows. -const ANCHOR_POSITION_ATTEMPTS = 12; -// Anchor restores only correct sub-pixel drift; anything larger is the user or -// a genuine relayout and must not be undone. -const ANCHOR_RESTORE_TOLERANCE_PX = 2; export const useChatTimelineScroll = ({ currentSessionId, currentSessionKey, sessionMessageCount, composerOverlayHeight, - lastUserMessageId, sessionIsWorking, revealGate = null, onActiveTurnChange, @@ -144,7 +123,6 @@ export const useChatTimelineScroll = ({ const listRef = React.useRef(null); const [scrollNode, setScrollNode] = React.useState(null); - const [anchorMessageId, setAnchorMessageId] = React.useState(null); const [showScrollButton, setShowScrollButton] = React.useState(false); // "Pinned" is the live edge, which history pagination uses to decide whether // it may load older pages without disturbing the read position. @@ -162,23 +140,16 @@ export const useChatTimelineScroll = ({ // while `liveFollowGenerationRef` still equals it. const userGenerationRef = React.useRef(0); const liveFollowGenerationRef = React.useRef(0); - // Anchor lifecycle: armed on send → pending until the row exists → positioned - // while the animated scroll runs → settled once it has come to rest. - const armedForNextUserMessageRef = React.useRef(false); - const pendingAnchorRef = React.useRef(null); - const positionedAnchorRef = React.useRef(null); - const settledAnchorRef = React.useRef(null); - const activeAnchorIndexRef = React.useRef(null); - const pendingAnchorRestoreRef = React.useRef<{ - readonly messageId: string; - readonly offset: number; - readonly userGeneration: number; - } | null>(null); - const anchorRestoreFrameRef = React.useRef(null); const showButtonTimerRef = React.useRef | null>(null); const composerOverlayHeightRef = React.useRef(composerOverlayHeight); composerOverlayHeightRef.current = composerOverlayHeight; + // Size of the list footer, reported by the list as it is measured; the + // real content end sits below the last row by this much. + const listFooterSizeRef = React.useRef(0); + const onListMetricsChange = React.useCallback((metrics: { readonly footerSize: number }) => { + listFooterSizeRef.current = Number.isFinite(metrics.footerSize) ? metrics.footerSize : 0; + }, []); const sessionMessageCountRef = React.useRef(sessionMessageCount); sessionMessageCountRef.current = sessionMessageCount; const currentSessionIdRef = React.useRef(currentSessionId); @@ -208,23 +179,8 @@ export const useChatTimelineScroll = ({ }, SHOW_SCROLL_BUTTON_DELAY_MS); }, []); - const clearAnchor = React.useCallback(() => { - armedForNextUserMessageRef.current = false; - pendingAnchorRef.current = null; - positionedAnchorRef.current = null; - settledAnchorRef.current = null; - activeAnchorIndexRef.current = null; - pendingAnchorRestoreRef.current = null; - if (anchorRestoreFrameRef.current !== null) { - cancelAnimationFrame(anchorRestoreFrameRef.current); - anchorRestoreFrameRef.current = null; - } - setAnchorMessageId(null); - }, []); - // A real gesture: stop every automatic movement until the user opts back - // in. The anchored END SPACE stays — collapsing it mid-gesture clamps the - // viewport back to the end — only the anchor machinery is disarmed. + // in. const onManualNavigation = React.useCallback(() => { userGenerationRef.current += 1; modeRef.current = 'free-scrolling'; @@ -242,16 +198,6 @@ export const useChatTimelineScroll = ({ cancelShowButtonTimer(); setShowScrollButton(true); } - armedForNextUserMessageRef.current = false; - pendingAnchorRef.current = null; - positionedAnchorRef.current = null; - settledAnchorRef.current = null; - activeAnchorIndexRef.current = null; - pendingAnchorRestoreRef.current = null; - if (anchorRestoreFrameRef.current !== null) { - cancelAnimationFrame(anchorRestoreFrameRef.current); - anchorRestoreFrameRef.current = null; - } }, [cancelShowButtonTimer]); const isLiveFollowActive = React.useCallback(() => ( @@ -320,7 +266,6 @@ export const useChatTimelineScroll = ({ modeRef.current = 'following-end'; // Returning to the end is an explicit opt back IN to live follow. liveFollowGenerationRef.current = userGenerationRef.current; - clearAnchor(); hideScrollButton(); void listRef.current?.scrollToEnd({ animated: mode === 'smooth' }); // While a stream is growing the content, a single jump lands on the @@ -338,62 +283,24 @@ export const useChatTimelineScroll = ({ void listRef.current?.scrollToEnd({ animated: false }); }, delay)); } - }, [clearAnchor, clearGoToBottomReasserts, hideScrollButton]); + }, [clearGoToBottomReasserts, hideScrollButton]); // User preference: with auto-follow off, streaming growth never moves the - // viewport. Sending from the live edge still parks the new message at the - // top, but no glide or end-follow correction runs afterwards; sending from - // mid-history leaves the viewport untouched. + // viewport. Sending from the live edge still lands on the end; sending + // from mid-history leaves the viewport untouched. const streamingAutoFollowEnabled = useUIStore((state) => state.streamingAutoFollowEnabled); const streamingAutoFollowEnabledRef = React.useRef(streamingAutoFollowEnabled); streamingAutoFollowEnabledRef.current = streamingAutoFollowEnabled; - // Sending arms the anchor. The message id is not known here (the optimistic - // row is created by the store), so the next new user message id claims it. - // Whether the send-time anchor positioning may animate. Sending from the - // live edge parks the new message with a short smooth scroll; sending - // from mid-history teleports — a long smooth scroll through the - // virtualized timeline gets cancelled by rows mounting and measuring - // along the way and dies partway there. - const anchorPositionInstantRef = React.useRef(false); - + // Sending is an explicit return to the live edge: the sent row and the + // reply that follows it stay in view through ordinary end-follow. const scrollToBottomOnSend = React.useCallback(() => { // With auto-follow off, a reader who scrolled away from the end stays - // exactly where they are: the sent message is not anchored and the - // scroll-to-bottom pill (already showing) leads to it. From the live - // edge, sending anchors the new turn as usual. + // exactly where they are; the scroll-to-bottom pill (already showing) + // leads to the sent message. if (!streamingAutoFollowEnabledRef.current && !isAtEndRef.current) return; - anchorPositionInstantRef.current = !isAtEndRef.current; - isAtEndRef.current = true; - setUserOwnsScroll(false); - modeRef.current = 'anchoring-new-turn'; - liveFollowGenerationRef.current = userGenerationRef.current; - armedForNextUserMessageRef.current = true; - // The optimistic row is not committed yet; the next NEW user message id - // relative to this baseline claims the anchor, independent of whether - // the commit lands before or after this call. - armBaselineUserMessageIdRef.current = lastArmedUserMessageIdRef.current; - pendingAnchorRef.current = null; - positionedAnchorRef.current = null; - settledAnchorRef.current = null; - activeAnchorIndexRef.current = null; - hideScrollButton(); - }, [hideScrollButton]); - - // Claim the anchor as soon as the sent row exists in the timeline. The - // comparison is against the baseline captured when the send armed the - // anchor, so the claim works whether the optimistic row committed before - // or after the arming call. - const lastArmedUserMessageIdRef = React.useRef(lastUserMessageId); - const armBaselineUserMessageIdRef = React.useRef(lastUserMessageId); - React.useEffect(() => { - lastArmedUserMessageIdRef.current = lastUserMessageId; - if (!armedForNextUserMessageRef.current) return; - if (!lastUserMessageId || lastUserMessageId === armBaselineUserMessageIdRef.current) return; - armedForNextUserMessageRef.current = false; - pendingAnchorRef.current = lastUserMessageId; - setAnchorMessageId(lastUserMessageId); - }, [lastUserMessageId]); + goToBottom('instant'); + }, [goToBottom]); const restoreSnapshot = React.useCallback(async (): Promise => { const sessionKey = currentSessionKeyRef.current; @@ -405,11 +312,10 @@ export const useChatTimelineScroll = ({ setUserOwnsScroll(false); modeRef.current = 'following-end'; liveFollowGenerationRef.current = userGenerationRef.current; - clearAnchor(); hideScrollButton(); void listRef.current?.scrollToEnd({ animated: false }); return false; - }, [clearAnchor, hideScrollButton]); + }, [hideScrollButton]); // ── list callbacks ────────────────────────────────────────────────────── const registerList = React.useCallback((list: TimelineListHandle | null) => { @@ -421,8 +327,8 @@ export const useChatTimelineScroll = ({ const onIsAtEndChange = React.useCallback((isAtEnd: boolean) => { // While an automatic movement owns the viewport, leaving the end is our - // own doing (the anchored turn parks mid-timeline, the glide trails its - // target between corrections) — not a reason to offer the pill. Only a + // own doing (the glide trails its target between corrections) — not a + // reason to offer the pill. Only a // real gesture (free-scrolling) shows it. if (!isAtEnd && isLiveFollowActive()) { hideScrollButton(); @@ -432,9 +338,7 @@ export const useChatTimelineScroll = ({ isAtEndRef.current = isAtEnd; setIsPinned(isAtEnd); if (isAtEnd) { - if (modeRef.current !== 'anchoring-new-turn') { - modeRef.current = 'following-end'; - } + modeRef.current = 'following-end'; liveFollowGenerationRef.current = userGenerationRef.current; setUserOwnsScroll(false); hideScrollButton(); @@ -446,105 +350,7 @@ export const useChatTimelineScroll = ({ queueSave(); }, [hideScrollButton, isLiveFollowActive, queueSave, scheduleShowScrollButton]); - // Park the anchored row near the top once the list has measured it. - const onAnchorReady = React.useCallback((messageId: string, anchorIndex: number) => { - // The anchored end space can be remeasured long after the send (turn - // completion, images decoding). Only the send-time anchoring mode may - // position the viewport. - if (modeRef.current !== 'anchoring-new-turn') return; - if (pendingAnchorRef.current === messageId) { - pendingAnchorRef.current = null; - } - activeAnchorIndexRef.current = anchorIndex; - if (positionedAnchorRef.current === messageId) return; - positionedAnchorRef.current = messageId; - settledAnchorRef.current = null; - - const positionAnchor = (remainingAttempts: number) => { - requestAnimationFrame(() => { - if (positionedAnchorRef.current !== messageId) return; - const list = listRef.current; - if (!list) { - if (remainingAttempts > 0) positionAnchor(remainingAttempts - 1); - return; - } - const scrollNode = list.getScrollableNode(); - if (!scrollNode) { - if (remainingAttempts > 0) positionAnchor(remainingAttempts - 1); - return; - } - - let finished = false; - const finishPositioning = () => { - if (finished) return; - finished = true; - clearTimeout(fallbackTimer); - scrollNode.removeEventListener('scrollend', finishPositioning); - if (positionedAnchorRef.current !== messageId) return; - // Re-assert the resting offset without animation so the - // smooth scroll's own momentum cannot drift past it. - const scrollOffset = list.getState().scroll; - void list.scrollToOffset({ offset: scrollOffset, animated: false }); - settledAnchorRef.current = messageId; - }; - const fallbackTimer = setTimeout(finishPositioning, ANCHOR_SETTLE_FALLBACK_MS); - scrollNode.addEventListener('scrollend', finishPositioning, { once: true }); - - void list.scrollToIndex({ - index: anchorIndex, - animated: !anchorPositionInstantRef.current, - viewPosition: 0, - viewOffset: CHAT_LIST_ANCHOR_OFFSET, - }); - }); - }; - - requestAnimationFrame(() => positionAnchor(ANCHOR_POSITION_ATTEMPTS)); - }, []); - - // The anchored row can still change height after it settles (an image - // decoding, a code block highlighting). Hold the resting offset, but only - // against sub-pixel drift and only while the user has not taken over. - const onAnchorSizeChanged = React.useCallback((messageId: string) => { - if (settledAnchorRef.current !== messageId) return; - if (!isLiveFollowActive()) return; - const scrollOffset = listRef.current?.getState().scroll; - if (scrollOffset === undefined) return; - - if (pendingAnchorRestoreRef.current === null) { - pendingAnchorRestoreRef.current = { - messageId, - offset: scrollOffset, - userGeneration: userGenerationRef.current, - }; - } - if (anchorRestoreFrameRef.current !== null) return; - - anchorRestoreFrameRef.current = requestAnimationFrame(() => { - anchorRestoreFrameRef.current = null; - const pending = pendingAnchorRestoreRef.current; - pendingAnchorRestoreRef.current = null; - if ( - !pending - || settledAnchorRef.current !== pending.messageId - || pending.userGeneration !== userGenerationRef.current - ) { - return; - } - const list = listRef.current; - const currentOffset = list?.getState().scroll; - if ( - typeof currentOffset === 'number' - && Math.abs(currentOffset - pending.offset) <= ANCHOR_RESTORE_TOLERANCE_PX - ) { - void list?.scrollToOffset({ offset: pending.offset, animated: false }); - } - }); - }, [isLiveFollowActive]); - - // Whether the real rows (ignoring any reserved anchored end space) are tall - // enough to scroll. Without this, entering a short session would scroll into - // the reserved space and strand the content above the viewport. + // Whether the real rows are tall enough to scroll at all. const realContentOverflowsViewport = React.useCallback((list: TimelineListHandle): boolean => { const state = list.getState(); if (state.data.length === 0) return false; @@ -562,29 +368,21 @@ export const useChatTimelineScroll = ({ } const realContentBottom = lastTop + Math.max(1, lastHeight); - const visibleScrollLength = Math.max( - 0, - state.scrollLength - composerOverlayHeightRef.current - CHAT_LIST_ANCHOR_OFFSET, - ); + const visibleScrollLength = Math.max(0, state.scrollLength - composerOverlayHeightRef.current); return realContentBottom > visibleScrollLength; }, []); - // One deterministic correction per data change, two frames out so the list - // has measured the new rows. Nothing runs while the user owns the scroll. - const dataChangeFramesRef = React.useRef<{ first: number | null; second: number | null }>({ - first: null, - second: null, - }); - // While the list width is resizing, every pinning write fights the - // per-frame row re-measure and the pinned viewport shakes. Corrections - // stand down for the whole resize and the visible content is held by the - // list's size compensation instead. Deliberately NO snap back to the end - // afterwards for a mid-conversation reader: a slow drag settles - // repeatedly, and each snap reads as the very jump this suspension - // removes. A reader pinned to a STREAMING session is the exception — the - // live edge is what they are watching, so the end is re-asserted once on - // settle. A pinned reader of an idle session gets no scroll at all: if the - // re-wrap moved the viewport off the end, the pin is released instead. + // While the list width is resizing every row re-wraps, and the list's + // total content length lags a frame behind the rows it contains: it + // still carries pre-wrap row sizes, so any end computed from it (the + // list's own maintainScrollAtEnd, the scroll node's scrollHeight) lands + // on a blank tail or short of the real end and the viewport bounces. + // A pinned reader — streaming or idle — stays on the end throughout: the + // pinned-end observer below re-asserts the MEASURED end of the last real + // row on every layout write, and once the resize settles the end is + // asserted one last time against the same measurement. An unpinned + // reader is held in place by the list's size compensation instead and + // is never scrolled. const widthResizingRef = React.useRef(false); React.useEffect(() => { if (!scrollNode || typeof ResizeObserver === 'undefined') return; @@ -604,46 +402,21 @@ export const useChatTimelineScroll = ({ quietTimer = setTimeout(() => { quietTimer = null; widthResizingRef.current = false; - if (!isAtEndRef.current || pendingAnchorRef.current !== null) return; - if (!sessionIsWorkingRef.current) { - // An idle pinned reader asked for nothing — a width change - // must not scroll them. If the re-wrap left the viewport - // off the end, release the pin instead of snapping back; - // the scroll-to-bottom pill offers the way home. - const listState = listRef.current?.getState(); - const atEndNow = listState ? resolveTimelineIsAtEnd(listState) : undefined; - if (atEndNow === false) { - isAtEndRef.current = false; - setIsPinned(false); - modeRef.current = 'free-scrolling'; - liveFollowGenerationRef.current = null; - scheduleShowScrollButton(); - queueSave(); - } - return; - } - { - // A streaming session keeps its live edge in view, so the - // end is re-asserted once on settle. - // Not scrollToEnd: the list's end offset comes from the - // total content length, which still carries pre-wrap row - // sizes (and any reserved anchored end space) right after a - // width change. Landing there parks the last row near the - // top of the viewport with a blank tail below it. Target - // the measured bottom of the last real row instead. - const list = listRef.current; - const state = list?.getState(); - const offset = state - ? resolveRealContentEndOffset({ - state, - composerOverlayHeight: composerOverlayHeightRef.current, - }) - : null; - if (list && offset !== null) { - void list.scrollToOffset({ offset, animated: false }); - } else { - void list?.scrollToEnd({ animated: false }); - } + if (!isAtEndRef.current) return; + if (userOwnsScrollRef.current || modeRef.current !== 'following-end') return; + const list = listRef.current; + const state = list?.getState(); + const offset = state + ? resolveRealContentEndOffset({ + state, + composerOverlayHeight: composerOverlayHeightRef.current, + footerSize: listFooterSizeRef.current, + }) + : null; + if (list && offset !== null) { + void list.scrollToOffset({ offset, animated: false }); + } else { + void list?.scrollToEnd({ animated: false }); } }, 350); }); @@ -652,7 +425,7 @@ export const useChatTimelineScroll = ({ observer.disconnect(); if (quietTimer !== null) clearTimeout(quietTimer); }; - }, [queueSave, scheduleShowScrollButton, scrollNode]); + }, [scrollNode]); // Keep the live edge in view after content growth. Within a viewport of // the end the remaining distance is glided so a revealed block and the @@ -699,7 +472,7 @@ export const useChatTimelineScroll = ({ const offset = resolveRealContentEndOffset({ state, composerOverlayHeight: composerOverlayHeightRef.current, - extraInset: CHAT_LIST_ANCHOR_OFFSET, + footerSize: listFooterSizeRef.current, }); if (offset !== null) { void list.scrollToOffset({ offset, animated: false }); @@ -742,58 +515,8 @@ export const useChatTimelineScroll = ({ // block is several viewports tall, so every block left the reader a // second behind and multiple screens above the live edge — measured // at 45% of the stream time spent 500-1600px behind at 420x640. - if (modeRef.current === 'following-end') { - followEnd(); - return; - } - - const frames = dataChangeFramesRef.current; - if (frames.first !== null) cancelAnimationFrame(frames.first); - if (frames.second !== null) cancelAnimationFrame(frames.second); - - frames.first = requestAnimationFrame(() => { - frames.first = null; - frames.second = requestAnimationFrame(() => { - frames.second = null; - if (!isLiveFollowActive()) return; - // An anchor that exists but has not come to rest yet owns the - // viewport; correcting now would fight its animation. - if (pendingAnchorRef.current !== null) return; - if ( - positionedAnchorRef.current !== null - && settledAnchorRef.current !== positionedAnchorRef.current - ) { - return; - } - - const list = listRef.current; - if (!list) return; - - if (modeRef.current === 'anchoring-new-turn') { - const anchorIndex = activeAnchorIndexRef.current; - if (anchorIndex === null) return; - const metrics = getAnchoredTurnMetrics({ - state: list.getState(), - anchorIndex, - composerOverlayHeight: composerOverlayHeightRef.current, - anchorOffset: CHAT_LIST_ANCHOR_OFFSET, - }); - // The turn still fits: leave the viewport exactly where the - // user is reading. - if (!metrics || metrics.scrollDeltaToRevealEnd <= 1) return; - // Animated: successive corrections restart the smooth scroll - // from the current position, so streaming reads as one - // continuous glide instead of a per-line hop. A real user - // gesture interrupts the native smooth scroll on its own. - void list.scrollToOffset({ - offset: list.getState().scroll + metrics.scrollDeltaToRevealEnd, - animated: true, - }); - return; - } - - }); - }); + if (modeRef.current !== 'following-end') return; + followEnd(); }, [followEnd, isLiveFollowActive, scheduleShowScrollButton]); // The streaming tail grows inside one row without changing the entries @@ -821,11 +544,7 @@ export const useChatTimelineScroll = ({ // A gesture is meaningful when the viewport can move up AT ALL: // either the real rows overflow the viewport, or there is scrolled - // history above (an anchored turn parks mid-conversation with - // reserved space below — the real rows may not overflow yet, but - // wheel-up is still a genuine opt-out; swallowing it left live-follow - // armed, which suppressed the pill and kept corrections armed under a - // viewport the user had taken). + // history above. const canScrollUp = () => { const list = listRef.current; if (!list) return false; @@ -933,18 +652,33 @@ export const useChatTimelineScroll = ({ // sits on the end of a session that is not producing output, any growth // of the content (a footer that decides to render, a row re-measured) // keeps the end in view with one instant write. Output growth belongs to - // followEnd, which glides. + // followEnd, which glides. A width resize is the one case handled for a + // streaming reader as well — see the resize observer above. React.useEffect(() => { if (!scrollNode || typeof MutationObserver === 'undefined') return; const content = scrollNode.firstElementChild; if (!content) return; const pin = () => { - if (sessionIsWorkingRef.current) return; - // A width resize re-wraps every row; pinning against each mutation - // scrolls the idle reader around. The resize settle handler above - // decides whether the pin survives the resize. - if (widthResizingRef.current) return; if (userOwnsScrollRef.current || !isAtEndRef.current || modeRef.current !== 'following-end') return; + if (widthResizingRef.current) { + // Re-wrapping rows: the scroll node's scrollHeight carries the + // list's stale total, so the end is the measured bottom of the + // last real row. Held for a streaming reader too — output + // growth is not what moves the viewport during a resize. + const state = listRef.current?.getState(); + const offset = state + ? resolveRealContentEndOffset({ + state, + composerOverlayHeight: composerOverlayHeightRef.current, + footerSize: listFooterSizeRef.current, + }) + : null; + if (offset !== null && Math.abs(offset - scrollNode.scrollTop) > 1) { + scrollNode.scrollTop = offset; + } + return; + } + if (sessionIsWorkingRef.current) return; const end = scrollNode.scrollHeight - scrollNode.clientHeight; if (end - scrollNode.scrollTop > 1) scrollNode.scrollTop = end; }; @@ -977,9 +711,8 @@ export const useChatTimelineScroll = ({ setUserOwnsScroll(false); modeRef.current = 'following-end'; liveFollowGenerationRef.current = userGenerationRef.current; - clearAnchor(); hideScrollButton(); - }, [clearAnchor, currentSessionId, currentSessionKey, flushSave, hideScrollButton]); + }, [currentSessionId, currentSessionKey, flushSave, hideScrollButton]); // Suppress the overlay scrollbar thumb while automatic movement owns the // scroll position, so it does not jump on each correction. @@ -990,10 +723,6 @@ export const useChatTimelineScroll = ({ React.useEffect(() => () => { cancelShowButtonTimer(); if (saveTimerRef.current !== null) clearTimeout(saveTimerRef.current); - if (anchorRestoreFrameRef.current !== null) cancelAnimationFrame(anchorRestoreFrameRef.current); - const frames = dataChangeFramesRef.current; - if (frames.first !== null) cancelAnimationFrame(frames.first); - if (frames.second !== null) cancelAnimationFrame(frames.second); }, [cancelShowButtonTimer]); // ── active-turn spy ───────────────────────────────────────────────────── @@ -1075,10 +804,8 @@ export const useChatTimelineScroll = ({ scrollNode, isPinned, registerList, - anchorMessageId, - onAnchorReady, - onAnchorSizeChanged, onIsAtEndChange, + onListMetricsChange, onManualNavigation, onTimelineDataChange, showScrollButton, diff --git a/packages/ui/src/hooks/useCommitComparison.ts b/packages/ui/src/hooks/useCommitComparison.ts new file mode 100644 index 00000000..236a9915 --- /dev/null +++ b/packages/ui/src/hooks/useCommitComparison.ts @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { GitLogEntry } from '@/lib/api/types'; +import { getRuntimeKey } from '@/lib/runtime-switch'; +import { useI18n } from '@/lib/i18n'; +import { useGitStore } from '@/stores/useGitStore'; +import { commitSelectionKey, useCommitSelectionStore } from '@/stores/useCommitSelectionStore'; +import { useRuntimeAPIs } from './useRuntimeAPIs'; + +type CommitHistory = + | { key: string; status: 'loading' } + | { key: string; status: 'ready'; commits: GitLogEntry[] } + | { key: string; status: 'error'; message: string }; +const NO_COMMITS: GitLogEntry[] = []; + +export function useCommitComparison(directory: string | null, branch: string | null, enabled: boolean, preferredHash?: string) { + const { git } = useRuntimeAPIs(); + const { t } = useI18n(); + const runtimeKey = useGitStore((state) => state.runtimeKey); + const key = commitSelectionKey(directory ?? '', branch, runtimeKey); + const selectedCommit = useCommitSelectionStore((state) => state.selections.get(key) ?? null); + const selectCommit = useCommitSelectionStore((state) => state.select); + const [history, setHistory] = useState(null); + const requestId = useRef(0); + const preferredHashRef = useRef(preferredHash); + preferredHashRef.current = preferredHash; + const refresh = useCallback(async () => { + if (!directory || !enabled) return; + const id = ++requestId.current; + const requestRuntime = getRuntimeKey(); + setHistory({ key, status: 'loading' }); + try { + const result = await git.getGitLog(directory, { maxCount: 50, to: branch ? `refs/heads/${branch}` : 'HEAD' }); + if (requestId.current !== id || getRuntimeKey() !== requestRuntime) return; + const commits = result.all.slice(0, 50); + setHistory({ key, status: 'ready', commits }); + if (!useCommitSelectionStore.getState().selections.has(key)) { + const initial = preferredHashRef.current + ? commits.find((commit) => commit.hash === preferredHashRef.current) + : commits[0]; + if (initial) selectCommit(key, initial); + } + } catch (error) { + if (requestId.current !== id || getRuntimeKey() !== requestRuntime) return; + setHistory({ key, status: 'error', message: error instanceof Error ? error.message : t('commitComparison.loadError') }); + } + }, [branch, directory, enabled, git, key, selectCommit, t]); + + useEffect(() => { + void refresh(); + return () => { requestId.current += 1; }; + }, [refresh]); + + const current = history?.key === key ? history : null; + return { + selectedCommit, + commits: current?.status === 'ready' ? current.commits : NO_COMMITS, + loading: enabled && (!current || current.status === 'loading'), + error: current?.status === 'error' ? current.message : null, + refresh, + select: (commit: GitLogEntry) => selectCommit(key, commit), + }; +} diff --git a/packages/ui/src/hooks/useGitComparison.test.tsx b/packages/ui/src/hooks/useGitComparison.test.tsx new file mode 100644 index 00000000..95e2ec55 --- /dev/null +++ b/packages/ui/src/hooks/useGitComparison.test.tsx @@ -0,0 +1,135 @@ +import React, { act } from 'react'; +import { expect, test } from 'bun:test'; +import { Window } from 'happy-dom'; +import type { GitComparisonSource } from './useGitComparison'; + +test('comparison reads preserve scope, report failures, retry, and stop while hidden', async () => { + const dom = new Window({ url: 'http://localhost' }); + const originals = new Map(); + const globals = { + window: dom, document: dom.document, navigator: dom.navigator, location: dom.location, + localStorage: dom.localStorage, + Element: dom.Element, HTMLElement: dom.HTMLElement, Node: dom.Node, + Event: dom.Event, CustomEvent: dom.CustomEvent, + requestAnimationFrame: dom.requestAnimationFrame.bind(dom), + cancelAnimationFrame: dom.cancelAnimationFrame.bind(dom), IS_REACT_ACT_ENVIRONMENT: true, + }; + for (const [name, value] of Object.entries(globals)) { + originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); + Object.defineProperty(globalThis, name, { configurable: true, writable: true, value }); + } + const originalFetch = globalThis.fetch; + const requests: Array<{ url: URL; resolve: (response: Response) => void }> = []; + globalThis.fetch = Object.assign((input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input), 'http://localhost'); + // Hold unrelated app-store bootstrap outside this fixture. Only explicitly + // resolved comparison requests should publish data during these transitions. + if (url.pathname === '/api/fs/home' || url.pathname === '/api/session-folders') { + return new Promise(() => {}); + } + return new Promise((resolve) => { requests.push({ url, resolve }); }); + }, originalFetch); + const { createRoot } = await import('react-dom/client'); + const { I18nProvider } = await import('@/lib/i18n'); + const { useGitComparison } = await import('./useGitComparison'); + type Capture = { current: ReturnType | null }; + const captured: Capture = { current: null }; + let directory = '/repo-a'; + let source: GitComparisonSource = { kind: 'branch', baseRef: 'refs/heads/main', headRef: 'feature' }; + let enabled = false; + let revision = '1'; + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + function Harness() { + captured.current = useGitComparison(directory, source, enabled, revision); + return null; + } + const current = () => { + if (!captured.current) throw new Error('Comparison did not render'); + return captured.current; + }; + const render = () => act(async () => { root.render(); }); + const finish = (index: number, response: Response) => act(async () => { requests[index].resolve(response); }); + try { + await render(); + expect(requests.map(({ url }) => url.pathname)).toEqual([]); + enabled = true; + await render(); + expect(requests).toHaveLength(1); + expect(requests[0].url.searchParams.get('base')).toBe('refs/heads/main'); + expect(requests[0].url.searchParams.get('includeWorkingTree')).toBe('true'); + await finish(0, Response.json({ files: [{ path: 'a.ts', status: 'M' }] })); + expect(current().files?.map((file) => file.path)).toEqual(['a.ts']); + const oldRefresh = current().refresh; + const oldFetchDiff = current().fetchDiff; + + const patch = current().fetchDiff('a.ts'); + await act(async () => { await Promise.resolve(); }); + expect(requests[1].url.pathname).toBe('/api/git/range-diff'); + expect(requests[1].url.searchParams.get('includeWorkingTree')).toBe('true'); + await finish(1, Response.json({ diff: 'branch patch' })); + expect(await patch).toEqual({ diff: 'branch patch' }); + + revision = '2'; + await render(); + expect(current().files?.map((file) => file.path)).toEqual(['a.ts']); + await finish(2, Response.json({ error: 'snapshot failed' }, { status: 500 })); + expect(current().files).toBeNull(); + expect(current().error).toBe('snapshot failed'); + let retry: Promise | undefined; + await act(async () => { retry = current().refresh(); }); + await finish(3, Response.json({ files: [] })); + await retry; + expect(current().files).toEqual([]); + expect(current().error).toBeNull(); + + source = { kind: 'commit', hash: 'a'.repeat(40) }; + await render(); + expect(current().files).toBeNull(); + expect(requests[4].url.pathname).toBe('/api/git/commit-files'); + await finish(4, Response.json({ files: [{ path: 'new.ts', previousPath: 'old.ts', changeType: 'R', insertions: 1, deletions: 1, isBinary: false }] })); + const commitPatch = current().fetchDiff('new.ts', 20); + await act(async () => { await Promise.resolve(); }); + expect(requests[5].url.pathname).toBe('/api/git/commit-diff'); + expect(requests[5].url.searchParams.get('hash')).toBe('a'.repeat(40)); + expect(requests[5].url.searchParams.get('previousPath')).toBe('old.ts'); + expect(requests[5].url.searchParams.get('context')).toBe('20'); + await finish(5, Response.json({ diff: 'commit patch' })); + expect(await commitPatch).toEqual({ diff: 'commit patch' }); + await oldRefresh(); + await expect(oldFetchDiff('a.ts')).rejects.toThrow(); + expect(requests).toHaveLength(6); + + source = { kind: 'branch', baseRef: 'main', headRef: 'feature' }; + await render(); + directory = '/repo-b'; + await render(); + await finish(7, Response.json({ files: [{ path: 'b.ts', status: 'A' }] })); + await finish(6, Response.json({ files: [{ path: 'stale.ts', status: 'D' }] })); + expect(current().files?.map((file) => file.path)).toEqual(['b.ts']); + + const refreshBeforeHide = current().refresh; + const fetchBeforeHide = current().fetchDiff; + enabled = false; + revision = '3'; + await render(); + await current().refresh(); + await refreshBeforeHide(); + await expect(fetchBeforeHide('b.ts')).rejects.toThrow(); + expect(requests).toHaveLength(8); + expect(current().files?.map((file) => file.path)).toEqual(['b.ts']); + enabled = true; + await render(); + expect(requests).toHaveLength(9); + await finish(8, Response.json({ files: [{ path: 'b.ts', status: 'A' }] })); + } finally { + await act(async () => root.unmount()); + globalThis.fetch = originalFetch; + await dom.happyDOM.abort(); + for (const [name, descriptor] of originals) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else Reflect.deleteProperty(globalThis, name); + } + } +}); diff --git a/packages/ui/src/hooks/useGitComparison.ts b/packages/ui/src/hooks/useGitComparison.ts new file mode 100644 index 00000000..57268205 --- /dev/null +++ b/packages/ui/src/hooks/useGitComparison.ts @@ -0,0 +1,79 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { GitDiffResponse } from '@/lib/api/types'; +import { getCommitFiles, getGitCommitDiff, getGitRangeDiff, getGitRangeFiles } from '@/lib/gitApi'; +import { useI18n } from '@/lib/i18n'; +import { getRuntimeKey } from '@/lib/runtime-switch'; +import type { WalkthroughSource } from '@/lib/walkthrough/types'; +import { useGitStore } from '@/stores/useGitStore'; + +export type GitComparisonSource = Extract; + +export interface GitComparisonFile { + path: string; + status: string; + previousPath?: string; + insertions: number; + deletions: number; +} + +type ComparisonFiles = + | { key: string; status: 'loading' } + | { key: string; status: 'ready'; files: GitComparisonFile[] } + | { key: string; status: 'error'; message: string }; + +/** File-list authority shared by the stacked desktop view and mobile drill-down. */ +export function useGitComparison(directory: string | null, source: GitComparisonSource | null, enabled = true, revision = '') { + const { t } = useI18n(); + const runtimeKey = useGitStore((state) => state.runtimeKey); + const key = directory && source ? JSON.stringify([runtimeKey, directory, source]) : null; + const sourceRef = useRef({ key, source, enabled }); + sourceRef.current = { key, source, enabled }; + const [result, setResult] = useState(null); + const generation = useRef(0); + + const refresh = useCallback(async () => { + const { key: targetKey, source: target, enabled: active } = sourceRef.current; + if (!enabled || !active || !key || targetKey !== key || !directory || !target) return; + const request = ++generation.current; + const runtime = getRuntimeKey(); + setResult((previous) => previous?.key === key && previous.status === 'ready' ? previous : { key, status: 'loading' }); + try { + const files: GitComparisonFile[] = target.kind === 'branch' + ? (await getGitRangeFiles(directory, { base: target.baseRef, head: target.headRef, includeWorkingTree: true })) + .map((file) => ({ ...file, insertions: 0, deletions: 0 })) + : (await getCommitFiles(directory, target.hash)).files + .map((file) => ({ path: file.path, status: file.changeType, previousPath: file.previousPath, insertions: file.insertions, deletions: file.deletions })); + if (generation.current !== request || getRuntimeKey() !== runtime) return; + setResult({ key, status: 'ready', files }); + } catch (error) { + if (generation.current !== request || getRuntimeKey() !== runtime) return; + setResult({ key, status: 'error', message: error instanceof Error ? error.message : t('diffView.state.failedToLoadDiff') }); + } + }, [directory, enabled, key, t]); + + useEffect(() => { + void refresh(); + return () => { generation.current += 1; }; + }, [refresh, revision]); + + const current = result?.key === key ? result : null; + const files = current?.status === 'ready' ? current.files : null; + const filesByPath = useMemo(() => new Map((files ?? []).map((file) => [file.path, file])), [files]); + const fetchDiff = useCallback(async (filePath: string, contextLines = 3): Promise => { + const { key: targetKey, source: target, enabled: active } = sourceRef.current; + const file = filesByPath.get(filePath); + if (!directory || targetKey !== key || !target || !file || !enabled || !active) throw new Error(t('diffView.state.failedToLoadDiff')); + return target.kind === 'branch' + ? getGitRangeDiff(directory, { base: target.baseRef, head: target.headRef, path: filePath, contextLines, includeWorkingTree: true }) + : getGitCommitDiff(directory, { hash: target.hash, path: filePath, previousPath: file.previousPath, contextLines }); + }, [directory, enabled, filesByPath, key, t]); + + return { + key, + files, + loading: Boolean(enabled && key && (!current || current.status === 'loading')), + error: current?.status === 'error' ? current.message : null, + refresh, + fetchDiff, + }; +} diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 1618dfc3..b18e4806 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -42,7 +42,7 @@ import { invokeActiveSelectionAddToChat, } from '@/lib/addSelectionToChat'; import { isIMECompositionEvent } from '@/lib/ime'; -import { hasOpenDropdown, isEditableEventTarget, shouldStopDropdownImeEscape } from './keyboard-shortcut-dom'; +import { hasActiveBtwComposer, hasOpenDropdown, isEditableEventTarget, shouldStopDropdownImeEscape } from './keyboard-shortcut-dom'; const dropdownTargetSelector = [ '[data-slot="dropdown-menu-content"]', '[data-slot="select-content"]', '[role="combobox"]', @@ -230,6 +230,7 @@ export const useKeyboardShortcuts = () => { focusChatInput(); }, cycle_agent: (event) => { + if (hasActiveBtwComposer()) return false; const state = useUIStore.getState(); const hasOverlay = state.isSettingsDialogOpen || state.isCommandPaletteOpen @@ -261,6 +262,7 @@ export const useKeyboardShortcuts = () => { return toggleTerminalSurfaceExpanded(); }, open_model_selector: () => { + if (hasActiveBtwComposer()) return false; const state = useUIStore.getState(); const hasOverlay = state.isCommandPaletteOpen || state.isHelpDialogOpen @@ -270,6 +272,7 @@ export const useKeyboardShortcuts = () => { state.setModelSelectorOpen(!state.isModelSelectorOpen); }, cycle_thinking_variant: () => { + if (hasActiveBtwComposer()) return false; const state = useUIStore.getState(); const hasOverlay = state.isCommandPaletteOpen || state.isHelpDialogOpen @@ -294,10 +297,12 @@ export const useKeyboardShortcuts = () => { cycle_favorite_model_forward: () => cycleFavoriteModel(1), cycle_favorite_model_backward: () => cycleFavoriteModel(-1), expand_input: () => { + if (hasActiveBtwComposer()) return false; if (useUIStore.getState().isMobile) return false; useUIStore.getState().toggleExpandedInput(); }, toggle_dictation: () => { + if (hasActiveBtwComposer()) return false; const state = useUIStore.getState(); if ( state.isCommandPaletteOpen @@ -316,6 +321,7 @@ export const useKeyboardShortcuts = () => { }); function cycleFavoriteModel(delta: number): boolean | void { + if (hasActiveBtwComposer()) return false; const state = useUIStore.getState(); const hasOverlay = state.isCommandPaletteOpen || state.isHelpDialogOpen @@ -403,6 +409,7 @@ export const useKeyboardShortcuts = () => { } if ( target?.closest('[role="dialog"]') + || target?.closest('[data-btw-composer="true"]') || isTerminalEventTarget(target) || dropdownOpen ) { diff --git a/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts b/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts index aaa31edf..5c1f6961 100644 --- a/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts @@ -7,7 +7,7 @@ import { useUIStore } from '@/stores/useUIStore'; import { useSelectionStore } from '@/sync/selection-store'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useKeybinds } from './useKeybind'; -import { isEditableEventTarget } from './keyboard-shortcut-dom'; +import { hasActiveBtwComposer, isEditableEventTarget } from './keyboard-shortcut-dom'; export const useMiniChatKeyboardShortcuts = () => { const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); @@ -25,6 +25,7 @@ export const useMiniChatKeyboardShortcuts = () => { const dispatcher = dispatcherRef.current; const cycleFavoriteModel = (delta: number): boolean | void => { + if (hasActiveBtwComposer()) return false; const { favoriteModels, addRecentModel } = useUIStore.getState(); if (favoriteModels.length === 0) return false; @@ -64,10 +65,12 @@ export const useMiniChatKeyboardShortcuts = () => { focusChatInput(); }, open_model_selector: () => { + if (hasActiveBtwComposer()) return false; const { isModelSelectorOpen, setModelSelectorOpen } = useUIStore.getState(); setModelSelectorOpen(!isModelSelectorOpen); }, cycle_thinking_variant: () => { + if (hasActiveBtwComposer()) return false; const configState = useConfigStore.getState(); if (configState.getCurrentModelVariants().length === 0) return false; diff --git a/packages/ui/src/hooks/useProviderLogo.ts b/packages/ui/src/hooks/useProviderLogo.ts index 80ded66d..c2342a3f 100644 --- a/packages/ui/src/hooks/useProviderLogo.ts +++ b/packages/ui/src/hooks/useProviderLogo.ts @@ -20,6 +20,7 @@ const LOGO_ALIAS = new Map([ ['codex', 'openai'], ['chatgpt', 'openai'], ['claude', 'anthropic'], + ['cline-pass', 'cline'], ['gemini', 'google'], ['evroc-ai', 'evroc'], ['evrocai', 'evroc'], diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 30d43cab..3128449c 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -75,11 +75,15 @@ button, line-height: round(1.625em, 1px); } -:root.light .message-content-text::selection, -:root.light .message-content-text ::selection, -:root.light .oc-file-preview::selection, -:root.light .oc-file-preview ::selection { - background: color-mix(in srgb, var(--interactive-border-focus) 18%, transparent); +/* Text selection in chat and file previews: the theme accent at 30%, the same + in every theme. The themes' own selection token is tuned for selected rows + and in several light themes is nearly the page background; a translucent + accent stays visible over every background and never hides the text. */ +.message-content-text::selection, +.message-content-text ::selection, +.oc-file-preview::selection, +.oc-file-preview ::selection { + background: var(--oc-text-selection); color: var(--surface-foreground); } @@ -89,14 +93,12 @@ button, /* Quoted chat fragment kept visibly highlighted while its comment input is open (the native selection collapses once the input takes focus). The - rectangles come from the selection's own client rects, so shape and shade - both match the native selection that was just showing. */ + rectangles come from the selection's own client rects and paint in the + selection colour, so shape and shade both match the native selection that + was just showing; the colour is translucent because these rectangles sit + above the text. */ .oc-chat-comment-rect { - background-color: var(--interactive-selection); -} - -:root.light .oc-chat-comment-rect { - background-color: color-mix(in srgb, var(--interactive-border-focus) 18%, transparent); + background-color: var(--oc-text-selection); } .pierre-diff-wrapper { @@ -653,6 +655,13 @@ html:not(.dark) .chat-scroll { } /* Overlay scrollbar styling (custom thumb) */ +/* Reserve the thumb's 6px width, 4px right inset and a 4px content gap. + Follow overflow, not hover visibility, so showing the thumb never shifts text. + Direct-child matching prevents nested scrollers from reserving space twice. */ +.overlay-scrollbar-wrapper:has(> .overlay-scrollbar > .overlay-scrollbar__thumb--vertical:not([hidden])) { + padding-right: 6px; +} + .overlay-scrollbar { position: absolute; inset: 0; @@ -908,11 +917,26 @@ html:not(.dark) .chat-scroll { } } -/* Assistant message footer: collapse text labels only on very narrow layouts. */ -@container message-footer (max-width: 16rem) { - .message-footer__label { - display: none; - } +/* Assistant message footer: one line of facts, in priority order. Which of + them fit is decided by useFactsFit, which hides the least important ones + until the model name stops being truncated — CSS can only guess at the + model's width, and a wrapped-away fact leaves its width behind as a hole. */ +.message-footer__facts { + display: flex; + align-items: center; + min-width: 0; + flex: 1 1 auto; + overflow: hidden; +} + +/* Each fact carries its own leading separator and spacing. */ +.message-footer__fact { + display: flex; + align-items: center; + gap: 0.375rem; + padding-inline-start: 0.375rem; + flex: 0 0 auto; + white-space: nowrap; } /* Animated tabs: collapse labels based on local container width. */ @@ -995,11 +1019,13 @@ html:not(.dark) .chat-scroll { } } -/* Text font */ +/* Text font. The line height is snapped to whole pixels: a fractional + value lets glyph baselines land on half pixels and text lines blur or + render unevenly as they scroll. */ .markdown-content { font-family: var(--font-sans); font-size: var(--text-markdown); - line-height: inherit; + line-height: round(1.625em, 1px); } .markdown-content p, @@ -1010,20 +1036,31 @@ html:not(.dark) .chat-scroll { line-height: inherit; } -/* Restore paragraph block spacing inside markdown content. The - --markdown-paragraph-spacing token existed but was never wired, so Tailwind - preflight's zeroed

margins left adjacent paragraphs collapsed into one - visual line. */ -.markdown-content p { - margin: 0 0 var(--markdown-paragraph-spacing) 0; +/* Block rhythm: every block carries the same margin on both sides, so the + gap between any two neighbours is one paragraph spacing (margins collapse) + and the first/last block of a message adds nothing. Tailwind preflight + zeroes these margins, which left adjacent paragraphs reading as one. */ +.markdown-content p, +.markdown-content ul, +.markdown-content ol, +.markdown-content blockquote, +.markdown-content hr, +.markdown-content table { + margin: var(--markdown-paragraph-spacing) 0; } /* The markdown renderer wraps each block in a display:contents [data-md-block] - element, so the message-level [&_.markdown-content>*:last-child]:mb-0 - nullifiers target the wrapper (where margin is ignored), not the paragraph. - Drop the trailing margin on the last paragraph of the last block directly so - messages don't gain extra space at the bottom. */ -.markdown-content > [data-md-block]:last-child p:last-child { + element, so the message-level [&_.markdown-content>*:first-child]:mt-0 / + [&_.markdown-content>*:last-child]:mb-0 nullifiers target the wrapper + (where margin is ignored), not the block. Trim the outer blocks directly so + messages gain no extra space at the top or bottom. */ +.markdown-content > [data-md-block]:first-child > :first-child, +.markdown-content > :first-child:not([data-md-block]) { + margin-top: 0; +} + +.markdown-content > [data-md-block]:last-child > :last-child, +.markdown-content > :last-child:not([data-md-block]) { margin-bottom: 0; } @@ -1110,35 +1147,36 @@ html:not(.dark) .chat-scroll { font-size: inherit !important; } -/* Streamdown headers: keep proportional but not oversized */ -.markdown-content h1 { - font-size: 1.125em; +/* Headings: a clear step above the body without becoming display type, and + more air above than below so each heading binds to the text it introduces. */ +.markdown-content h1, +.markdown-content h2, +.markdown-content h3, +.markdown-content h4, +.markdown-content h5, +.markdown-content h6 { + margin: 1.25rem 0 0.5rem; + color: color-mix(in srgb, var(--foreground) 95%, transparent); font-weight: 600; - margin-top: 1em; - margin-bottom: 0.5em; + line-height: 1.3; +} + +.markdown-content h1 { + font-size: 1.25rem; } .markdown-content h2 { - font-size: 1.0625em; - font-weight: 600; - margin-top: 0.875em; - margin-bottom: 0.375em; + font-size: 1.125rem; } .markdown-content h3 { - font-size: 1em; - font-weight: 600; - margin-top: 0.75em; - margin-bottom: 0.25em; + font-size: 1rem; } .markdown-content h4, .markdown-content h5, .markdown-content h6 { - font-size: 1em; - font-weight: 600; - margin-top: 0.625em; - margin-bottom: 0.25em; + font-size: 0.875rem; } /* Code font */ @@ -1148,35 +1186,17 @@ html:not(.dark) .chat-scroll { font-size: var(--text-code); } -/* Override Streamdown's hardcoded bg-muted for inline code - use theme colors instead */ +/* Inline code sits inside a sentence: a quiet muted chip in the text colour, + one step smaller than the prose so it never towers over it. */ .markdown-content code[data-markdown="inline-code"] { - background-color: var(--markdown-inline-code-bg, var(--surface-subtle)) !important; - color: var(--markdown-inline-code, var(--foreground)) !important; - padding: 0.125rem 0.3125rem; + background-color: var(--surface-muted) !important; + color: inherit !important; + padding: 0.1rem 0.35rem; border-radius: 0.375rem; word-break: break-all; overflow-wrap: break-word; } -/* Markdown headings - use theme colors */ -.markdown-content h1 { - color: var(--markdown-heading1, var(--primary)); -} - -.markdown-content h2 { - color: var(--markdown-heading2, var(--primary)); -} - -.markdown-content h3 { - color: var(--markdown-heading3, var(--primary)); -} - -.markdown-content h4, -.markdown-content h5, -.markdown-content h6 { - color: var(--markdown-heading4, var(--foreground)); -} - /* Markdown links - use theme colors */ .markdown-content a { color: var(--markdown-link, var(--primary)); @@ -1212,8 +1232,9 @@ html:not(.dark) .chat-scroll { /* Markdown blockquote - use theme colors */ .markdown-content blockquote { + border-left: 2px solid var(--markdown-blockquote-border, var(--border)); + padding-left: 0.8rem; color: var(--markdown-blockquote, var(--muted-foreground)); - border-left-color: var(--markdown-blockquote-border, var(--border)); } /* Markdown horizontal rule - use theme colors */ @@ -1666,44 +1687,54 @@ textarea[data-terminal-hidden-input="true"]::placeholder { color: transparent !important; } -/* Hide browser/system caret for Ghostty's own input surfaces. - Terminal cursor is rendered by the canvas renderer. */ -.terminal-viewport-container[contenteditable="true"][aria-label="Terminal input"], -.terminal-viewport-container [contenteditable="true"][aria-label="Terminal input"] { +/* libghostty-vt terminal surface. The canvas draws the grid and cursor; the + textarea only receives keyboard, IME and clipboard events and follows the + cursor so IME candidates appear where the user types. */ +.terminal-viewport-container .oc-terminal-canvas { + display: block; + width: 100%; + height: 100%; + cursor: text; +} + +.terminal-viewport-container .oc-terminal-input { + position: absolute; + left: 4px; + top: 4px; + width: 1px; + height: 1px; + opacity: 0; + padding: 0; + border: 0; + resize: none; + pointer-events: none; caret-color: transparent !important; outline: none !important; } -textarea[aria-label="Terminal input"], -input[aria-label="Terminal input"] { - caret-color: transparent !important; - outline: none !important; +.terminal-viewport-container .oc-terminal-scrollbar { + position: absolute; + top: 4px; + right: 1px; + bottom: 4px; + z-index: 1; + width: 8px; + cursor: default; + touch-action: none; } -/* When touch terminal input uses the portaled overlay, keep Ghostty's - internal editable surfaces fully non-painting to avoid duplicate cursors. */ -.terminal-viewport-container[data-hidden-input-overlay-active="true"] textarea:not([data-terminal-hidden-input="true"]), -.terminal-viewport-container[data-hidden-input-overlay-active="true"] input:not([data-terminal-hidden-input="true"]), -.terminal-viewport-container[data-hidden-input-overlay-active="true"] [contenteditable="true"] { - caret-color: transparent !important; - color: transparent !important; - -webkit-text-fill-color: transparent !important; - background: transparent !important; - border: 0 !important; - outline: none !important; - box-shadow: none !important; - text-shadow: none !important; +.terminal-viewport-container .oc-terminal-scrollbar-thumb { + position: absolute; + left: 1px; + right: 1px; + top: 0; + border-radius: 3px; + background: var(--oc-scrollbar-thumb); + transition: background-color 120ms ease-out; } -.terminal-viewport-container[data-hidden-input-overlay-active="true"] textarea:not([data-terminal-hidden-input="true"]), -.terminal-viewport-container[data-hidden-input-overlay-active="true"] input:not([data-terminal-hidden-input="true"]), -.terminal-viewport-container[data-hidden-input-overlay-active="true"] [contenteditable="true"] { - opacity: 0 !important; - font-size: 0 !important; - line-height: 0 !important; - pointer-events: none !important; - user-select: none !important; - -webkit-user-select: none !important; +.terminal-viewport-container .oc-terminal-scrollbar:hover .oc-terminal-scrollbar-thumb { + background: var(--oc-scrollbar-thumb-hover); } /* Mobile Terminal Optimizations */ @@ -1714,15 +1745,6 @@ input[aria-label="Terminal input"] { } } -/* Font loading states */ -.fonts-loading .terminal-viewport-container { - font-family: monospace; -} - -.fonts-loaded .terminal-viewport-container { - font-family: "JetBrainsMono Nerd Font", "FiraCode Nerd Font", "Fira Code", "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; -} - @keyframes oc-busy-pulse { 0%, 100% { opacity: 0.2; @@ -1862,11 +1884,6 @@ input[aria-label="Terminal input"] { } -/* Settings dialog: hide the overlay scrollbar; wheel/keyboard scroll still works. */ -[data-settings-view="true"] .overlay-scrollbar { - display: none; -} - /* Desktop app shell (Electron/native window): native apps use the arrow cursor for clickable controls, not the web's hand/pointer. Neutralize pointer cursors under the desktop runtime only — web in a browser keeps them. diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 001f21b1..bf041b6e 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -1,6 +1,5 @@ import type { WorktreeMetadata } from '@/types/worktree'; -import type { DraftStarterRef } from '@/lib/draftStarters'; -import type { InputHistoryScope } from '@/lib/inputHistoryScope'; +import type { DesktopSettings } from '@/lib/settings/registry'; type RuntimePlatform = 'web' | 'desktop' | 'vscode'; @@ -45,6 +44,9 @@ export interface TerminalStreamEvent { sequence?: number; data?: string; replayData?: string; + /** PTY size the snapshot history was drawn for; only `snapshot` events carry it. */ + cols?: number; + rows?: number; status?: 'running' | 'exited' | 'error'; exitCode?: number; signal?: number | null; @@ -188,18 +190,22 @@ export interface GetGitDiffOptions { /** * Diff between two refs. Uses three-dot (`base...head`) semantics server-side, so changes - * pulled into `head` by merging `base` are excluded — only the branch's own work is returned. + * pulled into `head` by merging `base` are excluded. Refs are used as selected. + * includeWorkingTree compares that merge base with the checked-out branch's + * current files, including staged, unstaged, and untracked changes. */ export interface GetGitRangeDiffOptions { base: string; head: string; path?: string; contextLines?: number; + includeWorkingTree?: boolean; } export interface GetGitRangeFilesOptions { base: string; head: string; + includeWorkingTree?: boolean; } /** One changed file in a `base...head` range, with its change letter (A/M/D/R/C). */ @@ -386,6 +392,7 @@ export interface GitLogResponse { export interface CommitFileEntry { path: string; + previousPath?: string; insertions: number; deletions: number; isBinary: boolean; @@ -396,6 +403,13 @@ export interface GitCommitFilesResponse { files: CommitFileEntry[]; } +export interface GetGitCommitDiffOptions { + hash: string; + path?: string; + previousPath?: string; + contextLines?: number; +} + export interface CommitFileDiffResponse { original: string; modified: string; @@ -567,6 +581,7 @@ export interface GitAPI { renameBranch(directory: string, oldName: string, newName: string): Promise<{ success: boolean; branch: string }>; getGitLog(directory: string, options?: GitLogOptions): Promise; getCommitFiles(directory: string, hash: string): Promise; + getGitCommitDiff?(directory: string, options: GetGitCommitDiffOptions): Promise; getCommitFileDiff?(directory: string, hash: string, filePath: string, isBinary: boolean): Promise; getCurrentGitIdentity(directory: string): Promise; hasLocalIdentity?(directory: string): Promise; @@ -697,79 +712,11 @@ export interface ProjectEntry { sidebarCollapsed?: boolean; } -export interface SettingsPayload { - themeId?: string; - useSystemTheme?: boolean; - themeVariant?: 'light' | 'dark'; - lightThemeId?: string; - darkThemeId?: string; - lastDirectory?: string; - homeDirectory?: string; - opencodeBinary?: string; - projects?: ProjectEntry[]; - activeProjectId?: string; - sidebarProjectDisplayMode?: 'all' | 'single'; - sidebarSessionGroupingMode?: 'by-worktree' | 'flat'; - sidebarProjectSortOrder?: 'manual' | 'a-z' | 'z-a' | 'date-added' | 'recent'; - sidebarShowRecentSection?: boolean; - securityScopedBookmarks?: string[]; - pinnedDirectories?: string[]; - showReasoningTraces?: boolean; - collapsibleThinkingBlocks?: boolean; - showDeletionDialog?: boolean; - nativeNotificationsEnabled?: boolean; - notificationMode?: 'always' | 'hidden-only'; - autoDeleteEnabled?: boolean; - autoSaveEnabled?: boolean; - autoDeleteAfterDays?: number; - sessionRetentionAction?: 'archive' | 'delete'; - followUpBehavior?: 'steer' | 'queue'; - queueModeEnabled?: boolean; - inputHistoryScope?: InputHistoryScope; - inputHistoryLimit?: number; - gitmojiEnabled?: boolean; - inputSpellcheckEnabled?: boolean; - enterToSend?: boolean; - enterToSendConfigured?: boolean; - showOpenCodeUpdateNotifications?: boolean; - openCodeUpdateToastDismissedVersion?: string; - showToolFileIcons?: boolean; - codeBlockLineWrap?: boolean; - showTurnChangedFiles?: boolean; - showExpandedBashTools?: boolean; - showExpandedEditTools?: boolean; - chatRenderMode?: 'sorted' | 'live'; - messageStreamTransport?: 'auto' | 'ws' | 'sse'; - activityRenderMode?: 'collapsed' | 'summary'; - mermaidRenderingMode?: 'svg' | 'ascii'; - showSplitAssistantMessageActions?: boolean; - fontSize?: number; - terminalFontSize?: number; - terminalShell?: TerminalShell; - terminalLoginShells?: TerminalShell[]; - editorFontSize?: number; - uiFont?: string; - monoFont?: string; - padding?: number; - cornerRadius?: number; - inputBarOffset?: number; - shortcutOverrides?: Record; - diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side'; - gitChangesViewMode?: 'flat' | 'tree'; - toolJsonViewMode?: 'summary' | 'formatted' | 'raw'; - directoryShowHidden?: boolean; - filesViewShowGitignored?: boolean; - openInAppId?: string; - gitProviderId?: string; - gitModelId?: string; - pwaAppName?: string; - mobileKeyboardMode?: 'native' | 'resize-content'; - draftStarters?: DraftStarterRef[]; - draftStartersVisible?: boolean; - draftStartersCraftGoalAdded?: boolean; - - [key: string]: unknown; -} +/** + * The settings document on the wire. Defined once in the settings registry; + * this alias keeps the runtime `SettingsAPI` contract readable. + */ +export type SettingsPayload = DesktopSettings; export interface SettingsLoadResult { settings: SettingsPayload; diff --git a/packages/ui/src/lib/appearanceAutoSave.ts b/packages/ui/src/lib/appearanceAutoSave.ts index e2cfb83f..a18d800e 100644 --- a/packages/ui/src/lib/appearanceAutoSave.ts +++ b/packages/ui/src/lib/appearanceAutoSave.ts @@ -1,277 +1,47 @@ import { useUIStore } from '@/stores/useUIStore'; -import { updateDesktopSettings } from '@/lib/persistence'; +import { isApplyingServerSettings, updateDesktopSettings } from '@/lib/persistence'; import type { DesktopSettings } from '@/lib/desktop'; -import type { MonoFontOption, UiFontOption } from '@/lib/fontOptions'; -import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; -import type { TerminalShell } from '@/lib/api/types'; - -type AppearanceSlice = { - showReasoningTraces: boolean; - streamingAutoFollowEnabled: boolean; - workStatusPanelEnabled: boolean; - workStatusHiddenSections: string[]; - sessionRecapEnabled: boolean; - sessionSuggestionEnabled: boolean; - sessionGoalEnabled: boolean; - sessionGoalDefaultBudgetEnabled: boolean; - sessionGoalDefaultBudget: number; - collapsibleThinkingBlocks: boolean; - showDeletionDialog: boolean; - nativeNotificationsEnabled: boolean; - notificationMode: 'always' | 'hidden-only'; - notifyOnSubtasks: boolean; - notifyOnCompletion: boolean; - notifyOnError: boolean; - notifyOnQuestion: boolean; - notificationTemplates: { - completion: { title: string; message: string }; - error: { title: string; message: string }; - question: { title: string; message: string }; - subtask: { title: string; message: string }; - }; - summarizeLastMessage: boolean; - summaryThreshold: number; - summaryLength: number; - maxLastMessageLength: number; - autoDeleteEnabled: boolean; - autoSaveEnabled: boolean; - autoDeleteAfterDays: number; - sessionRetentionAction: 'archive' | 'delete'; - fontSize: number; - terminalFontSize: number; - terminalShell: TerminalShell; - terminalLoginShells: TerminalShell[]; - editorFontSize: number; - uiFont: UiFontOption; - monoFont: MonoFontOption; - padding: number; - cornerRadius: number; - inputBarOffset: number; - mobileKeyboardMode: MobileKeyboardMode; - diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side'; - gitChangesViewMode: 'flat' | 'tree'; - toolJsonViewMode: 'summary' | 'formatted' | 'raw'; -}; +import { AUTO_SAVE_KEYS, readAutoSaveSnapshot } from '@/lib/settings/registry'; let initialized = false; +type SettingsValue = DesktopSettings[keyof DesktopSettings]; + +const isSameValue = (left: SettingsValue, right: SettingsValue): boolean => { + if (left === right) return true; + if (left === undefined || right === undefined) return false; + return JSON.stringify(left) === JSON.stringify(right); +}; + +/** + * Mirrors user changes of the registry's auto-saved fields (`ui.autoSave`) + * from `useUIStore` to the server. Which fields take part is decided in the + * registry, not here; values the settings sync just copied in from the server + * become the new baseline instead of a write. + */ export const startAppearanceAutoSave = (): void => { - if (initialized || typeof window === 'undefined') { + if (initialized || globalThis.window === undefined) { return; } initialized = true; - let previous: AppearanceSlice = { - showReasoningTraces: useUIStore.getState().showReasoningTraces, - streamingAutoFollowEnabled: useUIStore.getState().streamingAutoFollowEnabled, - workStatusPanelEnabled: useUIStore.getState().workStatusPanelEnabled, - workStatusHiddenSections: useUIStore.getState().workStatusHiddenSections, - sessionRecapEnabled: useUIStore.getState().sessionRecapEnabled, - sessionSuggestionEnabled: useUIStore.getState().sessionSuggestionEnabled, - sessionGoalEnabled: useUIStore.getState().sessionGoalEnabled, - sessionGoalDefaultBudgetEnabled: useUIStore.getState().sessionGoalDefaultBudgetEnabled, - sessionGoalDefaultBudget: useUIStore.getState().sessionGoalDefaultBudget, - collapsibleThinkingBlocks: useUIStore.getState().collapsibleThinkingBlocks, - showDeletionDialog: useUIStore.getState().showDeletionDialog, - nativeNotificationsEnabled: useUIStore.getState().nativeNotificationsEnabled, - notificationMode: useUIStore.getState().notificationMode, - notifyOnSubtasks: useUIStore.getState().notifyOnSubtasks, - notifyOnCompletion: useUIStore.getState().notifyOnCompletion, - notifyOnError: useUIStore.getState().notifyOnError, - notifyOnQuestion: useUIStore.getState().notifyOnQuestion, - notificationTemplates: useUIStore.getState().notificationTemplates, - summarizeLastMessage: useUIStore.getState().summarizeLastMessage, - summaryThreshold: useUIStore.getState().summaryThreshold, - summaryLength: useUIStore.getState().summaryLength, - maxLastMessageLength: useUIStore.getState().maxLastMessageLength, - autoDeleteEnabled: useUIStore.getState().autoDeleteEnabled, - autoSaveEnabled: useUIStore.getState().autoSaveEnabled, - autoDeleteAfterDays: useUIStore.getState().autoDeleteAfterDays, - sessionRetentionAction: useUIStore.getState().sessionRetentionAction, - fontSize: useUIStore.getState().fontSize, - terminalFontSize: useUIStore.getState().terminalFontSize, - terminalShell: useUIStore.getState().terminalShell, - terminalLoginShells: useUIStore.getState().terminalLoginShells, - editorFontSize: useUIStore.getState().editorFontSize, - uiFont: useUIStore.getState().uiFont, - monoFont: useUIStore.getState().monoFont, - padding: useUIStore.getState().padding, - cornerRadius: useUIStore.getState().cornerRadius, - inputBarOffset: useUIStore.getState().inputBarOffset, - mobileKeyboardMode: useUIStore.getState().mobileKeyboardMode, - diffLayoutPreference: useUIStore.getState().diffLayoutPreference, - gitChangesViewMode: useUIStore.getState().gitChangesViewMode, - toolJsonViewMode: useUIStore.getState().toolJsonViewMode, - }; + let previous = readAutoSaveSnapshot(); - useUIStore.subscribe((state) => { - const current: AppearanceSlice = { - showReasoningTraces: state.showReasoningTraces, - streamingAutoFollowEnabled: state.streamingAutoFollowEnabled, - workStatusPanelEnabled: state.workStatusPanelEnabled, - workStatusHiddenSections: state.workStatusHiddenSections, - sessionRecapEnabled: state.sessionRecapEnabled, - sessionSuggestionEnabled: state.sessionSuggestionEnabled, - sessionGoalEnabled: state.sessionGoalEnabled, - sessionGoalDefaultBudgetEnabled: state.sessionGoalDefaultBudgetEnabled, - sessionGoalDefaultBudget: state.sessionGoalDefaultBudget, - collapsibleThinkingBlocks: state.collapsibleThinkingBlocks, - showDeletionDialog: state.showDeletionDialog, - nativeNotificationsEnabled: state.nativeNotificationsEnabled, - notificationMode: state.notificationMode, - notifyOnSubtasks: state.notifyOnSubtasks, - notifyOnCompletion: state.notifyOnCompletion, - notifyOnError: state.notifyOnError, - notifyOnQuestion: state.notifyOnQuestion, - notificationTemplates: state.notificationTemplates, - summarizeLastMessage: state.summarizeLastMessage, - summaryThreshold: state.summaryThreshold, - summaryLength: state.summaryLength, - maxLastMessageLength: state.maxLastMessageLength, - autoDeleteEnabled: state.autoDeleteEnabled, - autoSaveEnabled: state.autoSaveEnabled, - autoDeleteAfterDays: state.autoDeleteAfterDays, - sessionRetentionAction: state.sessionRetentionAction, - fontSize: state.fontSize, - terminalFontSize: state.terminalFontSize, - terminalShell: state.terminalShell, - terminalLoginShells: state.terminalLoginShells, - editorFontSize: state.editorFontSize, - uiFont: state.uiFont, - monoFont: state.monoFont, - padding: state.padding, - cornerRadius: state.cornerRadius, - inputBarOffset: state.inputBarOffset, - mobileKeyboardMode: state.mobileKeyboardMode, - diffLayoutPreference: state.diffLayoutPreference, - gitChangesViewMode: state.gitChangesViewMode, - toolJsonViewMode: state.toolJsonViewMode, - }; + useUIStore.subscribe(() => { + const current = readAutoSaveSnapshot(); - const diff: Partial = {}; + if (isApplyingServerSettings()) { + previous = current; + return; + } - if (current.workStatusPanelEnabled !== previous.workStatusPanelEnabled) { - diff.workStatusPanelEnabled = current.workStatusPanelEnabled; - } - // Compared by content: the store hands back a new array on every change, - // so an identity check would push a write on unrelated store updates. - if (current.workStatusHiddenSections.join('\u0000') !== previous.workStatusHiddenSections.join('\u0000')) { - diff.workStatusHiddenSections = current.workStatusHiddenSections; - } - if (current.showReasoningTraces !== previous.showReasoningTraces) { - diff.showReasoningTraces = current.showReasoningTraces; - } - if (current.streamingAutoFollowEnabled !== previous.streamingAutoFollowEnabled) { - diff.streamingAutoFollowEnabled = current.streamingAutoFollowEnabled; - } - if (current.sessionRecapEnabled !== previous.sessionRecapEnabled) { - diff.sessionRecapEnabled = current.sessionRecapEnabled; - } - if (current.sessionSuggestionEnabled !== previous.sessionSuggestionEnabled) { - diff.sessionSuggestionEnabled = current.sessionSuggestionEnabled; - } - if (current.sessionGoalEnabled !== previous.sessionGoalEnabled) { - diff.sessionGoalEnabled = current.sessionGoalEnabled; - } - if (current.sessionGoalDefaultBudgetEnabled !== previous.sessionGoalDefaultBudgetEnabled) { - diff.sessionGoalDefaultBudgetEnabled = current.sessionGoalDefaultBudgetEnabled; - } - if (current.sessionGoalDefaultBudget !== previous.sessionGoalDefaultBudget) { - diff.sessionGoalDefaultBudget = current.sessionGoalDefaultBudget; - } - if (current.collapsibleThinkingBlocks !== previous.collapsibleThinkingBlocks) { - diff.collapsibleThinkingBlocks = current.collapsibleThinkingBlocks; - } - if (current.showDeletionDialog !== previous.showDeletionDialog) { - diff.showDeletionDialog = current.showDeletionDialog; - } - if (current.nativeNotificationsEnabled !== previous.nativeNotificationsEnabled) { - diff.nativeNotificationsEnabled = current.nativeNotificationsEnabled; - } - if (current.notificationMode !== previous.notificationMode) { - diff.notificationMode = current.notificationMode; - } - if (current.notifyOnSubtasks !== previous.notifyOnSubtasks) { - diff.notifyOnSubtasks = current.notifyOnSubtasks; - } - if (current.notifyOnCompletion !== previous.notifyOnCompletion) { - diff.notifyOnCompletion = current.notifyOnCompletion; - } - if (current.notifyOnError !== previous.notifyOnError) { - diff.notifyOnError = current.notifyOnError; - } - if (current.notifyOnQuestion !== previous.notifyOnQuestion) { - diff.notifyOnQuestion = current.notifyOnQuestion; - } - if (JSON.stringify(current.notificationTemplates) !== JSON.stringify(previous.notificationTemplates)) { - diff.notificationTemplates = current.notificationTemplates; - } - if (current.summarizeLastMessage !== previous.summarizeLastMessage) { - diff.summarizeLastMessage = current.summarizeLastMessage; - } - if (current.summaryThreshold !== previous.summaryThreshold) { - diff.summaryThreshold = current.summaryThreshold; - } - if (current.summaryLength !== previous.summaryLength) { - diff.summaryLength = current.summaryLength; - } - if (current.maxLastMessageLength !== previous.maxLastMessageLength) { - diff.maxLastMessageLength = current.maxLastMessageLength; - } - if (current.autoDeleteEnabled !== previous.autoDeleteEnabled) { - diff.autoDeleteEnabled = current.autoDeleteEnabled; - } - if (current.autoSaveEnabled !== previous.autoSaveEnabled) { - diff.autoSaveEnabled = current.autoSaveEnabled; - } - if (current.autoDeleteAfterDays !== previous.autoDeleteAfterDays) { - diff.autoDeleteAfterDays = current.autoDeleteAfterDays; - } - if (current.sessionRetentionAction !== previous.sessionRetentionAction) { - diff.sessionRetentionAction = current.sessionRetentionAction; - } - if (current.fontSize !== previous.fontSize) { - diff.fontSize = current.fontSize; - } - if (current.terminalFontSize !== previous.terminalFontSize) { - diff.terminalFontSize = current.terminalFontSize; - } - if (current.terminalShell !== previous.terminalShell) { - diff.terminalShell = current.terminalShell; - } - if (current.terminalLoginShells !== previous.terminalLoginShells) { - diff.terminalLoginShells = current.terminalLoginShells; - } - if (current.editorFontSize !== previous.editorFontSize) { - diff.editorFontSize = current.editorFontSize; - } - if (current.uiFont !== previous.uiFont) { - diff.uiFont = current.uiFont; - } - if (current.monoFont !== previous.monoFont) { - diff.monoFont = current.monoFont; - } - if (current.padding !== previous.padding) { - diff.padding = current.padding; - } - if (current.cornerRadius !== previous.cornerRadius) { - diff.cornerRadius = current.cornerRadius; - } - if (current.inputBarOffset !== previous.inputBarOffset) { - diff.inputBarOffset = current.inputBarOffset; - } - if (current.mobileKeyboardMode !== previous.mobileKeyboardMode) { - diff.mobileKeyboardMode = current.mobileKeyboardMode; - } - if (current.diffLayoutPreference !== previous.diffLayoutPreference) { - diff.diffLayoutPreference = current.diffLayoutPreference; - } - if (current.gitChangesViewMode !== previous.gitChangesViewMode) { - diff.gitChangesViewMode = current.gitChangesViewMode; - } - if (current.toolJsonViewMode !== previous.toolJsonViewMode) { - diff.toolJsonViewMode = current.toolJsonViewMode; + const diff: DesktopSettings = {}; + for (const key of AUTO_SAVE_KEYS) { + // Reference equality first: unchanged store slices keep their identity, + // so the structural compare only runs for the fields that moved. + if (isSameValue(current[key], previous[key])) continue; + Object.assign(diff, { [key]: current[key] }); } previous = current; @@ -280,5 +50,4 @@ export const startAppearanceAutoSave = (): void => { void updateDesktopSettings(diff); } }); - }; diff --git a/packages/ui/src/lib/btw.test.ts b/packages/ui/src/lib/btw.test.ts index 4442ac33..57ad4223 100644 --- a/packages/ui/src/lib/btw.test.ts +++ b/packages/ui/src/lib/btw.test.ts @@ -18,13 +18,16 @@ const childStoreSessions: Session[] = []; const currentSessionSwitches: string[] = []; const metadataPatches: Array<{ sessionId: string; result: Record }> = []; const parentSyncMessages: Message[] = []; +const sessionMessageReads: string[] = []; mock.module('@/lib/opencode/client', () => ({ opencodeClient: { forkSession: (sessionId: string, messageId?: string, directory?: string | null) => forkSessionImpl(sessionId, messageId, directory), - getSessionMessages: (id: string, limit?: number, directory?: string | null) => - getSessionMessagesImpl(id, limit, directory), + getSessionMessages: (id: string, limit?: number, directory?: string | null) => { + sessionMessageReads.push(id); + return getSessionMessagesImpl(id, limit, directory); + }, }, })); mock.module('@/sync/session-actions', () => ({ @@ -59,9 +62,10 @@ mock.module('@/sync/sync-refs', () => ({ }), })); -const { btwSessionTitle, startBtwSession, destroyBtwSession, promoteBtwSession, filterBtwTailMessages, findLastCompletedAssistantMessageID, BTW_BOUNDARY_INSTRUCTION, BTW_PROMOTION_NOTICE, buildBtwSyntheticTexts } = +const { preparePendingBtwSend, btwSessionTitle, startBtwSession, destroyBtwSession, promoteBtwSession, filterBtwTailMessages, findLastCompletedAssistantMessageID, BTW_BOUNDARY_INSTRUCTION, BTW_PROMOTION_NOTICE, buildBtwSyntheticTexts } = await import('@/lib/btw'); const { useBtwStore } = await import('@/stores/useBtwStore'); +const { useSelectionStore } = await import('@/sync/selection-store'); const makeSession = (id: string, directory?: string): Session => ({ id, @@ -72,8 +76,8 @@ const makeSession = (id: string, directory?: string): Session => ({ version: 1, }) as unknown as Session; -const record = (id: string): { info: Message; parts: Part[] } => ({ - info: { id, role: 'user', time: { created: 1 } } as unknown as Message, +const record = (id: string, created = 1): { info: Message; parts: Part[] } => ({ + info: { id, sessionID: 'fork-1', role: 'user', time: { created }, agent: 'plan', model: { providerID: 'provider', modelID: 'model' } }, parts: [], }); @@ -103,6 +107,7 @@ beforeEach(() => { currentSessionSwitches.length = 0; metadataPatches.length = 0; parentSyncMessages.length = 0; + sessionMessageReads.length = 0; useBtwStore.setState({ byParent: {} }); forkSessionImpl = () => Promise.reject(new Error('no forkSession stub')); getSessionMessagesImpl = () => Promise.resolve([record('msg-boundary')]); @@ -123,6 +128,17 @@ describe('btwSessionTitle', () => { }); describe('filterBtwTailMessages', () => { + test('keeps a newer user message whose ID sorts before the inherited boundary', () => { + const records = [record('msg_f001', 1), record('msg_0001', 2), record('msg_f002', 3)]; + expect(filterBtwTailMessages(records, 'msg_f001').map((entry) => entry.info.id)) + .toEqual(['msg_0001', 'msg_f002']); + }); + + test('keeps a loaded tail when its inherited boundary is outside the retained page', () => { + const records = [record('msg_0001', 2), record('msg_f002', 3)]; + expect(filterBtwTailMessages(records, 'msg_f001')).toEqual(records); + }); + test('keeps only messages after the boundary id', () => { const records = [record('msg-1'), record('msg-2'), record('msg-3')]; expect(filterBtwTailMessages(records, 'msg-2').map((r) => r.info.id)).toEqual(['msg-3']); @@ -155,14 +171,16 @@ describe('startBtwSession', () => { let sentText: unknown = null; let sentOptions: unknown = null; sendMessageImpl = (...args) => { - sentText = args[0]; - sentOptions = args[9]; - return Promise.resolve(); + sentText = args[0]; + sentOptions = args[9]; + expect(args[7]).toBe(undefined); + return Promise.resolve(); }; - const session = await startBtwSession(startInput); + const session = await startBtwSession({ ...startInput, variant: null }); expect(session.id).toBe('fork-1'); + expect(useSelectionStore.getState().getAgentModelVariantForSession('fork-1', 'build', 'provider', 'model')).toBeNull(); expect(registeredDirectories).toEqual(['fork-1:/project']); expect(childStoreSessions.map((s) => s.id)).toEqual(['fork-1']); expect(sentText).toBe('wtf is kafka'); @@ -172,7 +190,7 @@ describe('startBtwSession', () => { { sessionId: 'parent-1', result: { openchamber: { btwSessionID: 'fork-1' } } }, ]); // Transient creating flag is cleared once the flow settles. - expect(useBtwStore.getState().byParent).toEqual({}); + expect(useBtwStore.getState().byParent).toEqual({ 'parent-1': { creating: false } }); }); test('forks at the last completed assistant turn, not at the in-flight one', async () => { @@ -265,7 +283,7 @@ describe('startBtwSession', () => { // marker, link, then unlink rollback expect(metadataPatches.map((p) => p.sessionId)).toEqual(['fork-1', 'parent-1', 'parent-1']); expect(metadataPatches[2]?.result).toEqual({}); - expect(useBtwStore.getState().byParent).toEqual({}); + expect(useBtwStore.getState().byParent).toEqual({ 'parent-1': { creating: false } }); }); test('a failed boundary fetch deletes the fork', async () => { @@ -278,6 +296,22 @@ describe('startBtwSession', () => { expect(deleted).toEqual(['fork-1']); expect(metadataPatches).toEqual([]); }); + + test('rejects a second creation for the same parent before it forks', async () => { + let releaseFork: ((session: Session) => void) | undefined; + const forkStarted = new Promise((resolve) => { + forkSessionImpl = () => { + resolve(); + return new Promise((release) => { releaseFork = release; }); + }; + }); + + const first = startBtwSession(startInput); + await forkStarted; + await expect(startBtwSession(startInput)).rejects.toThrow('btw session creation already in progress'); + releaseFork?.(makeSession('fork-1', '/project')); + await first; + }); }); describe('destroyBtwSession', () => { @@ -310,7 +344,12 @@ describe('destroyBtwSession', () => { describe('promoteBtwSession', () => { const ref = { parentSessionId: 'parent-1', btwSessionId: 'fork-1', directory: '/project' }; - test('unlinks the parent, strips the marker, and navigates to the fork', async () => { + test('unlinks the parent, strips the marker, and navigates to the fork without generating a title', async () => { + const renamedTitles: string[] = []; + updateSessionTitleImpl = (_sessionId, title) => { + renamedTitles.push(title); + return Promise.resolve(); + }; patchSessionMetadataImpl = (sessionId, _directory, updater) => { const base = sessionId === 'fork-1' ? { openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-1' } } @@ -323,19 +362,43 @@ describe('promoteBtwSession', () => { await promoteBtwSession(ref); expect(metadataPatches).toEqual([ - { sessionId: 'parent-1', result: {} }, // The fork stops being a btw session but stays marked as promoted: its // transcript still carries the boundary instructions. { sessionId: 'fork-1', result: { openchamber: { btwPromoted: true } } }, + { sessionId: 'parent-1', result: {} }, ]); expect(currentSessionSwitches).toEqual(['fork-1']); + expect(sessionMessageReads).toEqual([]); + expect(renamedTitles).toEqual([]); }); test('a failed unlink aborts the promote without navigating', async () => { - patchSessionMetadataImpl = () => Promise.reject(new Error('patch failed')); - await expect(promoteBtwSession(ref)).rejects.toThrow('patch failed'); + const originalMetadata = { openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-1' } }; + patchSessionMetadataImpl = (sessionId, _directory, updater) => { + if (sessionId === 'parent-1') return Promise.reject(new Error('unlink failed')); + const result = updater(originalMetadata); + metadataPatches.push({ sessionId, result }); + return Promise.resolve(makeSession(sessionId)); + }; + + await expect(promoteBtwSession(ref)).rejects.toThrow('unlink failed'); + expect(currentSessionSwitches).toEqual([]); + expect(metadataPatches).toEqual([ + { sessionId: 'fork-1', result: { openchamber: { btwPromoted: true } } }, + { sessionId: 'fork-1', result: originalMetadata }, + ]); + }); + + test('a failed marker removal preserves the parent link', async () => { + patchSessionMetadataImpl = (sessionId) => { + if (sessionId === 'fork-1') return Promise.reject(new Error('marker failed')); + throw new Error('the parent must remain linked'); + }; + + await expect(promoteBtwSession(ref)).rejects.toThrow('marker failed'); expect(currentSessionSwitches).toEqual([]); }); + }); describe('buildBtwSyntheticTexts', () => { @@ -358,3 +421,55 @@ describe('buildBtwSyntheticTexts', () => { expect(buildBtwSyntheticTexts({ isBtwActive: false, isPromotedBtwSession: false })).toEqual([]); }); }); + + +describe('pending BTW preparation', () => { + test('cancelling and reopening during snippet expansion cannot revive the old send', async () => { + const { getRuntimeKey } = await import('@/lib/runtime-switch'); + const panels = useBtwStore.getState(); + panels.setPanelState('parent-1', { pending: true }); + let finish = () => {}; + const expansion = new Promise((resolve) => { finish = resolve; }); + const preparing = preparePendingBtwSend('parent-1', getRuntimeKey(), () => expansion); + panels.clearPanelState('parent-1'); + panels.setPanelState('parent-1', { pending: true }); + finish(); + expect(await preparing).toBeNull(); + expect(useBtwStore.getState().byParent['parent-1']).toEqual({ pending: true }); + }); + + test('preparation belongs to its parent and rejects duplicate sends', async () => { + const { getRuntimeKey } = await import('@/lib/runtime-switch'); + const panels = useBtwStore.getState(); + panels.setPanelState('parent-1', { pending: true }); + panels.setPanelState('parent-2', { pending: true }); + let finish = () => {}; + const expansion = new Promise((resolve) => { finish = resolve; }); + const preparing = preparePendingBtwSend('parent-1', getRuntimeKey(), () => expansion); + expect(await preparePendingBtwSend('parent-1', getRuntimeKey(), async () => {})).toBeNull(); + panels.clearPanelState('parent-2'); + finish(); + expect(await preparing).toBe(useBtwStore.getState().byParent['parent-1']?.pendingSend); + }); + + test('a stale composer cannot fork on the newly selected runtime', async () => { + await expect(startBtwSession({ ...startInput, expectedRuntimeKey: 'obsolete-runtime' })) + .rejects.toThrow('runtime changed'); + expect(useBtwStore.getState().byParent).toEqual({}); + expect(registeredDirectories).toEqual([]); + }); +}); + + +test('switching runtime during snippet expansion invalidates preparation', async () => { + const { getRuntimeKey, initializeRuntimeEndpoint } = await import('@/lib/runtime-switch'); + const panels = useBtwStore.getState(); + panels.setPanelState('parent-1', { pending: true }); + let finish = () => {}; + const expansion = new Promise((resolve) => { finish = resolve; }); + const preparing = preparePendingBtwSend('parent-1', getRuntimeKey(), () => expansion); + initializeRuntimeEndpoint({ apiBaseUrl: 'https://btw-test.invalid', runtimeKey: 'changed-during-preparation' }); + finish(); + expect(await preparing).toBeNull(); + expect(useBtwStore.getState().byParent).toEqual({}); +}); diff --git a/packages/ui/src/lib/btw.ts b/packages/ui/src/lib/btw.ts index f56fc59d..803155a7 100644 --- a/packages/ui/src/lib/btw.ts +++ b/packages/ui/src/lib/btw.ts @@ -4,10 +4,12 @@ import * as sessionActions from '@/sync/session-actions'; import { withBtwSessionLink, withBtwSessionMarker, withoutBtwSessionLink, withoutBtwSessionMarker } from '@/lib/sessionBtwMetadata'; import { useBtwStore } from '@/stores/useBtwStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; import { getSyncChildStores, getSyncMessages, registerSessionDirectory } from '@/sync/sync-refs'; import { Binary } from '@/sync/binary'; import type { ContextPartMetadata } from '@/lib/messages/contextParts'; import type { AttachedFile } from '@/stores/types/sessionTypes'; +import { getRuntimeKey } from '@/lib/runtime-switch'; /** * `/btw `: fork the main session into a temporary session and send @@ -24,12 +26,14 @@ import type { AttachedFile } from '@/stores/types/sessionTypes'; */ export type StartBtwInput = { parentSessionId: string; + expectedRuntimeKey?: string; question: string; directory: string; providerID: string; modelID: string; agent?: string; - variant?: string; + variant?: string | null; + permissionAutoAccept?: boolean; attachments?: AttachedFile[]; additionalParts?: Array<{ text: string; @@ -144,11 +148,45 @@ function insertForkIntoDirectoryStore(session: Session, directory: string): void } } +/** Preparation can be discarded until the server-side fork starts. */ +export async function preparePendingBtwSend( + parentSessionId: string, + expectedRuntimeKey: string, + prepare: () => Promise, +): Promise { + if (getRuntimeKey() !== expectedRuntimeKey) return null; + const panels = useBtwStore.getState(); + const owner = panels.byParent[parentSessionId]; + if (!owner?.pending || owner.creating || owner.pendingSend) return null; + const token = Symbol('btw-send'); + panels.setPanelState(parentSessionId, { pendingSend: token }); + try { + await prepare(); + } catch (error) { + if (useBtwStore.getState().byParent[parentSessionId]?.pendingSend === token) { + panels.setPanelState(parentSessionId, { pendingSend: undefined }); + } + throw error; + } + if (useBtwStore.getState().byParent[parentSessionId]?.pendingSend !== token) return null; + if (getRuntimeKey() !== expectedRuntimeKey) { + panels.clearPanelState(parentSessionId); + return null; + } + return token; +} + export async function startBtwSession(input: StartBtwInput): Promise { - const { setPanelState, clearPanelState } = useBtwStore.getState(); + const { setPanelState } = useBtwStore.getState(); + if (useBtwStore.getState().byParent[input.parentSessionId]?.creating) { + throw new Error('btw session creation already in progress'); + } + const expectedRuntimeKey = input.expectedRuntimeKey ?? getRuntimeKey(); + if (getRuntimeKey() !== expectedRuntimeKey) throw new Error('runtime changed'); setPanelState(input.parentSessionId, { creating: true }); try { await sessionActions.waitForConnectionOrThrow(); + if (getRuntimeKey() !== expectedRuntimeKey) throw new Error('runtime changed'); // Fork at the parent's last completed assistant turn rather than at HEAD, // so a `/btw` typed mid-turn does not inherit a half-finished one. const forkPointMessageID = findLastCompletedAssistantMessageID( @@ -165,18 +203,28 @@ export async function startBtwSession(input: StartBtwInput): Promise { // SAFETY: the SDK Session type omits the server's `directory` field; this // widening only reads it, with the requested directory as the fallback. const sessionDirectory = (forked as Session & { directory?: string | null }).directory ?? input.directory; - registerSessionDirectory(forked.id, sessionDirectory); - try { - // The boundary between inherited history and the fork's own tail is the - // id of the newest cloned message. Message ids are server-generated and - // ascending, so everything the fork produces sorts after it. + if (getRuntimeKey() !== expectedRuntimeKey) throw new Error('runtime changed'); + registerSessionDirectory(forked.id, sessionDirectory); + const selections = useSelectionStore.getState(); + selections.saveSessionModelSelection(forked.id, input.providerID, input.modelID); + if (input.agent) { + selections.saveSessionAgentSelection(forked.id, input.agent); + selections.saveAgentModelForSession(forked.id, input.agent, input.providerID, input.modelID); + selections.saveAgentModelVariantForSession(forked.id, input.agent, input.providerID, input.modelID, input.variant); + } + if (input.permissionAutoAccept !== undefined) { + const { usePermissionStore } = await import('@/stores/permissionStore'); + if (getRuntimeKey() !== expectedRuntimeKey) throw new Error('runtime changed'); + await usePermissionStore.getState().setSessionAutoAccept(forked.id, input.permissionAutoAccept); + if (getRuntimeKey() !== expectedRuntimeKey) throw new Error('runtime changed'); + } + // Locate the inherited-history boundary by identity, not by ID ordering. const newestCloned = await opencodeClient.getSessionMessages(forked.id, 1, sessionDirectory); // A `null` boundary makes the panel show every inherited message, so an // empty read must not be taken as "the fork inherited nothing" when we // know it did: having picked a fork point proves the parent had turns. - // Fall back to that id — the fork's own messages are created later and - // still sort after it, so the tail stays complete either way. + // Retain the known fork point as a fallback marker. const boundaryMessageID = newestCloned[newestCloned.length - 1]?.info.id ?? forkPointMessageID ?? null; @@ -188,16 +236,19 @@ export async function startBtwSession(input: StartBtwInput): Promise { // forks are hidden from session lists by this marker, so inserting an // unmarked fork first would flash it in the sidebar. const marked = await sessionActions.patchSessionMetadata(forked.id, sessionDirectory, (metadata) => - withBtwSessionMarker(metadata, input.parentSessionId, boundaryMessageID)); + withBtwSessionMarker(metadata, input.parentSessionId, boundaryMessageID), expectedRuntimeKey); // patchSessionMetadata already upserted the marked fork into the global // store; the directory child store still needs the explicit insert. insertForkIntoDirectoryStore(marked, sessionDirectory); - void sessionActions.updateSessionTitle(forked.id, btwSessionTitle(input.question)).catch(() => undefined); + void sessionActions.updateSessionTitle(forked.id, btwSessionTitle(input.question), { + directory: sessionDirectory, + expectedRuntimeKey, + }).catch(() => undefined); // Link the parent before sending so the panel opens as soon as the // metadata lands; the question streams into it. await sessionActions.patchSessionMetadata(input.parentSessionId, input.directory, (metadata) => - withBtwSessionLink(metadata, forked.id)); + withBtwSessionLink(metadata, forked.id), expectedRuntimeKey); try { await useSessionUIStore.getState().sendMessage( @@ -211,7 +262,7 @@ export async function startBtwSession(input: StartBtwInput): Promise { // its most dangerous here, with the parent's in-flight plan as the // newest thing in its context. [...btwBoundaryParts(), ...(input.additionalParts ?? [])], - input.variant, + input.variant ?? undefined, 'normal', { sessionId: forked.id, directory: sessionDirectory }, ); @@ -219,29 +270,31 @@ export async function startBtwSession(input: StartBtwInput): Promise { // A fork without its first question is not a usable btw session: // unlink the parent again before deleting the fork. await sessionActions.patchSessionMetadata(input.parentSessionId, input.directory, (metadata) => - withoutBtwSessionLink(metadata, forked.id)).catch(() => undefined); + withoutBtwSessionLink(metadata, forked.id), expectedRuntimeKey).catch(() => undefined); throw error; } } catch (error) { - await sessionActions.deleteSession(forked.id).catch(() => undefined); + await sessionActions.deleteSession(forked.id, { expectedRuntimeKey }).catch(() => undefined); throw error; } return forked; } finally { - clearPanelState(input.parentSessionId); + if (getRuntimeKey() === expectedRuntimeKey) setPanelState(input.parentSessionId, { creating: false }); } } /** - * Keep only the fork's own tail: messages after the last message cloned from - * the parent. A `null` boundary means the fork inherited nothing. + * Records are a chronologically ordered suffix of the session. Keep everything + * after the inherited-history marker; an absent marker is outside that suffix. + * Message IDs are identities, not timestamps (including client-generated IDs). */ export function filterBtwTailMessages( records: Array<{ info: Message; parts: Part[] }>, boundaryMessageID: string | null, ): Array<{ info: Message; parts: Part[] }> { if (!boundaryMessageID) return records; - return records.filter((record) => record.info.id > boundaryMessageID); + const boundaryIndex = records.findIndex((record) => record.info.id === boundaryMessageID); + return boundaryIndex < 0 ? records : records.slice(boundaryIndex + 1); } export type BtwSessionRef = { @@ -276,10 +329,23 @@ export async function destroyBtwSession(ref: BtwSessionRef): Promise { * session. */ export async function promoteBtwSession(ref: BtwSessionRef): Promise { - await sessionActions.patchSessionMetadata(ref.parentSessionId, ref.directory, (metadata) => - withoutBtwSessionLink(metadata, ref.btwSessionId)); - await sessionActions.patchSessionMetadata(ref.btwSessionId, ref.directory, withoutBtwSessionMarker) - .catch(() => undefined); + const expectedRuntimeKey = getRuntimeKey(); + let originalForkMetadata: Parameters[0] | null = null; + await sessionActions.patchSessionMetadata(ref.btwSessionId, ref.directory, (metadata) => { + originalForkMetadata = metadata; + return withoutBtwSessionMarker(metadata); + }, expectedRuntimeKey); + try { + await sessionActions.patchSessionMetadata(ref.parentSessionId, ref.directory, (metadata) => + withoutBtwSessionLink(metadata, ref.btwSessionId), expectedRuntimeKey); + } catch (error) { + const metadataToRestore = originalForkMetadata; + if (metadataToRestore) { + await sessionActions.patchSessionMetadata(ref.btwSessionId, ref.directory, () => metadataToRestore, expectedRuntimeKey) + .catch(() => undefined); + } + throw error; + } useBtwStore.getState().clearPanelState(ref.parentSessionId); useSessionUIStore.getState().setCurrentSession(ref.btwSessionId); } diff --git a/packages/ui/src/lib/chunkLoadRecovery.test.ts b/packages/ui/src/lib/chunkLoadRecovery.test.ts index 5dd0eea7..7d79e9dc 100644 --- a/packages/ui/src/lib/chunkLoadRecovery.test.ts +++ b/packages/ui/src/lib/chunkLoadRecovery.test.ts @@ -3,6 +3,41 @@ import { describe, expect, test } from 'bun:test'; import { importWithChunkRecovery } from './chunkLoadRecovery'; describe('importWithChunkRecovery', () => { + test('preserves the import error without navigating the VS Code webview', async () => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + let reloadCount = 0; + let markerWrites = 0; + Object.defineProperty(globalThis, 'window', { + configurable: true, + writable: true, + value: { + __VSCODE_CONFIG__: { workspaceFolder: 'C:/repo', workspaceFolders: [] }, + sessionStorage: { + getItem: () => null, + setItem: () => { markerWrites += 1; }, + }, + setTimeout: (callback: () => void) => { callback(); return 0; }, + location: { reload: () => { reloadCount += 1; } }, + }, + }); + + const failure = new Error('Failed to fetch dynamically imported module'); + let attempts = 0; + try { + const caught = await importWithChunkRecovery(async () => { + attempts += 1; + throw failure; + }).catch((error: Error) => error); + expect(caught).toBe(failure); + expect(attempts).toBe(2); + expect(reloadCount).toBe(0); + expect(markerWrites).toBe(0); + } finally { + if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow); + else Reflect.deleteProperty(globalThis, 'window'); + } + }); + test('schedules recovery reload when stored reload marker is corrupt', async () => { const globalWithWindow = globalThis as unknown as { window?: unknown }; const previousWindow = globalWithWindow.window; diff --git a/packages/ui/src/lib/chunkLoadRecovery.ts b/packages/ui/src/lib/chunkLoadRecovery.ts index 6dd0a480..b948218d 100644 --- a/packages/ui/src/lib/chunkLoadRecovery.ts +++ b/packages/ui/src/lib/chunkLoadRecovery.ts @@ -1,4 +1,5 @@ import { lazy } from 'react'; +import { isVSCodeRuntime } from './desktop'; declare const __APP_VERSION__: string | undefined; @@ -42,6 +43,9 @@ function reloadMarkerSignature(error: unknown): string { function scheduleReloadOnce(error: unknown): void { if (typeof window === 'undefined') return; + // VS Code owns webview navigation. Keep the import failure available to the + // error boundary instead of replacing the app with an unsupported reload. + if (isVSCodeRuntime()) return; const now = Date.now(); const signature = reloadMarkerSignature(error); diff --git a/packages/ui/src/lib/codemirror/languageByExtension.ts b/packages/ui/src/lib/codemirror/languageByExtension.ts index d46d6403..56ccea98 100644 --- a/packages/ui/src/lib/codemirror/languageByExtension.ts +++ b/packages/ui/src/lib/codemirror/languageByExtension.ts @@ -79,7 +79,7 @@ const markdownHighlight = () => syntaxHighlighting(HighlightStyle.define([ { tag: t.emphasis, fontStyle: 'italic' }, { tag: t.strikethrough, textDecoration: 'line-through' }, { tag: [t.link, t.url], color: 'var(--markdown-link, currentColor)', textDecoration: 'underline' }, - { tag: t.monospace, color: 'var(--markdown-inline-code, currentColor)', backgroundColor: 'var(--markdown-inline-code-bg, transparent)' }, + { tag: t.monospace, backgroundColor: 'var(--surface-muted)' }, { tag: t.quote, color: 'var(--markdown-blockquote, currentColor)', fontStyle: 'italic' }, { tag: t.list, color: 'color-mix(in srgb, var(--muted-foreground) 40%, var(--foreground) 60%)' }, { tag: t.heading, color: 'var(--markdown-heading1, currentColor)' }, diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 56b8e095..ff055591 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -1,19 +1,10 @@ import { z } from 'zod'; -import type { ProjectEntry, RuntimeAPIs, TerminalShell } from '@/lib/api/types'; +import type { RuntimeAPIs } from '@/lib/api/types'; import { getInjectedBootOutcome } from '@/lib/desktopBoot'; -import type { DraftStarterRef } from '@/lib/draftStarters'; -import type { InputHistoryScope } from '@/lib/inputHistoryScope'; -import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { isVSCodeBootstrapPresent } from '@/lib/vscodeBootstrap'; -type ManagedRemoteTunnelPreset = { - id: string; - name: string; - hostname: string; -}; - export type UpdateInfo = { available: boolean; version?: string; @@ -33,13 +24,7 @@ export type UpdateProgress = { total?: number; }; -export type SkillCatalogConfig = { - id: string; - label: string; - source: string; - subpath?: string; - gitIdentityId?: string; -}; +export type { SkillCatalogConfig } from '@/lib/settings/parsers'; export type DesktopWindowControlsPosition = 'left' | 'right'; export type DesktopWindowControlsSide = 'left' | 'right'; @@ -47,205 +32,9 @@ export type DesktopWindowControlAction = 'close' | 'minimize' | 'maximize'; // No fixed-width constant: control width depends on the style (classic vs traffic-lights). export type DesktopWindowControlsStyle = 'classic' | 'traffic-lights'; -export type DesktopSettings = { - themeId?: string; - useSystemTheme?: boolean; - themeVariant?: 'light' | 'dark'; - lightThemeId?: string; - darkThemeId?: string; - splashBgLight?: string; - splashFgLight?: string; - splashBgDark?: string; - splashFgDark?: string; - lastDirectory?: string; - homeDirectory?: string; - // Optional absolute path to `opencode` binary. - opencodeBinary?: string; - desktopLanAccessEnabled?: boolean; - desktopKeepAwakeEnabled?: boolean; - desktopMinimizeToTrayEnabled?: boolean; - desktopMacMenuBarEnabled?: boolean; - desktopUiPassword?: string; - projects?: ProjectEntry[]; - activeProjectId?: string; - sidebarProjectDisplayMode?: 'all' | 'single'; - sidebarSessionGroupingMode?: 'by-worktree' | 'flat'; - sidebarProjectSortOrder?: 'manual' | 'a-z' | 'z-a' | 'date-added' | 'recent'; - sidebarShowRecentSection?: boolean; - securityScopedBookmarks?: string[]; - pinnedDirectories?: string[]; - showReasoningTraces?: boolean; - /** Whether the in-chat work-status panel may render. */ - workStatusPanelEnabled?: boolean; - /** Work-status panel sections the user switched off. */ - workStatusHiddenSections?: string[]; - collapsibleThinkingBlocks?: boolean; - showDeletionDialog?: boolean; - nativeNotificationsEnabled?: boolean; - notificationMode?: 'always' | 'hidden-only'; - notifyOnSubtasks?: boolean; - - // Event toggles (which events trigger notifications) - notifyOnCompletion?: boolean; - notifyOnError?: boolean; - notifyOnQuestion?: boolean; - - // Per-event notification templates - notificationTemplates?: { - completion: { title: string; message: string }; - error: { title: string; message: string }; - question: { title: string; message: string }; - subtask: { title: string; message: string }; - }; - - // Summarization settings - summarizeLastMessage?: boolean; - summaryThreshold?: number; - summaryLength?: number; - maxLastMessageLength?: number; - - usageDisplayMode?: 'usage' | 'remaining'; - usageDropdownProviders?: string[]; - usageSelectedModels?: Record; // Map of providerId -> selected model names - usageCollapsedFamilies?: Record; // Map of providerId -> collapsed family IDs (UsagePage) - usageExpandedFamilies?: Record; // Map of providerId -> EXPANDED family IDs (header dropdown - inverted) - usageModelGroups?: Record; - modelAssignments?: Record; // modelName -> groupId - renamedGroups?: Record; // groupId -> custom label - }>; // Per-provider custom model groups configuration - autoDeleteEnabled?: boolean; - autoSaveEnabled?: boolean; - autoDeleteAfterDays?: number; - sessionRetentionAction?: 'archive' | 'delete'; - tunnelProvider?: string; - tunnelMode?: 'quick' | 'managed-remote' | 'managed-local'; - tunnelBootstrapTtlMs?: number | null; - tunnelSessionTtlMs?: number; - managedLocalTunnelConfigPath?: string | null; - managedRemoteTunnelHostname?: string; - managedRemoteTunnelToken?: string | null; - hasManagedRemoteTunnelToken?: boolean; - managedRemoteTunnelPresets?: ManagedRemoteTunnelPreset[]; - managedRemoteTunnelSelectedPresetId?: string; - managedRemoteTunnelPresetTokens?: Record; - defaultModel?: string; // format: "provider/model" - defaultVariant?: string; - defaultAgent?: string; - smallModelUseDefault?: boolean; - streamingAutoFollowEnabled?: boolean; - sessionRecapEnabled?: boolean; - sessionSuggestionEnabled?: boolean; - sessionGoalEnabled?: boolean; - sessionGoalDefaultBudgetEnabled?: boolean; - sessionGoalDefaultBudget?: number; - smallModelOverride?: string; // format: "provider/model" - // The walkthrough needs structured output and a roomy context, which the - // small model is often deliberately not chosen for. Unset means "use the - // small model"; a value replaces it for this feature only. - walkthroughModelOverride?: string; // format: "provider/model" - defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id - openInAppId?: string; - autoCreateWorktree?: boolean; - followUpBehavior?: 'steer' | 'queue'; - queueModeEnabled?: boolean; - gitmojiEnabled?: boolean; - defaultFileViewerPreview?: boolean; - zenModel?: string; - gitProviderId?: string; - gitModelId?: string; - pwaAppName?: string; - pwaOrientation?: 'system' | 'portrait' | 'landscape'; - mobileKeyboardMode?: MobileKeyboardMode; - desktopWindowControlsPosition?: DesktopWindowControlsPosition; - desktopWindowControlsStyle?: DesktopWindowControlsStyle; - inputSpellcheckEnabled?: boolean; - enterToSend?: boolean; - enterToSendConfigured?: boolean; - showOpenCodeUpdateNotifications?: boolean; - agentControlToolEnabled?: boolean; - agentWebToolEnabled?: boolean; - agentMemoryToolEnabled?: boolean; - agentMemoryFeatureAvailable?: boolean; - optimizeSystemPrompt?: boolean; - openCodeUpdateToastDismissedVersion?: string; - showToolFileIcons?: boolean; - codeBlockLineWrap?: boolean; - showTurnChangedFiles?: boolean; - showExpandedBashTools?: boolean; - showExpandedEditTools?: boolean; - timeFormatPreference?: 'auto' | '12h' | '24h'; - weekStartPreference?: 'auto' | 'sunday' | 'monday'; - chatRenderMode?: 'sorted' | 'live'; - messageStreamTransport?: 'auto' | 'ws' | 'sse'; - inputHistoryScope?: InputHistoryScope; - inputHistoryLimit?: number; - activityRenderMode?: 'collapsed' | 'summary'; - mermaidRenderingMode?: 'svg' | 'ascii'; - userMessageRenderingMode?: 'markdown' | 'plain'; - collapsibleUserMessages?: boolean; - stickyUserHeader?: boolean; - promptNavigatorEnabled?: boolean; - wideChatLayoutEnabled?: boolean; - showSplitAssistantMessageActions?: boolean; - fontSize?: number; - terminalFontSize?: number; - terminalShell?: TerminalShell; - terminalLoginShells?: TerminalShell[]; - editorFontSize?: number; - uiFont?: string; - monoFont?: string; - padding?: number; - cornerRadius?: number; - inputBarOffset?: number; - shortcutOverrides?: Record; - - favoriteModels?: Array<{ providerID: string; modelID: string }>; - hiddenModels?: Array<{ providerID: string; modelID: string }>; - collapsedModelProviders?: string[]; - recentModels?: Array<{ providerID: string; modelID: string }>; - recentAgents?: string[]; - recentEfforts?: Record; - diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side'; - gitChangesViewMode?: 'flat' | 'tree'; - toolJsonViewMode?: 'summary' | 'formatted' | 'raw'; - directoryShowHidden?: boolean; - filesViewShowGitignored?: boolean; - - // Message limit — controls fetch, trim, and Load More chunk size (default: 200) - messageLimit?: number; - - // User-added skills catalogs (persisted to ~/.config/openchamber/settings.json) - skillCatalogs?: SkillCatalogConfig[]; - // Opt-in to send anonymous usage reports for update checks (default: true) - reportUsage?: boolean; - - // Global behavior prompt — synced to ~/.config/opencode/AGENTS.md - globalBehaviorPrompt?: string; - responseStyleEnabled?: boolean; - responseStylePreset?: 'concise' | 'detailed' | 'mentor' | 'pushback' | 'noFiller' | 'matchEnergy' | 'warmPeer' | 'custom'; - responseStyleCustomInstructions?: string; - dictationEnabled?: boolean; - sttProvider?: 'local' | 'openai-compatible'; - sttServerUrl?: string; - sttModel?: string; - sttLocalModel?: string; - sttLanguage?: string; - // Per-provider git forge configuration (server-side settings.json): the API - // base URL for provider API calls and the bare hosts that auto-detect the - // provider. Server-authoritative; the client stores only a localStorage cache. - gitProviders?: { - github?: { apiBaseUrl?: string; detectUrls?: string[] }; - gitlab?: { apiBaseUrl?: string; detectUrls?: string[] }; - gitea?: { apiBaseUrl?: string; detectUrls?: string[] }; - }; - // Global draft welcome starters (pinned commands/skills), persisted to settings.json - draftStarters?: DraftStarterRef[]; - draftStartersVisible?: boolean; - // One-time migration marker: Craft a Goal was offered in the starter row. - draftStartersCraftGoalAdded?: boolean; - draftStartersScheduleTaskAdded?: boolean; -}; +// The settings document is defined once, in the registry, and re-exported here +// so the many existing importers keep their path. +export type { DesktopSettings } from '@/lib/settings/registry'; type DesktopBridgeGlobal = { invoke?: (cmd: string, args?: Record) => Promise; diff --git a/packages/ui/src/lib/desktopNative.ts b/packages/ui/src/lib/desktopNative.ts index 945f64b0..ff887b5e 100644 --- a/packages/ui/src/lib/desktopNative.ts +++ b/packages/ui/src/lib/desktopNative.ts @@ -64,16 +64,29 @@ export const setDesktopWindowTitle = async (title: string): Promise => { } }; +export type DesktopSplashColors = { + bgLight: string; + fgLight: string; + bgDark: string; + fgDark: string; +}; + +/** + * Tell the shell which theme the window resolved. The splash colours ride + * along so main can paint the next startup splash from its own store; they + * are device state and never go through the shared settings document. + */ export const setDesktopWindowTheme = async ( themeMode?: string, themeVariant?: string, + splash?: DesktopSplashColors, ): Promise => { if (!isDesktopShell()) { return; } try { - await invokeDesktopCommand('desktop_set_window_theme', { themeMode, themeVariant }); + await invokeDesktopCommand('desktop_set_window_theme', { themeMode, themeVariant, splash }); } catch { // ignore } diff --git a/packages/ui/src/lib/diff/patchFileDiff.test.ts b/packages/ui/src/lib/diff/patchFileDiff.test.ts index 4a3ebea8..8675fff6 100644 --- a/packages/ui/src/lib/diff/patchFileDiff.test.ts +++ b/packages/ui/src/lib/diff/patchFileDiff.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { extractHunkPatch, splitPatchIntoHunks } from "./patchFileDiff"; +import { extractHunkPatch, splitPatchIntoHunks, haveMatchingPatchVersions, getPatchHunkAnchors } from "./patchFileDiff"; const SAMPLE_PATCH = `diff --git a/foo.txt b/foo.txt index 1111111..2222222 100644 @@ -67,6 +67,22 @@ describe("splitPatchIntoHunks", () => { }); describe("extractHunkPatch", () => { + test("pairs display and action patches only with identical full blob identities and file headers", () => { + const patch = (hash: string, file = 'f') => `diff --git a/${file} b/${file}\nindex ${'a'.repeat(40)}..${hash} 100644\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-a\n+b\n`; + const first = patch('b'.repeat(40)); + expect(haveMatchingPatchVersions(first, first)).toBe(true); + expect(haveMatchingPatchVersions(first, patch('c'.repeat(40)))).toBe(false); + expect(haveMatchingPatchVersions(first, patch('b'.repeat(40), 'other'))).toBe(false); + expect(haveMatchingPatchVersions(SAMPLE_PATCH, SAMPLE_PATCH)).toBe(false); + }); + test("preserves CRLF content and mixed endings byte for byte", () => { + const header = 'diff --git a/f b/f\n--- a/f\n+++ b/f\n'; + const first = '@@ -1,2 +1,2 @@\n-before\r\n+after\r\n context\n'; + const second = '@@ -20 +20 @@\n-old\n+new\r\n'; + expect(splitPatchIntoHunks(header + first + second)).toEqual([header + first, header + second]); + expect(extractHunkPatch(header + first + second, 1)).toBe(header + second); + }); + test("returns the standalone patch for the requested index", () => { const second = extractHunkPatch(SAMPLE_PATCH, 1); expect(second).not.toBeNull(); @@ -81,3 +97,31 @@ describe("extractHunkPatch", () => { expect(extractHunkPatch("", 0)).toBeNull(); }); }); + +describe('hunk action anchors', () => { + const header = 'diff --git a/f b/f\n--- a/f\n+++ b/f\n'; + test('anchors below trailing deletions rather than above them on the shorter new side', () => { + expect(getPatchHunkAnchors(header + '@@ -8,5 +8,3 @@\n line8\n line9\n line10\n-gone11\n-gone12\n')).toEqual([ + { index: 0, side: 'deletions', lineNumber: 12 }, + ]); + }); + test('anchors before trailing context and preserves canonical hunk indices', () => { + expect(getPatchHunkAnchors(header + '@@ -1,2 +1,2 @@\n-old\n+new\n context\n@@ -20 +20 @@\n-before\n+after\n')).toEqual([ + { index: 0, side: 'additions', lineNumber: 1 }, + { index: 1, side: 'additions', lineNumber: 20 }, + ]); + }); + test('supports added and fully deleted files with an empty opposite side', () => { + expect(getPatchHunkAnchors(header + '@@ -0,0 +1,2 @@\n+a\n+b\n')).toEqual([{ index: 0, side: 'additions', lineNumber: 2 }]); + expect(getPatchHunkAnchors(header + '@@ -1,2 +0,0 @@\n-a\n-b\n')).toEqual([{ index: 0, side: 'deletions', lineNumber: 2 }]); + }); + test('ignores no-newline metadata when selecting the final row', () => { + expect(getPatchHunkAnchors(header + '@@ -1 +1 @@\n-before\r\n+after\r\n\\ No newline at end of file\n')).toEqual([ + { index: 0, side: 'additions', lineNumber: 1 }, + ]); + }); + test('does not create controls for an empty or malformed patch', () => { + expect(getPatchHunkAnchors('')).toEqual([]); + expect(getPatchHunkAnchors(header + '@@ -0,0 +0,0 @@\n')).toEqual([]); + }); +}); diff --git a/packages/ui/src/lib/diff/patchFileDiff.ts b/packages/ui/src/lib/diff/patchFileDiff.ts index 304d6f0a..8d64ec79 100644 --- a/packages/ui/src/lib/diff/patchFileDiff.ts +++ b/packages/ui/src/lib/diff/patchFileDiff.ts @@ -3,6 +3,7 @@ import { parsePatchFiles, processFile, trimPatchContext, + type AnnotationSide, type FileDiffMetadata, } from '@pierre/diffs'; @@ -10,6 +11,24 @@ const PATCH_DIFF_CACHE_LIMIT = 64; const DEFAULT_PATCH_CONTEXT_LINES = 3; const patchFileDiffCache = new Map(); +export const isBinaryPatch = (patch: string): boolean => + /^Binary files .+ differ$/m.test(patch) || /^GIT binary patch$/m.test(patch); + +const patchVersionHeader = (patch: string): string | null => { + const firstHunk = patch.search(/^@@\s/m); + if (firstHunk < 0) return null; + const header = patch.slice(0, firstHunk); + // getDiff requests full object IDs. Do not infer identity from abbreviated + // hashes or from changed-line totals, which survive partial staging. + return /^index (?:[a-f0-9]{40}|[a-f0-9]{64})\.\.(?:[a-f0-9]{40}|[a-f0-9]{64})(?: [0-7]+)?$/m.test(header) + ? header : null; +}; + +export const haveMatchingPatchVersions = (displayPatch: string, actionPatch: string): boolean => { + const displayHeader = patchVersionHeader(displayPatch); + return displayHeader !== null && displayHeader === patchVersionHeader(actionPatch); +}; + export const fileDiffFromPatch = ( file: string, patch: string, @@ -138,32 +157,15 @@ const emptyFileDiff = (file: string): FileDiffMetadata => export const splitPatchIntoHunks = (patch: string): string[] => { if (!patch) return []; - const lines = patch.split(/\r?\n/); - const hunkHeaderRegex = /^@@\s/; - const headerLines: string[] = []; - let firstHunk = 0; - while (firstHunk < lines.length && !hunkHeaderRegex.test(lines[firstHunk] ?? '')) { - headerLines.push(lines[firstHunk]); - firstHunk += 1; - } - - if (firstHunk >= lines.length) { - return []; - } - - const hunks: string[][] = []; - for (let index = firstHunk; index < lines.length; index += 1) { - const line = lines[index]; - if (hunkHeaderRegex.test(line ?? '')) { - hunks.push([...headerLines, line]); - } else if (hunks.length > 0) { - hunks[hunks.length - 1].push(line ?? ''); - } - } - - return hunks.map((hunkLines) => hunkLines.join('\n')) - .filter((hunk) => hunk.trim().length > 0) - .map((hunk) => (hunk.endsWith('\n') ? hunk : `${hunk}\n`)); + // Git's structural newlines are LF. A CR before LF inside a hunk belongs + // to the file contents and must survive an apply/reverse round trip. + const starts = [...patch.matchAll(/^@@\s/gm)].map((match) => match.index); + if (starts.length === 0) return []; + const header = patch.slice(0, starts[0]); + return starts.map((start, index) => { + const hunk = header + patch.slice(start, starts[index + 1] ?? patch.length); + return hunk.endsWith('\n') ? hunk : `${hunk}\n`; + }); }; /** @@ -176,3 +178,35 @@ export const extractHunkPatch = (patch: string, hunkIndex: number): string | nul const hunks = splitPatchIntoHunks(patch); return hunks[hunkIndex] ?? null; }; + +export interface PatchHunkAnchor { + index: number; + side: AnnotationSide; + lineNumber: number; +} + +/** Anchor each action after the final changed row, before trailing context. */ +export const getPatchHunkAnchors = (patch: string): PatchHunkAnchor[] => { + const anchors: PatchHunkAnchor[] = []; + for (const [index, hunk] of splitPatchIntoHunks(patch).entries()) { + const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[^\n]*(?:\n|$)/m.exec(hunk); + if (!header) continue; + let deletionLine = Number(header[1]); + let additionLine = Number(header[3]); + let anchor: PatchHunkAnchor | undefined; + for (const line of hunk.slice(header.index + header[0].length).split('\n')) { + if (line.startsWith('-')) { + anchor = { index, side: 'deletions', lineNumber: deletionLine++ }; + } else if (line.startsWith('+')) { + anchor = { index, side: 'additions', lineNumber: additionLine++ }; + } else if (line.startsWith(' ')) { + deletionLine += 1; + additionLine += 1; + } + } + if (anchor && Number.isSafeInteger(anchor.lineNumber) && anchor.lineNumber > 0) { + anchors.push(anchor); + } + } + return anchors; +}; diff --git a/packages/ui/src/lib/directoryShowHidden.ts b/packages/ui/src/lib/directoryShowHidden.ts index 0282b1b8..5cbef3b9 100644 --- a/packages/ui/src/lib/directoryShowHidden.ts +++ b/packages/ui/src/lib/directoryShowHidden.ts @@ -27,6 +27,9 @@ const notifyDirectoryShowHiddenChanged = () => { window.dispatchEvent(new Event(SHOW_HIDDEN_EVENT)); }; +/** The device's current choice, read the same way the hook reads it. */ +export const getDirectoryShowHidden = (): boolean => readStoredShowHidden(); + export const setDirectoryShowHidden = ( value: boolean, options: { persist?: boolean } = {} diff --git a/packages/ui/src/lib/filesViewShowGitignored.ts b/packages/ui/src/lib/filesViewShowGitignored.ts index 210e517f..7e070c78 100644 --- a/packages/ui/src/lib/filesViewShowGitignored.ts +++ b/packages/ui/src/lib/filesViewShowGitignored.ts @@ -25,6 +25,9 @@ const notifyFilesViewShowGitignoredChanged = () => { window.dispatchEvent(new Event(SHOW_GITIGNORED_EVENT)); }; +/** The device's current choice, read the same way the hook reads it. */ +export const getFilesViewShowGitignored = (): boolean => readStoredShowGitignored(); + export const setFilesViewShowGitignored = ( value: boolean, options: { persist?: boolean } = {} diff --git a/packages/ui/src/lib/ghostty/DOCUMENTATION.md b/packages/ui/src/lib/ghostty/DOCUMENTATION.md new file mode 100644 index 00000000..de993311 --- /dev/null +++ b/packages/ui/src/lib/ghostty/DOCUMENTATION.md @@ -0,0 +1,45 @@ +# libghostty-vt Terminal Adapter + +## Ownership + +This directory is OpenChamber's browser adapter for the official `libghostty-vt` C ABI, adapted from T3 Code (see `LICENSE-T3CODE`). It replaces the `ghostty-web` npm package: the emulator, the renderer and the input layer are all owned here, so a terminal bug is fixed in this directory rather than patched around in `node_modules`. + +- `runtime.ts` owns the single WebAssembly instance per page, the C struct layouts read from `ghostty_type_json`, allocation helpers, and the PTY write callback trampoline (embedded bytes compiled from `../../../scripts/ghostty-write-pty.zig`). +- `core.ts` owns one terminal's Ghostty handles: VT writes, resize, theme and 256-color palette, selection, key/mouse/paste encoding, and the render-state snapshot (`GhosttySnapshot`) the renderer draws. +- `renderer.ts` paints a snapshot into a Canvas 2D context: background runs, text runs, decorations, cursor. It measures the cell from the faces that will render. +- `surface.ts` owns the DOM: canvas, hidden textarea (keyboard, IME, clipboard), scrollbar, pointer selection, link hover/activation, mouse reporting, wheel scrolling, cursor blink, DPR changes, and the fit/notify cycle toward the PTY. +- `boxDrawing.ts` draws Box Drawing (U+2500–U+257F), Block Elements (U+2580–U+259F) and Powerline arrows (U+E0B0–U+E0B3) procedurally to the exact cell; the renderer never sends those to the font. +- `keyCodes.ts` mirrors the `GhosttyKey` enum of the pinned revision. `terminalLinks.ts` matches URLs across soft-wrapped rows. `fonts.ts` normalizes family lists for the canvas font shorthand and probes for monospace advances. +- `vendor/` holds the reproducible artifact (`ghostty-vt.wasm`), the pinned upstream revision (`VERSION`) and Ghostty's license. `fonts/` vendors the symbols-only Nerd Font (MIT) so prompt glyphs render without a locally installed Nerd Font and without a CDN. + +`components/terminal/TerminalViewport.tsx` is the only React consumer. React stays out of the render loop: the surface schedules its own frames. + +## Invariants + +- The grid is measured after the faces that will render are loaded (`document.fonts.load` for every style plus the bundled symbols font). A face that finishes loading later triggers a re-measure through `loadingdone`. Never size the grid from a fallback face on purpose. +- Generic keywords Chromium's canvas parser rejects (`ui-monospace`, `system-ui`) are stripped before any `context.font` assignment; an invalid shorthand silently no-ops and the grid would be measured with the previous font. +- The canvas context is created with `willReadFrequently: true`, which pins it to the software rasterizer. Gecko otherwise picks acceleration per canvas, and its GPU text path on macOS skips CoreText smoothing: a terminal created after page load drew thin, pencil-like glyphs while the first one stayed on the software path (confirmed: `gfx.canvas.accelerated=false` in Zen removed the symptom). The backing store is also sized to the mount at DPR before the first paint, so the compositor never sees the default 300×150 store stretched. +- Cell-filling symbols (borders, bars, block logos) are drawn by `boxDrawing.ts`, snapped to whole CSS pixels so neighbouring cells meet without seams. Fonts draw these only as tall as their em box, so at the 1.35 em line height every TUI border showed a strip of background between rows. +- The PTY hears about a resize only after the grid settles (150 ms) and at most once per fit; `onResize` is the sole resize channel. The first successful fit always notifies, even at the construction size. +- History replay (`resetAndWrite`) detaches the PTY writer so historical device queries never reach the live shell, and runs at the PTY size the history was drawn for when the caller passes one, so Ghostty reflows lines where the shell wrapped them. +- A hidden surface (`setVisible(false)`) keeps parsing output and answering VT queries but schedules no frames, no cursor timer and no scrollbar work. Reveal repaints in full. +- Touch hosts (`handleTouchPointer: false`) own scroll and long-press gestures through `scrollLines`, `selectWordAt` and `extendSelectionTo`; the surface ignores touch pointers and the compatibility mouse events that follow them so a tap does not summon the soft keyboard. +- Every terminal frees its own handles on `dispose()`; the WebAssembly instance is shared and never torn down. + +## Updating libghostty-vt + +1. Put the new upstream commit hash in `vendor/VERSION`. +2. Run `bun run --cwd packages/ui build:ghostty-wasm`. It downloads Zig 0.15.2 into `~/.cache/openchamber-ghostty`, clones Ghostty at the pin, builds `wasm32-freestanding`, replaces `vendor/ghostty-vt.wasm`, and prints the trampoline bytes for `runtime.ts` (they only change when the Zig source changes). +3. Reconcile the ABI numbers in `core.ts` (`RENDER_DATA`, `ROW_DATA`, `CELL_DATA`, option ids in `setTheme`, `ghostty_terminal_get` ids) and `keyCodes.ts` against the headers of the new revision. +4. `runtime.test.ts` fails when the artifact's embedded build metadata disagrees with `VERSION`. + +On macOS 27 with Xcode 26+ SDKs the script works around a Zig 0.15.2 limitation: the SDK's `libSystem.tbd` lists only `arm64e-macos`, so the script builds a patched SDK root and shims `xcrun` for the duration of the build. + +## Verification + +```sh +bun test packages/ui/src/lib/ghostty packages/ui/src/components/terminal +bun run --cwd packages/ui type-check +``` + +The `core` and `runtime` tests run the real WebAssembly under bun; they cover reflow, palette, replay isolation and recycled-row cleanliness. diff --git a/packages/ui/src/lib/ghostty/LICENSE-T3CODE b/packages/ui/src/lib/ghostty/LICENSE-T3CODE new file mode 100644 index 00000000..0551ba1e --- /dev/null +++ b/packages/ui/src/lib/ghostty/LICENSE-T3CODE @@ -0,0 +1,25 @@ +The libghostty-vt browser adapter in this directory (runtime.ts, core.ts, +renderer.ts, surface.ts, keyCodes.ts and their tests) is adapted from T3 Code, +https://github.com/pingdotgg/t3code, and carries its license: + +MIT License + +Copyright (c) 2026 T3 Tools Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/ui/src/lib/ghostty/boxDrawing.test.ts b/packages/ui/src/lib/ghostty/boxDrawing.test.ts new file mode 100644 index 00000000..4d668b96 --- /dev/null +++ b/packages/ui/src/lib/ghostty/boxDrawing.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, test } from 'bun:test'; + +import { drawBoxDrawingGlyph, isBoxDrawingText, type BoxDrawingContext } from './boxDrawing'; + +type Call = readonly [string, ...number[]]; + +interface RecordingContext { + readonly context: BoxDrawingContext; + readonly calls: Call[]; + readonly fills: string[]; +} + +function recordingContext(): RecordingContext { + const calls: Call[] = []; + const fills: string[] = []; + const context: BoxDrawingContext = { + fillStyle: '', + strokeStyle: '', + lineWidth: 1, + lineCap: 'butt', + fillRect: (x, y, w, h) => { + calls.push(['fillRect', x, y, w, h]); + fills.push(String(context.fillStyle)); + }, + beginPath: () => calls.push(['beginPath']), + moveTo: (x, y) => calls.push(['moveTo', x, y]), + lineTo: (x, y) => calls.push(['lineTo', x, y]), + quadraticCurveTo: (cpx, cpy, x, y) => calls.push(['quadraticCurveTo', cpx, cpy, x, y]), + closePath: () => calls.push(['closePath']), + fill: () => calls.push(['fill']), + stroke: () => calls.push(['stroke']), + }; + return { context, calls, fills }; +} + +const white = { r: 255, g: 255, b: 255 }; +// A 7.8 x 18 cell at a fractional x, like the real grid produces. +const cell = { x: 4 + 7.8 * 3, y: 4 + 18 * 2, width: 7.8, height: 18 }; + +describe('isBoxDrawingText', () => { + test('owns box drawing, block elements and powerline arrows only', () => { + expect(isBoxDrawingText('─')).toBe(true); + expect(isBoxDrawingText('╬')).toBe(true); + expect(isBoxDrawingText('▀')).toBe(true); + expect(isBoxDrawingText('░')).toBe(true); + expect(isBoxDrawingText('')).toBe(true); + expect(isBoxDrawingText('a')).toBe(false); + expect(isBoxDrawingText('❯')).toBe(false); + expect(isBoxDrawingText('')).toBe(false); + expect(isBoxDrawingText('──')).toBe(false); + }); +}); + +describe('drawBoxDrawingGlyph', () => { + test('fills a full block over the whole rounded cell so stacked rows touch', () => { + const { context, calls } = recordingContext(); + expect(drawBoxDrawingGlyph(context, '█', cell, white)).toBe(true); + // x: 27.4 -> 27, right: 35.2 -> 35; y: 40, bottom: 58. + expect(calls).toEqual([['fillRect', 27, 40, 8, 18]]); + }); + + test('splits the upper and lower half blocks at the shared middle pixel', () => { + const upper = recordingContext(); + const lower = recordingContext(); + drawBoxDrawingGlyph(upper.context, '▀', cell, white); + drawBoxDrawingGlyph(lower.context, '▄', cell, white); + expect(upper.calls).toEqual([['fillRect', 27, 40, 8, 9]]); + expect(lower.calls).toEqual([['fillRect', 27, 49, 8, 9]]); + }); + + test('shades with the foreground at partial alpha', () => { + const { context, fills } = recordingContext(); + drawBoxDrawingGlyph(context, '▒', cell, white); + expect(fills).toEqual(['rgba(255, 255, 255, 0.5)']); + }); + + test('draws light lines edge to edge so neighbouring cells join without seams', () => { + const { context, calls } = recordingContext(); + drawBoxDrawingGlyph(context, '─', cell, white); + // Each arm reaches the far edge of the one-pixel center band. + expect(calls).toEqual([ + ['fillRect', 27, 49, 5, 1], + ['fillRect', 31, 49, 4, 1], + ]); + const next = recordingContext(); + drawBoxDrawingGlyph(next.context, '─', { ...cell, x: cell.x + cell.width }, white); + expect(next.calls[0]).toEqual(['fillRect', 35, 49, 5, 1]); + }); + + test('closes a light corner at the junction square without a stub', () => { + const { context, calls } = recordingContext(); + drawBoxDrawingGlyph(context, '┌', cell, white); + expect(calls).toEqual([ + ['fillRect', 31, 49, 4, 1], + ['fillRect', 31, 49, 1, 9], + ]); + }); + + test('draws heavy arms three strokes thick', () => { + const { context, calls } = recordingContext(); + drawBoxDrawingGlyph(context, '━', cell, white); + expect(calls).toEqual([ + ['fillRect', 27, 48, 5, 3], + ['fillRect', 31, 48, 4, 3], + ]); + }); + + test('nests the two lines of a double corner', () => { + const { context, calls } = recordingContext(); + drawBoxDrawingGlyph(context, '╔', cell, white); + // Right arm: outer (top) line from the outer vertical line, inner (bottom) + // line from the inner vertical line. Down arm mirrors it. + expect(calls).toEqual([ + ['fillRect', 29, 47, 6, 1], + ['fillRect', 33, 51, 2, 1], + ['fillRect', 29, 47, 1, 11], + ['fillRect', 33, 51, 1, 7], + ]); + }); + + test('keeps a double cross open in the middle', () => { + const { context, calls } = recordingContext(); + drawBoxDrawingGlyph(context, '╬', cell, white); + expect(calls).toEqual([ + ['fillRect', 27, 47, 2, 1], + ['fillRect', 27, 51, 2, 1], + ['fillRect', 33, 47, 2, 1], + ['fillRect', 33, 51, 2, 1], + ['fillRect', 29, 40, 1, 7], + ['fillRect', 33, 40, 1, 7], + ['fillRect', 29, 51, 1, 7], + ['fillRect', 33, 51, 1, 7], + ]); + }); + + test('strokes arcs, diagonals and outline arrows and fills solid arrows', () => { + const arc = recordingContext(); + drawBoxDrawingGlyph(arc.context, '╭', cell, white); + expect(arc.calls.map(([name]) => name)).toEqual(['beginPath', 'moveTo', 'lineTo', 'quadraticCurveTo', 'lineTo', 'stroke']); + const diagonal = recordingContext(); + drawBoxDrawingGlyph(diagonal.context, '╳', cell, white); + expect(diagonal.calls.filter(([name]) => name === 'moveTo')).toHaveLength(2); + const solid = recordingContext(); + drawBoxDrawingGlyph(solid.context, '', cell, white); + expect(solid.calls.at(-1)).toEqual(['fill']); + const outline = recordingContext(); + drawBoxDrawingGlyph(outline.context, '', cell, white); + expect(outline.calls.at(-1)).toEqual(['stroke']); + }); + + test('splits dashed lines into their dash count', () => { + const { context, calls } = recordingContext(); + drawBoxDrawingGlyph(context, '┈', cell, white); + expect(calls).toHaveLength(4); + }); + + test('leaves other text to the font', () => { + const { context, calls } = recordingContext(); + expect(drawBoxDrawingGlyph(context, 'a', cell, white)).toBe(false); + expect(calls).toEqual([]); + }); +}); diff --git a/packages/ui/src/lib/ghostty/boxDrawing.ts b/packages/ui/src/lib/ghostty/boxDrawing.ts new file mode 100644 index 00000000..5a022662 --- /dev/null +++ b/packages/ui/src/lib/ghostty/boxDrawing.ts @@ -0,0 +1,367 @@ +import type { GhosttyColor } from './core'; + +/** + * Procedural glyphs for the cell-filling symbols TUI apps draw borders and + * bars with: Box Drawing (U+2500–U+257F), Block Elements (U+2580–U+259F) and + * the Powerline arrows (U+E0B0–U+E0B3). A font draws these only as tall as + * its own em box, so at the terminal's 1.35 em line height every border and + * every logo built from block characters shows a strip of background between + * rows. Native terminals draw them to the exact cell instead; so does this. + */ +export interface BoxDrawingContext { + fillStyle: string | CanvasGradient | CanvasPattern; + strokeStyle: string | CanvasGradient | CanvasPattern; + lineWidth: number; + lineCap: CanvasLineCap; + fillRect(x: number, y: number, w: number, h: number): void; + beginPath(): void; + moveTo(x: number, y: number): void; + lineTo(x: number, y: number): void; + quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void; + closePath(): void; + fill(): void; + stroke(): void; +} + +export interface BoxDrawingCell { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +const BOX_DRAWING_FIRST = 0x2500; +const BOX_DRAWING_LAST = 0x257f; +const BLOCK_FIRST = 0x2580; +const BLOCK_LAST = 0x259f; +const POWERLINE_FIRST = 0xe0b0; +const POWERLINE_LAST = 0xe0b3; + +/** Arm weights, one digit each for up, down, left, right; 0 is no arm. */ +const LIGHT = 1; +const HEAVY = 2; +const DOUBLE = 3; + +// Up/Down/Left/Right weights for U+2500..U+257F. Dashed, arc and diagonal +// forms carry their solid equivalent here and are special-cased when drawn. +const BOX_ARMS = + '0011 0022 1100 2200 0011 0022 1100 2200 0011 0022 1100 2200' + // 2500-250B lines and dashes + ' 0101 0102 0201 0202 0110 0120 0210 0220 1001 1002 2001 2002 1010 1020 2010 2020' + // 250C-251B corners + ' 1101 1102 2101 1201 2201 2102 1202 2202' + // 251C-2523 left tees + ' 1110 1120 2110 1210 2210 2120 1220 2220' + // 2524-252B right tees + ' 0111 0121 0112 0122 0211 0221 0212 0222' + // 252C-2533 top tees + ' 1011 1021 1012 1022 2011 2021 2012 2022' + // 2534-253B bottom tees + ' 1111 1121 1112 1122 2111 1211 2211 2121 2112 1221 1212 2122 1222 2221 2212 2222' + // 253C-254B crosses + ' 0011 0022 1100 2200' + // 254C-254F double dashes + ' 0033 3300' + // 2550-2551 double lines + ' 0103 0301 0303 0130 0310 0330 1003 3001 3003 1030 3010 3030' + // 2552-255D double corners + ' 1103 3301 3303 1130 3310 3330 0133 0311 0333 1033 3011 3033 1133 3311 3333' + // 255E-256C double tees and cross + ' 0101 0110 1010 1001' + // 256D-2570 arcs (drawn as curves) + ' 0000 0000 0000' + // 2571-2573 diagonals (drawn as lines) + ' 0010 1000 0001 0100 0020 2000 0002 0200' + // 2574-257B half lines + ' 0012 1200 0021 2100'; // 257C-257F mixed half lines + +const BOX_ARM_TABLE = BOX_ARMS.split(' '); + +const TRIPLE_DASH = new Set([0x2504, 0x2505, 0x2506, 0x2507]); +const QUAD_DASH = new Set([0x2508, 0x2509, 0x250a, 0x250b]); +const DOUBLE_DASH = new Set([0x254c, 0x254d, 0x254e, 0x254f]); + +// Block elements as unit rectangles (left, top, width, height) of the cell. +const BLOCK_RECTS = new Map([ + [0x2580, [[0, 0, 1, 1 / 2]]], + [0x2581, [[0, 7 / 8, 1, 1 / 8]]], + [0x2582, [[0, 6 / 8, 1, 2 / 8]]], + [0x2583, [[0, 5 / 8, 1, 3 / 8]]], + [0x2584, [[0, 1 / 2, 1, 1 / 2]]], + [0x2585, [[0, 3 / 8, 1, 5 / 8]]], + [0x2586, [[0, 2 / 8, 1, 6 / 8]]], + [0x2587, [[0, 1 / 8, 1, 7 / 8]]], + [0x2588, [[0, 0, 1, 1]]], + [0x2589, [[0, 0, 7 / 8, 1]]], + [0x258a, [[0, 0, 6 / 8, 1]]], + [0x258b, [[0, 0, 5 / 8, 1]]], + [0x258c, [[0, 0, 1 / 2, 1]]], + [0x258d, [[0, 0, 3 / 8, 1]]], + [0x258e, [[0, 0, 2 / 8, 1]]], + [0x258f, [[0, 0, 1 / 8, 1]]], + [0x2590, [[1 / 2, 0, 1 / 2, 1]]], + [0x2594, [[0, 0, 1, 1 / 8]]], + [0x2595, [[7 / 8, 0, 1 / 8, 1]]], + [0x2596, [[0, 1 / 2, 1 / 2, 1 / 2]]], + [0x2597, [[1 / 2, 1 / 2, 1 / 2, 1 / 2]]], + [0x2598, [[0, 0, 1 / 2, 1 / 2]]], + [0x2599, [[0, 0, 1 / 2, 1], [1 / 2, 1 / 2, 1 / 2, 1 / 2]]], + [0x259a, [[0, 0, 1 / 2, 1 / 2], [1 / 2, 1 / 2, 1 / 2, 1 / 2]]], + [0x259b, [[0, 0, 1, 1 / 2], [0, 1 / 2, 1 / 2, 1 / 2]]], + [0x259c, [[0, 0, 1, 1 / 2], [1 / 2, 1 / 2, 1 / 2, 1 / 2]]], + [0x259d, [[1 / 2, 0, 1 / 2, 1 / 2]]], + [0x259e, [[1 / 2, 0, 1 / 2, 1 / 2], [0, 1 / 2, 1 / 2, 1 / 2]]], + [0x259f, [[1 / 2, 0, 1 / 2, 1 / 2], [0, 1 / 2, 1, 1 / 2]]], +]); + +const SHADE_ALPHA = new Map([[0x2591, 0.25], [0x2592, 0.5], [0x2593, 0.75]]); + +/** Whether a cell's text is a single symbol this module draws instead of the font. */ +export function isBoxDrawingText(text: string): boolean { + if (text.length === 0 || text.length > 2) return false; + const code = text.codePointAt(0); + if (code === undefined || String.fromCodePoint(code) !== text) return false; + return ( + (code >= BOX_DRAWING_FIRST && code <= BOX_DRAWING_LAST) || + (code >= BLOCK_FIRST && code <= BLOCK_LAST) || + (code >= POWERLINE_FIRST && code <= POWERLINE_LAST) + ); +} + +function rgba(color: GhosttyColor, alpha: number): string { + return `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha})`; +} + +interface CellGeometry { + readonly left: number; + readonly right: number; + readonly top: number; + readonly bottom: number; + readonly centerX: number; + readonly centerY: number; + /** Light stroke thickness. */ + readonly stroke: number; + /** Half-distance between the two lines of a double stroke. */ + readonly gap: number; +} + +// Everything snaps to whole CSS pixels. Neighbouring cells round the same +// shared edge to the same value, so borders meet without seams, and a whole +// pixel stays crisp at every integer device pixel ratio. +function cellGeometry(cell: BoxDrawingCell): CellGeometry { + const left = Math.round(cell.x); + const right = Math.round(cell.x + cell.width); + const top = Math.round(cell.y); + const bottom = Math.round(cell.y + cell.height); + const stroke = Math.max(1, Math.round(cell.width / 8)); + return { + left, + right, + top, + bottom, + centerX: Math.round(cell.x + cell.width / 2), + centerY: Math.round(cell.y + cell.height / 2), + stroke, + gap: stroke + 1, + }; +} + +function armThickness(weight: number, stroke: number): number { + return weight === HEAVY ? stroke * 3 : stroke; +} + +/** Fill a horizontal band [x0, x1) centered on y with the given thickness. */ +function hBand(context: BoxDrawingContext, x0: number, x1: number, y: number, thickness: number): void { + if (x1 <= x0) return; + context.fillRect(x0, y - Math.floor(thickness / 2), x1 - x0, thickness); +} + +function vBand(context: BoxDrawingContext, y0: number, y1: number, x: number, thickness: number): void { + if (y1 <= y0) return; + context.fillRect(x - Math.floor(thickness / 2), y0, thickness, y1 - y0); +} + +function drawDashes( + context: BoxDrawingContext, + geometry: CellGeometry, + horizontal: boolean, + weight: number, + count: number, +): void { + const thickness = armThickness(weight, geometry.stroke); + const start = horizontal ? geometry.left : geometry.top; + const end = horizontal ? geometry.right : geometry.bottom; + const gapSize = geometry.stroke; + const span = end - start; + const dash = Math.max(1, Math.floor((span - gapSize * (count - 1)) / count)); + for (let index = 0; index < count; index += 1) { + const from = start + index * (dash + gapSize); + const to = index === count - 1 ? end : from + dash; + if (horizontal) hBand(context, from, to, geometry.centerY, thickness); + else vBand(context, from, to, geometry.centerX, thickness); + } +} + +function drawArms(context: BoxDrawingContext, geometry: CellGeometry, arms: string): void { + const up = Number(arms[0]); + const down = Number(arms[1]); + const left = Number(arms[2]); + const right = Number(arms[3]); + const { centerX, centerY, stroke, gap } = geometry; + const single = (weight: number) => weight === LIGHT || weight === HEAVY; + // A single-weight arm runs to the far edge of the thickest crossing arm's + // band, so corners and tees fill their junction square exactly, without a + // hole and without a stub past the perpendicular line. + const crossV = Math.max(armThickness(up, stroke), armThickness(down, stroke), stroke); + const crossH = Math.max(armThickness(left, stroke), armThickness(right, stroke), stroke); + const bandLeft = centerX - Math.floor(crossV / 2); + const bandRight = bandLeft + crossV; + const bandTop = centerY - Math.floor(crossH / 2); + const bandBottom = bandTop + crossH; + const verticalDouble = up === DOUBLE || down === DOUBLE; + const horizontalDouble = left === DOUBLE || right === DOUBLE; + + if (single(left)) { + hBand(context, geometry.left, verticalDouble ? centerX - gap : bandRight, centerY, armThickness(left, stroke)); + } + if (single(right)) { + hBand(context, verticalDouble ? centerX + gap : bandLeft, geometry.right, centerY, armThickness(right, stroke)); + } + if (single(up)) { + vBand(context, geometry.top, horizontalDouble ? centerY - gap : bandBottom, centerX, armThickness(up, stroke)); + } + if (single(down)) { + vBand(context, horizontalDouble ? centerY + gap : bandTop, geometry.bottom, centerX, armThickness(down, stroke)); + } + + // A double arm is two light lines. Where a line meets the perpendicular arm + // on its own side it stops at that arm's matching line (a double arm), at + // the center (a single arm), or crosses to the far line to close a corner + // or run straight through (no arm). `sign` is +1 toward the far side. + const stopX = (perpendicular: number, sign: 1 | -1) => + perpendicular === DOUBLE ? centerX - sign * gap : single(perpendicular) ? centerX : centerX + sign * gap; + const stopY = (perpendicular: number, sign: 1 | -1) => + perpendicular === DOUBLE ? centerY - sign * gap : single(perpendicular) ? centerY : centerY + sign * gap; + if (left === DOUBLE) { + hBand(context, geometry.left, stopX(up, 1), centerY - gap, stroke); + hBand(context, geometry.left, stopX(down, 1), centerY + gap, stroke); + } + if (right === DOUBLE) { + hBand(context, stopX(up, -1), geometry.right, centerY - gap, stroke); + hBand(context, stopX(down, -1), geometry.right, centerY + gap, stroke); + } + if (up === DOUBLE) { + vBand(context, geometry.top, stopY(left, 1), centerX - gap, stroke); + vBand(context, geometry.top, stopY(right, 1), centerX + gap, stroke); + } + if (down === DOUBLE) { + vBand(context, stopY(left, -1), geometry.bottom, centerX - gap, stroke); + vBand(context, stopY(right, -1), geometry.bottom, centerX + gap, stroke); + } +} + +function drawArc(context: BoxDrawingContext, geometry: CellGeometry, code: number): void { + const { centerX, centerY, stroke } = geometry; + // 256D ╭ down+right, 256E ╮ down+left, 256F ╯ up+left, 2570 ╰ up+right + const toRight = code === 0x256d || code === 0x2570; + const toDown = code === 0x256d || code === 0x256e; + const endX = toRight ? geometry.right : geometry.left; + const endY = toDown ? geometry.bottom : geometry.top; + const radius = Math.min(Math.abs(endX - centerX), Math.abs(endY - centerY)); + const dirX = toRight ? 1 : -1; + const dirY = toDown ? 1 : -1; + // An odd stroke sits on pixel centers, matching the fillRect bands of the + // straight forms so a curve continues a line without a half-pixel step. + const align = (stroke % 2) / 2; + const cx = centerX + align; + const cy = centerY + align; + context.beginPath(); + context.moveTo(endX, cy); + context.lineTo(cx + dirX * radius, cy); + context.quadraticCurveTo(cx, cy, cx, cy + dirY * radius); + context.lineTo(cx, endY); + context.lineWidth = stroke; + context.lineCap = 'butt'; + context.stroke(); +} + +function drawDiagonal(context: BoxDrawingContext, geometry: CellGeometry, code: number): void { + context.lineWidth = geometry.stroke; + context.lineCap = 'butt'; + context.beginPath(); + if (code === 0x2571 || code === 0x2573) { + context.moveTo(geometry.right, geometry.top); + context.lineTo(geometry.left, geometry.bottom); + } + if (code === 0x2572 || code === 0x2573) { + context.moveTo(geometry.left, geometry.top); + context.lineTo(geometry.right, geometry.bottom); + } + context.stroke(); +} + +function drawPowerline(context: BoxDrawingContext, geometry: CellGeometry, code: number): void { + const { left, right, top, bottom, centerY } = geometry; + const pointsRight = code === 0xe0b0 || code === 0xe0b1; + const tip = pointsRight ? right : left; + const base = pointsRight ? left : right; + context.beginPath(); + context.moveTo(base, top); + context.lineTo(tip, centerY); + context.lineTo(base, bottom); + if (code === 0xe0b0 || code === 0xe0b2) { + context.closePath(); + context.fill(); + return; + } + context.lineWidth = geometry.stroke; + context.lineCap = 'butt'; + context.stroke(); +} + +/** + * Draw one symbol into its cell. Returns false when the code point is not a + * symbol this module owns, so the caller falls back to the font. + */ +export function drawBoxDrawingGlyph( + context: BoxDrawingContext, + text: string, + cell: BoxDrawingCell, + color: GhosttyColor, +): boolean { + if (!isBoxDrawingText(text)) return false; + const code = text.codePointAt(0) ?? 0; + const geometry = cellGeometry(cell); + const solid = rgba(color, 1); + context.fillStyle = solid; + context.strokeStyle = solid; + + if (code >= BLOCK_FIRST && code <= BLOCK_LAST) { + const shade = SHADE_ALPHA.get(code); + if (shade !== undefined) { + context.fillStyle = rgba(color, shade); + context.fillRect(geometry.left, geometry.top, geometry.right - geometry.left, geometry.bottom - geometry.top); + return true; + } + const width = geometry.right - geometry.left; + const height = geometry.bottom - geometry.top; + for (const [x, y, w, h] of BLOCK_RECTS.get(code) ?? []) { + // Edges of eighths snap independently so stacked bars still tile. + const x0 = geometry.left + Math.round(x * width); + const x1 = geometry.left + Math.round((x + w) * width); + const y0 = geometry.top + Math.round(y * height); + const y1 = geometry.top + Math.round((y + h) * height); + context.fillRect(x0, y0, Math.max(1, x1 - x0), Math.max(1, y1 - y0)); + } + return true; + } + + if (code >= POWERLINE_FIRST && code <= POWERLINE_LAST) { + drawPowerline(context, geometry, code); + return true; + } + + if (code >= 0x256d && code <= 0x2570) { + drawArc(context, geometry, code); + return true; + } + if (code >= 0x2571 && code <= 0x2573) { + drawDiagonal(context, geometry, code); + return true; + } + const arms = BOX_ARM_TABLE[code - BOX_DRAWING_FIRST] ?? '0000'; + const dashCount = TRIPLE_DASH.has(code) ? 3 : QUAD_DASH.has(code) ? 4 : DOUBLE_DASH.has(code) ? 2 : 0; + if (dashCount > 0) { + const horizontal = arms[2] !== '0'; + drawDashes(context, geometry, horizontal, Number(horizontal ? arms[2] : arms[0]), dashCount); + return true; + } + drawArms(context, geometry, arms); + return true; +} diff --git a/packages/ui/src/lib/ghostty/core.test.ts b/packages/ui/src/lib/ghostty/core.test.ts new file mode 100644 index 00000000..707148f4 --- /dev/null +++ b/packages/ui/src/lib/ghostty/core.test.ts @@ -0,0 +1,158 @@ +// Adapted from T3 Code's libghostty-vt browser adapter tests (MIT, T3 Tools Inc.). +// See LICENSE-T3CODE in this directory. +import { afterEach, describe, expect, test } from 'bun:test'; + +import { GHOSTTY_CELL_WIDE, GhosttyTerminalCore, ghosttyCellText, ghosttyPaletteBytes, type GhosttyColor } from './core'; +import { loadGhosttyRuntime } from './runtime'; + +const WHITE: GhosttyColor = { r: 255, g: 255, b: 255 }; +const BLACK: GhosttyColor = { r: 0, g: 0, b: 0 }; + +function codepointView(codepoints: ReadonlyArray): DataView { + const view = new DataView(new ArrayBuffer(codepoints.length * 4)); + codepoints.forEach((codepoint, index) => view.setUint32(index * 4, codepoint, true)); + return view; +} + +describe('ghosttyCellText', () => { + test('converts oversized grapheme clusters without hitting engine spread limits', () => { + const graphemeLength = 130_000; + const view = new DataView(new ArrayBuffer(graphemeLength * 4)); + for (let index = 0; index < graphemeLength; index += 1) { + view.setUint32(index * 4, index === 0 ? 'a'.codePointAt(0)! : 0x301, true); + } + const text = ghosttyCellText(view, graphemeLength); + expect(text.length).toBe(graphemeLength); + expect(text.codePointAt(0)).toBe('a'.codePointAt(0)); + expect(text.codePointAt(graphemeLength - 1)).toBe(0x301); + }); + + test('converts small clusters including astral codepoints', () => { + expect([...ghosttyCellText(codepointView([0x1f642, 0x20e3]), 2)]).toEqual(['\u{1F642}', '\u{20E3}']); + expect(ghosttyCellText(codepointView([0x1f642]), 1)).toBe('🙂'); + expect(ghosttyCellText(codepointView([]), 0)).toBe(''); + }); +}); + +describe('ghosttyPaletteBytes', () => { + test('places the theme ANSI colors first and keeps the xterm cube and gray ramp', () => { + const ansi = Array.from({ length: 16 }, (_, index) => ({ r: index, g: index * 2, b: index * 3 })); + const bytes = ghosttyPaletteBytes(ansi); + expect(bytes.length).toBe(768); + expect([...bytes.subarray(15 * 3, 16 * 3)]).toEqual([15, 30, 45]); + // Index 196 is pure red in the 6x6x6 cube; 232 is the darkest gray. + expect([...bytes.subarray(196 * 3, 197 * 3)]).toEqual([255, 0, 0]); + expect([...bytes.subarray(232 * 3, 233 * 3)]).toEqual([8, 8, 8]); + expect([...bytes.subarray(255 * 3, 256 * 3)]).toEqual([238, 238, 238]); + }); +}); + +describe('GhosttyTerminalCore', () => { + const cores = new Set(); + + async function createCore(onData: (data: string) => void = () => {}, palette?: GhosttyColor[]) { + const core = await GhosttyTerminalCore.create(12, 3, 8, 16, { + foreground: WHITE, + background: BLACK, + cursor: WHITE, + palette, + }, onData); + cores.add(core); + return core; + } + + afterEach(() => { + for (const core of cores) core.dispose(); + cores.clear(); + }); + + test('preserves styles, wide cells, and selection after shared memory grows', async () => { + const core = await createCore(); + const runtime = await loadGhosttyRuntime(); + const grapheme = `e${'́'.repeat(64)}`; + core.write(`\x1b[1;3;4;8;9;53;38;2;123;45;67;48;2;9;8;7m${grapheme}\x1b[0m界🙂`); + const cells = core.snapshot().rowData[0]!.cells; + expect(cells[0]).toEqual({ + text: grapheme, + wide: 0, + foreground: { r: 123, g: 45, b: 67 }, + background: { r: 9, g: 8, b: 7 }, + bold: true, + italic: true, + invisible: true, + strikethrough: true, + overline: true, + underline: true, + selected: false, + }); + expect(cells.slice(1, 5).map(({ text, wide }) => ({ text, wide }))).toEqual([ + { text: '界', wide: 0 }, + { text: '', wide: GHOSTTY_CELL_WIDE.spacerTail }, + { text: '🙂', wide: 0 }, + { text: '', wide: GHOSTTY_CELL_WIDE.spacerTail }, + ]); + + runtime.memory.grow(1); + core.setSelection({ x: 0, y: 0 }, { x: 2, y: 0 }); + expect(core.snapshot().rowData[0]!.cells[0]).toEqual({ ...cells[0]!, selected: true }); + core.clearSelection(); + expect(core.snapshot().rowData[0]!.cells[0]).toEqual(cells[0]!); + }); + + test('renders ANSI colors from the theme palette', async () => { + const palette = Array.from({ length: 16 }, (_, index) => ({ r: 10 + index, g: 20, b: 30 })); + const core = await createCore(() => {}, palette); + core.write('\x1b[31mred\x1b[0m \x1b[94mblue'); + const cells = core.snapshot().rowData[0]!.cells; + expect(cells[0]!.foreground).toEqual({ r: 11, g: 20, b: 30 }); + expect(cells[4]!.foreground).toEqual({ r: 22, g: 20, b: 30 }); + // Indices past the theme keep the standard table. + core.write('\x1b[38;5;196mX'); + expect(core.snapshot().rowData[0]!.cells[8]!.foreground).toEqual({ r: 255, g: 0, b: 0 }); + }); + + test('answers device queries through the PTY writer but not during history replay', async () => { + const replies: string[] = []; + const core = await createCore((data) => replies.push(data)); + core.write('\x1b[5n'); + expect(replies).toEqual(['\x1b[0n']); + replies.length = 0; + + core.resetAndWrite('history\x1b[5n'); + expect(replies).toEqual([]); + expect(core.snapshot().rowData[0]!.text).toBe('history'); + + core.write('\x1b[5n'); + expect(replies).toEqual(['\x1b[0n']); + }); + + test('a fresh terminal after disposing a scrolled one shows none of its rows', async () => { + const first = await createCore(); + first.write(Array.from({ length: 200 }, (_, index) => `leak-${index}\r\n`).join('')); + first.snapshot(); + first.dispose(); + cores.delete(first); + + const second = await createCore(); + second.write('fresh\r\n'.repeat(4)); + const rows = second.snapshot().rowData.map((row) => row.text); + expect(rows.some((text) => text.includes('leak-'))).toBe(false); + expect(rows[0]).toBe('fresh'); + }); + + test('reflows history written at a wider size back to the fitted grid', async () => { + const core = await createCore(); + core.resize(40, 3, 8, 16); + core.resetAndWrite(`${'x'.repeat(30)}\r\nprompt> `); + core.resize(12, 3, 8, 16); + // 30 columns wrap into 12 + 12 + 6; the first wrapped row scrolls out of a 3-row viewport. + expect(core.snapshot().rowData.map((row) => row.text)).toEqual(['x'.repeat(12), 'x'.repeat(6), 'prompt>']); + }); + + test('encodes bracketed paste only when the terminal asked for it', async () => { + const core = await createCore(); + expect(core.encodePaste('hello')).toBe('hello'); + core.write('\x1b[?2004h'); + expect(core.encodePaste('hello')).toBe('\x1b[200~hello\x1b[201~'); + }); +}); diff --git a/packages/ui/src/lib/ghostty/core.ts b/packages/ui/src/lib/ghostty/core.ts new file mode 100644 index 00000000..f2d2c5b4 --- /dev/null +++ b/packages/ui/src/lib/ghostty/core.ts @@ -0,0 +1,1288 @@ +// Adapted from T3 Code's libghostty-vt browser adapter (MIT, T3 Tools Inc.). +// See LICENSE-T3CODE in this directory. + +import { + type GhosttyKeyboardLayoutMap, + ghosttyConsumedMods, + ghosttyKeyForCode, + ghosttyUnshiftedCodepoint, + loadGhosttyKeyboardLayoutMap, +} from './keyCodes'; +import { GhosttyRuntime, loadGhosttyRuntime } from './runtime'; + +const GHOSTTY_SUCCESS = 0; +const GHOSTTY_OUT_OF_SPACE = -3; +const MAX_SCROLLBACK_ROWS = 10_000; +// wasm32 C ABI layout for GhosttyTerminalSelectionFormatOptions at the +// libghostty-vt revision pinned alongside this module. +const SELECTION_FORMAT_OPTIONS_SIZE = 16; + +const RENDER_DATA = { + cols: 1, + rows: 2, + dirty: 3, + rowIterator: 4, + background: 5, + foreground: 6, + cursor: 7, + cursorHasValue: 8, + cursorStyle: 10, + cursorVisible: 11, + cursorBlinking: 12, + cursorInViewport: 14, + cursorX: 15, + cursorY: 16, +} as const; + +const ROW_DATA = { + dirty: 1, + raw: 2, + cells: 3, +} as const; + +const CELL_DATA = { + raw: 1, + style: 2, + graphemesLength: 3, + graphemes: 4, + background: 5, + foreground: 6, + selected: 7, +} as const; + +const RAW_CELL_DATA = { + wide: 3, +} as const; + +export const GHOSTTY_CELL_WIDE = { + narrow: 0, + wide: 1, + spacerTail: 2, + spacerHead: 3, +} as const; + +export interface GhosttyColor { + readonly r: number; + readonly g: number; + readonly b: number; +} + +export interface GhosttyTheme { + readonly foreground: GhosttyColor; + readonly background: GhosttyColor; + readonly cursor: GhosttyColor; + /** + * The 16 ANSI colors (normal then bright). Indices 16-255 keep the standard + * xterm cube and gray ramp. Omitted, Ghostty's built-in palette applies. + */ + readonly palette?: readonly GhosttyColor[]; + /** CSS color the renderer overlays on selected cells; not sent to Ghostty. */ + readonly selectionBackground?: string; +} + +const PALETTE_SIZE = 256; + +/** The xterm 256-color table with the first 16 entries replaced by the theme's ANSI colors. */ +export function ghosttyPaletteBytes(ansi: readonly GhosttyColor[]): Uint8Array { + const bytes = new Uint8Array(PALETTE_SIZE * 3); + const put = (index: number, color: GhosttyColor) => { + bytes[index * 3] = color.r; + bytes[index * 3 + 1] = color.g; + bytes[index * 3 + 2] = color.b; + }; + const cubeLevels = [0, 95, 135, 175, 215, 255]; + for (let index = 16; index < 232; index += 1) { + const value = index - 16; + put(index, { + r: cubeLevels[Math.floor(value / 36) % 6] ?? 0, + g: cubeLevels[Math.floor(value / 6) % 6] ?? 0, + b: cubeLevels[value % 6] ?? 0, + }); + } + for (let index = 232; index < PALETTE_SIZE; index += 1) { + const gray = 8 + (index - 232) * 10; + put(index, { r: gray, g: gray, b: gray }); + } + ansi.slice(0, 16).forEach((color, index) => put(index, color)); + return bytes; +} + +export interface GhosttyCell { + readonly text: string; + readonly wide: number; + readonly foreground: GhosttyColor; + readonly background: GhosttyColor; + readonly bold: boolean; + readonly italic: boolean; + readonly invisible: boolean; + readonly strikethrough: boolean; + readonly overline: boolean; + readonly underline: boolean; + readonly selected: boolean; +} + +export interface GhosttyRow { + readonly cells: readonly GhosttyCell[]; + readonly text: string; + readonly isWrapContinuation: boolean; + /** Whether this row soft-wraps onto the next row. */ + readonly wrapsToNext: boolean; +} + +export interface GhosttySnapshot { + readonly cols: number; + readonly rows: number; + readonly foreground: GhosttyColor; + readonly background: GhosttyColor; + readonly cursor: GhosttyColor; + readonly cursorX: number; + readonly cursorY: number; + readonly cursorVisible: boolean; + readonly cursorBlinking: boolean; + readonly cursorStyle: number; + readonly dirtyRows: ReadonlySet; + readonly rowData: readonly GhosttyRow[]; +} + +export interface GhosttySelectionRange { + readonly viewport: { + readonly start: { readonly x: number; readonly y: number }; + readonly end: { readonly x: number; readonly y: number }; + }; + readonly screen: { + readonly start: { readonly x: number; readonly y: number }; + readonly end: { readonly x: number; readonly y: number }; + }; +} + +export interface GhosttyScrollbar { + readonly total: number; + readonly offset: number; + readonly len: number; +} + +/** Grid position tagged with its Ghostty coordinate space: 1 viewport, 2 screen. */ +export interface GhosttyPointInput { + readonly x: number; + readonly y: number; + readonly tag?: 1 | 2; +} + +export interface GhosttyMouseInput { + readonly action: 'press' | 'release' | 'motion'; + readonly button: number | null; + readonly mods: number; + readonly x: number; + readonly y: number; + readonly screenWidth: number; + readonly screenHeight: number; + readonly cellWidth: number; + readonly cellHeight: number; + readonly paddingLeft: number; + readonly paddingRight: number; + readonly paddingTop: number; + readonly paddingBottom: number; + readonly anyButtonPressed: boolean; +} + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +function blend(foreground: GhosttyColor, background: GhosttyColor): GhosttyColor { + const channel = (front: number, back: number) => Math.floor((front * 155 + back * 100) / 255); + return { + r: channel(foreground.r, background.r), + g: channel(foreground.g, background.g), + b: channel(foreground.b, background.b), + }; +} + +function sameColor(left: GhosttyColor, right: GhosttyColor): boolean { + return left.r === right.r && left.g === right.g && left.b === right.b; +} + +/** + * A terminal program can print one base character followed by a huge run of + * combining marks, packing hundreds of thousands of codepoints into a single + * cell that still fits the scrollback buffer. Engines cap spread-call + * arguments far below that, so convert in bounded chunks instead of spreading + * every codepoint into String.fromCodePoint at once. + */ +export function ghosttyCellText(codepointView: DataView, graphemeLength: number): string { + if (graphemeLength === 1) return String.fromCodePoint(codepointView.getUint32(0, true)); + const CHUNK_SIZE = 4_096; + let text = ''; + for (let start = 0; start < graphemeLength; start += CHUNK_SIZE) { + const count = Math.min(CHUNK_SIZE, graphemeLength - start); + const codes = new Array(count); + for (let index = 0; index < count; index += 1) { + codes[index] = codepointView.getUint32((start + index) * 4, true); + } + text += String.fromCodePoint(...codes); + } + return text; +} + +export class GhosttyTerminalCore { + private readonly runtime: GhosttyRuntime; + private terminalSlot = 0; + private terminal = 0; + private renderStateSlot = 0; + private renderState = 0; + private rowIteratorSlot = 0; + private rowCellsSlot = 0; + private keyEncoderSlot = 0; + private keyEncoder = 0; + private keyEventSlot = 0; + private keyEvent = 0; + private mouseEncoderSlot = 0; + private mouseEncoder = 0; + private mouseEventSlot = 0; + private mouseEvent = 0; + private ptyWriterId = 0; + private ptyWriter: ((data: string) => void) | null = null; + private scratch = 0; + private graphemes = 0; + private graphemeCapacity = 0; + private style = 0; + private scrollbar = 0; + private rows: GhosttyRow[] = []; + private disposed = false; + private keyboardLayoutMap: GhosttyKeyboardLayoutMap | undefined; + + private constructor(runtime: GhosttyRuntime) { + this.runtime = runtime; + void loadGhosttyKeyboardLayoutMap().then((layoutMap) => { + if (!this.disposed) this.keyboardLayoutMap = layoutMap; + }); + } + + static async create( + cols: number, + rows: number, + cellWidth: number, + cellHeight: number, + theme: GhosttyTheme, + onPtyData: (data: string) => void, + ): Promise { + const core = new GhosttyTerminalCore(await loadGhosttyRuntime()); + try { + core.initialize(cols, rows, cellWidth, cellHeight, theme, onPtyData); + return core; + } catch (error) { + core.dispose(); + throw error; + } + } + + private initialize( + cols: number, + rows: number, + cellWidth: number, + cellHeight: number, + theme: GhosttyTheme, + onPtyData: (data: string) => void, + ): void { + const optionsSize = this.runtime.layout('GhosttyTerminalOptions').size; + const options = this.runtime.alloc(optionsSize); + this.runtime.setField(options, 'GhosttyTerminalOptions', 'cols', cols); + this.runtime.setField(options, 'GhosttyTerminalOptions', 'rows', rows); + this.runtime.setField(options, 'GhosttyTerminalOptions', 'max_scrollback', MAX_SCROLLBACK_ROWS); + this.terminalSlot = this.runtime.allocOpaque(); + const terminalResult = this.runtime.call('ghostty_terminal_new', 0, this.terminalSlot, options); + this.runtime.free(options, optionsSize); + this.assertSuccess('ghostty_terminal_new', terminalResult); + this.terminal = this.runtime.readPointer(this.terminalSlot); + this.applyDefaultCursorBlink(); + this.ptyWriter = onPtyData; + this.ptyWriterId = this.runtime.attachPtyWriter(this.terminal, onPtyData); + + this.renderStateSlot = this.runtime.allocOpaque(); + this.assertSuccess( + 'ghostty_render_state_new', + this.runtime.call('ghostty_render_state_new', 0, this.renderStateSlot), + ); + this.renderState = this.runtime.readPointer(this.renderStateSlot); + + this.rowIteratorSlot = this.runtime.allocOpaque(); + this.assertSuccess( + 'ghostty_render_state_row_iterator_new', + this.runtime.call('ghostty_render_state_row_iterator_new', 0, this.rowIteratorSlot), + ); + this.rowCellsSlot = this.runtime.allocOpaque(); + this.assertSuccess( + 'ghostty_render_state_row_cells_new', + this.runtime.call('ghostty_render_state_row_cells_new', 0, this.rowCellsSlot), + ); + + this.keyEncoderSlot = this.runtime.allocOpaque(); + this.assertSuccess( + 'ghostty_key_encoder_new', + this.runtime.call('ghostty_key_encoder_new', 0, this.keyEncoderSlot), + ); + this.keyEncoder = this.runtime.readPointer(this.keyEncoderSlot); + this.keyEventSlot = this.runtime.allocOpaque(); + this.assertSuccess( + 'ghostty_key_event_new', + this.runtime.call('ghostty_key_event_new', 0, this.keyEventSlot), + ); + this.keyEvent = this.runtime.readPointer(this.keyEventSlot); + + this.mouseEncoderSlot = this.runtime.allocOpaque(); + this.assertSuccess( + 'ghostty_mouse_encoder_new', + this.runtime.call('ghostty_mouse_encoder_new', 0, this.mouseEncoderSlot), + ); + this.mouseEncoder = this.runtime.readPointer(this.mouseEncoderSlot); + this.mouseEventSlot = this.runtime.allocOpaque(); + this.assertSuccess( + 'ghostty_mouse_event_new', + this.runtime.call('ghostty_mouse_event_new', 0, this.mouseEventSlot), + ); + this.mouseEvent = this.runtime.readPointer(this.mouseEventSlot); + + this.scratch = this.runtime.alloc(16); + const styleSize = this.runtime.layout('GhosttyStyle').size; + this.style = this.runtime.alloc(styleSize); + this.runtime.setField(this.style, 'GhosttyStyle', 'size', styleSize); + this.scrollbar = this.runtime.alloc(this.runtime.layout('GhosttyTerminalScrollbar').size); + this.setTheme(theme); + this.resize(cols, rows, cellWidth, cellHeight); + } + + write(data: string | Uint8Array): void { + this.ensureActive(); + const bytes = data instanceof Uint8Array ? data : encoder.encode(data); + if (bytes.length === 0) return; + const pointer = this.runtime.alloc(bytes.length); + this.runtime.bytes(pointer, bytes.length).set(bytes); + this.runtime.call('ghostty_terminal_vt_write', this.terminal, pointer, bytes.length); + this.runtime.free(pointer, bytes.length); + } + + resetAndWrite(data: string): void { + this.ensureActive(); + this.runtime.call('ghostty_terminal_reset', this.terminal); + // RIS returns the cursor to Ghostty's built-in steady default, so the + // embedder default has to be applied again before the replay runs. + this.applyDefaultCursorBlink(); + this.rows = []; + if (data.length === 0) return; + const writer = this.ptyWriter; + if (this.ptyWriterId !== 0) { + this.runtime.detachPtyWriter(this.terminal, this.ptyWriterId); + this.ptyWriterId = 0; + } + try { + this.write(data); + } finally { + if (writer !== null && !this.disposed) { + this.ptyWriterId = this.runtime.attachPtyWriter(this.terminal, writer); + } + } + } + + resize(cols: number, rows: number, cellWidth: number, cellHeight: number): void { + this.ensureActive(); + this.assertSuccess( + 'ghostty_terminal_resize', + this.runtime.call( + 'ghostty_terminal_resize', + this.terminal, + Math.max(1, Math.min(65_535, cols)), + Math.max(1, Math.min(65_535, rows)), + Math.max(1, Math.round(cellWidth)), + Math.max(1, Math.round(cellHeight)), + ), + ); + } + + /** + * Ghostty's built-in default cursor is steady, while the xterm.js renderer + * this replaced ran with `cursorBlink: true`. Option 23 is the embedder's + * default blink, which is the state a session starts in and returns to on + * DECSCUSR reset (CSI 0 q), so programs that ask for a specific cursor + * through DECSCUSR or DEC mode 12 still win. + */ + private applyDefaultCursorBlink(): void { + const blink = this.runtime.alloc(1); + this.runtime.bytes(blink, 1)[0] = 1; + this.runtime.call('ghostty_terminal_set', this.terminal, 23, blink); + this.runtime.free(blink, 1); + } + + setTheme(theme: GhosttyTheme): void { + this.ensureActive(); + const color = this.runtime.alloc(3); + for (const [option, value] of [ + [11, theme.foreground], + [12, theme.background], + [13, theme.cursor], + ] as const) { + this.runtime.bytes(color, 3).set([value.r, value.g, value.b]); + this.runtime.call('ghostty_terminal_set', this.terminal, option, color); + } + this.runtime.free(color, 3); + // Option 14 takes GhosttyColorRgb[256]; NULL restores the built-in table. + // Per-index OSC 4 overrides a program set survive either way. + if (theme.palette === undefined) { + this.runtime.call('ghostty_terminal_set', this.terminal, 14, 0); + return; + } + const palette = ghosttyPaletteBytes(theme.palette); + const pointer = this.runtime.alloc(palette.length); + this.runtime.bytes(pointer, palette.length).set(palette); + this.runtime.call('ghostty_terminal_set', this.terminal, 14, pointer); + this.runtime.free(pointer, palette.length); + } + + scroll(deltaRows: number): void { + this.ensureActive(); + const layout = this.runtime.layout('GhosttyTerminalScrollViewport'); + const scroll = this.runtime.alloc(layout.size); + this.runtime.setField(scroll, 'GhosttyTerminalScrollViewport', 'tag', 2); + const value = layout.fields.value!; + this.runtime.view(scroll + value.offset, value.size).setInt32(0, deltaRows, true); + this.runtime.call('ghostty_terminal_scroll_viewport', this.terminal, scroll); + this.runtime.free(scroll, layout.size); + } + + scrollToBottom(): void { + this.ensureActive(); + const layout = this.runtime.layout('GhosttyTerminalScrollViewport'); + const scroll = this.runtime.alloc(layout.size); + this.runtime.setField(scroll, 'GhosttyTerminalScrollViewport', 'tag', 1); + this.runtime.call('ghostty_terminal_scroll_viewport', this.terminal, scroll); + this.runtime.free(scroll, layout.size); + } + + isViewportActive(): boolean { + this.ensureActive(); + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call('ghostty_terminal_get', this.terminal, 32, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + scrollbarState(): GhosttyScrollbar | null { + this.ensureActive(); + const layout = this.runtime.layout('GhosttyTerminalScrollbar'); + this.runtime.bytes(this.scrollbar, layout.size).fill(0); + if ( + this.runtime.call('ghostty_terminal_get', this.terminal, 9, this.scrollbar) !== + GHOSTTY_SUCCESS + ) { + return null; + } + return { + total: this.runtime.readField(this.scrollbar, 'GhosttyTerminalScrollbar', 'total'), + offset: this.runtime.readField(this.scrollbar, 'GhosttyTerminalScrollbar', 'offset'), + len: this.runtime.readField(this.scrollbar, 'GhosttyTerminalScrollbar', 'len'), + }; + } + + isMouseTracking(): boolean { + this.ensureActive(); + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call('ghostty_terminal_get', this.terminal, 11, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + isMouseAnyEventTracking(): boolean { + this.ensureActive(); + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call('ghostty_terminal_mode_get', this.terminal, 1003, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + isAlternateScreen(): boolean { + this.ensureActive(); + this.runtime.bytes(this.scratch, 4).fill(0); + return ( + this.runtime.call('ghostty_terminal_get', this.terminal, 6, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.view(this.scratch, 4).getUint32(0, true) === 1 + ); + } + + isApplicationCursorKeys(): boolean { + this.ensureActive(); + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call('ghostty_terminal_mode_get', this.terminal, 1, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + encodeKey(event: KeyboardEvent, action: 'press' | 'release' = 'press'): string { + this.ensureActive(); + this.runtime.call('ghostty_key_encoder_setopt_from_terminal', this.keyEncoder, this.terminal); + this.runtime.call( + 'ghostty_key_event_set_action', + this.keyEvent, + action === 'release' ? 0 : event.repeat ? 2 : 1, + ); + this.runtime.call('ghostty_key_event_set_key', this.keyEvent, ghosttyKeyForCode(event.code)); + const mods = + (event.shiftKey ? 1 : 0) | + (event.ctrlKey ? 1 << 1 : 0) | + (event.altKey ? 1 << 2 : 0) | + (event.metaKey ? 1 << 3 : 0) | + (event.getModifierState('CapsLock') ? 1 << 4 : 0) | + (event.getModifierState('NumLock') ? 1 << 5 : 0); + this.runtime.call('ghostty_key_event_set_mods', this.keyEvent, mods); + this.runtime.call( + 'ghostty_key_event_set_consumed_mods', + this.keyEvent, + ghosttyConsumedMods(event), + ); + this.runtime.call('ghostty_key_event_set_composing', this.keyEvent, event.isComposing ? 1 : 0); + this.runtime.call( + 'ghostty_key_event_set_unshifted_codepoint', + this.keyEvent, + ghosttyUnshiftedCodepoint(event, this.keyboardLayoutMap), + ); + + const text = event.key.length === 1 ? event.key : ''; + const textBytes = encoder.encode(text); + const textPointer = textBytes.length === 0 ? 0 : this.runtime.alloc(textBytes.length); + if (textPointer !== 0) this.runtime.bytes(textPointer, textBytes.length).set(textBytes); + this.runtime.call('ghostty_key_event_set_utf8', this.keyEvent, textPointer, textBytes.length); + + const written = this.runtime.call('ghostty_wasm_alloc_usize'); + const encoded = this.encodeOutput(written, (output, outputSize) => + this.runtime.call( + 'ghostty_key_encoder_encode', + this.keyEncoder, + this.keyEvent, + output, + outputSize, + written, + ), + ); + this.runtime.call('ghostty_wasm_free_usize', written); + if (textPointer !== 0) this.runtime.free(textPointer, textBytes.length); + return encoded; + } + + encodePaste(data: string): string { + this.ensureActive(); + const input = encoder.encode(data); + if (input.length === 0) return ''; + const inputPointer = this.runtime.alloc(input.length); + this.runtime.bytes(inputPointer, input.length).set(input); + this.runtime.bytes(this.scratch, 1)[0] = 0; + const bracketed = + this.runtime.call('ghostty_terminal_mode_get', this.terminal, 2004, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0; + const written = this.runtime.call('ghostty_wasm_alloc_usize'); + let encoded = ''; + const sizeResult = this.runtime.call( + 'ghostty_paste_encode', + inputPointer, + input.length, + bracketed ? 1 : 0, + 0, + 0, + written, + ); + const outputSize = this.runtime.view(written, 4).getUint32(0, true); + if (sizeResult === GHOSTTY_OUT_OF_SPACE && outputSize > 0) { + const output = this.runtime.alloc(outputSize); + const result = this.runtime.call( + 'ghostty_paste_encode', + inputPointer, + input.length, + bracketed ? 1 : 0, + output, + outputSize, + written, + ); + const outputLength = this.runtime.view(written, 4).getUint32(0, true); + encoded = + result === GHOSTTY_SUCCESS ? decoder.decode(this.runtime.bytes(output, outputLength)) : ''; + this.runtime.free(output, outputSize); + } + this.runtime.call('ghostty_wasm_free_usize', written); + this.runtime.free(inputPointer, input.length); + return encoded; + } + + encodeMouse(input: GhosttyMouseInput): string { + this.ensureActive(); + this.runtime.call( + 'ghostty_mouse_encoder_setopt_from_terminal', + this.mouseEncoder, + this.terminal, + ); + + const sizeLayout = this.runtime.layout('GhosttyMouseEncoderSize'); + const size = this.runtime.alloc(sizeLayout.size); + for (const [field, value] of [ + ['size', sizeLayout.size], + ['screen_width', input.screenWidth], + ['screen_height', input.screenHeight], + ['cell_width', input.cellWidth], + ['cell_height', input.cellHeight], + ['padding_top', input.paddingTop], + ['padding_bottom', input.paddingBottom], + ['padding_right', input.paddingRight], + ['padding_left', input.paddingLeft], + ] as const) { + this.runtime.setField(size, 'GhosttyMouseEncoderSize', field, Math.max(0, Math.round(value))); + } + this.runtime.call('ghostty_mouse_encoder_setopt', this.mouseEncoder, 2, size); + this.runtime.free(size, sizeLayout.size); + + this.runtime.bytes(this.scratch, 1)[0] = input.anyButtonPressed ? 1 : 0; + this.runtime.call('ghostty_mouse_encoder_setopt', this.mouseEncoder, 3, this.scratch); + this.runtime.bytes(this.scratch, 1)[0] = 1; + this.runtime.call('ghostty_mouse_encoder_setopt', this.mouseEncoder, 4, this.scratch); + + this.runtime.call( + 'ghostty_mouse_event_set_action', + this.mouseEvent, + input.action === 'press' ? 0 : input.action === 'release' ? 1 : 2, + ); + if (input.button === null) { + this.runtime.call('ghostty_mouse_event_clear_button', this.mouseEvent); + } else { + this.runtime.call('ghostty_mouse_event_set_button', this.mouseEvent, input.button); + } + this.runtime.call('ghostty_mouse_event_set_mods', this.mouseEvent, input.mods); + const positionLayout = this.runtime.layout('GhosttyMousePosition'); + const position = this.runtime.alloc(positionLayout.size); + const positionView = this.runtime.view(position, positionLayout.size); + positionView.setFloat32(positionLayout.fields.x!.offset, input.x, true); + positionView.setFloat32(positionLayout.fields.y!.offset, input.y, true); + this.runtime.call('ghostty_mouse_event_set_position', this.mouseEvent, position); + this.runtime.free(position, positionLayout.size); + + const written = this.runtime.call('ghostty_wasm_alloc_usize'); + const encoded = this.encodeOutput(written, (output, outputSize) => + this.runtime.call( + 'ghostty_mouse_encoder_encode', + this.mouseEncoder, + this.mouseEvent, + output, + outputSize, + written, + ), + ); + this.runtime.call('ghostty_wasm_free_usize', written); + return encoded; + } + + setSelection(anchor: GhosttyPointInput, end: GhosttyPointInput): void { + this.ensureActive(); + const selectionLayout = this.runtime.layout('GhosttySelection'); + const gridRefSize = this.runtime.layout('GhosttyGridRef').size; + const selection = this.runtime.alloc(selectionLayout.size); + let start = 0; + let endRef = 0; + try { + this.runtime.setField(selection, 'GhosttySelection', 'size', selectionLayout.size); + start = this.gridRef(anchor.x, anchor.y, anchor.tag ?? 1); + endRef = this.gridRef(end.x, end.y, end.tag ?? 1); + const startField = selectionLayout.fields.start!; + const endField = selectionLayout.fields.end!; + this.runtime + .bytes(selection + startField.offset, startField.size) + .set(this.runtime.bytes(start, startField.size)); + this.runtime + .bytes(selection + endField.offset, endField.size) + .set(this.runtime.bytes(endRef, endField.size)); + this.runtime.call('ghostty_terminal_set', this.terminal, 21, selection); + } finally { + this.runtime.free(start, gridRefSize); + this.runtime.free(endRef, gridRefSize); + this.runtime.free(selection, selectionLayout.size); + } + } + + selectAll(): void { + this.ensureActive(); + const layout = this.runtime.layout('GhosttySelection'); + const selection = this.runtime.alloc(layout.size); + this.runtime.setField(selection, 'GhosttySelection', 'size', layout.size); + if ( + this.runtime.call('ghostty_terminal_select_all', this.terminal, selection) === GHOSTTY_SUCCESS + ) { + this.runtime.call('ghostty_terminal_set', this.terminal, 21, selection); + } + this.runtime.free(selection, layout.size); + } + + selectWord(col: number, row: number): GhosttySelectionRange | null { + return this.selectAt( + 'GhosttyTerminalSelectWordOptions', + 'ghostty_terminal_select_word', + col, + row, + ); + } + + selectLine(col: number, row: number): GhosttySelectionRange | null { + return this.selectAt( + 'GhosttyTerminalSelectLineOptions', + 'ghostty_terminal_select_line', + col, + row, + ); + } + + hyperlinkAt(col: number, row: number): string | null { + this.ensureActive(); + const ref = this.gridRef(col, row); + const written = this.runtime.call('ghostty_wasm_alloc_usize'); + const sizeResult = this.runtime.call('ghostty_grid_ref_hyperlink_uri', ref, 0, 0, written); + const outputSize = this.runtime.view(written, 4).getUint32(0, true); + let hyperlink: string | null = null; + if (sizeResult === GHOSTTY_OUT_OF_SPACE && outputSize > 0) { + const output = this.runtime.alloc(outputSize); + const result = this.runtime.call( + 'ghostty_grid_ref_hyperlink_uri', + ref, + output, + outputSize, + written, + ); + const outputLength = this.runtime.view(written, 4).getUint32(0, true); + if (result === GHOSTTY_SUCCESS && outputLength > 0) { + hyperlink = decoder.decode(this.runtime.bytes(output, outputLength)); + } + this.runtime.free(output, outputSize); + } + this.runtime.call('ghostty_wasm_free_usize', written); + this.runtime.free(ref, this.runtime.layout('GhosttyGridRef').size); + return hyperlink; + } + + clearSelection(): void { + this.ensureActive(); + this.runtime.call('ghostty_terminal_set', this.terminal, 21, 0); + } + + snapshot(): GhosttySnapshot { + this.ensureActive(); + this.assertSuccess( + 'ghostty_render_state_update', + this.runtime.call('ghostty_render_state_update', this.renderState, this.terminal), + ); + const cols = this.getU16(RENDER_DATA.cols); + const rowCount = this.getU16(RENDER_DATA.rows); + const dirty = this.getU32(RENDER_DATA.dirty); + const foreground = this.getColor(RENDER_DATA.foreground, { r: 229, g: 231, b: 235 }); + const background = this.getColor(RENDER_DATA.background, { r: 0, g: 0, b: 0 }); + const cursorHasValue = this.getBool(RENDER_DATA.cursorHasValue); + const cursor = cursorHasValue ? this.getColor(RENDER_DATA.cursor, foreground) : foreground; + const cursorInViewport = this.getBool(RENDER_DATA.cursorInViewport); + const cursorVisible = this.getBool(RENDER_DATA.cursorVisible) && cursorInViewport; + const cursorX = cursorInViewport ? this.getU16(RENDER_DATA.cursorX) : -1; + const cursorY = cursorInViewport ? this.getU16(RENDER_DATA.cursorY) : -1; + + if (this.rows.length !== rowCount || this.rows.some((row) => row.cells.length !== cols)) { + this.rows = Array.from({ length: rowCount }, () => ({ + cells: Array.from({ length: cols }, () => this.emptyCell(foreground, background)), + text: '', + isWrapContinuation: false, + wrapsToNext: false, + })); + } + + const dirtyRows = new Set(); + if (dirty !== 0) { + this.assertSuccess( + 'ghostty_render_state_get(row iterator)', + this.runtime.call( + 'ghostty_render_state_get', + this.renderState, + RENDER_DATA.rowIterator, + this.rowIteratorSlot, + ), + ); + const iterator = this.runtime.readPointer(this.rowIteratorSlot); + let rowIndex = 0; + while ( + rowIndex < rowCount && + this.runtime.call('ghostty_render_state_row_iterator_next', iterator) !== 0 + ) { + const rowDirty = dirty === 2 || this.getRowBool(iterator, ROW_DATA.dirty); + if (rowDirty) { + this.rows[rowIndex] = this.readRow(iterator, cols, foreground, background); + dirtyRows.add(rowIndex); + this.runtime.bytes(this.scratch, 1)[0] = 0; + this.runtime.call('ghostty_render_state_row_set', iterator, 0, this.scratch); + } + rowIndex += 1; + } + this.runtime.view(this.scratch, 4).setUint32(0, 0, true); + this.runtime.call('ghostty_render_state_set', this.renderState, 0, this.scratch); + } + + return { + cols, + rows: rowCount, + foreground, + background, + cursor, + cursorX, + cursorY, + cursorVisible, + cursorBlinking: this.getBool(RENDER_DATA.cursorBlinking), + cursorStyle: this.getU32(RENDER_DATA.cursorStyle), + dirtyRows, + rowData: this.rows, + }; + } + + selectionText(): string { + this.ensureActive(); + const options = this.runtime.alloc(SELECTION_FORMAT_OPTIONS_SIZE); + const optionsView = this.runtime.view(options, SELECTION_FORMAT_OPTIONS_SIZE); + optionsView.setUint32(0, SELECTION_FORMAT_OPTIONS_SIZE, true); + optionsView.setUint32(4, 0, true); + optionsView.setUint8(8, 1); + optionsView.setUint8(9, 1); + optionsView.setUint32(12, 0, true); + const written = this.runtime.call('ghostty_wasm_alloc_usize'); + const sizeResult = this.runtime.call( + 'ghostty_terminal_selection_format_buf', + this.terminal, + options, + 0, + 0, + written, + ); + const outputSize = this.runtime.view(written, 4).getUint32(0, true); + let text = ''; + if (sizeResult === GHOSTTY_OUT_OF_SPACE && outputSize > 0) { + const output = this.runtime.alloc(outputSize); + const result = this.runtime.call( + 'ghostty_terminal_selection_format_buf', + this.terminal, + options, + output, + outputSize, + written, + ); + const outputLength = this.runtime.view(written, 4).getUint32(0, true); + if (result === GHOSTTY_SUCCESS) { + text = decoder.decode(this.runtime.bytes(output, outputLength)); + } + this.runtime.free(output, outputSize); + } + this.runtime.call('ghostty_wasm_free_usize', written); + this.runtime.free(options, SELECTION_FORMAT_OPTIONS_SIZE); + return text; + } + + viewportPointToScreen(col: number, row: number): { x: number; y: number } | null { + return this.convertPoint(col, row, 1, 2); + } + + screenPointToViewport(col: number, row: number): { x: number; y: number } | null { + return this.convertPoint(col, row, 2, 1); + } + + private convertPoint( + col: number, + row: number, + fromTag: 1 | 2, + toTag: 1 | 2, + ): { x: number; y: number } | null { + this.ensureActive(); + const ref = this.gridRef(col, row, fromTag); + const point = this.pointFromGridRef(ref, toTag); + this.runtime.free(ref, this.runtime.layout('GhosttyGridRef').size); + return point; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + if (this.mouseEvent) this.runtime.call('ghostty_mouse_event_free', this.mouseEvent); + if (this.mouseEncoder) this.runtime.call('ghostty_mouse_encoder_free', this.mouseEncoder); + if (this.keyEvent) this.runtime.call('ghostty_key_event_free', this.keyEvent); + if (this.keyEncoder) this.runtime.call('ghostty_key_encoder_free', this.keyEncoder); + if (this.rowCellsSlot) { + const cells = this.runtime.readPointer(this.rowCellsSlot); + if (cells) this.runtime.call('ghostty_render_state_row_cells_free', cells); + } + if (this.rowIteratorSlot) { + const iterator = this.runtime.readPointer(this.rowIteratorSlot); + if (iterator) this.runtime.call('ghostty_render_state_row_iterator_free', iterator); + } + if (this.renderState) this.runtime.call('ghostty_render_state_free', this.renderState); + if (this.terminal) { + if (this.ptyWriterId) this.runtime.detachPtyWriter(this.terminal, this.ptyWriterId); + this.runtime.call('ghostty_terminal_free', this.terminal); + } + if (this.style) this.runtime.free(this.style, this.runtime.layout('GhosttyStyle').size); + if (this.scrollbar) { + this.runtime.free(this.scrollbar, this.runtime.layout('GhosttyTerminalScrollbar').size); + } + if (this.scratch) this.runtime.free(this.scratch, 16); + if (this.graphemes) this.runtime.free(this.graphemes, this.graphemeCapacity); + for (const slot of [ + this.mouseEventSlot, + this.mouseEncoderSlot, + this.keyEventSlot, + this.keyEncoderSlot, + this.rowCellsSlot, + this.rowIteratorSlot, + this.renderStateSlot, + this.terminalSlot, + ]) { + this.runtime.freeOpaque(slot); + } + } + + private encodeOutput( + written: number, + encode: (output: number, outputSize: number) => number, + ): string { + const sizeResult = encode(0, 0); + const outputSize = this.runtime.view(written, 4).getUint32(0, true); + if (sizeResult === GHOSTTY_SUCCESS && outputSize === 0) return ''; + if (sizeResult !== GHOSTTY_OUT_OF_SPACE || outputSize === 0) return ''; + + const output = this.runtime.alloc(outputSize); + const result = encode(output, outputSize); + const outputLength = this.runtime.view(written, 4).getUint32(0, true); + const encoded = + result === GHOSTTY_SUCCESS ? decoder.decode(this.runtime.bytes(output, outputLength)) : ''; + this.runtime.free(output, outputSize); + return encoded; + } + + private readRow( + iterator: number, + cols: number, + defaultForeground: GhosttyColor, + defaultBackground: GhosttyColor, + ): GhosttyRow { + this.assertSuccess( + 'ghostty_render_state_row_get(raw)', + this.runtime.call('ghostty_render_state_row_get', iterator, ROW_DATA.raw, this.scratch), + ); + const rawRow = this.runtime.view(this.scratch, 8).getBigUint64(0, true); + this.runtime.bytes(this.scratch + 8, 1)[0] = 0; + this.assertSuccess( + 'ghostty_row_get(wrap continuation)', + this.runtime.call('ghostty_row_get', rawRow, 2, this.scratch + 8), + ); + const isWrapContinuation = this.runtime.bytes(this.scratch + 8, 1)[0] !== 0; + this.runtime.bytes(this.scratch + 8, 1)[0] = 0; + this.assertSuccess( + 'ghostty_row_get(wrap)', + this.runtime.call('ghostty_row_get', rawRow, 1, this.scratch + 8), + ); + const wrapsToNext = this.runtime.bytes(this.scratch + 8, 1)[0] !== 0; + + this.assertSuccess( + 'ghostty_render_state_row_get(cells)', + this.runtime.call( + 'ghostty_render_state_row_get', + iterator, + ROW_DATA.cells, + this.rowCellsSlot, + ), + ); + const cellsIterator = this.runtime.readPointer(this.rowCellsSlot); + const { size: styleSize, fields: styleFields } = this.runtime.layout('GhosttyStyle'); + const cells: GhosttyCell[] = []; + while ( + cells.length < cols && + this.runtime.call('ghostty_render_state_row_cells_next', cellsIterator) !== 0 + ) { + let foreground = this.getCellColor(cellsIterator, CELL_DATA.foreground, defaultForeground); + let background = this.getCellColor(cellsIterator, CELL_DATA.background, defaultBackground); + this.runtime.bytes(this.style, styleSize).fill(0); + this.runtime.setField(this.style, 'GhosttyStyle', 'size', styleSize); + this.runtime.call( + 'ghostty_render_state_row_cells_get', + cellsIterator, + CELL_DATA.style, + this.style, + ); + const graphemeLength = this.getCellU32(cellsIterator, CELL_DATA.graphemesLength); + let text = ''; + if (graphemeLength > 0) { + const bufferSize = graphemeLength * 4; + if (bufferSize > this.graphemeCapacity) { + const capacity = Math.max(bufferSize, this.graphemeCapacity * 2); + const buffer = this.runtime.alloc(capacity); + this.runtime.free(this.graphemes, this.graphemeCapacity); + this.graphemes = buffer; + this.graphemeCapacity = capacity; + } + if ( + this.runtime.call( + 'ghostty_render_state_row_cells_get', + cellsIterator, + CELL_DATA.graphemes, + this.graphemes, + ) === GHOSTTY_SUCCESS + ) { + // Read through a DataView: the byte-array allocator guarantees no + // 4-byte alignment, which a Uint32Array view would require. + const codepointView = this.runtime.view(this.graphemes, bufferSize); + text = ghosttyCellText(codepointView, graphemeLength); + } + } + let wide = 0; + if (text.length === 0 && cells.at(-1)?.text.length) { + this.assertSuccess( + 'ghostty_render_state_row_cells_get(raw)', + this.runtime.call( + 'ghostty_render_state_row_cells_get', + cellsIterator, + CELL_DATA.raw, + this.scratch, + ), + ); + const rawCell = this.runtime.view(this.scratch, 8).getBigUint64(0, true); + this.runtime.view(this.scratch + 8, 4).setUint32(0, 0, true); + this.assertSuccess( + 'ghostty_cell_get(wide)', + this.runtime.call('ghostty_cell_get', rawCell, RAW_CELL_DATA.wide, this.scratch + 8), + ); + wide = this.runtime.view(this.scratch + 8, 4).getUint32(0, true); + } + const selected = this.getCellBool(cellsIterator, CELL_DATA.selected); + // Read the style after allocation and ABI calls, which can grow WASM memory. + const styleView = this.runtime.view(this.style, styleSize); + if (styleView.getUint8(styleFields.inverse!.offset) !== 0) { + [foreground, background] = [background, foreground]; + } + if (styleView.getUint8(styleFields.faint!.offset) !== 0) { + foreground = blend(foreground, background); + } + cells.push({ + text, + wide, + foreground, + background, + bold: styleView.getUint8(styleFields.bold!.offset) !== 0, + italic: styleView.getUint8(styleFields.italic!.offset) !== 0, + invisible: styleView.getUint8(styleFields.invisible!.offset) !== 0, + strikethrough: styleView.getUint8(styleFields.strikethrough!.offset) !== 0, + overline: styleView.getUint8(styleFields.overline!.offset) !== 0, + underline: styleView.getInt32(styleFields.underline!.offset, true) !== 0, + selected, + }); + } + while (cells.length < cols) cells.push(this.emptyCell(defaultForeground, defaultBackground)); + return { + cells, + text: cells + .map((cell) => cell.text || ' ') + .join('') + .trimEnd(), + isWrapContinuation, + wrapsToNext, + }; + } + + private gridRef(col: number, row: number, tag: 1 | 2 = 1): number { + const pointLayout = this.runtime.layout('GhosttyPoint'); + const point = this.runtime.alloc(pointLayout.size); + this.runtime.setField(point, 'GhosttyPoint', 'tag', tag); + const pointValue = pointLayout.fields.value!; + const valueOffset = pointValue.offset; + const view = this.runtime.view(point + valueOffset, pointValue.size); + view.setUint16(0, Math.max(0, col), true); + view.setUint32(4, Math.max(0, row), true); + const gridRefSize = this.runtime.layout('GhosttyGridRef').size; + const gridRef = this.runtime.alloc(gridRefSize); + this.runtime.setField(gridRef, 'GhosttyGridRef', 'size', gridRefSize); + const result = this.runtime.call('ghostty_terminal_grid_ref', this.terminal, point, gridRef); + this.runtime.free(point, pointLayout.size); + if (result !== GHOSTTY_SUCCESS) { + this.runtime.free(gridRef, gridRefSize); + this.assertSuccess('ghostty_terminal_grid_ref', result); + } + return gridRef; + } + + private selectAt( + optionsName: 'GhosttyTerminalSelectWordOptions' | 'GhosttyTerminalSelectLineOptions', + operation: 'ghostty_terminal_select_word' | 'ghostty_terminal_select_line', + col: number, + row: number, + ): GhosttySelectionRange | null { + this.ensureActive(); + const optionsLayout = this.runtime.layout(optionsName); + const selectionLayout = this.runtime.layout('GhosttySelection'); + const options = this.runtime.alloc(optionsLayout.size); + let ref = 0; + let selection = 0; + let range: GhosttySelectionRange | null = null; + try { + this.runtime.setField(options, optionsName, 'size', optionsLayout.size); + ref = this.gridRef(col, row); + const refField = optionsLayout.fields.ref!; + this.runtime + .bytes(options + refField.offset, refField.size) + .set(this.runtime.bytes(ref, refField.size)); + selection = this.runtime.alloc(selectionLayout.size); + this.runtime.setField(selection, 'GhosttySelection', 'size', selectionLayout.size); + const result = this.runtime.call(operation, this.terminal, options, selection); + if (result === GHOSTTY_SUCCESS) { + const start = selection + selectionLayout.fields.start!.offset; + const end = selection + selectionLayout.fields.end!.offset; + const viewportStart = this.pointFromGridRef(start, 1); + const viewportEnd = this.pointFromGridRef(end, 1); + const screenStart = this.pointFromGridRef(start, 2); + const screenEnd = this.pointFromGridRef(end, 2); + if (viewportStart && viewportEnd && screenStart && screenEnd) { + range = { + viewport: { start: viewportStart, end: viewportEnd }, + screen: { start: screenStart, end: screenEnd }, + }; + } + this.runtime.call('ghostty_terminal_set', this.terminal, 21, selection); + } + } finally { + this.runtime.free(selection, selectionLayout.size); + this.runtime.free(ref, this.runtime.layout('GhosttyGridRef').size); + this.runtime.free(options, optionsLayout.size); + } + return range; + } + + private pointFromGridRef(ref: number, tag: 1 | 2): { x: number; y: number } | null { + const coordinateLayout = this.runtime.layout('GhosttyPointCoordinate'); + const coordinate = this.runtime.alloc(coordinateLayout.size); + const result = this.runtime.call( + 'ghostty_terminal_point_from_grid_ref', + this.terminal, + ref, + tag, + coordinate, + ); + const point = + result === GHOSTTY_SUCCESS + ? { + x: this.runtime.readField(coordinate, 'GhosttyPointCoordinate', 'x'), + y: this.runtime.readField(coordinate, 'GhosttyPointCoordinate', 'y'), + } + : null; + this.runtime.free(coordinate, coordinateLayout.size); + return point; + } + + private getU16(data: number): number { + this.runtime.bytes(this.scratch, 2).fill(0); + this.assertSuccess( + 'ghostty_render_state_get', + this.runtime.call('ghostty_render_state_get', this.renderState, data, this.scratch), + ); + return this.runtime.view(this.scratch, 2).getUint16(0, true); + } + + private getU32(data: number): number { + this.runtime.bytes(this.scratch, 4).fill(0); + this.assertSuccess( + 'ghostty_render_state_get', + this.runtime.call('ghostty_render_state_get', this.renderState, data, this.scratch), + ); + return this.runtime.view(this.scratch, 4).getUint32(0, true); + } + + private getBool(data: number): boolean { + this.runtime.bytes(this.scratch, 1)[0] = 0; + this.assertSuccess( + 'ghostty_render_state_get', + this.runtime.call('ghostty_render_state_get', this.renderState, data, this.scratch), + ); + return this.runtime.bytes(this.scratch, 1)[0] !== 0; + } + + private getColor(data: number, fallback: GhosttyColor): GhosttyColor { + this.runtime.bytes(this.scratch, 3).fill(0); + const result = this.runtime.call( + 'ghostty_render_state_get', + this.renderState, + data, + this.scratch, + ); + return result === GHOSTTY_SUCCESS ? this.readColor(this.scratch) : fallback; + } + + private getRowBool(iterator: number, data: number): boolean { + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call('ghostty_render_state_row_get', iterator, data, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + private getCellU32(iterator: number, data: number): number { + this.runtime.bytes(this.scratch, 4).fill(0); + const result = this.runtime.call( + 'ghostty_render_state_row_cells_get', + iterator, + data, + this.scratch, + ); + return result === GHOSTTY_SUCCESS ? this.runtime.view(this.scratch, 4).getUint32(0, true) : 0; + } + + private getCellBool(iterator: number, data: number): boolean { + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call('ghostty_render_state_row_cells_get', iterator, data, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + private getCellColor(iterator: number, data: number, fallback: GhosttyColor): GhosttyColor { + this.runtime.bytes(this.scratch, 3).fill(0); + const result = this.runtime.call( + 'ghostty_render_state_row_cells_get', + iterator, + data, + this.scratch, + ); + return result === GHOSTTY_SUCCESS ? this.readColor(this.scratch) : fallback; + } + + private readColor(pointer: number): GhosttyColor { + const bytes = this.runtime.bytes(pointer, 3); + return { r: bytes[0] ?? 0, g: bytes[1] ?? 0, b: bytes[2] ?? 0 }; + } + + private emptyCell(foreground: GhosttyColor, background: GhosttyColor): GhosttyCell { + return { + text: '', + wide: 0, + foreground, + background, + bold: false, + italic: false, + invisible: false, + strikethrough: false, + overline: false, + underline: false, + selected: false, + }; + } + + private assertSuccess(operation: string, result: number): void { + if (result !== GHOSTTY_SUCCESS) throw new Error(`${operation} failed with result ${result}`); + } + + private ensureActive(): void { + if (this.disposed) throw new Error('libghostty-vt terminal has been disposed'); + } +} + +export function ghosttyColorsEqual(left: GhosttyColor, right: GhosttyColor): boolean { + return sameColor(left, right); +} diff --git a/packages/ui/src/lib/ghostty/fonts.test.ts b/packages/ui/src/lib/ghostty/fonts.test.ts new file mode 100644 index 00000000..f7b9f2a0 --- /dev/null +++ b/packages/ui/src/lib/ghostty/fonts.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'bun:test'; + +import { areFontAdvancesMonospace, canvasFontFamilies, quoteFontFamilyName } from './fonts'; + +describe('canvasFontFamilies', () => { + test('quotes names the canvas shorthand would reject and drops engine-specific generics', () => { + expect(canvasFontFamilies('ui-monospace, JetBrains Mono, "Fira Code", Menlo, monospace')) + .toBe('"JetBrains Mono", "Fira Code", Menlo, monospace'); + expect(canvasFontFamilies('ui-monospace')).toBeNull(); + expect(canvasFontFamilies('')).toBeNull(); + }); + + test('keeps already quoted and single-ident names as they are', () => { + expect(quoteFontFamilyName('"3270 Nerd Font"')).toBe('"3270 Nerd Font"'); + expect(quoteFontFamilyName('Menlo')).toBe('Menlo'); + expect(quoteFontFamilyName('M+ 1m')).toBe('"M+ 1m"'); + }); +}); + +describe('areFontAdvancesMonospace', () => { + test('accepts equal advances and treats unmeasurable input as monospace', () => { + expect(areFontAdvancesMonospace([7.2, 7.2, 7.2])).toBe(true); + expect(areFontAdvancesMonospace([7.2, 9.1, 7.2])).toBe(false); + expect(areFontAdvancesMonospace([])).toBe(true); + expect(areFontAdvancesMonospace([0, 0])).toBe(true); + }); +}); diff --git a/packages/ui/src/lib/ghostty/fonts.ts b/packages/ui/src/lib/ghostty/fonts.ts new file mode 100644 index 00000000..f4ba5f71 --- /dev/null +++ b/packages/ui/src/lib/ghostty/fonts.ts @@ -0,0 +1,73 @@ +// Adapted from T3 Code's libghostty-vt browser adapter (MIT, T3 Tools Inc.). +// See LICENSE-T3CODE in this directory. + +const MONOSPACE_PROBE_VARIANTS = ['normal 400', 'normal 700', 'italic 400', 'italic 700'] as const; +const MONOSPACE_PROBE_GLYPHS = ['i', 'M', 'W', '0', '@', '#', '.', ' '] as const; +const MONOSPACE_ADVANCE_TOLERANCE = 0.01; +// Generic keywords the canvas font shorthand parser does not accept in every +// engine (Chromium rejects ui-monospace outright, which silently voids the +// whole assignment). The concrete platform faces cover the same intent. +const UNSUPPORTED_CANVAS_GENERICS = /^(ui-monospace|ui-sans-serif|ui-serif|system-ui)$/i; + +export function quoteFontFamilyName(name: string): string { + const bare = name.trim(); + if (bare.length === 0) return ''; + // Already quoted, or a single ident that needs no quoting. + if (/^(['"]).*\1$/.test(bare)) return bare; + if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(bare)) return bare; + return `"${bare.replaceAll('"', '')}"`; +} + +/** + * Normalize a family list into a canvas-safe CSS font-family list, or null + * when nothing usable remains. Quotes names the shorthand would reject and + * drops generics that only some engines know. + */ +export function canvasFontFamilies(input: string): string | null { + const families = input + .split(',') + .map(quoteFontFamilyName) + .filter((name) => name.length > 0 && !UNSUPPORTED_CANVAS_GENERICS.test(name)); + return families.length > 0 ? families.join(', ') : null; +} + +export function areFontAdvancesMonospace(advances: readonly number[]): boolean { + const reference = advances[0]; + if ( + reference === undefined || + reference <= 0 || + advances.some((advance) => !Number.isFinite(advance) || advance <= 0) + ) { + return true; + } + return advances.every((advance) => Math.abs(advance - reference) < MONOSPACE_ADVANCE_TOLERANCE); +} + +let fontProbeContext: CanvasRenderingContext2D | null | undefined; + +/** + * Whether a family renders every character on the same advance. The cell grid + * requires this: a proportional face draws its text narrower than its own + * cells and strands the cursor. + */ +export function isMonospaceFamily(family: string): boolean { + const families = canvasFontFamilies(family); + if (families === null) return true; + try { + if (fontProbeContext === undefined) { + fontProbeContext = document.createElement('canvas').getContext('2d'); + } + if (fontProbeContext === null) return true; + const context = fontProbeContext; + // Fall back to a generic mono so an absent face measures as monospace and + // is left for the normal fallback chain to resolve. + for (const variant of MONOSPACE_PROBE_VARIANTS) { + context.font = `${variant} 32px ${families}, monospace`; + const advances = MONOSPACE_PROBE_GLYPHS.map((glyph) => context.measureText(glyph).width); + if (!areFontAdvancesMonospace(advances)) return false; + } + return true; + } catch { + return true; + } +} diff --git a/packages/ui/src/lib/ghostty/fonts/LICENSE b/packages/ui/src/lib/ghostty/fonts/LICENSE new file mode 100644 index 00000000..06eb073d --- /dev/null +++ b/packages/ui/src/lib/ghostty/fonts/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Ryan L McIntyre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/ui/src/lib/ghostty/fonts/SymbolsNerdFontMono-Regular.woff2 b/packages/ui/src/lib/ghostty/fonts/SymbolsNerdFontMono-Regular.woff2 new file mode 100644 index 00000000..1e2127fa Binary files /dev/null and b/packages/ui/src/lib/ghostty/fonts/SymbolsNerdFontMono-Regular.woff2 differ diff --git a/packages/ui/src/lib/ghostty/keyCodes.test.ts b/packages/ui/src/lib/ghostty/keyCodes.test.ts new file mode 100644 index 00000000..2ffd8027 --- /dev/null +++ b/packages/ui/src/lib/ghostty/keyCodes.test.ts @@ -0,0 +1,67 @@ +// Adapted from T3 Code's libghostty-vt browser adapter tests (MIT, T3 Tools Inc.). +// See LICENSE-T3CODE in this directory. +import { describe, expect, test } from 'bun:test'; + +import { ghosttyConsumedMods, ghosttyKeyForCode, ghosttyUnshiftedCodepoint } from './keyCodes'; + +describe('ghosttyKeyForCode', () => { + test('keeps the tail of the pinned Ghostty key enum in order', () => { + expect(ghosttyKeyForCode('F25')).toBe(ghosttyKeyForCode('F24') + 1); + expect(ghosttyKeyForCode('PrintScreen')).toBe(ghosttyKeyForCode('FnLock') + 1); + expect(ghosttyKeyForCode('Pause')).toBe(ghosttyKeyForCode('ScrollLock') + 1); + expect(ghosttyKeyForCode('Paste')).toBe(ghosttyKeyForCode('Cut') + 1); + }); +}); + +describe('ghosttyConsumedMods', () => { + const shifted = { altKey: false, ctrlKey: false, key: '@', metaKey: false, shiftKey: true }; + + test('only consumes a lone Shift producing a character', () => { + expect(ghosttyConsumedMods(shifted)).toBe(1); + expect(ghosttyConsumedMods({ ...shifted, ctrlKey: true })).toBe(0); + expect(ghosttyConsumedMods({ ...shifted, key: 'Tab' })).toBe(0); + // Deliberate: Shift+Space collapses to Space so it still types one. + expect(ghosttyConsumedMods({ ...shifted, key: ' ' })).toBe(1); + }); +}); + +describe('ghosttyUnshiftedCodepoint', () => { + test('provides the logical base character for Kitty keyboard encoding', () => { + expect(ghosttyUnshiftedCodepoint({ code: 'KeyC', key: 'c', shiftKey: false })).toBe( + 'c'.codePointAt(0), + ); + expect(ghosttyUnshiftedCodepoint({ code: 'KeyC', key: 'C', shiftKey: true })).toBe( + 'c'.codePointAt(0), + ); + expect(ghosttyUnshiftedCodepoint({ code: 'Digit1', key: '!', shiftKey: true })).toBe( + '1'.codePointAt(0), + ); + expect(ghosttyUnshiftedCodepoint({ code: 'Slash', key: '?', shiftKey: true })).toBe( + '/'.codePointAt(0), + ); + expect(ghosttyUnshiftedCodepoint({ code: 'Digit1', key: '&', shiftKey: false })).toBe( + '&'.codePointAt(0), + ); + expect(ghosttyUnshiftedCodepoint({ code: 'Enter', key: 'Enter', shiftKey: false })).toBe(0); + }); + + test('reports unknown instead of the shifted character without layout data', () => { + expect(ghosttyUnshiftedCodepoint({ code: 'Digit7', key: '/', shiftKey: true })).toBe(0); + expect(ghosttyUnshiftedCodepoint({ code: 'KeyD', key: 'Д', shiftKey: true })).toBe( + 'д'.codePointAt(0), + ); + }); + + test('prefers the active browser layout over US physical key positions', () => { + const layoutMap = new Map([ + ['Digit1', '&'], + ['KeyC', 'j'], + ]); + expect(ghosttyUnshiftedCodepoint({ code: 'Digit1', key: '1', shiftKey: true }, layoutMap)).toBe( + '&'.codePointAt(0), + ); + expect(ghosttyUnshiftedCodepoint({ code: 'KeyC', key: 'J', shiftKey: true }, layoutMap)).toBe( + 'j'.codePointAt(0), + ); + }); +}); diff --git a/packages/ui/src/lib/ghostty/keyCodes.ts b/packages/ui/src/lib/ghostty/keyCodes.ts new file mode 100644 index 00000000..39ff2f64 --- /dev/null +++ b/packages/ui/src/lib/ghostty/keyCodes.ts @@ -0,0 +1,269 @@ +// Adapted from T3 Code's libghostty-vt browser adapter (MIT, T3 Tools Inc.). +// See LICENSE-T3CODE in this directory. + +// This order mirrors GhosttyKey in ghostty/vt/key/event.h. The values are +// intentionally derived from the official W3C-aligned enum instead of +// maintaining a second keyboard protocol. +const ghosttyKeyboardCodes = [ + 'Unidentified', + 'Backquote', + 'Backslash', + 'BracketLeft', + 'BracketRight', + 'Comma', + 'Digit0', + 'Digit1', + 'Digit2', + 'Digit3', + 'Digit4', + 'Digit5', + 'Digit6', + 'Digit7', + 'Digit8', + 'Digit9', + 'Equal', + 'IntlBackslash', + 'IntlRo', + 'IntlYen', + 'KeyA', + 'KeyB', + 'KeyC', + 'KeyD', + 'KeyE', + 'KeyF', + 'KeyG', + 'KeyH', + 'KeyI', + 'KeyJ', + 'KeyK', + 'KeyL', + 'KeyM', + 'KeyN', + 'KeyO', + 'KeyP', + 'KeyQ', + 'KeyR', + 'KeyS', + 'KeyT', + 'KeyU', + 'KeyV', + 'KeyW', + 'KeyX', + 'KeyY', + 'KeyZ', + 'Minus', + 'Period', + 'Quote', + 'Semicolon', + 'Slash', + 'AltLeft', + 'AltRight', + 'Backspace', + 'CapsLock', + 'ContextMenu', + 'ControlLeft', + 'ControlRight', + 'Enter', + 'MetaLeft', + 'MetaRight', + 'ShiftLeft', + 'ShiftRight', + 'Space', + 'Tab', + 'Convert', + 'KanaMode', + 'NonConvert', + 'Delete', + 'End', + 'Help', + 'Home', + 'Insert', + 'PageDown', + 'PageUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'NumLock', + 'Numpad0', + 'Numpad1', + 'Numpad2', + 'Numpad3', + 'Numpad4', + 'Numpad5', + 'Numpad6', + 'Numpad7', + 'Numpad8', + 'Numpad9', + 'NumpadAdd', + 'NumpadBackspace', + 'NumpadClear', + 'NumpadClearEntry', + 'NumpadComma', + 'NumpadDecimal', + 'NumpadDivide', + 'NumpadEnter', + 'NumpadEqual', + 'NumpadMemoryAdd', + 'NumpadMemoryClear', + 'NumpadMemoryRecall', + 'NumpadMemoryStore', + 'NumpadMemorySubtract', + 'NumpadMultiply', + 'NumpadParenLeft', + 'NumpadParenRight', + 'NumpadSubtract', + 'NumpadSeparator', + 'NumpadArrowUp', + 'NumpadArrowDown', + 'NumpadArrowRight', + 'NumpadArrowLeft', + 'NumpadBegin', + 'NumpadHome', + 'NumpadEnd', + 'NumpadInsert', + 'NumpadDelete', + 'NumpadPageUp', + 'NumpadPageDown', + 'Escape', + 'F1', + 'F2', + 'F3', + 'F4', + 'F5', + 'F6', + 'F7', + 'F8', + 'F9', + 'F10', + 'F11', + 'F12', + 'F13', + 'F14', + 'F15', + 'F16', + 'F17', + 'F18', + 'F19', + 'F20', + 'F21', + 'F22', + 'F23', + 'F24', + 'F25', + 'Fn', + 'FnLock', + 'PrintScreen', + 'ScrollLock', + 'Pause', + 'BrowserBack', + 'BrowserFavorites', + 'BrowserForward', + 'BrowserHome', + 'BrowserRefresh', + 'BrowserSearch', + 'BrowserStop', + 'Eject', + 'LaunchApp1', + 'LaunchApp2', + 'LaunchMail', + 'MediaPlayPause', + 'MediaSelect', + 'MediaStop', + 'MediaTrackNext', + 'MediaTrackPrevious', + 'Power', + 'Sleep', + 'AudioVolumeDown', + 'AudioVolumeMute', + 'AudioVolumeUp', + 'WakeUp', + 'Copy', + 'Cut', + 'Paste', +] as const; + +const codeToGhosttyKey = new Map( + ghosttyKeyboardCodes.map((code, index) => [code, index]), +); + +export function ghosttyKeyForCode(code: string): number { + return codeToGhosttyKey.get(code) ?? 0; +} + +export interface GhosttyKeyboardLayoutMap { + get(code: string): string | undefined; +} + +const shiftedToUnshiftedCharacter = new Map([ + ['!', '1'], + ['@', '2'], + ['#', '3'], + ['$', '4'], + ['%', '5'], + ['^', '6'], + ['&', '7'], + ['*', '8'], + ['(', '9'], + [')', '0'], + ['~', '`'], + ['_', '-'], + ['+', '='], + ['{', '['], + ['}', ']'], + ['|', '\\'], + [':', ';'], + ['"', "'"], + ['<', ','], + ['>', '.'], + ['?', '/'], +]); + +let keyboardLayoutMapPromise: Promise | undefined; + +export function loadGhosttyKeyboardLayoutMap(): Promise { + if (keyboardLayoutMapPromise) return keyboardLayoutMapPromise; + // SAFETY: navigator.keyboard is Chromium-only and absent from lib.dom; the + // optional field keeps every other engine on the undefined branch. + const keyboard = ( + globalThis.navigator as Navigator & { + readonly keyboard?: { getLayoutMap(): Promise }; + } | undefined + )?.keyboard; + const promise = keyboard?.getLayoutMap().catch(() => undefined) ?? Promise.resolve(undefined); + keyboardLayoutMapPromise = promise; + return promise; +} + +// Browsers do not expose consumed modifiers; treat Shift as consumed for +// unchorded character input. +export function ghosttyConsumedMods( + event: Pick, +): number { + if (!event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) return 0; + return [...event.key].length === 1 ? 1 : 0; +} + +export function ghosttyUnshiftedCodepoint( + event: Pick, + layoutMap?: GhosttyKeyboardLayoutMap, +): number { + if ([...event.key].length !== 1) return 0; + const layoutCharacter = layoutMap?.get(event.code); + if (layoutCharacter && [...layoutCharacter].length === 1) { + return layoutCharacter.codePointAt(0) ?? 0; + } + if (/^[A-Z]$/u.test(event.key)) return event.key.charCodeAt(0) + 32; + if (event.shiftKey) { + const unshiftedCharacter = shiftedToUnshiftedCharacter.get(event.key); + if (unshiftedCharacter) return unshiftedCharacter.codePointAt(0) ?? 0; + const lowercase = event.key.toLowerCase(); + if (lowercase !== event.key && [...lowercase].length === 1) { + return lowercase.codePointAt(0) ?? 0; + } + // Without layout data the unshifted form of a shifted key is unknowable; + // reporting the shifted character as unshifted corrupts Kitty alternate keys. + return 0; + } + return event.key.codePointAt(0) ?? 0; +} diff --git a/packages/ui/src/lib/ghostty/renderer.test.ts b/packages/ui/src/lib/ghostty/renderer.test.ts new file mode 100644 index 00000000..5355dee2 --- /dev/null +++ b/packages/ui/src/lib/ghostty/renderer.test.ts @@ -0,0 +1,324 @@ +// Adapted from T3 Code's libghostty-vt browser adapter tests (MIT, T3 Tools Inc.). +// See LICENSE-T3CODE in this directory. +import { describe, expect, test } from 'bun:test'; + +import { GHOSTTY_CELL_WIDE, type GhosttyCell, type GhosttySnapshot } from './core'; +import { + ghosttyTextRunEnd, + measureGhosttyCell, + renderGhosttySnapshot, + terminalGridSize, +} from './renderer'; + +const cell = (text: string, wide = 0): GhosttyCell => ({ + text, + wide, + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + bold: false, + italic: false, + invisible: false, + strikethrough: false, + overline: false, + underline: false, + selected: false, +}); + +describe('terminalGridSize', () => { + test("matches the mobile renderer's cell-and-padding sizing model", () => { + expect(terminalGridSize(808, 408, { width: 10, height: 20, baseline: 15 }, 4)).toEqual({ + cols: 80, + rows: 20, + }); + }); + + test('never sends an invalid zero-sized terminal to libghostty', () => { + expect(terminalGridSize(0, 0, { width: 10, height: 20, baseline: 15 }, 4)).toEqual({ + cols: 1, + rows: 1, + }); + }); +}); + +describe('measureGhosttyCell', () => { + test('uses descender-aware metrics and the mobile terminal line-height', () => { + const measureText = (text: string) => + text === 'M' + ? { width: 7.2, actualBoundingBoxAscent: 9, actualBoundingBoxDescent: 0 } + : { width: 14.4, actualBoundingBoxAscent: 9, actualBoundingBoxDescent: 3 }; + const context = { font: '', measureText }; + + expect(measureGhosttyCell(context, 12, 'monospace')).toEqual({ + width: 7.2, + height: 16, + baseline: 11, + }); + }); +}); + +describe('ghosttyTextRunEnd', () => { + test('includes wide spacer tails in the visual clip without rendering spaces', () => { + const cells = [ + cell('界', GHOSTTY_CELL_WIDE.wide), + cell('', GHOSTTY_CELL_WIDE.spacerTail), + cell('🙂', GHOSTTY_CELL_WIDE.wide), + cell('', GHOSTTY_CELL_WIDE.spacerTail), + cell(''), + ]; + expect(ghosttyTextRunEnd(cells, 0, () => true)).toBe(4); + }); +}); + +describe('renderGhosttySnapshot', () => { + test('underlines every cell in a hovered wrapped link', () => { + const fillRectCalls: number[][] = []; + const context = { + canvas: { width: 200, height: 80 }, + beginPath: () => {}, + clip: () => {}, + fillRect: (...args: number[]) => fillRectCalls.push(args), + fillText: () => {}, + rect: () => {}, + resetTransform: () => {}, + restore: () => {}, + save: () => {}, + fillStyle: '', + strokeStyle: '', + font: '', + textBaseline: 'alphabetic' as const, + strokeRect: () => {}, + lineWidth: 1, + lineCap: 'butt' as const, + moveTo: () => {}, + lineTo: () => {}, + quadraticCurveTo: () => {}, + closePath: () => {}, + fill: () => {}, + stroke: () => {}, + }; + const snapshot: GhosttySnapshot = { + cols: 4, + rows: 2, + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + cursorX: -1, + cursorY: -1, + cursorVisible: false, + cursorBlinking: false, + cursorStyle: 1, + dirtyRows: new Set([0, 1]), + rowData: [0, 1].map(() => ({ + cells: [cell('a'), cell('b'), cell('c'), cell('d')], + text: 'abcd', + isWrapContinuation: false, + wrapsToNext: false, + })), + }; + + renderGhosttySnapshot({ + context, + snapshot, + metrics: { width: 10, height: 20, baseline: 15 }, + fontSize: 12, + fontFamily: 'monospace', + padding: 4, + forceFull: false, + cursorOn: false, + hoveredLinkRange: { start: { x: 2, y: 0 }, end: { x: 1, y: 1 } }, + }); + + expect(fillRectCalls.filter(([, , , height]) => height === 1)).toEqual([ + [24, 22, 10, 1], + [34, 22, 10, 1], + [4, 42, 10, 1], + [14, 42, 10, 1], + ]); + }); + + test('constrains text runs and cursor glyphs to their terminal cells', () => { + const fillTextCalls: unknown[][] = []; + const context = { + canvas: { width: 200, height: 40 }, + beginPath: () => {}, + clip: () => {}, + fillRect: () => {}, + fillText: (...args: unknown[]) => fillTextCalls.push(args), + rect: () => {}, + resetTransform: () => {}, + restore: () => {}, + save: () => {}, + fillStyle: '', + strokeStyle: '', + font: '', + textBaseline: 'alphabetic' as const, + strokeRect: () => {}, + lineWidth: 1, + lineCap: 'butt' as const, + moveTo: () => {}, + lineTo: () => {}, + quadraticCurveTo: () => {}, + closePath: () => {}, + fill: () => {}, + stroke: () => {}, + }; + const cells = [cell('a'), cell('b'), cell('x')]; + const snapshot: GhosttySnapshot = { + cols: 3, + rows: 1, + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + cursorX: 2, + cursorY: 0, + cursorVisible: true, + cursorBlinking: false, + cursorStyle: 1, + dirtyRows: new Set([0]), + rowData: [{ cells, text: 'abx', isWrapContinuation: false, wrapsToNext: false }], + }; + + renderGhosttySnapshot({ + context, + snapshot, + metrics: { width: 7.2, height: 16, baseline: 11 }, + fontSize: 12, + fontFamily: 'monospace', + padding: 4, + forceFull: false, + cursorOn: true, + }); + + expect(fillTextCalls).toEqual([ + ['abx', 4, 15, 21.6], + ['x', 18.4, 15, 7.2], + ]); + }); + + test('repaints the cell without an overlay during the blink off phase', () => { + const fillTextCalls: unknown[][] = []; + const context = { + canvas: { width: 200, height: 40 }, + beginPath: () => {}, + clip: () => {}, + fillRect: () => {}, + fillText: (...args: unknown[]) => fillTextCalls.push(args), + rect: () => {}, + resetTransform: () => {}, + restore: () => {}, + save: () => {}, + fillStyle: '', + strokeStyle: '', + font: '', + textBaseline: 'alphabetic' as const, + strokeRect: () => {}, + lineWidth: 1, + lineCap: 'butt' as const, + moveTo: () => {}, + lineTo: () => {}, + quadraticCurveTo: () => {}, + closePath: () => {}, + fill: () => {}, + stroke: () => {}, + }; + const snapshot: GhosttySnapshot = { + cols: 3, + rows: 1, + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + cursorX: 2, + cursorY: 0, + cursorVisible: true, + cursorBlinking: true, + cursorStyle: 1, + dirtyRows: new Set(), + rowData: [ + { + cells: [cell('a'), cell('b'), cell('x')], + text: 'abx', + isWrapContinuation: false, + wrapsToNext: false, + }, + ], + }; + + renderGhosttySnapshot({ + context, + snapshot, + metrics: { width: 7.2, height: 16, baseline: 11 }, + fontSize: 12, + fontFamily: 'monospace', + padding: 4, + forceFull: false, + cursorOn: false, + }); + + // The cursor row still repaints so the block disappears, but the inverted + // glyph the on phase draws over the cell is gone. + expect(fillTextCalls).toEqual([['abx', 4, 15, 21.6]]); + }); + + test('repaints the previous cursor row after the cursor moves', () => { + const clearedRows: number[] = []; + const context = { + canvas: { width: 200, height: 80 }, + beginPath: () => {}, + clip: () => {}, + fillRect: (_left: number, top: number, _width: number, height: number) => { + if (height === 16) clearedRows.push(top); + }, + fillText: () => {}, + rect: () => {}, + resetTransform: () => {}, + restore: () => {}, + save: () => {}, + fillStyle: '', + strokeStyle: '', + font: '', + textBaseline: 'alphabetic' as const, + strokeRect: () => {}, + lineWidth: 1, + lineCap: 'butt' as const, + moveTo: () => {}, + lineTo: () => {}, + quadraticCurveTo: () => {}, + closePath: () => {}, + fill: () => {}, + stroke: () => {}, + }; + const snapshot: GhosttySnapshot = { + cols: 1, + rows: 3, + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + cursorX: 0, + cursorY: 2, + cursorVisible: true, + cursorBlinking: false, + cursorStyle: 1, + dirtyRows: new Set(), + rowData: [0, 1, 2].map(() => ({ + cells: [cell('')], + text: '', + isWrapContinuation: false, + wrapsToNext: false, + })), + }; + + renderGhosttySnapshot({ + context, + snapshot, + metrics: { width: 7.2, height: 16, baseline: 11 }, + fontSize: 12, + fontFamily: 'monospace', + padding: 4, + forceFull: false, + cursorOn: true, + previousCursorY: 0, + }); + + expect(clearedRows).toEqual([4, 36, 36]); + }); +}); diff --git a/packages/ui/src/lib/ghostty/renderer.ts b/packages/ui/src/lib/ghostty/renderer.ts new file mode 100644 index 00000000..c9f19886 --- /dev/null +++ b/packages/ui/src/lib/ghostty/renderer.ts @@ -0,0 +1,331 @@ +// Adapted from T3 Code's libghostty-vt browser adapter (MIT, T3 Tools Inc.). +// See LICENSE-T3CODE in this directory. + +import { + GHOSTTY_CELL_WIDE, + ghosttyColorsEqual, + type GhosttyCell, + type GhosttyColor, + type GhosttySnapshot, +} from './core'; +import { drawBoxDrawingGlyph, isBoxDrawingText, type BoxDrawingContext } from './boxDrawing'; + +/** The canvas operations the renderer uses; a CanvasRenderingContext2D satisfies it structurally. */ +export interface GhosttyRenderContext extends BoxDrawingContext { + readonly canvas: { readonly width: number; readonly height: number }; + font: string; + textBaseline: CanvasTextBaseline; + fillRect(x: number, y: number, w: number, h: number): void; + strokeRect(x: number, y: number, w: number, h: number): void; + fillText(text: string, x: number, y: number, maxWidth?: number): void; + save(): void; + restore(): void; + beginPath(): void; + rect(x: number, y: number, w: number, h: number): void; + clip(): void; + resetTransform(): void; +} + +export interface GhosttyMeasureContext { + font: string; + measureText(text: string): { + readonly width: number; + readonly actualBoundingBoxAscent: number; + readonly actualBoundingBoxDescent: number; + }; +} + +export interface GhosttyCellMetrics { + readonly width: number; + readonly height: number; + readonly baseline: number; +} + +export interface GhosttyCellRange { + readonly start: { readonly x: number; readonly y: number }; + readonly end: { readonly x: number; readonly y: number }; +} + +const DEFAULT_SELECTION_BACKGROUND = 'rgba(72, 122, 191, 0.35)'; + +function cssColor(color: GhosttyColor): string { + return `rgb(${color.r}, ${color.g}, ${color.b})`; +} + +function sameTextStyle(left: GhosttyCell, right: GhosttyCell): boolean { + // Selection deliberately does not participate: it only tints the background + // overlay, and splitting a text run at a selection boundary visibly shifts + // glyph spacing whenever the face's true advance differs from the cell width. + return ( + ghosttyColorsEqual(left.foreground, right.foreground) && + left.bold === right.bold && + left.italic === right.italic && + left.invisible === right.invisible + ); +} + +export function ghosttyTextRunEnd( + cells: readonly GhosttyCell[], + start: number, + sameStyle: (cell: GhosttyCell) => boolean, +): number { + let end = start + 1; + while (end < cells.length) { + const next = cells[end]; + if (!next) break; + if (next.wide === GHOSTTY_CELL_WIDE.spacerTail) { + end += 1; + continue; + } + if (next.text.length === 0 || !sameStyle(next)) break; + end += 1; + } + return end; +} + +function fontForCell(cell: GhosttyCell, fontSize: number, fontFamily: string): string { + const style = cell.italic ? 'italic' : 'normal'; + const weight = cell.bold ? '700' : '400'; + return `${style} ${weight} ${fontSize}px ${fontFamily}`; +} + +export function measureGhosttyCell( + context: GhosttyMeasureContext, + fontSize: number, + fontFamily: string, +): GhosttyCellMetrics { + context.font = `normal 400 ${fontSize}px ${fontFamily}`; + const widthMeasurement = context.measureText('M'); + const verticalMeasurement = context.measureText('Mg'); + const ascent = verticalMeasurement.actualBoundingBoxAscent || fontSize; + const descent = verticalMeasurement.actualBoundingBoxDescent; + const glyphHeight = ascent + descent; + const height = Math.max(1, Math.round(fontSize * 1.35), Math.ceil(glyphHeight)); + return { + width: Math.max(1, widthMeasurement.width), + height, + baseline: Math.round((height - glyphHeight) / 2 + ascent), + }; +} + +export interface GhosttyGridSize { + readonly cols: number; + readonly rows: number; +} + +export function terminalGridSize( + width: number, + height: number, + metrics: GhosttyCellMetrics, + padding: number, +): GhosttyGridSize { + return { + cols: Math.max(1, Math.floor((width - padding * 2) / metrics.width)), + rows: Math.max(1, Math.floor((height - padding * 2) / metrics.height)), + }; +} + +export function renderGhosttySnapshot(options: { + readonly context: GhosttyRenderContext; + readonly snapshot: GhosttySnapshot; + readonly metrics: GhosttyCellMetrics; + readonly fontSize: number; + readonly fontFamily: string; + readonly padding: number; + readonly forceFull: boolean; + readonly cursorOn: boolean; + readonly previousCursorY?: number | null; + readonly focused?: boolean; + readonly selectionBackground?: string; + readonly hoveredLinkRange?: GhosttyCellRange | null; + /** Vertical origin of row 0; defaults to the horizontal padding. */ + readonly originY?: number; +}): void { + const { + context, + snapshot, + metrics, + fontSize, + fontFamily, + padding, + forceFull, + cursorOn, + previousCursorY, + } = options; + const focused = options.focused ?? true; + const selectionBackground = options.selectionBackground ?? DEFAULT_SELECTION_BACKGROUND; + const hoveredLinkRange = options.hoveredLinkRange ?? null; + const originY = options.originY ?? padding; + const rowsToDraw = forceFull + ? Array.from({ length: snapshot.rows }, (_, index) => index) + : [...snapshot.dirtyRows]; + if ( + previousCursorY !== null && + previousCursorY !== undefined && + previousCursorY >= 0 && + !rowsToDraw.includes(previousCursorY) + ) { + rowsToDraw.push(previousCursorY); + } + if (snapshot.cursorVisible && snapshot.cursorY >= 0 && !rowsToDraw.includes(snapshot.cursorY)) { + rowsToDraw.push(snapshot.cursorY); + } + + if (forceFull) { + context.save(); + context.resetTransform(); + context.fillStyle = cssColor(snapshot.background); + context.fillRect(0, 0, context.canvas.width, context.canvas.height); + context.restore(); + } + + context.textBaseline = 'alphabetic'; + for (const rowIndex of rowsToDraw) { + const row = snapshot.rowData[rowIndex]; + if (!row) continue; + const top = originY + rowIndex * metrics.height; + + context.fillStyle = cssColor(snapshot.background); + context.fillRect(padding, top, snapshot.cols * metrics.width, metrics.height); + + let backgroundStart = 0; + while (backgroundStart < row.cells.length) { + const first = row.cells[backgroundStart]; + if (!first) break; + let backgroundEnd = backgroundStart + 1; + while (backgroundEnd < row.cells.length) { + const next = row.cells[backgroundEnd]; + if ( + !next || + next.selected !== first.selected || + !ghosttyColorsEqual(next.background, first.background) + ) { + break; + } + backgroundEnd += 1; + } + if (first.selected || !ghosttyColorsEqual(first.background, snapshot.background)) { + const left = padding + backgroundStart * metrics.width; + const width = (backgroundEnd - backgroundStart) * metrics.width; + if (!ghosttyColorsEqual(first.background, snapshot.background)) { + context.fillStyle = cssColor(first.background); + context.fillRect(left, top, width, metrics.height); + } + if (first.selected) { + context.fillStyle = selectionBackground; + context.fillRect(left, top, width, metrics.height); + } + } + backgroundStart = backgroundEnd; + } + + let runStart = 0; + while (runStart < row.cells.length) { + const first = row.cells[runStart]; + if (!first) break; + if (first.text.length === 0) { + runStart += 1; + continue; + } + // Borders, bars and block logos are drawn to the exact cell instead of + // through the font, whose glyphs leave a gap at the terminal line height. + if (isBoxDrawingText(first.text)) { + if (!first.invisible) { + drawBoxDrawingGlyph( + context, + first.text, + { x: padding + runStart * metrics.width, y: top, width: metrics.width, height: metrics.height }, + first.foreground, + ); + } + runStart += 1; + continue; + } + const runEnd = ghosttyTextRunEnd( + row.cells, + runStart, + (cell) => sameTextStyle(cell, first) && !isBoxDrawingText(cell.text), + ); + const text = row.cells + .slice(runStart, runEnd) + .map((cell) => cell.text) + .join(''); + if (!first.invisible && text.trim().length > 0) { + context.save(); + context.beginPath(); + context.rect( + padding + runStart * metrics.width, + top, + (runEnd - runStart) * metrics.width, + metrics.height, + ); + context.clip(); + context.font = fontForCell(first, fontSize, fontFamily); + context.fillStyle = cssColor(first.foreground); + context.fillText( + text, + padding + runStart * metrics.width, + top + metrics.baseline, + (runEnd - runStart) * metrics.width, + ); + context.restore(); + } + runStart = runEnd; + } + + for (let column = 0; column < row.cells.length; column += 1) { + const cell = row.cells[column]; + const hoveredLink = + hoveredLinkRange !== null && + rowIndex >= hoveredLinkRange.start.y && + rowIndex <= hoveredLinkRange.end.y && + (rowIndex > hoveredLinkRange.start.y || column >= hoveredLinkRange.start.x) && + (rowIndex < hoveredLinkRange.end.y || column <= hoveredLinkRange.end.x); + if (!cell || (!cell.underline && !cell.strikethrough && !cell.overline && !hoveredLink)) { + continue; + } + context.fillStyle = cssColor(cell.foreground); + const left = padding + column * metrics.width; + if (cell.underline || hoveredLink) { + context.fillRect(left, top + metrics.height - 2, metrics.width, 1); + } + if (cell.strikethrough) { + context.fillRect(left, top + Math.floor(metrics.height * 0.55), metrics.width, 1); + } + if (cell.overline) context.fillRect(left, top + 1, metrics.width, 1); + } + } + + if (cursorOn && snapshot.cursorVisible && snapshot.cursorX >= 0 && snapshot.cursorY >= 0) { + const left = padding + snapshot.cursorX * metrics.width; + const top = originY + snapshot.cursorY * metrics.height; + context.fillStyle = cssColor(snapshot.cursor); + if (!focused) { + // An unfocused terminal draws a hollow cursor so the active pane is obvious. + context.strokeStyle = cssColor(snapshot.cursor); + context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, metrics.height - 1); + } else if (snapshot.cursorStyle === 0) { + context.fillRect(left, top, 2, metrics.height); + } else if (snapshot.cursorStyle === 2) { + context.fillRect(left, top + metrics.height - 2, metrics.width, 2); + } else if (snapshot.cursorStyle === 3) { + context.strokeStyle = cssColor(snapshot.cursor); + context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, metrics.height - 1); + } else { + context.fillRect(left, top, metrics.width, metrics.height); + const cell = snapshot.rowData[snapshot.cursorY]?.cells[snapshot.cursorX]; + if (cell?.text && isBoxDrawingText(cell.text)) { + drawBoxDrawingGlyph( + context, + cell.text, + { x: left, y: top, width: metrics.width, height: metrics.height }, + snapshot.background, + ); + } else if (cell?.text) { + context.font = fontForCell(cell, fontSize, fontFamily); + context.fillStyle = cssColor(snapshot.background); + context.fillText(cell.text, left, top + metrics.baseline, metrics.width); + } + } + } +} diff --git a/packages/ui/src/lib/ghostty/runtime.test.ts b/packages/ui/src/lib/ghostty/runtime.test.ts new file mode 100644 index 00000000..7730a07e --- /dev/null +++ b/packages/ui/src/lib/ghostty/runtime.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { loadGhosttyRuntime } from './runtime'; + +const vendorDir = join(dirname(fileURLToPath(import.meta.url)), 'vendor'); + +describe('vendored libghostty-vt WebAssembly', () => { + test('stays pinned to VERSION and inside the size budget', async () => { + const wasm = readFileSync(join(vendorDir, 'ghostty-vt.wasm')); + expect(wasm.byteLength).toBeLessThan(750_000); + + // The build embeds the pinned revision as semver build metadata, so VERSION + // is the single source of truth and drift between the two is caught here. + const runtime = await loadGhosttyRuntime(); + const out = runtime.alloc(8); + expect(runtime.call('ghostty_build_info', 10, out)).toBe(0); + const view = runtime.view(out, 8); + const embeddedRevision = new TextDecoder().decode( + runtime.bytes(view.getUint32(0, true), view.getUint32(4, true)), + ); + runtime.free(out, 8); + expect(embeddedRevision).toBe(readFileSync(join(vendorDir, 'VERSION'), 'utf8').trim()); + }); + + test('routes terminal replies through the embedded trampoline to the attached writer', async () => { + const runtime = await loadGhosttyRuntime(); + const optionsSize = runtime.layout('GhosttyTerminalOptions').size; + const options = runtime.alloc(optionsSize); + runtime.setField(options, 'GhosttyTerminalOptions', 'cols', 20); + runtime.setField(options, 'GhosttyTerminalOptions', 'rows', 4); + const slot = runtime.allocOpaque(); + expect(runtime.call('ghostty_terminal_new', 0, slot, options)).toBe(0); + runtime.free(options, optionsSize); + const terminal = runtime.readPointer(slot); + + const replies: string[] = []; + const writerId = runtime.attachPtyWriter(terminal, (data) => replies.push(data)); + const input = new TextEncoder().encode('\x1b[6n'); + const pointer = runtime.alloc(input.length); + runtime.bytes(pointer, input.length).set(input); + runtime.call('ghostty_terminal_vt_write', terminal, pointer, input.length); + runtime.free(pointer, input.length); + expect(replies).toEqual(['\x1b[1;1R']); + + runtime.detachPtyWriter(terminal, writerId); + runtime.call('ghostty_terminal_free', terminal); + runtime.freeOpaque(slot); + }); +}); diff --git a/packages/ui/src/lib/ghostty/runtime.ts b/packages/ui/src/lib/ghostty/runtime.ts new file mode 100644 index 00000000..ed789f62 --- /dev/null +++ b/packages/ui/src/lib/ghostty/runtime.ts @@ -0,0 +1,260 @@ +// Adapted from T3 Code's libghostty-vt browser adapter (MIT, T3 Tools Inc.). +// See LICENSE-T3CODE in this directory. + +type WasmFunction = (...args: Array) => number; + +interface TypeField { + readonly offset: number; + readonly size: number; + readonly type: string; +} + +interface TypeLayout { + readonly size: number; + readonly align: number; + readonly fields: Readonly>; +} + +type TypeLayouts = Readonly>; + +const textDecoder = new TextDecoder(); + +// The vendored artifact is fetched at runtime; `new URL` keeps this a plain +// static asset for Vite in every surface (web, VS Code webview, Electron's +// openchamber-ui:// protocol) and a readable file URL under bun's test runner. +const ghosttyWasmUrl = new URL('./vendor/ghostty-vt.wasm', import.meta.url); + +/** + * Compiled from scripts/ghostty-write-pty.zig: one exported function that + * forwards libghostty-vt's write-PTY callback to the `openchamber_write_pty` + * import. Embedding the 121 bytes avoids a second network fetch and any CSP + * question about data: URLs. + */ +const WRITE_PTY_TRAMPOLINE = Uint8Array.from([ + 0, 97, 115, 109, 1, 0, 0, 0, 1, 8, 1, 96, 4, 127, 127, 127, 127, 0, 2, 29, 1, 3, 101, 110, 118, + 21, 111, 112, 101, 110, 99, 104, 97, 109, 98, 101, 114, 95, 119, 114, 105, 116, 101, 95, 112, + 116, 121, 0, 0, 3, 2, 1, 0, 5, 3, 1, 0, 16, 6, 9, 1, 127, 1, 65, 128, 128, 192, 0, 11, 7, 30, 2, + 6, 109, 101, 109, 111, 114, 121, 2, 0, 17, 103, 104, 111, 115, 116, 116, 121, 95, 119, 114, + 105, 116, 101, 95, 112, 116, 121, 0, 1, 10, 18, 1, 16, 0, 32, 0, 32, 1, 32, 2, 32, 3, 16, 128, + 128, 128, 128, 0, 11, +]); + +export class GhosttyRuntime { + readonly memory: WebAssembly.Memory; + readonly layouts: TypeLayouts; + private readonly exports: WebAssembly.Exports; + private memoryView: DataView; + private readonly ptyWriters = new Map void>(); + private nextPtyWriterId = 1; + private writePtyFunctionIndex = 0; + + private constructor(instance: WebAssembly.Instance) { + this.exports = instance.exports; + const memory = instance.exports.memory; + if (!(memory instanceof WebAssembly.Memory)) { + throw new Error('libghostty-vt did not export WebAssembly memory'); + } + this.memory = memory; + this.memoryView = new DataView(memory.buffer); + const jsonPointer = this.call('ghostty_type_json'); + const bytes = new Uint8Array(memory.buffer); + let end = jsonPointer; + while (end < bytes.length && bytes[end] !== 0) end += 1; + // SAFETY: ghostty_type_json is generated by the pinned libghostty-vt build + // from its own C ABI structs; runtime.test.ts verifies the artifact matches + // VERSION, so the document has exactly this shape. + this.layouts = JSON.parse(textDecoder.decode(bytes.subarray(jsonPointer, end))) as TypeLayouts; + } + + static async load(bytes?: ArrayBuffer): Promise { + const wasmBytes = bytes ?? (await fetchGhosttyWasm()); + // The log import needs the instance's memory, which only exists once + // instantiation returns; the holder closes that loop. + const instanceHolder: { current: WebAssembly.Instance | null } = { current: null }; + const imports = { + env: { + log: (pointer: number, length: number) => { + const memory = instanceHolder.current?.exports.memory; + if (!(memory instanceof WebAssembly.Memory)) return; + const message = textDecoder.decode(new Uint8Array(memory.buffer, pointer, length)); + console.debug('[libghostty-vt]', message); + }, + }, + }; + const result = await WebAssembly.instantiate(wasmBytes, imports); + instanceHolder.current = result.instance; + const runtime = new GhosttyRuntime(result.instance); + await runtime.installWritePtyTrampoline(); + return runtime; + } + + call(name: string, ...args: Array): number { + const fn = this.exports[name]; + if (!(fn instanceof Function)) { + throw new Error(`libghostty-vt export is unavailable: ${name}`); + } + // SAFETY: every libghostty-vt export takes and returns wasm32 scalars (i32/i64). + return (fn as WasmFunction)(...args); + } + + layout(name: string): TypeLayout { + const layout = this.layouts[name]; + if (!layout) throw new Error(`libghostty-vt type layout is unavailable: ${name}`); + return layout; + } + + alloc(size: number): number { + const pointer = this.call('ghostty_wasm_alloc_u8_array', size); + if (pointer === 0) throw new Error(`libghostty-vt failed to allocate ${size} bytes`); + new Uint8Array(this.memory.buffer, pointer, size).fill(0); + return pointer; + } + + free(pointer: number, size: number): void { + if (pointer !== 0) this.call('ghostty_wasm_free_u8_array', pointer, size); + } + + allocOpaque(): number { + const pointer = this.call('ghostty_wasm_alloc_opaque'); + if (pointer === 0) throw new Error('libghostty-vt failed to allocate an opaque pointer'); + // The slot is uninitialized until a *_new call writes it; zero it so dispose + // paths that run after a partial initialization never free a garbage pointer. + new DataView(this.memory.buffer).setUint32(pointer, 0, true); + return pointer; + } + + freeOpaque(pointer: number): void { + if (pointer !== 0) this.call('ghostty_wasm_free_opaque', pointer); + } + + readPointer(slot: number): number { + return this.currentMemoryView().getUint32(slot, true); + } + + attachPtyWriter(terminal: number, writer: (data: string) => void): number { + if (this.writePtyFunctionIndex === 0) { + throw new Error('libghostty-vt PTY callback trampoline is unavailable'); + } + const id = this.nextPtyWriterId++; + this.ptyWriters.set(id, writer); + this.call('ghostty_terminal_set', terminal, 0, id); + this.call('ghostty_terminal_set', terminal, 1, this.writePtyFunctionIndex); + return id; + } + + detachPtyWriter(terminal: number, id: number): void { + this.call('ghostty_terminal_set', terminal, 1, 0); + this.call('ghostty_terminal_set', terminal, 0, 0); + this.ptyWriters.delete(id); + } + + view(pointer: number, size?: number): DataView { + return new DataView(this.memory.buffer, pointer, size); + } + + bytes(pointer: number, size: number): Uint8Array { + return new Uint8Array(this.memory.buffer, pointer, size); + } + + /** Reuse scalar reads across cells, refreshing after any terminal grows shared WASM memory. */ + private currentMemoryView(): DataView { + if (this.memoryView.buffer !== this.memory.buffer) { + this.memoryView = new DataView(this.memory.buffer); + } + return this.memoryView; + } + + setField(pointer: number, structName: string, fieldName: string, value: number): void { + const field = this.layout(structName).fields[fieldName]; + if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`); + const view = this.currentMemoryView(); + const offset = pointer + field.offset; + switch (field.type) { + case 'bool': + case 'u8': + view.setUint8(offset, value); + return; + case 'u16': + view.setUint16(offset, value, true); + return; + case 'i32': + view.setInt32(offset, value, true); + return; + case 'u32': + case 'enum': + view.setUint32(offset, value, true); + return; + case 'u64': + view.setBigUint64(offset, BigInt(value), true); + return; + default: + throw new Error(`Unsupported libghostty-vt field type: ${field.type}`); + } + } + + readField(pointer: number, structName: string, fieldName: string): number { + const field = this.layout(structName).fields[fieldName]; + if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`); + const view = this.currentMemoryView(); + const offset = pointer + field.offset; + switch (field.type) { + case 'bool': + case 'u8': + return view.getUint8(offset); + case 'u16': + return view.getUint16(offset, true); + case 'i32': + return view.getInt32(offset, true); + case 'u32': + case 'enum': + return view.getUint32(offset, true); + case 'u64': + return Number(view.getBigUint64(offset, true)); + default: + throw new Error(`Unsupported libghostty-vt field type: ${field.type}`); + } + } + + private async installWritePtyTrampoline(): Promise { + const result = await WebAssembly.instantiate(WRITE_PTY_TRAMPOLINE, { + env: { + openchamber_write_pty: (_terminal: number, userdata: number, pointer: number, length: number) => { + const writer = this.ptyWriters.get(userdata); + if (!writer || length === 0) return; + writer(textDecoder.decode(new Uint8Array(this.memory.buffer, pointer, length))); + }, + }, + }); + const trampoline = result.instance.exports.ghostty_write_pty; + const table = this.exports.__indirect_function_table; + if (!(trampoline instanceof Function) || !(table instanceof WebAssembly.Table)) { + throw new Error('libghostty-vt did not expose its callback table'); + } + const index = table.length; + // grow-then-set instead of grow(1, fn): WebKit stores a grow init value + // with broken type information and every later call_indirect through the + // entry traps with a signature mismatch. table.set canonicalizes correctly. + table.grow(1); + table.set(index, trampoline); + this.writePtyFunctionIndex = index; + } +} + +async function fetchGhosttyWasm(): Promise { + const response = await fetch(ghosttyWasmUrl); + if (!response.ok) { + throw new Error(`Unable to load libghostty-vt (${response.status})`); + } + return response.arrayBuffer(); +} + +let runtimePromise: Promise | null = null; + +/** One WebAssembly instance per page; every terminal owns and frees its own handles inside it. */ +export function loadGhosttyRuntime(): Promise { + runtimePromise ??= GhosttyRuntime.load().catch((error) => { + runtimePromise = null; + throw error; + }); + return runtimePromise; +} diff --git a/packages/ui/src/lib/ghostty/surface.test.ts b/packages/ui/src/lib/ghostty/surface.test.ts new file mode 100644 index 00000000..d38851bf --- /dev/null +++ b/packages/ui/src/lib/ghostty/surface.test.ts @@ -0,0 +1,179 @@ +// Adapted from T3 Code's libghostty-vt browser adapter tests (MIT, T3 Tools Inc.). +// See LICENSE-T3CODE in this directory. +import { describe, expect, test } from 'bun:test'; + +import type { GhosttyCell, GhosttyRow } from './core'; +import { + DEFAULT_TERMINAL_FONT_FAMILY, + advanceTerminalSelectionClickSequence, + isTerminalCopyShortcut, + isTerminalLinkPointerGesture, + isTerminalPasteShortcut, + resolveTerminalMouseData, + shouldBlinkTerminalCursor, + terminalContentOriginY, + terminalFontSize, + terminalGridCellAt, + terminalLinkAtPositionWithRange, + terminalScrollbarGeometry, + terminalScrollbarOffsetAtPointer, + terminalWheelArrowData, + terminalWheelDeltaRows, + loadTerminalFontFamily, +} from './surface'; + +const cell = (text: string): GhosttyCell => ({ + text, + wide: 0, + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + bold: false, + italic: false, + invisible: false, + strikethrough: false, + overline: false, + underline: false, + selected: false, +}); + +const row = (text: string, cols: number, flags: Partial> = {}): GhosttyRow => ({ + cells: Array.from({ length: cols }, (_, index) => cell([...text][index] ?? '')), + text: text.trimEnd(), + isWrapContinuation: flags.isWrapContinuation ?? false, + wrapsToNext: flags.wrapsToNext ?? false, +}); + +describe('terminalLinkAtPositionWithRange', () => { + test('reconstructs a URL soft-wrapped across two rows', () => { + const rows = [ + row('see https://open', 16, { wrapsToNext: true }), + row('chamber.dev/docs', 16, { isWrapContinuation: true }), + row('done', 16), + ]; + const link = terminalLinkAtPositionWithRange(rows, 1, 3); + expect(link).toEqual({ + text: 'https://openchamber.dev/docs', + range: { start: { x: 4, y: 0 }, end: { x: 15, y: 1 } }, + }); + }); + + test('refuses a link whose head scrolled above the viewport', () => { + const rows = [row('chamber.dev/docs', 16, { isWrapContinuation: true }), row('', 16)]; + expect(terminalLinkAtPositionWithRange(rows, 0, 2)).toBeNull(); + }); + + test('ignores plain text', () => { + expect(terminalLinkAtPositionWithRange([row('hello world', 16)], 0, 2)).toBeNull(); + }); +}); + +describe('terminal font resolution', () => { + test('keeps the glyph fallbacks behind a custom text face and drops canvas-hostile generics', async () => { + const loads: string[] = []; + const family = await loadTerminalFontFamily('ui-monospace, "JetBrains Mono", monospace', 13, { + load: (font) => { + loads.push(font); + return Promise.resolve(); + }, + resolve: (value) => `resolved:${value}`, + }); + expect(family).toBe('resolved:ui-monospace, "JetBrains Mono", monospace'); + expect(loads).toHaveLength(4); + expect(loads[0]?.startsWith('normal 400 13px "JetBrains Mono", monospace, "SF Mono"')).toBe(true); + expect(loads[0]).not.toContain('ui-monospace'); + }); + + test('clamps requested font sizes to the supported range', () => { + expect(terminalFontSize(undefined)).toBe(13); + expect(terminalFontSize(2)).toBe(6); + expect(terminalFontSize(99)).toBe(32); + expect(terminalFontSize(14.4)).toBe(14); + }); + + test('the default stack names only concrete faces plus the bundled symbols', () => { + expect(DEFAULT_TERMINAL_FONT_FAMILY).toContain('"Symbols Nerd Font Mono"'); + expect(DEFAULT_TERMINAL_FONT_FAMILY).not.toContain('ui-monospace'); + }); +}); + +describe('shortcuts and gestures', () => { + test('copy uses Cmd on macOS and Ctrl elsewhere, keeping Ctrl+C for SIGINT on macOS', () => { + expect(isTerminalCopyShortcut({ key: 'c', ctrlKey: true, metaKey: false, shiftKey: false }, 'MacIntel')).toBe(false); + expect(isTerminalCopyShortcut({ key: 'c', ctrlKey: false, metaKey: true, shiftKey: false }, 'MacIntel')).toBe(true); + expect(isTerminalCopyShortcut({ key: 'C', ctrlKey: true, metaKey: false, shiftKey: true }, 'Linux x86_64')).toBe(true); + }); + + test('paste uses Cmd+V on macOS, Ctrl+Shift+V or Shift+Insert elsewhere', () => { + expect(isTerminalPasteShortcut({ key: 'v', ctrlKey: false, metaKey: true, shiftKey: false }, 'MacIntel')).toBe(true); + expect(isTerminalPasteShortcut({ key: 'v', ctrlKey: true, metaKey: false, shiftKey: false }, 'Win32')).toBe(false); + expect(isTerminalPasteShortcut({ key: 'v', ctrlKey: true, metaKey: false, shiftKey: true }, 'Win32')).toBe(true); + expect(isTerminalPasteShortcut({ key: 'Insert', ctrlKey: false, metaKey: false, shiftKey: true }, 'Win32')).toBe(true); + }); + + test('link activation uses Command on macOS and Control elsewhere', () => { + expect(isTerminalLinkPointerGesture({ ctrlKey: false, metaKey: true }, 'MacIntel')).toBe(true); + expect(isTerminalLinkPointerGesture({ ctrlKey: true, metaKey: false }, 'MacIntel')).toBe(false); + expect(isTerminalLinkPointerGesture({ ctrlKey: true, metaKey: false }, 'Linux x86_64')).toBe(true); + }); + + test('recognizes stationary double and triple presses and restarts after movement', () => { + const first = advanceTerminalSelectionClickSequence(null, { clientX: 10, clientY: 10, timeStamp: 0 }); + const second = advanceTerminalSelectionClickSequence(first, { clientX: 11, clientY: 10, timeStamp: 200 }); + const third = advanceTerminalSelectionClickSequence(second, { clientX: 11, clientY: 11, timeStamp: 400 }); + expect([first.count, second.count, third.count]).toEqual([1, 2, 3]); + expect(advanceTerminalSelectionClickSequence(third, { clientX: 11, clientY: 11, timeStamp: 600 }).count).toBe(1); + expect(advanceTerminalSelectionClickSequence(second, { clientX: 40, clientY: 10, timeStamp: 500 }).count).toBe(1); + }); + + test('drops repeated motion reports until another action resets the cell', () => { + const motion = resolveTerminalMouseData('motion', '\x1b[<35;3;4M', ''); + expect(motion.send).toBe(true); + expect(resolveTerminalMouseData('motion', '\x1b[<35;3;4M', motion.nextMotionData).send).toBe(false); + const press = resolveTerminalMouseData('press', '\x1b[<0;3;4M', motion.nextMotionData); + expect(press).toEqual({ send: true, nextMotionData: '' }); + }); +}); + +describe('wheel scrolling', () => { + test('converts line and page deltas into rows and accumulates fractional pixels', () => { + expect(terminalWheelDeltaRows({ deltaY: 3, deltaMode: 1 }, 16, 24, 0)).toEqual({ rows: 3, remainder: 0 }); + expect(terminalWheelDeltaRows({ deltaY: -1, deltaMode: 2 }, 16, 24, 0)).toEqual({ rows: -24, remainder: 0 }); + const partial = terminalWheelDeltaRows({ deltaY: 10, deltaMode: 0 }, 16, 24, 0); + expect(partial.rows).toBe(0); + expect(terminalWheelDeltaRows({ deltaY: 10, deltaMode: 0 }, 16, 24, partial.remainder).rows).toBe(1); + }); + + test('emits one arrow per row honoring application cursor keys', () => { + expect(terminalWheelArrowData(-2, false)).toBe('\x1b[A\x1b[A'); + expect(terminalWheelArrowData(1, true)).toBe('\x1bOB'); + expect(terminalWheelArrowData(0, false)).toBe(''); + }); +}); + +describe('layout helpers', () => { + test('anchors the grid to the bottom only once scrollback exists', () => { + expect(terminalContentOriginY(100, 4, 5, 16, false)).toBe(4); + expect(terminalContentOriginY(100, 4, 5, 16, true)).toBe(16); + }); + + test('maps points inside the rendered grid without clamping its padding', () => { + const options = { bounds: { left: 10, top: 20 }, cols: 10, rows: 5, metrics: { width: 8, height: 16 }, padding: 4, originY: 4 }; + expect(terminalGridCellAt({ ...options, clientX: 14, clientY: 24 })).toEqual({ x: 0, y: 0 }); + expect(terminalGridCellAt({ ...options, clientX: 93, clientY: 103 })).toEqual({ x: 9, y: 4 }); + expect(terminalGridCellAt({ ...options, clientX: 12, clientY: 24 })).toBeNull(); + }); + + test('maps Ghostty scrollbar state to a proportional thumb and back to rows', () => { + const state = { total: 1000, offset: 500, len: 100 }; + const geometry = terminalScrollbarGeometry(state, 200); + expect(geometry).toEqual({ thumbHeight: 20, thumbTop: 100, maxOffset: 900 }); + expect(terminalScrollbarOffsetAtPointer(state, 200, 190, 10)).toBe(900); + expect(terminalScrollbarGeometry({ total: 24, offset: 0, len: 24 }, 200)).toBeNull(); + }); + + test('blinks only a focused visible cursor the terminal asked to blink', () => { + expect(shouldBlinkTerminalCursor({ focused: true, cursorBlinking: true, cursorVisible: true, reducedMotion: false })).toBe(true); + expect(shouldBlinkTerminalCursor({ focused: false, cursorBlinking: true, cursorVisible: true, reducedMotion: false })).toBe(false); + expect(shouldBlinkTerminalCursor({ focused: true, cursorBlinking: true, cursorVisible: true, reducedMotion: true })).toBe(false); + }); +}); diff --git a/packages/ui/src/lib/ghostty/surface.ts b/packages/ui/src/lib/ghostty/surface.ts new file mode 100644 index 00000000..6d554be7 --- /dev/null +++ b/packages/ui/src/lib/ghostty/surface.ts @@ -0,0 +1,2048 @@ +// Adapted from T3 Code's libghostty-vt browser adapter (MIT, T3 Tools Inc.). +// See LICENSE-T3CODE in this directory. + +import { collectWrappedTerminalLinkLine, extractTerminalLinks } from './terminalLinks'; +import { canvasFontFamilies, isMonospaceFamily } from './fonts'; +import { + GhosttyTerminalCore, + type GhosttyScrollbar, + type GhosttySnapshot, + type GhosttyTheme, +} from './core'; +import { + measureGhosttyCell, + renderGhosttySnapshot, + terminalGridSize, + type GhosttyCellRange, + type GhosttyCellMetrics, +} from './renderer'; + +// Bundled with the app: prompt symbols must not depend on a CDN fetch, and the +// cell grid must be measured with the faces that will actually render. +const symbolsFontUrl = new URL('./fonts/SymbolsNerdFontMono-Regular.woff2', import.meta.url).href; + +const DEFAULT_TERMINAL_FONT_SIZE = 13; +const MIN_TERMINAL_FONT_SIZE = 6; +const MAX_TERMINAL_FONT_SIZE = 32; +const SELECTION_MULTI_CLICK_INTERVAL_MS = 500; +// Compatibility mouse events follow a touch within this window; they must not +// summon the soft keyboard when the host owns touch gestures. +const TOUCH_MOUSE_COMPAT_WINDOW_MS = 1000; + +const isMacPlatform = (platform: string): boolean => /mac|iphone|ipad|ipod/i.test(platform); +// The glyph fallbacks only supply symbols the text faces are missing (powerline +// separators, devicons, and other private-use prompt symbols), so shells +// configured for a locally installed Nerd Font keep their prompt glyphs no +// matter which text face is active. +const TERMINAL_GLYPH_FALLBACKS = + '"Symbols Nerd Font Mono", "Symbols Nerd Font", "JetBrainsMono Nerd Font", ' + + '"JetBrainsMono NF", "FiraCode Nerd Font", "Hack Nerd Font", "MesloLGS NF", ' + + '"CaskaydiaCove Nerd Font", "PowerlineSymbols", monospace'; +// The platform's own monospace faces; concrete names only, because an +// unknown keyword (like ui-monospace) makes canvas font shorthand parsing +// reject the whole string. +export const DEFAULT_TERMINAL_FONT_FAMILY = + '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", ' + TERMINAL_GLYPH_FALLBACKS; +const CONTENT_PADDING = 4; +const MIN_SCROLLBAR_THUMB_HEIGHT = 18; +/** Half a blink cycle: the visible and hidden phases are equally long. */ +const CURSOR_BLINK_INTERVAL_MS = 500; +const TERMINAL_FONT_LOAD_TEXT = 'iMW0@# .'; +const TERMINAL_FONT_LOAD_VARIANTS = [ + 'normal 400', + 'normal 700', + 'italic 400', + 'italic 700', +] as const; + +/** Requested terminal font; omitted fields fall back to the defaults. */ +export interface GhosttyTerminalFont { + readonly family?: string; + readonly size?: number; +} + +let symbolsFontLoad: Promise | null = null; + +/** + * Register the bundled symbols-only Nerd Font once per page. It loads lazily + * with the first terminal, and because it carries no regular text glyphs it + * composes with any text face without changing metrics — prompt symbols and + * devicons render even on machines without a locally installed Nerd Font. + */ +function ensureTerminalSymbolsFont(): Promise { + if (symbolsFontLoad !== null) return symbolsFontLoad; + symbolsFontLoad = (async () => { + try { + const face = new FontFace('Symbols Nerd Font Mono', `url(${symbolsFontUrl})`); + document.fonts.add(await face.load()); + } catch { + // Locally installed fallback faces still apply. + } + })(); + return symbolsFontLoad; +} + +function uncheckedTerminalFontFamily(family?: string): string { + const custom = family === undefined ? null : canvasFontFamilies(family); + return custom === null ? DEFAULT_TERMINAL_FONT_FAMILY : `${custom}, ${DEFAULT_TERMINAL_FONT_FAMILY}`; +} + +function terminalFontFamily(family?: string): string { + // Quote non-ident names ("3270 Nerd Font", "M+ 1m"): an unquoted one makes + // the whole canvas font string invalid and the assignment silently no-ops. + const custom = family === undefined ? null : canvasFontFamilies(family); + if (custom === null) return DEFAULT_TERMINAL_FONT_FAMILY; + // The grid places the cursor and selection on one cell advance, so a + // proportional face would draw its text narrower than its own cells. Refuse + // it here rather than render a ragged grid with a stranded cursor. + if (!isMonospaceFamily(custom)) return DEFAULT_TERMINAL_FONT_FAMILY; + // A custom face keeps the glyph fallbacks so prompt symbols stay covered. + return uncheckedTerminalFontFamily(custom); +} + +/** Load every style the renderer can request, then validate the actual face. */ +export async function loadTerminalFontFamily( + family: string | undefined, + size: number, + environment?: { + readonly load: (font: string, text: string) => Promise; + readonly resolve: (family: string | undefined) => string; + }, +): Promise { + const candidate = uncheckedTerminalFontFamily(family); + const load = + environment?.load ?? + ((font: string, text: string) => document.fonts.load(font, text).then(() => undefined)); + try { + await Promise.all( + TERMINAL_FONT_LOAD_VARIANTS.map((variant) => + load(`${variant} ${size}px ${candidate}`, TERMINAL_FONT_LOAD_TEXT), + ), + ); + } catch { + // The fixed-width fallback stack remains available if a face cannot load. + } + return (environment?.resolve ?? terminalFontFamily)(family); +} + +export function terminalFontSize(size?: number): number { + if (size === undefined || !Number.isFinite(size)) return DEFAULT_TERMINAL_FONT_SIZE; + return Math.max(MIN_TERMINAL_FONT_SIZE, Math.min(MAX_TERMINAL_FONT_SIZE, Math.round(size))); +} + +/** + * Whether the cursor should keep toggling. An unfocused surface draws a steady + * hollow cursor instead of blinking, and a reduced-motion reader gets a steady + * cursor too rather than a permanently animating element. + */ +export function shouldBlinkTerminalCursor(state: { + readonly focused: boolean; + readonly cursorBlinking: boolean; + readonly cursorVisible: boolean; + readonly reducedMotion: boolean; +}): boolean { + return state.focused && state.cursorBlinking && state.cursorVisible && !state.reducedMotion; +} + +/** + * Vertical origin of the grid inside the mount. While content is shorter than + * the viewport the grid sits at the top like a fresh terminal. Once scrollback + * exists the prompt lives on the bottom row, so the grid anchors to the bottom + * edge instead: the sub-row remainder moves above row 0 and resizing within a + * row boundary keeps the prompt pinned instead of snapping up and down. + */ +export function terminalContentOriginY( + mountHeight: number, + padding: number, + rows: number, + cellHeight: number, + anchorBottom: boolean, +): number { + if (!anchorBottom) return padding; + const slack = mountHeight - padding * 2 - rows * cellHeight; + return padding + Math.max(0, slack); +} + +export interface TerminalScrollbarGeometry { + readonly thumbHeight: number; + readonly thumbTop: number; + readonly maxOffset: number; +} + +export function terminalScrollbarGeometry( + state: GhosttyScrollbar, + trackHeight: number, +): TerminalScrollbarGeometry | null { + const total = Math.max(0, state.total); + const len = Math.max(0, Math.min(state.len, total)); + const maxOffset = Math.max(0, total - len); + if (trackHeight <= 0 || len <= 0 || maxOffset === 0) return null; + const thumbHeight = Math.min( + trackHeight, + Math.max(MIN_SCROLLBAR_THUMB_HEIGHT, (trackHeight * len) / total), + ); + const travel = Math.max(0, trackHeight - thumbHeight); + const offset = Math.max(0, Math.min(state.offset, maxOffset)); + return { + thumbHeight, + thumbTop: travel * (offset / maxOffset), + maxOffset, + }; +} + +export function terminalScrollbarOffsetAtPointer( + state: GhosttyScrollbar, + trackHeight: number, + pointerY: number, + pointerOffset: number, +): number { + const geometry = terminalScrollbarGeometry(state, trackHeight); + if (geometry === null) return 0; + const travel = Math.max(0, trackHeight - geometry.thumbHeight); + if (travel === 0) return 0; + const thumbTop = Math.max(0, Math.min(pointerY - pointerOffset, travel)); + return Math.round((thumbTop / travel) * geometry.maxOffset); +} + +export function terminalGridCellAt(options: { + bounds: { left: number; top: number }; + clientX: number; + clientY: number; + cols: number; + rows: number; + metrics: Pick; + padding: number; + originY: number; +}): { x: number; y: number } | null { + const { bounds, clientX, clientY, cols, rows, metrics, padding, originY } = options; + const gridX = clientX - bounds.left - padding; + const gridY = clientY - bounds.top - originY; + if (gridX < 0 || gridY < 0 || gridX >= cols * metrics.width || gridY >= rows * metrics.height) { + return null; + } + return { + x: Math.floor(gridX / metrics.width), + y: Math.floor(gridY / metrics.height), + }; +} + +function terminalRowText(row: GhosttySnapshot['rowData'][number], trimRight: boolean): string { + const text = row.cells.map((cell) => cell.text || ' ').join(''); + return trimRight ? text.trimEnd() : text; +} + +function terminalColumnOffset(row: GhosttySnapshot['rowData'][number], column: number): number { + let offset = 0; + for (let cellIndex = 0; cellIndex < column; cellIndex += 1) { + offset += row.cells[cellIndex]?.text.length || 1; + } + return offset; +} + +export interface TerminalLinkWithRange { + readonly text: string; + readonly range: GhosttyCellRange; +} + +function terminalColumnAtOffset(row: GhosttySnapshot['rowData'][number], offset: number): number { + for (let column = 0; column < row.cells.length; column += 1) { + const nextOffset = terminalColumnOffset(row, column + 1); + if (offset < nextOffset) return column; + } + return Math.max(0, row.cells.length - 1); +} + +export function terminalLinkAtPositionWithRange( + rows: GhosttySnapshot['rowData'], + rowIndex: number, + column: number, +): TerminalLinkWithRange | null { + const wrappedLine = collectWrappedTerminalLinkLine(rowIndex + 1, (index) => { + const row = rows[index]; + if (!row) return null; + return { + isWrapped: row.isWrapContinuation, + translateToString: (trimRight = false) => terminalRowText(row, trimRight), + }; + }); + if (!wrappedLine) return null; + // Only viewport rows are available: a wrapped line whose head scrolled above + // the viewport would resolve a truncated match into a wrong link. + const firstSegment = wrappedLine.segments[0]; + if (firstSegment && rows[firstSegment.bufferLineNumber - 1]?.isWrapContinuation) { + return null; + } + const segment = wrappedLine.segments.find((value) => value.bufferLineNumber === rowIndex + 1); + const row = rows[rowIndex]; + if (!segment || !row) return null; + const lastSegment = wrappedLine.segments.at(-1); + const lastRow = lastSegment ? rows[lastSegment.bufferLineNumber - 1] : undefined; + // Ghostty's soft-wrap flag is authoritative: when the last collected row + // still wraps onward, its continuation is outside the viewport. + const continuesBelowViewport = lastRow !== undefined && lastRow.wrapsToNext; + const offset = segment.startIndex + terminalColumnOffset(row, column); + for (const match of extractTerminalLinks(wrappedLine.text)) { + if (offset >= match.start && offset < match.end) { + // A truncated tail must not activate as a complete link. + if (match.end === wrappedLine.text.length && continuesBelowViewport) return null; + const startSegment = wrappedLine.segments.find( + (value) => match.start >= value.startIndex && match.start < value.endIndex, + ); + const endSegment = wrappedLine.segments.find( + (value) => match.end - 1 >= value.startIndex && match.end - 1 < value.endIndex, + ); + const startRow = startSegment ? rows[startSegment.bufferLineNumber - 1] : undefined; + const endRow = endSegment ? rows[endSegment.bufferLineNumber - 1] : undefined; + if (!startSegment || !endSegment || !startRow || !endRow) return null; + return { + text: match.text, + range: { + start: { + x: terminalColumnAtOffset(startRow, match.start - startSegment.startIndex), + y: startSegment.bufferLineNumber - 1, + }, + end: { + x: terminalColumnAtOffset(endRow, match.end - 1 - endSegment.startIndex), + y: endSegment.bufferLineNumber - 1, + }, + }, + }; + } + } + return null; +} + +export function isTerminalCopyShortcut( + event: Pick, + platform = navigator.platform, +) { + if (event.key.toLowerCase() !== 'c') return false; + return isMacPlatform(platform) ? event.metaKey : event.ctrlKey; +} + +/** + * Canvas terminals have no DOM selection. Native copy and Electron's Edit + * menu `role: "copy"` both read the focused textarea, so an empty IME field + * writes blankness to the clipboard. Park the Ghostty selection there first. + */ +function primeTerminalCopyInput( + input: Pick, + selection: string, +): void { + input.value = selection; + if (selection.length === 0) return; + input.select(); +} + +function clearPrimedTerminalCopyInput( + input: Pick, + primedSelection: string, +): void { + // Only blank the copy we parked. The same textarea holds the IME candidate; + // wiping whatever is there would cancel CJK composition. + if (primedSelection.length === 0 || input.value !== primedSelection) return; + input.value = ''; +} + +/** + * Only a copy event that actually received the selection may cancel the + * clipboard.writeText fallback. Claiming without clipboardData (Electron's + * menu Copy) used to preventDefault an empty write and skip the fallback, + * which is how Cmd+C copied blankness. + */ +interface TerminalCopyEventResult { + readonly preventDefault: boolean; + readonly claimWriteFallback: boolean; +} + +function applyTerminalCopyEvent( + selection: string, + clipboardData: { setData: (type: string, data: string) => void } | null | undefined, +): TerminalCopyEventResult { + if (selection.length === 0 || !clipboardData) { + return { preventDefault: false, claimWriteFallback: false }; + } + clipboardData.setData('text/plain', selection); + return { preventDefault: true, claimWriteFallback: true }; +} + +export function isTerminalPasteShortcut( + event: Pick, + platform = navigator.platform, +) { + const key = event.key.toLowerCase(); + if (key === 'insert' && !isMacPlatform(platform)) { + return event.shiftKey && !event.ctrlKey && !event.metaKey; + } + if (key !== 'v') return false; + return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey; +} + +function isTerminalCompositionCommitInput(event: Pick): boolean { + return ( + event.inputType === '' || + event.inputType === 'insertCompositionText' || + event.inputType === 'insertFromComposition' + ); +} + +/** IME keydowns must not touch the hidden textarea; it holds the candidate. */ +function isTerminalCompositionKey( + event: Pick, + composing: boolean, +): boolean { + return event.isComposing || composing || event.key === 'Process' || event.keyCode === 229; +} + +function isTerminalAltGraphText( + event: Pick, +): boolean { + return event.getModifierState('AltGraph') && [...event.key].length === 1; +} + +function shouldReportTerminalMouse( + tracking: boolean, + event: Pick, +): boolean { + return tracking && !event.shiftKey && !event.ctrlKey && !event.metaKey; +} + +type TerminalMouseAction = 'press' | 'release' | 'motion'; + +export interface TerminalMouseDataResolution { + readonly send: boolean; + readonly nextMotionData: string; +} + +interface TerminalMouseTrackingState { + readonly tracking: boolean; + readonly motionData: string; +} + +export interface TerminalWheelRows { + readonly rows: number; + readonly remainder: number; +} + +export function resolveTerminalMouseData( + action: TerminalMouseAction, + data: string, + previousMotionData: string, +): TerminalMouseDataResolution { + const nextMotionData = action === 'motion' ? data : ''; + return { + send: data.length > 0 && (action !== 'motion' || data !== previousMotionData), + nextMotionData, + }; +} + +function resolveTerminalMouseTrackingState( + previousTracking: boolean, + tracking: boolean, + motionData: string, +): TerminalMouseTrackingState { + return { + tracking, + motionData: previousTracking === tracking ? motionData : '', + }; +} + +export function terminalWheelDeltaRows( + event: Pick, + cellHeight: number, + viewportRows: number, + remainder: number, +): TerminalWheelRows { + // deltaMode: 0 pixels, 1 lines, 2 pages. + const pixels = + event.deltaMode === 1 + ? event.deltaY * cellHeight + : event.deltaMode === 2 + ? event.deltaY * viewportRows * cellHeight + : event.deltaY; + const total = remainder + pixels / cellHeight; + const rows = Math.trunc(total); + return { rows, remainder: total - rows }; +} + +export function terminalWheelArrowData(rows: number, applicationCursorKeys: boolean): string { + if (rows === 0) return ''; + const sequence = + rows < 0 + ? applicationCursorKeys + ? '\u001bOA' + : '\u001b[A' + : applicationCursorKeys + ? '\u001bOB' + : '\u001b[B'; + return sequence.repeat(Math.abs(rows)); +} + +export function isTerminalLinkPointerGesture( + event: Pick, + platform = navigator.platform, +): boolean { + return isMacPlatform(platform) + ? event.metaKey && !event.ctrlKey + : event.ctrlKey && !event.metaKey; +} + +function ghosttyMouseButton(button: number): number | null { + switch (button) { + case 0: + return 1; + case 1: + return 3; + case 2: + return 2; + case 3: + return 4; + case 4: + return 5; + default: + return null; + } +} + +export interface TerminalSelectionClickSequence { + readonly count: number; + readonly time: number; + readonly x: number; + readonly y: number; +} + +export function advanceTerminalSelectionClickSequence( + previous: TerminalSelectionClickSequence | null, + event: Pick, +): TerminalSelectionClickSequence { + const repeats = + previous !== null && + event.timeStamp - previous.time <= SELECTION_MULTI_CLICK_INTERVAL_MS && + Math.hypot(event.clientX - previous.x, event.clientY - previous.y) <= 4; + return { + count: repeats ? (previous.count >= 3 ? 1 : previous.count + 1) : 1, + time: event.timeStamp, + x: event.clientX, + y: event.clientY, + }; +} + +export interface GhosttyGridPoint { + readonly x: number; + readonly y: number; +} + +export interface GhosttySelectionPosition { + readonly start: { readonly x: number; readonly y: number }; + readonly end: { readonly x: number; readonly y: number }; +} + +export interface GhosttyTerminalSurfaceOptions { + readonly theme: GhosttyTheme; + readonly font?: GhosttyTerminalFont; + /** Read after font and WASM loading. Hosts can supply a getter for the latest value. */ + readonly visible?: boolean; + /** Accessible names for the hidden input and the scrollbar, already localized by the host. */ + readonly labels: { readonly input: string; readonly scrollbar: string }; + /** + * When false the surface ignores touch pointers (and the compatibility mouse + * events that follow them) so the host can run its own scroll and + * long-press selection gestures through `scrollLines`, `selectWordAt` and + * `extendSelectionTo`. Defaults to true. + */ + readonly handleTouchPointer?: boolean; + readonly onData: (data: string) => void; + readonly onResize: (cols: number, rows: number) => void; + readonly onSelectionChange?: () => void; + /** Return false to keep a key press from reaching the terminal (host shortcuts). */ + readonly beforeKey?: (event: KeyboardEvent) => boolean; + readonly onLinkActivate?: (text: string, event: MouseEvent) => void; + /** + * A right-click the running application did not claim through mouse + * reporting. The host owns the menu, so it also owns preventing the browser + * default — whose Paste entry can never reach a canvas terminal. + */ + readonly onContextMenu?: (event: MouseEvent) => void; +} + +export class GhosttyTerminalSurface { + readonly canvas: HTMLCanvasElement; + readonly input: HTMLTextAreaElement; + readonly scrollbar: HTMLDivElement; + cols = 1; + rows = 1; + + private readonly mount: HTMLElement; + private readonly context: CanvasRenderingContext2D; + private readonly core: GhosttyTerminalCore; + private readonly options: GhosttyTerminalSurfaceOptions; + private visible: boolean; + private hasSize = false; + private metrics: GhosttyCellMetrics; + private fontFamily: string; + private requestedFontFamily: string | undefined; + private fontSize: number; + private fontEpoch = 0; + private pendingFontEpoch: number | null = null; + private readonly resizeObserver: ResizeObserver; + private readonly scrollbarThumb: HTMLDivElement; + private snapshot: GhosttySnapshot | null = null; + private frame = 0; + private cursorTimer: number | null = null; + private compositionInputToSuppress: string | null = null; + private compositionSuppressionTimer: number | null = null; + private cursorOn = true; + private renderedCursorY: number | null = null; + private forceFullRender = true; + private scrollbarDirty = true; + private scrollbarState: GhosttyScrollbar | null = null; + private scrollbarPointerId: number | null = null; + private scrollbarPointerOffset = 0; + private disposed = false; + private resizeNotifyTimer: number | null = null; + private originY = CONTENT_PADDING; + private mountHeight = 0; + private selectionEnd: { x: number; y: number } | null = null; + private selectionAnchorScreen: { x: number; y: number } | null = null; + private selectionEndScreen: { x: number; y: number } | null = null; + private selectionMode: 'cell' | 'word' | 'line' = 'cell'; + // Word/line selection base in screen coordinates so streaming output cannot + // shift the origin of a drag selection. + private selectionBase: { + start: { x: number; y: number }; + end: { x: number; y: number }; + } | null = null; + private selectionScrollTimer: number | null = null; + private selectionScrollDelta = 0; + private selectionPointer: { x: number; y: number } | null = null; + private mouseReportingPointerId: number | null = null; + private mouseReportingButton: number | null = null; + private linkActivationPointerId: number | null = null; + private hoveredLink: TerminalLinkWithRange | null = null; + private hoverPointer: { x: number; y: number } | null = null; + private linkModifierActive = false; + private selectionClickSequence: TerminalSelectionClickSequence | null = null; + private selectionMoved = false; + private composing = false; + private focused = false; + private resizeNotified = false; + private canvasConfigured = false; + private theme: GhosttyTheme; + private readonly suppressedKeyCodes = new Set(); + private pasteShortcutToken = 0; + private copyShortcutToken = 0; + private clearSelectionAfterCopy = false; + private primedCopySelection = ''; + private wheelRemainder = 0; + private lastMouseMotionData = ''; + private mouseAnyEventTracking = false; + private dprMedia: MediaQueryList | null = null; + // Read live on every blink decision, and watched so that dropping the + // preference restarts a blink cycle that has no timer left to notice it. + private readonly reducedMotionMedia = window.matchMedia?.('(prefers-reduced-motion: reduce)'); + private inputLeft = -1; + private inputTop = -1; + private lastTouchPointerAt = Number.NEGATIVE_INFINITY; + + private constructor( + mount: HTMLElement, + canvas: HTMLCanvasElement, + input: HTMLTextAreaElement, + scrollbar: HTMLDivElement, + scrollbarThumb: HTMLDivElement, + context: CanvasRenderingContext2D, + core: GhosttyTerminalCore, + metrics: GhosttyCellMetrics, + fontFamily: string, + options: GhosttyTerminalSurfaceOptions, + ) { + this.mount = mount; + this.canvas = canvas; + this.input = input; + this.scrollbar = scrollbar; + this.scrollbarThumb = scrollbarThumb; + this.context = context; + this.core = core; + this.mouseAnyEventTracking = core.isMouseAnyEventTracking(); + this.metrics = metrics; + this.options = options; + this.visible = options.visible ?? true; + this.theme = options.theme; + this.fontFamily = fontFamily; + this.requestedFontFamily = options.font?.family; + this.fontSize = terminalFontSize(options.font?.size); + this.resizeObserver = new ResizeObserver(() => this.fit()); + this.installEvents(); + this.watchDevicePixelRatio(); + this.reducedMotionMedia?.addEventListener('change', this.onReducedMotionChange); + document.fonts.addEventListener('loadingdone', this.onFontsLoaded); + this.resizeObserver.observe(mount); + } + + static async create( + mount: HTMLElement, + options: GhosttyTerminalSurfaceOptions, + ): Promise { + const canvas = document.createElement('canvas'); + canvas.className = 'oc-terminal-canvas'; + canvas.setAttribute('aria-hidden', 'true'); + + const input = document.createElement('textarea'); + input.className = 'oc-terminal-input'; + input.setAttribute('aria-label', options.labels.input); + input.setAttribute('autocorrect', 'off'); + input.autocapitalize = 'off'; + input.autocomplete = 'off'; + input.spellcheck = false; + + const scrollbar = document.createElement('div'); + scrollbar.className = 'oc-terminal-scrollbar'; + scrollbar.setAttribute('role', 'scrollbar'); + scrollbar.setAttribute('aria-label', options.labels.scrollbar); + scrollbar.setAttribute('aria-orientation', 'vertical'); + scrollbar.tabIndex = -1; + scrollbar.hidden = true; + const scrollbarThumb = document.createElement('div'); + scrollbarThumb.className = 'oc-terminal-scrollbar-thumb'; + scrollbar.append(scrollbarThumb); + mount.replaceChildren(canvas, input, scrollbar); + // Size the backing store before the browser paints this canvas for the + // first time. The font loads below yield to the compositor, and a canvas + // whose intrinsic size later changes while its CSS box stays put (the box + // is 100% of the mount) keeps showing the first, stretched image in + // Gecko until something else invalidates layout — a brand-new tab was + // blurry until a reload or a panel resize. + const initialRatio = window.devicePixelRatio || 1; + if (mount.clientWidth > 0 && mount.clientHeight > 0) { + canvas.width = Math.max(1, Math.round(mount.clientWidth * initialRatio)); + canvas.height = Math.max(1, Math.round(mount.clientHeight * initialRatio)); + } + + // willReadFrequently pins the canvas to the software rasterizer. Gecko + // otherwise decides per canvas whether to accelerate it, and its GPU text + // path on macOS skips CoreText smoothing: a terminal created after page + // load rendered thin, pencil-like glyphs while the first one stayed on the + // software path. The renderer repaints dirty rows only, so CPU raster is + // cheap, and it also makes the pixel readbacks of the tests exact. + const context = canvas.getContext('2d', { alpha: false, willReadFrequently: true }); + if (!context) throw new Error('Canvas 2D is unavailable'); + // An opaque canvas backing store initializes to solid black, and the font + // and WASM loads below leave it on screen for the whole setup window; paint + // the theme background first so the mount never flashes a black box. + context.fillStyle = `rgb(${options.theme.background.r}, ${options.theme.background.g}, ${options.theme.background.b})`; + context.fillRect(0, 0, canvas.width, canvas.height); + const fontSize = terminalFontSize(options.font?.size); + try { + // Cell metrics must come from the faces that will render; measuring before + // the bundled webfonts load would size the grid from a fallback font. + await ensureTerminalSymbolsFont(); + } catch { + // Metrics fall back to whichever faces are already available. + } + const fontFamily = await loadTerminalFontFamily(options.font?.family, fontSize); + const metrics = measureGhosttyCell(context, fontSize, fontFamily); + const grid = terminalGridSize(mount.clientWidth, mount.clientHeight, metrics, CONTENT_PADDING); + const core = await GhosttyTerminalCore.create( + grid.cols, + grid.rows, + metrics.width, + metrics.height, + options.theme, + options.onData, + ); + const surface = new GhosttyTerminalSurface( + mount, + canvas, + input, + scrollbar, + scrollbarThumb, + context, + core, + metrics, + fontFamily, + options, + ); + surface.fit(); + return surface; + } + + /** Pause canvas work without interrupting output parsing or terminal replies. */ + setVisible(visible: boolean): void { + if (this.disposed || this.visible === visible) return; + this.visible = visible; + this.cursorOn = true; + this.forceFullRender = true; + this.scrollbarDirty = true; + if (!visible) { + this.cancelRender(); + this.setSelectionAutoscroll(0); + return; + } + this.fit(); + } + + write(data: string): void { + if (this.disposed) return; + this.core.write(data); + this.synchronizeMouseTrackingState(); + // Restart the blink cycle from the visible phase so the cursor never sits + // invisible through a stream of output or a burst of typing echo. + this.cursorOn = true; + this.scrollbarDirty = true; + this.requestRender(); + } + + /** + * Replaces the screen with replayed history. History was laid out by the + * shell for the PTY size it had at the time; when that differs from the + * fitted grid the replay runs at the drawn size and Ghostty reflows to the + * fitted grid afterwards, so lines wrap where the shell wrapped them. + */ + resetAndWrite(data: string, drawnSize?: { readonly cols: number; readonly rows: number }): void { + if (this.disposed) return; + this.lastMouseMotionData = ''; + const replayAtDrawnSize = + drawnSize !== undefined && (drawnSize.cols !== this.cols || drawnSize.rows !== this.rows); + if (replayAtDrawnSize) { + this.core.resize(drawnSize.cols, drawnSize.rows, this.metrics.width, this.metrics.height); + } + try { + this.core.resetAndWrite(data); + } finally { + if (replayAtDrawnSize) { + this.core.resize(this.cols, this.rows, this.metrics.width, this.metrics.height); + } + } + this.synchronizeMouseTrackingState(); + // A replayed session starts from the visible phase like any other write: + // reattaching mid-blink must not open on an invisible cursor. + this.cursorOn = true; + this.forceFullRender = true; + this.scrollbarDirty = true; + this.requestRender(); + } + + setTheme(theme: GhosttyTheme): void { + if (this.disposed) return; + this.theme = theme; + this.core.setTheme(theme); + this.forceFullRender = true; + this.requestRender(); + } + + async setFont(font: GhosttyTerminalFont): Promise { + if (this.disposed) return; + const fontSize = terminalFontSize(font.size); + // The fields only change together with their metrics after the load, and + // the epoch lets the newest overlapping call win regardless of load order. + const epoch = ++this.fontEpoch; + this.pendingFontEpoch = epoch; + const fontFamily = await loadTerminalFontFamily(font.family, fontSize); + if (this.disposed || epoch !== this.fontEpoch) return; + this.pendingFontEpoch = null; + this.fontFamily = fontFamily; + this.requestedFontFamily = font.family; + this.fontSize = fontSize; + this.applyFontMetrics(); + } + + private applyFontMetrics(): void { + this.metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily); + this.core.resize(this.cols, this.rows, this.metrics.width, this.metrics.height); + // Cached IME textarea coordinates are stale in the new cell geometry. + this.inputLeft = -1; + this.inputTop = -1; + this.forceFullRender = true; + this.scrollbarDirty = true; + this.fit(); + this.requestRender(); + } + + private readonly onReducedMotionChange = () => { + if (this.disposed) return; + // Nothing else wakes an idle steady cursor: the blink timer only reschedules + // from a render, and reduced motion is exactly the state that stopped it. + this.cursorOn = true; + this.requestRender(); + }; + + private readonly onFontsLoaded = () => { + if (this.disposed) return; + // The explicit load validates every style and applies the newest request. + // Its own loading events must not revalidate the previously applied face. + if (this.pendingFontEpoch !== null) return; + // A face may become available after an earlier fallback measurement. Run + // the fixed-width guard again before using its newly loaded metrics. + const fontFamily = terminalFontFamily(this.requestedFontFamily); + if (fontFamily !== this.fontFamily) { + this.fontFamily = fontFamily; + this.applyFontMetrics(); + return; + } + // A face that finished loading after the initial measurement changes glyph + // advances; re-measure and refit so the grid matches what actually renders. + const metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily); + if ( + metrics.width === this.metrics.width && + metrics.height === this.metrics.height && + metrics.baseline === this.metrics.baseline + ) { + return; + } + this.applyFontMetrics(); + }; + + fit(): boolean { + if (this.disposed || !this.visible) return false; + const width = this.mount.clientWidth; + const height = this.mount.clientHeight; + if (width <= 0 || height <= 0) { + this.hasSize = false; + this.forceFullRender = true; + this.cancelRender(); + return false; + } + this.hasSize = true; + const ratio = window.devicePixelRatio || 1; + const pixelWidth = Math.max(1, Math.round(width * ratio)); + const pixelHeight = Math.max(1, Math.round(height * ratio)); + let shouldRender = false; + // The DPR transform must be installed even when the target size happens to + // equal the canvas default 300x150 backing store, so the first fit always + // schedules a canvas configuration. + if ( + this.canvas.width !== pixelWidth || + this.canvas.height !== pixelHeight || + !this.canvasConfigured + ) { + this.canvas.width = pixelWidth; + this.canvas.height = pixelHeight; + this.context.setTransform(ratio, 0, 0, ratio, 0, 0); + this.canvasConfigured = true; + this.forceFullRender = true; + this.scrollbarDirty = true; + shouldRender = true; + } + const grid = terminalGridSize(width, height, this.metrics, CONTENT_PADDING); + this.mountHeight = height; + // onResize is the only PTY resize channel, so the first successful fit must + // notify even when the measured grid equals the 1x1 construction sentinel. + if (grid.cols !== this.cols || grid.rows !== this.rows || !this.resizeNotified) { + this.cols = grid.cols; + this.rows = grid.rows; + this.core.resize(grid.cols, grid.rows, this.metrics.width, this.metrics.height); + this.notifyResize(); + this.forceFullRender = true; + this.scrollbarDirty = true; + shouldRender = true; + } + // Rendering synchronously keeps the repaint inside the same frame as the + // layout change: ResizeObserver fires before paint, so the browser never + // composites the old backing store stretched into the new element box. + if (shouldRender || this.forceFullRender) this.renderFrame(); + return true; + } + + /** + * The local grid reflows immediately, but the PTY only hears about settled + * dimensions: notifying on every drag step makes the shell reprint its + * prompt mid-drag, which reads as jitter. + */ + private notifyResize(): void { + this.resizeNotified = true; + if (this.resizeNotifyTimer !== null) window.clearTimeout(this.resizeNotifyTimer); + this.resizeNotifyTimer = window.setTimeout(() => { + this.resizeNotifyTimer = null; + if (!this.disposed) this.options.onResize(this.cols, this.rows); + }, 150); + } + + focus(): void { + if (this.disposed || !this.visible) return; + this.input.focus({ preventScroll: true }); + } + + /** + * Pastes clipboard text read by the host (context menu) with the same + * bracketed-paste encoding as a native paste event. The read joins the same + * race the paste shortcut uses — the token is claimed before it starts — so + * a shortcut or native paste arriving during the read supersedes this one + * instead of both reaching the shell. + */ + async pasteFromClipboard( + readText: () => Promise, + isCurrent: () => boolean = () => true, + ): Promise { + const token = ++this.pasteShortcutToken; + const text = await readText(); + if (this.disposed || this.pasteShortcutToken !== token || !isCurrent()) return; + // As in every paste path, delivering bumps the token so a clipboard read + // still in flight cannot land after this text reaches the shell. + this.pasteShortcutToken += 1; + if (text.length === 0) return; + const encoded = this.core.encodePaste(text); + if (encoded.length > 0) this.options.onData(encoded); + } + + hasSelection(): boolean { + return this.core.selectionText().length > 0; + } + + getSelection(): string { + return this.core.selectionText(); + } + + getSelectionPosition(): GhosttySelectionPosition | null { + if (!this.selectionAnchorScreen || !this.selectionEndScreen || !this.hasSelection()) + return null; + const before = + this.selectionAnchorScreen.y < this.selectionEndScreen.y || + (this.selectionAnchorScreen.y === this.selectionEndScreen.y && + this.selectionAnchorScreen.x <= this.selectionEndScreen.x); + return before + ? { start: this.selectionAnchorScreen, end: this.selectionEndScreen } + : { start: this.selectionEndScreen, end: this.selectionAnchorScreen }; + } + + getSelectionEndClientRect(): { readonly right: number; readonly bottom: number } | null { + const position = this.getSelectionPosition(); + if (!position) return null; + const viewportEnd = this.core.screenPointToViewport(position.end.x, position.end.y); + if (!viewportEnd) return null; + const bounds = this.canvas.getBoundingClientRect(); + return { + right: bounds.left + CONTENT_PADDING + (viewportEnd.x + 1) * this.metrics.width, + bottom: bounds.top + this.originY + (viewportEnd.y + 1) * this.metrics.height, + }; + } + + clearSelection(): void { + this.clearPrimedCopy(); + this.core.clearSelection(); + this.selectionEnd = null; + this.selectionAnchorScreen = null; + this.selectionEndScreen = null; + this.selectionMode = 'cell'; + this.selectionBase = null; + this.setSelectionAutoscroll(0); + this.options.onSelectionChange?.(); + // Selection highlights span rows Ghostty may not mark dirty for this change. + this.forceFullRender = true; + this.requestRender(); + } + + scrollToBottom(): void { + this.core.scrollToBottom(); + this.forceFullRender = true; + this.scrollbarDirty = true; + this.requestRender(); + } + + isAtBottom(): boolean { + return this.core.isViewportActive(); + } + + /** + * Repaints every row on the next frame. Hosts call this once a container + * transition (panel open, resize handle release) has settled: the canvas + * itself did not change, but a compositor that cached it mid-transition + * only drops that cache when the canvas contents update. + */ + refresh(): void { + if (this.disposed) return; + this.forceFullRender = true; + this.scrollbarDirty = true; + this.requestRender(); + } + + /** Scrolls the viewport by whole rows; positive moves toward the bottom. */ + scrollLines(deltaRows: number): void { + if (this.disposed) return; + this.scrollViewport(deltaRows); + } + + /** Starts a word selection at a client point (touch long-press). Returns whether a word was found. */ + selectWordAt(clientX: number, clientY: number): boolean { + if (this.disposed) return false; + const cell = this.cellAt(clientX, clientY); + const range = this.core.selectWord(cell.x, cell.y); + if (!range) return false; + this.selectionMode = 'word'; + this.selectionMoved = false; + this.selectionBase = range.screen; + this.selectionEnd = range.viewport.end; + this.selectionAnchorScreen = range.screen.start; + this.selectionEndScreen = range.screen.end; + this.options.onSelectionChange?.(); + this.forceFullRender = true; + this.requestRender(); + return true; + } + + /** Extends the current selection to a client point, keeping the selection mode's granularity. */ + extendSelectionTo(clientX: number, clientY: number): void { + if (this.disposed || this.selectionAnchorScreen === null) return; + const cell = this.cellAt(clientX, clientY); + if (cell.x === this.selectionEnd?.x && cell.y === this.selectionEnd.y) return; + this.extendSelectionToPoint(clientX, clientY); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.resizeObserver.disconnect(); + document.fonts.removeEventListener('loadingdone', this.onFontsLoaded); + this.dprMedia?.removeEventListener('change', this.onDevicePixelRatioChange); + this.dprMedia = null; + this.reducedMotionMedia?.removeEventListener('change', this.onReducedMotionChange); + if (this.selectionScrollTimer !== null) window.clearInterval(this.selectionScrollTimer); + if (this.resizeNotifyTimer !== null) { + window.clearTimeout(this.resizeNotifyTimer); + this.resizeNotifyTimer = null; + // Flush the settled dimensions so the PTY keeps the final size even when + // the surface unmounts inside the debounce window. + this.options.onResize(this.cols, this.rows); + } + this.cancelRender(); + if (this.compositionSuppressionTimer !== null) { + window.clearTimeout(this.compositionSuppressionTimer); + } + this.removeEvents(); + this.core.dispose(); + if ( + this.canvas.parentElement === this.mount || + this.input.parentElement === this.mount || + this.scrollbar.parentElement === this.mount + ) { + this.canvas.remove(); + this.input.remove(); + this.scrollbar.remove(); + } + } + + private readonly onKeyDown = (event: KeyboardEvent) => { + this.updateLinkModifier(event); + // Presses handled outside the terminal must also swallow their release: + // beforeKey runs side effects (keybindings, navigation sends), so it cannot + // be consulted again on keyup, and Kitty report-event-types sessions would + // otherwise receive a release for a press the shell never saw. + if (isTerminalAltGraphText(event) || this.options.beforeKey?.(event) === false) { + this.suppressedKeyCodes.add(event.code); + return; + } + if (isTerminalCopyShortcut(event) && this.hasSelection()) { + // A plain Ctrl+C/Cmd+C fires the browser's native copy event, caught in + // onCopyEvent; not preventing the default keeps that path alive. WebKit + // omits the keyboard copy event without a DOM selection, so race the + // clipboard write against it the same way paste races its read. The + // Shift variant has no native event (Chrome binds Ctrl+Shift+C to + // inspect), so synthesize one with execCommand("copy"). + const selection = this.getSelection(); + this.primeCopy(selection); + if (event.shiftKey) { + event.preventDefault(); + document.execCommand('copy'); + } else { + // A plain Ctrl+C is also SIGINT on non-mac: clear the selection once + // it copies so the next Ctrl+C reaches the shell. The Shift chord and + // Cmd+C are copy-only, so they keep the selection; resetting the flag + // up front also drops any clear owed by an earlier gesture that never + // completed. + this.clearSelectionAfterCopy = !event.shiftKey && !isMacPlatform(navigator.platform); + const clipboard = navigator.clipboard; + if (clipboard) { + // Defer the write past the default action: the native copy event + // (dispatched synchronously with the default action) claims the + // token first when it actually writes, and the write covers browsers + // whose shortcut produces no copy event. The primed textarea is what + // Electron's edit-menu Copy reads if it runs after this handler. + const token = ++this.copyShortcutToken; + void Promise.resolve().then(() => { + if (this.disposed || this.copyShortcutToken !== token) return; + void clipboard.writeText(selection).then( + () => { + // The write may have been superseded while in flight; only + // touch the selection if this gesture still owns the token. + if (this.disposed || this.copyShortcutToken !== token) return; + if (this.clearSelectionAfterCopy) { + this.clearSelectionAfterCopy = false; + this.clearSelection(); + } + }, + () => { + // The write failed and the native event has already had its + // chance, so nothing copied and no clear is owed by this + // gesture; a newer one may have just set the flag, so only + // drop it if this gesture still owns the token. + if (this.copyShortcutToken === token) { + this.clearSelectionAfterCopy = false; + } + }, + ); + }); + } + } + this.suppressedKeyCodes.add(event.code); + return; + } + if (isTerminalPasteShortcut(event)) { + this.suppressedKeyCodes.add(event.code); + const clipboard = navigator.clipboard; + if (clipboard) { + // Race the async clipboard read against the browser's own paste event: + // the native event (dispatched synchronously with the default action) + // always claims the token first when it fires, and the read covers + // browsers whose paste shortcut produces no paste event. Not preventing + // the default keeps the native path alive when the read is denied. + const token = ++this.pasteShortcutToken; + void clipboard.readText().then( + (text) => { + if (this.disposed || this.pasteShortcutToken !== token) return; + this.pasteShortcutToken += 1; + if (text.length > 0) this.options.onData(this.core.encodePaste(text)); + }, + () => { + // Clipboard read denied; the native paste event remains the path. + }, + ); + } + return; + } + // keyCode 229 is Safari's only signal that this keydown opens an IME + // composition; encoding it would double the committed text. Do not blank + // the textarea first: onInput leaves the in-progress candidate there. + if (isTerminalCompositionKey(event, this.composing)) { + return; + } + this.clearPrimedCopy(); + const data = this.core.encodeKey(event); + if (data.length === 0) return; + this.suppressedKeyCodes.delete(event.code); + event.preventDefault(); + event.stopPropagation(); + this.options.onData(data); + }; + + private readonly onKeyUp = (event: KeyboardEvent) => { + this.updateLinkModifier(event); + if (this.suppressedKeyCodes.delete(event.code)) return; + if (isTerminalCompositionKey(event, this.composing)) { + return; + } + // Ghostty's encoder only emits release codes when the terminal enabled the + // Kitty report-event-types flag, so legacy sessions send nothing here. + const data = this.core.encodeKey(event, 'release'); + if (data.length === 0) return; + event.preventDefault(); + event.stopPropagation(); + this.options.onData(data); + }; + + private readonly onFocus = () => { + this.focused = true; + this.cursorOn = true; + this.requestRender(); + }; + + private readonly onBlur = () => { + this.focused = false; + this.linkModifierActive = false; + this.refreshHoveredLink(); + // Suppressions survive blur deliberately: a shortcut that moves focus (for + // example terminal-toggle) must still swallow its own keyup if focus comes + // back before release. Stale entries are harmless — an encoding keydown + // always removes its code first. + // The steady unfocused hollow cursor must not inherit an off blink phase. + this.cursorOn = true; + this.requestRender(); + }; + + private readonly onDevicePixelRatioChange = () => { + this.watchDevicePixelRatio(); + this.fit(); + }; + + private watchDevicePixelRatio(): void { + this.dprMedia?.removeEventListener('change', this.onDevicePixelRatioChange); + // A resolution media query only fires once for the ratio it was created at, + // so re-arm it after every change (monitor moves, browser zoom). + this.dprMedia = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); + this.dprMedia.addEventListener('change', this.onDevicePixelRatioChange); + } + + private primeCopy(selection: string): void { + this.primedCopySelection = selection; + primeTerminalCopyInput(this.input, selection); + } + + private clearPrimedCopy(): void { + clearPrimedTerminalCopyInput(this.input, this.primedCopySelection); + this.primedCopySelection = ''; + } + + private readonly onCopyEvent = (event: ClipboardEvent) => { + const selection = this.hasSelection() ? this.getSelection() : this.input.value; + // Menu-role Copy never hits the keydown primer. The native action reads + // this.input, so park the current selection first — including when + // clipboardData is missing and we must not preventDefault. + this.primeCopy(selection); + const result = applyTerminalCopyEvent(selection, event.clipboardData); + if (result.preventDefault) event.preventDefault(); + if (result.claimWriteFallback) { + // The native event actually wrote the selection; drop the in-flight + // writeText so a late resolution cannot clobber a later user copy. + this.copyShortcutToken += 1; + if (this.clearSelectionAfterCopy) { + this.clearSelectionAfterCopy = false; + this.clearSelection(); + } + } + }; + + private readonly onPaste = (event: ClipboardEvent) => { + // Always suppress the browser's default insertion: content the textarea + // would receive (for example an html-only clipboard converted to text) + // leaks through onInput without bracketed-paste encoding. + event.preventDefault(); + const data = event.clipboardData?.getData('text/plain') ?? ''; + if (data.length === 0) return; + // The native paste won the race with actual text; a pending clipboard read + // must not double. An empty native paste leaves the read as the only path. + this.pasteShortcutToken += 1; + this.options.onData(this.core.encodePaste(data)); + }; + + private readonly onCompositionStart = () => { + this.clearPrimedCopy(); + this.clearCompositionInputSuppression(); + this.composing = true; + }; + + private readonly onCompositionEnd = (event: CompositionEvent) => { + this.composing = false; + const data = this.input.value || event.data; + if (data.length > 0) this.options.onData(data); + this.input.value = ''; + this.compositionInputToSuppress = data; + this.compositionSuppressionTimer = window.setTimeout(() => { + this.compositionInputToSuppress = null; + this.compositionSuppressionTimer = null; + }, 100); + }; + + private readonly onInput = (event: Event) => { + // SAFETY: registered for the textarea's "input" event, which is dispatched as an InputEvent. + const inputEvent = event as InputEvent; + if (this.composing || inputEvent.isComposing) return; + const data = this.input.value || inputEvent.data || ''; + if (data === this.compositionInputToSuppress && isTerminalCompositionCommitInput(inputEvent)) { + this.clearCompositionInputSuppression(); + this.input.value = ''; + return; + } + this.clearCompositionInputSuppression(); + if (data.length > 0) this.options.onData(data); + this.input.value = ''; + }; + + private clearCompositionInputSuppression(): void { + if (this.compositionSuppressionTimer !== null) { + window.clearTimeout(this.compositionSuppressionTimer); + this.compositionSuppressionTimer = null; + } + this.compositionInputToSuppress = null; + } + + private ignoresTouchPointer(event: PointerEvent): boolean { + if (event.pointerType !== 'touch' || this.options.handleTouchPointer !== false) return false; + this.lastTouchPointerAt = event.timeStamp; + return true; + } + + private readonly onPointerDown = (event: PointerEvent) => { + if (this.ignoresTouchPointer(event)) return; + this.focus(); + if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { + const button = ghosttyMouseButton(event.button); + if (button === null) return; + event.preventDefault(); + event.stopPropagation(); + this.clearHoveredLink('default'); + this.mouseReportingPointerId = event.pointerId; + this.mouseReportingButton = button; + this.sendMouse('press', button, event); + this.canvas.setPointerCapture(event.pointerId); + return; + } + if (event.button !== 0) return; + if (isTerminalLinkPointerGesture(event)) { + event.preventDefault(); + event.stopPropagation(); + this.linkActivationPointerId = event.pointerId; + this.canvas.setPointerCapture(event.pointerId); + return; + } + this.clearHoveredLink(); + const cell = this.cellAt(event.clientX, event.clientY); + this.selectionMoved = false; + this.selectionClickSequence = advanceTerminalSelectionClickSequence( + this.selectionClickSequence, + event, + ); + const clickCount = this.selectionClickSequence.count; + this.selectionMode = clickCount >= 3 ? 'line' : clickCount === 2 ? 'word' : 'cell'; + const range = + this.selectionMode === 'line' + ? this.core.selectLine(cell.x, cell.y) + : this.selectionMode === 'word' + ? this.core.selectWord(cell.x, cell.y) + : null; + if (range) { + this.selectionBase = range.screen; + this.selectionEnd = range.viewport.end; + this.selectionAnchorScreen = range.screen.start; + this.selectionEndScreen = range.screen.end; + this.options.onSelectionChange?.(); + } else { + this.selectionMode = 'cell'; + this.selectionBase = null; + this.selectionEnd = cell; + const screen = this.core.viewportPointToScreen(cell.x, cell.y); + this.selectionAnchorScreen = screen; + this.selectionEndScreen = screen; + if (screen) { + this.core.setSelection({ ...screen, tag: 2 }, { ...screen, tag: 2 }); + } else { + this.core.setSelection(cell, cell); + } + } + this.forceFullRender = true; + this.canvas.setPointerCapture(event.pointerId); + this.requestRender(); + }; + + private readonly onPointerMove = (event: PointerEvent) => { + if (this.ignoresTouchPointer(event)) return; + if (this.linkActivationPointerId === event.pointerId) return; + // Hover motion is only reportable in any-event tracking (DEC 1003); normal and + // button-event tracking never report motion without a captured pressed button. + const anyEventTracking = this.synchronizeMouseTrackingState(); + if ( + this.mouseReportingPointerId === event.pointerId || + shouldReportTerminalMouse(anyEventTracking, event) + ) { + event.preventDefault(); + this.hoverPointer = { x: event.clientX, y: event.clientY }; + this.linkModifierActive = isTerminalLinkPointerGesture(event); + // A drag whose press was already sent to the terminal application cannot + // turn into link activation midway through, so link feedback would lie. + this.setHoveredLink(null); + this.canvas.style.cursor = 'default'; + this.sendMouse('motion', this.buttonFromButtons(event.buttons), event); + return; + } + this.lastMouseMotionData = ''; + if (!this.selectionAnchorScreen || !this.canvas.hasPointerCapture(event.pointerId)) { + this.updateHoverCursor(event); + return; + } + this.clearHoveredLink(); + this.selectionPointer = { x: event.clientX, y: event.clientY }; + const bounds = this.canvas.getBoundingClientRect(); + this.setSelectionAutoscroll( + event.clientY < bounds.top ? -1 : event.clientY > bounds.bottom ? 1 : 0, + ); + const cell = this.cellAt(event.clientX, event.clientY); + if (cell.x === this.selectionEnd?.x && cell.y === this.selectionEnd.y) return; + this.extendSelectionToPoint(event.clientX, event.clientY); + }; + + private extendSelectionToPoint(clientX: number, clientY: number): void { + const anchorScreen = this.selectionAnchorScreen; + if (anchorScreen === null) return; + const cell = this.cellAt(clientX, clientY); + this.selectionMoved = true; + this.selectionEnd = cell; + const range = + this.selectionMode === 'line' + ? this.core.selectLine(cell.x, cell.y) + : this.selectionMode === 'word' + ? this.core.selectWord(cell.x, cell.y) + : null; + const cellScreen = this.core.viewportPointToScreen(cell.x, cell.y); + if (cellScreen === null) return; + const base = this.selectionBase; + const beforeBase = + base !== null && + (cellScreen.y < base.start.y || + (cellScreen.y === base.start.y && cellScreen.x < base.start.x)); + const anchor = base === null ? anchorScreen : beforeBase ? base.end : base.start; + const end = range === null ? cellScreen : beforeBase ? range.screen.start : range.screen.end; + this.selectionAnchorScreen = anchor; + this.selectionEndScreen = end; + this.core.setSelection({ ...anchor, tag: 2 }, { ...end, tag: 2 }); + this.options.onSelectionChange?.(); + this.forceFullRender = true; + this.requestRender(); + } + + private setSelectionAutoscroll(delta: number): void { + this.selectionScrollDelta = delta; + if (delta === 0) { + if (this.selectionScrollTimer !== null) { + window.clearInterval(this.selectionScrollTimer); + this.selectionScrollTimer = null; + } + return; + } + if (this.selectionScrollTimer !== null) return; + // Dragging past the edge scrolls the viewport and keeps extending the + // selection into the newly revealed rows, like xterm's drag scroller. + this.selectionScrollTimer = window.setInterval(() => { + if (this.disposed || this.selectionScrollDelta === 0) return; + this.scrollViewport(this.selectionScrollDelta); + const pointer = this.selectionPointer; + if (pointer) this.extendSelectionToPoint(pointer.x, pointer.y); + }, 80); + } + + private updateHoverCursor(event: PointerEvent): void { + this.hoverPointer = { x: event.clientX, y: event.clientY }; + this.linkModifierActive = isTerminalLinkPointerGesture(event); + this.refreshHoveredLink(); + } + + private updateLinkModifier(event: Pick): void { + const active = isTerminalLinkPointerGesture(event); + if (active === this.linkModifierActive) return; + this.linkModifierActive = active; + this.refreshHoveredLink(); + } + + private readonly onPointerLeave = () => { + this.lastMouseMotionData = ''; + this.clearHoveredLink(); + }; + + private clearHoveredLink(cursor = ''): void { + this.hoverPointer = null; + this.setHoveredLink(null); + this.canvas.style.cursor = cursor; + } + + private refreshHoveredLink(): void { + const pointer = this.hoverPointer; + const link = pointer && this.linkModifierActive ? this.linkAt(pointer.x, pointer.y) : null; + this.setHoveredLink(link); + } + + private setHoveredLink(link: TerminalLinkWithRange | null): void { + const previous = this.hoveredLink; + const unchanged = + previous?.text === link?.text && + previous?.range.start.x === link?.range.start.x && + previous?.range.start.y === link?.range.start.y && + previous?.range.end.x === link?.range.end.x && + previous?.range.end.y === link?.range.end.y; + this.canvas.style.cursor = link ? 'pointer' : ''; + if (unchanged) return; + this.hoveredLink = link; + this.forceFullRender = true; + this.requestRender(); + } + + private readonly onPointerUp = (event: PointerEvent) => { + if (this.ignoresTouchPointer(event)) return; + this.setSelectionAutoscroll(0); + if (this.linkActivationPointerId === event.pointerId) { + event.preventDefault(); + event.stopPropagation(); + this.linkActivationPointerId = null; + if (this.canvas.hasPointerCapture(event.pointerId)) { + this.canvas.releasePointerCapture(event.pointerId); + } + if (event.type !== 'pointercancel') { + const link = this.linkAt(event.clientX, event.clientY); + if (link) this.options.onLinkActivate?.(link.text, event); + } + return; + } + if (this.mouseReportingPointerId === event.pointerId) { + event.preventDefault(); + event.stopPropagation(); + this.sendMouse('release', this.mouseReportingButton, event); + this.mouseReportingPointerId = null; + this.mouseReportingButton = null; + if (this.canvas.hasPointerCapture(event.pointerId)) { + this.canvas.releasePointerCapture(event.pointerId); + } + if (event.type === 'pointercancel') { + this.clearHoveredLink(); + } else { + this.hoverPointer = { x: event.clientX, y: event.clientY }; + this.linkModifierActive = isTerminalLinkPointerGesture(event); + this.refreshHoveredLink(); + } + return; + } + if (this.canvas.hasPointerCapture(event.pointerId)) { + this.canvas.releasePointerCapture(event.pointerId); + } + if (event.button !== 0) return; + if (!this.selectionMoved && this.selectionMode === 'cell') { + this.clearSelection(); + } + this.options.onSelectionChange?.(); + }; + + private readonly onWheel = (event: WheelEvent) => { + if (event.deltaY === 0) return; + event.preventDefault(); + const delta = terminalWheelDeltaRows( + event, + this.metrics.height, + this.rows, + this.wheelRemainder, + ); + this.wheelRemainder = delta.remainder; + if (delta.rows === 0) return; + const magnitude = Math.abs(delta.rows); + if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { + const button = delta.rows < 0 ? 4 : 5; + for (let index = 0; index < magnitude; index += 1) { + this.sendMouse('press', button, event); + } + return; + } + if (this.core.isAlternateScreen()) { + // The alternate screen has no scrollback: translate wheel motion into + // arrow keys so full-screen apps like vim and less scroll, matching xterm. + this.options.onData(terminalWheelArrowData(delta.rows, this.core.isApplicationCursorKeys())); + return; + } + this.scrollViewport(delta.rows); + }; + + private readonly onMouseDown = (event: MouseEvent) => { + if (event.button === 0) event.preventDefault(); + if (event.timeStamp - this.lastTouchPointerAt < TOUCH_MOUSE_COMPAT_WINDOW_MS) return; + this.focus(); + }; + + private readonly onContextMenu = (event: MouseEvent) => { + if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { + event.preventDefault(); + return; + } + this.options.onContextMenu?.(event); + }; + + private readonly onScrollbarPointerDown = (event: PointerEvent) => { + if (event.button !== 0) return; + const state = this.readScrollbarState(); + if (state === null) return; + const bounds = this.scrollbar.getBoundingClientRect(); + const geometry = terminalScrollbarGeometry(state, bounds.height); + if (geometry === null) return; + event.preventDefault(); + event.stopPropagation(); + this.scrollbarPointerId = event.pointerId; + this.scrollbarPointerOffset = + event.target === this.scrollbarThumb + ? event.clientY - bounds.top - geometry.thumbTop + : geometry.thumbHeight / 2; + this.scrollbar.setPointerCapture(event.pointerId); + this.scrollbarToPointer(event.clientY, bounds); + }; + + private readonly onScrollbarPointerMove = (event: PointerEvent) => { + if (event.pointerId !== this.scrollbarPointerId || this.scrollbarState === null) return; + event.preventDefault(); + this.scrollbarToPointer(event.clientY, this.scrollbar.getBoundingClientRect()); + }; + + private readonly onScrollbarPointerUp = (event: PointerEvent) => { + if (event.pointerId !== this.scrollbarPointerId) return; + event.preventDefault(); + this.scrollbarPointerId = null; + if (this.scrollbar.hasPointerCapture(event.pointerId)) { + this.scrollbar.releasePointerCapture(event.pointerId); + } + }; + + private readonly onScrollbarKeyDown = (event: KeyboardEvent) => { + const state = this.readScrollbarState(); + if (state === null) return; + let delta = 0; + switch (event.key) { + case 'ArrowUp': + delta = -1; + break; + case 'ArrowDown': + delta = 1; + break; + case 'PageUp': + delta = -Math.max(1, state.len); + break; + case 'PageDown': + delta = Math.max(1, state.len); + break; + case 'Home': + delta = -state.offset; + break; + case 'End': + delta = state.total - state.len - state.offset; + break; + default: + return; + } + event.preventDefault(); + event.stopPropagation(); + this.scrollViewport(delta); + }; + + private installEvents(): void { + this.input.addEventListener('keydown', this.onKeyDown); + this.input.addEventListener('keyup', this.onKeyUp); + this.input.addEventListener('focus', this.onFocus); + this.input.addEventListener('blur', this.onBlur); + this.input.addEventListener('input', this.onInput); + this.input.addEventListener('paste', this.onPaste); + this.input.addEventListener('copy', this.onCopyEvent); + this.input.addEventListener('compositionstart', this.onCompositionStart); + this.input.addEventListener('compositionend', this.onCompositionEnd); + this.canvas.addEventListener('pointerdown', this.onPointerDown); + this.canvas.addEventListener('pointermove', this.onPointerMove); + this.canvas.addEventListener('pointerleave', this.onPointerLeave); + this.canvas.addEventListener('pointerup', this.onPointerUp); + this.canvas.addEventListener('pointercancel', this.onPointerUp); + this.canvas.addEventListener('wheel', this.onWheel, { passive: false }); + this.canvas.addEventListener('mousedown', this.onMouseDown); + this.canvas.addEventListener('contextmenu', this.onContextMenu); + this.scrollbar.addEventListener('pointerdown', this.onScrollbarPointerDown); + this.scrollbar.addEventListener('pointermove', this.onScrollbarPointerMove); + this.scrollbar.addEventListener('pointerup', this.onScrollbarPointerUp); + this.scrollbar.addEventListener('pointercancel', this.onScrollbarPointerUp); + this.scrollbar.addEventListener('keydown', this.onScrollbarKeyDown); + } + + private removeEvents(): void { + this.input.removeEventListener('keydown', this.onKeyDown); + this.input.removeEventListener('keyup', this.onKeyUp); + this.input.removeEventListener('focus', this.onFocus); + this.input.removeEventListener('blur', this.onBlur); + this.input.removeEventListener('input', this.onInput); + this.input.removeEventListener('paste', this.onPaste); + this.input.removeEventListener('copy', this.onCopyEvent); + this.input.removeEventListener('compositionstart', this.onCompositionStart); + this.input.removeEventListener('compositionend', this.onCompositionEnd); + this.canvas.removeEventListener('pointerdown', this.onPointerDown); + this.canvas.removeEventListener('pointermove', this.onPointerMove); + this.canvas.removeEventListener('pointerleave', this.onPointerLeave); + this.canvas.removeEventListener('pointerup', this.onPointerUp); + this.canvas.removeEventListener('pointercancel', this.onPointerUp); + this.canvas.removeEventListener('wheel', this.onWheel); + this.canvas.removeEventListener('mousedown', this.onMouseDown); + this.canvas.removeEventListener('contextmenu', this.onContextMenu); + this.scrollbar.removeEventListener('pointerdown', this.onScrollbarPointerDown); + this.scrollbar.removeEventListener('pointermove', this.onScrollbarPointerMove); + this.scrollbar.removeEventListener('pointerup', this.onScrollbarPointerUp); + this.scrollbar.removeEventListener('pointercancel', this.onScrollbarPointerUp); + this.scrollbar.removeEventListener('keydown', this.onScrollbarKeyDown); + } + + private scrollViewport(deltaRows: number): void { + let delta = Math.trunc(deltaRows); + const state = this.readScrollbarState(); + if (state !== null) { + const maxOffset = Math.max(0, state.total - state.len); + const offset = Math.max(0, Math.min(state.offset + delta, maxOffset)); + delta = offset - state.offset; + this.scrollbarState = { ...state, offset }; + } + if (delta === 0) return; + this.core.scroll(delta); + this.forceFullRender = true; + this.scrollbarDirty = true; + this.requestRender(); + } + + private scrollbarToPointer(clientY: number, bounds: DOMRect): void { + const state = this.scrollbarState; + if (state === null) return; + const offset = terminalScrollbarOffsetAtPointer( + state, + bounds.height, + clientY - bounds.top, + this.scrollbarPointerOffset, + ); + this.scrollViewport(offset - state.offset); + } + + private updateScrollbar(): void { + const state = this.readScrollbarState(); + const geometry = + state === null + ? null + : terminalScrollbarGeometry( + state, + Math.max(0, this.mount.clientHeight - CONTENT_PADDING * 2), + ); + this.scrollbar.hidden = geometry === null; + if (state === null || geometry === null) return; + this.scrollbar.setAttribute('aria-valuemin', '0'); + this.scrollbar.setAttribute('aria-valuemax', String(geometry.maxOffset)); + this.scrollbar.setAttribute( + 'aria-valuenow', + String(Math.max(0, Math.min(state.offset, geometry.maxOffset))), + ); + this.scrollbarThumb.style.height = `${geometry.thumbHeight}px`; + this.scrollbarThumb.style.transform = `translateY(${geometry.thumbTop}px)`; + } + + private readScrollbarState(): GhosttyScrollbar | null { + const state = this.core.scrollbarState(); + this.scrollbarState = state; + return state; + } + + private requestRender(): void { + if (this.disposed || !this.visible || !this.hasSize || this.frame !== 0) return; + this.frame = window.requestAnimationFrame(() => { + this.frame = 0; + this.renderFrame(); + }); + } + + private cancelRender(): void { + if (this.frame !== 0) { + window.cancelAnimationFrame(this.frame); + this.frame = 0; + } + if (this.cursorTimer !== null) { + window.clearTimeout(this.cursorTimer); + this.cursorTimer = null; + } + } + + private renderFrame(): void { + if (this.disposed || !this.visible) return; + if (this.frame !== 0) { + window.cancelAnimationFrame(this.frame); + this.frame = 0; + } + // Hidden thread drawers stay mounted so switching back is instant, but a + // display:none canvas has nothing to show. Ghostty keeps parsing; the + // ResizeObserver refits and repaints in full once the mount has a size. + if (this.mount.clientWidth === 0 || this.mount.clientHeight === 0) { + this.hasSize = false; + this.forceFullRender = true; + this.cancelRender(); + return; + } + this.snapshot = this.core.snapshot(); + // A cursor that is not blinking right now must be drawn, never caught in an + // off phase left behind by a blink that has since been turned off. + if (!this.blinkEnabled()) this.cursorOn = true; + // The origin only moves together with a forced full repaint: partial + // dirty-row redraws must never composite rows at a shifted origin over + // rows painted at the previous one. Bottom anchoring starts once + // scrollback exists, i.e. when the prompt actually lives at the bottom. + const scrollState = this.readScrollbarState(); + const anchorBottom = scrollState !== null && scrollState.total > scrollState.len; + const nextOriginY = terminalContentOriginY( + this.mountHeight, + CONTENT_PADDING, + this.rows, + this.metrics.height, + anchorBottom, + ); + if (nextOriginY !== this.originY) { + this.originY = nextOriginY; + this.forceFullRender = true; + } + this.refreshHoveredLink(); + renderGhosttySnapshot({ + context: this.context, + snapshot: this.snapshot, + metrics: this.metrics, + fontSize: this.fontSize, + fontFamily: this.fontFamily, + padding: CONTENT_PADDING, + originY: this.originY, + forceFull: this.forceFullRender, + cursorOn: this.cursorOn, + previousCursorY: this.renderedCursorY, + focused: this.focused, + hoveredLinkRange: this.hoveredLink?.range ?? null, + selectionBackground: this.theme.selectionBackground, + }); + this.positionInput(); + this.renderedCursorY = + this.cursorOn && this.snapshot.cursorVisible && this.snapshot.cursorY >= 0 + ? this.snapshot.cursorY + : null; + if (this.scrollbarDirty) { + this.scrollbarDirty = false; + this.updateScrollbar(); + } + this.forceFullRender = false; + this.scheduleCursorBlink(); + } + + private scheduleCursorBlink(): void { + if (this.cursorTimer !== null) window.clearTimeout(this.cursorTimer); + this.cursorTimer = null; + if (!this.blinkEnabled()) return; + this.cursorTimer = window.setTimeout(() => { + this.cursorTimer = null; + this.cursorOn = !this.cursorOn; + this.requestRender(); + }, CURSOR_BLINK_INTERVAL_MS); + } + + private blinkEnabled(): boolean { + const snapshot = this.snapshot; + if (!snapshot || !this.visible || !this.hasSize) return false; + return shouldBlinkTerminalCursor({ + focused: this.focused, + cursorBlinking: snapshot.cursorBlinking, + cursorVisible: snapshot.cursorVisible, + reducedMotion: this.reducedMotionMedia?.matches ?? false, + }); + } + + private positionInput(): void { + const snapshot = this.snapshot; + if (!snapshot || !snapshot.cursorVisible || snapshot.cursorX < 0 || snapshot.cursorY < 0) { + return; + } + // The IME candidate window anchors to the textarea, so it must follow the + // terminal cursor for composition to appear where the user is typing. + const left = CONTENT_PADDING + snapshot.cursorX * this.metrics.width; + const top = this.originY + snapshot.cursorY * this.metrics.height; + if (left === this.inputLeft && top === this.inputTop) return; + this.inputLeft = left; + this.inputTop = top; + this.input.style.left = `${left}px`; + this.input.style.top = `${top}px`; + this.input.style.height = `${this.metrics.height}px`; + } + + private cellAt(clientX: number, clientY: number): GhosttyGridPoint { + const bounds = this.canvas.getBoundingClientRect(); + return { + x: Math.max( + 0, + Math.min( + this.cols - 1, + Math.floor((clientX - bounds.left - CONTENT_PADDING) / this.metrics.width), + ), + ), + y: Math.max( + 0, + Math.min( + this.rows - 1, + Math.floor((clientY - bounds.top - this.originY) / this.metrics.height), + ), + ), + }; + } + + private linkAt(clientX: number, clientY: number): TerminalLinkWithRange | null { + if (!this.snapshot) return null; + const cell = terminalGridCellAt({ + bounds: this.canvas.getBoundingClientRect(), + clientX, + clientY, + cols: this.cols, + rows: this.rows, + metrics: this.metrics, + padding: CONTENT_PADDING, + originY: this.originY, + }); + if (!cell) return null; + const explicitHyperlink = this.core.hyperlinkAt(cell.x, cell.y); + if (explicitHyperlink) { + const start = { ...cell }; + const end = { ...cell }; + while (true) { + const previous = + start.x > 0 + ? { x: start.x - 1, y: start.y } + : start.y > 0 && this.snapshot.rowData[start.y]?.isWrapContinuation + ? { x: this.cols - 1, y: start.y - 1 } + : null; + if (!previous || this.core.hyperlinkAt(previous.x, previous.y) !== explicitHyperlink) break; + start.x = previous.x; + start.y = previous.y; + } + while (true) { + const next = + end.x + 1 < this.cols + ? { x: end.x + 1, y: end.y } + : end.y + 1 < this.rows && this.snapshot.rowData[end.y]?.wrapsToNext + ? { x: 0, y: end.y + 1 } + : null; + if (!next || this.core.hyperlinkAt(next.x, next.y) !== explicitHyperlink) break; + end.x = next.x; + end.y = next.y; + } + return { + text: explicitHyperlink, + range: { start, end }, + }; + } + return terminalLinkAtPositionWithRange(this.snapshot.rowData, cell.y, cell.x); + } + + private sendMouse(action: TerminalMouseAction, button: number | null, event: MouseEvent): void { + const bounds = this.canvas.getBoundingClientRect(); + const data = this.core.encodeMouse({ + action, + button, + mods: + (event.shiftKey ? 1 : 0) | + (event.ctrlKey ? 1 << 1 : 0) | + (event.altKey ? 1 << 2 : 0) | + (event.metaKey ? 1 << 3 : 0), + x: Math.max(0, event.clientX - bounds.left), + y: Math.max(0, event.clientY - bounds.top), + screenWidth: bounds.width, + screenHeight: bounds.height, + cellWidth: this.metrics.width, + cellHeight: this.metrics.height, + paddingLeft: CONTENT_PADDING, + paddingRight: CONTENT_PADDING, + paddingTop: this.originY, + paddingBottom: Math.max(0, bounds.height - this.originY - this.rows * this.metrics.height), + anyButtonPressed: event.buttons !== 0, + }); + const resolution = resolveTerminalMouseData(action, data, this.lastMouseMotionData); + this.lastMouseMotionData = resolution.nextMotionData; + if (resolution.send) this.options.onData(data); + } + + private synchronizeMouseTrackingState(): boolean { + // Output writes can toggle DEC 1003 without moving the pointer. Keep the + // previous mode so the next same-cell motion starts a fresh tracking session. + const tracking = this.core.isMouseAnyEventTracking(); + const state = resolveTerminalMouseTrackingState( + this.mouseAnyEventTracking, + tracking, + this.lastMouseMotionData, + ); + this.mouseAnyEventTracking = state.tracking; + this.lastMouseMotionData = state.motionData; + return tracking; + } + + private buttonFromButtons(buttons: number): number | null { + if ((buttons & 1) !== 0) return 1; + if ((buttons & 4) !== 0) return 3; + if ((buttons & 2) !== 0) return 2; + if ((buttons & 8) !== 0) return 4; + if ((buttons & 16) !== 0) return 5; + return null; + } +} diff --git a/packages/ui/src/lib/ghostty/terminalLinks.test.ts b/packages/ui/src/lib/ghostty/terminalLinks.test.ts new file mode 100644 index 00000000..7dd8dede --- /dev/null +++ b/packages/ui/src/lib/ghostty/terminalLinks.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'bun:test'; + +import { collectWrappedTerminalLinkLine, extractTerminalLinks } from './terminalLinks'; + +describe('extractTerminalLinks', () => { + test('finds http(s) URLs and trims trailing punctuation and unbalanced brackets', () => { + expect(extractTerminalLinks('see https://example.com/a?b=1). and http://x.y/z,')).toEqual([ + { text: 'https://example.com/a?b=1', start: 4, end: 29 }, + { text: 'http://x.y/z', start: 36, end: 48 }, + ]); + expect(extractTerminalLinks('(https://example.com/(a))')).toEqual([ + { text: 'https://example.com/(a)', start: 1, end: 24 }, + ]); + }); + + test('ignores bare paths and other schemes', () => { + expect(extractTerminalLinks('src/lib/x.ts:12 ftp://host/file')).toEqual([]); + }); +}); + +describe('collectWrappedTerminalLinkLine', () => { + test('joins wrapped rows and records each segment offset', () => { + const lines = [ + { isWrapped: false, translateToString: () => 'abc' }, + { isWrapped: true, translateToString: () => 'def' }, + { isWrapped: false, translateToString: () => 'ghi' }, + ]; + expect(collectWrappedTerminalLinkLine(2, (index) => lines[index])).toEqual({ + text: 'abcdef', + segments: [ + { bufferLineNumber: 1, text: 'abc', startIndex: 0, endIndex: 3 }, + { bufferLineNumber: 2, text: 'def', startIndex: 3, endIndex: 6 }, + ], + }); + }); + + test('returns null when the wrapped head is unavailable', () => { + const lines = [undefined, { isWrapped: true, translateToString: () => 'x' }]; + expect(collectWrappedTerminalLinkLine(2, (index) => lines[index])).toBeNull(); + }); +}); diff --git a/packages/ui/src/lib/ghostty/terminalLinks.ts b/packages/ui/src/lib/ghostty/terminalLinks.ts new file mode 100644 index 00000000..2b2783f7 --- /dev/null +++ b/packages/ui/src/lib/ghostty/terminalLinks.ts @@ -0,0 +1,113 @@ +// Adapted from T3 Code's libghostty-vt browser adapter (MIT, T3 Tools Inc.). +// See LICENSE-T3CODE in this directory. + +export interface TerminalLinkMatch { + readonly text: string; + readonly start: number; + readonly end: number; +} + +export interface TerminalBufferLineLike { + readonly isWrapped?: boolean; + translateToString(trimRight?: boolean): string; +} + +export interface WrappedTerminalLinkLineSegment { + readonly bufferLineNumber: number; + readonly text: string; + readonly startIndex: number; + readonly endIndex: number; +} + +export interface WrappedTerminalLinkLine { + readonly text: string; + readonly segments: ReadonlyArray; +} + +const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/giu; +const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/; + +function trimClosingDelimiters(value: string): string { + let output = value.replace(TRAILING_PUNCTUATION_PATTERN, ''); + if (output.length === 0) return output; + + const trimUnbalanced = (open: string, close: string) => { + while (output.endsWith(close)) { + const opens = output.split(open).length - 1; + const closes = output.split(close).length - 1; + if (opens >= closes) return; + output = output.slice(0, -1); + } + }; + + trimUnbalanced('(', ')'); + trimUnbalanced('[', ']'); + trimUnbalanced('{', '}'); + return output; +} + +/** http(s) URLs in one logical line, with trailing punctuation and unbalanced brackets trimmed. */ +export function extractTerminalLinks(line: string): TerminalLinkMatch[] { + const matches: TerminalLinkMatch[] = []; + URL_PATTERN.lastIndex = 0; + for (const rawMatch of line.matchAll(URL_PATTERN)) { + const raw = rawMatch[0]; + const start = rawMatch.index ?? -1; + if (start < 0 || raw.length === 0) continue; + const trimmed = trimClosingDelimiters(raw); + if (trimmed.length === 0) continue; + matches.push({ text: trimmed, start, end: start + trimmed.length }); + } + return matches; +} + +/** + * Joins a soft-wrapped line back together so a URL that the terminal broke + * across rows matches as one string, remembering where each row's text sits. + */ +export function collectWrappedTerminalLinkLine( + bufferLineNumber: number, + getLine: (bufferLineIndex: number) => TerminalBufferLineLike | null | undefined, +): WrappedTerminalLinkLine | null { + const anchorLine = getLine(bufferLineNumber - 1); + if (!anchorLine) return null; + + let startBufferLineNumber = bufferLineNumber; + let startLine = anchorLine; + + while (startBufferLineNumber > 1 && startLine.isWrapped) { + const previousLine = getLine(startBufferLineNumber - 2); + if (!previousLine) return null; + startBufferLineNumber -= 1; + startLine = previousLine; + } + + const segments: WrappedTerminalLinkLineSegment[] = []; + let nextStartIndex = 0; + let currentBufferLineNumber = startBufferLineNumber; + + while (true) { + const currentLine = getLine(currentBufferLineNumber - 1); + if (!currentLine) break; + + const nextLine = getLine(currentBufferLineNumber); + const hasWrappedContinuation = nextLine?.isWrapped === true; + const text = currentLine.translateToString(!hasWrappedContinuation); + + segments.push({ + bufferLineNumber: currentBufferLineNumber, + text, + startIndex: nextStartIndex, + endIndex: nextStartIndex + text.length, + }); + nextStartIndex += text.length; + + if (!hasWrappedContinuation) break; + currentBufferLineNumber += 1; + } + + return { + text: segments.map((segment) => segment.text).join(''), + segments, + }; +} diff --git a/packages/ui/src/lib/ghostty/vendor/LICENSE b/packages/ui/src/lib/ghostty/vendor/LICENSE new file mode 100644 index 00000000..0a07a66c --- /dev/null +++ b/packages/ui/src/lib/ghostty/vendor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/ui/src/lib/ghostty/vendor/VERSION b/packages/ui/src/lib/ghostty/vendor/VERSION new file mode 100644 index 00000000..aa5d5e74 --- /dev/null +++ b/packages/ui/src/lib/ghostty/vendor/VERSION @@ -0,0 +1 @@ +9f62873bf195e4d8a762d768a1405a5f2f7b1697 diff --git a/packages/ui/src/lib/ghostty/vendor/ghostty-vt.wasm b/packages/ui/src/lib/ghostty/vendor/ghostty-vt.wasm new file mode 100644 index 00000000..a8e2405a Binary files /dev/null and b/packages/ui/src/lib/ghostty/vendor/ghostty-vt.wasm differ diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index 1522154f..746d3425 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -984,6 +984,15 @@ export async function getCommitFiles( return gitHttp.getCommitFiles(directory, hash); } +export async function getGitCommitDiff(directory: string, options: import('./api/types').GetGitCommitDiffOptions): Promise { + const runtime = getRuntimeGit(); + if (runtime) { + if (!runtime.getGitCommitDiff) throw new Error('Commit comparisons are unavailable in this runtime'); + return runtime.getGitCommitDiff(directory, options); + } + return gitHttp.getGitCommitDiff(directory, options); +} + export async function getCommitFileDiff( directory: string, hash: string, diff --git a/packages/ui/src/lib/gitApiHttp.test.ts b/packages/ui/src/lib/gitApiHttp.test.ts index 1067481d..b7650b42 100644 --- a/packages/ui/src/lib/gitApiHttp.test.ts +++ b/packages/ui/src/lib/gitApiHttp.test.ts @@ -13,6 +13,11 @@ import { deleteRemoteBranch, dropGitStash, getGitBranches, + getGitRangeDiff, + getGitRangeFiles, + getGitCommitDiff, + getCommitFiles, + getGitLog, getGitStatus, gitFetch, merge, @@ -141,6 +146,74 @@ describe('gitApiHttp index mutations', () => { }); }); +describe('gitApiHttp branch comparisons', () => { + test('sends commit hashes and rename paths without trimming and rejects incomplete commit lists', async () => { + installWindowMock(); + const urls: URL[] = []; + globalThis.fetch = Object.assign(async (input: RequestInfo | URL) => { + const url = new URL(String(input), 'http://localhost'); + urls.push(url); + return Response.json(url.pathname.endsWith('/commit-diff') ? { diff: 'commit patch' } : { files: [{ path: 'incomplete' }] }); + }, previousFetch); + try { + const hash = 'a'.repeat(40); + expect(await getGitCommitDiff('/repo', { hash, path: ' new\nfile.ts', previousPath: 'old.ts', contextLines: 20 })) + .toEqual({ diff: 'commit patch' }); + expect(urls[0].pathname).toBe('/api/git/commit-diff'); + expect(urls[0].searchParams.get('hash')).toBe(hash); + expect(urls[0].searchParams.get('path')).toBe(' new\nfile.ts'); + expect(urls[0].searchParams.get('previousPath')).toBe('old.ts'); + expect(urls[0].searchParams.get('context')).toBe('20'); + await expect(getCommitFiles('/repo', hash)).rejects.toThrow(); + await expect(getGitLog('/repo', { maxCount: 50, to: 'refs/heads/feature' })).rejects.toThrow(); + expect(urls[2].searchParams.get('maxCount')).toBe('50'); + expect(urls[2].searchParams.get('to')).toBe('refs/heads/feature'); + expect(urls[2].searchParams.has('all')).toBe(false); + } finally { + restoreMocks(); + } + }); + + test('sends the exact selected refs and working-tree option to both range endpoints', async () => { + installWindowMock(); + const urls: URL[] = []; + globalThis.fetch = Object.assign(async (input: RequestInfo | URL) => { + const url = new URL(String(input), 'http://localhost'); + urls.push(url); + return Response.json(url.pathname.endsWith('/range-files') + ? { files: [{ path: 'new.ts', status: 'A' }] } + : { diff: 'current patch' }); + }, previousFetch); + try { + const options = { base: 'refs/heads/parent', head: 'child', includeWorkingTree: true }; + expect(await getGitRangeDiff('/repo', options)).toEqual({ diff: 'current patch' }); + expect(await getGitRangeFiles('/repo', options)).toEqual([{ path: 'new.ts', status: 'A' }]); + expect(urls).toHaveLength(2); + for (const url of urls) { + expect(url.searchParams.get('base')).toBe('refs/heads/parent'); + expect(url.searchParams.get('head')).toBe('child'); + expect(url.searchParams.get('includeWorkingTree')).toBe('true'); + } + } finally { + restoreMocks(); + } + }); + + test('rejects malformed file lists and preserves the server ref error', async () => { + installWindowMock(); + globalThis.fetch = Object.assign(async () => Response.json({ files: [{ path: 'new.ts' }] }), previousFetch); + const options = { base: 'missing', head: 'child', includeWorkingTree: true }; + try { + await expect(getGitRangeFiles('/repo', options)).rejects.toThrow(); + globalThis.fetch = Object.assign(async () => Response.json({ error: 'Fetch the selected ref first.' }, { status: 500 }), previousFetch); + await expect(getGitRangeDiff('/repo', options)).rejects.toThrow('Fetch the selected ref first.'); + await expect(getGitRangeFiles('/repo', options)).rejects.toThrow('Fetch the selected ref first.'); + } finally { + restoreMocks(); + } + }); +}); + describe('gitApiHttp status cache', () => { test('a Git refresh hint invalidates the cached status before listeners fetch', async () => { installWindowMock(); diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 7eda4da4..10210893 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -1,9 +1,11 @@ +import { z } from 'zod'; import type { GitStatus, GitDiffResponse, GetGitDiffOptions, GetGitRangeDiffOptions, GetGitRangeFilesOptions, + GetGitCommitDiffOptions, GitFileDiffResponse, GetGitFileDiffOptions, GitBranch, @@ -43,6 +45,24 @@ import { getRuntimeKey } from './runtime-switch'; import { notifyGitStatusInvalidated, subscribeGitStatusInvalidations } from './gitStatusInvalidation'; const API_BASE = '/api/git'; +const gitRangeDiffSchema = z.object({ diff: z.string() }); +const gitRangeFilesSchema = z.object({ files: z.array(z.object({ path: z.string(), status: z.string() })) }); +const gitRangeErrorSchema = z.object({ error: z.string() }); +const gitCommitFilesSchema = z.object({ files: z.array(z.object({ + path: z.string(), previousPath: z.string().optional(), changeType: z.string(), + insertions: z.number(), deletions: z.number(), isBinary: z.boolean(), +})) }); +const gitLogEntrySchema = z.object({ + hash: z.string(), date: z.string(), message: z.string(), refs: z.string(), body: z.string(), + author_name: z.string(), author_email: z.string(), filesChanged: z.number(), + insertions: z.number(), deletions: z.number(), parents: z.array(z.string()), +}); +const gitLogSchema = z.object({ all: z.array(gitLogEntrySchema), latest: gitLogEntrySchema.nullable(), total: z.number() }); + +async function rangeResponseError(response: Response, fallback: string): Promise { + const parsed = gitRangeErrorSchema.safeParse(await response.json().catch(() => null)); + return new Error(parsed.success ? parsed.data.error : `${fallback}: ${response.statusText}`); +} const GIT_STATUS_CACHE_TTL_MS = 1200; const GIT_REPO_CHECK_CACHE_TTL_MS = 5000; const gitStatusCache = new Map(); @@ -285,7 +305,7 @@ export async function getGitRangeDiff( directory: string, options: GetGitRangeDiffOptions ): Promise { - const { base, head, path, contextLines } = options; + const { base, head, path, contextLines, includeWorkingTree } = options; if (!base || !head) { throw new Error('base and head are required to fetch git range diff'); } @@ -296,40 +316,46 @@ export async function getGitRangeDiff( head, path: path || undefined, context: contextLines, + includeWorkingTree, }) ); if (!response.ok) { - throw new Error(`Failed to get git range diff: ${response.statusText}`); + throw await rangeResponseError(response, 'Failed to get git range diff'); } - return response.json(); + return gitRangeDiffSchema.parse(await response.json()); +} + +export async function getGitCommitDiff(directory: string, options: GetGitCommitDiffOptions): Promise { + const response = await runtimeFetch(buildUrl(`${API_BASE}/commit-diff`, directory, { + hash: options.hash, + path: options.path, + previousPath: options.previousPath, + context: options.contextLines, + })); + if (!response.ok) throw await rangeResponseError(response, 'Failed to get commit diff'); + return gitRangeDiffSchema.parse(await response.json()); } export async function getGitRangeFiles( directory: string, options: GetGitRangeFilesOptions ): Promise { - const { base, head } = options; + const { base, head, includeWorkingTree } = options; if (!base || !head) { throw new Error('base and head are required to fetch git range files'); } const response = await runtimeFetch( - buildUrl(`${API_BASE}/range-files`, directory, { base, head }) + buildUrl(`${API_BASE}/range-files`, directory, { base, head, includeWorkingTree }) ); if (!response.ok) { - throw new Error(`Failed to get git range files: ${response.statusText}`); + throw await rangeResponseError(response, 'Failed to get git range files'); } - const payload = (await response.json()) as { files?: unknown }; - if (!Array.isArray(payload.files)) return []; - return payload.files.filter((entry): entry is import('./api/types').GitRangeFileEntry => { - if (!entry || typeof entry !== 'object') return false; - const candidate = entry as { path?: unknown; status?: unknown }; - return typeof candidate.path === 'string' && typeof candidate.status === 'string'; - }); + return gitRangeFilesSchema.parse(await response.json()).files; } export async function getBranchBase( @@ -944,7 +970,7 @@ export async function getGitLog( const errorBody = await response.json().catch(() => ({ error: response.statusText })); throw new Error(`Failed to get git log: ${errorBody.error || response.statusText}`); } - return response.json(); + return gitLogSchema.parse(await response.json()); } export async function getCommitFiles( @@ -955,9 +981,9 @@ export async function getCommitFiles( buildUrl(`${API_BASE}/commit-files`, directory, { hash }) ); if (!response.ok) { - throw new Error(`Failed to get commit files: ${response.statusText}`); + throw await rangeResponseError(response, 'Failed to get commit files'); } - return response.json(); + return gitCommitFilesSchema.parse(await response.json()); } export async function getCommitFileDiff( diff --git a/packages/ui/src/lib/i18n/messages.test.ts b/packages/ui/src/lib/i18n/messages.test.ts index c51992fd..2c43672f 100644 --- a/packages/ui/src/lib/i18n/messages.test.ts +++ b/packages/ui/src/lib/i18n/messages.test.ts @@ -44,4 +44,27 @@ describe('i18n dictionaries', () => { expect(dictionary['common.language.japanese']).toBeTruthy(); } }); + + test('telemetry translations retain the numeric token placeholders', () => { + for (const dictionary of Object.values(localeDictionaries)) { + expect(dictionary['chat.workStatus.telemetry.tokens.inOut']).toContain('{input}'); + expect(dictionary['chat.workStatus.telemetry.tokens.inOut']).toContain('{output}'); + for (const parameter of ['input', 'output', 'reasoning']) { + expect(dictionary['chat.workStatus.telemetry.tokensDescription']).toContain(`{${parameter}}`); + } + } + }); + + test('all telemetry rows have translated explanations and compact labels', () => { + const metrics = ['responseSpeed', 'speed', 'llmDuration', 'toolDuration', 'ttft', 'steps', 'tokens', 'cacheHit', 'cost'] as const; + for (const [locale, dictionary] of Object.entries(localeDictionaries)) { + for (const metric of metrics) { + const label = dictionary[`chat.workStatus.telemetry.${metric}`]; + const description = dictionary[`chat.workStatus.telemetry.${metric}Description`]; + expect(label.length <= 16).toBe(true); + expect(description.length > 30).toBe(true); + if (locale !== 'en') expect(description === enDict[`chat.workStatus.telemetry.${metric}Description`]).toBe(false); + } + } + }); }); diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 174aa5a0..b9ff4a67 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1,5 +1,7 @@ import { linearIntegrationI18n } from './linear-integration.i18n'; export const settingsDict = { + 'settings.openchamber.visual.field.alwaysShowScrollbars': 'Bildlaufleisten immer anzeigen', + 'settings.openchamber.visual.field.alwaysShowScrollbarsHint': 'Bildlaufleisten bleiben sichtbar, auch wenn sich der Mauszeiger außerhalb des scrollbaren Bereichs befindet. Gilt nur auf diesem Gerät.', 'settings.providers.page.openCodeGo.title': 'OpenCode Go Nutzungsverfolgung', 'settings.providers.page.openCodeGo.description': 'Verbinden Sie das OpenCode Go Dashboard, um rollierenden, wöchentlichen und monatlichen Verbrauch anzuzeigen.', 'settings.providers.page.openCodeGo.workspaceId': 'Workspace-ID', @@ -53,7 +55,6 @@ export const settingsDict = { 'settings.view.pendingRestart.confirm.cancel': 'Abbrechen', 'settings.view.actions.backToSettings': 'Zurück zu Einstellungen', 'settings.view.actions.closeSettings': 'Einstellungen schließen', - 'settings.view.actions.openSectionList': 'Abschnittsliste öffnen', 'settings.view.actions.closeSettingsWithShortcut': 'Einstellungen schließen ({shortcut}+,)', 'settings.view.actions.back': 'Zurück', 'settings.view.actions.resizeNavigation': 'Einstellungsnavigation skalieren', @@ -444,6 +445,34 @@ export const settingsDict = { 'settings.common.permission.deny': 'Ablehnen', 'settings.common.state.comingSoon': 'Demnächst verfügbar...', 'settings.projects.actions.title': 'Aktionen', + 'settings.projects.shared.badge': 'Im Repo', + 'settings.projects.shared.actionsFromRepo': 'Im Repository gespeichert ({path}). Alle, die es pullen, bekommen diese.', + 'settings.projects.shared.commandsFromRepo': 'Laufen zuerst, im Repository gespeichert ({path})', + 'settings.projects.shared.invalid': 'Die Projektkonfiguration in {path} konnte nicht gelesen werden: {reason}', + 'settings.projects.shared.trusted': 'Repository-Befehle auf dieser Instanz vertraut', + 'settings.projects.shared.resetTrust': 'Vertrauen zurücksetzen', + 'settings.projects.shared.title': 'Repository-Konfiguration', + 'settings.projects.shared.description': 'Setup, das im Repository selbst liegt, damit alle, die es pullen, dieselben Aktionen, Setup-Befehle, Starter und Pläne bekommen. Es wird nichts geschrieben, bis du etwas dorthin verschiebst.', + 'settings.projects.shared.file': 'Datei', + 'settings.projects.shared.status.missing': 'Noch nicht im Repository', + 'settings.projects.shared.status.ok': 'Im Repository', + 'settings.projects.shared.plansDir': 'Ordner für Pläne', + 'settings.projects.shared.plansDirPlaceholder': '.openchamber/plans', + 'settings.projects.shared.plansDirInfo': 'Wo Pläne im Repository liegen, relativ zum Repository. Leer bedeutet .openchamber/plans. Ein eigener Ordner wie docs/plans ersetzt den Standard vollständig: nur dieser Ordner wird gelesen und beschrieben. Vorhandene Dateien verschiebst du beim Wechsel selbst.', + 'settings.projects.shared.plansDirAria': 'Ordner für Pläne im Repository', + 'settings.projects.shared.actions.share': 'Ins Repository verschieben', + 'settings.projects.shared.actions.showTitle': 'Zeigt diese Repository-Aktion wieder in deinem Menü.', + 'settings.projects.shared.actions.hideTitle': 'Blendet diese Repository-Aktion nur in deinem Menü aus; das Repository bleibt unverändert.', + 'settings.projects.shared.actions.makePersonalTitle': 'Entfernt es aus dem Repository und behält es nur in deinen Einstellungen auf dieser Instanz.', + 'settings.projects.shared.actions.shareTitle': 'Speichert es in {path} im Repository, damit alle, die das Repository pullen, es bekommen. Es verlässt deine persönlichen Einstellungen.', + 'settings.projects.shared.actions.shareAfterSave': 'Speichert zuerst deine Änderungen, dann verschieben', + 'settings.projects.shared.actions.makePersonal': 'In meine Einstellungen verschieben', + 'settings.projects.shared.actions.hide': 'Für mich ausblenden', + 'settings.projects.shared.actions.show': 'Anzeigen', + 'settings.projects.shared.hiddenBadge': 'Ausgeblendet', + 'settings.projects.shared.replaceMode': 'Nur meine Setup-Befehle verwenden, die des Repositorys überspringen', + 'settings.projects.shared.replaceModeAria': 'Nur meine Setup-Befehle verwenden, die des Repositorys überspringen', + 'settings.projects.shared.toast.shareFailed': 'Repository-Konfiguration konnte nicht aktualisiert werden', 'settings.projects.actions.description': 'Projektspezifische Befehle im Header.', 'settings.projects.actions.validation.fillNameAndCommand': 'Bitte Aktionsname und Befehl ausfüllen.', 'settings.projects.actions.state.loading': 'Wird geladen...', @@ -957,6 +986,8 @@ export const settingsDict = { 'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'LAN-Zugriff erfordert ein Desktop-UI-Kennwort. Bis ein Kennwort festgelegt ist, startet die Desktop-App nur lokal.', 'settings.openchamber.desktopPassword.field.password': 'Desktop-UI-Kennwort', 'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'Kein Kennwort erforderlich', + 'settings.openchamber.desktopPassword.field.passwordSetPlaceholder': 'Kennwort gesetzt. Neues eingeben, um es zu ersetzen.', + 'settings.openchamber.desktopPassword.actions.removePassword': 'Kennwort entfernen', 'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber fragt nach dem Neustart und dann, wenn die Anmeldesitzung abläuft: nach 12 Stunden oder 7 Tagen mit Vertrauen in dieses Gerät. Leer lassen, um die Anmeldung zu deaktivieren.', 'settings.openchamber.desktopNetwork.hint.openAfterRestart': 'Nach dem Neustart von einem anderen Gerät aus öffnen: ', 'settings.openchamber.desktopNetwork.hint.openNow': 'Von einem anderen Gerät aus öffnen: ', @@ -2100,8 +2131,10 @@ export const settingsDict = { 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Rechtschreibprüfung in Texteingaben aktivieren', 'settings.openchamber.visual.field.largeTextPaste': 'Großes Texteinfügen', 'settings.openchamber.visual.field.largeTextPasteHint': 'Beim Einfügen von mehr als etwa 2.000 Zeichen oder 25 Zeilen wählen, ob der Text als Datei angehängt, direkt eingefügt oder jedes Mal nachgefragt werden soll.', - 'settings.openchamber.visual.field.enterToSend': 'Enter sendet', - 'settings.openchamber.visual.field.enterToSendHint': 'Nach der Änderung steuern Enter und Shift+Enter das Verhalten auf jeder Oberfläche. Bis dahin behält jede Oberfläche ihr bestehendes Verhalten bei.', + 'settings.openchamber.visual.field.enterToSend': 'Tastenkürzel zum Senden', + 'settings.openchamber.visual.field.enterToSendHint': 'Wählen Sie das Tastenkürzel zum Senden im Standard-Composer. Im erweiterten Composer fügt Enter immer eine neue Zeile ein, und Strg/Cmd+Enter sendet.', + 'settings.openchamber.visual.option.enterToSend.enter.label': 'Mit Enter senden', + 'settings.openchamber.visual.option.enterToSend.modifier.label': 'Mit Strg/Cmd+Enter senden', 'settings.openchamber.visual.field.largeTextPasteAria': 'Verhalten bei großem Texteinfügen', 'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Großes Texteinfügen: {option}', 'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Jedes Mal fragen', @@ -2308,8 +2341,6 @@ export const settingsDict = { 'settings.openchamber.desktopNetwork.field.macMenuBarAria': 'OpenChamber in der macOS-Menüleiste anzeigen', 'settings.openchamber.desktopNetwork.field.macMenuBar': 'OpenChamber in der Menüleiste anzeigen', 'settings.openchamber.desktopNetwork.field.macMenuBarDescription': 'Erfordert einen Neustart der App. Wenn deaktiviert, erstellt OpenChamber weder den Menüleisten-Eintrag noch führt es dessen Sitzungs-, Genehmigungs- und Nutzungsaktualisierungen aus.', - 'settings.openchamber.desktopPassword.actions.showPassword': 'Passwort anzeigen', - 'settings.openchamber.desktopPassword.actions.hidePassword': 'Passwort verbergen', 'settings.openchamber.defaults.walkthroughModel.title': 'Walkthrough-Modell ändern', 'settings.openchamber.defaults.walkthroughModel.description': 'Die KI-Prüfung deiner Änderungen benötigt strukturierten Output und Platz für einen ganzen Diff, den ein günstiges kleines Modell oft nicht liefern kann. Modelle, die der Katalog als nicht in der Lage zu strukturiertem Output meldet, werden in diesem Auswahlfeld ausgeblendet. Lasse es leer, dann wird das kleine Modell verwendet.', 'settings.openchamber.defaults.walkthroughModel.overrideModel': 'Walkthrough-Modell', diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index c64d5c5b..fdb1ba6a 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -3,6 +3,21 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; import { linearPanelI18n } from './linear-panel.i18n'; export const dict = { + 'commitComparison.mode': 'Commit', + 'commitComparison.select': 'Commit auswählen', + 'commitComparison.search': 'Commits suchen...', + 'commitComparison.loadError': 'Commits konnten nicht geladen werden', + 'commitComparison.noCommits': 'Keine Commits gefunden', + 'commitComparison.emptyDiff': 'Keine Änderungen in diesem Commit', + 'chat.liveActivity.title': 'Aktivität', + 'chat.liveActivity.changedFile': '{count} Datei geändert', + 'chat.liveActivity.changedFiles': '{count} Dateien geändert', + 'chat.liveActivity.explored': 'Codebasis untersucht', + 'chat.liveActivity.ranCommand': '{count} Befehl ausgeführt', + 'chat.liveActivity.ranCommands': '{count} Befehle ausgeführt', + 'chat.liveActivity.researched': 'Im Web recherchiert', + 'chat.liveActivity.usedSubagent': '{count} Unteragent eingesetzt', + 'chat.liveActivity.usedSubagents': '{count} Unteragenten eingesetzt', 'sessions.sidebar.projectAction.active': 'Projektaktion aktiv', ...settingsDict, ...linearIssuePickerI18n.de, @@ -118,13 +133,13 @@ export const dict = { 'mobile.sessions.showArchived': 'Archivierte anzeigen ({count})', 'mobile.sessions.hideArchived': 'Archivierte ausblenden', 'mobile.sessions.activeWorktreeAria': 'Aktives Worktree', - 'mobile.sessions.activeProjectAria': 'Aktives Projekt', 'mobile.sessions.startNewChat': 'Neuen Chat starten', 'mobile.sessions.newChat': 'Neuer Chat', 'mobile.sessions.editOrder': 'Projekte neu anordnen', 'mobile.sessions.doneEditing': 'Fertig', 'mobile.sessions.editOrderHint': 'Ziehe den Griff, um die Projekte neu anzuordnen. Tippe auf das Häkchen, um zu beenden.', 'mobile.sessions.editProjectAria': '{label} bearbeiten', + 'mobile.sessions.newSessionInProjectAria': 'Neue Sitzung in {label}', 'mobile.sessions.dragHandleAria': '{label} ziehen, um neu anzuordnen', 'mobile.sessions.moveUpAria': '{label} nach oben verschieben', 'mobile.sessions.moveDownAria': '{label} nach unten verschieben', @@ -609,7 +624,6 @@ export const dict = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Angefügter Worktree archiviert.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': 'Angefügte Worktrees archiviert.', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'Archivierte Worktrees und entfernte Remote-Branches.', - 'sessions.missingDirectory.movedToProject': 'Der Ordner dieser Sitzung existiert nicht mehr. Die Sitzung wurde nach {project} verschoben.', 'sessions.sidebar.group.worktreeMissing': 'Worktree-Ordner fehlt', 'sessions.sidebar.sessionDialogs.worktree.label': 'Worktree', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'Worktree-Pfad nicht verfügbar.', @@ -1615,6 +1629,9 @@ export const dict = { 'rightSidebar.contextNotesTodo.plans.importFromFile': 'Plan aus Datei importieren', 'rightSidebar.contextNotesTodo.plans.empty': 'Noch keine gespeicherten Pläne.', 'rightSidebar.contextNotesTodo.plans.deletePlan': 'Plan löschen', + 'rightSidebar.contextNotesTodo.plans.sharedBadge': 'Im Repo', + 'rightSidebar.contextNotesTodo.plans.share': 'In den Plan-Ordner des Repositorys verschieben, damit alle, die es pullen, ihn sehen', + 'rightSidebar.contextNotesTodo.plans.makePersonal': 'Zu meinen Plänen verschieben, aus dem Repository heraus', 'rightSidebar.contextNotesTodo.plans.deletePlanWithTitle': 'Lösche Plan "{title}"', 'rightSidebar.contextNotesTodo.sendDialog.title.newSession': 'An neue Sitzung senden', 'rightSidebar.contextNotesTodo.sendDialog.title.newWorktree': 'An neuen Worktree senden', @@ -1633,6 +1650,7 @@ export const dict = { 'rightSidebar.contextNotesTodo.toast.sendTodoFailed': 'Fehler beim Senden des Todos', 'rightSidebar.contextNotesTodo.toast.updatePlanFailed': 'Plan konnte nicht aktualisiert werden', 'rightSidebar.contextNotesTodo.toast.deletePlanFailed': 'Fehler beim Löschen des Plans', + 'rightSidebar.contextNotesTodo.toast.movePlanFailed': 'Plan konnte nicht verschoben werden', 'rightSidebar.contextNotesTodo.toast.planFileEmpty': 'Plan-Datei ist leer', 'rightSidebar.contextNotesTodo.toast.importPlanFailed': 'Fehler beim Importieren des Plans', 'rightSidebar.contextNotesTodo.toast.planImported': 'Plan importiert', @@ -1664,6 +1682,7 @@ export const dict = { 'header.services.refreshRateLimitsAria': 'Ratenlimits aktualisieren', 'header.services.noRateLimits': 'Keine Ratenlimits verfügbar.', 'header.services.noRateLimitsReported': 'Keine Ratenlimits berichtet.', + 'header.services.usageRefreshFailedStale': 'Zuvor empfangene Nutzungsdaten werden angezeigt. Aktualisierung fehlgeschlagen: {error}', 'header.services.remoteUpdate.title': 'Remote-Instanz-Update', 'header.services.remoteUpdate.checking': 'Suche nach Updates...', 'header.services.remoteUpdate.upToDate': 'Diese Instanz ist auf dem neuesten Stand.', @@ -1746,6 +1765,7 @@ export const dict = { 'terminalView.tabs.closeTabTitle': 'Registerkarte schließen', 'terminalView.tabs.newTabTitle': 'Neue Registerkarte', 'terminalView.viewport.inputAria': 'Terminal-Eingabe', + 'terminalView.viewport.scrollbarAria': 'Terminal-Verlauf', 'directoryExplorerDialog.title': 'Projektverzeichnis hinzufügen', 'directoryExplorerDialog.description': 'Wählen Sie einen Ordner aus, der als Projekt hinzugefügt werden soll.', 'directoryExplorerDialog.toggle.showHidden': 'Versteckte anzeigen', @@ -2184,6 +2204,9 @@ export const dict = { 'chat.draftStarters.sectionCommands': 'Befehle', 'chat.draftStarters.sectionSkills': 'Fähigkeiten', 'chat.draftStarters.remove': 'Entfernen', + 'chat.draftStarters.sharedTitle': 'In der Repository-Konfiguration angeheftet; dort ändern', + 'chat.draftStarters.share': 'In die Repository-Konfiguration verschieben', + 'chat.draftStarters.makePersonal': 'In meine Einstellungen verschieben', 'chat.scrollToBottom.aria': 'Zum Ende scrollen', 'chat.promptNavigator.aria': 'Prompt-Navigation', 'chat.promptNavigator.currentPrompt': 'Aktueller Prompt', @@ -2288,6 +2311,8 @@ export const dict = { 'chat.btw.toast.destroyFailed': 'Die btw-Sitzung konnte nicht gelöscht werden. Sie bleibt in der Seitenleiste.', 'chat.btw.working': 'Arbeitet…', 'chat.btw.collapseAria': 'btw-Panel einklappen', + 'chat.btw.draftHint': 'Stelle deine Frage', + 'chat.btw.cancelAria': 'Diese BTW-Frage verwerfen', 'chat.btw.expandAria': 'btw-Panel ausklappen', 'chat.btw.promoteAria': 'Als eigene Sitzung behalten', 'chat.btw.toast.promoteFailed': 'Die btw-Sitzung konnte nicht behalten werden', @@ -2325,6 +2350,8 @@ export const dict = { 'chat.textSelection.toast.addToNotesSummaryFailed': 'Zusammenfassung der Auswahl nicht möglich, ausgewählter Text wurde zu Notizen hinzugefügt', 'chat.textSelection.actions.addToInput': 'Zur Eingabe hinzufügen', 'chat.textSelection.actions.comment': 'Kommentieren', + 'chat.textSelection.actions.askOpenChamber': 'Übrigens…', + 'chat.textSelection.title.askOpenChamber': 'BTW-Entwurf mit der Auswahl öffnen', 'chat.textSelection.title.commentOnSelection': 'Auswahl kommentieren', 'chat.textSelection.comment.placeholder': 'Optionalen Kommentar hinzufügen...', 'chat.textSelection.comment.attach': 'Anhängen', @@ -2340,6 +2367,8 @@ export const dict = { 'chat.messageBody.actions.openPreviewAria': 'Vorschau öffnen', 'chat.messageBody.actions.openPreview': 'Vorschau öffnen', 'chat.messageBody.actions.copyAnswer': 'Antwort kopieren', + 'chat.messageBody.actions.moreActions': 'Weitere Aktionen', + 'chat.messageBody.toast.copied': 'In die Zwischenablage kopiert', 'chat.messageBody.actions.savingImage': 'Bild wird gespeichert...', 'chat.messageBody.actions.saveAsImage': 'Als Bild speichern', 'chat.messageBody.actions.saveAsPlan': 'Als Plan speichern', @@ -2450,6 +2479,7 @@ export const dict = { 'chat.chatInput.draftPicker.projectTitle': 'Projekt', 'chat.chatInput.draftPicker.searchProjects': 'Projekte durchsuchen...', 'chat.chatInput.draftPicker.searchBranches': 'Branches durchsuchen...', + 'chat.chatInput.draftPicker.noProjectsFound': 'Keine Projekte gefunden.', 'chat.chatInput.worktrees': 'Worktrees', 'chat.chatInput.worktreeNew': '+ Neu', 'chat.chatInput.drop.insertMention': 'Hier ablegen, um als Erwähnung einzufügen', @@ -2796,6 +2826,13 @@ export const dict = { 'projectActions.actions.addAction': 'Aktion hinzufügen', 'projectActions.actions.addNewAction': 'Neue Aktion hinzufügen', 'projectActions.actions.autoDiscover': 'Automatisch entdecken', + 'projectActions.menu.sharedBadge': 'Repo', + 'projects.sharedTrust.title': 'Die im Repository gespeicherten Befehle ausführen?', + 'projects.sharedTrust.description': '{path} in diesem Repository definiert Befehle, die auf diesem Rechner laufen. Einmal vertrauen, und OpenChamber fragt erst wieder, wenn sie sich ändern.', + 'projects.sharedTrust.setupCommands': 'Worktree-Setup-Befehle', + 'projects.sharedTrust.actions': 'Aktionen', + 'projects.sharedTrust.skip': 'Diesmal nicht', + 'projects.sharedTrust.trust': 'Vertrauen und ausführen', 'projectActions.actions.chooseActionAria': 'Projektaktion auswählen', 'projectActions.actions.openPreview': 'Vorschau öffnen', 'projectActions.actions.runNamedAria': '{name} ausführen', @@ -3252,6 +3289,9 @@ export const dict = { 'quota.window.completions': 'Vervollständigungen', 'quota.window.premiumInteractions': 'KI-Guthaben', 'terminalView.actions.attachSelection': 'Ausgewählte Ausgabe anhängen', + 'terminalView.actions.copySelection': 'Ausgewählte Ausgabe kopieren', + 'terminalView.toast.selectionCopied': 'Ausgabe kopiert', + 'terminalView.toast.copyFailed': 'Kopieren fehlgeschlagen', 'terminalView.actions.restart': 'Terminal neu starten', 'chat.message.terminalContext': '{terminal}, Zeilen {start}-{end}', 'chat.message.context.codeComment': 'Kommentar zu {file}, Zeilen {start}-{end}', @@ -3476,7 +3516,6 @@ export const dict = { 'contextRail.surface.walkthrough.description': 'Walkthrough-Kontext', 'walkthrough.scope.all': 'Alle', 'walkthrough.scope.group.workingTree': 'Arbeitsbaum', - 'walkthrough.scope.group.committed': 'Eingecheckt', 'walkthrough.scope.staged': 'Bereitgestellt', 'walkthrough.scope.working': 'Arbeitsstand', 'walkthrough.scope.branch': 'Branch', @@ -3619,6 +3658,26 @@ export const dict = { 'chat.workStatus.action.openMr': 'Merge Request öffnen', 'chat.workStatus.action.openSubagent': '{name} öffnen', 'chat.workStatus.section.usage': 'Nutzung', + 'chat.workStatus.section.telemetry': 'Turn-Statistiken', + 'chat.workStatus.telemetry.responseSpeed': 'Antwort', + 'chat.workStatus.telemetry.responseSpeedDescription': 'Wie schnell der abschließende Text ankam. Ohne anfängliche Wartezeit, Denken und frühere Werkzeugaufrufe. Eine Schätzung aus Textzeitstempeln, keine Geschwindigkeitsmessung des Anbieters.', + 'chat.workStatus.telemetry.speed': 'Anfrage', + 'chat.workStatus.telemetry.llmDuration': 'Modellzeit', + 'chat.workStatus.telemetry.llmDurationDescription': 'Zeit aller Modellschritte einschließlich Warten auf Antworten. Die Werkzeuglaufzeit ist abgezogen. Das ist nicht nur die Zeit zur Texterzeugung.', + 'chat.workStatus.telemetry.toolDuration': 'Werkzeugzeit', + 'chat.workStatus.telemetry.toolDurationDescription': 'Laufzeit der Werkzeuge einschließlich fehlgeschlagener Aufrufe. Parallel laufende Werkzeuge zählen zeitlich nur einmal.', + 'chat.workStatus.telemetry.ttft': 'Mittlere TTFT', + 'chat.workStatus.telemetry.ttftDescription': 'Mittlere Wartezeit bis zum ersten Text oder Denkabschnitt jedes Modellschritts. Fehlt bei einem Schritt der Startzeitstempel, wird kein Wert angezeigt. Das ist bei reinen Werkzeugaufrufen häufig der Fall.', + 'chat.workStatus.telemetry.steps': 'Schritte', + 'chat.workStatus.telemetry.stepsDescription': 'Wie oft das Modell für diesen Prompt aufgerufen wurde. Werkzeugergebnisse lesen und den nächsten Schritt entscheiden erfordert meist einen weiteren Aufruf.', + 'chat.workStatus.telemetry.tokens': 'Tokens', + 'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓', + 'chat.workStatus.telemetry.speedDescription': 'Erzeugte Tokens aller Schritte einschließlich Denken, geteilt durch die Zeit ohne Werkzeugausführung. Warten auf das Modell zählt mit, daher können viele kurze Aufrufe den Wert senken.', + 'chat.workStatus.telemetry.tokensDescription': '↑ Eingabetokens ohne Cache: {input}. ↓ Erzeugte Tokens: {output} für Text und Werkzeugaufrufe plus {reasoning} zum Denken. Summen über alle Schritte dieses Prompts.', + 'chat.workStatus.telemetry.cacheHit': 'Cache', + 'chat.workStatus.telemetry.cacheHitDescription': 'Anteil der Eingabetokens, die über alle Schritte aus dem Prompt-Cache wiederverwendet wurden. Das kann Kosten und Wartezeit senken, ist aber kein Geschwindigkeitswert.', + 'chat.workStatus.telemetry.cost': 'Kosten', + 'chat.workStatus.telemetry.costDescription': 'Vom Anbieter gemeldete Kosten aller Modellschritte dieses Prompts in US-Dollar. Separate Subagent-Sitzungen sind nicht enthalten. Null kann ein kostenloses Modell oder fehlende Kostenangaben bedeuten.', 'chat.workStatus.goal.open': 'Ziel verwalten', 'chat.workStatus.goal.pause': 'Pausieren', 'chat.workStatus.goal.resume': 'Fortsetzen', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 7f01ff1e..4ec5a138 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1,5 +1,7 @@ import { linearIntegrationI18n } from './linear-integration.i18n'; export const settingsDict = { + 'settings.openchamber.visual.field.alwaysShowScrollbars': 'Always show scrollbars', + 'settings.openchamber.visual.field.alwaysShowScrollbarsHint': 'Keep scrollbars visible even when the pointer is outside the scrollable area. Applies on this device only.', 'settings.providers.page.openCodeGo.title': 'OpenCode Go usage tracking', 'settings.providers.page.openCodeGo.description': 'Connect the OpenCode Go dashboard to show rolling, weekly, and monthly quota.', 'settings.providers.page.openCodeGo.workspaceId': 'Workspace ID', @@ -58,7 +60,6 @@ export const settingsDict = { 'settings.view.pendingRestart.confirm.cancel': 'Cancel', 'settings.view.actions.backToSettings': 'Back to Settings', 'settings.view.actions.closeSettings': 'Close settings', - 'settings.view.actions.openSectionList': 'Open section list', 'settings.view.actions.closeSettingsWithShortcut': 'Close Settings ({shortcut}+,)', 'settings.view.actions.back': 'Back', 'settings.view.actions.resizeNavigation': 'Resize settings navigation', @@ -465,6 +466,34 @@ export const settingsDict = { 'settings.common.permission.deny': 'Deny', 'settings.common.state.comingSoon': 'Coming soon...', 'settings.projects.actions.title': 'Actions', + 'settings.projects.shared.badge': 'In repo', + 'settings.projects.shared.actionsFromRepo': 'Stored in the repository ({path}). Everyone who pulls it gets these.', + 'settings.projects.shared.commandsFromRepo': 'Run first, stored in the repository ({path})', + 'settings.projects.shared.invalid': 'The project config in {path} could not be read: {reason}', + 'settings.projects.shared.trusted': 'Repository commands trusted on this instance', + 'settings.projects.shared.resetTrust': 'Reset trust', + 'settings.projects.shared.title': 'Repository config', + 'settings.projects.shared.description': 'Setup stored in the repository itself, so everyone who pulls it gets the same actions, setup commands, starters, and plans. Nothing is written there until you move an item into it.', + 'settings.projects.shared.file': 'File', + 'settings.projects.shared.status.missing': 'Not in the repository yet', + 'settings.projects.shared.status.ok': 'In the repository', + 'settings.projects.shared.plansDir': 'Plans folder', + 'settings.projects.shared.plansDirPlaceholder': '.openchamber/plans', + 'settings.projects.shared.plansDirInfo': 'Where repository plans live, relative to the repository. Empty means .openchamber/plans. A custom folder such as docs/plans replaces the default entirely: only that folder is read and written. Move existing files yourself when you change it.', + 'settings.projects.shared.plansDirAria': 'Repository plans folder', + 'settings.projects.shared.actions.share': 'Move to repository', + 'settings.projects.shared.actions.showTitle': 'Shows this repository action in your menu again.', + 'settings.projects.shared.actions.hideTitle': 'Hides this repository action from your menu only; the repository is not changed.', + 'settings.projects.shared.actions.makePersonalTitle': 'Removes it from the repository and keeps it only in your settings on this instance.', + 'settings.projects.shared.actions.shareTitle': 'Stores it in {path} inside the repository, so everyone who pulls the repository gets it. It leaves your personal settings.', + 'settings.projects.shared.actions.shareAfterSave': 'Saves your edits first, then move', + 'settings.projects.shared.actions.makePersonal': 'Move to my settings', + 'settings.projects.shared.actions.hide': 'Hide for me', + 'settings.projects.shared.actions.show': 'Show', + 'settings.projects.shared.hiddenBadge': 'Hidden', + 'settings.projects.shared.replaceMode': 'Use only my setup commands, skip the repository\'s', + 'settings.projects.shared.replaceModeAria': 'Use only my setup commands, skip the repository\'s', + 'settings.projects.shared.toast.shareFailed': 'Failed to update the repository config', 'settings.projects.actions.description': 'Per-project commands shown in header next to project name.', 'settings.projects.actions.validation.fillNameAndCommand': 'Fill action name and command before saving.', 'settings.projects.actions.state.loading': 'Loading...', @@ -1017,10 +1046,10 @@ export const settingsDict = { 'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': 'Restarts the app so phones, tablets, and other computers on your Wi-Fi can open it.', 'settings.openchamber.desktopNetwork.field.warning': 'Warning: while enabled, the app is reachable by anyone on the same local network.', 'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'LAN access requires a Desktop UI Password. Until one is set, the desktop app starts local-only.', - 'settings.openchamber.desktopPassword.actions.showPassword': 'Show password', - 'settings.openchamber.desktopPassword.actions.hidePassword': 'Hide password', 'settings.openchamber.desktopPassword.field.password': 'Desktop UI Password', 'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'No password required', + 'settings.openchamber.desktopPassword.field.passwordSetPlaceholder': 'Password set. Type a new one to replace it.', + 'settings.openchamber.desktopPassword.actions.removePassword': 'Remove password', 'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber asks after restart, then when the login session expires: after 12 hours, or 7 days with Trust this device. Leave empty to disable login.', 'settings.openchamber.desktopNetwork.hint.openAfterRestart': 'After restart, open from another device: ', 'settings.openchamber.desktopNetwork.hint.openNow': 'Open from another device: ', @@ -2185,8 +2214,10 @@ export const settingsDict = { 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Enable Spellcheck in Text Inputs', 'settings.openchamber.visual.field.largeTextPaste': 'Large text paste', 'settings.openchamber.visual.field.largeTextPasteHint': 'When pasting more than about 2,000 characters or 25 lines, choose whether to attach the text as a file, paste it inline, or ask each time.', - 'settings.openchamber.visual.field.enterToSend': 'Enter sends', - 'settings.openchamber.visual.field.enterToSendHint': 'Once changed, this controls Enter and Shift+Enter on every surface. Until then, each surface keeps its existing behavior.', + 'settings.openchamber.visual.field.enterToSend': 'Send shortcut', + 'settings.openchamber.visual.field.enterToSendHint': 'Choose the send shortcut for the standard composer. In the expanded composer, Enter always adds a new line and Ctrl/Cmd+Enter sends.', + 'settings.openchamber.visual.option.enterToSend.enter.label': 'Send with Enter', + 'settings.openchamber.visual.option.enterToSend.modifier.label': 'Send with Ctrl/Cmd+Enter', 'settings.openchamber.visual.field.largeTextPasteAria': 'Large text paste behavior', 'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Large text paste: {option}', 'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Ask each time', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 3a27298d..d225c699 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -3,11 +3,29 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; import { linearPanelI18n } from './linear-panel.i18n'; export const dict = { + 'commitComparison.mode': 'Commit', + 'commitComparison.select': 'Select commit', + 'commitComparison.search': 'Search commits...', + 'commitComparison.loadError': 'Failed to load commits', + 'commitComparison.noCommits': 'No commits found', + 'commitComparison.emptyDiff': 'No changes in this commit', + 'chat.liveActivity.title': 'Activity', + 'chat.liveActivity.changedFile': 'Changed {count} file', + 'chat.liveActivity.changedFiles': 'Changed {count} files', + 'chat.liveActivity.explored': 'Explored codebase', + 'chat.liveActivity.ranCommand': 'Ran {count} command', + 'chat.liveActivity.ranCommands': 'Ran {count} commands', + 'chat.liveActivity.researched': 'Researched the web', + 'chat.liveActivity.usedSubagent': 'Used {count} subagent', + 'chat.liveActivity.usedSubagents': 'Used {count} subagents', 'sessions.sidebar.projectAction.active': 'Project action active', ...settingsDict, ...linearIssuePickerI18n.en, ...linearPanelI18n.en, 'terminalView.actions.attachSelection': 'Attach selected output', + 'terminalView.actions.copySelection': 'Copy selected output', + 'terminalView.toast.selectionCopied': 'Output copied', + 'terminalView.toast.copyFailed': 'Copy failed', 'terminalView.actions.restart': 'Restart terminal', 'chat.message.terminalContext': '{terminal}, lines {start}-{end}', 'chat.message.context.codeComment': 'Comment on {file}, lines {start}-{end}', @@ -145,13 +163,13 @@ export const dict = { 'mobile.sessions.showArchived': 'Show archived ({count})', 'mobile.sessions.hideArchived': 'Hide archived', 'mobile.sessions.activeWorktreeAria': 'Active worktree', - 'mobile.sessions.activeProjectAria': 'Active project', 'mobile.sessions.startNewChat': 'Start new chat', 'mobile.sessions.newChat': 'New chat', 'mobile.sessions.editOrder': 'Reorder projects', 'mobile.sessions.doneEditing': 'Done', 'mobile.sessions.editOrderHint': 'Drag the handle to reorder projects. Tap a project to show its worktrees and drag those too. Tap the check to finish.', 'mobile.sessions.editProjectAria': 'Edit {label}', + 'mobile.sessions.newSessionInProjectAria': 'New session in {label}', 'mobile.sessions.dragHandleAria': 'Drag {label} to reorder', 'mobile.sessions.moveUpAria': 'Move {label} up', 'mobile.sessions.moveDownAria': 'Move {label} down', @@ -705,7 +723,6 @@ export const dict = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Attached worktree archived.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': 'Attached worktrees archived.', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'Archived worktrees and removed remote branches.', - 'sessions.missingDirectory.movedToProject': 'This session\'s folder no longer exists. The session was moved to {project}.', 'sessions.sidebar.group.worktreeMissing': 'Worktree folder is missing', 'sessions.sidebar.sessionDialogs.worktree.label': 'Worktree', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'Worktree path unavailable.', @@ -1316,7 +1333,6 @@ export const dict = { 'contextRail.surface.walkthrough.description': 'An AI-guided walkthrough of your changes', 'walkthrough.scope.all': 'All uncommitted', 'walkthrough.scope.group.workingTree': 'Working tree', - 'walkthrough.scope.group.committed': 'Committed', 'walkthrough.scope.staged': 'Staged', 'walkthrough.scope.working': 'Unstaged', 'walkthrough.scope.branch': 'This branch', @@ -1911,6 +1927,9 @@ export const dict = { 'rightSidebar.contextNotesTodo.plans.importFromFile': 'Import plan from file', 'rightSidebar.contextNotesTodo.plans.empty': 'No saved plans yet.', 'rightSidebar.contextNotesTodo.plans.deletePlan': 'Delete plan', + 'rightSidebar.contextNotesTodo.plans.sharedBadge': 'In repo', + 'rightSidebar.contextNotesTodo.plans.share': 'Move to the repository plans folder, so everyone who pulls the repository sees it', + 'rightSidebar.contextNotesTodo.plans.makePersonal': 'Move to my plans, out of the repository', 'rightSidebar.contextNotesTodo.plans.deletePlanWithTitle': 'Delete plan "{title}"', 'rightSidebar.contextNotesTodo.sendDialog.title.newSession': 'Send to new session', 'rightSidebar.contextNotesTodo.sendDialog.title.newWorktree': 'Send to new worktree', @@ -1929,6 +1948,7 @@ export const dict = { 'rightSidebar.contextNotesTodo.toast.sendTodoFailed': 'Failed to send todo', 'rightSidebar.contextNotesTodo.toast.updatePlanFailed': 'Failed to update plan', 'rightSidebar.contextNotesTodo.toast.deletePlanFailed': 'Failed to delete plan', + 'rightSidebar.contextNotesTodo.toast.movePlanFailed': 'Failed to move plan', 'rightSidebar.contextNotesTodo.toast.planFileEmpty': 'Plan file is empty', 'rightSidebar.contextNotesTodo.toast.importPlanFailed': 'Failed to import plan', 'rightSidebar.contextNotesTodo.toast.planImported': 'Plan imported', @@ -1960,6 +1980,7 @@ export const dict = { 'header.services.refreshRateLimitsAria': 'Refresh rate limits', 'header.services.noRateLimits': 'No rate limits available.', 'header.services.noRateLimitsReported': 'No rate limits reported.', + 'header.services.usageRefreshFailedStale': 'Showing previously received usage. Refresh failed: {error}', 'header.services.remoteUpdate.title': 'Remote instance update', 'header.services.remoteUpdate.checking': 'Looking for updates...', 'header.services.remoteUpdate.upToDate': 'This instance is up to date.', @@ -2046,6 +2067,7 @@ export const dict = { 'terminalView.tabs.closeTabTitle': 'Close tab', 'terminalView.tabs.newTabTitle': 'New tab', 'terminalView.viewport.inputAria': 'Terminal input', + 'terminalView.viewport.scrollbarAria': 'Terminal scrollback', 'directoryExplorerDialog.title': 'Add project directory', 'directoryExplorerDialog.description': 'Choose a folder to add as a project.', 'directoryExplorerDialog.toggle.showHidden': 'Show hidden', @@ -2485,6 +2507,9 @@ export const dict = { 'chat.draftStarters.sectionCommands': 'Commands', 'chat.draftStarters.sectionSkills': 'Skills', 'chat.draftStarters.remove': 'Remove', + 'chat.draftStarters.sharedTitle': 'Pinned in the repository config; change it there', + 'chat.draftStarters.share': 'Move to repository config', + 'chat.draftStarters.makePersonal': 'Move to my settings', 'chat.scrollToBottom.aria': 'Scroll to bottom', 'chat.promptNavigator.aria': 'Prompt navigation', 'chat.promptNavigator.currentPrompt': 'Current prompt', @@ -2593,6 +2618,8 @@ export const dict = { 'chat.btw.toast.destroyFailed': 'Failed to destroy the btw session. It will remain in the sidebar.', 'chat.btw.working': 'Working…', 'chat.btw.collapseAria': 'Collapse the btw panel', + 'chat.btw.draftHint': 'Ask your question', + 'chat.btw.cancelAria': 'Cancel this BTW question', 'chat.btw.expandAria': 'Expand the btw panel', 'chat.btw.promoteAria': 'Keep as a separate session', 'chat.btw.toast.promoteFailed': 'Failed to keep the btw session', @@ -2638,6 +2665,8 @@ export const dict = { 'chat.textSelection.toast.addToNotesSummaryFailed': 'Could not summarize selection, added selected text to notes', 'chat.textSelection.actions.addToInput': 'Add to input', 'chat.textSelection.actions.comment': 'Comment', + 'chat.textSelection.actions.askOpenChamber': 'By the way…', + 'chat.textSelection.title.askOpenChamber': 'Open a BTW draft with the selection', 'chat.textSelection.title.commentOnSelection': 'Comment on selection', 'chat.textSelection.comment.placeholder': 'Add an optional comment...', 'chat.textSelection.comment.attach': 'Attach', @@ -2656,6 +2685,8 @@ export const dict = { 'chat.messageBody.actions.openPreviewAria': 'Open preview', 'chat.messageBody.actions.openPreview': 'Open preview', 'chat.messageBody.actions.copyAnswer': 'Copy answer', + 'chat.messageBody.actions.moreActions': 'More actions', + 'chat.messageBody.toast.copied': 'Copied to clipboard', 'chat.messageBody.actions.savingImage': 'Saving image...', 'chat.messageBody.actions.saveAsImage': 'Save as image', 'chat.messageBody.actions.saveAsPlan': 'Save as plan', @@ -2767,6 +2798,7 @@ export const dict = { 'chat.chatInput.draftPicker.projectTitle': 'Project', 'chat.chatInput.draftPicker.searchProjects': 'Search projects...', 'chat.chatInput.draftPicker.searchBranches': 'Search branches...', + 'chat.chatInput.draftPicker.noProjectsFound': 'No projects found.', 'chat.chatInput.worktrees': 'Worktrees', 'chat.chatInput.worktreeNew': '+ New', 'chat.chatInput.drop.insertMention': 'Drop to insert as mention', @@ -3113,6 +3145,13 @@ export const dict = { 'projectActions.actions.addAction': 'Add action', 'projectActions.actions.addNewAction': 'Add new action', 'projectActions.actions.autoDiscover': 'Auto-discover', + 'projectActions.menu.sharedBadge': 'repo', + 'projects.sharedTrust.title': 'Run the commands stored in this repository?', + 'projects.sharedTrust.description': '{path} in this repository defines commands that run on this machine. Trust them once, and OpenChamber asks again only when they change.', + 'projects.sharedTrust.setupCommands': 'Worktree setup commands', + 'projects.sharedTrust.actions': 'Actions', + 'projects.sharedTrust.skip': 'Not this time', + 'projects.sharedTrust.trust': 'Trust and run', 'projectActions.actions.autoDiscoverTooltip': 'Automatically discover and run the development server', 'projectActions.actions.chooseActionAria': 'Choose project action', 'projectActions.actions.openPreview': 'Open Preview', @@ -3620,6 +3659,26 @@ export const dict = { 'chat.workStatus.action.openMr': 'Open merge request', 'chat.workStatus.action.openSubagent': 'Open {name}', 'chat.workStatus.section.usage': 'Usage', + 'chat.workStatus.section.telemetry': 'Turn stats', + 'chat.workStatus.telemetry.responseSpeed': 'Response', + 'chat.workStatus.telemetry.responseSpeedDescription': 'How fast the final text arrived. Excludes the initial wait, reasoning, and earlier tool calls. An estimate from text timestamps, not a provider speed measurement.', + 'chat.workStatus.telemetry.speed': 'Whole turn', + 'chat.workStatus.telemetry.speedDescription': 'Tokens generated across all steps, including reasoning, divided by time with tool execution removed. Waiting for the model still counts, so many short tool calls can lower this number.', + 'chat.workStatus.telemetry.llmDuration': 'Model time', + 'chat.workStatus.telemetry.llmDurationDescription': 'Time spent on all model steps, including waiting for responses. Tool execution time is removed. This is not just time spent generating text.', + 'chat.workStatus.telemetry.toolDuration': 'Tool time', + 'chat.workStatus.telemetry.toolDurationDescription': 'Time spent running tools, including failed calls. Tools running at the same time are counted once, not added together.', + 'chat.workStatus.telemetry.ttft': 'Average TTFT', + 'chat.workStatus.telemetry.ttftDescription': 'Average wait before the first text or reasoning starts in each model step. Hidden when any step lacks a start timestamp, as tool-only steps often do.', + 'chat.workStatus.telemetry.steps': 'Steps', + 'chat.workStatus.telemetry.stepsDescription': 'How many times the model was called for this prompt. Reading tool results and deciding what to do next usually takes another step.', + 'chat.workStatus.telemetry.tokens': 'Tokens', + 'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓', + 'chat.workStatus.telemetry.tokensDescription': '↑ Input without cached tokens: {input}. ↓ Generated tokens: {output} for text and tool calls, plus {reasoning} for reasoning. Totals cover all steps of this prompt.', + 'chat.workStatus.telemetry.cacheHit': 'Cache', + 'chat.workStatus.telemetry.cacheHitDescription': 'Share of input tokens reused from the prompt cache across all steps. Reusing context can reduce cost and waiting, but this is not a speed score.', + 'chat.workStatus.telemetry.cost': 'Cost', + 'chat.workStatus.telemetry.costDescription': 'Cost reported by the provider for all model steps of this prompt, in US dollars. Excludes separate subagent sessions. Zero can mean a free model or a provider that reports no charge.', 'chat.workStatus.goal.open': 'Manage goal', 'chat.workStatus.goal.pause': 'Pause', 'chat.workStatus.goal.resume': 'Resume', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index b56c586f..59bb57f3 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1,5 +1,7 @@ import { linearIntegrationI18n } from './linear-integration.i18n'; export const settingsDict = { + 'settings.openchamber.visual.field.alwaysShowScrollbars': 'Mostrar siempre las barras de desplazamiento', + 'settings.openchamber.visual.field.alwaysShowScrollbarsHint': 'Mantén las barras de desplazamiento visibles incluso cuando el puntero esté fuera del área desplazable. Solo se aplica en este dispositivo.', 'settings.providers.page.openCodeGo.title': 'Seguimiento de uso de OpenCode Go', 'settings.providers.page.openCodeGo.description': 'Conecta el panel de OpenCode Go para ver las cuotas móvil, semanal y mensual.', 'settings.providers.page.openCodeGo.workspaceId': 'ID del espacio de trabajo', @@ -59,7 +61,6 @@ export const settingsDict = { "settings.view.pendingRestart.confirm.dontShowAgain": "No volver a mostrar", "settings.view.pendingRestart.confirm.cancel": "Cancelar", "settings.view.actions.backToSettings": "Volver a Configuración", "settings.view.actions.closeSettings": "Cerrar configuración", - "settings.view.actions.openSectionList": "Abrir lista de secciones", "settings.view.actions.closeSettingsWithShortcut": "Cerrar configuración ({shortcut}+,)", "settings.view.actions.back": "Atrás", "settings.view.actions.resizeNavigation": "Ajustar tamaño de la navegación", @@ -433,6 +434,34 @@ export const settingsDict = { "settings.common.permission.deny": "Denegar", "settings.common.state.comingSoon": "Próximamente...", "settings.projects.actions.title": "Acciones", + "settings.projects.shared.badge": "En el repo", + "settings.projects.shared.actionsFromRepo": "Guardadas en el repositorio ({path}). Todos los que lo clonen las tendrán.", + "settings.projects.shared.commandsFromRepo": "Se ejecutan primero, guardados en el repositorio ({path})", + "settings.projects.shared.invalid": "No se pudo leer la configuración del proyecto en {path}: {reason}", + "settings.projects.shared.trusted": "Comandos del repositorio de confianza en esta instancia", + "settings.projects.shared.resetTrust": "Restablecer confianza", + "settings.projects.shared.title": "Configuración en el repositorio", + "settings.projects.shared.description": "Configuración guardada en el propio repositorio, para que todos los que lo clonen tengan las mismas acciones, comandos de configuración, arranques y planes. No se escribe nada hasta que muevas un elemento allí.", + "settings.projects.shared.file": "Archivo", + "settings.projects.shared.status.missing": "Todavía no está en el repositorio", + "settings.projects.shared.status.ok": "En el repositorio", + "settings.projects.shared.plansDir": "Carpeta de planes", + "settings.projects.shared.plansDirPlaceholder": ".openchamber/plans", + "settings.projects.shared.plansDirInfo": "Dónde viven los planes del repositorio, relativo al repositorio. Vacío significa .openchamber/plans. Una carpeta propia como docs/plans reemplaza por completo la predeterminada: solo se lee y escribe esa carpeta. Mueve tú mismo los archivos existentes al cambiarla.", + "settings.projects.shared.plansDirAria": "Carpeta de planes en el repositorio", + "settings.projects.shared.actions.share": "Mover al repositorio", + "settings.projects.shared.actions.showTitle": "Vuelve a mostrar esta acción del repositorio en tu menú.", + "settings.projects.shared.actions.hideTitle": "Oculta esta acción del repositorio solo en tu menú; el repositorio no cambia.", + "settings.projects.shared.actions.makePersonalTitle": "Lo quita del repositorio y lo conserva solo en tus ajustes de esta instancia.", + "settings.projects.shared.actions.shareTitle": "Lo guarda en {path} dentro del repositorio, para que todos los que lo clonen lo tengan. Sale de tus ajustes personales.", + "settings.projects.shared.actions.shareAfterSave": "Primero se guardan tus cambios, luego mueve", + "settings.projects.shared.actions.makePersonal": "Mover a mis ajustes", + "settings.projects.shared.actions.hide": "Ocultar para mí", + "settings.projects.shared.actions.show": "Mostrar", + "settings.projects.shared.hiddenBadge": "Oculto", + "settings.projects.shared.replaceMode": "Usar solo mis comandos de configuración y omitir los del repositorio", + "settings.projects.shared.replaceModeAria": "Usar solo mis comandos de configuración y omitir los del repositorio", + "settings.projects.shared.toast.shareFailed": "No se pudo actualizar la configuración del repositorio", "settings.projects.actions.description": "Comandos por proyecto mostrados en el encabezado junto al nombre del proyecto.", "settings.projects.actions.validation.fillNameAndCommand": "Completa el nombre de la acción y el comando antes de guardar.", "settings.projects.actions.state.loading": "Cargando...", @@ -985,10 +1014,10 @@ export const settingsDict = { "settings.openchamber.desktopNetwork.field.allowLanAccessDescription": "Reinicia la aplicación para que los teléfonos, tablets y otros ordenadores en tu Wi-Fi puedan abrirla.", "settings.openchamber.desktopNetwork.field.warning": "Advertencia: mientras esté habilitado, la aplicación es accesible por cualquiera en la misma red local.", "settings.openchamber.desktopNetwork.field.passwordRequiredWarning": "El acceso LAN requiere una contraseña de UI de escritorio. Hasta que se configure, la app de escritorio se inicia solo localmente.", - "settings.openchamber.desktopPassword.actions.showPassword": "Mostrar contraseña", - "settings.openchamber.desktopPassword.actions.hidePassword": "Ocultar contraseña", "settings.openchamber.desktopPassword.field.password": "Contraseña de UI de escritorio", "settings.openchamber.desktopPassword.field.passwordPlaceholder": "No se requiere contraseña", + "settings.openchamber.desktopPassword.field.passwordSetPlaceholder": "Contraseña establecida. Escribe una nueva para reemplazarla.", + "settings.openchamber.desktopPassword.actions.removePassword": "Quitar contraseña", "settings.openchamber.desktopPassword.field.passwordDescription": "OpenChamber la pide después del reinicio y luego cuando vence la sesión: tras 12 horas, o 7 días con Confiar en este dispositivo. Déjalo vacío para desactivar el inicio de sesión.", "settings.openchamber.desktopNetwork.hint.openAfterRestart": "Después del reinicio, abre desde otro dispositivo: ", "settings.openchamber.desktopNetwork.hint.openNow": "Abrir desde otro dispositivo: ", @@ -2342,8 +2371,10 @@ export const settingsDict = { "settings.openchamber.visual.field.inputHistoryLimitDescription": "Bajar este número elimina de inmediato los prompts más antiguos de tu historial.", "settings.openchamber.visual.field.inputHistoryLimitAria": "Prompts que recordar", "settings.openchamber.visual.field.inputHistoryLimitUnit": "prompts", - "settings.openchamber.visual.field.enterToSend": "Enter envía", - "settings.openchamber.visual.field.enterToSendHint": "Después de cambiarlo, controla Enter y Shift+Enter en todas las superficies. Hasta entonces, cada superficie mantiene su comportamiento actual.", + "settings.openchamber.visual.field.enterToSend": "Atajo de envío", + "settings.openchamber.visual.field.enterToSendHint": "Elige el atajo de envío para el compositor estándar. En el compositor expandido, Intro siempre añade una nueva línea y Ctrl/Cmd+Intro envía.", + "settings.openchamber.visual.option.enterToSend.enter.label": "Enviar con Intro", + "settings.openchamber.visual.option.enterToSend.modifier.label": "Enviar con Ctrl/Cmd+Intro", ...linearIntegrationI18n.es, 'settings.page.integrations.title': 'Integraciones', 'settings.page.integrations.description': 'Conecta GitHub y Linear para que OpenChamber pueda trabajar con tus issues y pull requests.', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 3165786c..832b47f5 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -4,11 +4,29 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { + 'commitComparison.mode': 'Commit', + 'commitComparison.select': 'Seleccionar commit', + 'commitComparison.search': 'Buscar commits...', + 'commitComparison.loadError': 'No se pudieron cargar los commits', + 'commitComparison.noCommits': 'No se encontraron commits', + 'commitComparison.emptyDiff': 'No hay cambios en este commit', + 'chat.liveActivity.title': 'Actividad', + 'chat.liveActivity.changedFile': '{count} archivo modificado', + 'chat.liveActivity.changedFiles': '{count} archivos modificados', + 'chat.liveActivity.explored': 'Código explorado', + 'chat.liveActivity.ranCommand': '{count} comando ejecutado', + 'chat.liveActivity.ranCommands': '{count} comandos ejecutados', + 'chat.liveActivity.researched': 'Investigación en la web realizada', + 'chat.liveActivity.usedSubagent': '{count} subagente utilizado', + 'chat.liveActivity.usedSubagents': '{count} subagentes utilizados', 'sessions.sidebar.projectAction.active': 'Acción del proyecto en curso', ...settingsDict, ...linearIssuePickerI18n.es, ...linearPanelI18n.es, 'terminalView.actions.attachSelection': 'Adjuntar salida seleccionada', + 'terminalView.actions.copySelection': 'Copiar salida seleccionada', + 'terminalView.toast.selectionCopied': 'Salida copiada', + 'terminalView.toast.copyFailed': 'Error al copiar', 'terminalView.actions.restart': 'Reiniciar terminal', 'chat.message.terminalContext': '{terminal}, líneas {start}-{end}', 'chat.message.context.codeComment': 'Comentario en {file}, líneas {start}-{end}', @@ -146,7 +164,6 @@ export const dict: Record = { "mobile.sessions.showArchived": "Mostrar archivadas ({count})", "mobile.sessions.hideArchived": "Ocultar archivadas", "mobile.sessions.activeWorktreeAria": "Worktree activo", - "mobile.sessions.activeProjectAria": "Proyecto activo", "mobile.sessions.startNewChat": "Iniciar nuevo chat", "mobile.sessions.newChat": "Nuevo chat", "mobile.sessions.editOrder": "Reordenar proyectos", @@ -165,6 +182,7 @@ export const dict: Record = { "mobile.sessions.deleteSessionAria": "Eliminar {title}", "mobile.sessions.confirmDeleteSessionAria": "Confirmar eliminación de {title}", "mobile.sessions.editProjectAria": "Editar {label}", + "mobile.sessions.newSessionInProjectAria": "Nueva sesión en {label}", "mobile.projectEdit.worktreesTitle": "Worktrees", "mobile.projectEdit.worktreesEmpty": "Este proyecto aún no tiene worktrees.", "mobile.projectEdit.reorderHint": "Arrastra para reordenar los worktrees.", @@ -706,7 +724,6 @@ export const dict: Record = { "sessions.sidebar.sessionDialogs.worktree.attachedArchived": "Worktree adjunto archivado.", "sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural": "Worktrees adjuntos archivados.", "sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved": "Worktrees archivados y ramas remotas eliminadas.", - "sessions.missingDirectory.movedToProject": "La carpeta de esta sesión ya no existe. La sesión se movió a {project}.", "sessions.sidebar.group.worktreeMissing": "Falta la carpeta del worktree", "sessions.sidebar.sessionDialogs.worktree.label": "Worktree", "sessions.sidebar.sessionDialogs.worktree.pathUnavailable": "Ruta de worktree no disponible.", @@ -1317,7 +1334,6 @@ export const dict: Record = { "contextRail.surface.walkthrough.description": "Un recorrido por tus cambios guiado por IA", "walkthrough.scope.all": "Todo sin confirmar", "walkthrough.scope.group.workingTree": "Árbol de trabajo", - "walkthrough.scope.group.committed": "Confirmado", "walkthrough.scope.staged": "Preparados", "walkthrough.scope.working": "Sin preparar", "walkthrough.scope.branch": "Esta rama", @@ -1890,6 +1906,9 @@ export const dict: Record = { "rightSidebar.contextNotesTodo.plans.importFromFile": "Importar plan desde archivo", "rightSidebar.contextNotesTodo.plans.empty": "Aún no hay plans guardados.", "rightSidebar.contextNotesTodo.plans.deletePlan": "Eliminar plan", + "rightSidebar.contextNotesTodo.plans.sharedBadge": "En el repo", + "rightSidebar.contextNotesTodo.plans.share": "Mover a la carpeta de planes del repositorio, para que todos los que lo clonen lo vean", + "rightSidebar.contextNotesTodo.plans.makePersonal": "Mover a mis planes, fuera del repositorio", "rightSidebar.contextNotesTodo.plans.deletePlanWithTitle": "Eliminar plan \"{title}\"", "rightSidebar.contextNotesTodo.sendDialog.title.newSession": "Enviar a una nueva sesión", "rightSidebar.contextNotesTodo.sendDialog.title.newWorktree": "Enviar a una nueva sesión de worktree", @@ -1908,6 +1927,7 @@ export const dict: Record = { "rightSidebar.contextNotesTodo.toast.sendTodoFailed": "No se pudo enviar la tarea", "rightSidebar.contextNotesTodo.toast.updatePlanFailed": "No se pudo actualizar el plan", "rightSidebar.contextNotesTodo.toast.deletePlanFailed": "No se pudo eliminar el plan", + "rightSidebar.contextNotesTodo.toast.movePlanFailed": "No se pudo mover el plan", "rightSidebar.contextNotesTodo.toast.planFileEmpty": "El archivo del plan está vacío", "rightSidebar.contextNotesTodo.toast.importPlanFailed": "No se pudo importar el plan", "rightSidebar.contextNotesTodo.toast.planImported": "Plan importado", @@ -1939,6 +1959,7 @@ export const dict: Record = { "header.services.refreshRateLimitsAria": "Actualizar límites de tasa", "header.services.noRateLimits": "No hay límites de tasa disponibles.", "header.services.noRateLimitsReported": "No se reportaron límites de tasa.", + "header.services.usageRefreshFailedStale": "Se muestran los datos de uso recibidos anteriormente. No se pudieron actualizar: {error}", "header.services.remoteUpdate.title": "Actualización de instancia remota", "header.services.remoteUpdate.checking": "Buscando actualizaciones...", "header.services.remoteUpdate.upToDate": "Esta instancia está actualizada.", @@ -2025,6 +2046,7 @@ export const dict: Record = { "terminalView.tabs.closeTabTitle": "Cerrar pestaña", "terminalView.tabs.newTabTitle": "Nueva pestaña", "terminalView.viewport.inputAria": "Entrada de terminal", + "terminalView.viewport.scrollbarAria": "Historial del terminal", "directoryExplorerDialog.title": "Añadir directorio de proyecto", "directoryExplorerDialog.description": "Elige una carpeta para añadir como proyecto.", "directoryExplorerDialog.toggle.showHidden": "Mostrar ocultos", @@ -2464,6 +2486,9 @@ export const dict: Record = { "chat.draftStarters.sectionCommands": "Commands", "chat.draftStarters.sectionSkills": "Skills", "chat.draftStarters.remove": "Remove", + "chat.draftStarters.sharedTitle": "Fijado en la configuración del repositorio; cámbialo allí", + "chat.draftStarters.share": "Mover a la configuración del repositorio", + "chat.draftStarters.makePersonal": "Mover a mis ajustes", "chat.scrollToBottom.aria": "Ir al final", "chat.promptNavigator.aria": "Navegación de prompts", "chat.promptNavigator.currentPrompt": "Prompt actual", @@ -2571,6 +2596,8 @@ export const dict: Record = { 'chat.btw.toast.destroyFailed': 'No se pudo destruir la sesión btw. Permanecerá en la barra lateral.', 'chat.btw.working': 'Trabajando…', 'chat.btw.collapseAria': 'Contraer el panel btw', + 'chat.btw.draftHint': 'Haz tu pregunta', + 'chat.btw.cancelAria': 'Cancelar esta pregunta BTW', 'chat.btw.expandAria': 'Expandir el panel btw', 'chat.btw.promoteAria': 'Conservar como sesión aparte', 'chat.btw.toast.promoteFailed': 'No se pudo conservar la sesión btw', @@ -2617,6 +2644,8 @@ export const dict: Record = { "chat.textSelection.toast.addToNotesSummaryFailed": "No se pudo resumir la selección; se añadió el texto seleccionado a las notas", "chat.textSelection.actions.addToInput": "Añadir a la entrada", "chat.textSelection.actions.comment": "Comentar", + "chat.textSelection.actions.askOpenChamber": "Por cierto…", + "chat.textSelection.title.askOpenChamber": "Abrir un borrador BTW con la selección", "chat.textSelection.title.commentOnSelection": "Comentar la selección", "chat.textSelection.comment.placeholder": "Añade un comentario opcional...", "chat.textSelection.comment.attach": "Adjuntar", @@ -2635,6 +2664,8 @@ export const dict: Record = { "chat.messageBody.actions.openPreviewAria": "Abrir vista previa", "chat.messageBody.actions.openPreview": "Abrir vista previa", "chat.messageBody.actions.copyAnswer": "Copiar respuesta", + "chat.messageBody.actions.moreActions": "Más acciones", + "chat.messageBody.toast.copied": "Copiado al portapapeles", "chat.messageBody.actions.savingImage": "Guardando imagen...", "chat.messageBody.actions.saveAsImage": "Guardar como imagen", "chat.messageBody.actions.saveAsPlan": "Guardar como plan", @@ -2734,6 +2765,7 @@ export const dict: Record = { "chat.chatInput.draftPicker.projectTitle": "Proyecto", "chat.chatInput.draftPicker.searchProjects": "Buscar proyectos...", "chat.chatInput.draftPicker.searchBranches": "Buscar ramas...", + "chat.chatInput.draftPicker.noProjectsFound": "No se encontraron proyectos.", "chat.chatInput.worktrees": "Worktrees", "chat.chatInput.worktreeNew": "+ Nuevo", "chat.chatInput.drop.insertMention": "Suelta para insertar como mención", @@ -3080,6 +3112,13 @@ export const dict: Record = { "projectActions.actions.addAction": "Añadir acción", "projectActions.actions.addNewAction": "Añadir nueva acción", "projectActions.actions.autoDiscover": "Autodetectar", + "projectActions.menu.sharedBadge": "repo", + "projects.sharedTrust.title": "¿Ejecutar los comandos guardados en este repositorio?", + "projects.sharedTrust.description": "{path} en este repositorio define comandos que se ejecutan en esta máquina. Confía una vez y OpenChamber solo volverá a preguntar cuando cambien.", + "projects.sharedTrust.setupCommands": "Comandos de configuración del worktree", + "projects.sharedTrust.actions": "Acciones", + "projects.sharedTrust.skip": "Esta vez no", + "projects.sharedTrust.trust": "Confiar y ejecutar", "projectActions.actions.autoDiscoverTooltip": "Detecta y ejecuta automáticamente el servidor de desarrollo", "projectActions.actions.chooseActionAria": "Elegir acción del proyecto", "projectActions.actions.openPreview": "Abrir Preview", @@ -3622,6 +3661,26 @@ export const dict: Record = { 'chat.workStatus.action.openMr': 'Abrir solicitud de fusión', 'chat.workStatus.action.openSubagent': 'Abrir {name}', 'chat.workStatus.section.usage': 'Uso', + 'chat.workStatus.section.telemetry': 'Estadísticas del turno', + 'chat.workStatus.telemetry.responseSpeed': 'Respuesta', + 'chat.workStatus.telemetry.responseSpeedDescription': 'La velocidad a la que llegó el texto final. Excluye la espera inicial, el razonamiento y las llamadas anteriores a herramientas. Es una estimación basada en las marcas de tiempo del texto, no una medición del proveedor.', + 'chat.workStatus.telemetry.speed': 'Solicitud', + 'chat.workStatus.telemetry.llmDuration': 'Modelo', + 'chat.workStatus.telemetry.llmDurationDescription': 'Tiempo de todos los pasos del modelo, incluida la espera de respuestas. Se resta la ejecución de herramientas. No es solo el tiempo de generación de texto.', + 'chat.workStatus.telemetry.toolDuration': 'Herramientas', + 'chat.workStatus.telemetry.toolDurationDescription': 'Tiempo de ejecución de herramientas, incluidas las llamadas fallidas. Las herramientas que se ejecutan a la vez cuentan una sola vez.', + 'chat.workStatus.telemetry.ttft': 'TTFT medio', + 'chat.workStatus.telemetry.ttftDescription': 'Espera media hasta el primer texto o razonamiento de cada paso. Se oculta si falta la marca de inicio de algún paso, algo habitual en pasos que solo llaman a herramientas.', + 'chat.workStatus.telemetry.steps': 'Pasos', + 'chat.workStatus.telemetry.stepsDescription': 'Cuántas veces se llamó al modelo para este prompt. Leer el resultado de una herramienta y decidir qué hacer suele requerir otro paso.', + 'chat.workStatus.telemetry.tokens': 'Tokens', + 'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓', + 'chat.workStatus.telemetry.speedDescription': 'Tokens generados en todos los pasos, incluido el razonamiento, divididos por el tiempo sin ejecución de herramientas. La espera del modelo sí cuenta, por lo que muchas llamadas cortas pueden reducir este valor.', + 'chat.workStatus.telemetry.tokensDescription': '↑ Entrada sin tokens en caché: {input}. ↓ Generados: {output} para texto y llamadas a herramientas, más {reasoning} de razonamiento. Totales de todos los pasos de este prompt.', + 'chat.workStatus.telemetry.cacheHit': 'Caché', + 'chat.workStatus.telemetry.cacheHitDescription': 'Proporción de tokens de entrada reutilizados de la caché del prompt en todos los pasos. Reutilizar el contexto puede reducir el costo y la espera, pero no es una medida de velocidad.', + 'chat.workStatus.telemetry.cost': 'Costo', + 'chat.workStatus.telemetry.costDescription': 'Costo comunicado por el proveedor para todos los pasos de este prompt, en dólares estadounidenses. No incluye sesiones separadas de subagentes. Cero puede indicar un modelo gratuito o un proveedor que no informa del cobro.', 'chat.workStatus.goal.open': 'Gestionar objetivo', 'chat.workStatus.goal.pause': 'Pausar', 'chat.workStatus.goal.resume': 'Reanudar', diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index bfa32a9f..7cbbefc0 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1,5 +1,7 @@ import { linearIntegrationI18n } from './linear-integration.i18n'; export const settingsDict = { + 'settings.openchamber.visual.field.alwaysShowScrollbars': 'Toujours afficher les barres de défilement', + 'settings.openchamber.visual.field.alwaysShowScrollbarsHint': 'Gardez les barres de défilement visibles même lorsque le pointeur se trouve hors de la zone défilante. Uniquement sur cet appareil.', 'settings.providers.page.openCodeGo.title': 'Suivi de l’utilisation d’OpenCode Go', 'settings.providers.page.openCodeGo.description': 'Connectez le tableau de bord OpenCode Go pour afficher les quotas glissant, hebdomadaire et mensuel.', 'settings.providers.page.openCodeGo.workspaceId': 'ID de l’espace de travail', @@ -59,7 +61,6 @@ export const settingsDict = { 'settings.view.pendingRestart.confirm.dontShowAgain': 'Ne plus afficher', 'settings.view.pendingRestart.confirm.cancel': 'Annuler', 'settings.view.actions.backToSettings': 'Retour aux paramètres', 'settings.view.actions.closeSettings': 'Fermer les paramètres', - 'settings.view.actions.openSectionList': 'Ouvrir la liste des sections', 'settings.view.actions.closeSettingsWithShortcut': 'Fermer les paramètres ({shortcut}+,)', 'settings.view.actions.back': 'Retour', 'settings.view.actions.resizeNavigation': 'Redimensionner la navigation dans les paramètres', @@ -356,6 +357,34 @@ export const settingsDict = { 'settings.common.permission.deny': 'Refuser', 'settings.common.state.comingSoon': 'À venir...', 'settings.projects.actions.title': 'Actions', + 'settings.projects.shared.badge': 'Dans le dépôt', + 'settings.projects.shared.actionsFromRepo': 'Enregistrées dans le dépôt ({path}). Tous ceux qui le récupèrent les ont.', + 'settings.projects.shared.commandsFromRepo': 'Exécutées en premier, enregistrées dans le dépôt ({path})', + 'settings.projects.shared.invalid': 'La configuration du projet dans {path} n\'a pas pu être lue : {reason}', + 'settings.projects.shared.trusted': 'Commandes du dépôt approuvées sur cette instance', + 'settings.projects.shared.resetTrust': 'Réinitialiser la confiance', + 'settings.projects.shared.title': 'Configuration du dépôt', + 'settings.projects.shared.description': 'Configuration enregistrée dans le dépôt lui-même, pour que tous ceux qui le récupèrent aient les mêmes actions, commandes de configuration, amorces et plans. Rien n\'est écrit tant que vous n\'y déplacez pas un élément.', + 'settings.projects.shared.file': 'Fichier', + 'settings.projects.shared.status.missing': 'Pas encore dans le dépôt', + 'settings.projects.shared.status.ok': 'Dans le dépôt', + 'settings.projects.shared.plansDir': 'Dossier des plans', + 'settings.projects.shared.plansDirPlaceholder': '.openchamber/plans', + 'settings.projects.shared.plansDirInfo': 'Où vivent les plans du dépôt, relativement au dépôt. Vide signifie .openchamber/plans. Un dossier personnalisé comme docs/plans remplace entièrement le dossier par défaut : seul ce dossier est lu et écrit. Déplacez vous-même les fichiers existants quand vous le changez.', + 'settings.projects.shared.plansDirAria': 'Dossier des plans du dépôt', + 'settings.projects.shared.actions.share': 'Déplacer vers le dépôt', + 'settings.projects.shared.actions.showTitle': 'Réaffiche cette action du dépôt dans votre menu.', + 'settings.projects.shared.actions.hideTitle': 'Masque cette action du dépôt dans votre menu seulement ; le dépôt n\'est pas modifié.', + 'settings.projects.shared.actions.makePersonalTitle': 'Le retire du dépôt et ne le garde que dans vos réglages sur cette instance.', + 'settings.projects.shared.actions.shareTitle': 'L\'enregistre dans {path} du dépôt, pour que tous ceux qui le récupèrent l\'aient. Il quitte vos réglages personnels.', + 'settings.projects.shared.actions.shareAfterSave': 'Enregistre d\'abord vos modifications, puis déplacez', + 'settings.projects.shared.actions.makePersonal': 'Déplacer vers mes réglages', + 'settings.projects.shared.actions.hide': 'Masquer pour moi', + 'settings.projects.shared.actions.show': 'Afficher', + 'settings.projects.shared.hiddenBadge': 'Masqué', + 'settings.projects.shared.replaceMode': 'Utiliser uniquement mes commandes de configuration et ignorer celles du dépôt', + 'settings.projects.shared.replaceModeAria': 'Utiliser uniquement mes commandes de configuration et ignorer celles du dépôt', + 'settings.projects.shared.toast.shareFailed': 'Impossible de mettre à jour la configuration du dépôt', 'settings.projects.actions.description': 'Commandes par projet affichées dans l\'en-tête à côté du nom du projet.', 'settings.projects.actions.validation.fillNameAndCommand': 'Remplissez le nom de l\'action et la commande avant d\'enregistrer.', 'settings.projects.actions.state.loading': 'Chargement...', @@ -903,10 +932,10 @@ export const settingsDict = { 'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': 'Redémarre l\'application afin que les téléphones, tablettes et autres ordinateurs connectés à votre réseau Wi-Fi puissent l\'ouvrir.', 'settings.openchamber.desktopNetwork.field.warning': 'Attention : lorsqu\'elle est activée, l\'application est accessible à toute personne se trouvant sur le même réseau local.', 'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'L\'accès LAN nécessite un mot de passe de l\'interface utilisateur du bureau. Tant qu\'il n\'est pas défini, l\'application de bureau démarre en accès local uniquement.', - 'settings.openchamber.desktopPassword.actions.showPassword': 'Afficher le mot de passe', - 'settings.openchamber.desktopPassword.actions.hidePassword': 'Masquer le mot de passe', 'settings.openchamber.desktopPassword.field.password': 'Mot de passe de l\'interface utilisateur du bureau', 'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'Aucun mot de passe requis', + 'settings.openchamber.desktopPassword.field.passwordSetPlaceholder': 'Mot de passe défini. Saisissez-en un nouveau pour le remplacer.', + 'settings.openchamber.desktopPassword.actions.removePassword': 'Supprimer le mot de passe', 'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber demande après le redémarrage, puis quand la session de connexion expire : après 12 heures, ou 7 jours avec Trust this device. Laissez vide pour désactiver la connexion.', 'settings.openchamber.desktopNetwork.hint.openAfterRestart': 'Après redémarrage, ouvrez depuis un autre appareil :', 'settings.openchamber.desktopNetwork.hint.openNow': 'Ouvrir depuis un autre appareil :', @@ -2341,8 +2370,10 @@ export const settingsDict = { 'settings.openchamber.visual.field.inputHistoryLimitDescription': 'Réduire ce nombre supprime aussitôt les prompts les plus anciens de votre historique.', 'settings.openchamber.visual.field.inputHistoryLimitAria': 'Prompts à mémoriser', 'settings.openchamber.visual.field.inputHistoryLimitUnit': 'prompts', - 'settings.openchamber.visual.field.enterToSend': 'Entrée envoie', - 'settings.openchamber.visual.field.enterToSendHint': 'Après modification, ce réglage contrôle Entrée et Maj+Entrée sur toutes les surfaces. En attendant, chaque surface conserve son comportement actuel.', + 'settings.openchamber.visual.field.enterToSend': 'Raccourci d\'envoi', + 'settings.openchamber.visual.field.enterToSendHint': 'Choisissez le raccourci d\'envoi pour le composeur standard. Dans le composeur étendu, Entrée ajoute toujours une nouvelle ligne et Ctrl/Cmd+Entrée envoie.', + 'settings.openchamber.visual.option.enterToSend.enter.label': 'Envoyer avec Entrée', + 'settings.openchamber.visual.option.enterToSend.modifier.label': 'Envoyer avec Ctrl/Cmd+Entrée', ...linearIntegrationI18n.fr, 'settings.page.integrations.title': 'Intégrations', 'settings.page.integrations.description': 'Connectez GitHub et Linear pour qu’OpenChamber puisse travailler avec vos issues et pull requests.', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 1d60fd19..f814cd61 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -3,11 +3,29 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; import { linearPanelI18n } from './linear-panel.i18n'; export const dict = { + 'commitComparison.mode': 'Commit', + 'commitComparison.select': 'Choisir un commit', + 'commitComparison.search': 'Rechercher des commits...', + 'commitComparison.loadError': 'Impossible de charger les commits', + 'commitComparison.noCommits': 'Aucun commit trouvé', + 'commitComparison.emptyDiff': 'Aucune modification dans ce commit', + 'chat.liveActivity.title': 'Activité', + 'chat.liveActivity.changedFile': '{count} fichier modifié', + 'chat.liveActivity.changedFiles': '{count} fichiers modifiés', + 'chat.liveActivity.explored': 'Base de code explorée', + 'chat.liveActivity.ranCommand': '{count} commande exécutée', + 'chat.liveActivity.ranCommands': '{count} commandes exécutées', + 'chat.liveActivity.researched': 'Recherche sur le web effectuée', + 'chat.liveActivity.usedSubagent': '{count} sous-agent utilisé', + 'chat.liveActivity.usedSubagents': '{count} sous-agents utilisés', 'sessions.sidebar.projectAction.active': 'Action du projet en cours', ...settingsDict, ...linearIssuePickerI18n.fr, ...linearPanelI18n.fr, 'terminalView.actions.attachSelection': 'Joindre la sortie sélectionnée', + 'terminalView.actions.copySelection': 'Copier la sortie sélectionnée', + 'terminalView.toast.selectionCopied': 'Sortie copiée', + 'terminalView.toast.copyFailed': 'Échec de la copie', 'terminalView.actions.restart': 'Redémarrer le terminal', 'chat.message.terminalContext': '{terminal}, lignes {start}-{end}', 'chat.message.context.codeComment': 'Commentaire sur {file}, lignes {start}-{end}', @@ -534,7 +552,6 @@ export const dict = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Worktree ci-joint archivé.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': 'Worktrees joints archivés.', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'Worktrees archivés et branches du dépôt distant supprimées.', - 'sessions.missingDirectory.movedToProject': 'Le dossier de cette session n\'existe plus. La session a été déplacée vers {project}.', 'sessions.sidebar.group.worktreeMissing': 'Le dossier du worktree est introuvable', 'sessions.sidebar.sessionDialogs.worktree.label': 'Worktree', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'Chemin du worktree indisponible.', @@ -1135,7 +1152,6 @@ export const dict = { 'contextRail.surface.walkthrough.description': 'Un parcours de vos modifications guidé par l’IA', 'walkthrough.scope.all': 'Tout non validé', 'walkthrough.scope.group.workingTree': 'Copie de travail', - 'walkthrough.scope.group.committed': 'Validé', 'walkthrough.scope.staged': 'Indexées', 'walkthrough.scope.working': 'Non indexées', 'walkthrough.scope.branch': 'Cette branche', @@ -1586,6 +1602,9 @@ export const dict = { 'rightSidebar.contextNotesTodo.plans.importFromFile': 'Importer un plan à partir d\'un fichier', 'rightSidebar.contextNotesTodo.plans.empty': 'Aucun plan enregistré pour l\'instant.', 'rightSidebar.contextNotesTodo.plans.deletePlan': 'Supprimer le forfait', + 'rightSidebar.contextNotesTodo.plans.sharedBadge': 'Dans le dépôt', + 'rightSidebar.contextNotesTodo.plans.share': 'Déplacer vers le dossier des plans du dépôt, pour que tous ceux qui le récupèrent le voient', + 'rightSidebar.contextNotesTodo.plans.makePersonal': 'Déplacer vers mes plans, hors du dépôt', 'rightSidebar.contextNotesTodo.plans.deletePlanWithTitle': 'Supprimer le plan "{title}"', 'rightSidebar.contextNotesTodo.sendDialog.title.newSession': 'Envoyer à une nouvelle session', 'rightSidebar.contextNotesTodo.sendDialog.title.newWorktree': 'Envoyer vers un nouvel worktree', @@ -1604,6 +1623,7 @@ export const dict = { 'rightSidebar.contextNotesTodo.toast.sendTodoFailed': 'Échec de l\'envoi de la tâche', 'rightSidebar.contextNotesTodo.toast.updatePlanFailed': 'Échec de la mise à jour du plan', 'rightSidebar.contextNotesTodo.toast.deletePlanFailed': 'Échec de la suppression du plan', + 'rightSidebar.contextNotesTodo.toast.movePlanFailed': 'Impossible de déplacer le plan', 'rightSidebar.contextNotesTodo.toast.planFileEmpty': 'Le fichier de plan est vide', 'rightSidebar.contextNotesTodo.toast.importPlanFailed': 'Échec de l\'importation du plan', 'rightSidebar.contextNotesTodo.toast.planImported': 'Forfait importé', @@ -1634,6 +1654,7 @@ export const dict = { 'header.services.refreshRateLimitsAria': 'Limites du taux de rafraîchissement', 'header.services.noRateLimits': 'Aucune limite de taux disponible.', 'header.services.noRateLimitsReported': 'Aucune limite de taux signalée.', + 'header.services.usageRefreshFailedStale': 'Les données d’utilisation précédentes sont affichées. Échec de l’actualisation : {error}', 'header.services.used': 'Utilisé', 'header.services.remaining': 'Restant', 'header.services.modelFamily.other': 'Autre', @@ -1714,6 +1735,7 @@ export const dict = { 'terminalView.tabs.closeTabTitle': 'Fermer l\'onglet', 'terminalView.tabs.newTabTitle': 'Nouvel onglet', 'terminalView.viewport.inputAria': 'Entrée de borne', + 'terminalView.viewport.scrollbarAria': 'Historique du terminal', 'directoryExplorerDialog.title': 'Ajouter un répertoire de projet', 'directoryExplorerDialog.description': 'Choisissez un dossier à ajouter en tant que projet.', 'directoryExplorerDialog.toggle.showHidden': 'Afficher masqué', @@ -2233,6 +2255,8 @@ export const dict = { 'chat.btw.toast.destroyFailed': 'Échec de la suppression de la session btw. Elle restera dans la barre latérale.', 'chat.btw.working': 'En cours…', 'chat.btw.collapseAria': 'Réduire le panneau btw', + 'chat.btw.draftHint': 'Posez votre question', + 'chat.btw.cancelAria': 'Annuler cette question BTW', 'chat.btw.expandAria': 'Développer le panneau btw', 'chat.btw.promoteAria': 'Conserver comme session à part', 'chat.btw.toast.promoteFailed': 'Échec de la conservation de la session btw', @@ -2275,6 +2299,8 @@ export const dict = { 'chat.textSelection.toast.addToNotesSummaryFailed': 'Impossible de résumer la sélection, ajout du texte sélectionné aux notes', 'chat.textSelection.actions.addToInput': 'Ajouter à la saisie', 'chat.textSelection.actions.comment': 'Commenter', + 'chat.textSelection.actions.askOpenChamber': 'Au fait…', + 'chat.textSelection.title.askOpenChamber': 'Ouvrir un brouillon BTW avec la sélection', 'chat.textSelection.title.commentOnSelection': 'Commenter la sélection', 'chat.textSelection.comment.placeholder': 'Ajouter un commentaire facultatif...', 'chat.textSelection.comment.attach': 'Joindre', @@ -2293,6 +2319,8 @@ export const dict = { 'chat.messageBody.actions.openPreviewAria': 'Ouvrir l\'aperçu', 'chat.messageBody.actions.openPreview': 'Ouvrir l\'aperçu', 'chat.messageBody.actions.copyAnswer': 'Copier la réponse', + 'chat.messageBody.actions.moreActions': 'Plus d’actions', + 'chat.messageBody.toast.copied': 'Copié dans le presse-papiers', 'chat.messageBody.actions.savingImage': 'Enregistrement de l\'image...', 'chat.messageBody.actions.saveAsImage': 'Enregistrer sous image', 'chat.messageBody.actions.saveAsPlan': 'Enregistrer comme forfait', @@ -2389,6 +2417,7 @@ export const dict = { 'chat.chatInput.draftPicker.projectTitle': 'Projet', 'chat.chatInput.draftPicker.searchProjects': 'Rechercher des projets...', 'chat.chatInput.draftPicker.searchBranches': 'Rechercher des branches...', + 'chat.chatInput.draftPicker.noProjectsFound': 'Aucun projet trouvé.', 'chat.chatInput.worktrees': 'Worktrees', 'chat.chatInput.worktreeNew': '+ Nouveau', 'chat.chatInput.drop.insertMention': 'Déposer pour insérer comme mention', @@ -2726,6 +2755,13 @@ export const dict = { 'projectActions.actions.addAction': 'Ajouter une action', 'projectActions.actions.addNewAction': 'Ajouter une nouvelle action', 'projectActions.actions.autoDiscover': 'Découverte automatique', + 'projectActions.menu.sharedBadge': 'dépôt', + 'projects.sharedTrust.title': 'Exécuter les commandes enregistrées dans ce dépôt ?', + 'projects.sharedTrust.description': '{path} dans ce dépôt définit des commandes qui s\'exécutent sur cette machine. Faites-leur confiance une fois, et OpenChamber ne redemandera que si elles changent.', + 'projects.sharedTrust.setupCommands': 'Commandes de configuration du worktree', + 'projects.sharedTrust.actions': 'Actions', + 'projects.sharedTrust.skip': 'Pas cette fois', + 'projects.sharedTrust.trust': 'Faire confiance et exécuter', 'projectActions.actions.autoDiscoverTooltip': 'Détecte et lance automatiquement le serveur de développement', 'projectActions.actions.chooseActionAria': 'Choisir l\'action du projet', 'projectActions.actions.openPreview': 'Ouvrir l\'aperçu', @@ -3262,13 +3298,13 @@ export const dict = { 'mobile.sessions.showArchived': 'Afficher les archivées ({count})', 'mobile.sessions.hideArchived': 'Masquer les archivées', 'mobile.sessions.activeWorktreeAria': 'Worktree actif', - 'mobile.sessions.activeProjectAria': 'Projet actif', 'mobile.sessions.startNewChat': 'Démarrer un nouveau chat', 'mobile.sessions.newChat': 'Nouveau chat', 'mobile.sessions.editOrder': 'Réordonner les projets', 'mobile.sessions.doneEditing': 'Terminé', 'mobile.sessions.editOrderHint': 'Faites glisser la poignée pour réorganiser les projets. Touchez un projet pour afficher ses worktrees et les faire glisser aussi. Touchez la coche pour terminer.', 'mobile.sessions.editProjectAria': 'Modifier {label}', + 'mobile.sessions.newSessionInProjectAria': 'Nouvelle session dans {label}', 'mobile.sessions.dragHandleAria': 'Faire glisser {label} pour réordonner', 'mobile.sessions.moveUpAria': 'Déplacer {label} vers le haut', 'mobile.sessions.moveDownAria': 'Déplacer {label} vers le bas', @@ -3521,6 +3557,9 @@ export const dict = { 'chat.draftStarters.sectionCommands': 'Commandes', 'chat.draftStarters.sectionSkills': 'Skills', 'chat.draftStarters.remove': 'Retirer', + 'chat.draftStarters.sharedTitle': 'Épinglé dans la configuration du dépôt ; à modifier là-bas', + 'chat.draftStarters.share': 'Déplacer vers la configuration du dépôt', + 'chat.draftStarters.makePersonal': 'Déplacer vers mes réglages', 'chat.commandAutocomplete.command.handoffReviewDescription': 'Créer ou réutiliser une session de revue séparée à partir d’un handoff généré.', 'chat.commandAutocomplete.command.featurePlanDescription': 'Lancer une session guidée et interactive de planification pour une nouvelle fonctionnalité.', 'chat.commandAutocomplete.command.craftGoalDescription': 'Transformer une idée ou une tâche en Goal clair et vérifiable.', @@ -3619,6 +3658,26 @@ export const dict = { 'chat.workStatus.action.openMr': 'Ouvrir la demande de fusion', 'chat.workStatus.action.openSubagent': 'Ouvrir {name}', 'chat.workStatus.section.usage': 'Utilisation', + 'chat.workStatus.section.telemetry': 'Stats du tour', + 'chat.workStatus.telemetry.responseSpeed': 'Réponse', + 'chat.workStatus.telemetry.responseSpeedDescription': 'La vitesse à laquelle le texte final est arrivé. Sans attente initiale, raisonnement ni appels précédents aux outils. Une estimation basée sur les horodatages du texte, pas une mesure du fournisseur.', + 'chat.workStatus.telemetry.speed': 'Requête', + 'chat.workStatus.telemetry.llmDuration': 'Modèle', + 'chat.workStatus.telemetry.llmDurationDescription': 'Durée de toutes les étapes du modèle, attente des réponses comprise. Le temps des outils est soustrait. Ce ne sont pas uniquement les secondes de génération du texte.', + 'chat.workStatus.telemetry.toolDuration': 'Outils', + 'chat.workStatus.telemetry.toolDurationDescription': 'Temps passé à exécuter les outils, y compris les appels échoués. Les outils exécutés en parallèle ne sont comptés qu’une fois.', + 'chat.workStatus.telemetry.ttft': 'TTFT moyen', + 'chat.workStatus.telemetry.ttftDescription': 'Attente moyenne avant le premier texte ou raisonnement de chaque étape. Masquée si une étape manque d’horodatage de début, ce qui arrive souvent pour les appels aux outils sans texte.', + 'chat.workStatus.telemetry.steps': 'Étapes', + 'chat.workStatus.telemetry.stepsDescription': 'Nombre d’appels au modèle pour ce prompt. Lire les résultats d’un outil et décider de la suite demande généralement une nouvelle étape.', + 'chat.workStatus.telemetry.tokens': 'Jetons', + 'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓', + 'chat.workStatus.telemetry.speedDescription': 'Jetons générés à toutes les étapes, raisonnement compris, divisés par la durée hors exécution des outils. L’attente du modèle compte, donc de nombreux appels courts peuvent réduire ce chiffre.', + 'chat.workStatus.telemetry.tokensDescription': '↑ Entrée hors cache : {input}. ↓ Jetons générés : {output} pour le texte et les appels aux outils, plus {reasoning} pour le raisonnement. Totaux de toutes les étapes de ce prompt.', + 'chat.workStatus.telemetry.cacheHit': 'Cache', + 'chat.workStatus.telemetry.cacheHitDescription': 'Part des jetons d’entrée réutilisés depuis le cache du prompt, sur toutes les étapes. Réutiliser le contexte peut réduire le coût et l’attente, mais ce n’est pas un indice de vitesse.', + 'chat.workStatus.telemetry.cost': 'Coût', + 'chat.workStatus.telemetry.costDescription': 'Coût indiqué par le fournisseur pour toutes les étapes de ce prompt, en dollars américains. Les sessions séparées des sous-agents sont exclues. Zéro peut signifier un modèle gratuit ou un fournisseur sans indication de coût.', 'chat.workStatus.goal.open': 'Gérer l’objectif', 'chat.workStatus.goal.pause': 'Mettre en pause', 'chat.workStatus.goal.resume': 'Reprendre', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 9e77919b..a71338a8 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1,5 +1,7 @@ import { linearIntegrationI18n } from './linear-integration.i18n'; export const settingsDict = { + 'settings.openchamber.visual.field.alwaysShowScrollbars': 'スクロールバーを常に表示', + 'settings.openchamber.visual.field.alwaysShowScrollbarsHint': 'ポインターがスクロール領域の外にあるときも、スクロールバーを表示します。このデバイスにのみ適用されます。', 'settings.providers.page.openCodeGo.title': 'OpenCode Go 使用量追跡', 'settings.providers.page.openCodeGo.description': 'OpenCode Go ダッシュボードを接続して、ローリング、週間、月間のクォータを表示します。', 'settings.providers.page.openCodeGo.workspaceId': 'ワークスペース ID', @@ -59,7 +61,6 @@ export const settingsDict = { 'settings.view.pendingRestart.confirm.dontShowAgain': '今後表示しない', 'settings.view.pendingRestart.confirm.cancel': 'キャンセル', 'settings.view.actions.backToSettings': '設定に戻る', 'settings.view.actions.closeSettings': '設定を閉じる', - 'settings.view.actions.openSectionList': 'セクション一覧を開く', 'settings.view.actions.closeSettingsWithShortcut': '設定を閉じる ({shortcut}+,)', 'settings.view.actions.back': '戻る', 'settings.view.actions.resizeNavigation': '設定ナビゲーションのサイズ変更', @@ -466,6 +467,34 @@ export const settingsDict = { 'settings.common.permission.deny': '拒否', 'settings.common.state.comingSoon': '近日公開...', 'settings.projects.actions.title': 'アクション', + 'settings.projects.shared.badge': 'リポジトリ内', + 'settings.projects.shared.actionsFromRepo': 'リポジトリ内に保存 ({path})。pull した全員が使えます。', + 'settings.projects.shared.commandsFromRepo': '最初に実行。リポジトリ内に保存 ({path})', + 'settings.projects.shared.invalid': '{path} のプロジェクト設定を読み込めませんでした: {reason}', + 'settings.projects.shared.trusted': 'リポジトリのコマンドはこのインスタンスで信頼済み', + 'settings.projects.shared.resetTrust': '信頼をリセット', + 'settings.projects.shared.title': 'リポジトリ設定', + 'settings.projects.shared.description': 'リポジトリ自体に保存されるセットアップ。pull した全員が同じアクション、セットアップコマンド、スターター、プランを使えます。項目を移動するまで何も書き込まれません。', + 'settings.projects.shared.file': 'ファイル', + 'settings.projects.shared.status.missing': 'まだリポジトリにありません', + 'settings.projects.shared.status.ok': 'リポジトリにあります', + 'settings.projects.shared.plansDir': 'プランフォルダー', + 'settings.projects.shared.plansDirPlaceholder': '.openchamber/plans', + 'settings.projects.shared.plansDirInfo': 'リポジトリのプランを置く場所(リポジトリ相対)。空なら .openchamber/plans。docs/plans のようなカスタムフォルダーはデフォルトを完全に置き換え、そのフォルダーだけを読み書きします。変更時は既存ファイルを自分で移動してください。', + 'settings.projects.shared.plansDirAria': 'リポジトリのプランフォルダー', + 'settings.projects.shared.actions.share': 'リポジトリへ移動', + 'settings.projects.shared.actions.showTitle': 'このリポジトリのアクションを自分のメニューに再表示します。', + 'settings.projects.shared.actions.hideTitle': 'このリポジトリのアクションを自分のメニューだけで非表示にします。リポジトリは変更されません。', + 'settings.projects.shared.actions.makePersonalTitle': 'リポジトリから削除し、このインスタンスのあなたの設定にだけ残します。', + 'settings.projects.shared.actions.shareTitle': 'リポジトリ内の {path} に保存し、pull した全員が使えるようにします。あなたの個人設定からは外れます。', + 'settings.projects.shared.actions.shareAfterSave': '先に編集内容が保存されてから移動できます', + 'settings.projects.shared.actions.makePersonal': '自分の設定へ移動', + 'settings.projects.shared.actions.hide': '自分には非表示', + 'settings.projects.shared.actions.show': '表示', + 'settings.projects.shared.hiddenBadge': '非表示', + 'settings.projects.shared.replaceMode': '自分のセットアップコマンドのみ使用し、リポジトリのものはスキップ', + 'settings.projects.shared.replaceModeAria': '自分のセットアップコマンドのみ使用し、リポジトリのものはスキップ', + 'settings.projects.shared.toast.shareFailed': 'リポジトリ設定を更新できませんでした', 'settings.projects.actions.description': 'ヘッダーのプロジェクト名の横に表示されるプロジェクトごとのコマンド。', 'settings.projects.actions.validation.fillNameAndCommand': '保存する前にアクション名とコマンドを入力してください。', 'settings.projects.actions.state.loading': '読み込み中...', @@ -1018,10 +1047,10 @@ export const settingsDict = { 'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': 'アプリを再起動して、Wi-Fi 上の他のデバイスから開けるようにします。', 'settings.openchamber.desktopNetwork.field.warning': '警告: 有効にすると、アプリは同じローカルネットワーク上の誰からもアクセス可能になります。', 'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'LAN アクセスには Desktop UI パスワードが必要です。設定されるまで、Desktop アプリはローカルのみで起動します。', - 'settings.openchamber.desktopPassword.actions.showPassword': 'パスワードを表示', - 'settings.openchamber.desktopPassword.actions.hidePassword': 'パスワードを非表示', 'settings.openchamber.desktopPassword.field.password': 'Desktop UI パスワード', 'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'パスワード不要', + 'settings.openchamber.desktopPassword.field.passwordSetPlaceholder': 'パスワード設定済み。置き換えるには新しいパスワードを入力してください。', + 'settings.openchamber.desktopPassword.actions.removePassword': 'パスワードを削除', 'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber は再起動後、およびログイン Session の有効期限後(12時間、または「このデバイスを信頼」の場合は7日)に確認を求めます。空のままにするとログインが無効になります。', 'settings.openchamber.desktopNetwork.hint.openAfterRestart': '再起動後、別のデバイスから開く: ', 'settings.openchamber.desktopNetwork.hint.openNow': '別のデバイスから開く: ', @@ -2342,8 +2371,10 @@ export const settingsDict = { 'settings.openchamber.visual.field.inputHistoryLimitDescription': 'この数を減らすと、履歴内の古いプロンプトはすぐに削除されます。', 'settings.openchamber.visual.field.inputHistoryLimitAria': '記憶するプロンプト数', 'settings.openchamber.visual.field.inputHistoryLimitUnit': '件', - 'settings.openchamber.visual.field.enterToSend': 'Enterで送信', - 'settings.openchamber.visual.field.enterToSendHint': '変更すると、すべての環境でEnterとShift+Enterの動作を制御します。変更するまでは、各環境の既存の動作が維持されます。', + 'settings.openchamber.visual.field.enterToSend': '送信ショートカット', + 'settings.openchamber.visual.field.enterToSendHint': '標準コンポーザーの送信ショートカットを選択します。拡張コンポーザーでは、Enter は常に改行し、Ctrl/Cmd+Enter で送信します。', + 'settings.openchamber.visual.option.enterToSend.enter.label': 'Enter で送信', + 'settings.openchamber.visual.option.enterToSend.modifier.label': 'Ctrl/Cmd+Enter で送信', ...linearIntegrationI18n.ja, 'settings.page.integrations.title': '連携', 'settings.page.integrations.description': 'GitHub と Linear を接続すると、OpenChamber が Issue やプルリクエストを扱えるようになります。', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index a33013ad..02c86739 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -4,11 +4,29 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { + 'commitComparison.mode': 'コミット', + 'commitComparison.select': 'コミットを選択', + 'commitComparison.search': 'コミットを検索...', + 'commitComparison.loadError': 'コミットを読み込めませんでした', + 'commitComparison.noCommits': 'コミットが見つかりません', + 'commitComparison.emptyDiff': 'このコミットに変更はありません', + 'chat.liveActivity.title': 'アクティビティ', + 'chat.liveActivity.changedFile': '{count} ファイルを変更', + 'chat.liveActivity.changedFiles': '{count} ファイルを変更', + 'chat.liveActivity.explored': 'コードベースを調査', + 'chat.liveActivity.ranCommand': '{count} コマンドを実行', + 'chat.liveActivity.ranCommands': '{count} コマンドを実行', + 'chat.liveActivity.researched': 'ウェブを調査', + 'chat.liveActivity.usedSubagent': '{count} サブエージェントを使用', + 'chat.liveActivity.usedSubagents': '{count} サブエージェントを使用', 'sessions.sidebar.projectAction.active': 'プロジェクトアクション実行中', ...settingsDict, ...linearIssuePickerI18n.ja, ...linearPanelI18n.ja, 'terminalView.actions.attachSelection': '選択した出力を添付', + 'terminalView.actions.copySelection': '選択した出力をコピー', + 'terminalView.toast.selectionCopied': '出力をコピーしました', + 'terminalView.toast.copyFailed': 'コピーに失敗しました', 'terminalView.actions.restart': 'ターミナルを再起動', 'chat.message.terminalContext': '{terminal}、{start}〜{end}行', 'chat.message.context.codeComment': '{file} の {start}〜{end} 行へのコメント', @@ -146,13 +164,13 @@ export const dict: Record = { 'mobile.sessions.showArchived': 'アーカイブを表示({count})', 'mobile.sessions.hideArchived': 'アーカイブを非表示', 'mobile.sessions.activeWorktreeAria': 'アクティブなワークツリー', - 'mobile.sessions.activeProjectAria': 'アクティブなプロジェクト', 'mobile.sessions.startNewChat': '新しいチャットを開始', 'mobile.sessions.newChat': '新しいチャット', 'mobile.sessions.editOrder': 'プロジェクトの並び替え', 'mobile.sessions.doneEditing': '完了', 'mobile.sessions.editOrderHint': 'ハンドルをドラッグしてプロジェクトを並べ替えます。プロジェクトをタップするとワークツリーが表示され、同様にドラッグできます。チェックをタップして完了します。', 'mobile.sessions.editProjectAria': '{label}を編集', + 'mobile.sessions.newSessionInProjectAria': '{label}で新しいセッション', 'mobile.sessions.dragHandleAria': '{label}をドラッグして並び替え', 'mobile.sessions.moveUpAria': '{label}を上に移動', 'mobile.sessions.moveDownAria': '{label}を下に移動', @@ -706,7 +724,6 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '添付のワークツリーをアーカイブしました。', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': '添付のワークツリーをアーカイブしました。', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'ワークツリーをアーカイブし、リモートブランチを削除しました。', - 'sessions.missingDirectory.movedToProject': 'このセッションのフォルダーは存在しません。セッションを {project} に移動しました。', 'sessions.sidebar.group.worktreeMissing': 'ワークツリーのフォルダーがありません', 'sessions.sidebar.sessionDialogs.worktree.label': 'ワークツリー', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'ワークツリーパスは利用できません。', @@ -1313,7 +1330,6 @@ export const dict: Record = { 'contextRail.surface.walkthrough.description': 'AI による変更のガイド付きウォークスルー', 'walkthrough.scope.all': '未コミットすべて', 'walkthrough.scope.group.workingTree': '作業ツリー', - 'walkthrough.scope.group.committed': 'コミット済み', 'walkthrough.scope.staged': 'ステージ済み', 'walkthrough.scope.working': '未ステージ', 'walkthrough.scope.branch': 'このブランチ', @@ -1908,6 +1924,9 @@ export const dict: Record = { 'rightSidebar.contextNotesTodo.plans.importFromFile': 'ファイルから計画をインポート', 'rightSidebar.contextNotesTodo.plans.empty': 'まだ保存された計画はありません。', 'rightSidebar.contextNotesTodo.plans.deletePlan': '計画を削除', + 'rightSidebar.contextNotesTodo.plans.sharedBadge': 'リポジトリ内', + 'rightSidebar.contextNotesTodo.plans.share': 'リポジトリのプランフォルダーへ移動し、pull した全員が見られるようにします', + 'rightSidebar.contextNotesTodo.plans.makePersonal': 'リポジトリから自分のプランへ移動', 'rightSidebar.contextNotesTodo.plans.deletePlanWithTitle': '計画「{title}」を削除', 'rightSidebar.contextNotesTodo.sendDialog.title.newSession': '新しいセッションに送信', 'rightSidebar.contextNotesTodo.sendDialog.title.newWorktree': '新しいワークツリーに送信', @@ -1926,6 +1945,7 @@ export const dict: Record = { 'rightSidebar.contextNotesTodo.toast.sendTodoFailed': 'TODOの送信に失敗しました', 'rightSidebar.contextNotesTodo.toast.updatePlanFailed': '計画を更新できませんでした', 'rightSidebar.contextNotesTodo.toast.deletePlanFailed': '計画の削除に失敗しました', + 'rightSidebar.contextNotesTodo.toast.movePlanFailed': 'プランを移動できませんでした', 'rightSidebar.contextNotesTodo.toast.planFileEmpty': '計画ファイルが空です', 'rightSidebar.contextNotesTodo.toast.importPlanFailed': '計画のインポートに失敗しました', 'rightSidebar.contextNotesTodo.toast.planImported': '計画をインポートしました', @@ -1957,6 +1977,7 @@ export const dict: Record = { 'header.services.refreshRateLimitsAria': 'レート制限を更新', 'header.services.noRateLimits': 'レート制限は利用できません。', 'header.services.noRateLimitsReported': 'レート制限は報告されていません。', + 'header.services.usageRefreshFailedStale': '以前に取得した使用状況を表示しています。更新に失敗しました: {error}', 'header.services.remoteUpdate.title': 'リモートインスタンスの更新', 'header.services.remoteUpdate.checking': '更新を確認中...', 'header.services.remoteUpdate.upToDate': 'このインスタンスは最新です。', @@ -2043,6 +2064,7 @@ export const dict: Record = { 'terminalView.tabs.closeTabTitle': 'タブを閉じる', 'terminalView.tabs.newTabTitle': '新しいタブ', 'terminalView.viewport.inputAria': 'ターミナル入力', + 'terminalView.viewport.scrollbarAria': 'ターミナルのスクロールバック', 'directoryExplorerDialog.title': 'プロジェクトディレクトリを追加', 'directoryExplorerDialog.description': 'プロジェクトとして追加するフォルダを選択してください。', 'directoryExplorerDialog.toggle.showHidden': '隠しファイルを表示', @@ -2482,6 +2504,9 @@ export const dict: Record = { 'chat.draftStarters.sectionCommands': 'コマンド', 'chat.draftStarters.sectionSkills': 'スキル', 'chat.draftStarters.remove': '削除', + 'chat.draftStarters.sharedTitle': 'リポジトリ設定でピン留め。変更はそちらで', + 'chat.draftStarters.share': 'リポジトリ設定へ移動', + 'chat.draftStarters.makePersonal': '自分の設定へ移動', 'chat.scrollToBottom.aria': '一番下にスクロール', 'chat.promptNavigator.aria': 'プロンプトナビゲーション', 'chat.promptNavigator.currentPrompt': '現在のプロンプト', @@ -2589,6 +2614,8 @@ export const dict: Record = { 'chat.btw.toast.destroyFailed': 'btwセッションを破棄できませんでした。サイドバーに残ります。', 'chat.btw.working': '処理中…', 'chat.btw.collapseAria': 'btwパネルを折りたたむ', + 'chat.btw.draftHint': '質問を入力してください', + 'chat.btw.cancelAria': 'このBTWの質問をキャンセル', 'chat.btw.expandAria': 'btwパネルを展開する', 'chat.btw.promoteAria': '独立したセッションとして保持', 'chat.btw.toast.promoteFailed': 'btwセッションを保持できませんでした', @@ -2635,6 +2662,8 @@ export const dict: Record = { 'chat.textSelection.toast.addToNotesSummaryFailed': '選択範囲を要約できませんでした。選択テキストをメモに追加しました。', 'chat.textSelection.actions.addToInput': '入力欄に追加', 'chat.textSelection.actions.comment': 'コメント', + 'chat.textSelection.actions.askOpenChamber': 'ところで…', + 'chat.textSelection.title.askOpenChamber': '選択したテキストでBTWの下書きを開く', 'chat.textSelection.title.commentOnSelection': '選択範囲にコメント', 'chat.textSelection.comment.placeholder': '任意のコメントを追加...', 'chat.textSelection.comment.attach': '添付', @@ -2653,6 +2682,8 @@ export const dict: Record = { 'chat.messageBody.actions.openPreviewAria': 'プレビューを開く', 'chat.messageBody.actions.openPreview': 'プレビューを開く', 'chat.messageBody.actions.copyAnswer': '回答をコピー', + 'chat.messageBody.actions.moreActions': 'その他の操作', + 'chat.messageBody.toast.copied': 'クリップボードにコピーしました', 'chat.messageBody.actions.savingImage': '画像を保存中...', 'chat.messageBody.actions.saveAsImage': '画像として保存', 'chat.messageBody.actions.saveAsPlan': '計画として保存', @@ -2767,6 +2798,7 @@ export const dict: Record = { 'chat.chatInput.draftPicker.projectTitle': 'プロジェクト', 'chat.chatInput.draftPicker.searchProjects': 'プロジェクトを検索...', 'chat.chatInput.draftPicker.searchBranches': 'ブランチを検索...', + 'chat.chatInput.draftPicker.noProjectsFound': 'プロジェクトが見つかりません。', 'chat.chatInput.worktrees': 'ワークツリー', 'chat.chatInput.worktreeNew': '+ 新規', 'chat.chatInput.drop.insertMention': 'ドロップしてメンションとして挿入', @@ -3113,6 +3145,13 @@ export const dict: Record = { 'projectActions.actions.addAction': 'アクションを追加', 'projectActions.actions.addNewAction': '新しいアクションを追加', 'projectActions.actions.autoDiscover': '自動検出', + 'projectActions.menu.sharedBadge': 'リポジトリ', + 'projects.sharedTrust.title': 'このリポジトリに保存されたコマンドを実行しますか?', + 'projects.sharedTrust.description': 'このリポジトリの {path} には、このマシンで実行されるコマンドが定義されています。一度信頼すると、変更があった場合のみ再度確認します。', + 'projects.sharedTrust.setupCommands': 'ワークツリーのセットアップコマンド', + 'projects.sharedTrust.actions': 'アクション', + 'projects.sharedTrust.skip': '今回は実行しない', + 'projects.sharedTrust.trust': '信頼して実行', 'projectActions.actions.autoDiscoverTooltip': '開発サーバーを自動的に検出して実行します', 'projectActions.actions.chooseActionAria': 'プロジェクトアクションを選択', 'projectActions.actions.openPreview': 'プレビューを開く', @@ -3621,6 +3660,26 @@ export const dict: Record = { 'chat.workStatus.action.openMr': 'マージリクエストを開く', 'chat.workStatus.action.openSubagent': '{name} を開く', 'chat.workStatus.section.usage': '使用量', + 'chat.workStatus.section.telemetry': 'ターンの統計', + 'chat.workStatus.telemetry.responseSpeed': '回答', + 'chat.workStatus.telemetry.responseSpeedDescription': '最終テキストが届いた速さです。開始前の待ち時間、推論、先行するツール呼び出しは含みません。テキストの時刻から求めた推定値で、プロバイダー側の速度測定ではありません。', + 'chat.workStatus.telemetry.speed': 'リクエスト全体', + 'chat.workStatus.telemetry.llmDuration': 'モデル時間', + 'chat.workStatus.telemetry.llmDurationDescription': '応答待ちを含む全モデルステップの時間です。ツール実行時間は差し引いています。テキスト生成だけの時間ではありません。', + 'chat.workStatus.telemetry.toolDuration': 'ツール時間', + 'chat.workStatus.telemetry.toolDurationDescription': '失敗した呼び出しも含むツールの実行時間です。同時に動いたツールの時間は重複して加算しません。', + 'chat.workStatus.telemetry.ttft': '平均 TTFT', + 'chat.workStatus.telemetry.ttftDescription': '各ステップで最初のテキストや推論が始まるまでの平均待ち時間です。開始時刻がないステップがあれば表示しません。ツール呼び出しのみのステップでは時刻がないことがあります。', + 'chat.workStatus.telemetry.steps': 'ステップ数', + 'chat.workStatus.telemetry.stepsDescription': 'このプロンプトでモデルを呼び出した回数です。ツールの結果を読み、次の処理を決める際は通常もう一度呼び出します。', + 'chat.workStatus.telemetry.tokens': 'トークン', + 'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓', + 'chat.workStatus.telemetry.speedDescription': '推論を含む全ステップの生成トークン数を、ツール実行を除いた時間で割った値です。モデルの待ち時間は含むため、短い呼び出しが多いと低くなります。', + 'chat.workStatus.telemetry.tokensDescription': '↑ キャッシュを除く入力: {input}。↓ 生成: テキストとツール呼び出し {output}、推論 {reasoning}。このプロンプトの全ステップの合計です。', + 'chat.workStatus.telemetry.cacheHit': 'キャッシュ', + 'chat.workStatus.telemetry.cacheHitDescription': '全ステップの入力トークンのうち、プロンプトキャッシュから再利用した割合です。費用や待ち時間を減らせる場合がありますが、速度の指標ではありません。', + 'chat.workStatus.telemetry.cost': '費用', + 'chat.workStatus.telemetry.costDescription': 'このプロンプトの全モデルステップについてプロバイダーが報告した米ドル建ての費用です。別のサブエージェントセッションは含みません。ゼロは無料モデル、または費用の報告がない場合があります。', 'chat.workStatus.goal.open': '目標を管理', 'chat.workStatus.goal.pause': '一時停止', 'chat.workStatus.goal.resume': '再開', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index d124cc10..d19b5b09 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1,5 +1,7 @@ import { linearIntegrationI18n } from './linear-integration.i18n'; export const settingsDict = { + 'settings.openchamber.visual.field.alwaysShowScrollbars': '스크롤바 항상 표시', + 'settings.openchamber.visual.field.alwaysShowScrollbarsHint': '포인터가 스크롤 영역 밖에 있어도 스크롤바를 표시합니다. 이 기기에만 적용됩니다.', 'settings.providers.page.openCodeGo.title': 'OpenCode Go 사용량 추적', 'settings.providers.page.openCodeGo.description': 'OpenCode Go 대시보드를 연결하여 롤링, 주간 및 월간 할당량을 표시합니다.', 'settings.providers.page.openCodeGo.workspaceId': '워크스페이스 ID', @@ -59,7 +61,6 @@ export const settingsDict = { 'settings.view.pendingRestart.confirm.dontShowAgain': '다시 표시하지 않음', 'settings.view.pendingRestart.confirm.cancel': '취소', 'settings.view.actions.backToSettings': '설정으로 돌아가기', 'settings.view.actions.closeSettings': '설정 닫기', - 'settings.view.actions.openSectionList': '섹션 목록 열기', 'settings.view.actions.closeSettingsWithShortcut': '설정 닫기 ({shortcut}+,)', 'settings.view.actions.back': '뒤로', 'settings.view.actions.resizeNavigation': '설정 내비게이션 크기 조정', @@ -433,6 +434,34 @@ export const settingsDict = { 'settings.common.permission.deny': '거부', 'settings.common.state.comingSoon': '곧 제공됩니다...', 'settings.projects.actions.title': '작업', + 'settings.projects.shared.badge': '저장소에 있음', + 'settings.projects.shared.actionsFromRepo': '저장소에 저장됨({path}). 저장소를 받는 모든 사람이 사용합니다.', + 'settings.projects.shared.commandsFromRepo': '먼저 실행됨, 저장소에 저장됨({path})', + 'settings.projects.shared.invalid': '{path}의 프로젝트 설정을 읽을 수 없습니다: {reason}', + 'settings.projects.shared.trusted': '이 인스턴스에서 저장소 명령을 신뢰함', + 'settings.projects.shared.resetTrust': '신뢰 초기화', + 'settings.projects.shared.title': '저장소 설정', + 'settings.projects.shared.description': '저장소 자체에 저장되는 설정입니다. 저장소를 받는 모든 사람이 같은 작업, 설정 명령, 스타터, 플랜을 사용합니다. 항목을 옮기기 전에는 아무것도 기록되지 않습니다.', + 'settings.projects.shared.file': '파일', + 'settings.projects.shared.status.missing': '아직 저장소에 없음', + 'settings.projects.shared.status.ok': '저장소에 있음', + 'settings.projects.shared.plansDir': '플랜 폴더', + 'settings.projects.shared.plansDirPlaceholder': '.openchamber/plans', + 'settings.projects.shared.plansDirInfo': '저장소 플랜이 있는 위치입니다(저장소 기준 상대 경로). 비어 있으면 .openchamber/plans입니다. docs/plans 같은 사용자 지정 폴더는 기본값을 완전히 대체하며 그 폴더만 읽고 씁니다. 변경할 때 기존 파일은 직접 옮기세요.', + 'settings.projects.shared.plansDirAria': '저장소 플랜 폴더', + 'settings.projects.shared.actions.share': '저장소로 이동', + 'settings.projects.shared.actions.showTitle': '이 저장소 작업을 내 메뉴에 다시 표시합니다.', + 'settings.projects.shared.actions.hideTitle': '이 저장소 작업을 내 메뉴에서만 숨깁니다. 저장소는 바뀌지 않습니다.', + 'settings.projects.shared.actions.makePersonalTitle': '저장소에서 제거하고 이 인스턴스의 내 설정에만 남깁니다.', + 'settings.projects.shared.actions.shareTitle': '저장소 안의 {path}에 저장하여 저장소를 받는 모든 사람이 사용하게 합니다. 개인 설정에서는 빠집니다.', + 'settings.projects.shared.actions.shareAfterSave': '먼저 변경 사항이 저장된 뒤 이동할 수 있습니다', + 'settings.projects.shared.actions.makePersonal': '내 설정으로 이동', + 'settings.projects.shared.actions.hide': '나에게 숨기기', + 'settings.projects.shared.actions.show': '표시', + 'settings.projects.shared.hiddenBadge': '숨김', + 'settings.projects.shared.replaceMode': '내 설정 명령만 사용하고 저장소의 명령은 건너뛰기', + 'settings.projects.shared.replaceModeAria': '내 설정 명령만 사용하고 저장소의 명령은 건너뛰기', + 'settings.projects.shared.toast.shareFailed': '저장소 설정을 업데이트하지 못했습니다', 'settings.projects.actions.description': '프로젝트 이름 옆 헤더에 표시할 프로젝트별 명령어입니다.', 'settings.projects.actions.validation.fillNameAndCommand': '저장하기 전에 작업 이름과 명령을 입력하세요.', 'settings.projects.actions.state.loading': '로딩 중...', @@ -985,10 +1014,10 @@ export const settingsDict = { 'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': '휴대폰, 태블릿, Wi-Fi의 다른 컴퓨터에서 열 수 있도록 앱을 다시 시작합니다.', 'settings.openchamber.desktopNetwork.field.warning': '경고: 활성화된 동안 같은 로컬 네트워크의 누구나 앱에 접속할 수 있습니다.', 'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'LAN 접속에는 Desktop UI 비밀번호가 필요합니다. 설정하기 전까지 desktop 앱은 로컬 전용으로 시작됩니다.', - 'settings.openchamber.desktopPassword.actions.showPassword': '비밀번호 표시', - 'settings.openchamber.desktopPassword.actions.hidePassword': '비밀번호 숨기기', 'settings.openchamber.desktopPassword.field.password': 'Desktop UI 비밀번호', 'settings.openchamber.desktopPassword.field.passwordPlaceholder': '비밀번호 필요 없음', + 'settings.openchamber.desktopPassword.field.passwordSetPlaceholder': '비밀번호가 설정되어 있습니다. 바꾸려면 새 비밀번호를 입력하세요.', + 'settings.openchamber.desktopPassword.actions.removePassword': '비밀번호 제거', 'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber는 다시 시작 후 비밀번호를 요청하고, 이후 로그인 세션이 만료되면 다시 요청합니다. 기본 12시간, 이 디바이스 신뢰 선택 시 7일입니다. 로그인을 끄려면 비워 두세요.', 'settings.openchamber.desktopNetwork.hint.openAfterRestart': '다시 시작 후 다른 기기에서 열기: ', 'settings.openchamber.desktopNetwork.hint.openNow': '다른 기기에서 열기: ', @@ -2342,8 +2371,10 @@ export const settingsDict = { 'settings.openchamber.visual.field.inputHistoryLimitDescription': '이 숫자를 낮추면 기록에서 오래된 프롬프트가 바로 삭제됩니다.', 'settings.openchamber.visual.field.inputHistoryLimitAria': '기억할 프롬프트 수', 'settings.openchamber.visual.field.inputHistoryLimitUnit': '개', - 'settings.openchamber.visual.field.enterToSend': 'Enter로 전송', - 'settings.openchamber.visual.field.enterToSendHint': '변경하면 모든 환경에서 Enter와 Shift+Enter의 동작을 제어합니다. 변경하기 전에는 각 환경의 기존 동작이 유지됩니다.', + 'settings.openchamber.visual.field.enterToSend': '전송 단축키', + 'settings.openchamber.visual.field.enterToSendHint': '일반 작성기의 전송 단축키를 선택하세요. 확장 작성기에서는 Enter가 항상 줄바꿈을 하고 Ctrl/Cmd+Enter로 전송합니다.', + 'settings.openchamber.visual.option.enterToSend.enter.label': 'Enter로 전송', + 'settings.openchamber.visual.option.enterToSend.modifier.label': 'Ctrl/Cmd+Enter로 전송', ...linearIntegrationI18n.ko, 'settings.page.integrations.title': '통합', 'settings.page.integrations.description': 'GitHub와 Linear를 연결하면 OpenChamber가 이슈와 풀 리퀘스트를 다룰 수 있습니다.', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 2a6535f8..10a83368 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -4,11 +4,29 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { + 'commitComparison.mode': '커밋', + 'commitComparison.select': '커밋 선택', + 'commitComparison.search': '커밋 검색...', + 'commitComparison.loadError': '커밋을 불러오지 못했습니다', + 'commitComparison.noCommits': '커밋을 찾을 수 없습니다', + 'commitComparison.emptyDiff': '이 커밋에는 변경 사항이 없습니다', + 'chat.liveActivity.title': '활동', + 'chat.liveActivity.changedFile': '파일 {count}개 변경', + 'chat.liveActivity.changedFiles': '파일 {count}개 변경', + 'chat.liveActivity.explored': '코드베이스 탐색', + 'chat.liveActivity.ranCommand': '명령 {count}개 실행', + 'chat.liveActivity.ranCommands': '명령 {count}개 실행', + 'chat.liveActivity.researched': '웹 조사', + 'chat.liveActivity.usedSubagent': '하위 에이전트 {count}개 사용', + 'chat.liveActivity.usedSubagents': '하위 에이전트 {count}개 사용', 'sessions.sidebar.projectAction.active': '프로젝트 작업 실행 중', ...settingsDict, ...linearIssuePickerI18n.ko, ...linearPanelI18n.ko, 'terminalView.actions.attachSelection': '선택한 출력 첨부', + 'terminalView.actions.copySelection': '선택한 출력 복사', + 'terminalView.toast.selectionCopied': '출력을 복사했습니다', + 'terminalView.toast.copyFailed': '복사 실패', 'terminalView.actions.restart': '터미널 다시 시작', 'chat.message.terminalContext': '{terminal}, {start}-{end}행', 'chat.message.context.codeComment': '{file} {start}-{end}행에 대한 댓글', @@ -146,7 +164,6 @@ export const dict: Record = { 'mobile.sessions.showArchived': '보관된 항목 표시 ({count})', 'mobile.sessions.hideArchived': '보관된 항목 숨기기', 'mobile.sessions.activeWorktreeAria': '활성 워크트리', - 'mobile.sessions.activeProjectAria': '활성 프로젝트', 'mobile.sessions.startNewChat': '새 채팅 시작', 'mobile.sessions.newChat': '새 채팅', 'mobile.sessions.editOrder': '프로젝트 순서 변경', @@ -165,6 +182,7 @@ export const dict: Record = { 'mobile.sessions.deleteSessionAria': '{title} 삭제', 'mobile.sessions.confirmDeleteSessionAria': '{title} 삭제 확인', 'mobile.sessions.editProjectAria': '{label} 편집', + 'mobile.sessions.newSessionInProjectAria': '{label}에서 새 세션', 'mobile.projectEdit.worktreesTitle': '워크트리', 'mobile.projectEdit.worktreesEmpty': '이 프로젝트에는 아직 워크트리가 없습니다.', 'mobile.projectEdit.reorderHint': '드래그하여 워크트리 순서를 변경합니다.', @@ -706,7 +724,6 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '첨부됨 워크트리 보관됨.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': '첨부됨 워크트리 보관됨.', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': '워크트리가 보관되고 리모트 브랜치가 제거되었습니다.', - 'sessions.missingDirectory.movedToProject': '이 세션의 폴더가 더 이상 존재하지 않습니다. 세션을 {project}(으)로 이동했습니다.', 'sessions.sidebar.group.worktreeMissing': '워크트리 폴더가 없습니다', 'sessions.sidebar.sessionDialogs.worktree.label': '워크트리', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': '워크트리 경로를 사용할 수 없습니다.', @@ -1317,7 +1334,6 @@ export const dict: Record = { 'contextRail.surface.walkthrough.description': 'AI가 안내하는 변경 사항 워크스루', 'walkthrough.scope.all': '커밋되지 않은 전체', 'walkthrough.scope.group.workingTree': '작업 트리', - 'walkthrough.scope.group.committed': '커밋됨', 'walkthrough.scope.staged': '스테이지됨', 'walkthrough.scope.working': '스테이지 안 됨', 'walkthrough.scope.branch': '이 브랜치', @@ -1914,6 +1930,9 @@ export const dict: Record = { 'rightSidebar.contextNotesTodo.plans.importFromFile': '파일에서 플랜 가져오기', 'rightSidebar.contextNotesTodo.plans.empty': '아직 저장된 플랜 없음', 'rightSidebar.contextNotesTodo.plans.deletePlan': '플랜 삭제', + 'rightSidebar.contextNotesTodo.plans.sharedBadge': '저장소에 있음', + 'rightSidebar.contextNotesTodo.plans.share': '저장소 플랜 폴더로 이동하여 저장소를 받는 모든 사람이 보게 합니다', + 'rightSidebar.contextNotesTodo.plans.makePersonal': '저장소에서 내 플랜으로 이동', 'rightSidebar.contextNotesTodo.plans.deletePlanWithTitle': '플랜 "{title}" 삭제', 'rightSidebar.contextNotesTodo.sendDialog.title.newSession': '새 세션으로 보내기', 'rightSidebar.contextNotesTodo.sendDialog.title.newWorktree': '새 워크트리로 보내기', @@ -1932,6 +1951,7 @@ export const dict: Record = { 'rightSidebar.contextNotesTodo.toast.sendTodoFailed': 'Todo 전송 실패', 'rightSidebar.contextNotesTodo.toast.updatePlanFailed': '계획을 업데이트하지 못했습니다', 'rightSidebar.contextNotesTodo.toast.deletePlanFailed': '플랜 삭제 실패', + 'rightSidebar.contextNotesTodo.toast.movePlanFailed': '플랜을 이동하지 못했습니다', 'rightSidebar.contextNotesTodo.toast.planFileEmpty': '플랜 파일이 비어 있음', 'rightSidebar.contextNotesTodo.toast.importPlanFailed': '플랜 가져오기 실패', 'rightSidebar.contextNotesTodo.toast.planImported': '플랜 가져옴', @@ -1963,6 +1983,7 @@ export const dict: Record = { 'header.services.refreshRateLimitsAria': '레이트 리밋 새로고침', 'header.services.noRateLimits': '사용 가능한 레이트 리밋이 없습니다.', 'header.services.noRateLimitsReported': '보고된 레이트 리밋이 없습니다.', + 'header.services.usageRefreshFailedStale': '이전에 받은 사용량을 표시하고 있습니다. 새로고침 실패: {error}', 'header.services.remoteUpdate.title': '원격 인스턴스 업데이트', 'header.services.remoteUpdate.checking': '업데이트를 확인하는 중...', 'header.services.remoteUpdate.upToDate': '이 인스턴스는 최신 상태입니다.', @@ -2049,6 +2070,7 @@ export const dict: Record = { 'terminalView.tabs.closeTabTitle': '탭 닫기', 'terminalView.tabs.newTabTitle': '새 탭', 'terminalView.viewport.inputAria': '터미널 입력', + 'terminalView.viewport.scrollbarAria': '터미널 스크롤백', 'directoryExplorerDialog.title': '프로젝트 디렉터리 추가', 'directoryExplorerDialog.description': '프로젝트로 추가할 폴더를 선택하세요.', 'directoryExplorerDialog.toggle.showHidden': '숨김 항목 표시', @@ -2488,6 +2510,9 @@ export const dict: Record = { 'chat.draftStarters.sectionCommands': 'Commands', 'chat.draftStarters.sectionSkills': 'Skills', 'chat.draftStarters.remove': 'Remove', + 'chat.draftStarters.sharedTitle': '저장소 설정에 고정됨. 그곳에서 변경하세요', + 'chat.draftStarters.share': '저장소 설정으로 이동', + 'chat.draftStarters.makePersonal': '내 설정으로 이동', 'chat.scrollToBottom.aria': '맨 아래로 스크롤', 'chat.promptNavigator.aria': '프롬프트 탐색', 'chat.promptNavigator.currentPrompt': '현재 프롬프트', @@ -2595,6 +2620,8 @@ export const dict: Record = { 'chat.btw.toast.destroyFailed': 'btw 세션을 삭제하지 못했습니다. 사이드바에 남아 있습니다.', 'chat.btw.working': '작업 중…', 'chat.btw.collapseAria': 'btw 패널 접기', + 'chat.btw.draftHint': '질문을 입력하세요', + 'chat.btw.cancelAria': '이 BTW 질문 취소', 'chat.btw.expandAria': 'btw 패널 펼치기', 'chat.btw.promoteAria': '별도 세션으로 유지', 'chat.btw.toast.promoteFailed': 'btw 세션을 유지하지 못했습니다', @@ -2641,6 +2668,8 @@ export const dict: Record = { 'chat.textSelection.toast.addToNotesSummaryFailed': '선택 영역을 요약할 수 없어 선택한 텍스트를 메모에 추가함', 'chat.textSelection.actions.addToInput': '입력란에 추가', 'chat.textSelection.actions.comment': '댓글', + 'chat.textSelection.actions.askOpenChamber': '그런데…', + 'chat.textSelection.title.askOpenChamber': '선택한 텍스트로 BTW 초안 열기', 'chat.textSelection.title.commentOnSelection': '선택 영역에 댓글 달기', 'chat.textSelection.comment.placeholder': '선택적 댓글 추가...', 'chat.textSelection.comment.attach': '첨부', @@ -2657,6 +2686,8 @@ export const dict: Record = { 'chat.messageBody.actions.unpinContext': '컨텍스트에서 고정 해제(압축 후 유지되지 않음)', 'chat.messageBody.actions.contextPinFailed': '컨텍스트 고정을 업데이트하지 못했습니다', 'chat.messageBody.actions.copyAnswer': '답변 복사', + 'chat.messageBody.actions.moreActions': '추가 작업', + 'chat.messageBody.toast.copied': '클립보드에 복사됨', 'chat.messageBody.actions.savingImage': '이미지 저장 중…', 'chat.messageBody.actions.saveAsImage': '이미지로 저장', 'chat.messageBody.actions.saveAsPlan': '플랜으로 저장', @@ -2768,6 +2799,7 @@ export const dict: Record = { 'chat.chatInput.draftPicker.projectTitle': '프로젝트', 'chat.chatInput.draftPicker.searchProjects': '프로젝트 검색...', 'chat.chatInput.draftPicker.searchBranches': '브랜치 검색...', + 'chat.chatInput.draftPicker.noProjectsFound': '프로젝트를 찾을 수 없습니다.', 'chat.chatInput.worktrees': '워크트리', 'chat.chatInput.worktreeNew': '+ 새로 만들기', 'chat.chatInput.drop.insertMention': '여기에 놓아 멘션으로 추가', @@ -3114,6 +3146,13 @@ export const dict: Record = { 'projectActions.actions.addAction': '작업 추가', 'projectActions.actions.addNewAction': '새 작업 추가', 'projectActions.actions.autoDiscover': '자동 검색', + 'projectActions.menu.sharedBadge': '저장소', + 'projects.sharedTrust.title': '이 저장소에 저장된 명령을 실행할까요?', + 'projects.sharedTrust.description': '이 저장소의 {path}에 이 컴퓨터에서 실행되는 명령이 정의되어 있습니다. 한 번 신뢰하면 명령이 바뀔 때만 다시 묻습니다.', + 'projects.sharedTrust.setupCommands': '워크트리 설정 명령', + 'projects.sharedTrust.actions': '작업', + 'projects.sharedTrust.skip': '이번에는 건너뛰기', + 'projects.sharedTrust.trust': '신뢰하고 실행', 'projectActions.actions.autoDiscoverTooltip': '개발 서버를 자동으로 검색하고 실행합니다', 'projectActions.actions.chooseActionAria': '프로젝트 작업 선택', 'projectActions.actions.openPreview': '미리보기 열기', @@ -3621,6 +3660,26 @@ export const dict: Record = { 'chat.workStatus.action.openMr': '머지 리퀘스트 열기', 'chat.workStatus.action.openSubagent': '{name} 열기', 'chat.workStatus.section.usage': '사용량', + 'chat.workStatus.section.telemetry': '턴 통계', + 'chat.workStatus.telemetry.responseSpeed': '응답', + 'chat.workStatus.telemetry.responseSpeedDescription': '최종 텍스트가 도착한 속도입니다. 시작 전 대기, 추론, 이전 도구 호출은 제외합니다. 텍스트 시간 기록으로 계산한 추정치이며 제공자 측 속도 측정값은 아닙니다.', + 'chat.workStatus.telemetry.speed': '전체 요청', + 'chat.workStatus.telemetry.llmDuration': '모델 시간', + 'chat.workStatus.telemetry.llmDurationDescription': '응답 대기를 포함한 모든 모델 단계의 시간입니다. 도구 실행 시간은 뺍니다. 텍스트 생성 시간만을 뜻하지는 않습니다.', + 'chat.workStatus.telemetry.toolDuration': '도구 시간', + 'chat.workStatus.telemetry.toolDurationDescription': '실패한 호출을 포함한 도구 실행 시간입니다. 동시에 실행된 도구의 시간은 중복해서 더하지 않습니다.', + 'chat.workStatus.telemetry.ttft': '평균 TTFT', + 'chat.workStatus.telemetry.ttftDescription': '각 모델 단계에서 첫 텍스트나 추론이 시작되기까지의 평균 대기 시간입니다. 시작 시간이 없는 단계가 있으면 표시하지 않습니다. 도구만 호출하는 단계에서 흔히 발생합니다.', + 'chat.workStatus.telemetry.steps': '단계', + 'chat.workStatus.telemetry.stepsDescription': '이 프롬프트에서 모델을 호출한 횟수입니다. 도구 결과를 읽고 다음 작업을 결정하려면 보통 한 단계가 더 필요합니다.', + 'chat.workStatus.telemetry.tokens': '토큰', + 'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓', + 'chat.workStatus.telemetry.speedDescription': '추론을 포함한 모든 단계의 생성 토큰 수를 도구 실행 시간을 뺀 시간으로 나눈 값입니다. 모델 대기 시간은 포함되므로 짧은 호출이 많으면 낮아질 수 있습니다.', + 'chat.workStatus.telemetry.tokensDescription': '↑ 캐시를 제외한 입력 토큰: {input}. ↓ 생성 토큰: 텍스트와 도구 호출 {output}, 추론 {reasoning}. 이 프롬프트의 모든 단계 합계입니다.', + 'chat.workStatus.telemetry.cacheHit': '캐시 적중률', + 'chat.workStatus.telemetry.cacheHitDescription': '모든 단계의 입력 토큰 중 프롬프트 캐시에서 재사용한 비율입니다. 컨텍스트 재사용은 비용과 대기를 줄일 수 있지만 속도 점수는 아닙니다.', + 'chat.workStatus.telemetry.cost': '비용', + 'chat.workStatus.telemetry.costDescription': '제공자가 보고한 이 프롬프트의 모든 모델 단계 비용이며 미국 달러 기준입니다. 별도 하위 에이전트 세션은 제외합니다. 무료 모델이거나 제공자가 비용을 보고하지 않으면 0일 수 있습니다.', 'chat.workStatus.goal.open': '목표 관리', 'chat.workStatus.goal.pause': '일시정지', 'chat.workStatus.goal.resume': '재개', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 411a2a2c..4f6f33d1 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1,5 +1,7 @@ import { linearIntegrationI18n } from './linear-integration.i18n'; export const settingsDict = { + 'settings.openchamber.visual.field.alwaysShowScrollbars': 'Zawsze pokazuj paski przewijania', + 'settings.openchamber.visual.field.alwaysShowScrollbarsHint': 'Paski przewijania pozostają widoczne nawet wtedy, gdy wskaźnik znajduje się poza przewijanym obszarem. Dotyczy tylko tego urządzenia.', 'settings.providers.page.openCodeGo.title': 'Śledzenie użycia OpenCode Go', 'settings.providers.page.openCodeGo.description': 'Połącz panel OpenCode Go, aby wyświetlać limity kroczące, tygodniowe i miesięczne.', 'settings.providers.page.openCodeGo.workspaceId': 'ID przestrzeni roboczej', @@ -874,10 +876,10 @@ export const settingsDict = { 'settings.openchamber.desktopNetwork.field.keepAwakeDescription': 'Aby telefony nadal mogły otwierać tę aplikację. Ekran nadal może się wyłączyć.', 'settings.openchamber.desktopNetwork.field.warning': 'Ostrzeżenie: po włączeniu aplikacja jest dostępna dla każdego w tej samej sieci lokalnej.', 'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'Dostęp LAN wymaga hasła UI pulpitu. Dopóki go nie ustawisz, aplikacja pulpitu uruchamia się tylko lokalnie.', - 'settings.openchamber.desktopPassword.actions.showPassword': 'Pokaż hasło', - 'settings.openchamber.desktopPassword.actions.hidePassword': 'Ukryj hasło', 'settings.openchamber.desktopPassword.field.password': 'Hasło UI pulpitu', 'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'Hasło nie jest wymagane', + 'settings.openchamber.desktopPassword.field.passwordSetPlaceholder': 'Hasło ustawione. Wpisz nowe, aby je zastąpić.', + 'settings.openchamber.desktopPassword.actions.removePassword': 'Usuń hasło', 'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber pyta po restarcie, a potem po wygaśnięciu sesji logowania: po 12 godzinach albo po 7 dniach z opcją Zaufaj temu urządzeniu. Zostaw puste, aby wyłączyć logowanie.', 'settings.openchamber.desktopNetwork.hint.openAfterRestart': 'Po restarcie otwórz z innego urządzenia: ', 'settings.openchamber.desktopNetwork.hint.openNow': 'Otwórz z innego urządzenia: ', @@ -1472,6 +1474,34 @@ export const settingsDict = { 'settings.projects.actions.state.noDesktopSshForwards': 'Brak włączonych lokalnych przekierowań SSH.', 'settings.projects.actions.state.untitled': 'Akcja bez nazwy', 'settings.projects.actions.title': 'Akcje', + 'settings.projects.shared.badge': 'W repozytorium', + 'settings.projects.shared.actionsFromRepo': 'Zapisane w repozytorium ({path}). Każdy, kto je pobierze, je otrzyma.', + 'settings.projects.shared.commandsFromRepo': 'Uruchamiane najpierw, zapisane w repozytorium ({path})', + 'settings.projects.shared.invalid': 'Nie udało się odczytać konfiguracji projektu w {path}: {reason}', + 'settings.projects.shared.trusted': 'Polecenia z repozytorium zaufane w tej instancji', + 'settings.projects.shared.resetTrust': 'Resetuj zaufanie', + 'settings.projects.shared.title': 'Konfiguracja w repozytorium', + 'settings.projects.shared.description': 'Konfiguracja zapisana w samym repozytorium, dzięki czemu każdy, kto je pobierze, ma te same akcje, polecenia konfiguracji, startery i plany. Nic nie jest zapisywane, dopóki nie przeniesiesz tam elementu.', + 'settings.projects.shared.file': 'Plik', + 'settings.projects.shared.status.missing': 'Jeszcze nie ma w repozytorium', + 'settings.projects.shared.status.ok': 'W repozytorium', + 'settings.projects.shared.plansDir': 'Folder planów', + 'settings.projects.shared.plansDirPlaceholder': '.openchamber/plans', + 'settings.projects.shared.plansDirInfo': 'Gdzie leżą plany w repozytorium, względem repozytorium. Puste oznacza .openchamber/plans. Własny folder, np. docs/plans, całkowicie zastępuje domyślny: czytany i zapisywany jest tylko ten folder. Istniejące pliki przenieś samodzielnie przy zmianie.', + 'settings.projects.shared.plansDirAria': 'Folder planów w repozytorium', + 'settings.projects.shared.actions.share': 'Przenieś do repozytorium', + 'settings.projects.shared.actions.showTitle': 'Ponownie pokazuje tę akcję z repozytorium w Twoim menu.', + 'settings.projects.shared.actions.hideTitle': 'Ukrywa tę akcję z repozytorium tylko w Twoim menu; repozytorium się nie zmienia.', + 'settings.projects.shared.actions.makePersonalTitle': 'Usuwa to z repozytorium i zostawia tylko w Twoich ustawieniach w tej instancji.', + 'settings.projects.shared.actions.shareTitle': 'Zapisuje to w {path} w repozytorium, więc każdy, kto je pobierze, to otrzyma. Znika z Twoich osobistych ustawień.', + 'settings.projects.shared.actions.shareAfterSave': 'Najpierw zapisz zmiany, potem przenieś', + 'settings.projects.shared.actions.makePersonal': 'Przenieś do moich ustawień', + 'settings.projects.shared.actions.hide': 'Ukryj dla mnie', + 'settings.projects.shared.actions.show': 'Pokaż', + 'settings.projects.shared.hiddenBadge': 'Ukryte', + 'settings.projects.shared.replaceMode': 'Używaj tylko moich poleceń konfiguracji, pomiń te z repozytorium', + 'settings.projects.shared.replaceModeAria': 'Używaj tylko moich poleceń konfiguracji, pomiń te z repozytorium', + 'settings.projects.shared.toast.shareFailed': 'Nie udało się zaktualizować konfiguracji w repozytorium', 'settings.projects.actions.toast.saveFailed': 'Nie udało się zapisać akcji', 'settings.projects.actions.toast.saved': 'Akcje projektu zostały zapisane', 'settings.projects.actions.validation.fillNameAndCommand': 'Przed zapisaniem uzupełnij nazwę akcji i polecenie.', @@ -2189,7 +2219,6 @@ export const settingsDict = { 'settings.view.actions.backToSettings': 'Powrót do ustawień', 'settings.view.actions.closeSettings': 'Zamknij ustawienia', 'settings.view.actions.closeSettingsWithShortcut': 'Zamknij ustawienia ({shortcut}+,)', - 'settings.view.actions.openSectionList': 'Otwórz listę sekcji', 'settings.view.actions.reloadOpenCode': 'Przeładuj OpenCode', 'settings.view.actions.reloadOpenCodeTooltip': 'Uruchom ponownie OpenCode i przeładuj jego konfigurację.', @@ -2335,8 +2364,10 @@ export const settingsDict = { 'settings.openchamber.visual.field.inputHistoryLimitDescription': 'Zmniejszenie tej liczby od razu usuwa starsze prompty z historii.', 'settings.openchamber.visual.field.inputHistoryLimitAria': 'Liczba zapamiętywanych promptów', 'settings.openchamber.visual.field.inputHistoryLimitUnit': 'promptów', - 'settings.openchamber.visual.field.enterToSend': 'Enter wysyła', - 'settings.openchamber.visual.field.enterToSendHint': 'Po zmianie ustawienie steruje działaniem klawiszy Enter i Shift+Enter na każdej powierzchni. Do tego czasu każda powierzchnia zachowuje dotychczasowe działanie.', + 'settings.openchamber.visual.field.enterToSend': 'Skrót wysyłania', + 'settings.openchamber.visual.field.enterToSendHint': 'Wybierz skrót wysyłania dla standardowego komponentu. W rozszerzonym komponencie Enter zawsze dodaje nowy wiersz, a Ctrl/Cmd+Enter wysyła.', + 'settings.openchamber.visual.option.enterToSend.enter.label': 'Wyślij klawiszem Enter', + 'settings.openchamber.visual.option.enterToSend.modifier.label': 'Wyślij klawiszami Ctrl/Cmd+Enter', ...linearIntegrationI18n.pl, 'settings.page.integrations.title': 'Integracje', 'settings.page.integrations.description': 'Połącz GitHub i Linear, aby OpenChamber mógł pracować z Twoimi issue i pull requestami.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 648b37b6..3a376eff 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -4,11 +4,29 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { + 'commitComparison.mode': 'Commit', + 'commitComparison.select': 'Wybierz commit', + 'commitComparison.search': 'Szukaj commitów...', + 'commitComparison.loadError': 'Nie udało się wczytać commitów', + 'commitComparison.noCommits': 'Nie znaleziono commitów', + 'commitComparison.emptyDiff': 'Brak zmian w tym commicie', + 'chat.liveActivity.title': 'Aktywność', + 'chat.liveActivity.changedFile': 'Zmieniono {count} plik', + 'chat.liveActivity.changedFiles': 'Zmienione pliki: {count}', + 'chat.liveActivity.explored': 'Przeanalizowano bazę kodu', + 'chat.liveActivity.ranCommand': 'Wykonano {count} polecenie', + 'chat.liveActivity.ranCommands': 'Wykonane polecenia: {count}', + 'chat.liveActivity.researched': 'Przeszukano internet', + 'chat.liveActivity.usedSubagent': 'Użyto {count} subagenta', + 'chat.liveActivity.usedSubagents': 'Użyci subagenci: {count}', 'sessions.sidebar.projectAction.active': 'Trwa wykonywanie akcji projektu', ...settingsDict, ...linearIssuePickerI18n.pl, ...linearPanelI18n.pl, 'terminalView.actions.attachSelection': 'Dołącz zaznaczone dane wyjściowe', + 'terminalView.actions.copySelection': 'Kopiuj zaznaczone dane wyjściowe', + 'terminalView.toast.selectionCopied': 'Skopiowano dane wyjściowe', + 'terminalView.toast.copyFailed': 'Kopiowanie nie powiodło się', 'terminalView.actions.restart': 'Uruchom terminal ponownie', 'chat.message.terminalContext': '{terminal}, wiersze {start}-{end}', 'chat.message.context.codeComment': 'Komentarz do {file}, wiersze {start}-{end}', @@ -147,7 +165,6 @@ export const dict: Record = { 'mobile.sessions.showArchived': 'Pokaż zarchiwizowane ({count})', 'mobile.sessions.hideArchived': 'Ukryj zarchiwizowane', 'mobile.sessions.activeWorktreeAria': 'Aktywny worktree', - 'mobile.sessions.activeProjectAria': 'Aktywny projekt', 'mobile.sessions.startNewChat': 'Rozpocznij nowy czat', 'mobile.sessions.newChat': 'Nowy czat', 'mobile.sessions.editOrder': 'Zmień kolejność projektów', @@ -166,6 +183,7 @@ export const dict: Record = { 'mobile.sessions.deleteSessionAria': 'Usuń {title}', 'mobile.sessions.confirmDeleteSessionAria': 'Potwierdź usunięcie {title}', 'mobile.sessions.editProjectAria': 'Edytuj {label}', + 'mobile.sessions.newSessionInProjectAria': 'Nowa sesja w {label}', 'mobile.projectEdit.worktreesTitle': 'Worktree', 'mobile.projectEdit.worktreesEmpty': 'Ten projekt nie ma jeszcze worktree.', 'mobile.projectEdit.reorderHint': 'Przeciągnij, aby zmienić kolejność worktree.', @@ -706,7 +724,6 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Dołączone drzewo pracy zarchiwizowane.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': 'Dołączone drzewa pracy zarchiwizowane.', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'Zarchiwizowane drzewa pracy i usunięte zdalne gałęzie.', - 'sessions.missingDirectory.movedToProject': 'Folder tej sesji już nie istnieje. Sesja została przeniesiona do {project}.', 'sessions.sidebar.group.worktreeMissing': 'Brak folderu worktree', 'sessions.sidebar.sessionDialogs.worktree.label': 'Drzewo pracy', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'Ścieżka drzewa pracy niedostępna.', @@ -821,6 +838,9 @@ export const dict: Record = { 'chat.draftStarters.sectionCommands': 'Commands', 'chat.draftStarters.sectionSkills': 'Skills', 'chat.draftStarters.remove': 'Remove', + 'chat.draftStarters.sharedTitle': 'Przypięte w konfiguracji repozytorium; zmień to tam', + 'chat.draftStarters.share': 'Przenieś do konfiguracji repozytorium', + 'chat.draftStarters.makePersonal': 'Przenieś do moich ustawień', 'chat.scrollToBottom.aria': 'Przewiń na dół', 'chat.promptNavigator.aria': 'Nawigacja promptów', 'chat.promptNavigator.currentPrompt': 'Bieżący prompt', @@ -927,6 +947,8 @@ export const dict: Record = { 'chat.btw.toast.destroyFailed': 'Nie udało się zniszczyć sesji btw. Pozostanie na pasku bocznym.', 'chat.btw.working': 'Pracuje…', 'chat.btw.collapseAria': 'Zwiń panel btw', + 'chat.btw.draftHint': 'Zadaj pytanie', + 'chat.btw.cancelAria': 'Anuluj to pytanie BTW', 'chat.btw.expandAria': 'Rozwiń panel btw', 'chat.btw.promoteAria': 'Zachowaj jako osobną sesję', 'chat.btw.toast.promoteFailed': 'Nie udało się zachować sesji btw', @@ -973,6 +995,8 @@ export const dict: Record = { 'chat.textSelection.toast.addToNotesSummaryFailed': 'Nie można podsumować zaznaczenia, dodano wybrany tekst do notatek', 'chat.textSelection.actions.addToInput': 'Dodaj do pola wpisywania', 'chat.textSelection.actions.comment': 'Skomentuj', + 'chat.textSelection.actions.askOpenChamber': 'A tak przy okazji…', + 'chat.textSelection.title.askOpenChamber': 'Otwórz szkic BTW z zaznaczonym tekstem', 'chat.textSelection.title.commentOnSelection': 'Skomentuj zaznaczenie', 'chat.textSelection.comment.placeholder': 'Dodaj opcjonalny komentarz...', 'chat.textSelection.comment.attach': 'Załącz', @@ -991,6 +1015,8 @@ export const dict: Record = { 'chat.messageBody.actions.openPreviewAria': 'Otwórz podgląd', 'chat.messageBody.actions.openPreview': 'Otwórz podgląd', 'chat.messageBody.actions.copyAnswer': 'Kopiuj odpowiedź', + 'chat.messageBody.actions.moreActions': 'Więcej akcji', + 'chat.messageBody.toast.copied': 'Skopiowano do schowka', 'chat.messageBody.actions.savingImage': 'Zapisywanie obrazu...', 'chat.messageBody.actions.saveAsImage': 'Zapisz jako obraz', 'chat.messageBody.actions.saveAsPlan': 'Zapisz jako plan', @@ -1312,6 +1338,7 @@ export const dict: Record = { 'chat.chatInput.draftPicker.projectTitle': 'Projekt', 'chat.chatInput.draftPicker.searchProjects': 'Szukaj projektów...', 'chat.chatInput.draftPicker.searchBranches': 'Szukaj gałęzi...', + 'chat.chatInput.draftPicker.noProjectsFound': 'Nie znaleziono projektów.', 'chat.chatInput.drop.attachFiles': 'Drop files here to attach', 'chat.chatInput.drop.insertMention': 'Drop to insert as mention', 'chat.chatInput.fileFallback': 'file', @@ -1684,7 +1711,6 @@ export const dict: Record = { 'contextRail.surface.walkthrough.description': 'Przewodnik po zmianach prowadzony przez AI', 'walkthrough.scope.all': 'Wszystko niezatwierdzone', 'walkthrough.scope.group.workingTree': 'Drzewo robocze', - 'walkthrough.scope.group.committed': 'Zatwierdzone', 'walkthrough.scope.staged': 'W poczekalni', 'walkthrough.scope.working': 'Poza poczekalnią', 'walkthrough.scope.branch': 'Ta gałąź', @@ -2770,6 +2796,7 @@ export const dict: Record = { 'header.services.modelFamily.other': 'Inne', 'header.services.noRateLimits': 'Brak dostępnych limitów użycia.', 'header.services.noRateLimitsReported': 'Nie zgłoszono limitów użycia.', + 'header.services.usageRefreshFailedStale': 'Wyświetlane są wcześniej otrzymane dane użycia. Odświeżanie nie powiodło się: {error}', 'header.services.remoteUpdate.title': 'Aktualizacja zdalnej instancji', 'header.services.remoteUpdate.checking': 'Sprawdzanie aktualizacji...', 'header.services.remoteUpdate.upToDate': 'Ta instancja jest aktualna.', @@ -2944,6 +2971,13 @@ export const dict: Record = { 'projectActions.actions.addActionAria': 'Dodaj akcję', 'projectActions.actions.addNewAction': 'Dodaj nową akcję', 'projectActions.actions.autoDiscover': 'Wykryj automatycznie', + 'projectActions.menu.sharedBadge': 'repo', + 'projects.sharedTrust.title': 'Uruchomić polecenia zapisane w tym repozytorium?', + 'projects.sharedTrust.description': '{path} w tym repozytorium definiuje polecenia uruchamiane na tym komputerze. Zaufaj raz, a OpenChamber zapyta ponownie tylko wtedy, gdy się zmienią.', + 'projects.sharedTrust.setupCommands': 'Polecenia konfiguracji worktree', + 'projects.sharedTrust.actions': 'Akcje', + 'projects.sharedTrust.skip': 'Nie tym razem', + 'projects.sharedTrust.trust': 'Zaufaj i uruchom', 'projectActions.actions.autoDiscoverTooltip': 'Automatycznie wykrywa i uruchamia serwer deweloperski', 'projectActions.actions.chooseActionAria': 'Wybierz akcję projektu', 'projectActions.actions.openPreview': 'Otwórz podgląd', @@ -2989,6 +3023,9 @@ export const dict: Record = { 'rightSidebar.contextNotesTodo.notes.placeholder': 'Zapisz kontekst, przypomnienia lub linki', 'rightSidebar.contextNotesTodo.plan.defaultTitle': 'Plan', 'rightSidebar.contextNotesTodo.plans.deletePlan': 'Usuń plan', + 'rightSidebar.contextNotesTodo.plans.sharedBadge': 'W repozytorium', + 'rightSidebar.contextNotesTodo.plans.share': 'Przenieś do folderu planów w repozytorium, aby każdy, kto je pobierze, go widział', + 'rightSidebar.contextNotesTodo.plans.makePersonal': 'Przenieś do moich planów, poza repozytorium', 'rightSidebar.contextNotesTodo.plans.deletePlanWithTitle': 'Usuń plan „{title}”', 'rightSidebar.contextNotesTodo.plans.empty': 'Brak zapisanych planów.', 'rightSidebar.contextNotesTodo.plans.importFromFile': 'Importuj plan z pliku', @@ -3001,6 +3038,7 @@ export const dict: Record = { 'rightSidebar.contextNotesTodo.toast.createSessionFailed': 'Nie udało się utworzyć sesji', 'rightSidebar.contextNotesTodo.toast.updatePlanFailed': 'Nie udało się zaktualizować planu', 'rightSidebar.contextNotesTodo.toast.deletePlanFailed': 'Nie udało się usunąć planu', + 'rightSidebar.contextNotesTodo.toast.movePlanFailed': 'Nie udało się przenieść planu', 'rightSidebar.contextNotesTodo.toast.importPlanFailed': 'Nie udało się zaimportować planu', 'rightSidebar.contextNotesTodo.toast.loadNotesFailed': 'Nie udało się załadować notatek projektu', 'rightSidebar.contextNotesTodo.toast.noActiveSession': 'Nie wybrano aktywnej sesji', @@ -3416,6 +3454,7 @@ export const dict: Record = { 'terminalView.tabs.closeTabTitle': 'Close tab', 'terminalView.tabs.newTabTitle': 'New tab', 'terminalView.viewport.inputAria': 'Terminal input', + 'terminalView.viewport.scrollbarAria': 'Historia terminala', 'textarea.resizeHandleAria': 'Resize textarea', 'updateDialog.actions.copied': 'Skopiowano!', 'updateDialog.actions.copyCommand': 'Kopiuj polecenie', @@ -3638,6 +3677,26 @@ export const dict: Record = { 'chat.workStatus.action.openMr': 'Otwórz żądanie scalenia', 'chat.workStatus.action.openSubagent': 'Otwórz {name}', 'chat.workStatus.section.usage': 'Zużycie', + 'chat.workStatus.section.telemetry': 'Statystyki tury', + 'chat.workStatus.telemetry.responseSpeed': 'Odpowiedź', + 'chat.workStatus.telemetry.responseSpeedDescription': 'Jak szybko docierał końcowy tekst. Bez początkowego oczekiwania, rozumowania i wcześniejszych wywołań narzędzi. To szacunek z czasów tekstu, a nie pomiar po stronie dostawcy.', + 'chat.workStatus.telemetry.speed': 'Całe żądanie', + 'chat.workStatus.telemetry.llmDuration': 'Czas modelu', + 'chat.workStatus.telemetry.llmDurationDescription': 'Czas wszystkich kroków modelu wraz z oczekiwaniem na odpowiedzi. Czas narzędzi jest odjęty. To nie tylko czas generowania tekstu.', + 'chat.workStatus.telemetry.toolDuration': 'Narzędzia', + 'chat.workStatus.telemetry.toolDurationDescription': 'Czas wykonywania narzędzi, także nieudanych wywołań. Narzędzia działające równolegle liczymy czasowo tylko raz.', + 'chat.workStatus.telemetry.ttft': 'Średni TTFT', + 'chat.workStatus.telemetry.ttftDescription': 'Średnie oczekiwanie na pierwszy tekst lub rozumowanie w każdym kroku. Ukryte, gdy choć jeden krok nie ma czasu rozpoczęcia, co często dotyczy kroków z samymi narzędziami.', + 'chat.workStatus.telemetry.steps': 'Kroki', + 'chat.workStatus.telemetry.stepsDescription': 'Liczba wywołań modelu dla tego promptu. Odczytanie wyniku narzędzia i decyzja o dalszym działaniu zwykle wymaga kolejnego kroku.', + 'chat.workStatus.telemetry.tokens': 'Tokeny', + 'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓', + 'chat.workStatus.telemetry.speedDescription': 'Tokeny wygenerowane we wszystkich krokach, także rozumowania, podzielone przez czas bez wykonywania narzędzi. Oczekiwanie na model nadal się liczy, więc wiele krótkich wywołań obniża ten wynik.', + 'chat.workStatus.telemetry.tokensDescription': '↑ Wejście bez tokenów z pamięci podręcznej: {input}. ↓ Wygenerowane: {output} dla tekstu i wywołań narzędzi oraz {reasoning} dla rozumowania. Sumy ze wszystkich kroków tego promptu.', + 'chat.workStatus.telemetry.cacheHit': 'Pamięć podr.', + 'chat.workStatus.telemetry.cacheHitDescription': 'Udział tokenów wejściowych użytych ponownie z pamięci podręcznej promptu we wszystkich krokach. Może to zmniejszyć koszt i oczekiwanie, ale nie jest miarą szybkości.', + 'chat.workStatus.telemetry.cost': 'Koszt', + 'chat.workStatus.telemetry.costDescription': 'Koszt wszystkich kroków tego promptu zgłoszony przez dostawcę, w dolarach amerykańskich. Bez oddzielnych sesji subagentów. Zero może oznaczać darmowy model lub brak informacji o opłacie.', 'chat.workStatus.goal.open': 'Zarządzaj celem', 'chat.workStatus.goal.pause': 'Wstrzymaj', 'chat.workStatus.goal.resume': 'Wznów', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 83179622..a6edf406 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1,5 +1,7 @@ import { linearIntegrationI18n } from './linear-integration.i18n'; export const settingsDict = { + 'settings.openchamber.visual.field.alwaysShowScrollbars': 'Sempre mostrar barras de rolagem', + 'settings.openchamber.visual.field.alwaysShowScrollbarsHint': 'Mantenha as barras de rolagem visíveis mesmo quando o ponteiro estiver fora da área de rolagem. Aplica-se apenas a este dispositivo.', 'settings.providers.page.openCodeGo.title': 'Monitoramento de uso do OpenCode Go', 'settings.providers.page.openCodeGo.description': 'Conecte o painel do OpenCode Go para exibir as cotas móvel, semanal e mensal.', 'settings.providers.page.openCodeGo.workspaceId': 'ID do workspace', @@ -59,7 +61,6 @@ export const settingsDict = { "settings.view.pendingRestart.confirm.dontShowAgain": "Não mostrar novamente", "settings.view.pendingRestart.confirm.cancel": "Cancelar", "settings.view.actions.backToSettings": "Voltar às configurações", "settings.view.actions.closeSettings": "Fechar configurações", - "settings.view.actions.openSectionList": "Abrir lista de seções", "settings.view.actions.closeSettingsWithShortcut": "Fechar configurações ({shortcut}+,)", "settings.view.actions.back": "Voltar", "settings.view.actions.resizeNavigation": "Ajustar tamanho da navegação", @@ -433,6 +434,34 @@ export const settingsDict = { "settings.common.permission.deny": "Negar", "settings.common.state.comingSoon": "Em breve...", "settings.projects.actions.title": "Ações", + "settings.projects.shared.badge": "No repositório", + "settings.projects.shared.actionsFromRepo": "Guardadas no repositório ({path}). Todos que o baixarem terão estas.", + "settings.projects.shared.commandsFromRepo": "Executados primeiro, guardados no repositório ({path})", + "settings.projects.shared.invalid": "A configuração do projeto em {path} não pôde ser lida: {reason}", + "settings.projects.shared.trusted": "Comandos do repositório confiáveis nesta instância", + "settings.projects.shared.resetTrust": "Redefinir confiança", + "settings.projects.shared.title": "Configuração no repositório", + "settings.projects.shared.description": "Configuração guardada no próprio repositório, para que todos que o baixarem tenham as mesmas ações, comandos de configuração, iniciadores e planos. Nada é gravado até você mover um item para lá.", + "settings.projects.shared.file": "Arquivo", + "settings.projects.shared.status.missing": "Ainda não está no repositório", + "settings.projects.shared.status.ok": "No repositório", + "settings.projects.shared.plansDir": "Pasta de planos", + "settings.projects.shared.plansDirPlaceholder": ".openchamber/plans", + "settings.projects.shared.plansDirInfo": "Onde ficam os planos do repositório, relativo ao repositório. Vazio significa .openchamber/plans. Uma pasta própria como docs/plans substitui a padrão por completo: só essa pasta é lida e gravada. Mova você mesmo os arquivos existentes ao trocar.", + "settings.projects.shared.plansDirAria": "Pasta de planos no repositório", + "settings.projects.shared.actions.share": "Mover para o repositório", + "settings.projects.shared.actions.showTitle": "Mostra esta ação do repositório novamente no seu menu.", + "settings.projects.shared.actions.hideTitle": "Oculta esta ação do repositório apenas no seu menu; o repositório não muda.", + "settings.projects.shared.actions.makePersonalTitle": "Remove do repositório e mantém apenas nas suas configurações nesta instância.", + "settings.projects.shared.actions.shareTitle": "Guarda em {path} dentro do repositório, para que todos que o baixarem tenham. Sai das suas configurações pessoais.", + "settings.projects.shared.actions.shareAfterSave": "Salva suas edições primeiro, depois mova", + "settings.projects.shared.actions.makePersonal": "Mover para minhas configurações", + "settings.projects.shared.actions.hide": "Ocultar para mim", + "settings.projects.shared.actions.show": "Mostrar", + "settings.projects.shared.hiddenBadge": "Oculto", + "settings.projects.shared.replaceMode": "Usar apenas meus comandos de configuração e ignorar os do repositório", + "settings.projects.shared.replaceModeAria": "Usar apenas meus comandos de configuração e ignorar os do repositório", + "settings.projects.shared.toast.shareFailed": "Falha ao atualizar a configuração no repositório", "settings.projects.actions.description": "Comandos por projeto mostrados no cabeçalho junto ao nome do projeto.", "settings.projects.actions.validation.fillNameAndCommand": "Preencha o nome da ação e o comando antes de salvar.", "settings.projects.actions.state.loading": "Carregando...", @@ -985,10 +1014,10 @@ export const settingsDict = { "settings.openchamber.desktopNetwork.field.allowLanAccessDescription": "Reinicia o aplicativo para que os telefones, tablets e outros computadores em seu Wi-Fi possam abri-lo.", "settings.openchamber.desktopNetwork.field.warning": "Aviso: enquanto estiver habilitado, o aplicativo ficará acessível a qualquer pessoa na mesma rede local.", "settings.openchamber.desktopNetwork.field.passwordRequiredWarning": "O acesso LAN exige uma senha da UI do desktop. Até configurar uma, o app de desktop inicia apenas localmente.", - "settings.openchamber.desktopPassword.actions.showPassword": "Mostrar senha", - "settings.openchamber.desktopPassword.actions.hidePassword": "Ocultar senha", "settings.openchamber.desktopPassword.field.password": "Senha da UI do desktop", "settings.openchamber.desktopPassword.field.passwordPlaceholder": "Nenhuma senha obrigatória", + "settings.openchamber.desktopPassword.field.passwordSetPlaceholder": "Senha definida. Digite uma nova para substituí-la.", + "settings.openchamber.desktopPassword.actions.removePassword": "Remover senha", "settings.openchamber.desktopPassword.field.passwordDescription": "O OpenChamber pede após reiniciar e depois quando a sessão expira: em 12 horas, ou 7 dias com Confiar neste dispositivo. Deixe vazio para desativar o login.", "settings.openchamber.desktopNetwork.hint.openAfterRestart": "Depois do reinício, abra de outro dispositivo: ", "settings.openchamber.desktopNetwork.hint.openNow": "Abrir de outro dispositivo: ", @@ -2342,8 +2371,10 @@ export const settingsDict = { "settings.openchamber.visual.field.inputHistoryLimitDescription": "Reduzir esse número remove na hora os prompts mais antigos do seu histórico.", "settings.openchamber.visual.field.inputHistoryLimitAria": "Prompts para lembrar", "settings.openchamber.visual.field.inputHistoryLimitUnit": "prompts", - "settings.openchamber.visual.field.enterToSend": "Enter envia", - "settings.openchamber.visual.field.enterToSendHint": "Depois de alterada, esta opção controla Enter e Shift+Enter em todas as superfícies. Até lá, cada superfície mantém seu comportamento atual.", + "settings.openchamber.visual.field.enterToSend": "Atalho de envio", + "settings.openchamber.visual.field.enterToSendHint": "Escolha o atalho de envio para o compositor padrão. No compositor expandido, Enter sempre cria uma nova linha e Ctrl/Cmd+Enter envia.", + "settings.openchamber.visual.option.enterToSend.enter.label": "Enviar com Enter", + "settings.openchamber.visual.option.enterToSend.modifier.label": "Enviar com Ctrl/Cmd+Enter", ...linearIntegrationI18n['pt-BR'], 'settings.page.integrations.title': 'Integrações', 'settings.page.integrations.description': 'Conecte o GitHub e o Linear para que o OpenChamber possa trabalhar com suas issues e pull requests.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 2fba7ec4..9cb76c1d 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -4,11 +4,29 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { + 'commitComparison.mode': 'Commit', + 'commitComparison.select': 'Selecionar commit', + 'commitComparison.search': 'Buscar commits...', + 'commitComparison.loadError': 'Não foi possível carregar os commits', + 'commitComparison.noCommits': 'Nenhum commit encontrado', + 'commitComparison.emptyDiff': 'Nenhuma alteração neste commit', + 'chat.liveActivity.title': 'Atividade', + 'chat.liveActivity.changedFile': '{count} arquivo alterado', + 'chat.liveActivity.changedFiles': '{count} arquivos alterados', + 'chat.liveActivity.explored': 'Base de código explorada', + 'chat.liveActivity.ranCommand': '{count} comando executado', + 'chat.liveActivity.ranCommands': '{count} comandos executados', + 'chat.liveActivity.researched': 'Pesquisa na web realizada', + 'chat.liveActivity.usedSubagent': '{count} subagente utilizado', + 'chat.liveActivity.usedSubagents': '{count} subagentes utilizados', 'sessions.sidebar.projectAction.active': 'Ação do projeto em execução', ...settingsDict, ...linearIssuePickerI18n['pt-BR'], ...linearPanelI18n['pt-BR'], 'terminalView.actions.attachSelection': 'Anexar saída selecionada', + 'terminalView.actions.copySelection': 'Copiar saída selecionada', + 'terminalView.toast.selectionCopied': 'Saída copiada', + 'terminalView.toast.copyFailed': 'Falha ao copiar', 'terminalView.actions.restart': 'Reiniciar terminal', 'chat.message.terminalContext': '{terminal}, linhas {start}-{end}', 'chat.message.context.codeComment': 'Comentário em {file}, linhas {start}-{end}', @@ -146,7 +164,6 @@ export const dict: Record = { "mobile.sessions.showArchived": "Mostrar arquivadas ({count})", "mobile.sessions.hideArchived": "Ocultar arquivadas", "mobile.sessions.activeWorktreeAria": "Worktree ativa", - "mobile.sessions.activeProjectAria": "Projeto ativo", "mobile.sessions.startNewChat": "Iniciar novo chat", "mobile.sessions.newChat": "Novo chat", "mobile.sessions.editOrder": "Reordenar projetos", @@ -165,6 +182,7 @@ export const dict: Record = { "mobile.sessions.deleteSessionAria": "Excluir {title}", "mobile.sessions.confirmDeleteSessionAria": "Confirmar exclusão de {title}", "mobile.sessions.editProjectAria": "Editar {label}", + "mobile.sessions.newSessionInProjectAria": "Nova sessão em {label}", "mobile.projectEdit.worktreesTitle": "Worktrees", "mobile.projectEdit.worktreesEmpty": "Este projeto ainda não tem worktrees.", "mobile.projectEdit.reorderHint": "Arraste para reordenar os worktrees.", @@ -706,7 +724,6 @@ export const dict: Record = { "sessions.sidebar.sessionDialogs.worktree.attachedArchived": "Worktree adjunto archivado.", "sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural": "Worktrees adjuntos archivados.", "sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved": "Worktrees archivados e branches remotas excluídas.", - "sessions.missingDirectory.movedToProject": "A pasta desta sessão não existe mais. A sessão foi movida para {project}.", "sessions.sidebar.group.worktreeMissing": "A pasta do worktree está ausente", "sessions.sidebar.sessionDialogs.worktree.label": "Worktree", "sessions.sidebar.sessionDialogs.worktree.pathUnavailable": "Caminho de worktree não disponível.", @@ -1317,7 +1334,6 @@ export const dict: Record = { "contextRail.surface.walkthrough.description": "Um percurso pelas suas mudanças guiado por IA", "walkthrough.scope.all": "Tudo sem commit", "walkthrough.scope.group.workingTree": "Árvore de trabalho", - "walkthrough.scope.group.committed": "Com commit", "walkthrough.scope.staged": "No stage", "walkthrough.scope.working": "Fora do stage", "walkthrough.scope.branch": "Este branch", @@ -1890,6 +1906,9 @@ export const dict: Record = { "rightSidebar.contextNotesTodo.plans.importFromFile": "Importar plano de arquivo", "rightSidebar.contextNotesTodo.plans.empty": "Ainda não há planos salvos.", "rightSidebar.contextNotesTodo.plans.deletePlan": "Excluir plano", + "rightSidebar.contextNotesTodo.plans.sharedBadge": "No repositório", + "rightSidebar.contextNotesTodo.plans.share": "Mover para a pasta de planos do repositório, para que todos que o baixarem o vejam", + "rightSidebar.contextNotesTodo.plans.makePersonal": "Mover para meus planos, fora do repositório", "rightSidebar.contextNotesTodo.plans.deletePlanWithTitle": "Excluir plano \"{title}\"", "rightSidebar.contextNotesTodo.sendDialog.title.newSession": "Enviar a uma nova sessão", "rightSidebar.contextNotesTodo.sendDialog.title.newWorktree": "Enviar a uma nova sessão de worktree", @@ -1908,6 +1927,7 @@ export const dict: Record = { "rightSidebar.contextNotesTodo.toast.sendTodoFailed": "Não foi possível enviar a tarefa", "rightSidebar.contextNotesTodo.toast.updatePlanFailed": "Falha ao atualizar o plano", "rightSidebar.contextNotesTodo.toast.deletePlanFailed": "Não foi possível excluir o plano", + "rightSidebar.contextNotesTodo.toast.movePlanFailed": "Falha ao mover o plano", "rightSidebar.contextNotesTodo.toast.planFileEmpty": "O arquivo do plano está vazio", "rightSidebar.contextNotesTodo.toast.importPlanFailed": "Não foi possível importar o plano", "rightSidebar.contextNotesTodo.toast.planImported": "Plano importado", @@ -1939,6 +1959,7 @@ export const dict: Record = { "header.services.refreshRateLimitsAria": "Atualizar limites de taxa", "header.services.noRateLimits": "Não há limites de taxa disponíveis.", "header.services.noRateLimitsReported": "Nenhum limite de taxa foi informado.", + "header.services.usageRefreshFailedStale": "Exibindo os dados de uso recebidos anteriormente. Falha ao atualizar: {error}", "header.services.remoteUpdate.title": "Atualização da instância remota", "header.services.remoteUpdate.checking": "Procurando atualizações...", "header.services.remoteUpdate.upToDate": "Esta instância está atualizada.", @@ -2025,6 +2046,7 @@ export const dict: Record = { "terminalView.tabs.closeTabTitle": "Fechar aba", "terminalView.tabs.newTabTitle": "Nova aba", "terminalView.viewport.inputAria": "Entrada de terminal", + "terminalView.viewport.scrollbarAria": "Histórico do terminal", "directoryExplorerDialog.title": "Adicionar diretório de projeto", "directoryExplorerDialog.description": "Escolha uma pasta para adicionar como projeto.", "directoryExplorerDialog.toggle.showHidden": "Mostrar ocultos", @@ -2464,6 +2486,9 @@ export const dict: Record = { "chat.draftStarters.sectionCommands": "Commands", "chat.draftStarters.sectionSkills": "Skills", "chat.draftStarters.remove": "Remove", + "chat.draftStarters.sharedTitle": "Fixado na configuração do repositório; altere lá", + "chat.draftStarters.share": "Mover para a configuração do repositório", + "chat.draftStarters.makePersonal": "Mover para minhas configurações", "chat.scrollToBottom.aria": "Ir ao final", "chat.promptNavigator.aria": "Navegação de prompts", "chat.promptNavigator.currentPrompt": "Prompt atual", @@ -2571,9 +2596,13 @@ export const dict: Record = { 'chat.btw.toast.destroyFailed': 'Falha ao destruir a sessão btw. Ela permanecerá na barra lateral.', 'chat.btw.working': 'Trabalhando…', 'chat.btw.collapseAria': 'Recolher o painel btw', + 'chat.btw.draftHint': 'Faça sua pergunta', + 'chat.btw.cancelAria': 'Cancelar esta pergunta BTW', 'chat.btw.expandAria': 'Expandir o painel btw', 'chat.btw.promoteAria': 'Manter como sessão separada', 'chat.btw.toast.promoteFailed': 'Falha ao manter a sessão btw', + 'chat.textSelection.actions.askOpenChamber': 'A propósito…', + 'chat.textSelection.title.askOpenChamber': 'Abrir um rascunho BTW com a seleção', "chat.container.readOnlySubagentPromptBanner": "Sessões de subagente não podem receber prompts.", "chat.container.sessionLoadError.title": "Não foi possível carregar a sessão", "chat.container.sessionLoadError.description": "Não foi possível buscar a conversa — o servidor pode estar desligado ou inacessível. Nada foi perdido; tente novamente quando ele voltar.", @@ -2635,6 +2664,8 @@ export const dict: Record = { "chat.messageBody.actions.openPreviewAria": "Abrir visualização", "chat.messageBody.actions.openPreview": "Abrir visualização", "chat.messageBody.actions.copyAnswer": "Copiar resposta", + "chat.messageBody.actions.moreActions": "Mais ações", + "chat.messageBody.toast.copied": "Copiado para a área de transferência", "chat.messageBody.actions.savingImage": "Salvando imagem...", "chat.messageBody.actions.saveAsImage": "Salvar como imagem", "chat.messageBody.actions.saveAsPlan": "Salvar como plano", @@ -2734,6 +2765,7 @@ export const dict: Record = { "chat.chatInput.draftPicker.projectTitle": "Projeto", "chat.chatInput.draftPicker.searchProjects": "Buscar projetos...", "chat.chatInput.draftPicker.searchBranches": "Buscar branches...", + "chat.chatInput.draftPicker.noProjectsFound": "Nenhum projeto encontrado.", "chat.chatInput.worktrees": "Worktrees", "chat.chatInput.worktreeNew": "+ Novo", "chat.chatInput.drop.insertMention": "Solte para inserir como menção", @@ -3080,6 +3112,13 @@ export const dict: Record = { "projectActions.actions.addAction": "Adicionar ação", "projectActions.actions.addNewAction": "Adicionar nova ação", "projectActions.actions.autoDiscover": "Detectar automaticamente", + "projectActions.menu.sharedBadge": "repo", + "projects.sharedTrust.title": "Executar os comandos guardados neste repositório?", + "projects.sharedTrust.description": "{path} neste repositório define comandos que rodam nesta máquina. Confie uma vez e o OpenChamber só perguntará de novo quando eles mudarem.", + "projects.sharedTrust.setupCommands": "Comandos de configuração do worktree", + "projects.sharedTrust.actions": "Ações", + "projects.sharedTrust.skip": "Agora não", + "projects.sharedTrust.trust": "Confiar e executar", "projectActions.actions.autoDiscoverTooltip": "Detecta e executa automaticamente o servidor de desenvolvimento", "projectActions.actions.chooseActionAria": "Escolher ação do projeto", "projectActions.actions.openPreview": "Abrir Preview", @@ -3622,6 +3661,26 @@ export const dict: Record = { 'chat.workStatus.action.openMr': 'Abrir solicitação de merge', 'chat.workStatus.action.openSubagent': 'Abrir {name}', 'chat.workStatus.section.usage': 'Uso', + 'chat.workStatus.section.telemetry': 'Estatísticas do turno', + 'chat.workStatus.telemetry.responseSpeed': 'Resposta', + 'chat.workStatus.telemetry.responseSpeedDescription': 'A velocidade com que o texto final chegou. Exclui a espera inicial, o raciocínio e as chamadas anteriores de ferramentas. É uma estimativa pelos horários do texto, não uma medição do provedor.', + 'chat.workStatus.telemetry.speed': 'Solicitação', + 'chat.workStatus.telemetry.llmDuration': 'Modelo', + 'chat.workStatus.telemetry.llmDurationDescription': 'Tempo de todas as etapas do modelo, incluindo a espera pelas respostas. O tempo das ferramentas é descontado. Não é apenas o tempo de geração do texto.', + 'chat.workStatus.telemetry.toolDuration': 'Ferramentas', + 'chat.workStatus.telemetry.toolDurationDescription': 'Tempo de execução das ferramentas, incluindo chamadas que falharam. Ferramentas executadas ao mesmo tempo contam uma vez só.', + 'chat.workStatus.telemetry.ttft': 'TTFT médio', + 'chat.workStatus.telemetry.ttftDescription': 'Espera média até o primeiro texto ou raciocínio de cada etapa. Não aparece se faltar o horário de início de alguma etapa, algo comum em chamadas apenas de ferramentas.', + 'chat.workStatus.telemetry.steps': 'Etapas', + 'chat.workStatus.telemetry.stepsDescription': 'Quantas vezes o modelo foi chamado para este prompt. Ler o resultado de uma ferramenta e decidir o próximo passo geralmente exige outra chamada.', + 'chat.workStatus.telemetry.tokens': 'Tokens', + 'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓', + 'chat.workStatus.telemetry.speedDescription': 'Tokens gerados em todas as etapas, incluindo raciocínio, divididos pelo tempo sem execução de ferramentas. A espera pelo modelo conta, então muitas chamadas curtas podem reduzir este valor.', + 'chat.workStatus.telemetry.tokensDescription': '↑ Entrada sem tokens em cache: {input}. ↓ Gerados: {output} para texto e chamadas de ferramentas, mais {reasoning} de raciocínio. Totais de todas as etapas deste prompt.', + 'chat.workStatus.telemetry.cacheHit': 'Cache', + 'chat.workStatus.telemetry.cacheHitDescription': 'Parcela dos tokens de entrada reutilizados do cache do prompt em todas as etapas. Reutilizar o contexto pode reduzir custo e espera, mas não é uma medida de velocidade.', + 'chat.workStatus.telemetry.cost': 'Custo', + 'chat.workStatus.telemetry.costDescription': 'Custo informado pelo provedor para todas as etapas deste prompt, em dólares americanos. Não inclui sessões separadas de subagentes. Zero pode indicar um modelo gratuito ou um provedor que não informa a cobrança.', 'chat.workStatus.goal.open': 'Gerenciar objetivo', 'chat.workStatus.goal.pause': 'Pausar', 'chat.workStatus.goal.resume': 'Retomar', diff --git a/packages/ui/src/lib/i18n/messages/tr.settings.ts b/packages/ui/src/lib/i18n/messages/tr.settings.ts index 6c5b3a4c..afa837f9 100644 --- a/packages/ui/src/lib/i18n/messages/tr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr.settings.ts @@ -1,5 +1,7 @@ import { linearIntegrationI18n } from './linear-integration.i18n'; export const settingsDict = { + 'settings.openchamber.visual.field.alwaysShowScrollbars': 'Kaydırma çubuklarını her zaman göster', + 'settings.openchamber.visual.field.alwaysShowScrollbarsHint': 'İşaretçi kaydırılabilir alanın dışındayken bile kaydırma çubuklarını görünür tutar. Yalnızca bu cihazda geçerlidir.', 'settings.providers.page.openCodeGo.title': 'OpenCode Go kullanım takibi', 'settings.providers.page.openCodeGo.description': 'Kayan, haftalık ve aylık kotayı göstermek için OpenCode Go kontrol panelini bağlayın.', 'settings.providers.page.openCodeGo.workspaceId': 'Çalışma alanı ID\'si', @@ -58,7 +60,6 @@ export const settingsDict = { 'settings.view.pendingRestart.confirm.cancel': 'İptal', 'settings.view.actions.backToSettings': 'Ayarlar\'a geri dön', 'settings.view.actions.closeSettings': 'Ayarları kapat', - 'settings.view.actions.openSectionList': 'Bölüm listesini aç', 'settings.view.actions.closeSettingsWithShortcut': 'Ayarları kapat ({shortcut}+,)', 'settings.view.actions.back': 'Geri', 'settings.view.actions.resizeNavigation': 'Ayar gezinmesini yeniden boyutlandır', @@ -455,6 +456,34 @@ export const settingsDict = { 'settings.common.permission.deny': 'Reddet', 'settings.common.state.comingSoon': 'Yakında...', 'settings.projects.actions.title': 'Eylemler', + 'settings.projects.shared.badge': 'Depoda', + 'settings.projects.shared.actionsFromRepo': 'Depoda saklanır ({path}). Depoyu çeken herkes bunları alır.', + 'settings.projects.shared.commandsFromRepo': 'Önce çalışır, depoda saklanır ({path})', + 'settings.projects.shared.invalid': '{path} içindeki proje yapılandırması okunamadı: {reason}', + 'settings.projects.shared.trusted': 'Depo komutlarına bu örnekte güveniliyor', + 'settings.projects.shared.resetTrust': 'Güveni sıfırla', + 'settings.projects.shared.title': 'Depo yapılandırması', + 'settings.projects.shared.description': 'Deponun kendisinde saklanan kurulum; depoyu çeken herkes aynı eylemleri, kurulum komutlarını, başlatıcıları ve planları alır. Oraya bir öğe taşıyana kadar hiçbir şey yazılmaz.', + 'settings.projects.shared.file': 'Dosya', + 'settings.projects.shared.status.missing': 'Henüz depoda değil', + 'settings.projects.shared.status.ok': 'Depoda', + 'settings.projects.shared.plansDir': 'Planlar klasörü', + 'settings.projects.shared.plansDirPlaceholder': '.openchamber/plans', + 'settings.projects.shared.plansDirInfo': 'Depo planlarının bulunduğu yer (depoya göre). Boş bırakılırsa .openchamber/plans kullanılır. docs/plans gibi özel bir klasör varsayılanı tamamen değiştirir: yalnızca o klasör okunur ve yazılır. Değiştirdiğinde mevcut dosyaları kendin taşı.', + 'settings.projects.shared.plansDirAria': 'Depo planlar klasörü', + 'settings.projects.shared.actions.share': 'Depoya taşı', + 'settings.projects.shared.actions.showTitle': 'Bu depo eylemini menünde yeniden gösterir.', + 'settings.projects.shared.actions.hideTitle': 'Bu depo eylemini yalnızca senin menünden gizler; depo değişmez.', + 'settings.projects.shared.actions.makePersonalTitle': 'Depodan kaldırır ve yalnızca bu örnekteki ayarlarında tutar.', + 'settings.projects.shared.actions.shareTitle': 'Depodaki {path} içine kaydeder; depoyu çeken herkes alır. Kişisel ayarlarından çıkar.', + 'settings.projects.shared.actions.shareAfterSave': 'Önce değişikliklerin kaydedilir, sonra taşı', + 'settings.projects.shared.actions.makePersonal': 'Ayarlarıma taşı', + 'settings.projects.shared.actions.hide': 'Benim için gizle', + 'settings.projects.shared.actions.show': 'Göster', + 'settings.projects.shared.hiddenBadge': 'Gizli', + 'settings.projects.shared.replaceMode': 'Yalnızca kendi kurulum komutlarımı kullan, depodakileri atla', + 'settings.projects.shared.replaceModeAria': 'Yalnızca kendi kurulum komutlarımı kullan, depodakileri atla', + 'settings.projects.shared.toast.shareFailed': 'Depo yapılandırması güncellenemedi', 'settings.projects.actions.description': 'Üst bilgide proje adının yanında gösterilen proje bazlı komutlar.', 'settings.projects.actions.validation.fillNameAndCommand': 'Kaydetmeden önce eylem adını ve komutu doldurun.', 'settings.projects.actions.state.loading': 'Yükleniyor...', @@ -1004,10 +1033,10 @@ export const settingsDict = { 'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': 'Telefonlar, tabletler ve Wi-Fi ağınızdaki diğer bilgisayarların uygulamayı açabilmesi için uygulamayı yeniden başlatır.', 'settings.openchamber.desktopNetwork.field.warning': 'Uyarı: Etkinken uygulamaya aynı yerel ağdaki herkes erişebilir.', 'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'LAN erişimi Masaüstü UI Şifresi gerektirir. Şifre ayarlanana kadar masaüstü uygulaması yalnızca yerel olarak başlar.', - 'settings.openchamber.desktopPassword.actions.showPassword': 'Şifreyi göster', - 'settings.openchamber.desktopPassword.actions.hidePassword': 'Şifreyi gizle', 'settings.openchamber.desktopPassword.field.password': 'Masaüstü UI Şifresi', 'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'Şifre gerekmez', + 'settings.openchamber.desktopPassword.field.passwordSetPlaceholder': 'Şifre ayarlı. Değiştirmek için yeni bir şifre yazın.', + 'settings.openchamber.desktopPassword.actions.removePassword': 'Şifreyi kaldır', 'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber yeniden başlatma sonrasında sorar, ardından giriş session\'ı sona erdiğinde tekrar sorar: 12 saat sonra veya Trust this device ile 7 gün sonra. Girişi devre dışı bırakmak için boş bırakın.', 'settings.openchamber.desktopNetwork.hint.openAfterRestart': 'Yeniden başlatma sonrasında başka bir cihazdan açın: ', 'settings.openchamber.desktopNetwork.hint.openNow': 'Başka bir cihazdan açın: ', @@ -1951,7 +1980,7 @@ export const settingsDict = { 'settings.openchamber.visual.field.inputBarOffset': 'Giriş Çubuğu Ofseti', 'settings.openchamber.visual.field.inputBarOffsetTooltip': 'Ana ekran çubuğu gibi işletim sistemi düzeyindeki ekran engellerinden kaçınmak için giriş çubuğunu yukarı kaldırır.', 'settings.openchamber.visual.field.inputHistoryScope': 'Girdi geçmişi kapsamı', - 'settings.openchamber.visual.field.inputHistoryScopeDescription': 'Gönderilen istemlerin bu çalışma zamanına bağlı tüm projelerde mi yoksa yalnızca geçerli oturumda mı geri çağrılacağını seçin.', + 'settings.openchamber.visual.field.inputHistoryScopeDescription': 'Gönderilen promptların bu çalışma zamanına bağlı tüm projelerde mi yoksa yalnızca geçerli oturumda mı geri çağrılacağını seçin.', 'settings.openchamber.visual.section.inputHistoryScopeAria': 'Girdi geçmişi kapsamı', 'settings.openchamber.visual.option.inputHistoryScope.global.label': 'Tüm projeler', 'settings.openchamber.visual.option.inputHistoryScope.session.label': 'Geçerli oturum', @@ -1959,8 +1988,10 @@ export const settingsDict = { 'settings.openchamber.visual.field.inputHistoryLimitDescription': 'Bu sayıyı azaltmak, eski prompt\'ları geçmişinizden hemen siler.', 'settings.openchamber.visual.field.inputHistoryLimitAria': 'Hatırlanacak prompt sayısı', 'settings.openchamber.visual.field.inputHistoryLimitUnit': 'prompt', - 'settings.openchamber.visual.field.enterToSend': 'Enter gönderir', - 'settings.openchamber.visual.field.enterToSendHint': 'Değiştirildikten sonra Enter ve Shift+Enter davranışını tüm yüzeylerde kontrol eder. O zamana kadar her yüzey mevcut davranışını korur.', + 'settings.openchamber.visual.field.enterToSend': 'Gönderme kısayolu', + 'settings.openchamber.visual.field.enterToSendHint': 'Standart oluşturucu için gönderme kısayolunu seçin. Genişletilmiş oluşturucuda Enter her zaman yeni satır ekler, Ctrl/Cmd+Enter gönderir.', + 'settings.openchamber.visual.option.enterToSend.enter.label': 'Enter ile gönder', + 'settings.openchamber.visual.option.enterToSend.modifier.label': 'Ctrl/Cmd+Enter ile gönder', 'settings.openchamber.visual.actions.resetInputBarOffsetAria': 'Giriş çubuğu ofsetini sıfırla', 'settings.openchamber.visual.field.terminalQuickKeysAria': 'Terminal hızlı tuşları', 'settings.openchamber.visual.field.terminalQuickKeys': 'Terminal Hızlı Tuşları', diff --git a/packages/ui/src/lib/i18n/messages/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts index b771a45f..f3601b11 100644 --- a/packages/ui/src/lib/i18n/messages/tr.ts +++ b/packages/ui/src/lib/i18n/messages/tr.ts @@ -3,11 +3,29 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; import { linearPanelI18n } from './linear-panel.i18n'; export const dict = { + 'commitComparison.mode': 'Commit', + 'commitComparison.select': 'Commit seç', + 'commitComparison.search': 'Commit ara...', + 'commitComparison.loadError': 'Commitler yüklenemedi', + 'commitComparison.noCommits': 'Commit bulunamadı', + 'commitComparison.emptyDiff': 'Bu committe değişiklik yok', + 'chat.liveActivity.title': 'Etkinlik', + 'chat.liveActivity.changedFile': '{count} dosya değiştirildi', + 'chat.liveActivity.changedFiles': '{count} dosya değiştirildi', + 'chat.liveActivity.explored': 'Kod tabanı incelendi', + 'chat.liveActivity.ranCommand': '{count} komut çalıştırıldı', + 'chat.liveActivity.ranCommands': '{count} komut çalıştırıldı', + 'chat.liveActivity.researched': 'Web araştırması yapıldı', + 'chat.liveActivity.usedSubagent': '{count} alt agent kullanıldı', + 'chat.liveActivity.usedSubagents': '{count} alt agent kullanıldı', 'sessions.sidebar.projectAction.active': 'Proje eylemi çalışıyor', ...settingsDict, ...linearIssuePickerI18n.tr, ...linearPanelI18n.tr, 'terminalView.actions.attachSelection': 'Seçili çıktıyı ekle', + 'terminalView.actions.copySelection': 'Seçili çıktıyı kopyala', + 'terminalView.toast.selectionCopied': 'Çıktı kopyalandı', + 'terminalView.toast.copyFailed': 'Kopyalama başarısız', 'terminalView.actions.restart': 'Terminali yeniden başlat', 'chat.message.terminalContext': '{terminal}, {start}-{end}. satırlar', 'chat.chatInput.terminalContext': '{terminal}, {start}-{end}. satırlar', @@ -132,13 +150,13 @@ export const dict = { 'mobile.sessions.showArchived': 'Arşivlenenleri göster ({count})', 'mobile.sessions.hideArchived': 'Arşivlenenleri gizle', 'mobile.sessions.activeWorktreeAria': 'Etkin worktree', - 'mobile.sessions.activeProjectAria': 'Etkin proje', 'mobile.sessions.startNewChat': 'Yeni sohbet başlat', 'mobile.sessions.newChat': 'Yeni sohbet', 'mobile.sessions.editOrder': 'Projeleri yeniden sırala', 'mobile.sessions.doneEditing': 'Tamam', 'mobile.sessions.editOrderHint': 'Projeleri yeniden sıralamak için tutamacı sürükleyin. Worktree\'lerini görmek için bir projeye dokunun ve onları da sürükleyin. Bitirmek için onay işaretine dokunun.', 'mobile.sessions.editProjectAria': '{label} öğesini düzenle', + 'mobile.sessions.newSessionInProjectAria': '{label} içinde yeni session', 'mobile.sessions.dragHandleAria': '{label} öğesini yeniden sıralamak için sürükleyin', 'mobile.sessions.moveUpAria': '{label} öğesini yukarı taşı', 'mobile.sessions.moveDownAria': '{label} öğesini aşağı taşı', @@ -687,7 +705,6 @@ export const dict = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Bağlı worktree arşivlendi.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': 'Bağlı worktree\'ler arşivlendi.', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'Worktree\'ler arşivlendi ve uzak branch\'ler kaldırıldı.', - 'sessions.missingDirectory.movedToProject': 'Bu oturumun klasörü artık mevcut değil. Oturum {project} konumuna taşındı.', 'sessions.sidebar.group.worktreeMissing': 'Worktree klasörü eksik', 'sessions.sidebar.sessionDialogs.worktree.label': 'Worktree', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'Worktree yolu kullanılamıyor.', @@ -1185,7 +1202,6 @@ export const dict = { 'contextRail.surface.walkthrough.description': 'Değişikliklerinin AI rehberliğindeki inceleme turu', 'walkthrough.scope.all': 'Commit edilmemiş tüm değişiklikler', 'walkthrough.scope.group.workingTree': 'Working tree', - 'walkthrough.scope.group.committed': 'Commit edilmiş', 'walkthrough.scope.staged': 'Stage\'lenmiş', 'walkthrough.scope.working': 'Stage\'lenmemiş', 'walkthrough.scope.branch': 'Bu branch', @@ -1675,6 +1691,9 @@ export const dict = { 'rightSidebar.contextNotesTodo.plans.importFromFile': 'Planı dosyadan içe aktar', 'rightSidebar.contextNotesTodo.plans.empty': 'Henüz kaydedilmiş plan yok.', 'rightSidebar.contextNotesTodo.plans.deletePlan': 'Planı sil', + 'rightSidebar.contextNotesTodo.plans.sharedBadge': 'Depoda', + 'rightSidebar.contextNotesTodo.plans.share': 'Depo planlar klasörüne taşı; depoyu çeken herkes görür', + 'rightSidebar.contextNotesTodo.plans.makePersonal': 'Depodan çıkarıp planlarıma taşı', 'rightSidebar.contextNotesTodo.plans.deletePlanWithTitle': 'Planı sil: "{title}"', 'rightSidebar.contextNotesTodo.sendDialog.title.newSession': 'Yeni session\'a gönder', 'rightSidebar.contextNotesTodo.sendDialog.title.newWorktree': 'Yeni worktree\'ye gönder', @@ -1693,6 +1712,7 @@ export const dict = { 'rightSidebar.contextNotesTodo.toast.sendTodoFailed': 'Yapılacak gönderilemedi', 'rightSidebar.contextNotesTodo.toast.updatePlanFailed': 'Plan güncellenemedi', 'rightSidebar.contextNotesTodo.toast.deletePlanFailed': 'Plan silinemedi', + 'rightSidebar.contextNotesTodo.toast.movePlanFailed': 'Plan taşınamadı', 'rightSidebar.contextNotesTodo.toast.planFileEmpty': 'Plan dosyası boş', 'rightSidebar.contextNotesTodo.toast.importPlanFailed': 'Plan içe aktarılamadı', 'rightSidebar.contextNotesTodo.toast.planImported': 'Plan içe aktarıldı', @@ -1724,6 +1744,7 @@ export const dict = { 'header.services.refreshRateLimitsAria': 'Rate limit\'leri yenile', 'header.services.noRateLimits': 'Kullanılabilir rate limit yok.', 'header.services.noRateLimitsReported': 'Bildirilen rate limit yok.', + 'header.services.usageRefreshFailedStale': 'Daha önce alınan kullanım verileri gösteriliyor. Yenileme başarısız: {error}', 'header.services.remoteUpdate.title': 'Uzak instance güncellemesi', 'header.services.remoteUpdate.checking': 'Güncellemeler aranıyor...', 'header.services.remoteUpdate.upToDate': 'Bu instance güncel.', @@ -1810,6 +1831,7 @@ export const dict = { 'terminalView.tabs.closeTabTitle': 'Sekmeyi kapat', 'terminalView.tabs.newTabTitle': 'Yeni sekme', 'terminalView.viewport.inputAria': 'Terminal girişi', + 'terminalView.viewport.scrollbarAria': 'Terminal geçmişi', 'directoryExplorerDialog.title': 'Proje dizini ekle', 'directoryExplorerDialog.description': 'Proje olarak eklemek için bir klasör seçin.', 'directoryExplorerDialog.toggle.showHidden': 'Gizli dosyaları göster', @@ -2081,6 +2103,9 @@ export const dict = { 'chat.draftStarters.sectionCommands': 'Komutlar', 'chat.draftStarters.sectionSkills': 'Skill\'ler', 'chat.draftStarters.remove': 'Kaldır', + 'chat.draftStarters.sharedTitle': 'Depo yapılandırmasında sabitlendi; orada değiştirin', + 'chat.draftStarters.share': 'Depo yapılandırmasına taşı', + 'chat.draftStarters.makePersonal': 'Ayarlarıma taşı', 'chat.scrollToBottom.aria': 'En alta kaydır', 'chat.promptNavigator.aria': 'Prompt gezinmesi', 'chat.promptNavigator.currentPrompt': 'Mevcut prompt', @@ -2233,6 +2258,8 @@ export const dict = { 'chat.messageBody.actions.openPreviewAria': 'Önizlemeyi aç', 'chat.messageBody.actions.openPreview': 'Önizlemeyi aç', 'chat.messageBody.actions.copyAnswer': 'Yanıtı kopyala', + 'chat.messageBody.actions.moreActions': 'Diğer işlemler', + 'chat.messageBody.toast.copied': 'Panoya kopyalandı', 'chat.messageBody.actions.savingImage': 'Görsel kaydediliyor...', 'chat.messageBody.actions.saveAsImage': 'Görsel olarak kaydet', 'chat.messageBody.actions.saveAsPlan': 'Plan olarak kaydet', @@ -2338,6 +2365,7 @@ export const dict = { 'chat.chatInput.draftPicker.projectTitle': 'Proje', 'chat.chatInput.draftPicker.searchProjects': 'Projelerde ara...', 'chat.chatInput.draftPicker.searchBranches': 'Branch\'lerde ara...', + 'chat.chatInput.draftPicker.noProjectsFound': 'Proje bulunamadı.', 'chat.chatInput.worktrees': 'Worktree\'ler', 'chat.chatInput.worktreeNew': '+ Yeni', 'chat.chatInput.drop.insertMention': 'Mention olarak eklemek için bırak', @@ -2674,6 +2702,13 @@ export const dict = { 'projectActions.actions.addAction': 'Eylem ekle', 'projectActions.actions.addNewAction': 'Yeni eylem ekle', 'projectActions.actions.autoDiscover': 'Otomatik keşfet', + 'projectActions.menu.sharedBadge': 'depo', + 'projects.sharedTrust.title': 'Bu depoda saklanan komutlar çalıştırılsın mı?', + 'projects.sharedTrust.description': 'Bu depodaki {path}, bu makinede çalışan komutlar tanımlıyor. Bir kez güvenin; OpenChamber yalnızca değiştiklerinde yeniden sorar.', + 'projects.sharedTrust.setupCommands': 'Worktree kurulum komutları', + 'projects.sharedTrust.actions': 'Eylemler', + 'projects.sharedTrust.skip': 'Bu sefer değil', + 'projects.sharedTrust.trust': 'Güven ve çalıştır', 'projectActions.actions.autoDiscoverTooltip': 'Geliştirme sunucusunu otomatik keşfeder ve çalıştırır', 'projectActions.actions.chooseActionAria': 'Proje eylemini seç', 'projectActions.actions.openPreview': 'Önizlemeyi Aç', @@ -3164,6 +3199,26 @@ export const dict = { 'chat.workStatus.action.openPr': 'Pull request\'i aç', 'chat.workStatus.action.openSubagent': '{name} öğesini aç', 'chat.workStatus.section.usage': 'Kullanım', + 'chat.workStatus.section.telemetry': 'Tur istatistikleri', + 'chat.workStatus.telemetry.responseSpeed': 'Yanıt', + 'chat.workStatus.telemetry.responseSpeedDescription': 'Son metnin ne hızla geldiği. İlk bekleme, akıl yürütme ve önceki araç çağrıları dahil değildir. Metin zamanlarından hesaplanan bir tahmindir, sağlayıcı tarafındaki hız ölçümü değildir.', + 'chat.workStatus.telemetry.speed': 'Tüm istek', + 'chat.workStatus.telemetry.llmDuration': 'Model süresi', + 'chat.workStatus.telemetry.llmDurationDescription': 'Yanıt bekleme dahil tüm model adımlarının süresi. Araç çalışma süresi çıkarılır. Yalnızca metin üretme süresi değildir.', + 'chat.workStatus.telemetry.toolDuration': 'Araç süresi', + 'chat.workStatus.telemetry.toolDurationDescription': 'Başarısız çağrılar dahil araçların çalışma süresi. Aynı anda çalışan araçların süreleri bir kez sayılır.', + 'chat.workStatus.telemetry.ttft': 'Ortalama TTFT', + 'chat.workStatus.telemetry.ttftDescription': 'Her model adımında ilk metin veya akıl yürütme başlayana kadar ortalama bekleme. Bir adımın başlangıç zamanı yoksa gösterilmez; yalnızca araç çağıran adımlarda bu sık görülür.', + 'chat.workStatus.telemetry.steps': 'Adımlar', + 'chat.workStatus.telemetry.stepsDescription': 'Bu prompt için modelin kaç kez çağrıldığı. Araç sonucunu okuyup sıradaki işi belirlemek genellikle yeni bir adım gerektirir.', + 'chat.workStatus.telemetry.tokens': 'Tokenlar', + 'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓', + 'chat.workStatus.telemetry.speedDescription': 'Akıl yürütme dahil tüm adımlarda üretilen tokenların, araç çalışması çıkarılmış süreye bölümü. Modeli bekleme süresi sayılır; çok sayıda kısa çağrı bu değeri düşürebilir.', + 'chat.workStatus.telemetry.tokensDescription': '↑ Önbellek hariç girdi: {input}. ↓ Üretilen tokenlar: metin ve araç çağrıları için {output}, akıl yürütme için {reasoning}. Bu promptun tüm adımlarının toplamıdır.', + 'chat.workStatus.telemetry.cacheHit': 'Önbellek', + 'chat.workStatus.telemetry.cacheHitDescription': 'Tüm adımlarda prompt önbelleğinden yeniden kullanılan girdi tokenlarının oranı. Bağlamı yeniden kullanmak maliyeti ve beklemeyi azaltabilir, ancak bu bir hız puanı değildir.', + 'chat.workStatus.telemetry.cost': 'Maliyet', + 'chat.workStatus.telemetry.costDescription': 'Sağlayıcının bu promptun tüm model adımları için bildirdiği ABD doları tutarı. Ayrı alt agent oturumları dahil değildir. Sıfır, ücretsiz model veya ücret bildirmeyen sağlayıcı anlamına gelebilir.', 'chat.workStatus.goal.open': 'Hedefi yönet', 'chat.workStatus.goal.pause': 'Duraklat', 'chat.workStatus.goal.resume': 'Devam et', @@ -3242,6 +3297,8 @@ export const dict = { 'chat.btw.toast.destroyFailed': 'btw session yok edilemedi. Kenar çubuğunda kalacak.', 'chat.btw.working': 'Çalışıyor…', 'chat.btw.collapseAria': 'btw panelini daralt', + 'chat.btw.draftHint': 'Sorunuzu sorun', + 'chat.btw.cancelAria': 'Bu BTW sorusunu iptal et', 'chat.btw.expandAria': 'btw panelini genişlet', 'chat.btw.promoteAria': 'Ayrı bir session olarak sakla', 'chat.btw.toast.promoteFailed': 'btw session saklanamadı', @@ -3272,6 +3329,8 @@ export const dict = { 'chat.container.sessionLoadError.authDescription': 'Session\'ınızın süresi doldu, bu yüzden sunucu isteği reddetti. Oturum açın, sohbet yüklenecek.', 'chat.textSelection.actions.addToInput': 'Girdiye ekle', 'chat.textSelection.actions.comment': 'Yorum yap', + 'chat.textSelection.actions.askOpenChamber': 'Bu arada…', + 'chat.textSelection.title.askOpenChamber': 'Seçili metinle BTW taslağı aç', 'chat.textSelection.title.commentOnSelection': 'Seçime yorum yap', 'chat.textSelection.comment.placeholder': 'İsteğe bağlı bir yorum ekleyin...', 'chat.textSelection.comment.attach': 'Ekle', diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index f4336d12..4d54d484 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1,5 +1,7 @@ import { linearIntegrationI18n } from './linear-integration.i18n'; export const settingsDict = { + 'settings.openchamber.visual.field.alwaysShowScrollbars': 'Завжди показувати смуги прокручування', + 'settings.openchamber.visual.field.alwaysShowScrollbarsHint': 'Залишати смуги прокручування видимими, навіть коли курсор поза областю прокручування. Лише на цьому пристрої.', 'settings.providers.page.openCodeGo.title': 'Відстеження використання OpenCode Go', 'settings.providers.page.openCodeGo.description': 'Підключіть панель OpenCode Go, щоб бачити ковзну, тижневу та місячну квоту.', 'settings.providers.page.openCodeGo.workspaceId': 'ID робочого простору', @@ -59,7 +61,6 @@ export const settingsDict = { "settings.view.pendingRestart.confirm.dontShowAgain": "Більше не показувати", "settings.view.pendingRestart.confirm.cancel": "Скасувати", "settings.view.actions.backToSettings": "Назад до налаштувань", "settings.view.actions.closeSettings": "Закрити налаштування", - "settings.view.actions.openSectionList": "Відкрити список розділів", "settings.view.actions.closeSettingsWithShortcut": "Закрити налаштування ({shortcut}+,)", "settings.view.actions.back": "Назад", "settings.view.actions.resizeNavigation": "Змінити розмір навігації налаштувань", @@ -433,6 +434,34 @@ export const settingsDict = { "settings.common.permission.deny": "Заборонити", "settings.common.state.comingSoon": "Незабаром...", "settings.projects.actions.title": "Дії", + "settings.projects.shared.badge": "У репозиторії", + "settings.projects.shared.actionsFromRepo": "Зберігаються в репозиторії ({path}). Їх отримує кожен, хто його клонує.", + "settings.projects.shared.commandsFromRepo": "Виконуються першими, зберігаються в репозиторії ({path})", + "settings.projects.shared.invalid": "Не вдалося прочитати конфіг проєкту в {path}: {reason}", + "settings.projects.shared.trusted": "Командам із репозиторію довірено на цьому інстансі", + "settings.projects.shared.resetTrust": "Скинути довіру", + "settings.projects.shared.title": "Конфіг у репозиторії", + "settings.projects.shared.description": "Налаштування, що лежать у самому репозиторії, тож кожен, хто його клонує, отримує ті самі дії, команди сетапу, стартери й плани. Туди нічого не записується, поки ви не перенесете елемент.", + "settings.projects.shared.file": "Файл", + "settings.projects.shared.status.missing": "Ще немає в репозиторії", + "settings.projects.shared.status.ok": "У репозиторії", + "settings.projects.shared.plansDir": "Тека планів", + "settings.projects.shared.plansDirPlaceholder": ".openchamber/plans", + "settings.projects.shared.plansDirInfo": "Де лежать плани репозиторію, відносно репозиторію. Порожнє означає .openchamber/plans. Своя тека, наприклад docs/plans, повністю замінює типову: читається й пишеться лише вона. Наявні файли при зміні перенесіть самі.", + "settings.projects.shared.plansDirAria": "Тека планів у репозиторії", + "settings.projects.shared.actions.share": "Перенести в репозиторій", + "settings.projects.shared.actions.showTitle": "Знову показує цю дію з репозиторію у вашому меню.", + "settings.projects.shared.actions.hideTitle": "Ховає цю дію з репозиторію лише у вашому меню; репозиторій не змінюється.", + "settings.projects.shared.actions.makePersonalTitle": "Прибирає з репозиторію і лишає лише у ваших налаштуваннях на цьому інстансі.", + "settings.projects.shared.actions.shareTitle": "Зберігає це в {path} у репозиторії, тож кожен, хто його клонує, це отримає. З ваших особистих налаштувань воно зникає.", + "settings.projects.shared.actions.shareAfterSave": "Спочатку збережуться ваші правки, потім перенести", + "settings.projects.shared.actions.makePersonal": "Перенести в мої налаштування", + "settings.projects.shared.actions.hide": "Сховати для мене", + "settings.projects.shared.actions.show": "Показати", + "settings.projects.shared.hiddenBadge": "Сховано", + "settings.projects.shared.replaceMode": "Використовувати лише мої команди налаштування, пропустити ті, що з репозиторію", + "settings.projects.shared.replaceModeAria": "Використовувати лише мої команди налаштування, пропустити ті, що з репозиторію", + "settings.projects.shared.toast.shareFailed": "Не вдалося оновити конфіг у репозиторії", "settings.projects.actions.description": "Команди для кожного проєкту відображаються в заголовку біля назви проєкту.", "settings.projects.actions.validation.fillNameAndCommand": "Введіть назву дії та команду перед збереженням.", "settings.projects.actions.state.loading": "Завантаження...", @@ -985,10 +1014,10 @@ export const settingsDict = { "settings.openchamber.desktopNetwork.field.allowLanAccessDescription": "Перезапускає застосунок, щоб телефони, планшети та інші комп’ютери в мережі Wi-Fi могли його відкрити.", "settings.openchamber.desktopNetwork.field.warning": "Попередження: якщо це ввімкнено, застосунок доступний усім у тій самій локальній мережі.", "settings.openchamber.desktopNetwork.field.passwordRequiredWarning": "Для LAN-доступу потрібен пароль десктопного UI. Доки його не задано, десктопний застосунок запускається лише локально.", - "settings.openchamber.desktopPassword.actions.showPassword": "Показати пароль", - "settings.openchamber.desktopPassword.actions.hidePassword": "Приховати пароль", "settings.openchamber.desktopPassword.field.password": "Пароль для десктопного UI", "settings.openchamber.desktopPassword.field.passwordPlaceholder": "Пароль не потрібен", + "settings.openchamber.desktopPassword.field.passwordSetPlaceholder": "Пароль встановлено. Введіть новий, щоб замінити.", + "settings.openchamber.desktopPassword.actions.removePassword": "Видалити пароль", "settings.openchamber.desktopPassword.field.passwordDescription": "OpenChamber попросить пароль після перезапуску, а потім коли сесія логіну спливе: через 12 годин або через 7 днів із «Довіряти цьому пристрою». Залиште порожнім, щоб вимкнути логін.", "settings.openchamber.desktopNetwork.hint.openAfterRestart": "Після перезавантаження відкрити з іншого пристрою: ", "settings.openchamber.desktopNetwork.hint.openNow": "Відкрити з іншого пристрою: ", @@ -2342,8 +2371,10 @@ export const settingsDict = { "settings.openchamber.visual.field.inputHistoryLimitDescription": "Якщо зменшити це число, старіші промпти одразу буде видалено з історії.", "settings.openchamber.visual.field.inputHistoryLimitAria": "Скільки промптів пам’ятати", "settings.openchamber.visual.field.inputHistoryLimitUnit": "промптів", - "settings.openchamber.visual.field.enterToSend": "Enter надсилає", - "settings.openchamber.visual.field.enterToSendHint": "Після зміни цей параметр керує поведінкою Enter і Shift+Enter на всіх поверхнях. До цього кожна поверхня зберігає свою поточну поведінку.", + "settings.openchamber.visual.field.enterToSend": "Комбінація для надсилання", + "settings.openchamber.visual.field.enterToSendHint": "Виберіть комбінацію для надсилання у стандартному композері. У розгорнутому композері Enter завжди додає новий рядок, а Ctrl/Cmd+Enter надсилає.", + "settings.openchamber.visual.option.enterToSend.enter.label": "Надсилати клавішею Enter", + "settings.openchamber.visual.option.enterToSend.modifier.label": "Надсилати за допомогою Ctrl/Cmd+Enter", ...linearIntegrationI18n.uk, 'settings.page.integrations.title': 'Інтеграції', 'settings.page.integrations.description': 'Підключіть GitHub і Linear, щоб OpenChamber міг працювати з вашими задачами та pull request-ами.', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index fbffd53c..c79dc2a7 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -4,11 +4,29 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { + 'commitComparison.mode': 'Коміт', + 'commitComparison.select': 'Вибрати коміт', + 'commitComparison.search': 'Пошук комітів...', + 'commitComparison.loadError': 'Не вдалося завантажити коміти', + 'commitComparison.noCommits': 'Комітів не знайдено', + 'commitComparison.emptyDiff': 'У цьому коміті немає змін', + 'chat.liveActivity.title': 'Дії', + 'chat.liveActivity.changedFile': 'Змінено {count} файл', + 'chat.liveActivity.changedFiles': 'Змінено файлів: {count}', + 'chat.liveActivity.explored': 'Досліджено кодову базу', + 'chat.liveActivity.ranCommand': 'Виконано {count} команду', + 'chat.liveActivity.ranCommands': 'Виконано команд: {count}', + 'chat.liveActivity.researched': 'Проведено пошук в інтернеті', + 'chat.liveActivity.usedSubagent': 'Залучено {count} сабагента', + 'chat.liveActivity.usedSubagents': 'Залучено сабагентів: {count}', 'sessions.sidebar.projectAction.active': 'Виконується дія проєкту', ...settingsDict, ...linearIssuePickerI18n.uk, ...linearPanelI18n.uk, 'terminalView.actions.attachSelection': 'Прикріпити вибраний вивід', + 'terminalView.actions.copySelection': 'Скопіювати вибраний вивід', + 'terminalView.toast.selectionCopied': 'Вивід скопійовано', + 'terminalView.toast.copyFailed': 'Не вдалося скопіювати', 'terminalView.actions.restart': 'Перезапустити термінал', 'chat.message.terminalContext': '{terminal}, рядки {start}-{end}', 'chat.message.context.codeComment': 'Коментар до {file}, рядки {start}-{end}', @@ -146,7 +164,6 @@ export const dict: Record = { "mobile.sessions.showArchived": "Показати архівовані ({count})", "mobile.sessions.hideArchived": "Сховати архівовані", "mobile.sessions.activeWorktreeAria": "Активний worktree", - "mobile.sessions.activeProjectAria": "Активний проєкт", "mobile.sessions.startNewChat": "Почати новий чат", "mobile.sessions.newChat": "Новий чат", "mobile.sessions.editOrder": "Змінити порядок проєктів", @@ -165,6 +182,7 @@ export const dict: Record = { "mobile.sessions.deleteSessionAria": "Видалити {title}", "mobile.sessions.confirmDeleteSessionAria": "Підтвердити видалення {title}", "mobile.sessions.editProjectAria": "Редагувати {label}", + "mobile.sessions.newSessionInProjectAria": "Нова сесія в {label}", "mobile.projectEdit.worktreesTitle": "Ворктрі", "mobile.projectEdit.worktreesEmpty": "У цьому проєкті ще немає ворктрі.", "mobile.projectEdit.reorderHint": "Перетягніть, щоб змінити порядок ворктрі.", @@ -706,7 +724,6 @@ export const dict: Record = { "sessions.sidebar.sessionDialogs.worktree.attachedArchived": "Прикріплене worktree заархівовано.", "sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural": "Прикріплені worktree заархівовано.", "sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved": "Worktree заархівовано, віддалені гілки видалено.", - "sessions.missingDirectory.movedToProject": "Теки цієї сесії більше не існує. Сесію перенесено до {project}.", "sessions.sidebar.group.worktreeMissing": "Теки worktree немає", "sessions.sidebar.sessionDialogs.worktree.label": "Worktree", "sessions.sidebar.sessionDialogs.worktree.pathUnavailable": "Шлях worktree недоступний.", @@ -1317,7 +1334,6 @@ export const dict: Record = { "contextRail.surface.walkthrough.description": "Покроковий розбір ваших змін за допомогою AI", "walkthrough.scope.all": "Усе незакомічене", "walkthrough.scope.group.workingTree": "Робоче дерево", - "walkthrough.scope.group.committed": "Закомічене", "walkthrough.scope.staged": "В індексі", "walkthrough.scope.working": "Поза індексом", "walkthrough.scope.branch": "Ця гілка", @@ -1890,6 +1906,9 @@ export const dict: Record = { "rightSidebar.contextNotesTodo.plans.importFromFile": "Імпортувати план із файлу", "rightSidebar.contextNotesTodo.plans.empty": "Ще немає збережених планів.", "rightSidebar.contextNotesTodo.plans.deletePlan": "Видалити план", + "rightSidebar.contextNotesTodo.plans.sharedBadge": "У репозиторії", + "rightSidebar.contextNotesTodo.plans.share": "Перенести в теку планів репозиторію, щоб його бачив кожен, хто клонує репозиторій", + "rightSidebar.contextNotesTodo.plans.makePersonal": "Перенести в мої плани, з репозиторію", "rightSidebar.contextNotesTodo.plans.deletePlanWithTitle": "Видалити план \"{title}\"", "rightSidebar.contextNotesTodo.sendDialog.title.newSession": "Надіслати до нової сесії", "rightSidebar.contextNotesTodo.sendDialog.title.newWorktree": "Надіслати до нової сесії в worktree", @@ -1908,6 +1927,7 @@ export const dict: Record = { "rightSidebar.contextNotesTodo.toast.sendTodoFailed": "Не вдалося надіслати завдання", "rightSidebar.contextNotesTodo.toast.updatePlanFailed": "Не вдалося оновити план", "rightSidebar.contextNotesTodo.toast.deletePlanFailed": "Не вдалося видалити план", + "rightSidebar.contextNotesTodo.toast.movePlanFailed": "Не вдалося перемістити план", "rightSidebar.contextNotesTodo.toast.planFileEmpty": "Файл плану порожній", "rightSidebar.contextNotesTodo.toast.importPlanFailed": "Не вдалося імпортувати план", "rightSidebar.contextNotesTodo.toast.planImported": "План імпортовано", @@ -1939,6 +1959,7 @@ export const dict: Record = { "header.services.refreshRateLimitsAria": "Оновити ліміти запитів", "header.services.noRateLimits": "Ліміти запитів недоступні.", "header.services.noRateLimitsReported": "Ліміти запитів не надходять.", + "header.services.usageRefreshFailedStale": "Показано раніше отримані дані використання. Не вдалося оновити: {error}", "header.services.remoteUpdate.title": "Оновлення віддаленого інстанса", "header.services.remoteUpdate.checking": "Шукаємо оновлення...", "header.services.remoteUpdate.upToDate": "Цей інстанс уже оновлений.", @@ -2025,6 +2046,7 @@ export const dict: Record = { "terminalView.tabs.closeTabTitle": "Закрити вкладку", "terminalView.tabs.newTabTitle": "Нова вкладка", "terminalView.viewport.inputAria": "Ввід терміналу", + "terminalView.viewport.scrollbarAria": "Історія термінала", "directoryExplorerDialog.title": "Додати каталог проєкту", "directoryExplorerDialog.description": "Виберіть папку, щоб додати її як проєкт.", "directoryExplorerDialog.toggle.showHidden": "Показати приховані", @@ -2464,6 +2486,9 @@ export const dict: Record = { "chat.draftStarters.sectionCommands": "Команди", "chat.draftStarters.sectionSkills": "Скіли", "chat.draftStarters.remove": "Прибрати", + "chat.draftStarters.sharedTitle": "Закріплено в конфігу репозиторію; змінюйте там", + "chat.draftStarters.share": "Перенести в конфіг репозиторію", + "chat.draftStarters.makePersonal": "Перенести в мої налаштування", "chat.scrollToBottom.aria": "Прокрутити вниз", "chat.promptNavigator.aria": "Навігація за промптами", "chat.promptNavigator.currentPrompt": "Поточний промпт", @@ -2571,6 +2596,8 @@ export const dict: Record = { 'chat.btw.toast.destroyFailed': 'Не вдалося знищити сесію btw. Вона залишиться в бічній панелі.', 'chat.btw.working': 'Працює…', 'chat.btw.collapseAria': 'Згорнути панель btw', + 'chat.btw.draftHint': 'Поставте своє запитання', + 'chat.btw.cancelAria': 'Скасувати це запитання BTW', 'chat.btw.expandAria': 'Розгорнути панель btw', 'chat.btw.promoteAria': 'Залишити як окрему сесію', 'chat.btw.toast.promoteFailed': 'Не вдалося залишити сесію btw', @@ -2617,6 +2644,8 @@ export const dict: Record = { "chat.textSelection.toast.addToNotesSummaryFailed": "Не вдалося підсумувати виділення, виділений текст додано до нотаток", "chat.textSelection.actions.addToInput": "Додати в поле вводу", "chat.textSelection.actions.comment": "Коментувати", + "chat.textSelection.actions.askOpenChamber": "До речі…", + "chat.textSelection.title.askOpenChamber": "Відкрити BTW-чернетку з виділеним текстом", "chat.textSelection.title.commentOnSelection": "Коментувати виділене", "chat.textSelection.comment.placeholder": "Додайте коментар за бажанням...", "chat.textSelection.comment.attach": "Прикріпити", @@ -2635,6 +2664,8 @@ export const dict: Record = { "chat.messageBody.actions.openPreviewAria": "Відкрити попередній перегляд", "chat.messageBody.actions.openPreview": "Відкрити попередній перегляд", "chat.messageBody.actions.copyAnswer": "Скопіювати відповідь", + "chat.messageBody.actions.moreActions": "Більше дій", + "chat.messageBody.toast.copied": "Скопійовано в буфер обміну", "chat.messageBody.actions.savingImage": "Збереження зображення...", "chat.messageBody.actions.saveAsImage": "Зберегти як зображення", "chat.messageBody.actions.saveAsPlan": "Зберегти як план", @@ -2734,6 +2765,7 @@ export const dict: Record = { "chat.chatInput.draftPicker.projectTitle": "Проєкт", "chat.chatInput.draftPicker.searchProjects": "Пошук проєктів...", "chat.chatInput.draftPicker.searchBranches": "Пошук гілок...", + "chat.chatInput.draftPicker.noProjectsFound": "Проєктів не знайдено.", "chat.chatInput.worktrees": "Worktree", "chat.chatInput.worktreeNew": "+ Новий", "chat.chatInput.drop.insertMention": "Відпустіть, щоб вставити як згадку", @@ -3080,6 +3112,13 @@ export const dict: Record = { "projectActions.actions.addAction": "Додати дію", "projectActions.actions.addNewAction": "Додати нову дію", "projectActions.actions.autoDiscover": "Автовиявлення", + "projectActions.menu.sharedBadge": "репо", + "projects.sharedTrust.title": "Виконати команди, збережені в цьому репозиторії?", + "projects.sharedTrust.description": "{path} у цьому репозиторії містить команди, які виконуються на цьому комп'ютері. Довірте їх один раз, і OpenChamber запитає знову лише коли вони зміняться.", + "projects.sharedTrust.setupCommands": "Команди налаштування worktree", + "projects.sharedTrust.actions": "Дії", + "projects.sharedTrust.skip": "Не цього разу", + "projects.sharedTrust.trust": "Довірити й виконати", "projectActions.actions.autoDiscoverTooltip": "Автоматично знаходить і запускає сервер розробки", "projectActions.actions.chooseActionAria": "Вибрати дію проєкту", "projectActions.actions.openPreview": "Відкрити Preview", @@ -3622,6 +3661,26 @@ export const dict: Record = { 'chat.workStatus.action.openMr': 'Відкрити запит на злиття', 'chat.workStatus.action.openSubagent': 'Відкрити {name}', 'chat.workStatus.section.usage': 'Використання', + 'chat.workStatus.section.telemetry': 'Статистика ходу', + 'chat.workStatus.telemetry.responseSpeed': 'Відповідь', + 'chat.workStatus.telemetry.responseSpeedDescription': 'Як швидко надходив фінальний текст. Без очікування на початок, міркувань і попередніх викликів інструментів. Це оцінка за часовими мітками тексту, а не вимір швидкості на сервері провайдера.', + 'chat.workStatus.telemetry.speed': 'Увесь запит', + 'chat.workStatus.telemetry.llmDuration': 'Час моделі', + 'chat.workStatus.telemetry.llmDurationDescription': 'Час усіх кроків моделі, включно з очікуванням відповідей. Час виконання інструментів віднято. Це не лише час генерації тексту.', + 'chat.workStatus.telemetry.toolDuration': 'Час інструментів', + 'chat.workStatus.telemetry.toolDurationDescription': 'Час виконання інструментів, включно з невдалими викликами. Паралельне виконання рахується один раз, а не додається кілька разів.', + 'chat.workStatus.telemetry.ttft': 'Середній TTFT', + 'chat.workStatus.telemetry.ttftDescription': 'Середнє очікування до початку тексту або міркувань на кожному кроці моделі. Не показуємо, якщо хоча б один крок не має часової мітки початку, як часто буває з викликами лише інструментів.', + 'chat.workStatus.telemetry.steps': 'Кроки', + 'chat.workStatus.telemetry.stepsDescription': 'Скільки разів зверталися до моделі для цього промпту. Прочитати результат інструмента й вирішити, що робити далі, зазвичай означає ще один крок.', + 'chat.workStatus.telemetry.tokens': 'Токени', + 'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓', + 'chat.workStatus.telemetry.speedDescription': 'Згенеровані токени всіх кроків, включно з міркуваннями, поділені на час без виконання інструментів. Очікування моделі залишається, тому багато коротких викликів можуть знижувати цей показник.', + 'chat.workStatus.telemetry.tokensDescription': '↑ Вхідні токени без кешованих: {input}. ↓ Згенеровані: {output} для тексту й викликів інструментів та {reasoning} для міркувань. Суми охоплюють усі кроки цього промпту.', + 'chat.workStatus.telemetry.cacheHit': 'Кеш', + 'chat.workStatus.telemetry.cacheHitDescription': 'Частка вхідних токенів, повторно використаних із кешу промпту на всіх кроках. Повторне використання контексту може зменшити вартість і очікування, але це не оцінка швидкості.', + 'chat.workStatus.telemetry.cost': 'Вартість', + 'chat.workStatus.telemetry.costDescription': 'Вартість усіх кроків моделі для цього промпту за даними провайдера, у доларах США. Окремі сесії субагентів не включено. Нуль може означати безкоштовну модель або провайдера, який не повідомляє про оплату.', 'chat.workStatus.goal.open': 'Керувати ціллю', 'chat.workStatus.goal.pause': 'Пауза', 'chat.workStatus.goal.resume': 'Відновити', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index fbdbff27..fc294c23 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1,5 +1,7 @@ import { linearIntegrationI18n } from './linear-integration.i18n'; export const settingsDict = { + 'settings.openchamber.visual.field.alwaysShowScrollbars': '始终显示滚动条', + 'settings.openchamber.visual.field.alwaysShowScrollbarsHint': '即使指针位于可滚动区域之外,也保持滚动条可见。仅在此设备上生效。', 'settings.providers.page.openCodeGo.title': 'OpenCode Go 用量跟踪', 'settings.providers.page.openCodeGo.description': '连接 OpenCode Go 控制面板以显示滚动、每周和每月配额。', 'settings.providers.page.openCodeGo.workspaceId': '工作区 ID', @@ -59,7 +61,6 @@ export const settingsDict = { 'settings.view.pendingRestart.confirm.dontShowAgain': '不再显示', 'settings.view.pendingRestart.confirm.cancel': '取消', 'settings.view.actions.backToSettings': '返回设置', 'settings.view.actions.closeSettings': '关闭设置', - 'settings.view.actions.openSectionList': '打开分组列表', 'settings.view.actions.closeSettingsWithShortcut': '关闭设置({shortcut}+,)', 'settings.view.actions.back': '返回', 'settings.view.actions.resizeNavigation': '调整设置导航宽度', @@ -433,6 +434,34 @@ export const settingsDict = { 'settings.common.permission.deny': '拒绝', 'settings.common.state.comingSoon': '即将推出...', 'settings.projects.actions.title': '操作', + 'settings.projects.shared.badge': '在仓库中', + 'settings.projects.shared.actionsFromRepo': '存储在仓库中({path})。拉取仓库的每个人都会获得。', + 'settings.projects.shared.commandsFromRepo': '首先运行,存储在仓库中({path})', + 'settings.projects.shared.invalid': '无法读取 {path} 中的项目配置:{reason}', + 'settings.projects.shared.trusted': '已在此实例上信任仓库命令', + 'settings.projects.shared.resetTrust': '重置信任', + 'settings.projects.shared.title': '仓库配置', + 'settings.projects.shared.description': '存储在仓库本身的设置,拉取仓库的每个人都会获得相同的操作、设置命令、启动项和计划。在你移入项目之前不会写入任何内容。', + 'settings.projects.shared.file': '文件', + 'settings.projects.shared.status.missing': '尚未在仓库中', + 'settings.projects.shared.status.ok': '已在仓库中', + 'settings.projects.shared.plansDir': '计划文件夹', + 'settings.projects.shared.plansDirPlaceholder': '.openchamber/plans', + 'settings.projects.shared.plansDirInfo': '仓库计划的存放位置(相对于仓库)。留空表示 .openchamber/plans。自定义文件夹(如 docs/plans)会完全替代默认值:只读写该文件夹。更改时请自行移动现有文件。', + 'settings.projects.shared.plansDirAria': '仓库计划文件夹', + 'settings.projects.shared.actions.share': '移至仓库', + 'settings.projects.shared.actions.showTitle': '在你的菜单中重新显示此仓库操作。', + 'settings.projects.shared.actions.hideTitle': '仅在你的菜单中隐藏此仓库操作;仓库不会改变。', + 'settings.projects.shared.actions.makePersonalTitle': '从仓库中移除,仅保留在此实例上你的设置中。', + 'settings.projects.shared.actions.shareTitle': '存储到仓库内的 {path},拉取仓库的每个人都会获得。它会从你的个人设置中移除。', + 'settings.projects.shared.actions.shareAfterSave': '先保存你的修改,然后再移动', + 'settings.projects.shared.actions.makePersonal': '移至我的设置', + 'settings.projects.shared.actions.hide': '对我隐藏', + 'settings.projects.shared.actions.show': '显示', + 'settings.projects.shared.hiddenBadge': '已隐藏', + 'settings.projects.shared.replaceMode': '仅使用我的设置命令,跳过仓库中的命令', + 'settings.projects.shared.replaceModeAria': '仅使用我的设置命令,跳过仓库中的命令', + 'settings.projects.shared.toast.shareFailed': '更新仓库配置失败', 'settings.projects.actions.description': '按项目显示在项目名旁边表头中的命令。', 'settings.projects.actions.validation.fillNameAndCommand': '保存前请填写操作名称和命令。', 'settings.projects.actions.state.loading': '加载中...', @@ -985,10 +1014,10 @@ export const settingsDict = { 'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': '会重启应用,以便手机、平板和同一 Wi‑Fi 下的其他电脑访问。', 'settings.openchamber.desktopNetwork.field.warning': '警告:启用后,同一本地网络中的任何人都可访问此应用。', 'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': '局域网访问需要桌面 UI 密码。在设置密码之前,桌面应用只会以本机访问模式启动。', - 'settings.openchamber.desktopPassword.actions.showPassword': '显示密码', - 'settings.openchamber.desktopPassword.actions.hidePassword': '隐藏密码', 'settings.openchamber.desktopPassword.field.password': '桌面 UI 密码', 'settings.openchamber.desktopPassword.field.passwordPlaceholder': '不需要密码', + 'settings.openchamber.desktopPassword.field.passwordSetPlaceholder': '已设置密码。输入新密码以替换。', + 'settings.openchamber.desktopPassword.actions.removePassword': '移除密码', 'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber 会在重启后要求输入密码,之后在登录会话过期时再次要求:12 小时后,或选择“信任此设备”后 7 天。留空可关闭登录。', 'settings.openchamber.desktopNetwork.hint.openAfterRestart': '重启后可在其他设备打开:', 'settings.openchamber.desktopNetwork.hint.openNow': '可在其他设备打开:', @@ -2342,8 +2371,10 @@ export const settingsDict = { 'settings.openchamber.visual.field.inputHistoryLimitDescription': '调低这个数字会立即从历史记录中删除较早的提示词。', 'settings.openchamber.visual.field.inputHistoryLimitAria': '要记住的提示词数量', 'settings.openchamber.visual.field.inputHistoryLimitUnit': '条', - 'settings.openchamber.visual.field.enterToSend': 'Enter 发送', - 'settings.openchamber.visual.field.enterToSendHint': '更改后,此设置会控制所有界面中的 Enter 和 Shift+Enter。更改前,各界面保持现有行为。', + 'settings.openchamber.visual.field.enterToSend': '发送快捷键', + 'settings.openchamber.visual.field.enterToSendHint': '请选择标准输入框的发送快捷键。在展开的输入框中,Enter 始终换行,Ctrl/Cmd+Enter 发送。', + 'settings.openchamber.visual.option.enterToSend.enter.label': '按 Enter 发送', + 'settings.openchamber.visual.option.enterToSend.modifier.label': '按 Ctrl/Cmd+Enter 发送', ...linearIntegrationI18n['zh-CN'], 'settings.page.integrations.title': '集成', 'settings.page.integrations.description': '连接 GitHub 和 Linear,让 OpenChamber 可以处理你的 issue 和拉取请求。', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 89c67d83..6c82e796 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -4,11 +4,29 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { + 'commitComparison.mode': '提交', + 'commitComparison.select': '选择提交', + 'commitComparison.search': '搜索提交...', + 'commitComparison.loadError': '无法加载提交', + 'commitComparison.noCommits': '未找到提交', + 'commitComparison.emptyDiff': '此提交没有更改', + 'chat.liveActivity.title': '活动', + 'chat.liveActivity.changedFile': '更改了 {count} 个文件', + 'chat.liveActivity.changedFiles': '更改了 {count} 个文件', + 'chat.liveActivity.explored': '探索了代码库', + 'chat.liveActivity.ranCommand': '运行了 {count} 条命令', + 'chat.liveActivity.ranCommands': '运行了 {count} 条命令', + 'chat.liveActivity.researched': '进行了网络研究', + 'chat.liveActivity.usedSubagent': '使用了 {count} 个子代理', + 'chat.liveActivity.usedSubagents': '使用了 {count} 个子代理', 'sessions.sidebar.projectAction.active': '项目操作正在运行', ...settingsDict, ...linearIssuePickerI18n['zh-CN'], ...linearPanelI18n['zh-CN'], 'terminalView.actions.attachSelection': '附加所选输出', + 'terminalView.actions.copySelection': '复制所选输出', + 'terminalView.toast.selectionCopied': '已复制输出', + 'terminalView.toast.copyFailed': '复制失败', 'terminalView.actions.restart': '重启终端', 'chat.message.terminalContext': '{terminal},第 {start}-{end} 行', 'chat.message.context.codeComment': '对 {file} 第 {start}-{end} 行的评论', @@ -146,7 +164,6 @@ export const dict: Record = { 'mobile.sessions.showArchived': '显示已归档 ({count})', 'mobile.sessions.hideArchived': '隐藏已归档', 'mobile.sessions.activeWorktreeAria': '活动工作树', - 'mobile.sessions.activeProjectAria': '活动项目', 'mobile.sessions.startNewChat': '开始新会话', 'mobile.sessions.newChat': '新会话', 'mobile.sessions.editOrder': '重新排序项目', @@ -165,6 +182,7 @@ export const dict: Record = { 'mobile.sessions.deleteSessionAria': '删除 {title}', 'mobile.sessions.confirmDeleteSessionAria': '确认删除 {title}', 'mobile.sessions.editProjectAria': '编辑 {label}', + 'mobile.sessions.newSessionInProjectAria': '在 {label} 中新建会话', 'mobile.projectEdit.worktreesTitle': '工作树', 'mobile.projectEdit.worktreesEmpty': '此项目还没有工作树。', 'mobile.projectEdit.reorderHint': '拖动以重新排序工作树。', @@ -706,7 +724,6 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '关联工作树已归档。', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': '关联工作树已归档。', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': '工作树已归档且远程分支已移除。', - 'sessions.missingDirectory.movedToProject': '此会话的文件夹已不存在。会话已移至 {project}。', 'sessions.sidebar.group.worktreeMissing': '工作树文件夹缺失', 'sessions.sidebar.sessionDialogs.worktree.label': '工作树', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': '工作树路径不可用。', @@ -1317,7 +1334,6 @@ export const dict: Record = { 'contextRail.surface.walkthrough.description': '由 AI 引导的改动导读', 'walkthrough.scope.all': '全部未提交', 'walkthrough.scope.group.workingTree': '工作区', - 'walkthrough.scope.group.committed': '已提交', 'walkthrough.scope.staged': '已暂存', 'walkthrough.scope.working': '未暂存', 'walkthrough.scope.branch': '当前分支', @@ -1878,6 +1894,9 @@ export const dict: Record = { 'rightSidebar.contextNotesTodo.plans.importFromFile': '从文件导入计划', 'rightSidebar.contextNotesTodo.plans.empty': '还没有已保存的计划。', 'rightSidebar.contextNotesTodo.plans.deletePlan': '删除计划', + 'rightSidebar.contextNotesTodo.plans.sharedBadge': '在仓库中', + 'rightSidebar.contextNotesTodo.plans.share': '移至仓库计划文件夹,拉取仓库的每个人都能看到', + 'rightSidebar.contextNotesTodo.plans.makePersonal': '移至我的计划,移出仓库', 'rightSidebar.contextNotesTodo.plans.deletePlanWithTitle': '删除计划“{title}”', 'rightSidebar.contextNotesTodo.sendDialog.title.newSession': '发送到新会话', 'rightSidebar.contextNotesTodo.sendDialog.title.newWorktree': '发送到新工作树', @@ -1896,6 +1915,7 @@ export const dict: Record = { 'rightSidebar.contextNotesTodo.toast.sendTodoFailed': '发送待办失败', 'rightSidebar.contextNotesTodo.toast.updatePlanFailed': '更新计划失败', 'rightSidebar.contextNotesTodo.toast.deletePlanFailed': '删除计划失败', + 'rightSidebar.contextNotesTodo.toast.movePlanFailed': '移动计划失败', 'rightSidebar.contextNotesTodo.toast.planFileEmpty': '计划文件为空', 'rightSidebar.contextNotesTodo.toast.importPlanFailed': '导入计划失败', 'rightSidebar.contextNotesTodo.toast.planImported': '计划已导入', @@ -1927,6 +1947,7 @@ export const dict: Record = { 'header.services.refreshRateLimitsAria': '刷新速率限制', 'header.services.noRateLimits': '没有可用的速率限制。', 'header.services.noRateLimitsReported': '未上报速率限制。', + 'header.services.usageRefreshFailedStale': '正在显示之前获取的用量数据。刷新失败:{error}', 'header.services.remoteUpdate.title': '远程实例更新', 'header.services.remoteUpdate.checking': '正在检查更新...', 'header.services.remoteUpdate.upToDate': '此实例已是最新。', @@ -2013,6 +2034,7 @@ export const dict: Record = { 'terminalView.tabs.closeTabTitle': '关闭标签页', 'terminalView.tabs.newTabTitle': '新建标签页', 'terminalView.viewport.inputAria': '终端输入', + 'terminalView.viewport.scrollbarAria': '终端回滚历史', 'directoryExplorerDialog.title': '添加项目目录', 'directoryExplorerDialog.description': '选择一个文件夹添加为项目。', 'directoryExplorerDialog.toggle.showHidden': '显示隐藏项', @@ -2452,6 +2474,9 @@ export const dict: Record = { 'chat.draftStarters.sectionCommands': 'Commands', 'chat.draftStarters.sectionSkills': 'Skills', 'chat.draftStarters.remove': 'Remove', + 'chat.draftStarters.sharedTitle': '固定在仓库配置中;请在那里修改', + 'chat.draftStarters.share': '移至仓库配置', + 'chat.draftStarters.makePersonal': '移至我的设置', 'chat.scrollToBottom.aria': '滚动到底部', 'chat.promptNavigator.aria': '提示词导航', 'chat.promptNavigator.currentPrompt': '当前提示', @@ -2559,6 +2584,8 @@ export const dict: Record = { 'chat.btw.toast.destroyFailed': '销毁 btw 会话失败。它将保留在侧边栏中。', 'chat.btw.working': '处理中…', 'chat.btw.collapseAria': '收起 btw 面板', + 'chat.btw.draftHint': '提出你的问题', + 'chat.btw.cancelAria': '取消这次 BTW 提问', 'chat.btw.expandAria': '展开 btw 面板', 'chat.btw.promoteAria': '保留为独立会话', 'chat.btw.toast.promoteFailed': '保留 btw 会话失败', @@ -2605,6 +2632,8 @@ export const dict: Record = { 'chat.textSelection.toast.addToNotesSummaryFailed': '无法总结所选内容,已将所选文本添加到笔记', 'chat.textSelection.actions.addToInput': '添加到输入框', 'chat.textSelection.actions.comment': '评论', + 'chat.textSelection.actions.askOpenChamber': '顺便问一下…', + 'chat.textSelection.title.askOpenChamber': '用所选文本打开 BTW 草稿', 'chat.textSelection.title.commentOnSelection': '评论所选内容', 'chat.textSelection.comment.placeholder': '添加可选评论...', 'chat.textSelection.comment.attach': '附加', @@ -2623,6 +2652,8 @@ export const dict: Record = { 'chat.messageBody.actions.openPreviewAria': '打开预览', 'chat.messageBody.actions.openPreview': '打开预览', 'chat.messageBody.actions.copyAnswer': '复制回答', + 'chat.messageBody.actions.moreActions': '更多操作', + 'chat.messageBody.toast.copied': '已复制到剪贴板', 'chat.messageBody.actions.savingImage': '正在保存图片...', 'chat.messageBody.actions.saveAsImage': '保存为图片', 'chat.messageBody.actions.saveAsPlan': '保存为计划', @@ -2734,6 +2765,7 @@ export const dict: Record = { 'chat.chatInput.draftPicker.projectTitle': '项目', 'chat.chatInput.draftPicker.searchProjects': '搜索项目...', 'chat.chatInput.draftPicker.searchBranches': '搜索分支...', + 'chat.chatInput.draftPicker.noProjectsFound': '未找到项目。', 'chat.chatInput.worktrees': '工作树', 'chat.chatInput.worktreeNew': '+ 新建', 'chat.chatInput.drop.insertMention': '释放以插入为提及', @@ -3080,6 +3112,13 @@ export const dict: Record = { 'projectActions.actions.addAction': '添加操作', 'projectActions.actions.addNewAction': '添加新操作', 'projectActions.actions.autoDiscover': '自动发现', + 'projectActions.menu.sharedBadge': '仓库', + 'projects.sharedTrust.title': '运行此仓库中存储的命令?', + 'projects.sharedTrust.description': '此仓库中的 {path} 定义了会在本机运行的命令。信任一次后,只有当命令变更时 OpenChamber 才会再次询问。', + 'projects.sharedTrust.setupCommands': '工作树设置命令', + 'projects.sharedTrust.actions': '操作', + 'projects.sharedTrust.skip': '这次不运行', + 'projects.sharedTrust.trust': '信任并运行', 'projectActions.actions.autoDiscoverTooltip': '自动发现并运行开发服务器', 'projectActions.actions.chooseActionAria': '选择项目操作', 'projectActions.actions.openPreview': '打开 Preview', @@ -3622,6 +3661,26 @@ export const dict: Record = { 'chat.workStatus.action.openMr': '打开合并请求', 'chat.workStatus.action.openSubagent': '打开 {name}', 'chat.workStatus.section.usage': '用量', + 'chat.workStatus.section.telemetry': '轮次统计', + 'chat.workStatus.telemetry.responseSpeed': '回答速度', + 'chat.workStatus.telemetry.responseSpeedDescription': '最终文本到达的速度。不含开始前的等待、推理和之前的工具调用。这是根据文本时间戳估算的速度,不是提供商测得的生成速度。', + 'chat.workStatus.telemetry.speed': '整个请求', + 'chat.workStatus.telemetry.llmDuration': '模型耗时', + 'chat.workStatus.telemetry.llmDurationDescription': '所有模型步骤的耗时,包括等待回答的时间。已扣除工具执行时间,并不只是生成文本的时间。', + 'chat.workStatus.telemetry.toolDuration': '工具耗时', + 'chat.workStatus.telemetry.toolDurationDescription': '工具执行所用的时间,包括失败的调用。多个工具同时运行的时间只计算一次,不重复相加。', + 'chat.workStatus.telemetry.ttft': '平均首字延迟', + 'chat.workStatus.telemetry.ttftDescription': '每个模型步骤开始输出文本或推理前的平均等待时间。如果有任何步骤缺少开始时间戳,就不显示。只调用工具的步骤经常没有这项数据。', + 'chat.workStatus.telemetry.steps': '步骤', + 'chat.workStatus.telemetry.stepsDescription': '处理这条提示时调用模型的次数。读取工具结果并决定下一步通常需要再次调用模型。', + 'chat.workStatus.telemetry.tokens': 'Token', + 'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓', + 'chat.workStatus.telemetry.speedDescription': '所有步骤生成的 token 数,包括推理,除以扣除工具执行后的时间。等待模型的时间仍计入,因此多次短工具调用可能拉低这个数值。', + 'chat.workStatus.telemetry.tokensDescription': '↑ 不含缓存的输入 token:{input}。↓ 生成的 token:文本和工具调用 {output},推理 {reasoning}。统计这条提示的所有步骤。', + 'chat.workStatus.telemetry.cacheHit': '缓存命中率', + 'chat.workStatus.telemetry.cacheHitDescription': '所有步骤中从提示缓存复用的输入 token 比例。复用上下文可能降低费用和等待时间,但这不是速度评分。', + 'chat.workStatus.telemetry.cost': '费用', + 'chat.workStatus.telemetry.costDescription': '提供商报告的这条提示所有模型步骤的费用,单位为美元。不含独立子代理会话。零可能表示免费模型,也可能是提供商未报告费用。', 'chat.workStatus.goal.open': '管理目标', 'chat.workStatus.goal.pause': '暂停', 'chat.workStatus.goal.resume': '继续', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 3426e52b..95385523 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1,5 +1,7 @@ import { linearIntegrationI18n } from './linear-integration.i18n'; export const settingsDict = { + 'settings.openchamber.visual.field.alwaysShowScrollbars': '一律顯示捲軸', + 'settings.openchamber.visual.field.alwaysShowScrollbarsHint': '即使指標位於可捲動區域之外,也保持捲軸可見。僅在此裝置上生效。', 'settings.providers.page.openCodeGo.title': 'OpenCode Go 用量追蹤', 'settings.providers.page.openCodeGo.description': '連接 OpenCode Go 控制面板以顯示滾動、每週和每月配額。', 'settings.providers.page.openCodeGo.workspaceId': '工作區 ID', @@ -57,7 +59,6 @@ export const settingsDict = { 'settings.view.pendingRestart.confirm.dontShowAgain': '不要再顯示', 'settings.view.pendingRestart.confirm.cancel': '取消', 'settings.view.actions.backToSettings': '返回設定頁', 'settings.view.actions.closeSettings': '關閉設定', - 'settings.view.actions.openSectionList': '開啟群組清單', 'settings.view.actions.closeSettingsWithShortcut': '關閉設定({shortcut}+,)', 'settings.view.actions.back': '返回', 'settings.view.actions.resizeNavigation': '調整設定導覽寬度', @@ -430,6 +431,34 @@ export const settingsDict = { 'settings.common.permission.deny': '拒絕', 'settings.common.state.comingSoon': '即將推出...', 'settings.projects.actions.title': '操作', + 'settings.projects.shared.badge': '在儲存庫中', + 'settings.projects.shared.actionsFromRepo': '儲存在儲存庫中({path})。拉取儲存庫的每個人都會取得。', + 'settings.projects.shared.commandsFromRepo': '優先執行,儲存在儲存庫中({path})', + 'settings.projects.shared.invalid': '無法讀取 {path} 中的專案設定:{reason}', + 'settings.projects.shared.trusted': '已在此執行個體上信任儲存庫命令', + 'settings.projects.shared.resetTrust': '重設信任', + 'settings.projects.shared.title': '儲存庫設定', + 'settings.projects.shared.description': '儲存在儲存庫本身的設定,拉取儲存庫的每個人都會取得相同的動作、設定命令、啟動項和計畫。在你移入項目之前不會寫入任何內容。', + 'settings.projects.shared.file': '檔案', + 'settings.projects.shared.status.missing': '尚未在儲存庫中', + 'settings.projects.shared.status.ok': '已在儲存庫中', + 'settings.projects.shared.plansDir': '計畫資料夾', + 'settings.projects.shared.plansDirPlaceholder': '.openchamber/plans', + 'settings.projects.shared.plansDirInfo': '儲存庫計畫的存放位置(相對於儲存庫)。留空表示 .openchamber/plans。自訂資料夾(如 docs/plans)會完全取代預設值:只讀寫該資料夾。變更時請自行移動現有檔案。', + 'settings.projects.shared.plansDirAria': '儲存庫計畫資料夾', + 'settings.projects.shared.actions.share': '移至儲存庫', + 'settings.projects.shared.actions.showTitle': '在你的選單中重新顯示此儲存庫動作。', + 'settings.projects.shared.actions.hideTitle': '僅在你的選單中隱藏此儲存庫動作;儲存庫不會改變。', + 'settings.projects.shared.actions.makePersonalTitle': '從儲存庫中移除,僅保留在此執行個體上你的設定中。', + 'settings.projects.shared.actions.shareTitle': '儲存到儲存庫內的 {path},拉取儲存庫的每個人都會取得。它會從你的個人設定中移除。', + 'settings.projects.shared.actions.shareAfterSave': '先儲存你的修改,然後再移動', + 'settings.projects.shared.actions.makePersonal': '移至我的設定', + 'settings.projects.shared.actions.hide': '對我隱藏', + 'settings.projects.shared.actions.show': '顯示', + 'settings.projects.shared.hiddenBadge': '已隱藏', + 'settings.projects.shared.replaceMode': '僅使用我的設定命令,略過儲存庫中的命令', + 'settings.projects.shared.replaceModeAria': '僅使用我的設定命令,略過儲存庫中的命令', + 'settings.projects.shared.toast.shareFailed': '更新儲存庫設定失敗', 'settings.projects.actions.description': '按專案顯示在專案名稱旁標題列中的命令。', 'settings.projects.actions.validation.fillNameAndCommand': '儲存前請填寫操作名稱和命令。', 'settings.projects.actions.state.loading': '載入中...', @@ -2252,11 +2281,11 @@ export const settingsDict = { 'settings.openchamber.desktopNetwork.field.keepAwakeAria': 'OpenChamber 執行時保持電腦喚醒', 'settings.openchamber.desktopNetwork.field.keepAwake': 'OpenChamber 執行時保持電腦喚醒', 'settings.openchamber.desktopNetwork.field.keepAwakeDescription': '讓手機可以持續開啟此應用程式。螢幕仍可關閉。', - 'settings.openchamber.desktopPassword.actions.showPassword': '顯示密碼', - 'settings.openchamber.desktopPassword.actions.hidePassword': '隱藏密碼', 'settings.openchamber.desktopPassword.field.password': '桌面 UI 密碼', 'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber 會在重新啟動後要求輸入密碼,之後會在登入工作階段過期時再次要求:12 小時後,或選擇「信任此裝置」後 7 天。留空可停用登入。', 'settings.openchamber.desktopPassword.field.passwordPlaceholder': '不需要密碼', + 'settings.openchamber.desktopPassword.field.passwordSetPlaceholder': '已設定密碼。輸入新密碼以取代。', + 'settings.openchamber.desktopPassword.actions.removePassword': '移除密碼', 'settings.page.plugins.title': '外掛', 'settings.plugins.dialog.add.action.cancel': '取消', 'settings.plugins.dialog.add.action.submit': '新增', @@ -2342,8 +2371,10 @@ export const settingsDict = { 'settings.openchamber.visual.field.inputHistoryLimitDescription': '調低這個數字會立刻從歷史記錄移除較早的提示詞。', 'settings.openchamber.visual.field.inputHistoryLimitAria': '要記住的提示詞數量', 'settings.openchamber.visual.field.inputHistoryLimitUnit': '則', - 'settings.openchamber.visual.field.enterToSend': 'Enter 傳送', - 'settings.openchamber.visual.field.enterToSendHint': '變更後,此設定會控制所有介面中的 Enter 與 Shift+Enter。變更前,各介面會維持現有行為。', + 'settings.openchamber.visual.field.enterToSend': '傳送快捷鍵', + 'settings.openchamber.visual.field.enterToSendHint': '請選擇標準輸入框的傳送快捷鍵。在展開的輸入框中,Enter 一律換行,Ctrl/Cmd+Enter 傳送。', + 'settings.openchamber.visual.option.enterToSend.enter.label': '按 Enter 傳送', + 'settings.openchamber.visual.option.enterToSend.modifier.label': '按 Ctrl/Cmd+Enter 傳送', ...linearIntegrationI18n['zh-TW'], 'settings.page.integrations.title': '整合', 'settings.page.integrations.description': '連接 GitHub 和 Linear,讓 OpenChamber 可以處理你的 issue 和 pull request。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index a73a89cd..76799b51 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -4,11 +4,29 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { + 'commitComparison.mode': '提交', + 'commitComparison.select': '選擇提交', + 'commitComparison.search': '搜尋提交...', + 'commitComparison.loadError': '無法載入提交', + 'commitComparison.noCommits': '找不到提交', + 'commitComparison.emptyDiff': '此提交沒有變更', + 'chat.liveActivity.title': '活動', + 'chat.liveActivity.changedFile': '變更了 {count} 個檔案', + 'chat.liveActivity.changedFiles': '變更了 {count} 個檔案', + 'chat.liveActivity.explored': '探索了程式碼庫', + 'chat.liveActivity.ranCommand': '執行了 {count} 條命令', + 'chat.liveActivity.ranCommands': '執行了 {count} 條命令', + 'chat.liveActivity.researched': '進行了網路研究', + 'chat.liveActivity.usedSubagent': '使用了 {count} 個子代理', + 'chat.liveActivity.usedSubagents': '使用了 {count} 個子代理', 'sessions.sidebar.projectAction.active': '專案操作正在執行', ...settingsDict, ...linearIssuePickerI18n['zh-TW'], ...linearPanelI18n['zh-TW'], 'terminalView.actions.attachSelection': '附加所選輸出', + 'terminalView.actions.copySelection': '複製所選輸出', + 'terminalView.toast.selectionCopied': '已複製輸出', + 'terminalView.toast.copyFailed': '複製失敗', 'terminalView.actions.restart': '重新啟動終端', 'chat.message.terminalContext': '{terminal},第 {start}-{end} 行', 'chat.message.context.codeComment': '對 {file} 第 {start}-{end} 行的評論', @@ -146,7 +164,6 @@ export const dict: Record = { 'mobile.sessions.showArchived': '顯示已封存 ({count})', 'mobile.sessions.hideArchived': '隱藏已封存', 'mobile.sessions.activeWorktreeAria': '作用中的工作樹', - 'mobile.sessions.activeProjectAria': '作用中的專案', 'mobile.sessions.startNewChat': '開始新聊天', 'mobile.sessions.newChat': '新聊天', 'mobile.sessions.editOrder': '重新排序專案', @@ -165,6 +182,7 @@ export const dict: Record = { 'mobile.sessions.deleteSessionAria': '刪除 {title}', 'mobile.sessions.confirmDeleteSessionAria': '確認刪除 {title}', 'mobile.sessions.editProjectAria': '編輯 {label}', + 'mobile.sessions.newSessionInProjectAria': '在 {label} 中新增會話', 'mobile.projectEdit.worktreesTitle': '工作樹', 'mobile.projectEdit.worktreesEmpty': '此專案還沒有工作樹。', 'mobile.projectEdit.reorderHint': '拖曳以重新排序工作樹。', @@ -719,7 +737,6 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '關聯 worktree 已封存。', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': '關聯 worktree 已封存。', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'worktree 已封存且遠端分支已移除。', - 'sessions.missingDirectory.movedToProject': '此工作階段的資料夾已不存在。工作階段已移至 {project}。', 'sessions.sidebar.group.worktreeMissing': '工作樹資料夾遺失', 'sessions.sidebar.sessionDialogs.worktree.label': 'worktree', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'worktree 路徑無法使用。', @@ -1329,7 +1346,6 @@ export const dict: Record = { 'contextRail.surface.walkthrough.description': '由 AI 引導的變更導讀', 'walkthrough.scope.all': '全部未提交', 'walkthrough.scope.group.workingTree': '工作區', - 'walkthrough.scope.group.committed': '已提交', 'walkthrough.scope.staged': '已暫存', 'walkthrough.scope.working': '未暫存', 'walkthrough.scope.branch': '目前分支', @@ -1888,6 +1904,9 @@ export const dict: Record = { 'rightSidebar.contextNotesTodo.plans.importFromFile': '從檔案匯入計畫', 'rightSidebar.contextNotesTodo.plans.empty': '還沒有已儲存的計畫。', 'rightSidebar.contextNotesTodo.plans.deletePlan': '刪除計畫', + 'rightSidebar.contextNotesTodo.plans.sharedBadge': '在儲存庫中', + 'rightSidebar.contextNotesTodo.plans.share': '移至儲存庫計畫資料夾,拉取儲存庫的每個人都能看到', + 'rightSidebar.contextNotesTodo.plans.makePersonal': '移至我的計畫,移出儲存庫', 'rightSidebar.contextNotesTodo.plans.deletePlanWithTitle': '刪除計畫「{title}」', 'rightSidebar.contextNotesTodo.sendDialog.title.newSession': '傳送到新會話', 'rightSidebar.contextNotesTodo.sendDialog.title.newWorktree': '傳送到新 worktree', @@ -1906,6 +1925,7 @@ export const dict: Record = { 'rightSidebar.contextNotesTodo.toast.sendTodoFailed': '傳送待辦失敗', 'rightSidebar.contextNotesTodo.toast.updatePlanFailed': '更新計畫失敗', 'rightSidebar.contextNotesTodo.toast.deletePlanFailed': '刪除計畫失敗', + 'rightSidebar.contextNotesTodo.toast.movePlanFailed': '移動計畫失敗', 'rightSidebar.contextNotesTodo.toast.planFileEmpty': '計畫檔案為空', 'rightSidebar.contextNotesTodo.toast.importPlanFailed': '匯入計畫失敗', 'rightSidebar.contextNotesTodo.toast.planImported': '計畫已匯入', @@ -1937,6 +1957,7 @@ export const dict: Record = { 'header.services.refreshRateLimitsAria': '重新整理速率限制', 'header.services.noRateLimits': '沒有可用的速率限制。', 'header.services.noRateLimitsReported': '未報告速率限制。', + 'header.services.usageRefreshFailedStale': '正在顯示先前取得的用量資料。重新整理失敗:{error}', 'header.services.used': '已用', 'header.services.remaining': '剩餘', 'header.services.modelFamily.other': '其他', @@ -2017,6 +2038,7 @@ export const dict: Record = { 'terminalView.tabs.closeTabTitle': '關閉分頁', 'terminalView.tabs.newTabTitle': '新增分頁', 'terminalView.viewport.inputAria': '終端機輸入', + 'terminalView.viewport.scrollbarAria': '終端機回捲歷史', 'directoryExplorerDialog.title': '新增專案目錄', 'directoryExplorerDialog.description': '選擇一個資料夾新增為專案。', 'directoryExplorerDialog.toggle.showHidden': '顯示隱藏項目', @@ -2456,6 +2478,9 @@ export const dict: Record = { 'chat.draftStarters.sectionCommands': 'Commands', 'chat.draftStarters.sectionSkills': 'Skills', 'chat.draftStarters.remove': 'Remove', + 'chat.draftStarters.sharedTitle': '釘選在儲存庫設定中;請在那裡修改', + 'chat.draftStarters.share': '移至儲存庫設定', + 'chat.draftStarters.makePersonal': '移至我的設定', 'chat.scrollToBottom.aria': '捲動到底部', 'chat.promptNavigator.aria': '提示詞導覽', 'chat.promptNavigator.currentPrompt': '目前提示', @@ -2563,6 +2588,8 @@ export const dict: Record = { 'chat.btw.toast.destroyFailed': '銷毀 btw 工作階段失敗。它將保留在側邊欄中。', 'chat.btw.working': '處理中…', 'chat.btw.collapseAria': '收合 btw 面板', + 'chat.btw.draftHint': '提出你的問題', + 'chat.btw.cancelAria': '取消這次 BTW 提問', 'chat.btw.expandAria': '展開 btw 面板', 'chat.btw.promoteAria': '保留為獨立工作階段', 'chat.btw.toast.promoteFailed': '保留 btw 工作階段失敗', @@ -2609,6 +2636,8 @@ export const dict: Record = { 'chat.textSelection.toast.addToNotesSummaryFailed': '無法總結所選內容,已將所選文字加入筆記', 'chat.textSelection.actions.addToInput': '加入輸入框', 'chat.textSelection.actions.comment': '留言', + 'chat.textSelection.actions.askOpenChamber': '順便問一下…', + 'chat.textSelection.title.askOpenChamber': '用所選文字開啟 BTW 草稿', 'chat.textSelection.title.commentOnSelection': '對所選內容留言', 'chat.textSelection.comment.placeholder': '新增選填留言...', 'chat.textSelection.comment.attach': '附加', @@ -2627,6 +2656,8 @@ export const dict: Record = { 'chat.messageBody.actions.openPreviewAria': '開啟預覽', 'chat.messageBody.actions.openPreview': '開啟預覽', 'chat.messageBody.actions.copyAnswer': '複製回答', + 'chat.messageBody.actions.moreActions': '更多操作', + 'chat.messageBody.toast.copied': '已複製到剪貼簿', 'chat.messageBody.actions.savingImage': '正在儲存圖片...', 'chat.messageBody.actions.saveAsImage': '儲存為圖片', 'chat.messageBody.actions.saveAsPlan': '儲存為計畫', @@ -2738,6 +2769,7 @@ export const dict: Record = { 'chat.chatInput.draftPicker.projectTitle': '專案', 'chat.chatInput.draftPicker.searchProjects': '搜尋專案...', 'chat.chatInput.draftPicker.searchBranches': '搜尋分支...', + 'chat.chatInput.draftPicker.noProjectsFound': '找不到專案。', 'chat.chatInput.worktrees': 'Worktree', 'chat.chatInput.worktreeNew': '+ 新增', 'chat.chatInput.drop.insertMention': '放開以插入為提及', @@ -3084,6 +3116,13 @@ export const dict: Record = { 'projectActions.actions.addAction': '新增操作', 'projectActions.actions.addNewAction': '新增新操作', 'projectActions.actions.autoDiscover': '自動發現', + 'projectActions.menu.sharedBadge': '儲存庫', + 'projects.sharedTrust.title': '執行此儲存庫中儲存的命令?', + 'projects.sharedTrust.description': '此儲存庫中的 {path} 定義了會在本機執行的命令。信任一次後,只有當命令變更時 OpenChamber 才會再次詢問。', + 'projects.sharedTrust.setupCommands': '工作樹設定命令', + 'projects.sharedTrust.actions': '動作', + 'projects.sharedTrust.skip': '這次不執行', + 'projects.sharedTrust.trust': '信任並執行', 'projectActions.actions.autoDiscoverTooltip': '自動探索並執行開發伺服器', 'projectActions.actions.chooseActionAria': '選擇專案操作', 'projectActions.actions.openPreview': '開啟預覽', @@ -3621,6 +3660,26 @@ export const dict: Record = { 'chat.workStatus.action.openMr': '開啟合併請求', 'chat.workStatus.action.openSubagent': '開啟 {name}', 'chat.workStatus.section.usage': '用量', + 'chat.workStatus.section.telemetry': '輪次統計', + 'chat.workStatus.telemetry.responseSpeed': '回答速度', + 'chat.workStatus.telemetry.responseSpeedDescription': '最終文字到達的速度。不含開始前的等待、推理和先前的工具呼叫。這是根據文字時間戳記估算的速度,不是供應商測得的生成速度。', + 'chat.workStatus.telemetry.speed': '整個請求', + 'chat.workStatus.telemetry.llmDuration': '模型耗時', + 'chat.workStatus.telemetry.llmDurationDescription': '所有模型步驟的耗時,包括等待回答的時間。已扣除工具執行時間,並不只是生成文字的時間。', + 'chat.workStatus.telemetry.toolDuration': '工具耗時', + 'chat.workStatus.telemetry.toolDurationDescription': '工具執行所用的時間,包括失敗的呼叫。多個工具同時執行的時間只計算一次,不重複相加。', + 'chat.workStatus.telemetry.ttft': '平均首字延遲', + 'chat.workStatus.telemetry.ttftDescription': '每個模型步驟開始輸出文字或推理前的平均等待時間。若任何步驟缺少開始時間戳記,就不顯示。僅呼叫工具的步驟經常沒有這項資料。', + 'chat.workStatus.telemetry.steps': '步驟', + 'chat.workStatus.telemetry.stepsDescription': '處理這則提示時呼叫模型的次數。讀取工具結果並決定下一步通常需要再次呼叫模型。', + 'chat.workStatus.telemetry.tokens': 'Token', + 'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓', + 'chat.workStatus.telemetry.speedDescription': '所有步驟生成的 token 數,包括推理,除以扣除工具執行後的時間。等待模型的時間仍計入,因此多次短工具呼叫可能拉低這個數值。', + 'chat.workStatus.telemetry.tokensDescription': '↑ 不含快取的輸入 token:{input}。↓ 生成的 token:文字和工具呼叫 {output},推理 {reasoning}。統計這則提示的所有步驟。', + 'chat.workStatus.telemetry.cacheHit': '快取命中率', + 'chat.workStatus.telemetry.cacheHitDescription': '所有步驟中從提示快取重複使用的輸入 token 比例。重複使用上下文可能降低費用和等待時間,但這不是速度評分。', + 'chat.workStatus.telemetry.cost': '費用', + 'chat.workStatus.telemetry.costDescription': '供應商回報的這則提示所有模型步驟的費用,單位為美元。不含獨立子代理工作階段。零可能表示免費模型,也可能是供應商未回報費用。', 'chat.workStatus.goal.open': '管理目標', 'chat.workStatus.goal.pause': '暫停', 'chat.workStatus.goal.resume': '繼續', diff --git a/packages/ui/src/lib/modelPrefsAutoSave.ts b/packages/ui/src/lib/modelPrefsAutoSave.ts index bef31d31..efb81af0 100644 --- a/packages/ui/src/lib/modelPrefsAutoSave.ts +++ b/packages/ui/src/lib/modelPrefsAutoSave.ts @@ -1,5 +1,5 @@ import { useUIStore } from '@/stores/useUIStore'; -import { updateDesktopSettings } from '@/lib/persistence'; +import { isApplyingServerSettings, updateDesktopSettings } from '@/lib/persistence'; import { getRuntimeKey, subscribeRuntimeEndpointWillChange } from '@/lib/runtime-switch'; type ModelRef = { providerID: string; modelID: string }; @@ -133,6 +133,12 @@ export const startModelPrefsAutoSave = () => { if (modelPrefsEqual(next, prev)) { return; } + // Adopted from the server by the settings sync: that is the new baseline, + // not a change of this window's to send back. + if (isApplyingServerSettings()) { + lastSent = cloneModelPrefs(next); + return; + } schedule(); }); diff --git a/packages/ui/src/lib/openchamberConfig.test.ts b/packages/ui/src/lib/openchamberConfig.test.ts index 267b3e49..6bc34342 100644 --- a/packages/ui/src/lib/openchamberConfig.test.ts +++ b/packages/ui/src/lib/openchamberConfig.test.ts @@ -2,149 +2,197 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test'; import { createProjectIdFromPath } from './projectId'; -const homeDirectory = '/Users/test'; const project = { id: 'openchamber', path: '/workspace/openchamber' }; +const endpoint = `/api/projects/${encodeURIComponent(createProjectIdFromPath(project.path))}/config`; -let files = new Map(); +const emptyPersonal = { + setupWorktree: [], + setupWorktreeWait: null, + setupWorktreeMode: 'append', + projectActions: [], + projectActionsPrimaryId: null, + draftStarters: [], + hiddenSharedActionIds: [], + sharedTrust: null, +}; -mock.module('@/contexts/runtimeAPIRegistry', () => ({ - getRegisteredRuntimeAPIs: mock(() => ({ - files: { - createDirectory: mock(async () => ({ success: true })), - readFile: mock(async (path: string) => ({ content: files.get(path) ?? '' })), - writeFile: mock(async (path: string, content: string) => { - files.set(path, content); - return { success: true }; - }), - delete: mock(async (path: string) => { - files.delete(path); - }), - }, - })), -})); +const emptyShared = { + status: 'missing', + path: '.openchamber/project.json', + setupWorktree: [], + setupWorktreeWait: null, + projectActions: [], + draftStarters: [], + plansDir: null, +}; -mock.module('@/lib/desktop', () => ({ - getDesktopHomeDirectory: mock(async () => homeDirectory), - isVSCodeRuntime: mock(() => false), -})); +// A minimal stand-in for the server: one personal document per project, the +// PUT merges the patch and echoes the merged view back like the real route. +let stored: Record = { ...emptyPersonal }; +let sharedOverride: Record | null = null; +let viewOverride: Record | null = null; + +const viewOf = (): Record => { + if (viewOverride) return viewOverride; + const personal = { ...emptyPersonal, ...stored }; + const shared = { ...emptyShared, ...(sharedOverride ?? {}) }; + const actions = personal.projectActions as Array>; + // The real server sanitizes starters before merging; the stand-in does the same. + const starters = (personal.draftStarters as Array>) + .filter((starter) => starter.type === 'command' || starter.type === 'skill'); + return { + trust: { hash: null, trusted: true }, + setupWorktree: [...(shared.setupWorktree as string[]), ...(personal.setupWorktree as string[])], + setupWorktreeWait: personal.setupWorktreeWait ?? shared.setupWorktreeWait ?? false, + projectActions: [ + ...(shared.projectActions as Array>).map((action) => ({ ...action, source: 'shared' })), + ...actions.map((action) => ({ ...action, source: 'personal' })), + ], + projectActionsPrimaryId: personal.projectActionsPrimaryId, + draftStarters: [ + ...(shared.draftStarters as Array>).map((starter) => ({ ...starter, source: 'shared' })), + ...starters.map((starter) => ({ ...starter, source: 'personal' })), + ], + shared, + personal, + }; +}; +let requests: Array<{ url: string; method: string; body: unknown }> = []; +let failWith: number | null = null; mock.module('@/lib/runtime-fetch', () => ({ - runtimeFetch: mock(async (url: string) => { - if (url.endsWith('/fs/home')) { - return new Response(JSON.stringify({ home: homeDirectory }), { - headers: { 'Content-Type': 'application/json' }, - }); + runtimeFetch: mock(async (url: string, init?: RequestInit) => { + const method = init?.method ?? 'GET'; + const body = typeof init?.body === 'string' ? JSON.parse(init.body) : null; + requests.push({ url, method, body }); + if (failWith !== null) { + return new Response(JSON.stringify({ error: 'nope' }), { status: failWith }); } - - return new Response(JSON.stringify({ success: true }), { - headers: { 'Content-Type': 'application/json' }, - }); + if (method === 'PUT' && url.endsWith('/shared')) { + sharedOverride = { ...(sharedOverride ?? {}), status: 'ok', ...(body as Record) }; + } else if (method === 'PUT') { + const patch = { ...(body as Record) }; + delete patch.projectPath; + stored = { ...stored, ...patch }; + } + return new Response(JSON.stringify(viewOf()), { headers: { 'Content-Type': 'application/json' } }); }), })); const { getProjectActionsState, + getProjectDraftStarters, + getProjectSetup, + getWorktreeSetupCommands, + getWorktreeSetupWaitEnabled, saveProjectActionsState, + saveWorktreeSetupCommands, + updateSharedProjectSetup, } = await import('./openchamberConfig'); -const getConfigPath = (projectPath: string): string => ( - `${homeDirectory}/.config/openchamber/projects/${createProjectIdFromPath(projectPath)}.json` -); - -describe('project actions config sanitization', () => { +describe('project config client', () => { beforeEach(() => { - files = new Map(); + stored = { ...emptyPersonal }; + sharedOverride = null; + viewOverride = null; + requests = []; + failWith = null; }); - test('round-trips runIn parent through saved project actions state', async () => { + test('reads and writes through the project config route, never a file path', async () => { const saved = await saveProjectActionsState(project, { - actions: [{ - id: 'action-1', - name: 'Run action', - command: 'pnpm dev', - runIn: 'parent', - }], + actions: [{ id: 'action-1', name: 'Run action', command: 'pnpm dev', runIn: 'parent' }], primaryActionId: 'action-1', }); - expect(saved).toBe(true); + expect(requests[0]).toEqual({ + url: endpoint, + method: 'PUT', + body: { + projectActions: [{ id: 'action-1', name: 'Run action', command: 'pnpm dev', runIn: 'parent' }], + projectActionsPrimaryId: 'action-1', + projectPath: project.path, + }, + }); const state = await getProjectActionsState(project); - expect(state).toEqual({ - actions: [{ - id: 'action-1', - name: 'Run action', - command: 'pnpm dev', - icon: null, - runIn: 'parent', - }], + actions: [{ id: 'action-1', name: 'Run action', command: 'pnpm dev', runIn: 'parent', source: 'personal' }], primaryActionId: 'action-1', }); + expect(requests[1]).toEqual({ url: endpoint, method: 'GET', body: null }); }); - test('keeps runIn omitted when saving project actions in the current worktree', async () => { - const saved = await saveProjectActionsState(project, { - actions: [{ - id: 'action-1', - name: 'Run action', - command: 'pnpm dev', - }], - primaryActionId: 'action-1', - }); - - expect(saved).toBe(true); - - const state = await getProjectActionsState(project); - - expect(state).toEqual({ - actions: [{ - id: 'action-1', - name: 'Run action', - command: 'pnpm dev', - icon: null, - }], - primaryActionId: 'action-1', - }); + test('exposes the merged view with the shared and personal blocks', async () => { + stored = { ...emptyPersonal, setupWorktree: ['cp .env.example .env'], draftStarters: [{ type: 'command', name: 'mine' }] }; + sharedOverride = { status: 'ok', setupWorktree: ['bun install'], setupWorktreeWait: true, plansDir: 'docs/plans', draftStarters: [{ type: 'skill', name: 'triage-prs' }] }; + const setup = await getProjectSetup(project); + expect(setup.setupWorktree).toEqual(['bun install', 'cp .env.example .env']); + expect(setup.setupWorktreeWait).toBe(true); + expect(setup.shared.status).toBe('ok'); + expect(setup.shared.plansDir).toBe('docs/plans'); + expect(setup.personal.setupWorktree).toEqual(['cp .env.example .env']); + expect(await getProjectDraftStarters(project)).toEqual([ + { type: 'skill', name: 'triage-prs', source: 'shared' }, + { type: 'command', name: 'mine', source: 'personal' }, + ]); }); - test('normalizes runIn worktree to omission when loading project actions state', async () => { - files.set(getConfigPath(project.path), JSON.stringify({ + test('never sends the source mark back when saving actions', async () => { + await saveProjectActionsState(project, { + actions: [{ id: 'a', name: 'A', command: 'x', source: 'personal' }], + primaryActionId: null, + }); + expect(requests[0].body).toEqual({ + projectActions: [{ id: 'a', name: 'A', command: 'x' }], + projectActionsPrimaryId: null, projectPath: project.path, - projectActions: [ - { id: 'action-1', name: 'Run action', command: 'pnpm dev', runIn: 'worktree' }, - ], - projectActionsPrimaryId: 'action-1', - })); - - const state = await getProjectActionsState(project); - - expect(state).toEqual({ - actions: [ - { id: 'action-1', name: 'Run action', command: 'pnpm dev', icon: null }, - ], - primaryActionId: 'action-1', }); }); - test('omits unsupported runIn values when loading project actions state', async () => { - files.set(getConfigPath(project.path), JSON.stringify({ - projectPath: project.path, - projectActions: [ - { id: 'action-project', name: 'Project', command: 'pnpm dev', runIn: 'project' }, - { id: 'action-number', name: 'Number', command: 'pnpm test', runIn: 123 }, - ], - projectActionsPrimaryId: 'action-project', - })); + test('drops empty setup commands before sending', async () => { + await saveWorktreeSetupCommands(project, ['bun install', '', ' ']); + expect(requests[0].body).toEqual({ setupWorktree: ['bun install'], projectPath: project.path }); + expect(await getWorktreeSetupCommands(project)).toEqual(['bun install']); + }); - const state = await getProjectActionsState(project); + test('parses the personal starters defensively from the response', async () => { + stored = { ...emptyPersonal, draftStarters: [{ type: 'skill', name: 'triage-prs' }, { type: 'bogus', name: 'x' }] }; + expect((await getProjectSetup(project)).personal.draftStarters).toEqual([{ type: 'skill', name: 'triage-prs' }]); + }); - expect(state).toEqual({ - actions: [ - { id: 'action-project', name: 'Project', command: 'pnpm dev', icon: null }, - { id: 'action-number', name: 'Number', command: 'pnpm test', icon: null }, - ], - primaryActionId: 'action-project', + test('writes the shared file through its own route without source marks and returns the view', async () => { + const view = await updateSharedProjectSetup(project, { + projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev', source: 'personal' }], + plansDir: 'docs/plans', }); + expect(requests[0]).toEqual({ + url: `${endpoint}/shared`, + method: 'PUT', + body: { projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }], plansDir: 'docs/plans' }, + }); + expect(view?.shared.plansDir).toBe('docs/plans'); + expect(view?.projectActions).toEqual([{ id: 'dev', name: 'Dev', command: 'bun run dev', source: 'shared' }]); + failWith = 500; + expect(await updateSharedProjectSetup(project, { plansDir: null })).toBeNull(); + }); + + test('a failed read resolves to the empty value and a failed write to false', async () => { + failWith = 500; + expect(await getWorktreeSetupCommands(project)).toEqual([]); + expect(await getWorktreeSetupWaitEnabled(project)).toBe(false); + expect(await getProjectActionsState(project)).toEqual({ actions: [], primaryActionId: null }); + expect(await saveWorktreeSetupCommands(project, ['x'])).toBe(false); + }); + + test('a response with an unexpected shape is not trusted', async () => { + viewOverride = { setupWorktree: 'bun install' }; + expect(await getWorktreeSetupCommands(project)).toEqual([]); + }); + + test('a project without a path never hits the network', async () => { + expect(await getWorktreeSetupCommands({ id: 'x', path: '' })).toEqual([]); + expect(await saveWorktreeSetupCommands({ id: 'x', path: '' }, ['x'])).toBe(false); + expect(requests).toHaveLength(0); }); }); diff --git a/packages/ui/src/lib/openchamberConfig.ts b/packages/ui/src/lib/openchamberConfig.ts index 11f2fadc..9888bd64 100644 --- a/packages/ui/src/lib/openchamberConfig.ts +++ b/packages/ui/src/lib/openchamberConfig.ts @@ -1,52 +1,36 @@ /** - * OpenChamber project-level configuration service. - * Stores per-project settings in ~/.config/openchamber/projects/.json. - * Migrates from legacy /.openchamber/openchamber.json. + * Client for the project setup routes: worktree setup commands, project + * actions, and pinned draft starters. * - * Notes, todos, and plan files used to live here too. They are now server-owned - * (`packages/web/server/lib/project-context`) and reached through - * `@/lib/projectContextApi`; what remains here is the client-owned rest. + * A project's setup is the merge of two files the server (or the VS Code + * extension host) owns: the personal one in `~/.config/openchamber/projects/` + * and, when a team shares it, `/.openchamber/project.json`. The merged + * view says what runs; its `shared` and `personal` blocks say where each + * entry came from, so a Settings page edits the personal block and never + * copies a teammate's entry into it. This module only speaks HTTP: it + * resolves no home directory and composes no path, so the same code serves + * web, desktop, VS Code, and the phone, including a phone driving a remote + * instance. + * + * Reads keep the contract callers were written against: a failed read logs + * and resolves to the empty setup, because worktree creation and the new + * session screen must keep working when the config cannot be fetched. + * Writes resolve `false` on failure. */ -import type { FilesAPI } from './api/types'; -import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; -import { getDesktopHomeDirectory } from './desktop'; -import { isVSCodeRuntime } from './desktop'; +import { z } from 'zod'; + import { sanitizeStarterRefs, type DraftStarterRef } from './draftStarters'; import { createProjectIdFromPath } from './projectId'; import { runtimeFetch } from './runtime-fetch'; type ProjectRef = { id: string; path: string }; -const CONFIG_FILENAME = 'openchamber.json'; -// LEGACY_PROJECT_CONFIG: legacy per-project config root inside repo. -const LEGACY_CONFIG_DIR = '.openchamber'; -const USER_PROJECTS_DIR_SEGMENTS = ['.config', 'openchamber', 'projects']; - -/** - * Get the runtime Files API if available (Desktop/VSCode). - */ -function getRuntimeFilesAPI(): FilesAPI | null { - const apis = getRegisteredRuntimeAPIs(); - if (apis?.files) { - return apis.files; - } - return null; -} - -interface OpenChamberConfig { - projectPath?: string; - 'setup-worktree'?: string[]; - 'setup-worktree-wait'?: boolean; - projectActions?: OpenChamberProjectAction[]; - projectActionsPrimaryId?: string; - draftStarters?: DraftStarterRef[]; - /** Written by the server via the git-providers route; the client must preserve it. */ - gitProviders?: unknown; -} - type OpenChamberProjectActionPlatform = 'macos' | 'linux' | 'windows'; +/** Where a merged entry came from: the repo's shared file or the user's own file. */ +export type ProjectSetupSource = 'shared' | 'personal'; + export interface OpenChamberProjectAction { id: string; name: string; @@ -57,6 +41,8 @@ export interface OpenChamberProjectAction { autoOpenUrl?: boolean; openUrl?: string; desktopOpenSshForward?: string; + /** Present on merged entries only. */ + source?: ProjectSetupSource; } export interface OpenChamberProjectActionsState { @@ -64,488 +50,261 @@ export interface OpenChamberProjectActionsState { primaryActionId: string | null; } -const OPENCHAMBER_PROJECT_ACTION_NAME_MAX_LENGTH = 80; -const OPENCHAMBER_PROJECT_ACTION_COMMAND_MAX_LENGTH = 4000; -const OPENCHAMBER_PROJECT_ACTION_OPEN_URL_MAX_LENGTH = 2000; -const OPENCHAMBER_PROJECT_ACTION_DESKTOP_FORWARD_MAX_LENGTH = 300; +export type ProjectDraftStarter = DraftStarterRef & { source: ProjectSetupSource }; -const OPENCHAMBER_ACTION_PLATFORM_SET = new Set(['macos', 'linux', 'windows']); +/** The view the server returns; the server sanitizes, the client only checks the shape. */ +const sourceSchema = z.enum(['shared', 'personal']); -const normalize = (value: string): string => { - if (!value) return ''; - const replaced = value.replace(/\\/g, '/'); - return replaced === '/' ? '/' : replaced.replace(/\/+$/, ''); +const projectActionSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + command: z.string().min(1), + icon: z.string().nullable().optional(), + runIn: z.literal('parent').optional(), + platforms: z.array(z.enum(['macos', 'linux', 'windows'])).optional(), + autoOpenUrl: z.literal(true).optional(), + openUrl: z.string().optional(), + desktopOpenSshForward: z.string().optional(), +}); + +const starterRefsSchema = z.unknown().transform((value) => sanitizeStarterRefs(value)); + +const sourcedStartersSchema = z.array(z.object({ + type: z.enum(['command', 'skill']), + name: z.string().min(1), + source: sourceSchema, +})); + +const sharedSchema = z.object({ + status: z.enum(['missing', 'ok', 'invalid']), + reason: z.string().optional(), + path: z.string(), + setupWorktree: z.array(z.string()), + setupWorktreeWait: z.boolean().nullable(), + projectActions: z.array(projectActionSchema), + draftStarters: starterRefsSchema, + plansDir: z.string().nullable(), +}); + +const personalSchema = z.object({ + setupWorktree: z.array(z.string()), + setupWorktreeWait: z.boolean().nullable(), + setupWorktreeMode: z.enum(['append', 'replace']), + projectActions: z.array(projectActionSchema), + projectActionsPrimaryId: z.string().nullable(), + draftStarters: starterRefsSchema, + hiddenSharedActionIds: z.array(z.string()), + sharedTrust: z.object({ hash: z.string(), trustedAt: z.number() }).nullable(), +}); + +const projectSetupSchema = z.object({ + /** Nothing to trust when `hash` is null; otherwise trusted only for the recorded hash. */ + trust: z.object({ hash: z.string().nullable(), trusted: z.boolean() }), + setupWorktree: z.array(z.string()), + setupWorktreeWait: z.boolean(), + projectActions: z.array(projectActionSchema.extend({ source: sourceSchema })), + projectActionsPrimaryId: z.string().nullable(), + draftStarters: sourcedStartersSchema, + shared: sharedSchema, + personal: personalSchema, +}); + +export type ProjectSetup = z.infer; + +/** What a client may change: the personal file only. */ +export type ProjectSetupPatch = Partial<{ + setupWorktree: string[]; + setupWorktreeWait: boolean; + setupWorktreeMode: 'append' | 'replace'; + projectActions: OpenChamberProjectAction[]; + projectActionsPrimaryId: string | null; + draftStarters: DraftStarterRef[]; + hiddenSharedActionIds: string[]; + /** The trust answer for the shared commands with this hash; `null` forgets it. */ + sharedTrustHash: string | null; +}>; + +const EMPTY_PROJECT_SETUP: ProjectSetup = { + trust: { hash: null, trusted: true }, + setupWorktree: [], + setupWorktreeWait: false, + projectActions: [], + projectActionsPrimaryId: null, + draftStarters: [], + shared: { + status: 'missing', + path: '.openchamber/project.json', + setupWorktree: [], + setupWorktreeWait: null, + projectActions: [], + draftStarters: [], + plansDir: null, + }, + personal: { + setupWorktree: [], + setupWorktreeWait: null, + setupWorktreeMode: 'append', + projectActions: [], + projectActionsPrimaryId: null, + draftStarters: [], + hiddenSharedActionIds: [], + sharedTrust: null, + }, }; -const joinPath = (base: string, segment: string): string => { - const normalizedBase = normalize(base); - const cleanSegment = segment.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, ''); - if (!normalizedBase || normalizedBase === '/') { - return `/${cleanSegment}`; +/** + * The storage id is derived from the project path, not from `project.id`: + * project ids in settings have churned across versions, and the path-derived + * id is what names the config file on disk and locates the checkout. + */ +const resolveProjectSetupId = (project: ProjectRef): string => { + const projectPath = typeof project?.path === 'string' ? project.path.trim() : ''; + return projectPath ? createProjectIdFromPath(projectPath) : ''; +}; + +const endpointFor = (projectId: string): string => `/api/projects/${encodeURIComponent(projectId)}/config`; + +const parseSetupResponse = async (response: Response): Promise => { + const parsed = projectSetupSchema.safeParse(await response.json()); + if (!parsed.success) { + throw new Error('Project config response has an unexpected shape'); } - return `${normalizedBase}/${cleanSegment}`; + return parsed.data; }; -const getLegacyConfigPath = (projectDirectory: string): string => { - return joinPath(joinPath(projectDirectory, LEGACY_CONFIG_DIR), CONFIG_FILENAME); -}; - -const getBaseUrl = (): string => { - const defaultBaseUrl = import.meta.env.VITE_OPENCODE_URL || '/api'; - if (defaultBaseUrl.startsWith('/')) { - return defaultBaseUrl; - } - return defaultBaseUrl; -}; - -const postJson = async (url: string, body: unknown): Promise<{ ok: boolean; data: T | null }> => { +/** The project's merged setup, or the empty setup when it cannot be read. */ +export async function getProjectSetup(project: ProjectRef): Promise { + const projectId = resolveProjectSetupId(project); + if (!projectId) return EMPTY_PROJECT_SETUP; try { - const response = await runtimeFetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - if (!response.ok) { - return { ok: false, data: null }; - } - const data = (await response.json().catch(() => null)) as T | null; - return { ok: true, data }; - } catch { - return { ok: false, data: null }; - } -}; - -const mkdirp = async (path: string): Promise => { - const runtimeFiles = getRuntimeFilesAPI(); - if (runtimeFiles?.createDirectory) { - try { - const result = await runtimeFiles.createDirectory(path); - if (result?.success) { - return true; - } - } catch { - // fall through - } - } - - const res = await postJson<{ success?: boolean }>(`${getBaseUrl()}/fs/mkdir`, { path }); - return Boolean(res.ok); -}; - -const readTextFile = async (path: string): Promise => { - const runtimeFiles = getRuntimeFilesAPI(); - if (runtimeFiles?.readFile) { - try { - const result = await runtimeFiles.readFile(path); - const content = typeof result?.content === 'string' ? result.content : ''; - return content; - } catch { - return null; - } - } - - try { - const response = await runtimeFetch(`${getBaseUrl()}/fs/read?path=${encodeURIComponent(path)}`, - { - // Avoid conditional requests (304 + empty body). - cache: 'no-store', - } - ); - if (!response.ok) { - return null; - } - return await response.text(); - } catch { - return null; - } -}; - -const writeTextFile = async (path: string, content: string): Promise => { - const runtimeFiles = getRuntimeFilesAPI(); - if (runtimeFiles?.writeFile) { - try { - const result = await runtimeFiles.writeFile(path, content); - if (result?.success) { - return true; - } - } catch { - // fall through - } - } - - const res = await postJson<{ success?: boolean }>(`${getBaseUrl()}/fs/write`, { path, content }); - return Boolean(res.ok); -}; - -const resolveHomeDirectory = async (): Promise => { - // Use server-reported home as the source of truth for user config paths. - // In some runtimes, window.__OPENCHAMBER_HOME__ can be workspace/project-root - // scoped, which would incorrectly route writes into the project directory. - try { - const response = await runtimeFetch(`${getBaseUrl()}/fs/home`, { - // Avoid conditional requests (304 + empty body). + const response = await runtimeFetch(endpointFor(projectId), { + method: 'GET', + headers: { Accept: 'application/json' }, cache: 'no-store', }); if (!response.ok) { - throw new Error('Failed to resolve home directory from API'); + throw new Error(`HTTP ${response.status}`); } - const payload = await response.json().catch(() => null) as { home?: unknown } | null; - const home = typeof payload?.home === 'string' ? payload.home.trim() : ''; - if (home) { - return normalize(home); - } - } catch { - // fall through - } - - // Fallback for environments where /api/fs/home is unavailable. - // VSCode intentionally avoids this because embedded home equals workspace path. - if (!isVSCodeRuntime()) { - const desktopHome = await getDesktopHomeDirectory().catch(() => null); - if (desktopHome && desktopHome.trim().length > 0) { - return normalize(desktopHome); - } - } - return null; -}; - -const getUserProjectsDirectory = async (): Promise => { - const home = await resolveHomeDirectory(); - if (!home) { - return null; - } - return USER_PROJECTS_DIR_SEGMENTS.reduce((acc, segment) => joinPath(acc, segment), home); -}; - -const resolveConfigProjectId = (project: ProjectRef): string | null => { - const projectDirectory = typeof project?.path === 'string' ? project.path.trim() : ''; - const normalizedProject = projectDirectory ? normalize(projectDirectory) : ''; - if (!normalizedProject) return null; - return createProjectIdFromPath(normalizedProject) || null; -}; - -const getUserConfigPath = async (project: ProjectRef): Promise => { - const base = await getUserProjectsDirectory(); - if (!base) { - return null; - } - const safeId = resolveConfigProjectId(project); - if (!safeId) { - return null; - } - return joinPath(base, `${safeId}.json`); -}; - -const trimToMaxLength = (value: string, maxLength: number): string => { - if (value.length <= maxLength) { - return value; - } - return value.slice(0, maxLength); -}; - -const sanitizeProjectActionPlatforms = (value: unknown): OpenChamberProjectActionPlatform[] => { - if (!Array.isArray(value)) { - return []; - } - - const unique: OpenChamberProjectActionPlatform[] = []; - const seen = new Set(); - for (const entry of value) { - if (typeof entry !== 'string') { - continue; - } - const normalized = entry.trim().toLowerCase() as OpenChamberProjectActionPlatform; - if (!OPENCHAMBER_ACTION_PLATFORM_SET.has(normalized) || seen.has(normalized)) { - continue; - } - seen.add(normalized); - unique.push(normalized); - } - - return unique; -}; - -const sanitizeProjectActions = (value: unknown): OpenChamberProjectAction[] => { - if (!Array.isArray(value)) { - return []; - } - - const sanitized: OpenChamberProjectAction[] = []; - const seenIds = new Set(); - - for (const entry of value) { - if (!entry || typeof entry !== 'object') { - continue; - } - - const record = entry as { - id?: unknown; - name?: unknown; - command?: unknown; - icon?: unknown; - runIn?: unknown; - platforms?: unknown; - autoOpenUrl?: unknown; - openUrl?: unknown; - desktopOpenSshForward?: unknown; - }; - - const id = typeof record.id === 'string' ? record.id.trim() : ''; - const name = trimToMaxLength(typeof record.name === 'string' ? record.name.trim() : '', OPENCHAMBER_PROJECT_ACTION_NAME_MAX_LENGTH); - const command = trimToMaxLength(typeof record.command === 'string' ? record.command.trim() : '', OPENCHAMBER_PROJECT_ACTION_COMMAND_MAX_LENGTH); - - if (!id || !name || !command || seenIds.has(id)) { - continue; - } - seenIds.add(id); - - const iconRaw = typeof record.icon === 'string' ? record.icon.trim() : ''; - const runIn = record.runIn === 'parent' ? 'parent' : undefined; - const platforms = sanitizeProjectActionPlatforms(record.platforms); - const autoOpenUrl = record.autoOpenUrl === true; - const openUrlRaw = typeof record.openUrl === 'string' ? record.openUrl.trim() : ''; - const openUrl = trimToMaxLength(openUrlRaw, OPENCHAMBER_PROJECT_ACTION_OPEN_URL_MAX_LENGTH); - const desktopOpenSshForwardRaw = typeof record.desktopOpenSshForward === 'string' - ? record.desktopOpenSshForward.trim() - : ''; - const desktopOpenSshForward = trimToMaxLength( - desktopOpenSshForwardRaw, - OPENCHAMBER_PROJECT_ACTION_DESKTOP_FORWARD_MAX_LENGTH - ); - - const sanitizedAction: OpenChamberProjectAction = { - id, - name, - command, - icon: iconRaw || null, - ...(autoOpenUrl ? { autoOpenUrl: true } : {}), - ...(openUrl ? { openUrl } : {}), - ...(desktopOpenSshForward ? { desktopOpenSshForward } : {}), - ...(platforms.length > 0 ? { platforms } : {}), - }; - if (runIn) { - sanitizedAction.runIn = runIn; - } - sanitized.push(sanitizedAction); - } - - return sanitized; -}; - -const sanitizeProjectActionsState = (value: { - actions?: unknown; - primaryActionId?: unknown; -} | null | undefined): OpenChamberProjectActionsState => { - const actions = sanitizeProjectActions(value?.actions); - const primaryRaw = typeof value?.primaryActionId === 'string' ? value.primaryActionId.trim() : ''; - const primaryActionId = primaryRaw && actions.some((entry) => entry.id === primaryRaw) - ? primaryRaw - : null; - - return { - actions, - primaryActionId, - }; -}; - -/** - * Read the config for a project. - * Returns null if file doesn't exist or is invalid. - */ -async function readOpenChamberConfig(project: ProjectRef): Promise { - const projectDirectory = typeof project?.path === 'string' ? project.path.trim() : ''; - if (!projectDirectory) { - return null; - } - - const configPath = await getUserConfigPath(project); - - const readText = async (path: string): Promise => { - // Keep behavior consistent with other helpers. - const text = await readTextFile(path); - if (text === null) { - return null; - } - return text; - }; - - const parseConfig = (text: string | null): OpenChamberConfig | null => { - if (typeof text !== 'string') { - return null; - } - const trimmed = text.trim(); - if (!trimmed) { - return null; - } - try { - const parsed = JSON.parse(trimmed); - if (!parsed || typeof parsed !== 'object') { - return null; - } - return parsed as OpenChamberConfig; - } catch { - return null; - } - }; - - // 1) Prefer new per-user config. - if (configPath) { - const existing = parseConfig(await readText(configPath)); - if (existing) { - return existing; - } - } - - // 2) Migrate legacy /.openchamber/openchamber.json. - // LEGACY_PROJECT_CONFIG: migrate project-local openchamber.json -> ~/.config/openchamber/projects/.json - const legacyPath = getLegacyConfigPath(projectDirectory); - const legacyConfig = parseConfig(await readText(legacyPath)); - if (!legacyConfig) { - return null; - } - - // Best-effort write + delete legacy. - try { - const wrote = await writeOpenChamberConfig(project, legacyConfig); - if (wrote) { - await deleteLegacyOpenChamberConfig(projectDirectory); - } - } catch { - // Ignore migration failures; still return legacy content. - } - - return legacyConfig; -} - -/** - * Write the per-user config for a project. - * - * Server owns `version`, `scheduledTasks`, and `gitProviders` keys; client - * reads them via their dedicated routes and never round-trips them through - * this config write path to avoid a read-then-write race clobbering a - * concurrent server update. - */ -async function writeOpenChamberConfig( - project: ProjectRef, - config: OpenChamberConfig -): Promise { - const projectDirectory = typeof project?.path === 'string' ? project.path.trim() : ''; - if (!projectDirectory) { - return false; - } - - const configDir = await getUserProjectsDirectory(); - const configPath = await getUserConfigPath(project); - if (!configDir || !configPath) { - return false; - } - - try { - const okDir = await mkdirp(configDir); - if (!okDir) { - return false; - } - - const existingRaw = await readTextFile(configPath); - let existing: Record = {}; - if (typeof existingRaw === 'string' && existingRaw.trim()) { - try { - const parsed = JSON.parse(existingRaw); - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - existing = parsed as Record; - } - } catch { - existing = {}; - } - } - - const serverOwned: Record = {}; - if (existing.version !== undefined) serverOwned.version = existing.version; - if (existing.scheduledTasks !== undefined) serverOwned.scheduledTasks = existing.scheduledTasks; - if (existing.gitProviders !== undefined) serverOwned.gitProviders = existing.gitProviders; - - const content = JSON.stringify({ - ...existing, - ...config, - ...serverOwned, - projectPath: normalize(projectDirectory), - }, null, 2); - return await writeTextFile(configPath, content); + return await parseSetupResponse(response); } catch (error) { - console.error('Failed to write openchamber config:', error); + console.warn('Failed to read project config:', error); + return EMPTY_PROJECT_SETUP; + } +} + +/** Change the personal part of the project's setup. */ +export async function updateProjectSetup(project: ProjectRef, patch: ProjectSetupPatch): Promise { + const projectId = resolveProjectSetupId(project); + if (!projectId) return false; + try { + const response = await runtimeFetch(endpointFor(projectId), { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ ...patch, projectPath: project.path.trim() }), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + await parseSetupResponse(response); + return true; + } catch (error) { + console.warn('Failed to save project config:', error); return false; } } +/** What a client may change in the team's shared file; every named key replaces the current value. */ +export type SharedProjectSetupPatch = Partial<{ + setupWorktree: string[]; + setupWorktreeWait: boolean | null; + projectActions: OpenChamberProjectAction[]; + draftStarters: DraftStarterRef[]; + plansDir: string | null; +}>; + /** - * Update specific keys in the config, preserving other values. + * Change the team's shared file in the checkout (`/.openchamber/project.json`). + * The server removes the file when nothing is left in it, and records trust + * for the commands this instance just shared. Resolves the merged view, or + * `null` on failure so a caller can tell "saved nothing" from "saved and empty". */ -async function updateOpenChamberConfig( - project: ProjectRef, - updates: Partial -): Promise { - const existing = await readOpenChamberConfig(project) || {}; - const merged = { ...existing, ...updates }; - return writeOpenChamberConfig(project, merged); +export async function updateSharedProjectSetup(project: ProjectRef, patch: SharedProjectSetupPatch): Promise { + const projectId = resolveProjectSetupId(project); + if (!projectId) return null; + const body: SharedProjectSetupPatch = { ...patch }; + if (patch.projectActions) body.projectActions = patch.projectActions.map(withoutSource); + try { + const response = await runtimeFetch(`${endpointFor(projectId)}/shared`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return await parseSetupResponse(response); + } catch (error) { + console.warn('Failed to save the shared project config:', error); + return null; + } } /** - * Get worktree setup commands from config. + * The commands a new worktree runs: shared first, then personal (or personal + * only in replace mode). Code that is about to run them goes through + * `resolveWorktreeSetupCommands` in `lib/sharedTrustConfirmation.ts` instead, + * which asks for trust the first time the shared ones would run. */ export async function getWorktreeSetupCommands(project: ProjectRef): Promise { - const config = await readOpenChamberConfig(project); - return config?.['setup-worktree'] ?? []; + return (await getProjectSetup(project)).setupWorktree; } export async function saveWorktreeSetupCommands(project: ProjectRef, commands: string[]): Promise { - const filtered = commands.filter((cmd) => cmd.trim().length > 0); - return updateOpenChamberConfig(project, { 'setup-worktree': filtered }); + return updateProjectSetup(project, { setupWorktree: commands.filter((cmd) => cmd.trim().length > 0) }); } export async function getWorktreeSetupWaitEnabled(project: ProjectRef): Promise { - const config = await readOpenChamberConfig(project); - return config?.['setup-worktree-wait'] === true; + return (await getProjectSetup(project)).setupWorktreeWait; } export async function saveWorktreeSetupWaitEnabled(project: ProjectRef, enabled: boolean): Promise { - return updateOpenChamberConfig(project, { 'setup-worktree-wait': enabled }); + return updateProjectSetup(project, { setupWorktreeWait: enabled }); } -/** - * Get this project's pinned draft welcome starters. - */ -export async function getProjectDraftStarters(project: ProjectRef): Promise { - const config = await readOpenChamberConfig(project); - return sanitizeStarterRefs(config?.draftStarters); +/** The starters pinned for this project, shared ones first, each marked with its source. */ +export async function getProjectDraftStarters(project: ProjectRef): Promise { + return (await getProjectSetup(project)).draftStarters; } +/** Replace the user's own project starters; shared ones are untouched. */ export async function saveProjectDraftStarters(project: ProjectRef, starters: DraftStarterRef[]): Promise { - return updateOpenChamberConfig(project, { draftStarters: sanitizeStarterRefs(starters) }); + return updateProjectSetup(project, { draftStarters: sanitizeStarterRefs(starters) }); } +/** The actions the project offers to run: merged, each marked with its source. */ export async function getProjectActionsState(project: ProjectRef): Promise { - const config = await readOpenChamberConfig(project); - return sanitizeProjectActionsState({ - actions: config?.projectActions, - primaryActionId: config?.projectActionsPrimaryId, - }); + const setup = await getProjectSetup(project); + return { actions: setup.projectActions, primaryActionId: setup.projectActionsPrimaryId }; } +/** Replace the user's own project actions; shared ones are untouched. */ export async function saveProjectActionsState( project: ProjectRef, - value: OpenChamberProjectActionsState + value: OpenChamberProjectActionsState, ): Promise { - const sanitized = sanitizeProjectActionsState({ - actions: value.actions, - primaryActionId: value.primaryActionId, - }); - - return updateOpenChamberConfig(project, { - projectActions: sanitized.actions, - projectActionsPrimaryId: sanitized.primaryActionId ?? undefined, + return updateProjectSetup(project, { + projectActions: value.actions.map(withoutSource), + projectActionsPrimaryId: value.primaryActionId, }); } +/** The source mark is the server's to add; it never travels back in a write. */ +const withoutSource = (action: OpenChamberProjectAction): OpenChamberProjectAction => { + const copy = { ...action }; + delete copy.source; + return copy; +}; + /** * Substitute variables in a command string. * Supported variables: @@ -565,24 +324,4 @@ export function substituteCommandVariables( .replace(/\$\{ROOT_WORKTREE_PATH\}/g, variables.rootWorktreePath); } -async function deleteLegacyOpenChamberConfig(projectDirectory: string): Promise { - const legacyPath = getLegacyConfigPath(projectDirectory); - const runtimeFiles = getRuntimeFilesAPI(); - - if (runtimeFiles?.delete) { - try { - await runtimeFiles.delete(legacyPath); - return; - } catch { - // fall through - } - } - - try { - await postJson(`${getBaseUrl()}/fs/delete`, { path: legacyPath }); - } catch { - // ignored - } -} - export type { ProjectRef }; diff --git a/packages/ui/src/lib/openchamberEvents.test.ts b/packages/ui/src/lib/openchamberEvents.test.ts index e24e4b5e..dc591db4 100644 --- a/packages/ui/src/lib/openchamberEvents.test.ts +++ b/packages/ui/src/lib/openchamberEvents.test.ts @@ -1,12 +1,4 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; - -mock.module('./runtime-url', () => ({ - getRuntimeUrlResolver: () => ({ sse: (path: string) => `http://runtime.test${path}` }), -})); - -mock.module('./runtime-switch', () => ({ - subscribeRuntimeEndpointChanged: () => () => undefined, -})); +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; class MockEventSource { static CLOSED = 2; @@ -29,20 +21,37 @@ class MockEventSource { describe('openchamber events', () => { beforeEach(() => { MockEventSource.instances = []; - globalThis.window = {} as Window & typeof globalThis; - globalThis.EventSource = MockEventSource as unknown as typeof EventSource; + Object.defineProperty(globalThis, 'window', { + value: Object.assign(new EventTarget(), { location: new URL('http://runtime.test') }), + configurable: true, + writable: true, + }); + Object.defineProperty(globalThis, 'EventSource', { value: MockEventSource, configurable: true, writable: true }); }); afterEach(() => { - delete (globalThis as { window?: unknown }).window; - delete (globalThis as { EventSource?: unknown }).EventSource; + Reflect.deleteProperty(globalThis, 'window'); + Reflect.deleteProperty(globalThis, 'EventSource'); + }); + + test('does not open the server-only event stream in VS Code', async () => { + Object.defineProperty(window, '__VSCODE_CONFIG__', { + value: { workspaceFolder: 'C:/repo', workspaceFolders: [] }, + configurable: true, + }); + const { subscribeOpenchamberEvents } = await import('./openchamberEvents'); + const unsubscribe = subscribeOpenchamberEvents(() => undefined); + try { + expect(MockEventSource.instances).toHaveLength(0); + } finally { + unsubscribe(); + } }); test('dispatches externally created session events', async () => { const { subscribeOpenchamberEvents } = await import('./openchamberEvents'); const events: unknown[] = []; - const listener = (event: unknown) => events.push(event); - const unsubscribe = subscribeOpenchamberEvents(listener); + const unsubscribe = subscribeOpenchamberEvents((event) => events.push(event)); const source = MockEventSource.instances[0]; source.onmessage?.({ @@ -72,4 +81,44 @@ describe('openchamber events', () => { ]); unsubscribe(); }); + + test('a connected control SSE stream clears delivered queues without reconnecting or polling', async () => { + const { subscribeMessageQueueSync } = await import('@/sync/message-queue-sync'); + const { getRuntimeKey } = await import('./runtime-switch'); + const { useMessageQueueStore, createMessageQueueTarget, getMessageQueueKey } = await import('@/stores/messageQueueStore'); + const runtimeKey = getRuntimeKey(); + const target = createMessageQueueTarget('session-sse', '/repo', runtimeKey); + if (!target) throw new Error('Missing queue target'); + useMessageQueueStore.getState().resetForRuntimeSwitch(runtimeKey); + useMessageQueueStore.setState({ queuedMessages: {}, sendingIds: {} }); + const originalFetch = globalThis.fetch; + let reads = 0; + globalThis.fetch = Object.assign(async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input), 'http://runtime.test'); + if (url.pathname === '/api/message-queue') reads += 1; + return Response.json({ revision: 1, sessions: [] }); + }, originalFetch); + const unsubscribe = subscribeMessageQueueSync(runtimeKey); + const source = MockEventSource.instances[0]; + try { + source.onmessage?.({ data: JSON.stringify({ type: 'openchamber:event-stream-ready', properties: {} }) }); + await useMessageQueueStore.getState().hydrate(); + expect(reads).toBe(1); + const session = { sessionId: target.sessionId, directory: target.directory, sendingId: 'q1', items: [{ id: 'q1', content: 'queued', text: 'queued', createdAt: 1, attachments: [], sendConfig: { providerID: 'p', modelID: 'm' } }] }; + source.onmessage?.({ data: JSON.stringify({ type: 'openchamber:message-queue.updated', properties: { revision: 2, session } }) }); + const key = getMessageQueueKey(target); + expect(useMessageQueueStore.getState().queuedMessages[key]).toHaveLength(1); + source.onmessage?.({ data: JSON.stringify({ type: 'openchamber:message-queue.updated', properties: { revision: 3, session: { ...session, items: [], sendingId: null } } }) }); + expect(useMessageQueueStore.getState().queuedMessages[key]).toBeUndefined(); + expect(useMessageQueueStore.getState().sendingIds[key]).toBeUndefined(); + expect(reads).toBe(1); + expect(MockEventSource.instances).toHaveLength(1); + unsubscribe(); + source.onmessage?.({ data: JSON.stringify({ type: 'openchamber:message-queue.updated', properties: { revision: 4, session } }) }); + expect(useMessageQueueStore.getState().queuedMessages[key]).toBeUndefined(); + } finally { + unsubscribe(); + globalThis.fetch = originalFetch; + } + }); }); diff --git a/packages/ui/src/lib/openchamberEvents.ts b/packages/ui/src/lib/openchamberEvents.ts index 9baa6020..53998c80 100644 --- a/packages/ui/src/lib/openchamberEvents.ts +++ b/packages/ui/src/lib/openchamberEvents.ts @@ -1,5 +1,7 @@ import { getRuntimeUrlResolver } from './runtime-url'; import { subscribeRuntimeEndpointChanged } from './runtime-switch'; +import { isVSCodeRuntime } from './desktop'; +import { messageQueueUpdatedEventSchema, type MessageQueueUpdatedEvent } from '@/stores/messageQueueStore'; type ScheduledTaskRanEvent = { type: 'scheduled-task-ran'; @@ -43,6 +45,8 @@ type AgentMemoryChangedEvent = { }; type OpenChamberEvent = + | { type: 'event-stream-ready' } + | MessageQueueUpdatedEvent | ScheduledTaskRanEvent | SessionCreatedEvent | BrowserControlRequestEvent @@ -126,6 +130,15 @@ const getEventProperties = (properties: unknown): Record | null const dispatchFromEnvelope = (envelope: { type: string; properties: unknown }) => { if (envelope.type === 'openchamber:event-stream-ready') { reconnectAttempt = 0; + for (const listener of listeners) listener({ type: 'event-stream-ready' }); + return; + } + + if (envelope.type === 'openchamber:message-queue.updated') { + const parsed = messageQueueUpdatedEventSchema.safeParse(envelope); + if (parsed.success) { + for (const listener of listeners) listener(parsed.data); + } return; } @@ -250,9 +263,11 @@ const connect = () => { canControlBrowser ? { browser: '1' } : undefined, )); source.onopen = () => { + if (eventSource !== source) return; resetHeartbeatTimer(); }; source.onmessage = (event) => { + if (eventSource !== source) return; resetHeartbeatTimer(); const envelope = parseEnvelope(event.data); if (!envelope) { @@ -262,6 +277,7 @@ const connect = () => { }; source.onerror = () => { + if (eventSource !== source) return; cleanupSource(); scheduleReconnect(); }; @@ -284,6 +300,10 @@ const cleanupRuntimeChangeSubscription = () => { }; export const subscribeOpenchamberEvents = (listener: Listener): (() => void) => { + // VS Code runs OpenCode through its bridge, not the OpenChamber server that + // owns this stream. Opening it here retries against vscode-webview:// forever. + if (isVSCodeRuntime()) return () => undefined; + listeners.add(listener); ensureRuntimeChangeSubscription(); connect(); diff --git a/packages/ui/src/lib/opencode/client.questions.test.ts b/packages/ui/src/lib/opencode/client.questions.test.ts new file mode 100644 index 00000000..91d97e08 --- /dev/null +++ b/packages/ui/src/lib/opencode/client.questions.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import type { QuestionRequest } from '@/types/question'; + +type QuestionListResult = { + data?: unknown[]; + error?: unknown; + request?: Request; + response?: Response; +}; + +const makeQuestion = (id: string, overrides?: Partial): QuestionRequest => ({ + id, + sessionID: `ses_${id}`, + questions: [ + { + question: `${id}: proceed with the plan?`, + header: 'Build', + options: [{ label: 'Yes', description: 'Proceed' }], + }, + ], + ...overrides, +}); + +const makeListResult = (items: unknown[]): QuestionListResult => ({ + data: items, + error: undefined, + request: new Request('http://test/'), + response: new Response(null, { status: 200 }), +}); + +const makeListError = (status: number, message: string): QuestionListResult => ({ + data: undefined, + error: new Error(message), + request: new Request('http://test/'), + response: new Response(null, { status }), +}); + +const questionListArgs: Array<{ directory?: string } | undefined> = []; +const questionListResults: QuestionListResult[] = []; + +const questionListMock = mock((args?: { directory?: string }) => { + questionListArgs.push(args); + const result = questionListResults.shift() ?? makeListResult([]); + return Promise.resolve(result); +}); + +const createOpencodeClientMock = mock(() => ({ + question: { + list: questionListMock, + }, +})); + +mock.module('@opencode-ai/sdk/v2', () => ({ + createOpencodeClient: createOpencodeClientMock, +})); + +mock.module('@/contexts/runtimeAPIRegistry', () => ({ + getRegisteredRuntimeAPIs: mock(() => null), +})); + +mock.module('@/lib/runtime-url', () => ({ + getRuntimeUrlResolver: mock(() => ({ + api: (path: string) => path, + })), +})); + +mock.module('@/lib/runtime-switch', () => ({ + getRuntimeApiBaseUrl: mock(() => ''), + getRuntimeKey: mock(() => 'test-runtime'), +})); + +mock.module('@/lib/runtime-fetch', () => ({ + runtimeFetch: mock(async () => new Response(JSON.stringify([]), { + headers: { 'Content-Type': 'application/json' }, + })), +})); + +mock.module('@/lib/startupTrace', () => ({ + markStartupTrace: mock(() => undefined), +})); + +const { opencodeClient } = await import(`./client?cache-test-questions=${Date.now()}`); + +beforeEach(() => { + questionListArgs.length = 0; + questionListResults.length = 0; +}); + +describe('opencodeClient.listPendingQuestions', () => { + test('merges unscoped + per-directory results with id-dedupe, first occurrence wins', async () => { + const globalQuestion = makeQuestion('q1'); + const duplicateQuestion = makeQuestion('q1', { sessionID: 'ses_dup' }); + const scopedQuestion = makeQuestion('q2'); + const otherQuestion = makeQuestion('q3'); + + questionListResults.push( + makeListResult([globalQuestion]), + makeListResult([duplicateQuestion, scopedQuestion]), + makeListResult([otherQuestion]), + makeListResult([]), + ); + + const result = await opencodeClient.listPendingQuestions({ + directories: ['/repo', ' /repo ', '/repo/', '/other', ' ', null, undefined, 'd:\\MyProject', 'D:/MyProject'], + }); + + expect(result).toEqual([globalQuestion, scopedQuestion, otherQuestion]); + expect(questionListArgs).toEqual([ + undefined, + { directory: '/repo' }, + { directory: '/other' }, + { directory: 'D:/MyProject' }, + ]); + }); + + test('rejects the whole call when question.list fails (no empty-success masquerade)', async () => { + questionListResults.push(makeListError(500, 'boom'), makeListError(500, 'boom')); + + await expect( + opencodeClient.listPendingQuestions({ directories: ['/repo'] }), + ).rejects.toThrow('question.list failed'); + }); + + test('ignores entries without a usable string id during the V1 merge', async () => { + const valid = makeQuestion('q1'); + questionListResults.push( + makeListResult([valid, null, { sessionID: 'ses_x' }, { id: 42 }, { id: '' }, 'not-an-object']), + makeListResult([makeQuestion('q2')]), + ); + + const result = await opencodeClient.listPendingQuestions({ directories: ['/repo'] }); + + expect(result).toEqual([valid, makeQuestion('q2')]); + }); + + test('returns an empty array when every list is empty (true empty success)', async () => { + questionListResults.push(makeListResult([]), makeListResult([])); + + const result = await opencodeClient.listPendingQuestions({ directories: ['/repo'] }); + + expect(result).toEqual([]); + expect(questionListArgs).toEqual([undefined, { directory: '/repo' }]); + }); +}); diff --git a/packages/ui/src/lib/opencode/client.test.ts b/packages/ui/src/lib/opencode/client.test.ts index 2be89a52..c6c8406c 100644 --- a/packages/ui/src/lib/opencode/client.test.ts +++ b/packages/ui/src/lib/opencode/client.test.ts @@ -99,16 +99,16 @@ beforeEach(() => { }); describe('opencodeClient directory availability', () => { - type ProbeBody = { error?: string; reason?: string; entries?: never[] }; + type ProbeBody = { error: string; reason?: string } | { isDirectory: boolean } | { isFile: boolean; size: number }; const json = (status: number, body: ProbeBody): Response => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' }, }); test('stats the directory through the OpenChamber filesystem route, never through OpenCode path resolution', async () => { - runtimeFetchResults.push(json(200, { entries: [] })); + runtimeFetchResults.push(json(200, { isDirectory: true })); expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('available'); - expect(runtimeFetchCalls).toEqual([{ path: '/api/fs/list', query: { path: '/private/deleted-worktree' } }]); + expect(runtimeFetchCalls).toEqual([{ path: '/api/fs/directory-stat', query: { path: '/private/deleted-worktree' } }]); expect(pathGetCalls).toBe(0); }); @@ -119,10 +119,19 @@ describe('opencodeClient directory availability', () => { runtimeFetchResults.push(json(400, { error: 'Specified path is not a directory', reason: 'not-directory' })); expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('missing'); + runtimeFetchResults.push(json(200, { isFile: true, size: 12 })); + expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('unknown'); + runtimeFetchResults.push(json(404, { error: 'Not Found' })); expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('unknown'); - runtimeFetchResults.push(json(500, { error: 'Failed to list directory' })); + runtimeFetchResults.push(json(500, { error: 'Failed to stat path' })); + expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('unknown'); + + runtimeFetchResults.push(json(403, { error: 'Access to directory denied', reason: 'os-permission' })); + expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('unknown'); + + runtimeFetchResults.push(json(501, { error: 'Unsupported' })); expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('unknown'); runtimeFetchResults.push(new Error('offline')); diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 410e9165..c4480b3c 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -71,7 +71,7 @@ type SdkResult = { }; type DirectoryAvailability = "available" | "missing" | "unknown"; -const directoryProbeErrorSchema = z.object({ reason: z.string().optional() }); +const directoryProbeErrorSchema = z.object({ reason: z.string().optional(), isDirectory: z.boolean().optional() }); function unwrapSdkData(result: SdkResult, operation: string): T { @@ -597,25 +597,26 @@ class OpencodeService { } /** - * Distinguishes a confirmed-missing directory from an unavailable probe. - * Offline, permission, and other transport failures stay `unknown` so callers - * do not treat a temporary outage as proof the path was deleted. - * - * The probe is OpenChamber's own `/api/fs/list`, which stats the path on the - * server's disk. OpenCode's `/path` cannot answer this question: it echoes - * the requested directory and resolves its project through Git discovery - * that swallows errors, so a deleted worktree still comes back as a valid - * location. A runtime without that route (VS Code) answers `unknown`. - */ + * Distinguishes a confirmed-missing directory from an unavailable probe. + * Offline, permission, and other transport failures stay `unknown` so callers + * do not treat a temporary outage as proof the path was deleted. + * + * The probe is OpenChamber's own `/api/fs/directory-stat`, which asks the + * server to stat the path without listing its contents. OpenCode's `/path` + * cannot answer this question: it echoes the requested directory and resolves + * its project through Git discovery that swallows errors, so a deleted worktree + * still comes back as a valid location. A runtime without that route (VS Code) + * answers `unknown`. + */ async getDirectoryAvailability(directory: string): Promise { const normalized = this.normalizeCandidatePath(directory); if (!normalized) { return "unknown"; } try { - const response = await runtimeFetch("/api/fs/list", { query: { path: normalized } }); - if (response.ok) return "available"; + const response = await runtimeFetch("/api/fs/directory-stat", { query: { path: normalized } }); const body = directoryProbeErrorSchema.safeParse(await response.json().catch(() => null)).data; + if (response.ok && body?.isDirectory === true) return "available"; const reason = parseFilesystemErrorReason(body?.reason); return reason === "not-found" || reason === "not-directory" ? "missing" : "unknown"; } catch { diff --git a/packages/ui/src/lib/persistence.test.ts b/packages/ui/src/lib/persistence.test.ts index e6eb13d4..37130989 100644 --- a/packages/ui/src/lib/persistence.test.ts +++ b/packages/ui/src/lib/persistence.test.ts @@ -35,6 +35,18 @@ type TestWindow = { let createdWindow = false; let createdLocalStorage = false; +let isolatedRuntimeCounter = 0; + +// Each test gets its own runtime identity so an in-flight load or save left +// behind by the previous test is rejected as stale instead of leaking its +// response into this test's stores or server-known values. +const isolateRuntime = (): void => { + isolatedRuntimeCounter += 1; + switchRuntimeEndpoint({ + apiBaseUrl: `https://isolated-${isolatedRuntimeCounter}.example`, + runtimeKey: `isolated-${isolatedRuntimeCounter}`, + }); +}; const originalInputHistoryApplyScope = useInputHistoryStore.getState().applyScope; const originalInputHistoryApplyEntryLimit = useInputHistoryStore.getState().applyEntryLimit; @@ -160,6 +172,7 @@ describe('applyPersistedHomeDirectoryToWindow', () => { describe('updateDesktopSettings', () => { beforeEach(() => { getWindow(); + isolateRuntime(); registerRuntimeAPIs(null); invalidateSettingsCache(); resetModelPrefsState(); @@ -410,15 +423,22 @@ describe('updateDesktopSettings', () => { expect(localStorage.getItem('selectedThemeId')).toBeNull(); expect(localStorage.getItem('directoryTreeShowHidden')).toBeNull(); expect(localStorage.getItem('sttModel')).toBeNull(); + // The mirror carries every user-owned field the server returned, so the + // draft-starter markers ride along with the three values under test. expect(JSON.parse(localStorage.getItem(getRuntimeSettingsMirrorStorageKey('mirror-a')) ?? '{}')).toEqual({ themeId: 'theme-a', directoryShowHidden: true, sttModel: 'model-a', + draftStartersCraftGoalAdded: true, + draftStartersScheduleTaskAdded: true, + }); + expect(JSON.parse(localStorage.getItem(getRuntimeSettingsMirrorStorageKey('mirror-b')) ?? '{}')).toEqual({ + draftStartersCraftGoalAdded: true, + draftStartersScheduleTaskAdded: true, }); - expect(JSON.parse(localStorage.getItem(getRuntimeSettingsMirrorStorageKey('mirror-b')) ?? '{}')).toEqual({}); }); - test('resets in-memory preferences omitted by an authoritative runtime snapshot', async () => { + test('keeps in-memory preferences that an authoritative runtime snapshot omits', async () => { getWindow(); switchRuntimeEndpoint({ apiBaseUrl: 'https://preferences-a.example', runtimeKey: 'preferences-a' }); registerSettingsApi(async () => ({}), async () => ({ @@ -451,13 +471,15 @@ describe('updateDesktopSettings', () => { })); await syncDesktopSettings(); - expect(useUIStore.getState().showReasoningTraces).toBe(true); - expect(useUIStore.getState().terminalShell).toBe('auto'); - expect(useUIStore.getState().favoriteModels).toEqual([]); - expect(useUIStore.getState().toolJsonViewMode).toBe('summary'); - expect(useUIStore.getState().globalDraftStarters).toBeNull(); - expect(useUIStore.getState().draftStartersVisible).toBe(true); - expect(useMessageQueueStore.getState().followUpBehavior).toBe('queue'); + // An omitted key is "unset", not "reset to default": the window keeps what + // it holds and nothing is written back. + expect(useUIStore.getState().showReasoningTraces).toBe(false); + expect(useUIStore.getState().terminalShell).toBe('fish'); + expect(useUIStore.getState().favoriteModels).toHaveLength(1); + expect(useUIStore.getState().toolJsonViewMode).toBe('raw'); + expect(useUIStore.getState().globalDraftStarters).toEqual([{ type: 'command', name: 'runtime-a' }]); + expect(useUIStore.getState().draftStartersVisible).toBe(false); + expect(useMessageQueueStore.getState().followUpBehavior).toBe('steer'); }); test('treats settings save responses as partial patches', async () => { @@ -528,7 +550,7 @@ describe('updateDesktopSettings', () => { }); }); - test('seeds missing shared sidebar preferences from the hydrated local cache', async () => { + test('keeps hydrated sidebar preferences the server omits and writes nothing back', async () => { getWindow(); const saves: Array> = []; useSessionDisplayStore.setState({ @@ -550,15 +572,21 @@ describe('updateDesktopSettings', () => { })); await syncDesktopSettings(); + await delay(300); - expect(saves).toEqual([{ - draftStartersCraftGoalAdded: true, - draftStartersScheduleTaskAdded: true, - sidebarProjectDisplayMode: 'single', - sidebarSessionGroupingMode: 'flat', - sidebarProjectSortOrder: 'a-z', - sidebarShowRecentSection: false, - }]); + expect(saves).toEqual([]); + const state = useSessionDisplayStore.getState(); + expect({ + projectDisplayMode: state.projectDisplayMode, + sessionGroupingMode: state.sessionGroupingMode, + projectSortOrder: state.projectSortOrder, + showRecentSection: state.showRecentSection, + }).toEqual({ + projectDisplayMode: 'single', + sessionGroupingMode: 'flat', + projectSortOrder: 'a-z', + showRecentSection: false, + }); }); test('preserves local sidebar preferences when the authoritative load fails', async () => { @@ -801,7 +829,6 @@ describe('updateDesktopSettings', () => { expect(saveCalls).toHaveLength(1); expect(saveCalls[0]).toEqual({ - draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true, favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-haiku-4' }], hiddenModels: [{ providerID: 'openai', modelID: 'gpt-5' }], collapsedModelProviders: ['openai'], @@ -818,6 +845,7 @@ describe('updateDesktopSettings', () => { getWindow(); useUIStore.getState().setTerminalShell('auto'); useUIStore.getState().setTerminalLoginShells([]); + useUIStore.getState().setToolJsonViewMode('summary'); const saveCalls: Array> = []; registerSettingsSave(async (changes) => { saveCalls.push(changes); @@ -835,6 +863,45 @@ describe('updateDesktopSettings', () => { expect(saveCalls.some((changes) => changes.toolJsonViewMode === 'formatted')).toBe(true); }); + test('legacy server lists show telemetry, while explicit hiding survives hydration', async () => { + getWindow(); + for (const explicit of [undefined, false, true]) { + invalidateSettingsCache(); + registerSettingsApi(async (changes) => changes, async () => ({ + settings: { workStatusHiddenSections: ['mcp', 'telemetry'], workStatusHiddenSectionsExplicit: explicit, + draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true }, + source: 'web', + })); + await syncDesktopSettings(); + expect(useUIStore.getState().workStatusHiddenSections).toEqual(explicit ? ['mcp', 'telemetry'] : ['mcp']); + expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(explicit === true); + } + }); + + test('autosaves telemetry hiding and its list together, then restores them through settings load', async () => { + getWindow(); + invalidateSettingsCache(); + let server: SettingsPayload = { workStatusHiddenSections: [], draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true }; + const saves: Partial[] = []; + registerSettingsApi(async (changes) => { saves.push(changes); server = { ...server, ...changes }; return changes; }, + async () => ({ settings: server, source: 'web' })); + await syncDesktopSettings(); + expect(useUIStore.getState().workStatusHiddenSections).toEqual([]); + startAppearanceAutoSave(); + useUIStore.getState().setWorkStatusSectionVisible('telemetry', false); + await delay(600); + expect(saves.some((changes) => changes.workStatusHiddenSectionsExplicit === true)).toBe(true); + expect(server.workStatusHiddenSections).toEqual(['telemetry']); + expect(server.workStatusHiddenSectionsExplicit).toBe(true); + invalidateSettingsCache(); + await syncDesktopSettings(); + expect(useUIStore.getState().workStatusHiddenSections).toEqual(['telemetry']); + expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(true); + // An unrelated partial save response must not re-enable a hidden section. + await updateDesktopSettings({ workStatusPanelEnabled: useUIStore.getState().workStatusPanelEnabled }); + expect(useUIStore.getState().workStatusHiddenSections).toEqual(['telemetry']); + }); + test('applies persisted autoSaveEnabled from server settings', async () => { getWindow(); invalidateSettingsCache(); @@ -885,7 +952,7 @@ describe('updateDesktopSettings', () => { expect(useInputHistoryStore.getState().entryLimit).toBe(100); }); - test('defaults omitted input history scope to global without writing a migration', async () => { + test('keeps the hydrated input history scope when the server omits it and writes nothing', async () => { getWindow(); invalidateSettingsCache(); useInputHistoryStore.getState().applyScope('session'); @@ -904,11 +971,11 @@ describe('updateDesktopSettings', () => { await syncDesktopSettings(); - expect(useInputHistoryStore.getState().scope).toBe(DEFAULT_INPUT_HISTORY_SCOPE); + expect(useInputHistoryStore.getState().scope).toBe('session'); expect(saveCalls.some((changes) => changes.inputHistoryScope !== undefined)).toBe(false); }); - test('defaults omitted input history limit to forty without writing a migration', async () => { + test('keeps the hydrated input history limit when the server omits it and writes nothing', async () => { getWindow(); invalidateSettingsCache(); useInputHistoryStore.getState().applyEntryLimit(100); @@ -927,7 +994,7 @@ describe('updateDesktopSettings', () => { await syncDesktopSettings(); - expect(useInputHistoryStore.getState().entryLimit).toBe(DEFAULT_INPUT_HISTORY_LIMIT); + expect(useInputHistoryStore.getState().entryLimit).toBe(100); expect(saveCalls.some((changes) => changes.inputHistoryLimit !== undefined)).toBe(false); }); @@ -1009,7 +1076,7 @@ describe('updateDesktopSettings', () => { expect(saveCalls.some((changes) => changes.autoSaveEnabled === false)).toBe(true); }); - test('seeds omitted autoSaveEnabled from the hydrated client preference', async () => { + test('keeps the hydrated autoSaveEnabled when the server omits it and writes nothing', async () => { getWindow(); invalidateSettingsCache(); useUIStore.getState().setAutoSaveEnabled(false); @@ -1026,27 +1093,110 @@ describe('updateDesktopSettings', () => { await delay(500); expect(useUIStore.getState().autoSaveEnabled).toBe(false); - expect(saveCalls.some((changes) => changes.autoSaveEnabled === false)).toBe(true); + expect(saveCalls).toEqual([]); }); - test('seeds default autoSaveEnabled when omitted and client still has the default', async () => { + test('a bootstrap that adopts server values produces zero writes even with the auto-savers running', async () => { + getWindow(); + invalidateSettingsCache(); + // The setup below is itself "a person changing things" as far as the + // auto-savers can tell; let those writes drain before recording. + const saveCalls: Array> = []; + let recording = false; + registerSettingsApi(async (changes) => { + if (recording) saveCalls.push(changes); + return { ...changes } as SettingsPayload; + }, async () => ({ + settings: { + showReasoningTraces: false, + terminalShell: 'fish', + favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-sonnet-4' }], + // A legacy list the client normalises on read: the normalised copy is + // still not this window's change and must not be written back. + workStatusHiddenSections: ['mcp', 'telemetry'], + draftStartersCraftGoalAdded: true, + draftStartersScheduleTaskAdded: true, + }, + source: 'web', + })); + startAppearanceAutoSave(); + const stopModelPrefs = startModelPrefsAutoSave(); + useUIStore.getState().setShowReasoningTraces(true); + useUIStore.getState().setTerminalShell('auto'); + resetModelPrefsState(); + await delay(1500); + recording = true; + + try { + await syncDesktopSettings(); + await delay(1500); + + expect(useUIStore.getState().showReasoningTraces).toBe(false); + expect(useUIStore.getState().terminalShell).toBe('fish'); + expect(useUIStore.getState().favoriteModels).toHaveLength(1); + expect(useUIStore.getState().workStatusHiddenSections).toEqual(['mcp']); + expect(saveCalls).toEqual([]); + } finally { + stopModelPrefs(); + } + }); + + test('drops a write whose value the server already holds', async () => { getWindow(); invalidateSettingsCache(); - useUIStore.getState().setAutoSaveEnabled(true); const saveCalls: Array> = []; registerSettingsApi(async (changes) => { saveCalls.push(changes); return { ...changes } as SettingsPayload; }, async () => ({ - settings: { draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true }, + settings: { fontSize: 15, draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true }, source: 'web', })); - await syncDesktopSettings(); - await delay(500); - expect(useUIStore.getState().autoSaveEnabled).toBe(true); - expect(saveCalls.some((changes) => changes.autoSaveEnabled === true)).toBe(true); + await updateDesktopSettings({ fontSize: 15 }); + expect(saveCalls).toEqual([]); + expect(getSettingsSaveState()).toBe('idle'); + + await updateDesktopSettings({ fontSize: 16 }); + expect(saveCalls).toEqual([{ fontSize: 16 }]); + }); + + test('toggling back to the server value inside the debounce window cancels the pending write', async () => { + getWindow(); + invalidateSettingsCache(); + const saveCalls: Array> = []; + registerSettingsApi(async (changes) => { + saveCalls.push(changes); + return { ...changes } as SettingsPayload; + }, async () => ({ + settings: { showDeletionDialog: true, draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true }, + source: 'web', + })); + await syncDesktopSettings(); + + void updateDesktopSettings({ showDeletionDialog: false, fontSize: 17 }); + await updateDesktopSettings({ showDeletionDialog: true }); + + expect(saveCalls).toEqual([{ fontSize: 17 }]); + }); + + test('a failed save forgets its optimistic value so the retry is sent', async () => { + getWindow(); + invalidateSettingsCache(); + let fail = true; + const saveCalls: Array> = []; + registerSettingsSave(async (changes) => { + saveCalls.push(changes); + if (fail) throw new Error('offline'); + return { ...changes } as SettingsPayload; + }); + + await updateDesktopSettings({ fontSize: 18 }); + fail = false; + await updateDesktopSettings({ fontSize: 18 }); + + expect(saveCalls).toEqual([{ fontSize: 18 }, { fontSize: 18 }]); }); test('does not invent theme defaults when the authoritative snapshot omits theme fields', async () => { @@ -1147,6 +1297,7 @@ describe('updateDesktopSettings', () => { describe('unload lifecycle flush (#2197)', () => { beforeEach(() => { getWindow(); + isolateRuntime(); registerRuntimeAPIs(null); invalidateSettingsCache(); }); diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 655bda97..c111a610 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -1,8 +1,21 @@ import type { DesktopSettings } from '@/lib/desktop'; -import { sanitizeWorkStatusHiddenSections } from '@/components/chat/work-status/sections'; -import { createProjectIdFromPath } from '@/lib/projectId'; import { useUIStore } from '@/stores/useUIStore'; -import { isMonoFontOption, isUiFontOption } from '@/lib/fontOptions'; +import { loadAppearancePreferences, applyAppearancePreferences } from '@/lib/appearancePersistence'; +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { setStoredMobileKeyboardMode } from '@/lib/mobileKeyboardMode'; +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { isCapacitorApp } from '@/lib/platform'; +import { getRuntimeKey, subscribeRuntimeEndpointChanged, subscribeRuntimeEndpointWillChange } from '@/lib/runtime-switch'; +import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; +import { + applySettingsToStores, + isDeviceSettingsKey, + isWritableSettingsKey, + MIRRORED_KEYS, + parseSettingsDocument, + SETTINGS_KEYS, +} from '@/lib/settings/registry'; +import { SETTINGS_SURFACE_QUERY, getSettingsSurface } from '@/lib/settings/surface'; import { useGitProviderDomainsStore, normalizeApiBaseUrl, @@ -12,32 +25,6 @@ import { getProjectGitProviders, resolveProjectIdForDirectory } from '@/lib/proj import { useProjectsStore } from '@/stores/useProjectsStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; -import { - DEFAULT_FOLLOW_UP_BEHAVIOR, - isFollowUpBehavior, - normalizeFollowUpBehavior, - useMessageQueueStore, - type FollowUpBehavior, -} from '@/stores/messageQueueStore'; -import { setDirectoryShowHidden } from '@/lib/directoryShowHidden'; -import { setFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; -import { loadAppearancePreferences, applyAppearancePreferences } from '@/lib/appearancePersistence'; -import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; -import { sanitizeStarterRefs } from '@/lib/draftStarters'; -import { - DEFAULT_INPUT_HISTORY_LIMIT, - DEFAULT_INPUT_HISTORY_SCOPE, - isInputHistoryLimit, - isInputHistoryScope, -} from '@/lib/inputHistoryScope'; -import { useInputHistoryStore } from '@/stores/useInputHistoryStore'; -import { normalizeMobileKeyboardMode, setStoredMobileKeyboardMode } from '@/lib/mobileKeyboardMode'; -import { runtimeFetch } from '@/lib/runtime-fetch'; -import { isCapacitorApp } from '@/lib/platform'; -import { isTerminalShell } from '@/lib/terminalShell'; -import { getRuntimeKey, subscribeRuntimeEndpointChanged, subscribeRuntimeEndpointWillChange } from '@/lib/runtime-switch'; -import { DEFAULT_OPEN_IN_APP_ID } from '@/lib/openInApps'; -import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; export const applyPersistedHomeDirectoryToWindow = (homeDirectory: string): void => { if (typeof window === 'undefined') { @@ -55,6 +42,33 @@ export const applyPersistedHomeDirectoryToWindow = (homeDirectory: string): void }; const SETTINGS_MIRROR_INDEX_KEY = 'openchamber.settingsMirror.v2.index'; +// Set once a runtime's device fields have been read from the server document +// (installs that predate the settings split still carry them there). After +// that the local store is the only owner and the server copy is ignored. +const DEVICE_SEED_KEY_PREFIX = 'openchamber.deviceSeeded.v1:'; +const getDeviceSeedStorageKey = (runtimeKey: string): string => `${DEVICE_SEED_KEY_PREFIX}${encodeURIComponent(runtimeKey)}`; + +/** + * The part of a server document this window may apply: everything but device + * fields, plus the device fields exactly once per runtime as a migration seed. + */ +const withoutStaleDeviceFields = (settings: DesktopSettings, runtimeKey: string): DesktopSettings => { + const seedKey = getDeviceSeedStorageKey(runtimeKey); + let seedDevice = false; + try { + seedDevice = localStorage.getItem(seedKey) === null; + if (seedDevice) localStorage.setItem(seedKey, String(Date.now())); + } catch { + seedDevice = false; + } + if (seedDevice) return settings; + const next: DesktopSettings = {}; + for (const key of SETTINGS_KEYS) { + if (settings[key] === undefined || isDeviceSettingsKey(key)) continue; + Object.assign(next, { [key]: settings[key] }); + } + return next; +}; const SETTINGS_MIRROR_KEY_PREFIX = 'openchamber.settingsMirror.v2:'; const MAX_SETTINGS_MIRROR_RUNTIMES = 5; @@ -70,37 +84,13 @@ const setOrRemoveLocalStorage = (key: string, value: string | null): void => { }; const persistRuntimeSettingsMirror = (settings: DesktopSettings, runtimeKey: string): void => { - const mirror = { - themeId: settings.themeId, - themeVariant: settings.themeVariant, - lightThemeId: settings.lightThemeId, - darkThemeId: settings.darkThemeId, - useSystemTheme: settings.useSystemTheme, - lastDirectory: settings.lastDirectory, - homeDirectory: settings.homeDirectory, - projects: settings.projects, - activeProjectId: settings.activeProjectId, - sidebarProjectDisplayMode: settings.sidebarProjectDisplayMode, - sidebarSessionGroupingMode: settings.sidebarSessionGroupingMode, - sidebarProjectSortOrder: settings.sidebarProjectSortOrder, - sidebarShowRecentSection: settings.sidebarShowRecentSection, - pinnedDirectories: settings.pinnedDirectories, - gitmojiEnabled: settings.gitmojiEnabled, - directoryShowHidden: settings.directoryShowHidden, - filesViewShowGitignored: settings.filesViewShowGitignored, - openInAppId: settings.openInAppId, - pwaAppName: settings.pwaAppName, - mobileKeyboardMode: settings.mobileKeyboardMode, - openCodeUpdateToastDismissedVersion: settings.openCodeUpdateToastDismissedVersion, - inputHistoryScope: settings.inputHistoryScope, - inputHistoryLimit: settings.inputHistoryLimit, - dictationEnabled: settings.dictationEnabled, - sttProvider: settings.sttProvider, - sttServerUrl: settings.sttServerUrl, - sttModel: settings.sttModel, - sttLocalModel: settings.sttLocalModel, - sttLanguage: settings.sttLanguage, - }; + // Every user-owned field the server holds for this runtime, so a later + // phase can serve the profile from the mirror; secrets and computed flags + // never land in browser storage. + const mirror: DesktopSettings = {}; + for (const key of MIRRORED_KEYS) { + if (settings[key] !== undefined) Object.assign(mirror, { [key]: settings[key] }); + } localStorage.setItem(getRuntimeSettingsMirrorStorageKey(runtimeKey), JSON.stringify(mirror)); let previous: string[] = []; @@ -288,295 +278,6 @@ type PersistApi = { onFinishHydration?: (callback: () => void) => (() => void) | undefined; }; -const sanitizeSkillCatalogs = (value: unknown): DesktopSettings['skillCatalogs'] | undefined => { - if (!Array.isArray(value)) { - return undefined; - } - - const result: NonNullable = []; - const seen = new Set(); - - for (const entry of value) { - if (!entry || typeof entry !== 'object') continue; - const candidate = entry as Record; - - const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; - const label = typeof candidate.label === 'string' ? candidate.label.trim() : ''; - const source = typeof candidate.source === 'string' ? candidate.source.trim() : ''; - const subpath = typeof candidate.subpath === 'string' ? candidate.subpath.trim() : ''; - const gitIdentityId = typeof candidate.gitIdentityId === 'string' ? candidate.gitIdentityId.trim() : ''; - - if (!id || !label || !source) continue; - if (seen.has(id)) continue; - seen.add(id); - - const catalog: NonNullable[number] = { - id, - label, - source, - }; - if (subpath) catalog.subpath = subpath; - if (gitIdentityId) catalog.gitIdentityId = gitIdentityId; - result.push(catalog); - } - - return result; -}; - -const sanitizeShortcutOverrides = (value: unknown): Record | undefined => { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return undefined; - } - const result: Record = {}; - for (const [key, combo] of Object.entries(value)) { - const normalizedKey = typeof key === 'string' ? key.trim() : ''; - const normalizedCombo = typeof combo === 'string' ? combo.trim() : ''; - if (!normalizedKey || !normalizedCombo) continue; - result[normalizedKey] = normalizedCombo; - } - return result; -}; - -const areStringRecordsEqual = (left: Record, right: Record): boolean => { - const leftEntries = Object.entries(left); - const rightEntries = Object.entries(right); - if (leftEntries.length !== rightEntries.length) return false; - return leftEntries.every(([key, value]) => right[key] === value); -}; - -const areModelRefsEqual = ( - left: Array<{ providerID: string; modelID: string }>, - right: Array<{ providerID: string; modelID: string }>, -): boolean => ( - left.length === right.length && - left.every((item, idx) => item.providerID === right[idx]?.providerID && item.modelID === right[idx]?.modelID) -); - -const areStringArraysEqual = (left: string[], right: string[]): boolean => ( - left.length === right.length && left.every((value, idx) => value === right[idx]) -); - -const sanitizeStringArray = (value: unknown): string[] | undefined => { - if (!Array.isArray(value)) return undefined; - return Array.from(new Set(value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0))); -}; - -const GIT_PROVIDER_NAMES = ['github', 'gitlab', 'gitea'] as const; - -const sanitizeGitProviders = (value: unknown): DesktopSettings['gitProviders'] | undefined => { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return undefined; - } - const source = value as Record; - const result: NonNullable = {}; - let hasAny = false; - for (const provider of GIT_PROVIDER_NAMES) { - const entry = source[provider]; - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { - continue; - } - const record = entry as Record; - const providerConfig: { apiBaseUrl?: string; detectUrls?: string[] } = {}; - const apiBaseUrl = normalizeApiBaseUrl(record.apiBaseUrl); - if (apiBaseUrl) { - providerConfig.apiBaseUrl = apiBaseUrl; - } - // detectUrls are bare hosts (scheme/port/path stripped) via parseGitHost. - const detectUrls = normalizeDomainList(record.detectUrls); - if (detectUrls.length > 0) { - providerConfig.detectUrls = detectUrls; - } - if (Object.keys(providerConfig).length > 0) { - result[provider] = providerConfig; - hasAny = true; - } - } - return hasAny ? result : undefined; -}; - -const sanitizeRecentEfforts = (value: unknown): Record | undefined => { - if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; - const result: Record = {}; - for (const [key, variants] of Object.entries(value)) { - if (!key || !Array.isArray(variants)) continue; - const sanitized = sanitizeStringArray(variants); - if (sanitized && sanitized.length > 0) { - result[key] = sanitized.slice(0, 5); - } - } - return Object.keys(result).length > 0 ? result : undefined; -}; - -const areRecentEffortsEqual = (left: Record, right: Record): boolean => { - const leftKeys = Object.keys(left); - if (leftKeys.length !== Object.keys(right).length) return false; - return leftKeys.every((key) => Array.isArray(right[key]) && areStringArraysEqual(left[key], right[key])); -}; - -const HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/; - -const normalizeIconBackground = (value: unknown): string | null => { - if (typeof value !== 'string') { - return null; - } - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - return HEX_COLOR_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null; -}; - -const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefined => { - if (!Array.isArray(value)) { - return undefined; - } - - const result: NonNullable = []; - const seenIds = new Set(); - const seenPaths = new Set(); - - for (const entry of value) { - if (!entry || typeof entry !== 'object') continue; - const candidate = entry as Record; - - const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : ''; - if (!rawPath) continue; - - const normalizedPath = rawPath === '/' ? rawPath : rawPath.replace(/\\/g, '/').replace(/\/+$/, ''); - if (!normalizedPath) continue; - - const id = createProjectIdFromPath(normalizedPath); - if (!id) continue; - - if (seenIds.has(id) || seenPaths.has(normalizedPath)) continue; - seenIds.add(id); - seenPaths.add(normalizedPath); - - const project: NonNullable[number] = { - id, - path: normalizedPath, - }; - - if (typeof candidate.label === 'string' && candidate.label.trim().length > 0) { - project.label = candidate.label.trim(); - } - if (typeof candidate.icon === 'string' && candidate.icon.trim().length > 0) { - project.icon = candidate.icon.trim(); - } - if (candidate.iconImage === null) { - project.iconImage = null; - } else if (candidate.iconImage && typeof candidate.iconImage === 'object') { - const iconImage = candidate.iconImage as Record; - const mime = typeof iconImage.mime === 'string' ? iconImage.mime.trim() : ''; - const updatedAt = typeof iconImage.updatedAt === 'number' && Number.isFinite(iconImage.updatedAt) - ? Math.max(0, Math.round(iconImage.updatedAt)) - : 0; - const source = iconImage.source === 'custom' || iconImage.source === 'auto' - ? iconImage.source - : null; - if (mime && updatedAt > 0 && source) { - project.iconImage = { mime, updatedAt, source }; - } - } - if (typeof candidate.color === 'string' && candidate.color.trim().length > 0) { - project.color = candidate.color.trim(); - } - if (candidate.iconBackground === null) { - project.iconBackground = null; - } else { - const iconBackground = normalizeIconBackground(candidate.iconBackground); - if (iconBackground) { - project.iconBackground = iconBackground; - } - } - if (typeof candidate.addedAt === 'number' && Number.isFinite(candidate.addedAt) && candidate.addedAt >= 0) { - project.addedAt = candidate.addedAt; - } - if ( - typeof candidate.lastOpenedAt === 'number' && - Number.isFinite(candidate.lastOpenedAt) && - candidate.lastOpenedAt >= 0 - ) { - project.lastOpenedAt = candidate.lastOpenedAt; - } - if (typeof candidate.sidebarCollapsed === 'boolean') { - project.sidebarCollapsed = candidate.sidebarCollapsed; - } - result.push(project); - } - - return result.length > 0 ? result : undefined; -}; - -const sanitizeManagedRemoteTunnelPresets = (value: unknown): DesktopSettings['managedRemoteTunnelPresets'] | undefined => { - if (!Array.isArray(value)) { - return undefined; - } - - const result: NonNullable = []; - const seenIds = new Set(); - const seenHostnames = new Set(); - - for (const entry of value) { - if (!entry || typeof entry !== 'object') continue; - const candidate = entry as Record; - - const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; - const name = typeof candidate.name === 'string' ? candidate.name.trim() : ''; - const hostname = typeof candidate.hostname === 'string' ? candidate.hostname.trim().toLowerCase() : ''; - - if (!id || !name || !hostname) continue; - if (seenIds.has(id) || seenHostnames.has(hostname)) continue; - seenIds.add(id); - seenHostnames.add(hostname); - - result.push({ id, name, hostname }); - } - - return result; -}; - -const sanitizeManagedRemoteTunnelPresetTokens = (value: unknown): DesktopSettings['managedRemoteTunnelPresetTokens'] | undefined => { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return undefined; - } - - const candidate = value as Record; - const result: Record = {}; - for (const [key, tokenValue] of Object.entries(candidate)) { - const id = key.trim(); - const token = typeof tokenValue === 'string' ? tokenValue.trim() : ''; - if (!id || !token) continue; - result[id] = token; - } - - return Object.keys(result).length > 0 ? result : undefined; -}; - -const sanitizeModelRefs = (value: unknown, limit: number): Array<{ providerID: string; modelID: string }> | undefined => { - if (!Array.isArray(value)) { - return undefined; - } - - const result: Array<{ providerID: string; modelID: string }> = []; - const seen = new Set(); - - for (const entry of value) { - if (!entry || typeof entry !== 'object') continue; - const candidate = entry as Record; - const providerID = typeof candidate.providerID === 'string' ? candidate.providerID.trim() : ''; - const modelID = typeof candidate.modelID === 'string' ? candidate.modelID.trim() : ''; - if (!providerID || !modelID) continue; - const key = `${providerID}/${modelID}`; - if (seen.has(key)) continue; - seen.add(key); - result.push({ providerID, modelID }); - if (result.length >= limit) break; - } - - return result; -}; - const getPersistApi = (): PersistApi | undefined => { const candidate = useUIStore.persist; if (candidate && typeof candidate === 'object') { @@ -587,1217 +288,19 @@ const getPersistApi = (): PersistApi | undefined => { const getRuntimeSettingsAPI = () => getRegisteredRuntimeAPIs()?.settings ?? null; -const materializeAuthoritativeUiSettings = (settings: DesktopSettings): DesktopSettings => { - const defaults = useUIStore.getInitialState(); +const settingsEndpointForSurface = (): string => `/api/config/settings?${SETTINGS_SURFACE_QUERY}=${getSettingsSurface()}`; - return { - // Theme fields are deliberately NOT defaulted: the theme authority is the - // ThemeSystemContext (scoped per-runtime entry + bootstrap syncs). A - // server document without theme fields means "not set" — inventing - // defaults here would clobber the window's theme and write it back to the - // server. Absent fields keep the current preferences. - openInAppId: DEFAULT_OPEN_IN_APP_ID, - showReasoningTraces: defaults.showReasoningTraces, - streamingAutoFollowEnabled: defaults.streamingAutoFollowEnabled, - workStatusPanelEnabled: defaults.workStatusPanelEnabled, - workStatusHiddenSections: defaults.workStatusHiddenSections, - sessionRecapEnabled: defaults.sessionRecapEnabled, - sessionSuggestionEnabled: defaults.sessionSuggestionEnabled, - sessionGoalEnabled: defaults.sessionGoalEnabled, - sessionGoalDefaultBudgetEnabled: defaults.sessionGoalDefaultBudgetEnabled, - sessionGoalDefaultBudget: defaults.sessionGoalDefaultBudget, - collapsibleThinkingBlocks: defaults.collapsibleThinkingBlocks, - autoDeleteEnabled: defaults.autoDeleteEnabled, - autoSaveEnabled: defaults.autoSaveEnabled, - autoDeleteAfterDays: defaults.autoDeleteAfterDays, - sessionRetentionAction: defaults.sessionRetentionAction, - followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR, - showDeletionDialog: defaults.showDeletionDialog, - nativeNotificationsEnabled: defaults.nativeNotificationsEnabled, - notificationMode: defaults.notificationMode, - notifyOnSubtasks: defaults.notifyOnSubtasks, - notifyOnCompletion: defaults.notifyOnCompletion, - notifyOnError: defaults.notifyOnError, - notifyOnQuestion: defaults.notifyOnQuestion, - notificationTemplates: defaults.notificationTemplates, - summarizeLastMessage: defaults.summarizeLastMessage, - summaryThreshold: defaults.summaryThreshold, - summaryLength: defaults.summaryLength, - maxLastMessageLength: defaults.maxLastMessageLength, - inputSpellcheckEnabled: defaults.inputSpellcheckEnabled, - enterToSend: defaults.enterToSend, - enterToSendConfigured: defaults.enterToSendConfigured, - showOpenCodeUpdateNotifications: defaults.showOpenCodeUpdateNotifications, - agentControlToolEnabled: defaults.agentControlToolEnabled, - agentWebToolEnabled: defaults.agentWebToolEnabled, - agentMemoryToolEnabled: defaults.agentMemoryToolEnabled, - showToolFileIcons: defaults.showToolFileIcons, - codeBlockLineWrap: defaults.codeBlockLineWrap, - showTurnChangedFiles: defaults.showTurnChangedFiles, - showExpandedBashTools: defaults.showExpandedBashTools, - showExpandedEditTools: defaults.showExpandedEditTools, - timeFormatPreference: defaults.timeFormatPreference, - weekStartPreference: defaults.weekStartPreference, - desktopWindowControlsPosition: defaults.desktopWindowControlsPosition, - desktopWindowControlsStyle: defaults.desktopWindowControlsStyle, - chatRenderMode: defaults.chatRenderMode, - activityRenderMode: defaults.activityRenderMode, - mermaidRenderingMode: defaults.mermaidRenderingMode, - userMessageRenderingMode: defaults.userMessageRenderingMode, - collapsibleUserMessages: defaults.collapsibleUserMessages, - messageStreamTransport: 'auto', - inputHistoryScope: DEFAULT_INPUT_HISTORY_SCOPE, - inputHistoryLimit: DEFAULT_INPUT_HISTORY_LIMIT, - stickyUserHeader: defaults.stickyUserHeader, - promptNavigatorEnabled: defaults.promptNavigatorEnabled, - wideChatLayoutEnabled: defaults.wideChatLayoutEnabled, - showSplitAssistantMessageActions: defaults.showSplitAssistantMessageActions, - draftStartersVisible: defaults.draftStartersVisible, - reportUsage: defaults.reportUsage, - fontSize: defaults.fontSize, - terminalFontSize: defaults.terminalFontSize, - terminalShell: defaults.terminalShell, - terminalLoginShells: defaults.terminalLoginShells, - editorFontSize: defaults.editorFontSize, - uiFont: defaults.uiFont, - monoFont: defaults.monoFont, - padding: defaults.padding, - cornerRadius: defaults.cornerRadius, - inputBarOffset: defaults.inputBarOffset, - shortcutOverrides: defaults.shortcutOverrides, - mobileKeyboardMode: 'resize-content', - favoriteModels: defaults.favoriteModels, - hiddenModels: defaults.hiddenModels, - collapsedModelProviders: defaults.collapsedModelProviders, - recentModels: defaults.recentModels, - recentAgents: defaults.recentAgents, - recentEfforts: defaults.recentEfforts, - diffLayoutPreference: defaults.diffLayoutPreference, - gitChangesViewMode: defaults.gitChangesViewMode, - toolJsonViewMode: defaults.toolJsonViewMode, - directoryShowHidden: true, - filesViewShowGitignored: false, - dictationEnabled: true, - sttProvider: 'local', - sttServerUrl: 'http://localhost:8001/v1', - sttModel: 'deepdml/faster-whisper-large-v3-turbo-ct2', - sttLocalModel: 'parakeet-tdt-0.6b-v2-int8', - sttLanguage: '', - ...settings, - }; +/** Copy a parsed snapshot into the live stores. Omitted keys stay as they are. */ +const applyDesktopUiPreferences = (settings: DesktopSettings): void => { + applySettingsToStores(settings); }; -const applyDesktopUiPreferences = (settings: DesktopSettings) => { - const store = useUIStore.getState(); - const configStore = typeof window !== 'undefined' - ? window.__zustand_config_store__?.getState?.() ?? null - : null; - const configStoreApi = typeof window !== 'undefined' - ? window.__zustand_config_store__ ?? null - : null; - const queueStore = useMessageQueueStore.getState(); - const inputHistoryStore = useInputHistoryStore.getState(); - - if (typeof settings.workStatusPanelEnabled === 'boolean' - && settings.workStatusPanelEnabled !== store.workStatusPanelEnabled) { - store.setWorkStatusPanelEnabled(settings.workStatusPanelEnabled); - } - if (Array.isArray(settings.workStatusHiddenSections)) { - const next = sanitizeWorkStatusHiddenSections(settings.workStatusHiddenSections); - if (next.join('\u0000') !== store.workStatusHiddenSections.join('\u0000')) { - store.setWorkStatusHiddenSections(next); - } - } - if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) { - store.setShowReasoningTraces(settings.showReasoningTraces); - } - if (typeof settings.streamingAutoFollowEnabled === 'boolean' && settings.streamingAutoFollowEnabled !== store.streamingAutoFollowEnabled) { - store.setStreamingAutoFollowEnabled(settings.streamingAutoFollowEnabled); - } - if (typeof settings.sessionRecapEnabled === 'boolean' && settings.sessionRecapEnabled !== store.sessionRecapEnabled) { - store.setSessionRecapEnabled(settings.sessionRecapEnabled); - } - if (typeof settings.sessionSuggestionEnabled === 'boolean' && settings.sessionSuggestionEnabled !== store.sessionSuggestionEnabled) { - store.setSessionSuggestionEnabled(settings.sessionSuggestionEnabled); - } - if (typeof settings.sessionGoalEnabled === 'boolean' && settings.sessionGoalEnabled !== store.sessionGoalEnabled) { - store.setSessionGoalEnabled(settings.sessionGoalEnabled); - } - if (typeof settings.sessionGoalDefaultBudgetEnabled === 'boolean' && settings.sessionGoalDefaultBudgetEnabled !== store.sessionGoalDefaultBudgetEnabled) { - store.setSessionGoalDefaultBudgetEnabled(settings.sessionGoalDefaultBudgetEnabled); - } - if (typeof settings.sessionGoalDefaultBudget === 'number' && Number.isFinite(settings.sessionGoalDefaultBudget) && settings.sessionGoalDefaultBudget !== store.sessionGoalDefaultBudget) { - store.setSessionGoalDefaultBudget(settings.sessionGoalDefaultBudget); - } - if (typeof settings.collapsibleThinkingBlocks === 'boolean' && settings.collapsibleThinkingBlocks !== store.collapsibleThinkingBlocks) { - store.setCollapsibleThinkingBlocks(settings.collapsibleThinkingBlocks); - } - if (typeof settings.autoDeleteEnabled === 'boolean' && settings.autoDeleteEnabled !== store.autoDeleteEnabled) { - store.setAutoDeleteEnabled(settings.autoDeleteEnabled); - } - if (typeof settings.autoSaveEnabled === 'boolean' && settings.autoSaveEnabled !== store.autoSaveEnabled) { - store.setAutoSaveEnabled(settings.autoSaveEnabled); - } - if (typeof settings.autoDeleteAfterDays === 'number' && Number.isFinite(settings.autoDeleteAfterDays)) { - const normalized = Math.max(1, Math.min(365, settings.autoDeleteAfterDays)); - if (normalized !== store.autoDeleteAfterDays) { - store.setAutoDeleteAfterDays(normalized); - } - } - if (settings.sessionRetentionAction === 'archive' || settings.sessionRetentionAction === 'delete') { - if (settings.sessionRetentionAction !== store.sessionRetentionAction) { - store.setSessionRetentionAction(settings.sessionRetentionAction); - } - } - - let nextFollowUpBehavior: FollowUpBehavior | null = null; - if (isFollowUpBehavior(settings.followUpBehavior)) { - nextFollowUpBehavior = settings.followUpBehavior; - } else if (typeof settings.queueModeEnabled === 'boolean') { - nextFollowUpBehavior = normalizeFollowUpBehavior(undefined, settings.queueModeEnabled); - } - if (nextFollowUpBehavior && nextFollowUpBehavior !== queueStore.followUpBehavior) { - queueStore.setFollowUpBehavior(nextFollowUpBehavior); - } - - if (typeof settings.showDeletionDialog === 'boolean' && settings.showDeletionDialog !== store.showDeletionDialog) { - store.setShowDeletionDialog(settings.showDeletionDialog); - } - if (typeof settings.nativeNotificationsEnabled === 'boolean' && settings.nativeNotificationsEnabled !== store.nativeNotificationsEnabled) { - store.setNativeNotificationsEnabled(settings.nativeNotificationsEnabled); - } - if (typeof settings.notificationMode === 'string' && (settings.notificationMode === 'always' || settings.notificationMode === 'hidden-only')) { - if (settings.notificationMode !== store.notificationMode) { - store.setNotificationMode(settings.notificationMode); - } - } - if (typeof settings.notifyOnSubtasks === 'boolean' && settings.notifyOnSubtasks !== store.notifyOnSubtasks) { - store.setNotifyOnSubtasks(settings.notifyOnSubtasks); - } - if (typeof settings.notifyOnCompletion === 'boolean' && settings.notifyOnCompletion !== store.notifyOnCompletion) { - store.setNotifyOnCompletion(settings.notifyOnCompletion); - } - if (typeof settings.notifyOnError === 'boolean' && settings.notifyOnError !== store.notifyOnError) { - store.setNotifyOnError(settings.notifyOnError); - } - if (typeof settings.notifyOnQuestion === 'boolean' && settings.notifyOnQuestion !== store.notifyOnQuestion) { - store.setNotifyOnQuestion(settings.notifyOnQuestion); - } - if (settings.notificationTemplates && typeof settings.notificationTemplates === 'object') { - store.setNotificationTemplates(settings.notificationTemplates); - } - if (typeof settings.summarizeLastMessage === 'boolean' && settings.summarizeLastMessage !== store.summarizeLastMessage) { - store.setSummarizeLastMessage(settings.summarizeLastMessage); - } - if (typeof settings.summaryThreshold === 'number' && Number.isFinite(settings.summaryThreshold)) { - store.setSummaryThreshold(settings.summaryThreshold); - } - if (typeof settings.summaryLength === 'number' && Number.isFinite(settings.summaryLength)) { - store.setSummaryLength(settings.summaryLength); - } - if (typeof settings.maxLastMessageLength === 'number' && Number.isFinite(settings.maxLastMessageLength)) { - store.setMaxLastMessageLength(settings.maxLastMessageLength); - } - if (typeof settings.inputSpellcheckEnabled === 'boolean' && settings.inputSpellcheckEnabled !== store.inputSpellcheckEnabled) { - store.setInputSpellcheckEnabled(settings.inputSpellcheckEnabled); - } - if (settings.enterToSend === true || settings.enterToSend === false) { - if (settings.enterToSend !== store.enterToSend) { - store.setEnterToSend(settings.enterToSend); - } - } - if (settings.enterToSendConfigured === true || settings.enterToSendConfigured === false) { - if (settings.enterToSendConfigured !== store.enterToSendConfigured) { - store.setEnterToSendConfigured(settings.enterToSendConfigured); - } - } - if ( - typeof settings.showOpenCodeUpdateNotifications === 'boolean' - && settings.showOpenCodeUpdateNotifications !== store.showOpenCodeUpdateNotifications - ) { - store.setShowOpenCodeUpdateNotifications(settings.showOpenCodeUpdateNotifications); - } - if ( - typeof settings.agentControlToolEnabled === 'boolean' - && settings.agentControlToolEnabled !== store.agentControlToolEnabled - ) { - store.setAgentControlToolEnabled(settings.agentControlToolEnabled); - } - if ( - typeof settings.agentWebToolEnabled === 'boolean' - && settings.agentWebToolEnabled !== store.agentWebToolEnabled - ) { - store.setAgentWebToolEnabled(settings.agentWebToolEnabled); - } - if ( - typeof settings.agentMemoryToolEnabled === 'boolean' - && settings.agentMemoryToolEnabled !== store.agentMemoryToolEnabled - ) { - store.setAgentMemoryToolEnabled(settings.agentMemoryToolEnabled); - } - // Server-owned: it says whether this build has the feature at all. - if ( - typeof settings.agentMemoryFeatureAvailable === 'boolean' - && settings.agentMemoryFeatureAvailable !== store.agentMemoryFeatureAvailable - ) { - store.setAgentMemoryFeatureAvailable(settings.agentMemoryFeatureAvailable); - } - if (typeof settings.showToolFileIcons === 'boolean' && settings.showToolFileIcons !== store.showToolFileIcons) { - store.setShowToolFileIcons(settings.showToolFileIcons); - } - if (typeof settings.codeBlockLineWrap === 'boolean' && settings.codeBlockLineWrap !== store.codeBlockLineWrap) { - store.setCodeBlockLineWrap(settings.codeBlockLineWrap); - } - if (typeof settings.showTurnChangedFiles === 'boolean' && settings.showTurnChangedFiles !== store.showTurnChangedFiles) { - store.setShowTurnChangedFiles(settings.showTurnChangedFiles); - } - if (typeof settings.showExpandedBashTools === 'boolean' && settings.showExpandedBashTools !== store.showExpandedBashTools) { - store.setShowExpandedBashTools(settings.showExpandedBashTools); - } - if (typeof settings.showExpandedEditTools === 'boolean' && settings.showExpandedEditTools !== store.showExpandedEditTools) { - store.setShowExpandedEditTools(settings.showExpandedEditTools); - } - if (typeof settings.timeFormatPreference === 'string' - && (settings.timeFormatPreference === 'auto' || settings.timeFormatPreference === '12h' || settings.timeFormatPreference === '24h')) { - if (settings.timeFormatPreference !== store.timeFormatPreference) { - store.setTimeFormatPreference(settings.timeFormatPreference); - } - } - if (typeof settings.weekStartPreference === 'string' - && (settings.weekStartPreference === 'auto' || settings.weekStartPreference === 'sunday' || settings.weekStartPreference === 'monday')) { - if (settings.weekStartPreference !== store.weekStartPreference) { - store.setWeekStartPreference(settings.weekStartPreference); - } - } - if (typeof settings.desktopWindowControlsPosition === 'string') { - const nextPosition = settings.desktopWindowControlsPosition === 'left' - ? 'left' - : (settings.desktopWindowControlsPosition === 'right' || settings.desktopWindowControlsPosition === 'auto') - ? 'right' - : null; - if (nextPosition && nextPosition !== store.desktopWindowControlsPosition) { - store.setDesktopWindowControlsPosition(nextPosition); - } - } - if (typeof settings.desktopWindowControlsStyle === 'string') { - const nextStyle = settings.desktopWindowControlsStyle === 'traffic-lights' - ? 'traffic-lights' - : settings.desktopWindowControlsStyle === 'classic' - ? 'classic' - : null; - if (nextStyle && nextStyle !== store.desktopWindowControlsStyle) { - store.setDesktopWindowControlsStyle(nextStyle); - } - } - if (typeof settings.chatRenderMode === 'string' - && (settings.chatRenderMode === 'sorted' || settings.chatRenderMode === 'live')) { - if (settings.chatRenderMode !== store.chatRenderMode) { - store.setChatRenderMode(settings.chatRenderMode); - } - } - if (typeof settings.activityRenderMode === 'string' - && (settings.activityRenderMode === 'collapsed' || settings.activityRenderMode === 'summary')) { - if (settings.activityRenderMode !== store.activityRenderMode) { - store.setActivityRenderMode(settings.activityRenderMode); - } - } - if (typeof settings.mermaidRenderingMode === 'string' - && (settings.mermaidRenderingMode === 'svg' || settings.mermaidRenderingMode === 'ascii')) { - if (settings.mermaidRenderingMode !== store.mermaidRenderingMode) { - store.setMermaidRenderingMode(settings.mermaidRenderingMode); - } - } - if (typeof settings.userMessageRenderingMode === 'string' - && (settings.userMessageRenderingMode === 'markdown' || settings.userMessageRenderingMode === 'plain')) { - if (settings.userMessageRenderingMode !== store.userMessageRenderingMode) { - store.setUserMessageRenderingMode(settings.userMessageRenderingMode); - } - } - if (typeof settings.collapsibleUserMessages === 'boolean' && settings.collapsibleUserMessages !== store.collapsibleUserMessages) { - store.setCollapsibleUserMessages(settings.collapsibleUserMessages); - } - if (typeof settings.messageStreamTransport === 'string' - && (settings.messageStreamTransport === 'auto' || settings.messageStreamTransport === 'ws' || settings.messageStreamTransport === 'sse')) { - if (configStore && settings.messageStreamTransport !== configStore.settingsMessageStreamTransport) { - configStore.setSettingsMessageStreamTransport(settings.messageStreamTransport); - } - } - if ( - typeof settings.inputHistoryScope === 'string' - && isInputHistoryScope(settings.inputHistoryScope) - && settings.inputHistoryScope !== inputHistoryStore.scope - ) { - inputHistoryStore.applyScope(settings.inputHistoryScope); - } - if (isInputHistoryLimit(settings.inputHistoryLimit) && settings.inputHistoryLimit !== inputHistoryStore.entryLimit) { - inputHistoryStore.applyEntryLimit(settings.inputHistoryLimit); - } - if (typeof settings.stickyUserHeader === 'boolean' && settings.stickyUserHeader !== store.stickyUserHeader) { - store.setStickyUserHeader(settings.stickyUserHeader); - } - if (typeof settings.promptNavigatorEnabled === 'boolean' && settings.promptNavigatorEnabled !== store.promptNavigatorEnabled) { - store.setPromptNavigatorEnabled(settings.promptNavigatorEnabled); - } - if (typeof settings.wideChatLayoutEnabled === 'boolean' && settings.wideChatLayoutEnabled !== store.wideChatLayoutEnabled) { - store.setWideChatLayoutEnabled(settings.wideChatLayoutEnabled); - } - if ( - typeof settings.showSplitAssistantMessageActions === 'boolean' - && settings.showSplitAssistantMessageActions !== store.showSplitAssistantMessageActions - ) { - store.setShowSplitAssistantMessageActions(settings.showSplitAssistantMessageActions); - } - if (typeof settings.reportUsage === 'boolean' && settings.reportUsage !== store.reportUsage) { - store.setReportUsage(settings.reportUsage); - } - if (typeof settings.fontSize === 'number' && Number.isFinite(settings.fontSize) && settings.fontSize !== store.fontSize) { - store.setFontSize(settings.fontSize); - } - if (Array.isArray(settings.draftStarters)) { - let nextStarters = sanitizeStarterRefs(settings.draftStarters); - if (settings.draftStartersCraftGoalAdded !== true && !nextStarters.some((starter) => starter.type === 'command' && starter.name === 'craft-goal')) { - const planIndex = nextStarters.findIndex((starter) => starter.type === 'command' && starter.name === 'plan-feature'); - const insertAt = planIndex >= 0 ? planIndex + 1 : nextStarters.length; - nextStarters = [ - ...nextStarters.slice(0, insertAt), - { type: 'command', name: 'craft-goal' }, - ...nextStarters.slice(insertAt), - ]; - } - if (settings.draftStartersScheduleTaskAdded !== true && !nextStarters.some((starter) => starter.type === 'command' && starter.name === 'schedule-task')) { - const goalIndex = nextStarters.findIndex((starter) => starter.type === 'command' && starter.name === 'craft-goal'); - const insertAt = goalIndex >= 0 ? goalIndex + 1 : nextStarters.length; - nextStarters = [ - ...nextStarters.slice(0, insertAt), - { type: 'command', name: 'schedule-task' }, - ...nextStarters.slice(insertAt), - ]; - } - if (JSON.stringify(store.globalDraftStarters) !== JSON.stringify(nextStarters)) { - store.setGlobalDraftStarters(nextStarters); - } - if (settings.draftStartersCraftGoalAdded !== true || settings.draftStartersScheduleTaskAdded !== true) { - settings.draftStarters = nextStarters; - settings.draftStartersCraftGoalAdded = true; - settings.draftStartersScheduleTaskAdded = true; - } - } else { - // The built-in default already contains Craft a Goal and Schedule a Task; - // only persist the markers so removing them later remains a durable user - // choice. - if (settings.draftStartersCraftGoalAdded !== true) { - settings.draftStartersCraftGoalAdded = true; - } - if (settings.draftStartersScheduleTaskAdded !== true) { - settings.draftStartersScheduleTaskAdded = true; - } - } - if (typeof settings.draftStartersVisible === 'boolean' && settings.draftStartersVisible !== store.draftStartersVisible) { - store.setDraftStartersVisible(settings.draftStartersVisible); - } - if (typeof settings.terminalFontSize === 'number' && Number.isFinite(settings.terminalFontSize) && settings.terminalFontSize !== store.terminalFontSize) { - store.setTerminalFontSize(settings.terminalFontSize); - } - if (isTerminalShell(settings.terminalShell) && settings.terminalShell !== store.terminalShell) { - store.setTerminalShell(settings.terminalShell); - } - if ( - Array.isArray(settings.terminalLoginShells) - && ( - settings.terminalLoginShells.length !== store.terminalLoginShells.length - || settings.terminalLoginShells.some((shell, index) => shell !== store.terminalLoginShells[index]) - ) - ) { - store.setTerminalLoginShells(settings.terminalLoginShells); - } - if (typeof settings.editorFontSize === 'number' && Number.isFinite(settings.editorFontSize) && settings.editorFontSize !== store.editorFontSize) { - store.setEditorFontSize(settings.editorFontSize); - } - if (isUiFontOption(settings.uiFont) && settings.uiFont !== store.uiFont) { - store.setUiFont(settings.uiFont); - } - if (isMonoFontOption(settings.monoFont) && settings.monoFont !== store.monoFont) { - store.setMonoFont(settings.monoFont); - } - if (typeof settings.padding === 'number' && Number.isFinite(settings.padding) && settings.padding !== store.padding) { - store.setPadding(settings.padding); - } - if (typeof settings.cornerRadius === 'number' && Number.isFinite(settings.cornerRadius) && settings.cornerRadius !== store.cornerRadius) { - store.setCornerRadius(settings.cornerRadius); - } - if (typeof settings.inputBarOffset === 'number' && Number.isFinite(settings.inputBarOffset) && settings.inputBarOffset !== store.inputBarOffset) { - store.setInputBarOffset(settings.inputBarOffset); - } - if (settings.shortcutOverrides && !areStringRecordsEqual(settings.shortcutOverrides, store.shortcutOverrides)) { - useUIStore.setState({ shortcutOverrides: settings.shortcutOverrides }); - } - if (typeof settings.mobileKeyboardMode === 'string') { - const mode = normalizeMobileKeyboardMode(settings.mobileKeyboardMode, store.mobileKeyboardMode); - if (mode !== store.mobileKeyboardMode) { - store.setMobileKeyboardMode(mode); - } - } - if (configStoreApi && configStore) { - const nextConfigState: Partial = {}; - if (typeof settings.dictationEnabled === 'boolean' && settings.dictationEnabled !== configStore.dictationEnabled) { - nextConfigState.dictationEnabled = settings.dictationEnabled; - } - if ((settings.sttProvider === 'local' || settings.sttProvider === 'openai-compatible') && settings.sttProvider !== configStore.sttProvider) { - nextConfigState.sttProvider = settings.sttProvider; - } - if (typeof settings.sttServerUrl === 'string' && settings.sttServerUrl !== configStore.sttServerUrl) { - nextConfigState.sttServerUrl = settings.sttServerUrl; - } - if (typeof settings.sttModel === 'string' && settings.sttModel !== configStore.sttModel) { - nextConfigState.sttModel = settings.sttModel; - } - if (typeof settings.sttLocalModel === 'string' && settings.sttLocalModel !== configStore.sttLocalModel) { - nextConfigState.sttLocalModel = settings.sttLocalModel; - } - if (typeof settings.sttLanguage === 'string' && settings.sttLanguage !== configStore.sttLanguage) { - nextConfigState.sttLanguage = settings.sttLanguage; - } - if (Object.keys(nextConfigState).length > 0) { - configStoreApi.setState(nextConfigState); - } - } - - if (Array.isArray(settings.favoriteModels)) { - const current = store.favoriteModels; - const next = settings.favoriteModels; - if (!areModelRefsEqual(current, next)) { - useUIStore.setState({ favoriteModels: next }); - } - } - - if (Array.isArray(settings.hiddenModels)) { - const current = store.hiddenModels; - const next = settings.hiddenModels; - if (!areModelRefsEqual(current, next)) { - useUIStore.setState({ hiddenModels: next }); - } - } - - if (Array.isArray(settings.collapsedModelProviders)) { - const current = store.collapsedModelProviders; - const next = settings.collapsedModelProviders; - if (!areStringArraysEqual(current, next)) { - useUIStore.setState({ collapsedModelProviders: next }); - } - } - - if (Array.isArray(settings.recentModels)) { - const current = store.recentModels; - const next = settings.recentModels; - if (!areModelRefsEqual(current, next)) { - useUIStore.setState({ recentModels: next }); - } - } - - if (Array.isArray(settings.recentAgents)) { - const current = store.recentAgents; - const next = settings.recentAgents; - if (!areStringArraysEqual(current, next)) { - useUIStore.setState({ recentAgents: next }); - } - } - - if (settings.recentEfforts && typeof settings.recentEfforts === 'object') { - const current = store.recentEfforts; - const next = settings.recentEfforts; - if (!areRecentEffortsEqual(current, next)) { - useUIStore.setState({ recentEfforts: next }); - } - } - if (typeof settings.diffLayoutPreference === 'string' - && (settings.diffLayoutPreference === 'dynamic' || settings.diffLayoutPreference === 'inline' || settings.diffLayoutPreference === 'side-by-side')) { - if (settings.diffLayoutPreference !== store.diffLayoutPreference) { - store.setDiffLayoutPreference(settings.diffLayoutPreference); - } - } - if (typeof settings.gitChangesViewMode === 'string' - && (settings.gitChangesViewMode === 'flat' || settings.gitChangesViewMode === 'tree')) { - if (settings.gitChangesViewMode !== store.gitChangesViewMode) { - store.setGitChangesViewMode(settings.gitChangesViewMode); - } - } - if (typeof settings.toolJsonViewMode === 'string' - && (settings.toolJsonViewMode === 'summary' || settings.toolJsonViewMode === 'formatted' || settings.toolJsonViewMode === 'raw')) { - if (settings.toolJsonViewMode !== store.toolJsonViewMode) { - store.setToolJsonViewMode(settings.toolJsonViewMode); - } - } - if (typeof settings.directoryShowHidden === 'boolean') { - setDirectoryShowHidden(settings.directoryShowHidden, { persist: false }); - } - if (typeof settings.filesViewShowGitignored === 'boolean') { - setFilesViewShowGitignored(settings.filesViewShowGitignored, { persist: false }); - } - const sessionDisplayChanges: Partial> = {}; - if (settings.sidebarProjectDisplayMode === 'all' || settings.sidebarProjectDisplayMode === 'single') { - sessionDisplayChanges.projectDisplayMode = settings.sidebarProjectDisplayMode; - } - if (settings.sidebarSessionGroupingMode === 'by-worktree' || settings.sidebarSessionGroupingMode === 'flat') { - sessionDisplayChanges.sessionGroupingMode = settings.sidebarSessionGroupingMode; - } - if (settings.sidebarProjectSortOrder === 'manual' - || settings.sidebarProjectSortOrder === 'a-z' - || settings.sidebarProjectSortOrder === 'z-a' - || settings.sidebarProjectSortOrder === 'date-added' - || settings.sidebarProjectSortOrder === 'recent') { - sessionDisplayChanges.projectSortOrder = settings.sidebarProjectSortOrder; - } - if (typeof settings.sidebarShowRecentSection === 'boolean') { - sessionDisplayChanges.showRecentSection = settings.sidebarShowRecentSection; - } - if (Object.keys(sessionDisplayChanges).length > 0) { - useSessionDisplayStore.setState(sessionDisplayChanges); - } -}; - -const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { - if (!payload || typeof payload !== 'object') { - return null; - } - - const candidate = payload as Record; - const result: DesktopSettings = {}; - - if (typeof candidate.themeId === 'string' && candidate.themeId.length > 0) { - result.themeId = candidate.themeId; - } - if (candidate.useSystemTheme === true || candidate.useSystemTheme === false) { - result.useSystemTheme = candidate.useSystemTheme; - } - if (typeof candidate.themeVariant === 'string' && (candidate.themeVariant === 'light' || candidate.themeVariant === 'dark')) { - result.themeVariant = candidate.themeVariant; - } - if (typeof candidate.lightThemeId === 'string' && candidate.lightThemeId.length > 0) { - result.lightThemeId = candidate.lightThemeId; - } - if (typeof candidate.darkThemeId === 'string' && candidate.darkThemeId.length > 0) { - result.darkThemeId = candidate.darkThemeId; - } - if (typeof candidate.lastDirectory === 'string' && candidate.lastDirectory.length > 0) { - result.lastDirectory = candidate.lastDirectory; - } - if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) { - result.homeDirectory = candidate.homeDirectory; - } - - if (typeof candidate.opencodeBinary === 'string') { - const trimmed = candidate.opencodeBinary.trim(); - result.opencodeBinary = trimmed.length > 0 ? trimmed : undefined; - } - if (typeof candidate.desktopLanAccessEnabled === 'boolean') { - result.desktopLanAccessEnabled = candidate.desktopLanAccessEnabled; - } - if (typeof candidate.desktopKeepAwakeEnabled === 'boolean') { - result.desktopKeepAwakeEnabled = candidate.desktopKeepAwakeEnabled; - } - if (typeof candidate.desktopMinimizeToTrayEnabled === 'boolean') { - result.desktopMinimizeToTrayEnabled = candidate.desktopMinimizeToTrayEnabled; - } - if (typeof candidate.desktopMacMenuBarEnabled === 'boolean') { - result.desktopMacMenuBarEnabled = candidate.desktopMacMenuBarEnabled; - } - - const projects = sanitizeProjects(candidate.projects); - if (projects) { - result.projects = projects; - } - if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) { - result.activeProjectId = candidate.activeProjectId; - } - if (candidate.sidebarProjectDisplayMode === 'all' || candidate.sidebarProjectDisplayMode === 'single') { - result.sidebarProjectDisplayMode = candidate.sidebarProjectDisplayMode; - } - if (candidate.sidebarSessionGroupingMode === 'by-worktree' || candidate.sidebarSessionGroupingMode === 'flat') { - result.sidebarSessionGroupingMode = candidate.sidebarSessionGroupingMode; - } - if (candidate.sidebarProjectSortOrder === 'manual' - || candidate.sidebarProjectSortOrder === 'a-z' - || candidate.sidebarProjectSortOrder === 'z-a' - || candidate.sidebarProjectSortOrder === 'date-added' - || candidate.sidebarProjectSortOrder === 'recent') { - result.sidebarProjectSortOrder = candidate.sidebarProjectSortOrder; - } - if (typeof candidate.sidebarShowRecentSection === 'boolean') { - result.sidebarShowRecentSection = candidate.sidebarShowRecentSection; - } - - if (Array.isArray(candidate.securityScopedBookmarks)) { - result.securityScopedBookmarks = candidate.securityScopedBookmarks.filter( - (entry): entry is string => typeof entry === 'string' && entry.length > 0 - ); - } - if (Array.isArray(candidate.pinnedDirectories)) { - result.pinnedDirectories = Array.from( - new Set( - candidate.pinnedDirectories.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0) - ) - ); - } - if (Array.isArray(candidate.draftStarters)) { - result.draftStarters = sanitizeStarterRefs(candidate.draftStarters); - } - if (typeof candidate.draftStartersVisible === 'boolean') { - result.draftStartersVisible = candidate.draftStartersVisible; - } - if (typeof candidate.draftStartersCraftGoalAdded === 'boolean') { - result.draftStartersCraftGoalAdded = candidate.draftStartersCraftGoalAdded; - } - if (typeof candidate.draftStartersScheduleTaskAdded === 'boolean') { - result.draftStartersScheduleTaskAdded = candidate.draftStartersScheduleTaskAdded; - } - if (typeof candidate.workStatusPanelEnabled === 'boolean') { - result.workStatusPanelEnabled = candidate.workStatusPanelEnabled; - } - if (Array.isArray(candidate.workStatusHiddenSections)) { - // Unknown ids are dropped rather than kept: they would hide nothing and - // accumulate forever as sections get renamed. - result.workStatusHiddenSections = sanitizeWorkStatusHiddenSections(candidate.workStatusHiddenSections); - } - if (typeof candidate.showReasoningTraces === 'boolean') { - result.showReasoningTraces = candidate.showReasoningTraces; - } - if (typeof candidate.streamingAutoFollowEnabled === 'boolean') { - result.streamingAutoFollowEnabled = candidate.streamingAutoFollowEnabled; - } - if (typeof candidate.inputHistoryScope === 'string' && isInputHistoryScope(candidate.inputHistoryScope)) { - result.inputHistoryScope = candidate.inputHistoryScope; - } - if (typeof candidate.inputHistoryLimit === 'number' && isInputHistoryLimit(candidate.inputHistoryLimit)) { - result.inputHistoryLimit = candidate.inputHistoryLimit; - } - if (typeof candidate.sessionRecapEnabled === 'boolean') { - result.sessionRecapEnabled = candidate.sessionRecapEnabled; - } - if (typeof candidate.sessionSuggestionEnabled === 'boolean') { - result.sessionSuggestionEnabled = candidate.sessionSuggestionEnabled; - } - if (typeof candidate.sessionGoalEnabled === 'boolean') { - result.sessionGoalEnabled = candidate.sessionGoalEnabled; - } - if (typeof candidate.sessionGoalDefaultBudgetEnabled === 'boolean') { - result.sessionGoalDefaultBudgetEnabled = candidate.sessionGoalDefaultBudgetEnabled; - } - if (typeof candidate.sessionGoalDefaultBudget === 'number' && Number.isFinite(candidate.sessionGoalDefaultBudget) && candidate.sessionGoalDefaultBudget > 0) { - result.sessionGoalDefaultBudget = Math.floor(candidate.sessionGoalDefaultBudget); - } - if (typeof candidate.collapsibleThinkingBlocks === 'boolean') { - result.collapsibleThinkingBlocks = candidate.collapsibleThinkingBlocks; - } - if (typeof candidate.autoDeleteEnabled === 'boolean') { - result.autoDeleteEnabled = candidate.autoDeleteEnabled; - } - if (typeof candidate.autoSaveEnabled === 'boolean') { - result.autoSaveEnabled = candidate.autoSaveEnabled; - } - if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) { - result.autoDeleteAfterDays = candidate.autoDeleteAfterDays; - } - if (candidate.sessionRetentionAction === 'archive' || candidate.sessionRetentionAction === 'delete') { - result.sessionRetentionAction = candidate.sessionRetentionAction; - } - if (typeof candidate.tunnelProvider === 'string') { - const provider = candidate.tunnelProvider.trim().toLowerCase(); - if (provider.length > 0) { - result.tunnelProvider = provider; - } - } - if (typeof candidate.tunnelMode === 'string') { - const mode = candidate.tunnelMode.trim().toLowerCase(); - if (mode === 'quick' || mode === 'managed-remote' || mode === 'managed-local') { - result.tunnelMode = mode; - } - } - if (candidate.tunnelBootstrapTtlMs === null) { - result.tunnelBootstrapTtlMs = null; - } else if (typeof candidate.tunnelBootstrapTtlMs === 'number' && Number.isFinite(candidate.tunnelBootstrapTtlMs)) { - result.tunnelBootstrapTtlMs = candidate.tunnelBootstrapTtlMs; - } - if (typeof candidate.tunnelSessionTtlMs === 'number' && Number.isFinite(candidate.tunnelSessionTtlMs)) { - result.tunnelSessionTtlMs = candidate.tunnelSessionTtlMs; - } - if (candidate.managedLocalTunnelConfigPath === null) { - result.managedLocalTunnelConfigPath = null; - } else if (typeof candidate.managedLocalTunnelConfigPath === 'string') { - const trimmed = candidate.managedLocalTunnelConfigPath.trim(); - result.managedLocalTunnelConfigPath = trimmed.length > 0 ? trimmed : null; - } - if (typeof candidate.managedRemoteTunnelHostname === 'string') { - result.managedRemoteTunnelHostname = candidate.managedRemoteTunnelHostname.trim(); - } - if (candidate.managedRemoteTunnelToken === null) { - result.managedRemoteTunnelToken = null; - } else if (typeof candidate.managedRemoteTunnelToken === 'string') { - result.managedRemoteTunnelToken = candidate.managedRemoteTunnelToken.trim(); - } - const managedRemoteTunnelPresets = sanitizeManagedRemoteTunnelPresets(candidate.managedRemoteTunnelPresets); - if (managedRemoteTunnelPresets) { - result.managedRemoteTunnelPresets = managedRemoteTunnelPresets; - } - if (typeof candidate.managedRemoteTunnelSelectedPresetId === 'string') { - const trimmed = candidate.managedRemoteTunnelSelectedPresetId.trim(); - result.managedRemoteTunnelSelectedPresetId = trimmed.length > 0 ? trimmed : undefined; - } - const managedRemoteTunnelPresetTokens = sanitizeManagedRemoteTunnelPresetTokens(candidate.managedRemoteTunnelPresetTokens); - if (managedRemoteTunnelPresetTokens) { - result.managedRemoteTunnelPresetTokens = managedRemoteTunnelPresetTokens; - } - if (typeof candidate.defaultModel === 'string' && candidate.defaultModel.length > 0) { - result.defaultModel = candidate.defaultModel; - } - if (typeof candidate.defaultVariant === 'string' && candidate.defaultVariant.length > 0) { - result.defaultVariant = candidate.defaultVariant; - } - if (typeof candidate.defaultAgent === 'string' && candidate.defaultAgent.length > 0) { - result.defaultAgent = candidate.defaultAgent; - } - if (typeof candidate.smallModelUseDefault === 'boolean') { - result.smallModelUseDefault = candidate.smallModelUseDefault; - } - if (typeof candidate.smallModelOverride === 'string' && candidate.smallModelOverride.length > 0) { - result.smallModelOverride = candidate.smallModelOverride; - } - if (typeof candidate.walkthroughModelOverride === 'string' && candidate.walkthroughModelOverride.length > 0) { - result.walkthroughModelOverride = candidate.walkthroughModelOverride; - } - if (typeof candidate.autoCreateWorktree === 'boolean') { - result.autoCreateWorktree = candidate.autoCreateWorktree; - } - if (typeof candidate.gitmojiEnabled === 'boolean') { - result.gitmojiEnabled = candidate.gitmojiEnabled; - } - if (isFollowUpBehavior(candidate.followUpBehavior)) { - result.followUpBehavior = candidate.followUpBehavior; - } else if (typeof candidate.queueModeEnabled === 'boolean') { - result.followUpBehavior = normalizeFollowUpBehavior(undefined, candidate.queueModeEnabled); - } - if (typeof candidate.showDeletionDialog === 'boolean') { - result.showDeletionDialog = candidate.showDeletionDialog; - } - if (typeof candidate.nativeNotificationsEnabled === 'boolean') { - result.nativeNotificationsEnabled = candidate.nativeNotificationsEnabled; - } - if (typeof candidate.notificationMode === 'string' && (candidate.notificationMode === 'always' || candidate.notificationMode === 'hidden-only')) { - result.notificationMode = candidate.notificationMode; - } - if (typeof candidate.notifyOnSubtasks === 'boolean') { - result.notifyOnSubtasks = candidate.notifyOnSubtasks; - } - if (typeof candidate.notifyOnCompletion === 'boolean') { - result.notifyOnCompletion = candidate.notifyOnCompletion; - } - if (typeof candidate.notifyOnError === 'boolean') { - result.notifyOnError = candidate.notifyOnError; - } - if (typeof candidate.notifyOnQuestion === 'boolean') { - result.notifyOnQuestion = candidate.notifyOnQuestion; - } - if (candidate.notificationTemplates && typeof candidate.notificationTemplates === 'object') { - const templates = candidate.notificationTemplates as Record; - const validateTemplate = (key: string): { title: string; message: string } | undefined => { - const value = templates[key]; - if (!value || typeof value !== 'object') return undefined; - const obj = value as Record; - const title = typeof obj.title === 'string' ? obj.title : ''; - const message = typeof obj.message === 'string' ? obj.message : ''; - return { title, message }; - }; - const completion = validateTemplate('completion'); - const error = validateTemplate('error'); - const question = validateTemplate('question'); - const subtask = validateTemplate('subtask'); - if (completion || error || question || subtask) { - result.notificationTemplates = { - completion: completion ?? { title: 'Task Complete', message: 'Your task has finished.' }, - error: error ?? { title: 'Error Occurred', message: 'An error occurred while processing your task.' }, - question: question ?? { title: 'Input Needed', message: 'Please provide input to continue.' }, - subtask: subtask ?? { title: 'Subtask Complete', message: 'A subtask has finished.' }, - }; - } - } - if (typeof candidate.summarizeLastMessage === 'boolean') { - result.summarizeLastMessage = candidate.summarizeLastMessage; - } - if (typeof candidate.summaryThreshold === 'number' && Number.isFinite(candidate.summaryThreshold)) { - result.summaryThreshold = Math.max(0, Math.round(candidate.summaryThreshold)); - } - if (typeof candidate.summaryLength === 'number' && Number.isFinite(candidate.summaryLength)) { - result.summaryLength = Math.max(10, Math.round(candidate.summaryLength)); - } - if (typeof candidate.maxLastMessageLength === 'number' && Number.isFinite(candidate.maxLastMessageLength)) { - result.maxLastMessageLength = Math.max(10, Math.round(candidate.maxLastMessageLength)); - } - if (candidate.usageDisplayMode === 'usage' || candidate.usageDisplayMode === 'remaining') { - result.usageDisplayMode = candidate.usageDisplayMode; - } - if (Array.isArray(candidate.usageDropdownProviders)) { - result.usageDropdownProviders = candidate.usageDropdownProviders.filter( - (entry): entry is string => typeof entry === 'string' && entry.length > 0 - ); - } - - // Parse usageSelectedModels (Record) - if (candidate.usageSelectedModels && typeof candidate.usageSelectedModels === 'object') { - const selectedModels: Record = {}; - for (const [providerId, models] of Object.entries(candidate.usageSelectedModels)) { - if (Array.isArray(models)) { - selectedModels[providerId] = models.filter((m): m is string => typeof m === 'string'); - } - } - if (Object.keys(selectedModels).length > 0) { - result.usageSelectedModels = selectedModels; - } - } - - // Parse usageCollapsedFamilies (Record) - if (candidate.usageCollapsedFamilies && typeof candidate.usageCollapsedFamilies === 'object') { - const collapsedFamilies: Record = {}; - for (const [providerId, families] of Object.entries(candidate.usageCollapsedFamilies)) { - if (Array.isArray(families)) { - collapsedFamilies[providerId] = families.filter((f): f is string => typeof f === 'string'); - } - } - if (Object.keys(collapsedFamilies).length > 0) { - result.usageCollapsedFamilies = collapsedFamilies; - } - } - - // Parse usageExpandedFamilies (Record) - inverted collapsed logic for header dropdown - if (candidate.usageExpandedFamilies && typeof candidate.usageExpandedFamilies === 'object') { - const expandedFamilies: Record = {}; - for (const [providerId, families] of Object.entries(candidate.usageExpandedFamilies)) { - if (Array.isArray(families)) { - expandedFamilies[providerId] = families.filter((f): f is string => typeof f === 'string'); - } - } - if (Object.keys(expandedFamilies).length > 0) { - result.usageExpandedFamilies = expandedFamilies; - } - } - - // Parse usageModelGroups - custom model groups configuration per provider - if (candidate.usageModelGroups && typeof candidate.usageModelGroups === 'object') { - const modelGroups: Record; - modelAssignments?: Record; - renamedGroups?: Record; - }> = {}; - for (const [providerId, config] of Object.entries(candidate.usageModelGroups)) { - if (config && typeof config === 'object') { - const typedConfig = config as Record; - const providerConfig: NonNullable[string] = {}; - - // Parse customGroups - if (Array.isArray(typedConfig.customGroups)) { - providerConfig.customGroups = typedConfig.customGroups - .filter((g): g is Record => g && typeof g === 'object') - .map((g) => ({ - id: String(g.id ?? ''), - label: String(g.label ?? ''), - models: Array.isArray(g.models) - ? g.models.filter((m): m is string => typeof m === 'string') - : [], - order: typeof g.order === 'number' ? g.order : 0, - })); - } - - // Parse modelAssignments - if (typedConfig.modelAssignments && typeof typedConfig.modelAssignments === 'object') { - providerConfig.modelAssignments = Object.fromEntries( - Object.entries(typedConfig.modelAssignments as Record) - .filter(([, v]) => typeof v === 'string') - .map(([k, v]) => [k, String(v)]) - ); - } - - // Parse renamedGroups - if (typedConfig.renamedGroups && typeof typedConfig.renamedGroups === 'object') { - providerConfig.renamedGroups = Object.fromEntries( - Object.entries(typedConfig.renamedGroups as Record) - .filter(([, v]) => typeof v === 'string') - .map(([k, v]) => [k, String(v)]) - ); - } - - if (Object.keys(providerConfig).length > 0) { - modelGroups[providerId] = providerConfig; - } - } - } - if (Object.keys(modelGroups).length > 0) { - result.usageModelGroups = modelGroups; - } - } - - if (typeof candidate.inputSpellcheckEnabled === 'boolean') { - result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled; - } - if (candidate.enterToSend === true || candidate.enterToSend === false) { - result.enterToSend = candidate.enterToSend; - } - if (candidate.enterToSendConfigured === true || candidate.enterToSendConfigured === false) { - result.enterToSendConfigured = candidate.enterToSendConfigured; - } - if (typeof candidate.showOpenCodeUpdateNotifications === 'boolean') { - result.showOpenCodeUpdateNotifications = candidate.showOpenCodeUpdateNotifications; - } - if (typeof candidate.agentControlToolEnabled === 'boolean') { - result.agentControlToolEnabled = candidate.agentControlToolEnabled; - } - if (typeof candidate.agentWebToolEnabled === 'boolean') { - result.agentWebToolEnabled = candidate.agentWebToolEnabled; - } - if (typeof candidate.agentMemoryToolEnabled === 'boolean') { - result.agentMemoryToolEnabled = candidate.agentMemoryToolEnabled; - } - if (typeof candidate.openCodeUpdateToastDismissedVersion === 'string') { - result.openCodeUpdateToastDismissedVersion = candidate.openCodeUpdateToastDismissedVersion.trim().slice(0, 128); - } - if (typeof candidate.showToolFileIcons === 'boolean') { - result.showToolFileIcons = candidate.showToolFileIcons; - } - if (typeof candidate.codeBlockLineWrap === 'boolean') { - result.codeBlockLineWrap = candidate.codeBlockLineWrap; - } - if (typeof candidate.showTurnChangedFiles === 'boolean') { - result.showTurnChangedFiles = candidate.showTurnChangedFiles; - } - if (typeof candidate.showExpandedBashTools === 'boolean') { - result.showExpandedBashTools = candidate.showExpandedBashTools; - } - if (typeof candidate.showExpandedEditTools === 'boolean') { - result.showExpandedEditTools = candidate.showExpandedEditTools; - } - if (typeof candidate.timeFormatPreference === 'string' - && (candidate.timeFormatPreference === 'auto' || candidate.timeFormatPreference === '12h' || candidate.timeFormatPreference === '24h')) { - result.timeFormatPreference = candidate.timeFormatPreference; - } - if (typeof candidate.weekStartPreference === 'string' - && (candidate.weekStartPreference === 'auto' || candidate.weekStartPreference === 'sunday' || candidate.weekStartPreference === 'monday')) { - result.weekStartPreference = candidate.weekStartPreference; - } - if (typeof candidate.desktopWindowControlsPosition === 'string') { - if (candidate.desktopWindowControlsPosition === 'left') { - result.desktopWindowControlsPosition = 'left'; - } else if ( - candidate.desktopWindowControlsPosition === 'right' - || candidate.desktopWindowControlsPosition === 'auto' - ) { - // Legacy "auto" never read OS chrome config; treat as right. - result.desktopWindowControlsPosition = 'right'; - } - } - if (typeof candidate.desktopWindowControlsStyle === 'string') { - if (candidate.desktopWindowControlsStyle === 'classic' || candidate.desktopWindowControlsStyle === 'traffic-lights') { - result.desktopWindowControlsStyle = candidate.desktopWindowControlsStyle; - } - } - if (typeof candidate.chatRenderMode === 'string' - && (candidate.chatRenderMode === 'sorted' || candidate.chatRenderMode === 'live')) { - result.chatRenderMode = candidate.chatRenderMode; - } - if (typeof candidate.messageStreamTransport === 'string' - && (candidate.messageStreamTransport === 'auto' || candidate.messageStreamTransport === 'ws' || candidate.messageStreamTransport === 'sse')) { - result.messageStreamTransport = candidate.messageStreamTransport; - } - if (typeof candidate.activityRenderMode === 'string' - && (candidate.activityRenderMode === 'collapsed' || candidate.activityRenderMode === 'summary')) { - result.activityRenderMode = candidate.activityRenderMode; - } - if (typeof candidate.mermaidRenderingMode === 'string' - && (candidate.mermaidRenderingMode === 'svg' || candidate.mermaidRenderingMode === 'ascii')) { - result.mermaidRenderingMode = candidate.mermaidRenderingMode; - } - if (typeof candidate.userMessageRenderingMode === 'string' - && (candidate.userMessageRenderingMode === 'markdown' || candidate.userMessageRenderingMode === 'plain')) { - result.userMessageRenderingMode = candidate.userMessageRenderingMode; - } - if (typeof candidate.collapsibleUserMessages === 'boolean') { - result.collapsibleUserMessages = candidate.collapsibleUserMessages; - } - if (typeof candidate.stickyUserHeader === 'boolean') { - result.stickyUserHeader = candidate.stickyUserHeader; - } - if (typeof candidate.promptNavigatorEnabled === 'boolean') { - result.promptNavigatorEnabled = candidate.promptNavigatorEnabled; - } - if (typeof candidate.wideChatLayoutEnabled === 'boolean') { - result.wideChatLayoutEnabled = candidate.wideChatLayoutEnabled; - } - if (typeof candidate.showSplitAssistantMessageActions === 'boolean') { - result.showSplitAssistantMessageActions = candidate.showSplitAssistantMessageActions; - } - if (typeof candidate.fontSize === 'number' && Number.isFinite(candidate.fontSize)) { - result.fontSize = candidate.fontSize; - } - if (typeof candidate.terminalFontSize === 'number' && Number.isFinite(candidate.terminalFontSize)) { - result.terminalFontSize = candidate.terminalFontSize; - } - if (isTerminalShell(candidate.terminalShell)) { - result.terminalShell = candidate.terminalShell; - } - if (Array.isArray(candidate.terminalLoginShells)) { - result.terminalLoginShells = [...new Set(candidate.terminalLoginShells.filter(isTerminalShell))]; - } - if (typeof candidate.editorFontSize === 'number' && Number.isFinite(candidate.editorFontSize)) { - result.editorFontSize = candidate.editorFontSize; - } - if (isUiFontOption(candidate.uiFont)) { - result.uiFont = candidate.uiFont; - } - if (isMonoFontOption(candidate.monoFont)) { - result.monoFont = candidate.monoFont; - } - if (typeof candidate.padding === 'number' && Number.isFinite(candidate.padding)) { - result.padding = candidate.padding; - } - if (typeof candidate.cornerRadius === 'number' && Number.isFinite(candidate.cornerRadius)) { - result.cornerRadius = candidate.cornerRadius; - } - if (typeof candidate.inputBarOffset === 'number' && Number.isFinite(candidate.inputBarOffset)) { - result.inputBarOffset = candidate.inputBarOffset; - } - const shortcutOverrides = sanitizeShortcutOverrides(candidate.shortcutOverrides); - if (shortcutOverrides) { - result.shortcutOverrides = shortcutOverrides; - } - if (typeof candidate.mobileKeyboardMode === 'string') { - if (candidate.mobileKeyboardMode === 'native' || candidate.mobileKeyboardMode === 'resize-content') { - result.mobileKeyboardMode = candidate.mobileKeyboardMode; - } - } - - const favoriteModels = sanitizeModelRefs(candidate.favoriteModels, 64); - if (favoriteModels) { - result.favoriteModels = favoriteModels; - } - - const hiddenModels = sanitizeModelRefs(candidate.hiddenModels, 1024); - if (hiddenModels) { - result.hiddenModels = hiddenModels; - } - - const collapsedModelProviders = sanitizeStringArray(candidate.collapsedModelProviders); - if (collapsedModelProviders) { - result.collapsedModelProviders = collapsedModelProviders; - } - - const recentModels = sanitizeModelRefs(candidate.recentModels, 16); - if (recentModels) { - result.recentModels = recentModels; - } - - const recentAgents = sanitizeStringArray(candidate.recentAgents); - if (recentAgents) { - result.recentAgents = recentAgents; - } - - const recentEfforts = sanitizeRecentEfforts(candidate.recentEfforts); - if (recentEfforts) { - result.recentEfforts = recentEfforts; - } - if ( - typeof candidate.diffLayoutPreference === 'string' - && (candidate.diffLayoutPreference === 'dynamic' - || candidate.diffLayoutPreference === 'inline' - || candidate.diffLayoutPreference === 'side-by-side') - ) { - result.diffLayoutPreference = candidate.diffLayoutPreference; - } - if ( - typeof candidate.gitChangesViewMode === 'string' - && (candidate.gitChangesViewMode === 'flat' || candidate.gitChangesViewMode === 'tree') - ) { - result.gitChangesViewMode = candidate.gitChangesViewMode; - } - if ( - typeof candidate.toolJsonViewMode === 'string' - && (candidate.toolJsonViewMode === 'summary' || candidate.toolJsonViewMode === 'formatted' || candidate.toolJsonViewMode === 'raw') - ) { - result.toolJsonViewMode = candidate.toolJsonViewMode; - } - if (typeof candidate.directoryShowHidden === 'boolean') { - result.directoryShowHidden = candidate.directoryShowHidden; - } - if (typeof candidate.filesViewShowGitignored === 'boolean') { - result.filesViewShowGitignored = candidate.filesViewShowGitignored; - } - if (typeof candidate.openInAppId === 'string' && candidate.openInAppId.length > 0) { - result.openInAppId = candidate.openInAppId; - } - if (typeof candidate.pwaAppName === 'string') { - const normalized = candidate.pwaAppName.trim().replace(/\s+/g, ' ').slice(0, 64); - result.pwaAppName = normalized.length > 0 ? normalized : ''; - } - - const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs); - if (skillCatalogs) { - result.skillCatalogs = skillCatalogs; - } - - if (typeof candidate.reportUsage === 'boolean') { - result.reportUsage = candidate.reportUsage; - } - - if (typeof candidate.globalBehaviorPrompt === 'string') { - result.globalBehaviorPrompt = candidate.globalBehaviorPrompt; - } - if (typeof candidate.responseStyleEnabled === 'boolean') { - result.responseStyleEnabled = candidate.responseStyleEnabled; - } - if ( - typeof candidate.responseStylePreset === 'string' - && (candidate.responseStylePreset === 'concise' - || candidate.responseStylePreset === 'detailed' - || candidate.responseStylePreset === 'mentor' - || candidate.responseStylePreset === 'pushback' - || candidate.responseStylePreset === 'noFiller' - || candidate.responseStylePreset === 'matchEnergy' - || candidate.responseStylePreset === 'warmPeer' - || candidate.responseStylePreset === 'custom') - ) { - result.responseStylePreset = candidate.responseStylePreset; - } - if (typeof candidate.responseStyleCustomInstructions === 'string') { - result.responseStyleCustomInstructions = candidate.responseStyleCustomInstructions; - } - if (typeof candidate.dictationEnabled === 'boolean') { - result.dictationEnabled = candidate.dictationEnabled; - } - if (candidate.sttProvider === 'local' || candidate.sttProvider === 'openai-compatible') { - result.sttProvider = candidate.sttProvider; - } else if (candidate.sttProvider === 'server') { - // Legacy provider migration: 'server' was the OpenAI-compatible endpoint. - result.sttProvider = 'openai-compatible'; - } else if (candidate.sttProvider === 'browser' || candidate.sttProvider === 'wasm') { - result.sttProvider = 'local'; - } - if (typeof candidate.sttServerUrl === 'string') { - result.sttServerUrl = candidate.sttServerUrl.trim(); - } - if (typeof candidate.sttModel === 'string') { - result.sttModel = candidate.sttModel.trim(); - } - if (typeof candidate.sttLocalModel === 'string') { - result.sttLocalModel = candidate.sttLocalModel.trim(); - } - if (typeof candidate.sttLanguage === 'string') { - result.sttLanguage = candidate.sttLanguage.trim(); - } - - const gitProviders = sanitizeGitProviders(candidate.gitProviders); - if (gitProviders) { - result.gitProviders = gitProviders; - } - - return result; -}; +/** Parse an untrusted settings document at the boundary; `null` when it is not an object at all. */ +const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => parseSettingsDocument(payload); type SettingsRuntimeContext = { runtimeKey: string; generation: number }; +/** Whether a settings write reached its store. A no-op (nothing to send) counts as ok. */ +export type SettingsWriteResult = { ok: boolean }; type SettingsMutation = { revision: number; changes: Partial }; type SettingsOperation = { revision: number }; @@ -1858,11 +361,33 @@ class SettingsMutationTracker { // Short-lived cache + in-flight dedup for settings fetches to avoid repeated GET calls during startup let _settingsRuntimeGeneration = 0; let _settingsCache: { value: DesktopSettings | null; at: number; context: SettingsRuntimeContext } | null = null; +// The last value the server was seen holding for each key, for the current +// runtime. A write whose value equals it is redundant and is dropped before it +// reaches the wire — this is what turns "the store changed because we adopted +// the server's value" into zero PUTs instead of an echo (appearanceAutoSave and +// the model-prefs auto-save both subscribe to the store, not to intent). +let _serverKnownSettings: Partial = {}; +// True while server values are being copied into the stores. Store +// subscribers that mirror changes back to the server (appearanceAutoSave, +// modelPrefsAutoSave) read this to tell "a person changed it" from "we just +// adopted it" — the second must never become a write. +let _applyingServerSettings = false; + +export const isApplyingServerSettings = (): boolean => _applyingServerSettings; + +const applyServerSettings = (settings: DesktopSettings): void => { + _applyingServerSettings = true; + try { + applyDesktopUiPreferences(settings); + } finally { + _applyingServerSettings = false; + } +}; let _settingsInflight: { promise: Promise; context: SettingsRuntimeContext } | null = null; let _pendingSettingsChanges: Partial | null = null; let _pendingSettingsContext: SettingsRuntimeContext | null = null; let _settingsFlushTimer: ReturnType | null = null; -let _settingsFlushWaiters: Array<() => void> = []; +let _settingsFlushWaiters: Array<(result: SettingsWriteResult) => void> = []; let _settingsLifecycleInitialized = false; let _pendingSettingsRevision = 0; const _settingsMutationTracker = new SettingsMutationTracker(); @@ -1874,6 +399,39 @@ const captureSettingsRuntimeContext = (): SettingsRuntimeContext => ({ generation: _settingsRuntimeGeneration, }); +type SettingsKey = keyof DesktopSettings; +type SettingsValue = DesktopSettings[SettingsKey]; + +// SAFETY: a Partial here always comes from the typed stores or +// from `sanitizeWebSettings`, both of which only ever set DesktopSettings keys. +const settingsKeysOf = (changes: Partial): SettingsKey[] => Object.keys(changes) as SettingsKey[]; + +const isSameSettingValue = (left: SettingsValue | undefined, right: SettingsValue | undefined): boolean => { + if (left === right) return true; + if (left === undefined || right === undefined) return false; + return JSON.stringify(left) === JSON.stringify(right); +}; + +const rememberServerSettings = (settings: Partial): void => { + _serverKnownSettings = { ..._serverKnownSettings, ...settings }; +}; + +const forgetServerSettings = (keys: SettingsKey[]): void => { + const next: Partial = { ..._serverKnownSettings }; + for (const key of keys) delete next[key]; + _serverKnownSettings = next; +}; + +/** Keys of `changes` whose value differs from what the server is known to hold. */ +const withoutRedundantSettings = (changes: Partial): Partial => { + const next: Partial = {}; + for (const key of settingsKeysOf(changes)) { + if (isSameSettingValue(changes[key], _serverKnownSettings[key])) continue; + Object.assign(next, { [key]: changes[key] }); + } + return next; +}; + const isSameSettingsRuntimeContext = (left: SettingsRuntimeContext, right: SettingsRuntimeContext): boolean => ( left.runtimeKey === right.runtimeKey && left.generation === right.generation ); @@ -1918,6 +476,7 @@ const ensureSettingsRuntimeLifecycle = (): void => { _pendingSettingsRevision = 0; _settingsCache = null; _settingsInflight = null; + _serverKnownSettings = {}; }); // Mirror the deferred safe-storage lifecycle: without these listeners, a @@ -1968,6 +527,7 @@ const fetchWebSettings = async (context = captureSettingsRuntimeContext()): Prom if (!isSettingsRuntimeContextCurrent(context)) return null; const settings = sanitizeWebSettings(result.settings); _settingsCache = { value: settings, at: Date.now(), context }; + if (settings) rememberServerSettings(settings); return settings; } catch (error) { if (!isSettingsRuntimeContextCurrent(context)) return null; @@ -1977,7 +537,10 @@ const fetchWebSettings = async (context = captureSettingsRuntimeContext()): Prom if (!isSettingsRuntimeContextCurrent(context)) return null; try { - const response = await runtimeFetch('/api/config/settings', { + // The surface kind travels as a query parameter, not a header: a header + // would turn the request into a CORS preflight, which older instances + // (and the packaged desktop's cross-origin shell) refuse. + const response = await runtimeFetch(settingsEndpointForSurface(), { method: 'GET', headers: { Accept: 'application/json' }, }); @@ -1989,6 +552,7 @@ const fetchWebSettings = async (context = captureSettingsRuntimeContext()): Prom if (!isSettingsRuntimeContextCurrent(context)) return null; const settings = sanitizeWebSettings(data); _settingsCache = { value: settings, at: Date.now(), context }; + if (settings) rememberServerSettings(settings); return settings; } catch (error) { if (!isSettingsRuntimeContextCurrent(context)) return null; @@ -2005,9 +569,11 @@ const fetchWebSettings = async (context = captureSettingsRuntimeContext()): Prom return inflight.promise; }; -/** Invalidate cached settings (call after a successful PUT) */ +/** Forget everything cached about the server document: the GET cache and the + * last-known per-key values used to drop redundant writes. */ export const invalidateSettingsCache = (): void => { _settingsCache = null; + _serverKnownSettings = {}; }; export const syncDesktopSettings = async (options?: { bootstrap?: boolean; adoptTheme?: boolean }): Promise => { @@ -2070,46 +636,21 @@ export const syncDesktopSettings = async (options?: { bootstrap?: boolean; adopt let settings = overlayPendingChanges(_settingsMutationTracker.reconcile(loadedSettings, operation)); await waitForHydration(); if (!isSettingsRuntimeContextCurrent(context)) return; - settings = overlayPendingChanges(_settingsMutationTracker.reconcile(loadedSettings, operation)); - const shouldPersistCraftGoalMigration = settings.draftStartersCraftGoalAdded !== true - || settings.draftStartersScheduleTaskAdded !== true; - // `autoSaveEnabled` is new to the settings backend. Until the server has a - // value, materialize would invent the client default (true) and overwrite a - // deliberate legacy "off" preference migrated from - // `openchamber:files:auto-save-enabled`. Prefer the hydrated store value and - // seed the backend once so later omitted→default authority is correct. - const shouldSeedAutoSaveEnabled = typeof settings.autoSaveEnabled !== 'boolean'; - const shouldSeedSidebarProjectDisplayMode = settings.sidebarProjectDisplayMode === undefined; - const shouldSeedSidebarSessionGroupingMode = settings.sidebarSessionGroupingMode === undefined; - const shouldSeedSidebarProjectSortOrder = settings.sidebarProjectSortOrder === undefined; - const shouldSeedSidebarShowRecentSection = settings.sidebarShowRecentSection === undefined; - const authoritativeSettings = materializeAuthoritativeUiSettings(settings); + settings = withoutStaleDeviceFields( + overlayPendingChanges(_settingsMutationTracker.reconcile(loadedSettings, operation)), + context.runtimeKey, + ); + // Keys the server omits are "unset", not "reset": this window keeps + // whatever it already holds for them and nothing is written back. A + // bootstrap therefore never seeds the server from local state — a write + // only ever carries a change a person made in this window. try { persistToLocalStorage(settings); } catch (error) { console.warn('persistToLocalStorage failed:', error); } - if (shouldSeedAutoSaveEnabled) { - authoritativeSettings.autoSaveEnabled = useUIStore.getState().autoSaveEnabled; - } - const sessionDisplayState = useSessionDisplayStore.getState(); - if (shouldSeedSidebarProjectDisplayMode) { - authoritativeSettings.sidebarProjectDisplayMode = sessionDisplayState.projectDisplayMode; - } - if (shouldSeedSidebarSessionGroupingMode) { - authoritativeSettings.sidebarSessionGroupingMode = sessionDisplayState.sessionGroupingMode; - } - if (shouldSeedSidebarProjectSortOrder) { - authoritativeSettings.sidebarProjectSortOrder = sessionDisplayState.projectSortOrder; - } - if (shouldSeedSidebarShowRecentSection) { - authoritativeSettings.sidebarShowRecentSection = sessionDisplayState.showRecentSection; - } - if (settings.draftStarters === undefined) { - useUIStore.setState({ globalDraftStarters: null }); - } try { - applyDesktopUiPreferences(authoritativeSettings); + applyServerSettings(settings); } catch (error) { console.warn('applyDesktopUiPreferences failed:', error); } @@ -2147,35 +688,8 @@ export const syncDesktopSettings = async (options?: { bootstrap?: boolean; adopt } catch (error) { console.warn('applyProjectGitProviderSettings failed:', error); } - const migrationPatch: Partial = {}; - if (shouldPersistCraftGoalMigration) { - if (authoritativeSettings.draftStarters) { - migrationPatch.draftStarters = authoritativeSettings.draftStarters; - } - migrationPatch.draftStartersCraftGoalAdded = true; - migrationPatch.draftStartersScheduleTaskAdded = true; - } - if (shouldSeedAutoSaveEnabled) { - migrationPatch.autoSaveEnabled = authoritativeSettings.autoSaveEnabled; - } - if (shouldSeedSidebarProjectDisplayMode) { - migrationPatch.sidebarProjectDisplayMode = authoritativeSettings.sidebarProjectDisplayMode; - } - if (shouldSeedSidebarSessionGroupingMode) { - migrationPatch.sidebarSessionGroupingMode = authoritativeSettings.sidebarSessionGroupingMode; - } - if (shouldSeedSidebarProjectSortOrder) { - migrationPatch.sidebarProjectSortOrder = authoritativeSettings.sidebarProjectSortOrder; - } - if (shouldSeedSidebarShowRecentSection) { - migrationPatch.sidebarShowRecentSection = authoritativeSettings.sidebarShowRecentSection; - } - if (Object.keys(migrationPatch).length > 0) { - await updateDesktopSettings(migrationPatch); - if (!isSettingsRuntimeContextCurrent(context)) return; - } - dispatchSettingsSynced(authoritativeSettings, bootstrap, adoptTheme); + dispatchSettingsSynced(settings, bootstrap, adoptTheme); }; try { @@ -2194,6 +708,7 @@ export const syncDesktopSettings = async (options?: { bootstrap?: boolean; adopt // `keepalive` is set only on the lifecycle-suspend path, where the document may // be torn down mid-request; the ordinary debounced write uses a plain fetch. async function _flushSettingsUpdate({ keepalive = false }: { keepalive?: boolean } = {}): Promise { + let ok = false; const changes = _pendingSettingsChanges; const context = _pendingSettingsContext; const revision = _pendingSettingsRevision; @@ -2206,23 +721,33 @@ async function _flushSettingsUpdate({ keepalive = false }: { keepalive?: boolean try { if (!changes || !context || Object.keys(changes).length === 0 || !isSettingsRuntimeContextCurrent(context)) { // Nothing will be written — clear any pending "Saving…" indicator. + ok = true; dispatchSettingsSaveState('saved'); return; } const operation = _settingsMutationTracker.begin(revision); + // Assume the merge lands so a same-value write arriving mid-flight is not + // sent twice; a failed request forgets these keys so a retry goes through. + rememberServerSettings(changes); + const forgetSentSettings = () => forgetServerSettings(settingsKeysOf(changes)); try { const runtimeSettings = getRuntimeSettingsAPI(); if (runtimeSettings) { try { - const updated = await runtimeSettings.save(changes); + // The runtime API hands back whatever the bridge or server returned; + // it is parsed here like any other boundary payload. + const updated = sanitizeWebSettings(await runtimeSettings.save(changes)); if (!isSettingsRuntimeContextCurrent(context)) return; if (updated) { + rememberServerSettings(updated); const reconciled = _settingsMutationTracker.reconcile(updated, operation); - applyDesktopUiPreferences(reconciled); + applyServerSettings(reconciled); dispatchSettingsSynced(reconciled, false); _settingsCache = null; } + if (!updated) forgetSentSettings(); + ok = Boolean(updated); dispatchSettingsSaveState(updated ? 'saved' : 'error'); return; } catch (error) { @@ -2233,7 +758,7 @@ async function _flushSettingsUpdate({ keepalive = false }: { keepalive?: boolean if (!isSettingsRuntimeContextCurrent(context)) return; try { - const response = await runtimeFetch('/api/config/settings', { + const response = await runtimeFetch(settingsEndpointForSurface(), { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -2246,6 +771,7 @@ async function _flushSettingsUpdate({ keepalive = false }: { keepalive?: boolean if (!isSettingsRuntimeContextCurrent(context)) return; if (!response.ok) { console.warn('Failed to update shared settings via API:', response.status, response.statusText); + forgetSentSettings(); dispatchSettingsSaveState('error'); return; } @@ -2253,18 +779,22 @@ async function _flushSettingsUpdate({ keepalive = false }: { keepalive?: boolean const updated = sanitizeWebSettings(await response.json().catch(() => null)); if (!isSettingsRuntimeContextCurrent(context)) return; if (updated) { + rememberServerSettings(updated); const reconciled = _settingsMutationTracker.reconcile(updated, operation); - applyDesktopUiPreferences(reconciled); + applyServerSettings(reconciled); dispatchSettingsSynced(reconciled, false); + ok = true; dispatchSettingsSaveState('saved'); // Invalidate GET cache so next read sees the fresh data _settingsCache = null; } else { + forgetSentSettings(); dispatchSettingsSaveState('error'); } } catch (error) { if (isSettingsRuntimeContextCurrent(context)) { console.warn('Failed to update shared settings via API:', error); + forgetSentSettings(); dispatchSettingsSaveState('error'); } } @@ -2272,13 +802,26 @@ async function _flushSettingsUpdate({ keepalive = false }: { keepalive?: boolean _settingsMutationTracker.finish(operation); } } finally { - waiters.forEach((resolve) => resolve()); + waiters.forEach((resolve) => resolve({ ok })); } } -export const updateDesktopSettings = async (changes: Partial): Promise => { +/** + * Load the shared settings document for the current runtime (cached briefly + * during startup bursts). Pages that need a field the stores do not carry read + * it from here instead of fetching the endpoint themselves. `null` is a load + * failure, never an empty document. + */ +export const loadDesktopSettings = (): Promise => fetchWebSettings(); + +/** + * Queue a change a person made in this window for the debounced write. Keys + * whose value the server already holds are dropped; computed server flags are + * never sent. Resolves once the write (or the decision not to write) settled. + */ +export const updateDesktopSettings = async (changes: Partial): Promise => { if (typeof window === 'undefined') { - return; + return { ok: false }; } ensureSettingsRuntimeLifecycle(); const context = captureSettingsRuntimeContext(); @@ -2288,15 +831,36 @@ export const updateDesktopSettings = async (changes: Partial): void _flushSettingsUpdate(); } - _pendingSettingsChanges = { ...(_pendingSettingsChanges ?? {}), ...changes }; + // Merge first, then drop keys that now equal the server: a toggle back to + // the server's value inside the debounce window cancels the pending write + // for that key instead of leaving the earlier value queued. + const writable: Partial = {}; + for (const key of settingsKeysOf(changes)) { + if (isWritableSettingsKey(key)) Object.assign(writable, { [key]: changes[key] }); + } + const pending = withoutRedundantSettings({ ...(_pendingSettingsChanges ?? {}), ...writable }); + if (Object.keys(pending).length === 0) { + _pendingSettingsChanges = null; + _pendingSettingsContext = null; + if (_settingsFlushTimer) { + clearTimeout(_settingsFlushTimer); + _settingsFlushTimer = null; + } + const waiters = _settingsFlushWaiters; + _settingsFlushWaiters = []; + waiters.forEach((resolve) => resolve({ ok: true })); + dispatchSettingsSaveState('saved'); + return { ok: true }; + } + _pendingSettingsChanges = pending; _pendingSettingsContext = context; - _pendingSettingsRevision = _settingsMutationTracker.record(changes); + _pendingSettingsRevision = _settingsMutationTracker.record(withoutRedundantSettings(writable)); dispatchSettingsSaveState('saving'); if (_settingsFlushTimer) { clearTimeout(_settingsFlushTimer); } - const flushed = new Promise((resolve) => { + const flushed = new Promise((resolve) => { _settingsFlushWaiters.push(resolve); }); _settingsFlushTimer = setTimeout(() => void _flushSettingsUpdate(), SETTINGS_DEBOUNCE_MS); diff --git a/packages/ui/src/lib/projectContextApi.ts b/packages/ui/src/lib/projectContextApi.ts index a1f991f9..0d8c3b12 100644 --- a/packages/ui/src/lib/projectContextApi.ts +++ b/packages/ui/src/lib/projectContextApi.ts @@ -26,6 +26,8 @@ export interface ProjectPlanLink { title: string; createdAt: number; pinned: boolean; + /** The user's own plan, or one from the team's shared plans folder. */ + source?: 'shared' | 'personal'; } export type ProjectNoteSource = 'manual' | 'selection' | 'agent'; @@ -45,6 +47,8 @@ interface ProjectContextData { notes: ProjectNote[]; todos: ProjectTodoItem[]; plans: ProjectPlanLink[]; + /** Absolute path of the team's shared plans folder when the project has one. */ + sharedPlansDir: string | null; } interface ProjectPlanContent extends ProjectPlanLink { @@ -136,6 +140,7 @@ const parseContext = (payload: unknown): ProjectContextData => { notes: Array.isArray(record.notes) ? record.notes : [], todos: Array.isArray(record.todos) ? record.todos : [], plans: Array.isArray(record.plans) ? record.plans : [], + sharedPlansDir: typeof record.sharedPlansDir === 'string' ? record.sharedPlansDir : null, }; }; @@ -348,3 +353,33 @@ export const deleteProjectPlan = async ( } return parseContext(await response.json()); }; + +/** + * Move a plan between the user's folder and the team's shared plans folder. + * The plan gets a new id on the other side; resolves `null` when it is gone. + * Sharing needs a shared plans folder set for the project (Project settings). + */ +const movePlan = async ( + project: ProjectRef, + planId: string, + direction: 'share' | 'unshare', +): Promise<{ plan: ProjectPlanLink; context: ProjectContextData } | null> => { + const response = await runtimeFetch( + `${basePath(requireProjectId(project))}/plans/${encodeURIComponent(planId)}/${direction}`, + { method: 'POST' }, + ); + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error(await readErrorMessage(response, direction === 'share' ? 'Failed to share plan' : 'Failed to make plan personal')); + } + const payload = await response.json() as { plan?: ProjectPlanLink; context?: unknown }; + if (!payload?.plan) { + throw new Error('Malformed plan move response'); + } + return { plan: payload.plan, context: parseContext(payload.context) }; +}; + +export const shareProjectPlan = (project: ProjectRef, planId: string) => movePlan(project, planId, 'share'); +export const unshareProjectPlan = (project: ProjectRef, planId: string) => movePlan(project, planId, 'unshare'); diff --git a/packages/ui/src/lib/quota/fetchQuota.ts b/packages/ui/src/lib/quota/fetchQuota.ts new file mode 100644 index 00000000..462fab65 --- /dev/null +++ b/packages/ui/src/lib/quota/fetchQuota.ts @@ -0,0 +1,59 @@ +import { z } from 'zod'; +import type { ProviderResult, QuotaProviderId } from '@/types'; +import { runtimeFetch } from '@/lib/runtime-fetch'; + +const windowSchema = z.object({ + usedPercent: z.number().nullable(), + remainingPercent: z.number().nullable(), + windowSeconds: z.number().nullable(), + resetAfterSeconds: z.number().nullable(), + resetAt: z.number().nullable(), + resetAtFormatted: z.string().nullable(), + resetAfterFormatted: z.string().nullable(), + valueLabel: z.string().nullable().optional(), +}); +const windowsSchema = z.record(z.string(), windowSchema); + +/** The deadline covers response bodies too, including transports that ignore abort. */ +export const fetchQuota = async ( + providerId: QuotaProviderId, + { signal, timeoutMs = 30_000 }: { signal?: AbortSignal; timeoutMs?: number } = {}, +): Promise => { + const controller = new AbortController(); + const abort = () => controller.abort(new DOMException('The operation was aborted.', 'AbortError')); + if (signal?.aborted) abort(); + else signal?.addEventListener('abort', abort, { once: true }); + const timer = setTimeout(() => controller.abort(new DOMException('Quota request timed out', 'TimeoutError')), timeoutMs); + let rejectAborted: () => void = () => {}; + const aborted = new Promise((_resolve, reject) => { + rejectAborted = () => reject(controller.signal.reason); + if (controller.signal.aborted) rejectAborted(); + else controller.signal.addEventListener('abort', rejectAborted, { once: true }); + }); + const readResult = async () => { + controller.signal.throwIfAborted(); + const response = await runtimeFetch(`/api/quota/${encodeURIComponent(providerId)}`, { signal: controller.signal }); + const payload = await response.json(); + if (!response.ok) { + const failure = z.object({ error: z.string() }).safeParse(payload); + throw new Error(failure.success ? failure.data.error : `Failed to fetch quota (${response.status})`); + } + return z.object({ + providerId: z.literal(providerId), + providerName: z.string(), + ok: z.boolean(), + configured: z.boolean(), + error: z.string().optional(), + planLabel: z.string().nullable().optional(), + usage: z.object({ windows: windowsSchema, models: z.record(z.string(), z.object({ windows: windowsSchema })).optional() }).nullable(), + fetchedAt: z.number(), + }).parse(payload); + }; + try { + return await Promise.race([readResult(), aborted]); + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', abort); + controller.signal.removeEventListener('abort', rejectAborted); + } +}; diff --git a/packages/ui/src/lib/quota/providers/index.ts b/packages/ui/src/lib/quota/providers/index.ts index 749f5516..c626256b 100644 --- a/packages/ui/src/lib/quota/providers/index.ts +++ b/packages/ui/src/lib/quota/providers/index.ts @@ -7,6 +7,7 @@ export interface QuotaProviderMeta { export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [ { id: 'claude', name: 'Claude' }, + { id: 'cline-pass', name: 'ClinePass' }, { id: 'codex', name: 'Codex' }, { id: 'cursor', name: 'Cursor' }, { id: 'github-copilot', name: 'GitHub Copilot' }, @@ -24,6 +25,7 @@ export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [ { id: 'crof', name: 'CrofAI' }, { id: 'deepseek', name: 'DeepSeek' }, { id: 'exe-dev', name: 'exe.dev' }, + { id: 'hyper', name: 'Charm Hyper' }, { id: 'neuralwatt', name: 'NeuralWatt' }, { id: 'xai', name: 'xAI' }, ]; diff --git a/packages/ui/src/lib/relay/handshake.ts b/packages/ui/src/lib/relay/handshake.ts index 35497eac..a2fef028 100644 --- a/packages/ui/src/lib/relay/handshake.ts +++ b/packages/ui/src/lib/relay/handshake.ts @@ -44,7 +44,7 @@ export type HandshakeAction = | { type: 'send-text'; text: string } // `replyText`, when present, must be sent to the peer before any encrypted frame. // `batch` is the negotiated frame-batching capability for the session. - | { type: 'established'; channel: EstablishedChannelCrypto; batch: boolean; replyText?: string } + | { type: 'established'; channel: EstablishedChannelCrypto; batch: boolean; flowControl: boolean; replyText?: string } | { type: 'ignore' } | { type: 'fail'; closeCode: number; reason: string }; @@ -60,8 +60,9 @@ const parseHandshakeMessage = (raw: string): E2eeHelloMessage | E2eeReadyMessage if (message.v !== RELAY_PROTOCOL_VERSION) return null; // Unknown/missing capability flag = false = legacy behavior. const batch = message.batch === true; + const flowControl = message.flowControl === true; if (message.t === 'ready') { - return { t: 'ready', v: RELAY_PROTOCOL_VERSION, batch }; + return { t: 'ready', v: RELAY_PROTOCOL_VERSION, batch, flowControl }; } if ( message.t === 'hello' && @@ -75,6 +76,7 @@ const parseHandshakeMessage = (raw: string): E2eeHelloMessage | E2eeReadyMessage clientPubJwk: message.clientPubJwk as JsonWebKey, nonce: message.nonce, batch, + flowControl, }; } return null; @@ -97,6 +99,7 @@ export interface ClientHandshake { export interface ClientHandshakeOptions { /** Advertise frame batching. Default true; set false to force legacy behavior. */ batch?: boolean; + flowControl?: boolean; } // hostEncPubJwk comes from the pairing offer (QR / deep link) and is the trust @@ -114,8 +117,9 @@ export const createClientHandshake = async ( v: RELAY_PROTOCOL_VERSION, clientPubJwk: await exportPublicKeyJwk(ephemeralKeyPair.publicKey), nonce: bytesToBase64Url(nonce), - ...(localBatch ? { batch: true } : {}), }; + if (localBatch) hello.batch = true; + if (options.flowControl !== false) hello.flowControl = true; let established = false; return { helloText: JSON.stringify(hello), @@ -143,6 +147,7 @@ export const createClientHandshake = async ( type: 'established', // Batching runs only if both peers advertised it. batch: localBatch && message.batch === true, + flowControl: options.flowControl !== false && message.flowControl === true, channel: { encryptor: createFrameEncryptor(keys.clientToHost), decryptor: createFrameDecryptor(keys.hostToClient), @@ -161,6 +166,7 @@ export interface HostHandshake { export interface HostHandshakeOptions { /** Support frame batching. Default true; set false to force legacy behavior. */ batch?: boolean; + flowControl?: boolean; } export const createHostHandshake = ( @@ -216,13 +222,15 @@ export const createHostHandshake = ( const ready: E2eeReadyMessage = { t: 'ready', v: RELAY_PROTOCOL_VERSION, - ...(negotiatedBatch ? { batch: true } : {}), }; + if (negotiatedBatch) ready.batch = true; + if (options.flowControl !== false && message.flowControl === true) ready.flowControl = true; readyText = JSON.stringify(ready); established = true; return { type: 'established', batch: negotiatedBatch, + flowControl: ready.flowControl === true, replyText: readyText, channel: { encryptor: createFrameEncryptor(keys.hostToClient), diff --git a/packages/ui/src/lib/relay/protocol.ts b/packages/ui/src/lib/relay/protocol.ts index b14d34df..b6ff89b8 100644 --- a/packages/ui/src/lib/relay/protocol.ts +++ b/packages/ui/src/lib/relay/protocol.ts @@ -47,6 +47,7 @@ export const TunnelFrameType = { WsClose: 10, Ping: 11, Pong: 12, + DeliveryAck: 13, } as const; export type TunnelFrameTypeValue = (typeof TunnelFrameType)[keyof typeof TunnelFrameType]; @@ -97,6 +98,8 @@ export interface E2eeHelloMessage { // Capability advertisement: the client can pack multiple tunnel frames into // one encrypted WS message. Missing/false = legacy (one frame per message). batch?: boolean; + /** Client supports cumulative downstream delivery acknowledgements. */ + flowControl?: boolean; } export interface E2eeReadyMessage { @@ -105,6 +108,8 @@ export interface E2eeReadyMessage { // Host echoes `batch: true` only when it also supports batching AND the client // advertised it. Batching is enabled for the session only if both agree. batch?: boolean; + /** Enabled only when both peers support downstream flow control. */ + flowControl?: boolean; } // Relay-assigned WebSocket close codes. @@ -119,4 +124,3 @@ export const RelayCloseCode = { RekeyMismatch: 1008, ChannelFailure: 1011, } as const; - diff --git a/packages/ui/src/lib/relay/tunnel-client.test.ts b/packages/ui/src/lib/relay/tunnel-client.test.ts index 4616c7ad..dee41af5 100644 --- a/packages/ui/src/lib/relay/tunnel-client.test.ts +++ b/packages/ui/src/lib/relay/tunnel-client.test.ts @@ -10,7 +10,7 @@ import { type FrameEncryptor, } from './crypto'; import { createHostHandshake } from './handshake'; -import { TunnelFrameType } from './protocol'; +import { RelayCloseCode, TunnelFrameType } from './protocol'; import { isAmbiguousTransportFailure } from './transport-error'; import { createFragmentAssembler, @@ -244,7 +244,7 @@ const setupClient = async ( ): Promise<{ client: RelayTunnelClient; connectionCount: () => number; - killWire: () => void; + killWire: (code?: number) => void; sendTextToClient: (text: string) => void; clientBinaryCount: () => number; }> => { @@ -279,7 +279,7 @@ const setupClient = async ( return { client, connectionCount: () => count, - killWire: () => lastClientEndpoint?.close(1006, 'killed'), + killWire: (code = 1006) => lastClientEndpoint?.close(code, 'killed'), sendTextToClient: (text: string) => lastHostEndpoint?.send(text), clientBinaryCount: () => lastClientEndpoint?.binarySent ?? 0, }; @@ -438,6 +438,55 @@ describe('createRelayTunnelClient', () => { expect(['reconnecting', 'connecting', 'connected', 'error']).toContain(status.state); }); + test('outbound retries cannot hide a silent peer', async () => { + const { client } = await setupClient({ silent: true }, { + batch: false, pingTimeoutMs: 40, reconnectBaseDelayMs: 2000, reconnectMaxDelayMs: 2000, + }); + track(client); + let requests = 0; + const timer = setInterval(() => { + requests++; + void client.fetch('/health').catch(() => undefined); + }, 10); + try { + await wait(250); + expect(requests).toBeGreaterThan(10); + expect(client.getStatus()).toEqual({ state: 'reconnecting', lastError: 'relay keepalive timeout' }); + } finally { + clearInterval(timer); + } + }); + + test('continuing inbound stream data stays healthy without idle pings', async () => { + const frames: TunnelFrame[] = []; + const { client, connectionCount } = await setupClient({ recordFrame: frame => frames.push(frame) }, { batchWindowMs: 5 }); + track(client); + const response = await client.fetch('/never-ends'); + await wait(250); + expect(connectionCount()).toBe(1); + expect(client.getStatus().state).toBe('connected'); + expect(frames.some(frame => frame.frameType === TunnelFrameType.Ping)).toBe(false); + await response.body?.cancel(); + }); + + for (const code of [RelayCloseCode.AuthFailed, RelayCloseCode.DuplicateClient, RelayCloseCode.LimitExceeded]) { + test(`terminal relay rejection ${code} rejects subsequent HTTP and WS opens`, async () => { + const { client, killWire, connectionCount } = await setupClient(); + track(client); + await client.fetch('/health'); + killWire(code); + await wait(10); + expect(client.getStatus().state).toBe('error'); + const reason = client.getStatus().lastError; + await expect(client.fetch('/health')).rejects.toThrow(reason); + const socket = client.openWebSocket('/api/terminal/ws'); + const closed = await new Promise(resolve => { socket.onclose = event => resolve(event.reason); }); + expect(closed).toBe(reason); + await wait(100); + expect(connectionCount()).toBe(1); + }); + } + test('survives duplicate ready frames from a slow first handshake (first-request 500 regression)', async () => { // firstHelloDelayMs > helloRetryMs (20ms): the client retries `hello` // several times, and the host answers every retry with `ready`. The diff --git a/packages/ui/src/lib/relay/tunnel-client.ts b/packages/ui/src/lib/relay/tunnel-client.ts index 24a9d985..53eaeae1 100644 --- a/packages/ui/src/lib/relay/tunnel-client.ts +++ b/packages/ui/src/lib/relay/tunnel-client.ts @@ -25,10 +25,10 @@ import { encodeFragmentedMessage, encodeJsonPayload, encodeTunnelFrame, + encodeDeliveryAck, type OutboundFrameBatcher, type TunnelFrame, } from './tunnel-codec'; -import { TUNNEL_FRAGMENT_FLAG } from './protocol'; import { isHttpResponsePayload, isStreamAbortPayload, @@ -162,6 +162,8 @@ export interface RelayTunnelClientOptions { batchWindowMs?: number; /** Advertise frame batching in the handshake. Default true. */ batch?: boolean; + /** Advertise downstream delivery acknowledgements. Default true. */ + flowControl?: boolean; reconnectBaseDelayMs?: number; reconnectMaxDelayMs?: number; hiddenOrOfflineMaxDelayMs?: number; @@ -234,6 +236,7 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela const createWire = options.createWireSocket ?? ((url: string) => wrapNativeWebSocket(new WebSocket(url))); let closed = false; + let terminalError: Error | null = null; let status: RelayTunnelStatus = { state: 'idle' }; // Plain listener set — status must not fan out through shared stores. const statusListeners = new Set<(next: RelayTunnelStatus) => void>(); @@ -350,7 +353,7 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela let handshake; try { - handshake = await createClientHandshake(options.hostEncPubJwk, { batch: advertiseBatch }); + handshake = await createClientHandshake(options.hostEncPubJwk, { batch: advertiseBatch, flowControl: options.flowControl }); } catch (error) { if (generation !== attemptGeneration || closed) return; failAttempt(generation, toError(error), true); @@ -380,12 +383,20 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela let channel: ActiveChannel | null = null; let cryptoChannel: EstablishedChannelCrypto | null = null; let batchNegotiated = false; + let flowControlNegotiated = false; + let receivedBytes = 0; + let acknowledgedBytes = 0; + let ackTimer: ReturnType | null = null; let batcher: OutboundFrameBatcher | null = null; - // Idle tracking: updated on any non-Ping/Pong frame in EITHER direction. - // Ping/Pong are excluded so the keepalive can't sustain itself. - let lastActivityAt = Date.now(); + // Only received frames prove peer liveness. Outbound retries may continue + // indefinitely on a half-open socket and must not suppress the probe. + let lastReceivedAt = Date.now(); const cleanupTimers = (): void => { + if (ackTimer !== null) { + clearTimeout(ackTimer); + ackTimer = null; + } if (helloInterval !== null) { clearInterval(helloInterval); helloInterval = null; @@ -412,6 +423,7 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela function failAttemptLocal(error: Error, asErrorState = false, terminal = false): void { if (settled || generation !== attemptGeneration) return; settled = true; + if (terminal) terminalError = error; cleanupTimers(); if (channel) { activeChannel = null; @@ -444,9 +456,10 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela } }; - const establish = (crypto: EstablishedChannelCrypto, batch: boolean): void => { + const establish = (crypto: EstablishedChannelCrypto, batch: boolean, flowControl: boolean): void => { cryptoChannel = crypto; batchNegotiated = batch; + flowControlNegotiated = flowControl; if (helloInterval !== null) { clearInterval(helloInterval); helloInterval = null; @@ -467,10 +480,11 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela .then(async () => { if (channelObj.dead) return; const encrypted = await crypto.encryptor.encrypt(plaintext); + if (channelObj.dead) return; wire.send(encrypted); }) .catch(() => { - // Send failures surface via wire close; do not break the chain. + failAttemptLocal(new Error('relay encrypt/send failed')); }); }; const localBatcher = batch @@ -484,10 +498,6 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela dead: false, send(frame: Uint8Array): void { if (channelObj.dead) return; - const frameType = frame[0] & ~TUNNEL_FRAGMENT_FLAG; - if (frameType !== TunnelFrameType.Ping && frameType !== TunnelFrameType.Pong) { - lastActivityAt = Date.now(); - } if (localBatcher) localBatcher.enqueue(frame); else sendEncryptedPlaintext(frame); }, @@ -495,14 +505,13 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela channel = channelObj; activeChannel = channelObj; consecutiveFailures = 0; - lastActivityAt = Date.now(); + lastReceivedAt = Date.now(); setStatus({ state: 'connected' }); resolveWaiters(channelObj); pingTimer = setInterval(() => { const now = Date.now(); - // Only ping when the tunnel has actually been idle; streaming traffic - // keeps lastActivityAt fresh, so sustained bursts send zero pings. - if (now - lastActivityAt < pingIntervalMs) return; + // Slow-but-progressing inbound traffic is healthy, even without Pongs. + if (now - lastReceivedAt < pingIntervalMs) return; channelObj.send(encodeTunnelFrame(TunnelFrameType.Ping, 0, EMPTY_PAYLOAD)); // Expect a Pong (or any frame) before the deadline; otherwise it's dead. if (pongDeadline === null) { @@ -514,6 +523,14 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela }, pingIntervalMs); }; + const acknowledgeDelivery = (): void => { + if (ackTimer !== null) clearTimeout(ackTimer); + ackTimer = null; + if (!channel || channel.dead || receivedBytes === acknowledgedBytes) return; + acknowledgedBytes = receivedBytes; + channel.send(encodeTunnelFrame(TunnelFrameType.DeliveryAck, 0, encodeDeliveryAck(receivedBytes))); + }; + const handleTunnelFrame = (channelObj: ActiveChannel, plaintext: Uint8Array): void => { let frame: TunnelFrame; try { @@ -522,7 +539,17 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela failAttemptLocal(toError(error)); return; } + if (frame.frameType === TunnelFrameType.DeliveryAck) { + failAttemptLocal(new Error('unexpected downstream delivery acknowledgement')); + return; + } + if (flowControlNegotiated && frame.streamId !== 0) { + // Count even late/cancelled streams: they still consumed sender credit. + receivedBytes += plaintext.length; + if (ackTimer === null) ackTimer = setTimeout(acknowledgeDelivery, 10); + } // Any received frame proves the tunnel is alive — clear the pong deadline. + lastReceivedAt = Date.now(); if (pongDeadline !== null) { clearTimeout(pongDeadline); pongDeadline = null; @@ -532,8 +559,6 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela return; } if (frame.frameType === TunnelFrameType.Pong) return; - // Non-keepalive inbound traffic counts as activity (suppresses our ping). - lastActivityAt = Date.now(); let payload = frame.payload; if (frame.frameType === TunnelFrameType.WsText || frame.frameType === TunnelFrameType.WsBinary) { @@ -576,7 +601,7 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela const action = await handshake.handleText(data); if (action.type === 'established') { if (cryptoChannel) return; - establish(action.channel, action.batch); + establish(action.channel, action.batch, action.flowControl); } else if (action.type === 'fail') { failAttemptLocal(new Error(`relay handshake failed: ${action.reason}`)); } @@ -605,23 +630,20 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela failAttemptLocal(toError(error)); return; } - if (batchNegotiated) { - // One encrypted message may carry several tunnel frames; dispatch - // each in order through the same per-frame handling as legacy. - let frames: Uint8Array[]; - try { - frames = decodeFrameBatch(plaintext); - } catch (error) { - failAttemptLocal(toError(error)); - return; - } - for (const frame of frames) { - if (settled || generation !== attemptGeneration || currentChannel.dead) return; - handleTunnelFrame(currentChannel, frame); - } + let frames: Uint8Array[]; + try { + frames = batchNegotiated ? decodeFrameBatch(plaintext) : [plaintext]; + } catch (error) { + failAttemptLocal(toError(error)); return; } - handleTunnelFrame(currentChannel, plaintext); + for (const frame of frames) { + if (settled || generation !== attemptGeneration || currentChannel.dead) return; + handleTunnelFrame(currentChannel, frame); + } + // One ACK per received batch, rather than one per fragment. Small + // tails use the timer so a final frame cannot strand sender credit. + if (flowControlNegotiated && receivedBytes - acknowledgedBytes >= 16 * 1024) acknowledgeDelivery(); }) .catch((error: unknown) => { failAttemptLocal(toError(error)); @@ -658,6 +680,7 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela const waitForChannel = (signal?: AbortSignal): Promise => { if (closed) return Promise.reject(new Error('relay tunnel closed')); if (signal?.aborted) return Promise.reject(abortError()); + if (terminalError) return Promise.reject(terminalError); if (activeChannel && !activeChannel.dead) return Promise.resolve(activeChannel); return new Promise((resolve, reject) => { let onAbort: (() => void) | null = null; diff --git a/packages/ui/src/lib/relay/tunnel-codec.ts b/packages/ui/src/lib/relay/tunnel-codec.ts index 2c390abe..97109e4b 100644 --- a/packages/ui/src/lib/relay/tunnel-codec.ts +++ b/packages/ui/src/lib/relay/tunnel-codec.ts @@ -18,6 +18,14 @@ import { const MAX_STREAM_ID = 0xffffffff; +/** Cumulative raw tunnel-frame bytes, excluding stream zero and batch/crypto overhead. */ +export const encodeDeliveryAck = (receivedBytes: number): Uint8Array => { + if (!Number.isSafeInteger(receivedBytes) || receivedBytes < 0) throw new Error('invalid delivery acknowledgement'); + const payload = new Uint8Array(8); + new DataView(payload.buffer).setBigUint64(0, BigInt(receivedBytes)); + return payload; +}; + export class TunnelCodecError extends Error { constructor(message: string) { super(message); diff --git a/packages/ui/src/lib/responseStyle.ts b/packages/ui/src/lib/responseStyle.ts index 1aae6b31b..6ce58cd2 100644 --- a/packages/ui/src/lib/responseStyle.ts +++ b/packages/ui/src/lib/responseStyle.ts @@ -1,4 +1,4 @@ -import { runtimeFetch } from './runtime-fetch'; +import { loadDesktopSettings } from './persistence'; export const RESPONSE_STYLE_PRESETS = ['concise', 'detailed', 'mentor', 'pushback', 'noFiller', 'matchEnergy', 'warmPeer'] as const; export type ResponseStylePreset = typeof RESPONSE_STYLE_PRESETS[number]; @@ -45,16 +45,7 @@ const buildResponseStyleInstruction = ({ }; export const fetchResponseStyleInstruction = async (): Promise => { - const response = await runtimeFetch('/api/config/settings', { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - if (!response.ok) return null; - const settings = await response.json().catch(() => null) as { - responseStyleEnabled?: unknown; - responseStylePreset?: unknown; - responseStyleCustomInstructions?: unknown; - } | null; + const settings = await loadDesktopSettings(); if (!settings) return null; return buildResponseStyleInstruction({ enabled: settings.responseStyleEnabled === true, diff --git a/packages/ui/src/lib/sessionBtwMetadata.ts b/packages/ui/src/lib/sessionBtwMetadata.ts index 2c53dfc1..0c63cb2d 100644 --- a/packages/ui/src/lib/sessionBtwMetadata.ts +++ b/packages/ui/src/lib/sessionBtwMetadata.ts @@ -11,10 +11,10 @@ import { getSessionMetadata, type SessionMetadataRecord } from '@/lib/sessionRev * survives reloads. * - The fork itself is marked `openchamber.kind = 'btw'` with * `originalSessionID` (its parent) and `btwBoundaryMessageID` — the id of - * the last message cloned from the parent. Messages with a greater id are - * the fork's own tail and are what the panel renders. Message ids are - * server-generated ascending identifiers, so the boundary is a plain string - * comparison and immune to client clock skew. + * the last message cloned from the parent. The panel locates this marker + * in the chronologically ordered transcript and renders what follows it. + * IDs must not be compared to determine chronology: they can roll over and + * user-message IDs can be generated by a different client clock. */ type BtwMetadata = { kind?: string; diff --git a/packages/ui/src/lib/settings/DOCUMENTATION.md b/packages/ui/src/lib/settings/DOCUMENTATION.md new file mode 100644 index 00000000..8b9f3515 --- /dev/null +++ b/packages/ui/src/lib/settings/DOCUMENTATION.md @@ -0,0 +1,33 @@ +# Settings + +## Purpose + +`packages/ui/src/lib/settings` owns what an OpenChamber setting *is*: its key, its scope, how a value is parsed at the boundary, and where the UI keeps its live copy. The storage and sync mechanics (debounced writes, mirrors, bootstrap adoption) live in `lib/persistence.ts` and consume this module; the Settings pages consume the stores. + +## Modules + +- `registry.ts` — the settings registry. One `SETTINGS_REGISTRY` table plus two key lists (`LOCAL_DEVICE_KEYS`, `DESKTOP_SHELL_KEYS`) and the derived helpers other modules use: `DesktopSettings` (the document type), `parseSettingsDocument`, `applySettingsToStores`, `AUTO_SAVE_KEYS` / `readAutoSaveSnapshot`, `MIRRORED_KEYS`, `buildSettingsRegistrySnapshot`. +- `parsers.ts` — value-level boundary parsers (zod schemas wrapped as `SettingsParser`). `undefined` means "reject", never "default". +- `registry-snapshot.ts` — renders the plain-JSON snapshot for the two consumers that cannot import the UI's TypeScript: the OpenChamber server (`packages/web/server/lib/opencode/settings-registry.json`) and the VS Code extension host (`packages/vscode/src/settings-registry.json`). Regenerate with `bun run settings-registry:generate`; `registry.test.ts` fails when a checked-in copy is stale. +- `metadata.ts`, `search.ts` — Settings page metadata and the search index (unchanged by the registry; see `.agents/skills/settings-ui-patterns`). + +## Invariants + +- **A key that is not in the registry does not persist.** `parseSettingsDocument` drops unknown keys on the way in; `updateDesktopSettings` sends only registry keys that are not `computed`; the server and the VS Code bridge drop anything the snapshot does not list. +- **Every key has exactly one scope.** `instance` (a fact about the machine the server runs on, never synced), `profile` (the person's preference, shared by every client of the instance), `device` (state of this install/surface). `LOCAL_DEVICE_KEYS` are device fields that only ever lived in `useUIStore`'s persisted slice; `DESKTOP_SHELL_KEYS` are instance facts the Electron main process writes straight into `settings.json` and no client reads. +- **Per-surface profile fields** (`perSurface: true`) are a fixed, owner-decided set: the theme ids and mode, the chat-layout switches that depend on screen size, and the typography sizes. A change made on one surface kind is stored for that kind only: every settings request carries the client's kind as the `surface` query parameter, never a header, so the request needs no CORS preflight from the cross-origin desktop and phone shells and works against older instances (`surface.ts`: `vscode`, `desktop`, `mobile` for the phone app and the hosted mobile shell alike, else `web`), the store writes the value under `fields[key].surfaces[]` and leaves the base untouched, and a read resolves that kind's value first, the base value otherwise, or nothing (the client keeps what it holds). Writes without a surface (migrations, the one-time seed) set the base. The Settings UI is unchanged; the difference is only where the value lands. +- **Missing is not default.** `applySettingsToStores` writes only the fields the snapshot carries; an omitted field leaves the store as it is. Defaults live in the stores' initial state, not in the registry. +- **Writes carry intent.** Fields with `ui.autoSave` are watched by `lib/appearanceAutoSave.ts`; changes made while `isApplyingServerSettings()` is true (a sync copying server values in) are a new baseline, not a write. The six model-preference fields are watched by `lib/modelPrefsAutoSave.ts` with its own debounce and are therefore `autoSave: false` here. +- **Sibling-dependent applies are explicit.** A `ui.write` receives the parsed snapshot as `SettingsSiblingView`, which names the only siblings a write may consult (`draftStarters*Added`, `workStatusHiddenSectionsExplicit`). Extend the view when a new field needs one. +- **Device fields never cross the wire.** `updateDesktopSettings` drops `device` keys before the debounce, the server and the VS Code bridge drop them again, and the mirror never held them. Their home is the local store (`useUIStore` persisted slice, `mobileKeyboardMode` browser storage, `desktopSplashColors` in the desktop shell's own store via the window-theme IPC). A server document that still carries device keys from before the split is applied exactly once per runtime as a seed (`openchamber.deviceSeeded.v1:` in browser storage) and ignored afterwards. +- **Two files on the instance.** `settings.json` keeps instance facts and legacy keys; `preferences.json` beside it holds every `profile` key as `{ value, updatedAt }` (`version: 1`). The server (`packages/web/server/lib/opencode/settings-files.js`) and the VS Code bridge (`packages/vscode/src/settings-files.ts`) seed `preferences.json` once from an existing `settings.json`, keep a copy of the profile's base values in `settings.json` on every write (a build from before the split reads only that file, so a rollback keeps the user's preferences; current builds ignore the copy because `preferences.json` wins), and never touch a `preferences.json` they cannot parse; clients see one merged document and never address the files. Server modules that read a profile key off the disk (small model, session goal/assist, walkthrough) use `readMergedSettingsSync`. +- **Markers, not code, carry the special cases.** `adopt: 'bootstrap-only'` (workspace pointers), `derived` (computed by the writer from other fields), `secret` (accepted on write, never returned), `computed` (server-emitted, never persisted), `surfaces` (which surface kinds have the field). + +## Adding a setting + +1. Add one entry to `SETTINGS_REGISTRY` with `scope`, a parser from `parsers.ts`, and a `ui` binding when a store holds the live value. Use an existing setter so its side effects run. +2. Run `bun run settings-registry:generate` and commit both JSON snapshots. +3. If the server must validate the value beyond the registry gate, add its branch to `sanitizeSettingsUpdate` in `packages/web/server/lib/opencode/settings-helpers.js`; the drift test in `settings-helpers.test.js` needs a valid sample value for the new key. +4. Add the Settings control and search entry per `.agents/skills/settings-ui-patterns`. + +`DesktopSettings`, `SettingsPayload`, the client sanitizer, the mirror, the apply step and the auto-save all follow from step 1; there is no second list to update. diff --git a/packages/ui/src/lib/settings/parsers.ts b/packages/ui/src/lib/settings/parsers.ts new file mode 100644 index 00000000..ddb11992 --- /dev/null +++ b/packages/ui/src/lib/settings/parsers.ts @@ -0,0 +1,417 @@ +/** + * Boundary parsers for settings values. Every value that arrives from the + * server, the VS Code bridge, or browser storage passes through one of these + * before it is trusted; `undefined` means "reject", never "default". + * + * These are the value-level rules the registry (`./registry.ts`) attaches to + * each key. They are zod schemas wrapped into one function shape so the + * registry can hold hand-written and schema-derived parsers alike, and so the + * registry can be evaluated for its shape (the generated JSON snapshot) + * without a browser. + */ +import { z, type ZodType } from 'zod'; + +import type { ProjectEntry } from '@/lib/api/types'; +import { createProjectIdFromPath } from '@/lib/projectId'; + +/** + * `raw` is the whole untrusted document, for the few legacy keys whose value + * is derived from a sibling (`queueModeEnabled` → `followUpBehavior`). + */ +export type SettingsParser = (value: unknown, raw: SettingsRawDocument) => T | undefined; + +/** The untrusted document as received; only ever read through a parser. */ +export type SettingsRawDocument = Readonly>; + +export type ModelRef = { providerID: string; modelID: string }; + +export type NotificationTemplates = { + completion: { title: string; message: string }; + error: { title: string; message: string }; + question: { title: string; message: string }; + subtask: { title: string; message: string }; +}; + +export type UsageModelGroups = Record; + modelAssignments?: Record; + renamedGroups?: Record; +}>; + +export type ManagedRemoteTunnelPreset = { id: string; name: string; hostname: string }; + +export type SkillCatalogConfig = { + id: string; + label: string; + source: string; + subpath?: string; + gitIdentityId?: string; +}; + +/** Wrap a schema as a parser: success yields the parsed value, failure yields `undefined`. */ +export const fromSchema = (schema: ZodType): SettingsParser => (value) => { + const result = schema.safeParse(value); + return result.success ? result.data : undefined; +}; + +const finiteNumber = z.number().refine(Number.isFinite); +const trimmed = z.string().transform((value) => value.trim()); +const nonEmptyTrimmed = trimmed.pipe(z.string().min(1)); +const looseObject = z.record(z.string(), z.unknown()); + +export const parseBoolean = fromSchema(z.boolean()); + +/** A non-empty string, kept verbatim. */ +export const parseNonEmptyString = fromSchema(z.string().min(1)); + +/** Any string, trimmed; empty stays empty (some keys use '' as "unset"). */ +export const parseTrimmedString = fromSchema(trimmed); + +/** A trimmed string that is only accepted when something is left after trimming. */ +export const parseNonEmptyTrimmedString = fromSchema(nonEmptyTrimmed); + +/** Free text with an upper bound, kept verbatim (whitespace is content here). */ +export const parseTextUpTo = (maxLength: number): SettingsParser => fromSchema(z.string().max(maxLength)); + +export const parseTrimmedStringUpTo = (maxLength: number): SettingsParser => fromSchema( + trimmed.transform((value) => value.slice(0, maxLength)), +); + +export const parseOneOf = (options: T): SettingsParser => fromSchema( + trimmed.pipe(z.enum(options)), +); + +export const parseFiniteNumber = fromSchema(finiteNumber); + +export const parseIntegerInRange = (min: number, max: number): SettingsParser => fromSchema( + finiteNumber.transform((value) => Math.max(min, Math.min(max, Math.round(value)))), +); + +export const parseIntegerAtLeast = (min: number): SettingsParser => fromSchema( + finiteNumber.transform((value) => Math.max(min, Math.round(value))), +); + +export const parsePositiveInteger = fromSchema(finiteNumber.positive().transform(Math.floor)); + +/** `null` clears the value; a finite number keeps it. */ +export const parseNullableFiniteNumber = fromSchema(z.union([z.null(), finiteNumber])); + +/** `null` clears the value; a non-empty trimmed string keeps it; '' becomes null. */ +export const parseNullableTrimmedPath = fromSchema( + z.union([z.null(), trimmed.transform((value) => (value.length > 0 ? value : null))]), +); + +export const parseNullableTrimmedString = fromSchema(z.union([z.null(), trimmed])); + +const stringEntries = z.array(z.unknown()).transform((entries) => entries.filter((entry) => z.string().min(1).safeParse(entry).success)); + +/** Distinct non-empty strings, order preserved. */ +export const parseStringSet = fromSchema(stringEntries.transform((entries) => Array.from(new Set(entries.map(String))))); + +/** Non-empty strings, duplicates kept (order is the user's). */ +export const parseStringList = fromSchema(stringEntries.transform((entries) => entries.map(String))); + +const stringListRecord = looseObject.transform((record) => { + const result: Record = {}; + for (const [key, entries] of Object.entries(record)) { + const parsed = z.array(z.unknown()).safeParse(entries); + if (parsed.success) { + result[key] = parsed.data.filter((entry) => z.string().safeParse(entry).success).map(String); + } + } + return result; +}); + +export const parseStringRecordOfStringLists = fromSchema( + stringListRecord.pipe(z.record(z.string(), z.array(z.string())).refine((record) => Object.keys(record).length > 0)), +); + +const modelRefSchema = z.object({ + providerID: nonEmptyTrimmed, + modelID: nonEmptyTrimmed, +}); + +export const parseModelRefs = (limit: number): SettingsParser => fromSchema( + z.array(z.unknown()).transform((entries) => { + const result: ModelRef[] = []; + const seen = new Set(); + for (const entry of entries) { + const parsed = modelRefSchema.safeParse(entry); + if (!parsed.success) continue; + const key = `${parsed.data.providerID}/${parsed.data.modelID}`; + if (seen.has(key)) continue; + seen.add(key); + result.push(parsed.data); + if (result.length >= limit) break; + } + return result; + }), +); + +export const parseRecentEfforts = fromSchema( + looseObject.transform((record) => { + const result: Record = {}; + for (const [key, variants] of Object.entries(record)) { + if (!key) continue; + const parsed = stringEntries.safeParse(variants); + if (!parsed.success) continue; + const unique = Array.from(new Set(parsed.data.map(String))); + if (unique.length > 0) result[key] = unique.slice(0, 5); + } + return result; + }).refine((record) => Object.keys(record).length > 0), +); + +export const parseShortcutOverrides = fromSchema( + looseObject.transform((record) => { + const result: Record = {}; + for (const [key, combo] of Object.entries(record)) { + const normalizedKey = key.trim(); + const normalizedCombo = nonEmptyTrimmed.safeParse(combo); + if (!normalizedKey || !normalizedCombo.success) continue; + result[normalizedKey] = normalizedCombo.data; + } + return result; + }), +); + +const DEFAULT_NOTIFICATION_TEMPLATES: NotificationTemplates = { + completion: { title: 'Task Complete', message: 'Your task has finished.' }, + error: { title: 'Error Occurred', message: 'An error occurred while processing your task.' }, + question: { title: 'Input Needed', message: 'Please provide input to continue.' }, + subtask: { title: 'Subtask Complete', message: 'A subtask has finished.' }, +}; + +const notificationTemplateSchema = z.object({ + title: z.string().catch(''), + message: z.string().catch(''), +}); + +export const parseNotificationTemplates = fromSchema( + looseObject.transform((record) => { + const read = (key: keyof NotificationTemplates) => { + const parsed = notificationTemplateSchema.safeParse(record[key]); + return parsed.success ? parsed.data : undefined; + }; + const completion = read('completion'); + const error = read('error'); + const question = read('question'); + const subtask = read('subtask'); + if (!completion && !error && !question && !subtask) return undefined; + return { + completion: completion ?? DEFAULT_NOTIFICATION_TEMPLATES.completion, + error: error ?? DEFAULT_NOTIFICATION_TEMPLATES.error, + question: question ?? DEFAULT_NOTIFICATION_TEMPLATES.question, + subtask: subtask ?? DEFAULT_NOTIFICATION_TEMPLATES.subtask, + }; + }).pipe(z.custom((value) => value !== undefined)), +); + +const stringMap = looseObject.transform((record) => Object.fromEntries( + Object.entries(record).flatMap(([key, value]) => { + const parsed = z.string().safeParse(value); + return parsed.success ? [[key, parsed.data] as const] : []; + }), +)); + +const customGroupSchema = z.object({ + id: z.unknown().transform((value) => String(value ?? '')), + label: z.unknown().transform((value) => String(value ?? '')), + models: z.array(z.unknown()).transform((models) => models.filter((model) => z.string().safeParse(model).success).map(String)).catch([]), + order: z.number().catch(0), +}); + +export const parseUsageModelGroups = fromSchema( + looseObject.transform((record) => { + const result: UsageModelGroups = {}; + for (const [providerId, config] of Object.entries(record)) { + const parsedConfig = looseObject.safeParse(config); + if (!parsedConfig.success) continue; + const providerConfig: UsageModelGroups[string] = {}; + const customGroups = z.array(z.unknown()).safeParse(parsedConfig.data.customGroups); + if (customGroups.success) { + providerConfig.customGroups = customGroups.data.flatMap((group) => { + const parsed = customGroupSchema.safeParse(group); + return parsed.success ? [parsed.data] : []; + }); + } + const modelAssignments = stringMap.safeParse(parsedConfig.data.modelAssignments); + if (modelAssignments.success) providerConfig.modelAssignments = modelAssignments.data; + const renamedGroups = stringMap.safeParse(parsedConfig.data.renamedGroups); + if (renamedGroups.success) providerConfig.renamedGroups = renamedGroups.data; + if (Object.keys(providerConfig).length > 0) result[providerId] = providerConfig; + } + return result; + }).refine((record) => Object.keys(record).length > 0), +); + +const managedRemoteTunnelPresetSchema = z.object({ + id: nonEmptyTrimmed, + name: nonEmptyTrimmed, + hostname: nonEmptyTrimmed.transform((value) => value.toLowerCase()), +}); + +export const parseManagedRemoteTunnelPresets = fromSchema( + z.array(z.unknown()).transform((entries) => { + const result: ManagedRemoteTunnelPreset[] = []; + const seenIds = new Set(); + const seenHostnames = new Set(); + for (const entry of entries) { + const parsed = managedRemoteTunnelPresetSchema.safeParse(entry); + if (!parsed.success) continue; + if (seenIds.has(parsed.data.id) || seenHostnames.has(parsed.data.hostname)) continue; + seenIds.add(parsed.data.id); + seenHostnames.add(parsed.data.hostname); + result.push(parsed.data); + } + return result; + }), +); + +export const parseManagedRemoteTunnelPresetTokens = fromSchema( + looseObject.transform((record) => { + const result: Record = {}; + for (const [key, token] of Object.entries(record)) { + const id = key.trim(); + const parsedToken = nonEmptyTrimmed.safeParse(token); + if (!id || !parsedToken.success) continue; + result[id] = parsedToken.data; + } + return result; + }).refine((record) => Object.keys(record).length > 0), +); + +const skillCatalogSchema = z.object({ + id: nonEmptyTrimmed, + label: nonEmptyTrimmed, + source: nonEmptyTrimmed, + subpath: trimmed.optional().catch(undefined), + gitIdentityId: trimmed.optional().catch(undefined), +}); + +export const parseSkillCatalogs = fromSchema( + z.array(z.unknown()).transform((entries) => { + const result: SkillCatalogConfig[] = []; + const seen = new Set(); + for (const entry of entries) { + const parsed = skillCatalogSchema.safeParse(entry); + if (!parsed.success || seen.has(parsed.data.id)) continue; + seen.add(parsed.data.id); + const catalog: SkillCatalogConfig = { id: parsed.data.id, label: parsed.data.label, source: parsed.data.source }; + if (parsed.data.subpath) catalog.subpath = parsed.data.subpath; + if (parsed.data.gitIdentityId) catalog.gitIdentityId = parsed.data.gitIdentityId; + result.push(catalog); + } + return result; + }), +); + +const HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/; + +const nonNegativeFinite = finiteNumber.nonnegative(); + +const projectEntrySchema = z.object({ + path: nonEmptyTrimmed, + label: nonEmptyTrimmed.optional().catch(undefined), + icon: nonEmptyTrimmed.optional().catch(undefined), + iconImage: z.union([ + z.null(), + z.object({ + mime: nonEmptyTrimmed, + updatedAt: nonNegativeFinite.transform(Math.round).pipe(z.number().positive()), + source: z.enum(['custom', 'auto']), + }), + ]).optional().catch(undefined), + color: nonEmptyTrimmed.optional().catch(undefined), + iconBackground: z.union([ + z.null(), + trimmed.pipe(z.string().regex(HEX_COLOR_PATTERN)).transform((value) => value.toLowerCase()), + ]).optional().catch(undefined), + addedAt: nonNegativeFinite.optional().catch(undefined), + lastOpenedAt: nonNegativeFinite.optional().catch(undefined), + sidebarCollapsed: z.boolean().optional().catch(undefined), +}); + +export const parseProjects = fromSchema( + z.array(z.unknown()).transform((entries) => { + const result: ProjectEntry[] = []; + const seenIds = new Set(); + const seenPaths = new Set(); + for (const entry of entries) { + const parsed = projectEntrySchema.safeParse(entry); + if (!parsed.success) continue; + const rawPath = parsed.data.path; + const normalizedPath = rawPath === '/' ? rawPath : rawPath.replace(/\\/g, '/').replace(/\/+$/, ''); + if (!normalizedPath) continue; + const id = createProjectIdFromPath(normalizedPath); + if (!id || seenIds.has(id) || seenPaths.has(normalizedPath)) continue; + seenIds.add(id); + seenPaths.add(normalizedPath); + + const project: ProjectEntry = { id, path: normalizedPath }; + if (parsed.data.label) project.label = parsed.data.label; + if (parsed.data.icon) project.icon = parsed.data.icon; + if (parsed.data.iconImage !== undefined) project.iconImage = parsed.data.iconImage; + if (parsed.data.color) project.color = parsed.data.color; + if (parsed.data.iconBackground !== undefined) project.iconBackground = parsed.data.iconBackground; + if (parsed.data.addedAt !== undefined) project.addedAt = parsed.data.addedAt; + if (parsed.data.lastOpenedAt !== undefined) project.lastOpenedAt = parsed.data.lastOpenedAt; + if (parsed.data.sidebarCollapsed !== undefined) project.sidebarCollapsed = parsed.data.sidebarCollapsed; + result.push(project); + } + return result; + }).refine((projects) => projects.length > 0), +); + +const followUpBehaviorSchema = z.enum(['steer', 'queue']); + +/** Legacy `queueModeEnabled` → `followUpBehavior`; 'immediate' collapses onto 'steer'. */ +export const parseFollowUpBehavior: SettingsParser<'steer' | 'queue'> = (value, raw) => { + const direct = followUpBehaviorSchema.safeParse(value); + if (direct.success) return direct.data; + if (value === 'immediate') return 'steer'; + const legacy = z.boolean().safeParse(raw.queueModeEnabled); + if (!legacy.success) return undefined; + return legacy.data ? 'queue' : 'steer'; +}; + +/** Legacy provider names: 'server' was the OpenAI-compatible endpoint; 'browser'/'wasm' the local one. */ +export const parseSttProvider = fromSchema( + trimmed.pipe(z.enum(['local', 'openai-compatible', 'server', 'browser', 'wasm'])).transform((provider): 'local' | 'openai-compatible' => { + if (provider === 'server') return 'openai-compatible'; + if (provider === 'browser' || provider === 'wasm') return 'local'; + return provider; + }), +); + +/** Legacy 'auto' never read OS chrome config; it means right. */ +export const parseDesktopWindowControlsPosition = fromSchema( + trimmed.pipe(z.enum(['left', 'right', 'auto'])).transform((mode): 'left' | 'right' => (mode === 'left' ? 'left' : 'right')), +); + +export const parsePwaAppName = fromSchema( + trimmed.transform((value) => value.replace(/\s+/g, ' ').slice(0, 64)), +); + +/** Lower-cased, deduplicated shells; unknown names are dropped. */ +export const parseTerminalShells = (isShell: (value: string) => value is T): SettingsParser => fromSchema( + z.array(z.unknown()).transform((entries) => { + const shells: T[] = []; + for (const entry of entries) { + const parsed = trimmed.transform((value) => value.toLowerCase()).safeParse(entry); + if (parsed.success && isShell(parsed.data) && !shells.includes(parsed.data)) shells.push(parsed.data); + } + return shells; + }), +); + +/** A value accepted by a domain type guard (`isTerminalShell`, `isUiFontOption`, …). */ +export const parseGuarded = (isValid: (value: unknown) => value is T): SettingsParser => fromSchema( + z.custom(isValid), +); + +/** Map a parser's output; `undefined` from the mapper rejects the value. */ +export const mapParser = (parser: SettingsParser, map: (value: A) => B | undefined): SettingsParser => (value, raw) => { + const parsed = parser(value, raw); + return parsed === undefined ? undefined : map(parsed); +}; diff --git a/packages/ui/src/lib/settings/registry-snapshot.ts b/packages/ui/src/lib/settings/registry-snapshot.ts new file mode 100644 index 00000000..050d3947 --- /dev/null +++ b/packages/ui/src/lib/settings/registry-snapshot.ts @@ -0,0 +1,39 @@ +/** + * Generates `settings-registry.json` — the plain-data view of the registry the + * OpenChamber server (plain ESM, no bundler) and the VS Code extension host + * consume. Both are checked in; `registry.test.ts` fails when they are stale. + * + * bun run settings-registry:generate + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { buildSettingsRegistrySnapshot } from './registry'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..', '..', '..', '..'); + +/** Every checked-in copy of the snapshot, relative to the repo root. */ +export const SETTINGS_REGISTRY_SNAPSHOT_PATHS = [ + 'packages/web/server/lib/opencode/settings-registry.json', + 'packages/vscode/src/settings-registry.json', +] as const; + +export const renderSettingsRegistrySnapshot = (): string => `${JSON.stringify(buildSettingsRegistrySnapshot(), null, 2)}\n`; + +export const writeSettingsRegistrySnapshots = (): string[] => { + const rendered = renderSettingsRegistrySnapshot(); + return SETTINGS_REGISTRY_SNAPSHOT_PATHS.map((relativePath) => { + const target = resolve(repoRoot, relativePath); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, rendered, 'utf8'); + return target; + }); +}; + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + for (const target of writeSettingsRegistrySnapshots()) { + console.log(`wrote ${target}`); + } +} diff --git a/packages/ui/src/lib/settings/registry.test.ts b/packages/ui/src/lib/settings/registry.test.ts new file mode 100644 index 00000000..dcdebd87 --- /dev/null +++ b/packages/ui/src/lib/settings/registry.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { z } from 'zod'; + +import { useUIStore } from '@/stores/useUIStore'; +import { + AUTO_SAVE_KEYS, + DESKTOP_SHELL_KEYS, + LOCAL_DEVICE_KEYS, + MIRRORED_KEYS, + SETTINGS_KEYS, + SETTINGS_REGISTRY, + applySettingsToStores, + buildSettingsRegistrySnapshot, + parseSettingsDocument, +} from './registry'; +import { renderSettingsRegistrySnapshot, SETTINGS_REGISTRY_SNAPSHOT_PATHS } from './registry-snapshot'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', '..'); + +describe('settings registry', () => { + test('every key lives in exactly one table', () => { + const all = [...SETTINGS_KEYS, ...LOCAL_DEVICE_KEYS, ...DESKTOP_SHELL_KEYS]; + expect(new Set(all).size).toBe(all.length); + }); + + test('covers every key the ui-store persists, under its settings name', () => { + const partialize = useUIStore.persist.getOptions().partialize; + expect(partialize).toBeTruthy(); + // zustand types the persisted slice loosely; only its key names matter here. + const persistedKeys = Object.keys(z.object({}).passthrough().parse(partialize!(useUIStore.getInitialState()))); + // The store's name for the shared `draftStarters` field. + const aliases = new Map([['globalDraftStarters', 'draftStarters']]); + const known = new Set([...SETTINGS_KEYS, ...LOCAL_DEVICE_KEYS]); + const missing = persistedKeys.map((key) => aliases.get(key) ?? key).filter((key) => !known.has(key)); + expect(missing).toEqual([]); + }); + + test('per-surface storage is a profile-only marker', () => { + for (const key of SETTINGS_KEYS) { + if (SETTINGS_REGISTRY[key].perSurface) { + expect(SETTINGS_REGISTRY[key].scope).toBe('profile'); + } + } + }); + + test('computed and secret fields never reach the mirror; computed ones are never auto-saved', () => { + for (const key of MIRRORED_KEYS) { + expect(SETTINGS_REGISTRY[key].secret).toBe(undefined); + expect(SETTINGS_REGISTRY[key].computed).toBe(undefined); + expect(SETTINGS_REGISTRY[key].scope).not.toBe('device'); + } + for (const key of AUTO_SAVE_KEYS) { + expect(SETTINGS_REGISTRY[key].computed).toBe(undefined); + } + }); + + test('parses a document at the boundary: unknown keys dropped, rejected values absent, legacy keys mapped', () => { + const parsed = parseSettingsDocument({ + fontSize: 15, + toolJsonViewMode: 'invalid', + queueModeEnabled: false, + gitProviderId: 'anthropic', + markdownDisplayMode: 'x', + autoDeleteAfterDays: 900, + sttProvider: 'server', + }); + expect(parsed).toEqual({ + fontSize: 15, + followUpBehavior: 'steer', + queueModeEnabled: false, + autoDeleteAfterDays: 365, + sttProvider: 'openai-compatible', + }); + expect(parseSettingsDocument(null)).toBeNull(); + expect(parseSettingsDocument([])).toBeNull(); + }); + + test('applies only the fields a snapshot carries and leaves the rest alone', () => { + useUIStore.getState().setTerminalShell('fish'); + useUIStore.getState().setShowReasoningTraces(true); + applySettingsToStores({ showReasoningTraces: false }); + expect(useUIStore.getState().showReasoningTraces).toBe(false); + expect(useUIStore.getState().terminalShell).toBe('fish'); + }); + + test('applies the hidden-sections list together with its explicit marker', () => { + applySettingsToStores({ workStatusHiddenSections: ['mcp', 'telemetry'] }); + expect(useUIStore.getState().workStatusHiddenSections).toEqual(['mcp']); + expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(false); + applySettingsToStores({ workStatusHiddenSections: ['mcp', 'telemetry'], workStatusHiddenSectionsExplicit: true }); + expect(useUIStore.getState().workStatusHiddenSections).toEqual(['mcp', 'telemetry']); + expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(true); + }); + + test('the checked-in JSON snapshots match the registry (run `bun run settings-registry:generate`)', () => { + const rendered = renderSettingsRegistrySnapshot(); + for (const relativePath of SETTINGS_REGISTRY_SNAPSHOT_PATHS) { + expect(readFileSync(resolve(repoRoot, relativePath), 'utf8')).toBe(rendered); + } + }); + + test('the snapshot names every key of every table', () => { + const snapshot = buildSettingsRegistrySnapshot(); + const keys = Object.keys(snapshot.fields); + expect(keys.length).toBe(SETTINGS_KEYS.length + LOCAL_DEVICE_KEYS.length + DESKTOP_SHELL_KEYS.length); + for (const key of LOCAL_DEVICE_KEYS) expect(snapshot.fields[key]).toEqual({ scope: 'device', local: true }); + for (const key of DESKTOP_SHELL_KEYS) expect(snapshot.fields[key].owner).toBe('desktop-shell'); + }); +}); diff --git a/packages/ui/src/lib/settings/registry.ts b/packages/ui/src/lib/settings/registry.ts new file mode 100644 index 00000000..f9465cc9 --- /dev/null +++ b/packages/ui/src/lib/settings/registry.ts @@ -0,0 +1,702 @@ +/** + * The settings registry: one table that names every OpenChamber setting, who + * owns it (`scope`), how a value is parsed at the boundary, and where the UI + * keeps its live copy. + * + * Everything else about settings derives from this table: the `DesktopSettings` + * type, the boundary sanitizer, the per-runtime mirror, the store apply step, + * the store-subscribing auto-save, and — through the generated JSON snapshot + * (`settings-registry.json`, see `registry-snapshot.ts`) — the server's and the + * VS Code bridge's key lists. A key that is not here does not persist. + * + * Scopes (see `.opencode/plans/settings-scopes.md`): + * - `instance`: a fact about the machine the server runs on. Never synced. + * - `profile`: the person's preference. Synced to every client of the + * instance; a few are stored per surface kind (`perSurface`). + * - `device`: state of this install/surface. Fields that still cross the wire + * today are listed here so Phase 2 can stop them deliberately; fields that + * only ever lived in the local store are in `LOCAL_DEVICE_KEYS`. + */ +import type { ProjectEntry, TerminalShell } from '@/lib/api/types'; +import type { DesktopWindowControlsPosition, DesktopWindowControlsStyle } from '@/lib/desktop'; +import { getDirectoryShowHidden, setDirectoryShowHidden } from '@/lib/directoryShowHidden'; +import type { DraftStarterRef } from '@/lib/draftStarters'; +import { sanitizeStarterRefs } from '@/lib/draftStarters'; +import { getFilesViewShowGitignored, setFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; +import { isMonoFontOption, isUiFontOption, type MonoFontOption, type UiFontOption } from '@/lib/fontOptions'; +import { isInputHistoryLimit, isInputHistoryScope, type InputHistoryScope } from '@/lib/inputHistoryScope'; +import { normalizeMobileKeyboardMode } from '@/lib/mobileKeyboardMode'; +import { isTerminalShell } from '@/lib/terminalShell'; +import { sanitizeWorkStatusHiddenSections } from '@/components/chat/work-status/sections'; +import { useInputHistoryStore } from '@/stores/useInputHistoryStore'; +import { useMessageQueueStore } from '@/stores/messageQueueStore'; +import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; +import { useUIStore, type FileEditorKeymap, type LargeTextPasteBehavior } from '@/stores/useUIStore'; +import { z } from 'zod'; +import { + fromSchema, + mapParser, + parseBoolean, + parseDesktopWindowControlsPosition, + parseFiniteNumber, + parseFollowUpBehavior, + parseGuarded, + parseIntegerAtLeast, + parseIntegerInRange, + parseManagedRemoteTunnelPresetTokens, + parseManagedRemoteTunnelPresets, + parseModelRefs, + parseNonEmptyString, + parseNonEmptyTrimmedString, + parseNotificationTemplates, + parseNullableFiniteNumber, + parseNullableTrimmedPath, + parseNullableTrimmedString, + parseOneOf, + parsePositiveInteger, + parseProjects, + parsePwaAppName, + parseRecentEfforts, + parseShortcutOverrides, + parseSkillCatalogs, + parseStringList, + parseStringRecordOfStringLists, + parseStringSet, + parseSttProvider, + parseTerminalShells, + parseTextUpTo, + parseTrimmedString, + parseTrimmedStringUpTo, + parseUsageModelGroups, + type ManagedRemoteTunnelPreset, + type ModelRef, + type NotificationTemplates, + type SettingsParser, + type SettingsRawDocument, + type SkillCatalogConfig, + type UsageModelGroups, +} from './parsers'; + +export type SettingsScope = 'instance' | 'profile' | 'device'; +export type SettingsSurface = 'web' | 'desktop' | 'vscode' | 'mobile'; + +/** + * The siblings a field's `write` may consult in the same parsed snapshot. Named + * explicitly (not `DesktopSettings`) so the registry's type does not refer to + * itself through the bindings; extend it when another field needs a sibling. + */ +export type SettingsSiblingView = { + readonly draftStartersCraftGoalAdded?: boolean; + readonly draftStartersScheduleTaskAdded?: boolean; + readonly workStatusHiddenSectionsExplicit?: boolean; +}; + +/** + * How the UI keeps a live copy of a field, when it keeps one at all. Method + * syntax on purpose: it keeps `SettingsFieldSpec` assignable to + * `SettingsFieldSpec`, which is what the generic loops below iterate. + */ +export type SettingsUiBinding = { + read(): T | undefined; + write(value: T, snapshot: SettingsSiblingView): void; + /** Send changes of the backing store to the server (store-subscribing auto-save). */ + autoSave: boolean; +}; + +export type SettingsFieldSpec = { + scope: SettingsScope; + parse(value: unknown, raw: SettingsRawDocument): T | undefined; + ui?: SettingsUiBinding; + /** Profile fields the owner chose to store per surface kind (change on a phone stays on phones). */ + perSurface?: true; + /** Surfaces that have this field; absent means all. */ + surfaces?: readonly SettingsSurface[]; + /** Workspace pointers: adopted only on a bootstrap-grade sync (see `SettingsSyncedDetail`). */ + adopt?: 'bootstrap-only'; + /** Computed by the writer from other fields; never edited directly. */ + derived?: true; + /** Accepted on write, never returned by a read. */ + secret?: true; + /** Emitted by the server for this build/process; never accepted on a write, never persisted. */ + computed?: true; +}; + +const field = (spec: SettingsFieldSpec): SettingsFieldSpec => spec; + +type UIStoreState = ReturnType; + +/** A field whose live copy is one `useUIStore` key, written through its setter. */ +const uiStore = ( + key: K, + write: (value: UIStoreState[K], snapshot: SettingsSiblingView) => void, + options: { autoSave?: boolean } = {}, +): SettingsUiBinding => ({ + read: () => useUIStore.getState()[key], + write, + autoSave: options.autoSave ?? true, +}); + +const setUi = (key: K) => (value: UIStoreState[K]): void => { + // SAFETY: a single-key patch built from the key it is typed by. + useUIStore.setState({ [key]: value } as Pick); +}; + +// The config store is reached through the global it registers on `window` +// (`useConfigStore` imports the shared write path, so a direct import here +// would be a load-order cycle). Absent outside the browser. +const configStore = () => globalThis.window?.__zustand_config_store__ ?? null; + +type ConfigStoreState = NonNullable>['getState']>>; + +const configField = ( + key: K, +): SettingsUiBinding => ({ + read: () => configStore()?.getState()[key], + write: (value) => { + // SAFETY: a single-key patch built from the key it is typed by. + configStore()?.setState({ [key]: value } as Pick); + }, + // The config store's own setters write these through `updateDesktopSettings`. + autoSave: false, +}); + +type SessionDisplayState = ReturnType; + +const sessionDisplayField = ( + key: K, +): SettingsUiBinding => ({ + read: () => useSessionDisplayStore.getState()[key], + write: (value) => { + // SAFETY: a single-key patch built from the key it is typed by. + useSessionDisplayStore.setState({ [key]: value } as Pick); + }, + autoSave: false, +}); + +const RESPONSE_STYLE_PRESETS = ['concise', 'detailed', 'mentor', 'pushback', 'noFiller', 'matchEnergy', 'warmPeer', 'custom'] as const; + +const parseTerminalShell: SettingsParser = parseGuarded(isTerminalShell); +const parseUiFont: SettingsParser = parseGuarded(isUiFontOption); +const parseMonoFont: SettingsParser = parseGuarded(isMonoFontOption); +const parseInputHistoryScope: SettingsParser = fromSchema(z.string().refine(isInputHistoryScope)); +const parseInputHistoryLimit: SettingsParser = fromSchema(z.number().refine(isInputHistoryLimit)); +const parseMobileKeyboardModeValue = mapParser(parseTrimmedString, (value) => normalizeMobileKeyboardMode(value, undefined)); +const parseDraftStarters: SettingsParser = mapParser(fromSchema(z.array(z.unknown())), sanitizeStarterRefs); +// Unknown ids are dropped rather than kept: they would hide nothing and +// accumulate forever as sections get renamed. +const parseWorkStatusHiddenSections: SettingsParser = mapParser(fromSchema(z.array(z.unknown())), (value) => sanitizeWorkStatusHiddenSections(value)); +const parseLargeTextPasteBehavior: SettingsParser = parseOneOf(['ask', 'attach', 'inline']); +const parseFileEditorKeymap: SettingsParser = parseOneOf(['default', 'vim']); + +/** + * Removing a built-in starter must stay a durable choice, so the list is only + * patched with the built-ins when the corresponding marker says they were + * never offered. The markers travel with the user's edit (useDraftStarters). + */ +const withOfferedBuiltInStarters = (starters: DraftStarterRef[], snapshot: SettingsSiblingView): DraftStarterRef[] => { + let next = starters; + const insertAfter = (name: string, after: string) => { + if (next.some((starter) => starter.type === 'command' && starter.name === name)) return; + const anchor = next.findIndex((starter) => starter.type === 'command' && starter.name === after); + const insertAt = anchor >= 0 ? anchor + 1 : next.length; + next = [...next.slice(0, insertAt), { type: 'command', name }, ...next.slice(insertAt)]; + }; + if (snapshot.draftStartersCraftGoalAdded !== true) insertAfter('craft-goal', 'plan-feature'); + if (snapshot.draftStartersScheduleTaskAdded !== true) insertAfter('schedule-task', 'craft-goal'); + return next; +}; + +export const SETTINGS_REGISTRY = { + // ── Theme (profile, per surface; ThemeSystemContext owns the live copy) ── + themeId: field({ scope: 'profile', perSurface: true, parse: parseNonEmptyString }), + useSystemTheme: field({ scope: 'profile', perSurface: true, parse: parseBoolean }), + themeVariant: field({ scope: 'profile', derived: true, parse: parseOneOf(['light', 'dark']) }), + lightThemeId: field({ scope: 'profile', perSurface: true, parse: parseNonEmptyString }), + darkThemeId: field({ scope: 'profile', perSurface: true, parse: parseNonEmptyString }), + + // ── Workspace pointers and instance facts ── + lastDirectory: field({ scope: 'instance', adopt: 'bootstrap-only', parse: parseNonEmptyString }), + homeDirectory: field({ scope: 'instance', parse: parseNonEmptyString }), + opencodeBinary: field({ scope: 'instance', parse: parseTrimmedString }), + projects: field({ scope: 'instance', parse: parseProjects }), + activeProjectId: field({ scope: 'instance', adopt: 'bootstrap-only', parse: parseNonEmptyString }), + securityScopedBookmarks: field({ scope: 'instance', surfaces: ['desktop'], parse: parseStringList }), + pinnedDirectories: field({ scope: 'instance', parse: parseStringSet }), + desktopLanAccessEnabled: field({ scope: 'instance', surfaces: ['desktop'], parse: parseBoolean }), + desktopKeepAwakeEnabled: field({ scope: 'instance', surfaces: ['desktop'], parse: parseBoolean }), + desktopMinimizeToTrayEnabled: field({ scope: 'instance', surfaces: ['desktop'], parse: parseBoolean }), + desktopMacMenuBarEnabled: field({ scope: 'instance', surfaces: ['desktop'], parse: parseBoolean }), + // Write-only: the desktop network page learns whether one is set from + // `hasDesktopUiPassword` and sends a value only when the user types a new + // one (or removes it with an empty string). + desktopUiPassword: field({ scope: 'instance', secret: true, surfaces: ['desktop'], parse: parseTrimmedString }), + hasDesktopUiPassword: field({ scope: 'instance', computed: true, surfaces: ['desktop'], parse: parseBoolean }), + desktopLanAccessActive: field({ scope: 'instance', computed: true, surfaces: ['desktop'], parse: parseBoolean }), + desktopLanAccessBlockedReason: field({ scope: 'instance', computed: true, surfaces: ['desktop'], parse: parseTrimmedString }), + githubClientId: field({ scope: 'instance', parse: parseNonEmptyTrimmedString }), + githubScopes: field({ scope: 'instance', parse: parseNonEmptyTrimmedString }), + skillCatalogs: field({ scope: 'instance', parse: parseSkillCatalogs }), + defaultGitIdentityId: field({ scope: 'instance', parse: parseTrimmedString }), + gitProviderId: field({ scope: 'instance', parse: parseTrimmedString }), + gitModelId: field({ scope: 'instance', parse: parseTrimmedString }), + gitProviders: field>({ + scope: 'instance', + parse: fromSchema(z.record(z.string(), z.object({ + apiBaseUrl: z.string().optional(), + detectUrls: z.array(z.string()).optional(), + }).partial()).optional()), + }), + permissionAutoAccept: field({ + scope: 'instance', + parse: fromSchema(z.object({ + sessions: z.record(z.string().min(1), z.boolean()).catch({}), + revision: z.number().int().nonnegative().catch(0), + })), + }), + agentControlToolEnabled: field({ scope: 'instance', parse: parseBoolean, ui: uiStore('agentControlToolEnabled', (v) => useUIStore.getState().setAgentControlToolEnabled(v)) }), + agentWebToolEnabled: field({ scope: 'instance', parse: parseBoolean, ui: uiStore('agentWebToolEnabled', (v) => useUIStore.getState().setAgentWebToolEnabled(v)) }), + agentMemoryToolEnabled: field({ scope: 'instance', parse: parseBoolean, ui: uiStore('agentMemoryToolEnabled', (v) => useUIStore.getState().setAgentMemoryToolEnabled(v)) }), + // Server-owned: it says whether this build has the feature at all. + agentMemoryFeatureAvailable: field({ + scope: 'instance', + computed: true, + parse: parseBoolean, + ui: uiStore('agentMemoryFeatureAvailable', (v) => useUIStore.getState().setAgentMemoryFeatureAvailable(v), { autoSave: false }), + }), + openCodeUpdateToastDismissedVersion: field({ scope: 'instance', parse: parseTrimmedStringUpTo(128) }), + autoDeleteEnabled: field({ scope: 'instance', parse: parseBoolean, ui: uiStore('autoDeleteEnabled', (v) => useUIStore.getState().setAutoDeleteEnabled(v)) }), + autoDeleteAfterDays: field({ scope: 'instance', parse: parseIntegerInRange(1, 365), ui: uiStore('autoDeleteAfterDays', (v) => useUIStore.getState().setAutoDeleteAfterDays(v)) }), + sessionRetentionAction: field({ scope: 'instance', parse: parseOneOf(['archive', 'delete']), ui: uiStore('sessionRetentionAction', (v) => useUIStore.getState().setSessionRetentionAction(v)) }), + terminalShell: field({ scope: 'instance', parse: parseTerminalShell, ui: uiStore('terminalShell', (v) => useUIStore.getState().setTerminalShell(v)) }), + terminalLoginShells: field({ scope: 'instance', parse: parseTerminalShells(isTerminalShell), ui: uiStore('terminalLoginShells', (v) => useUIStore.getState().setTerminalLoginShells(v)) }), + openInAppId: field({ scope: 'instance', parse: parseNonEmptyTrimmedString }), + dictationEnabled: field({ scope: 'profile', parse: parseBoolean, ui: configField('dictationEnabled') }), + sttProvider: field({ scope: 'instance', parse: parseSttProvider, ui: configField('sttProvider') }), + sttServerUrl: field({ scope: 'instance', parse: parseTrimmedStringUpTo(2048), ui: configField('sttServerUrl') }), + sttModel: field({ scope: 'instance', parse: parseTrimmedStringUpTo(256), ui: configField('sttModel') }), + sttLocalModel: field({ scope: 'instance', parse: parseTrimmedStringUpTo(256), ui: configField('sttLocalModel') }), + sttLanguage: field({ scope: 'profile', parse: parseTrimmedStringUpTo(64), ui: configField('sttLanguage') }), + + // ── Tunnels (instance) ── + tunnelProvider: field({ scope: 'instance', parse: mapParser(parseNonEmptyTrimmedString, (value) => value.toLowerCase()) }), + tunnelMode: field({ scope: 'instance', parse: fromSchema(z.string().transform((value) => value.trim().toLowerCase()).pipe(z.enum(['quick', 'managed-remote', 'managed-local']))) }), + tunnelBootstrapTtlMs: field({ scope: 'instance', parse: parseNullableFiniteNumber }), + tunnelSessionTtlMs: field({ scope: 'instance', parse: parseFiniteNumber }), + managedLocalTunnelConfigPath: field({ scope: 'instance', parse: parseNullableTrimmedPath }), + managedRemoteTunnelHostname: field({ scope: 'instance', parse: parseTrimmedString }), + managedRemoteTunnelToken: field({ scope: 'instance', secret: true, parse: parseNullableTrimmedString }), + hasManagedRemoteTunnelToken: field({ scope: 'instance', computed: true, parse: parseBoolean }), + managedRemoteTunnelPresets: field({ scope: 'instance', parse: parseManagedRemoteTunnelPresets }), + managedRemoteTunnelSelectedPresetId: field({ scope: 'instance', parse: parseNonEmptyTrimmedString }), + // Write-only: the tunnel page learns which presets have a token from the + // tunnel status endpoint (`managedRemoteTunnelTokenPresetIds`), never from here. + managedRemoteTunnelPresetTokens: field({ scope: 'instance', secret: true, parse: parseManagedRemoteTunnelPresetTokens }), + + // ── Sidebar display (profile; useSessionDisplayStore) ── + sidebarProjectDisplayMode: field({ scope: 'profile', parse: parseOneOf(['all', 'single']), ui: sessionDisplayField('projectDisplayMode') }), + sidebarSessionGroupingMode: field({ scope: 'profile', parse: parseOneOf(['by-worktree', 'flat']), ui: sessionDisplayField('sessionGroupingMode') }), + sidebarProjectSortOrder: field({ scope: 'profile', parse: parseOneOf(['manual', 'a-z', 'z-a', 'date-added', 'recent']), ui: sessionDisplayField('projectSortOrder') }), + sidebarShowRecentSection: field({ scope: 'profile', parse: parseBoolean, ui: sessionDisplayField('showRecentSection') }), + + // ── Work status ── + workStatusPanelEnabled: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('workStatusPanelEnabled', (v) => useUIStore.getState().setWorkStatusPanelEnabled(v)) }), + workStatusHiddenSections: field({ + scope: 'profile', + parse: parseWorkStatusHiddenSections, + ui: { + read: () => useUIStore.getState().workStatusHiddenSections, + // The explicit marker distinguishes chosen lists from the old telemetry + // default; both land in one store update so subscribers never see the + // list without its marker. + write: (value, snapshot) => { + const explicit = snapshot.workStatusHiddenSectionsExplicit === true; + useUIStore.setState({ + workStatusHiddenSections: sanitizeWorkStatusHiddenSections(value, explicit), + workStatusHiddenSectionsExplicit: explicit, + }); + }, + autoSave: true, + }, + }), + workStatusHiddenSectionsExplicit: field({ + scope: 'profile', + parse: parseBoolean, + // Applied together with the list above. + ui: uiStore('workStatusHiddenSectionsExplicit', () => undefined), + }), + + // ── Chat and rendering (profile) ── + showReasoningTraces: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('showReasoningTraces', (v) => useUIStore.getState().setShowReasoningTraces(v)) }), + streamingAutoFollowEnabled: field({ scope: 'profile', perSurface: true, parse: parseBoolean, ui: uiStore('streamingAutoFollowEnabled', (v) => useUIStore.getState().setStreamingAutoFollowEnabled(v)) }), + collapsibleThinkingBlocks: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('collapsibleThinkingBlocks', (v) => useUIStore.getState().setCollapsibleThinkingBlocks(v)) }), + showTextJustificationActivity: field({ scope: 'profile', parse: parseBoolean }), + chatRenderMode: field({ scope: 'profile', parse: parseOneOf(['sorted', 'live']), ui: uiStore('chatRenderMode', (v) => useUIStore.getState().setChatRenderMode(v)) }), + activityRenderMode: field({ scope: 'profile', parse: parseOneOf(['collapsed', 'summary']), ui: uiStore('activityRenderMode', (v) => useUIStore.getState().setActivityRenderMode(v)) }), + mermaidRenderingMode: field({ scope: 'profile', parse: parseOneOf(['svg', 'ascii']), ui: uiStore('mermaidRenderingMode', (v) => useUIStore.getState().setMermaidRenderingMode(v)) }), + userMessageRenderingMode: field({ scope: 'profile', parse: parseOneOf(['markdown', 'plain']), ui: uiStore('userMessageRenderingMode', (v) => useUIStore.getState().setUserMessageRenderingMode(v)) }), + collapsibleUserMessages: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('collapsibleUserMessages', (v) => useUIStore.getState().setCollapsibleUserMessages(v)) }), + stickyUserHeader: field({ scope: 'profile', perSurface: true, parse: parseBoolean, ui: uiStore('stickyUserHeader', (v) => useUIStore.getState().setStickyUserHeader(v)) }), + promptNavigatorEnabled: field({ scope: 'profile', perSurface: true, parse: parseBoolean, ui: uiStore('promptNavigatorEnabled', (v) => useUIStore.getState().setPromptNavigatorEnabled(v)) }), + wideChatLayoutEnabled: field({ scope: 'profile', perSurface: true, parse: parseBoolean, ui: uiStore('wideChatLayoutEnabled', (v) => useUIStore.getState().setWideChatLayoutEnabled(v)) }), + showSplitAssistantMessageActions: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('showSplitAssistantMessageActions', (v) => useUIStore.getState().setShowSplitAssistantMessageActions(v)) }), + showToolFileIcons: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('showToolFileIcons', (v) => useUIStore.getState().setShowToolFileIcons(v)) }), + codeBlockLineWrap: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('codeBlockLineWrap', (v) => useUIStore.getState().setCodeBlockLineWrap(v)) }), + showTurnChangedFiles: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('showTurnChangedFiles', (v) => useUIStore.getState().setShowTurnChangedFiles(v)) }), + showExpandedBashTools: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('showExpandedBashTools', (v) => useUIStore.getState().setShowExpandedBashTools(v)) }), + showExpandedEditTools: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('showExpandedEditTools', (v) => useUIStore.getState().setShowExpandedEditTools(v)) }), + toolJsonViewMode: field({ scope: 'profile', parse: parseOneOf(['summary', 'formatted', 'raw']), ui: uiStore('toolJsonViewMode', (v) => useUIStore.getState().setToolJsonViewMode(v)) }), + timeFormatPreference: field({ scope: 'profile', parse: parseOneOf(['auto', '12h', '24h']), ui: uiStore('timeFormatPreference', (v) => useUIStore.getState().setTimeFormatPreference(v)) }), + weekStartPreference: field({ scope: 'profile', parse: parseOneOf(['auto', 'sunday', 'monday']), ui: uiStore('weekStartPreference', (v) => useUIStore.getState().setWeekStartPreference(v)) }), + messageStreamTransport: field({ scope: 'profile', parse: parseOneOf(['auto', 'ws', 'sse']), ui: configField('settingsMessageStreamTransport') }), + diffLayoutPreference: field({ scope: 'profile', parse: parseOneOf(['dynamic', 'inline', 'side-by-side']), ui: uiStore('diffLayoutPreference', (v) => useUIStore.getState().setDiffLayoutPreference(v)) }), + diffWrapLines: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('diffWrapLines', (v) => useUIStore.getState().setDiffWrapLines(v)) }), + gitChangesViewMode: field({ scope: 'profile', parse: parseOneOf(['flat', 'tree']), ui: uiStore('gitChangesViewMode', (v) => useUIStore.getState().setGitChangesViewMode(v)) }), + gitmojiEnabled: field({ scope: 'profile', parse: parseBoolean }), + defaultFileViewerPreview: field({ scope: 'profile', parse: parseBoolean }), + directoryShowHidden: field({ + scope: 'profile', + parse: parseBoolean, + ui: { read: getDirectoryShowHidden, write: (v) => setDirectoryShowHidden(v, { persist: false }), autoSave: false }, + }), + filesViewShowGitignored: field({ + scope: 'profile', + parse: parseBoolean, + ui: { read: getFilesViewShowGitignored, write: (v) => setFilesViewShowGitignored(v, { persist: false }), autoSave: false }, + }), + fileEditorKeymap: field({ scope: 'profile', parse: parseFileEditorKeymap, ui: uiStore('fileEditorKeymap', (v) => useUIStore.getState().setFileEditorKeymap(v)) }), + autoSaveEnabled: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('autoSaveEnabled', (v) => useUIStore.getState().setAutoSaveEnabled(v)) }), + autoCreateWorktree: field({ scope: 'profile', parse: parseBoolean }), + sessionTabsEnabled: field({ scope: 'profile', surfaces: ['web', 'desktop', 'vscode'], parse: parseBoolean, ui: uiStore('sessionTabsEnabled', (v) => useUIStore.getState().setSessionTabsEnabled(v)) }), + showOpenCodeRestartConfirm: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('showOpenCodeRestartConfirm', (v) => useUIStore.getState().setShowOpenCodeRestartConfirm(v)) }), + allowPromptingSubagentSessions: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('allowPromptingSubagentSessions', (v) => useUIStore.getState().setAllowPromptingSubagentSessions(v)) }), + + // ── Composer (profile) ── + inputSpellcheckEnabled: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('inputSpellcheckEnabled', (v) => useUIStore.getState().setInputSpellcheckEnabled(v)) }), + enterToSend: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('enterToSend', (v) => useUIStore.getState().setEnterToSend(v)) }), + enterToSendConfigured: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('enterToSendConfigured', (v) => useUIStore.getState().setEnterToSendConfigured(v)) }), + persistChatDraft: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('persistChatDraft', (v) => useUIStore.getState().setPersistChatDraft(v)) }), + largeTextPasteBehavior: field({ scope: 'profile', parse: parseLargeTextPasteBehavior, ui: uiStore('largeTextPasteBehavior', (v) => useUIStore.getState().setLargeTextPasteBehavior(v)) }), + followUpBehavior: field({ + scope: 'profile', + parse: parseFollowUpBehavior, + ui: { + read: () => useMessageQueueStore.getState().followUpBehavior, + write: (v) => useMessageQueueStore.getState().setFollowUpBehavior(v), + autoSave: false, + }, + }), + /** Legacy boolean that `followUpBehavior` absorbs at parse time. */ + queueModeEnabled: field({ scope: 'profile', parse: parseBoolean }), + inputHistoryScope: field({ + scope: 'profile', + parse: parseInputHistoryScope, + ui: { read: () => useInputHistoryStore.getState().scope, write: (v) => useInputHistoryStore.getState().applyScope(v), autoSave: false }, + }), + inputHistoryLimit: field({ + scope: 'profile', + parse: parseInputHistoryLimit, + ui: { read: () => useInputHistoryStore.getState().entryLimit, write: (v) => useInputHistoryStore.getState().applyEntryLimit(v), autoSave: false }, + }), + draftStarters: field({ + scope: 'profile', + parse: parseDraftStarters, + ui: { + read: () => useUIStore.getState().globalDraftStarters ?? undefined, + write: (value, snapshot) => useUIStore.getState().setGlobalDraftStarters(withOfferedBuiltInStarters(value, snapshot)), + // useDraftStarters writes the list together with its markers. + autoSave: false, + }, + }), + draftStartersVisible: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('draftStartersVisible', (v) => useUIStore.getState().setDraftStartersVisible(v)) }), + draftStartersCraftGoalAdded: field({ scope: 'profile', parse: parseBoolean }), + draftStartersScheduleTaskAdded: field({ scope: 'profile', parse: parseBoolean }), + + // ── Typography (profile; sizes per surface) ── + fontSize: field({ scope: 'profile', perSurface: true, parse: parseFiniteNumber, ui: uiStore('fontSize', (v) => useUIStore.getState().setFontSize(v)) }), + terminalFontSize: field({ scope: 'profile', perSurface: true, parse: parseFiniteNumber, ui: uiStore('terminalFontSize', (v) => useUIStore.getState().setTerminalFontSize(v)) }), + editorFontSize: field({ scope: 'profile', perSurface: true, parse: parseFiniteNumber, ui: uiStore('editorFontSize', (v) => useUIStore.getState().setEditorFontSize(v)) }), + uiFont: field({ scope: 'profile', parse: parseUiFont, ui: uiStore('uiFont', (v) => useUIStore.getState().setUiFont(v)) }), + monoFont: field({ scope: 'profile', parse: parseMonoFont, ui: uiStore('monoFont', (v) => useUIStore.getState().setMonoFont(v)) }), + padding: field({ scope: 'profile', perSurface: true, parse: parseFiniteNumber, ui: uiStore('padding', (v) => useUIStore.getState().setPadding(v)) }), + cornerRadius: field({ scope: 'profile', perSurface: true, parse: parseFiniteNumber, ui: uiStore('cornerRadius', (v) => useUIStore.getState().setCornerRadius(v)) }), + shortcutOverrides: field({ + scope: 'profile', + parse: parseShortcutOverrides, + ui: uiStore('shortcutOverrides', setUi('shortcutOverrides')), + }), + + // ── Models and agents (profile) ── + defaultModel: field({ scope: 'profile', parse: parseNonEmptyString }), + defaultVariant: field({ scope: 'profile', parse: parseNonEmptyString }), + defaultAgent: field({ scope: 'profile', parse: parseNonEmptyString }), + smallModelUseDefault: field({ scope: 'profile', parse: parseBoolean }), + smallModelOverride: field({ scope: 'profile', parse: parseNonEmptyString }), + walkthroughModelOverride: field({ scope: 'profile', parse: parseNonEmptyString }), + zenModel: field({ scope: 'profile', parse: parseNonEmptyTrimmedString }), + // The model-prefs auto-save owns these six with its own debounce. + favoriteModels: field({ scope: 'profile', parse: parseModelRefs(64), ui: uiStore('favoriteModels', setUi('favoriteModels'), { autoSave: false }) }), + hiddenModels: field({ scope: 'profile', parse: parseModelRefs(1024), ui: uiStore('hiddenModels', setUi('hiddenModels'), { autoSave: false }) }), + collapsedModelProviders: field({ scope: 'profile', parse: parseStringSet, ui: uiStore('collapsedModelProviders', setUi('collapsedModelProviders'), { autoSave: false }) }), + recentModels: field({ scope: 'profile', parse: parseModelRefs(16), ui: uiStore('recentModels', setUi('recentModels'), { autoSave: false }) }), + recentAgents: field({ scope: 'profile', parse: parseStringSet, ui: uiStore('recentAgents', setUi('recentAgents'), { autoSave: false }) }), + recentEfforts: field({ scope: 'profile', parse: parseRecentEfforts, ui: uiStore('recentEfforts', setUi('recentEfforts'), { autoSave: false }) }), + providerOrder: field({ scope: 'profile', parse: parseStringSet, ui: uiStore('providerOrder', (v) => useUIStore.getState().setProviderOrder(v)) }), + + // ── Sessions and summaries (profile) ── + sessionRecapEnabled: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('sessionRecapEnabled', (v) => useUIStore.getState().setSessionRecapEnabled(v)) }), + sessionSuggestionEnabled: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('sessionSuggestionEnabled', (v) => useUIStore.getState().setSessionSuggestionEnabled(v)) }), + sessionGoalEnabled: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('sessionGoalEnabled', (v) => useUIStore.getState().setSessionGoalEnabled(v)) }), + sessionGoalDefaultBudgetEnabled: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('sessionGoalDefaultBudgetEnabled', (v) => useUIStore.getState().setSessionGoalDefaultBudgetEnabled(v)) }), + sessionGoalDefaultBudget: field({ scope: 'profile', parse: parsePositiveInteger, ui: uiStore('sessionGoalDefaultBudget', (v) => useUIStore.getState().setSessionGoalDefaultBudget(v)) }), + summarizeLastMessage: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('summarizeLastMessage', (v) => useUIStore.getState().setSummarizeLastMessage(v)) }), + summaryThreshold: field({ scope: 'profile', parse: parseIntegerAtLeast(0), ui: uiStore('summaryThreshold', (v) => useUIStore.getState().setSummaryThreshold(v)) }), + summaryLength: field({ scope: 'profile', parse: parseIntegerAtLeast(10), ui: uiStore('summaryLength', (v) => useUIStore.getState().setSummaryLength(v)) }), + maxLastMessageLength: field({ scope: 'profile', parse: parseIntegerAtLeast(10), ui: uiStore('maxLastMessageLength', (v) => useUIStore.getState().setMaxLastMessageLength(v)) }), + showDeletionDialog: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('showDeletionDialog', (v) => useUIStore.getState().setShowDeletionDialog(v)) }), + + // ── Notifications (profile) ── + nativeNotificationsEnabled: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('nativeNotificationsEnabled', (v) => useUIStore.getState().setNativeNotificationsEnabled(v)) }), + notificationMode: field({ scope: 'profile', parse: parseOneOf(['always', 'hidden-only']), ui: uiStore('notificationMode', (v) => useUIStore.getState().setNotificationMode(v)) }), + notifyOnSubtasks: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('notifyOnSubtasks', (v) => useUIStore.getState().setNotifyOnSubtasks(v)) }), + notifyOnCompletion: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('notifyOnCompletion', (v) => useUIStore.getState().setNotifyOnCompletion(v)) }), + notifyOnError: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('notifyOnError', (v) => useUIStore.getState().setNotifyOnError(v)) }), + notifyOnQuestion: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('notifyOnQuestion', (v) => useUIStore.getState().setNotifyOnQuestion(v)) }), + notificationTemplates: field({ + scope: 'profile', + parse: parseNotificationTemplates, + ui: uiStore('notificationTemplates', (v) => useUIStore.getState().setNotificationTemplates(v)), + }), + showOpenCodeUpdateNotifications: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('showOpenCodeUpdateNotifications', (v) => useUIStore.getState().setShowOpenCodeUpdateNotifications(v)) }), + reportUsage: field({ scope: 'profile', parse: parseBoolean, ui: uiStore('reportUsage', (v) => useUIStore.getState().setReportUsage(v)) }), + + // ── Usage page (profile; the page reads and writes these itself) ── + usageDisplayMode: field({ scope: 'profile', parse: parseOneOf(['usage', 'remaining']) }), + usageDropdownProviders: field({ scope: 'profile', parse: parseStringList }), + usageSelectedModels: field({ scope: 'profile', parse: parseStringRecordOfStringLists }), + usageCollapsedFamilies: field({ scope: 'profile', parse: parseStringRecordOfStringLists }), + usageExpandedFamilies: field({ scope: 'profile', parse: parseStringRecordOfStringLists }), + usageModelGroups: field({ scope: 'profile', parse: parseUsageModelGroups }), + + // ── Behavior (profile) ── + globalBehaviorPrompt: field({ scope: 'profile', parse: parseTextUpTo(1024 * 1024) }), + responseStyleEnabled: field({ scope: 'profile', parse: parseBoolean }), + responseStylePreset: field({ scope: 'profile', parse: parseOneOf(RESPONSE_STYLE_PRESETS) }), + responseStyleCustomInstructions: field({ scope: 'profile', parse: parseTextUpTo(50_000) }), + optimizeSystemPrompt: field({ scope: 'profile', parse: parseBoolean }), + + // The server serves the PWA manifest from these, so they are facts about + // the instance even though only the installed web app shows them. + pwaAppName: field({ scope: 'instance', surfaces: ['web'], parse: parsePwaAppName }), + pwaOrientation: field({ scope: 'instance', surfaces: ['web'], parse: parseOneOf(['system', 'portrait', 'landscape']) }), + + // ── Device fields: never written to the server; an old settings.json that + // still carries one is read once as a seed for the local store. ── + mobileKeyboardMode: field({ + scope: 'device', + surfaces: ['mobile'], + parse: parseMobileKeyboardModeValue, + ui: uiStore('mobileKeyboardMode', (v) => useUIStore.getState().setMobileKeyboardMode(v)), + }), + desktopWindowControlsPosition: field({ + scope: 'device', + surfaces: ['desktop'], + parse: parseDesktopWindowControlsPosition, + ui: uiStore('desktopWindowControlsPosition', (v) => useUIStore.getState().setDesktopWindowControlsPosition(v)), + }), + desktopWindowControlsStyle: field({ + scope: 'device', + surfaces: ['desktop'], + parse: parseOneOf(['classic', 'traffic-lights']), + ui: uiStore('desktopWindowControlsStyle', (v) => useUIStore.getState().setDesktopWindowControlsStyle(v)), + }), + inputBarOffset: field({ scope: 'device', surfaces: ['mobile', 'web'], parse: parseFiniteNumber, ui: uiStore('inputBarOffset', (v) => useUIStore.getState().setInputBarOffset(v)) }), +} as const; + +export type SettingsKey = keyof typeof SETTINGS_REGISTRY; + +type FieldValue = S extends SettingsFieldSpec ? T : never; + +/** The shared settings document as every client sees it: every registry key, optional. */ +export type DesktopSettings = { -readonly [K in SettingsKey]?: FieldValue<(typeof SETTINGS_REGISTRY)[K]> }; + +/** + * Device state that only ever lived in `useUIStore`'s persisted slice. Listed + * so the registry accounts for every persisted key; none of these crosses the + * wire, so they carry no parser. `globalDraftStarters` is the store's name for + * the `draftStarters` field and is therefore not here. + */ +export const LOCAL_DEVICE_KEYS = [ + 'theme', + 'isSidebarOpen', + 'sidebarWidth', + 'contextPanelByDirectory', + 'contextRailOrder', + 'contextRailHiddenSurfaces', + 'contextEditorTreeVisible', + 'contextEditorTreeWidth', + 'notesPanelHeight', + 'workStatusExpandedSections', + 'workStatusScrollTop', + 'isSessionSwitcherOpen', + 'sidebarSection', + 'settingsPage', + 'settingsHasOpenedOnce', + 'settingsProjectsSelectedId', + 'settingsRemoteInstancesSelectedId', + 'isSessionCreateDialogOpen', + 'autoDeleteLastRunAt', + 'messageLimit', + 'walkthroughTocWidth', + 'linearIssueListStatus', + 'linearIssueListAssignee', + 'linearIssueListTeamIdByRuntime', + 'linearIssueListPriority', + 'showTerminalQuickKeysOnDesktop', + 'dockBadgeEnabled', + 'alwaysShowScrollbars', + 'agentMemoryViewedAt', + 'projectContextSidebarWidth', +] as const; + +/** + * Instance facts the Electron main process writes straight into + * `settings.json` (`mutateSettingsRoot`). The server keeps them when merging + * and never accepts them from a client; no client reads them. + * `desktopSplashColors` arrives over the window-theme IPC and replaces the + * flat `splash*` keys older builds wrote through the settings document. + */ +export const DESKTOP_SHELL_KEYS = [ + 'desktopSplashColors', + 'desktopHosts', + 'desktopDefaultHostId', + 'desktopInstallId', + 'desktopLocalPort', + 'desktopSshInstances', + 'desktopWindowState', +] as const; + +const isSettingsKey = (key: string): key is SettingsKey => Object.prototype.hasOwnProperty.call(SETTINGS_REGISTRY, key); + +export const SETTINGS_KEYS: SettingsKey[] = Object.keys(SETTINGS_REGISTRY).filter(isSettingsKey); + +/** Keys whose value belongs to this install and therefore never goes to the server. */ +export const isDeviceSettingsKey = (key: SettingsKey): boolean => SETTINGS_REGISTRY[key].scope === 'device'; + +type SettingsValue = DesktopSettings[SettingsKey]; + +/** The erased view the generic loops iterate; assignable because the bindings use method syntax. */ +const specOf = (key: SettingsKey): SettingsFieldSpec => SETTINGS_REGISTRY[key]; + +/** Keys the client may send to the server: not computed, not this install's device state. */ +export const isWritableSettingsKey = (key: SettingsKey): boolean => !SETTINGS_REGISTRY[key].computed && !isDeviceSettingsKey(key); + +/** + * Parse an untrusted document (server response, bridge payload) into the + * trusted shape. Keys not in the registry are dropped; a value a parser rejects + * is dropped as if absent — never replaced by a default. + */ +const rawDocumentSchema = z.record(z.string(), z.unknown()); + +export const parseSettingsDocument = (payload: unknown): DesktopSettings | null => { + const document = rawDocumentSchema.safeParse(payload); + if (!document.success) { + return null; + } + const raw = document.data; + const result: DesktopSettings = {}; + for (const key of SETTINGS_KEYS) { + const spec = specOf(key); + const parsed = spec.parse(raw[key], raw); + if (parsed !== undefined) { + Object.assign(result, { [key]: parsed }); + } + } + return result; +}; + +const isSameValue = (left: SettingsValue, right: SettingsValue): boolean => { + if (left === right) return true; + if (left === undefined || right === undefined) return false; + return JSON.stringify(left) === JSON.stringify(right); +}; + +/** + * Copy the fields a snapshot carries into their live stores. A field the + * snapshot omits is left alone ("missing is not default"); a field whose + * store already holds the value is not written again. + */ +export const applySettingsToStores = (snapshot: DesktopSettings): void => { + for (const key of SETTINGS_KEYS) { + const spec = specOf(key); + if (!spec.ui) continue; + const value = snapshot[key]; + if (value === undefined) continue; + if (isSameValue(spec.ui.read(), value)) continue; + spec.ui.write(value, snapshot); + } +}; + +/** Keys whose backing store the auto-save watches. */ +export const AUTO_SAVE_KEYS = SETTINGS_KEYS.filter((key) => { + const spec = specOf(key); + return spec.ui?.autoSave === true; +}); + +/** Current store values for the auto-saved keys (undefined for unset). */ +export const readAutoSaveSnapshot = (): DesktopSettings => { + const snapshot: DesktopSettings = {}; + for (const key of AUTO_SAVE_KEYS) { + const spec = specOf(key); + const value = spec.ui?.read(); + if (value !== undefined) Object.assign(snapshot, { [key]: value }); + } + return snapshot; +}; + +/** Keys the per-runtime browser mirror carries: everything the server owns for the user, minus secrets and computed flags. */ +export const MIRRORED_KEYS = SETTINGS_KEYS.filter((key) => { + const spec = specOf(key); + return spec.scope !== 'device' && !spec.secret && !spec.computed; +}); + +/** Shape of one field in the generated JSON snapshot the server and the VS Code bridge consume. */ +export type SettingsRegistrySnapshotField = { + scope: SettingsScope; + perSurface?: true; + surfaces?: readonly SettingsSurface[]; + adopt?: 'bootstrap-only'; + derived?: true; + secret?: true; + computed?: true; + /** Lives only in the local store; never crosses the wire. */ + local?: true; + /** Written by the desktop shell straight into the file; never by a client. */ + owner?: 'desktop-shell'; +}; + +export type SettingsRegistrySnapshot = { + version: 1; + fields: Record; +}; + +export const buildSettingsRegistrySnapshot = (): SettingsRegistrySnapshot => { + const fields: Record = {}; + for (const key of SETTINGS_KEYS) { + const spec = specOf(key); + const entry: SettingsRegistrySnapshotField = { scope: spec.scope }; + if (spec.perSurface) entry.perSurface = true; + if (spec.surfaces) entry.surfaces = spec.surfaces; + if (spec.adopt) entry.adopt = spec.adopt; + if (spec.derived) entry.derived = true; + if (spec.secret) entry.secret = true; + if (spec.computed) entry.computed = true; + fields[key] = entry; + } + for (const key of LOCAL_DEVICE_KEYS) { + fields[key] = { scope: 'device', local: true }; + } + for (const key of DESKTOP_SHELL_KEYS) { + fields[key] = { scope: 'instance', owner: 'desktop-shell', surfaces: ['desktop'] }; + } + return { version: 1, fields }; +}; diff --git a/packages/ui/src/lib/settings/search.test.ts b/packages/ui/src/lib/settings/search.test.ts index 553ae284..81540baf 100644 --- a/packages/ui/src/lib/settings/search.test.ts +++ b/packages/ui/src/lib/settings/search.test.ts @@ -18,6 +18,17 @@ const runtimeCtx = { }; describe('settings search', () => { + test('finds the scrollbar preference on every surface', () => { + for (const context of [runtimeCtx, { ...runtimeCtx, isDesktop: true }, { ...runtimeCtx, isVSCode: true }, { ...runtimeCtx, isMobile: true }]) { + const results = buildSettingsSearchResults({ + query: 'scrollbar', + runtimeCtx: context, + t, + getPageTitle: (page) => page, + }); + expect(results.find((result) => result.id === 'appearance.scrollbars')?.page).toBe('appearance'); + } + }); test('finds Linear connect on the integrations page', () => { const results = buildSettingsSearchResults({ query: 'linear', diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index fb9d63c1..ad10fcbb 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -29,11 +29,15 @@ interface SettingsSearchAvailabilityContext extends SettingsRuntimeContext { isLinux: boolean; // Windows ARM64 — temporary workaround gate (see opencode#19130). isWindowsArm64: boolean; - // Git provider override fields only render once an account is connected. - gitProvidersConnected: { github: boolean; gitlab: boolean; gitea: boolean }; } const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ + { + id: 'chat.activity-default', + page: 'chat', + titleKey: 'settings.openchamber.visual.section.activityDefault', + keywords: ['activity', 'collapsed', 'expanded', 'live', 'tools', 'history'], + }, { id: 'appearance.language', page: 'appearance', @@ -77,6 +81,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ // Electron shell (isMac already implies isDesktopShell), local or remote host. isAvailable: (ctx) => ctx.isMac, }, + { + id: 'appearance.scrollbars', + page: 'appearance', + titleKey: 'settings.openchamber.visual.field.alwaysShowScrollbars', + descriptionKey: 'settings.openchamber.visual.field.alwaysShowScrollbarsHint', + keywords: ['scrollbar', 'scrollbars', 'scroll', 'mouse', 'wheel', 'accessibility', 'always visible'], + }, { id: 'appearance.pwa-install-name', page: 'appearance', @@ -150,19 +161,20 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ descriptionKey: 'settings.openchamber.visual.field.autoSaveEnabledInfo', keywords: ['editor', 'autosave', 'auto-save', 'files', 'save'], }, - { - id: 'appearance.expanded-editor-toolbar', - page: 'general', - titleKey: 'settings.openchamber.visual.field.expandedEditorToolbar', - keywords: ['editor', 'toolbar', 'tabs', 'docked', 'files'], - isAvailable: (ctx) => !ctx.isVSCode, - }, { id: 'appearance.file-editor-keymap', page: 'general', titleKey: 'settings.openchamber.visual.field.fileEditorKeymap', keywords: ['editor', 'vim', 'keymap'], }, + { + id: 'appearance.session-tabs', + page: 'general', + titleKey: 'settings.openchamber.visual.field.sessionTabsGroup', + descriptionKey: 'settings.openchamber.visual.field.sessionTabsInfo', + keywords: ['session', 'tabs', 'header', 'working set'], + isAvailable: (ctx) => !ctx.isMobile && !ctx.isVSCode, + }, { id: 'appearance.terminal-quick-keys', page: 'general', @@ -241,6 +253,19 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ titleKey: 'settings.openchamber.visual.section.reasoning', keywords: ['thinking', 'traces'], }, + { + id: 'chat.streaming', + page: 'chat', + titleKey: 'settings.openchamber.visual.section.streaming', + keywords: ['stream', 'scroll'], + }, + { + id: 'chat.streaming-auto-follow', + page: 'chat', + titleKey: 'settings.openchamber.visual.field.streamingAutoFollow', + descriptionKey: 'settings.openchamber.visual.field.streamingAutoFollowInfo', + keywords: ['autoscroll', 'auto-scroll', 'follow', 'stick to bottom', 'streaming'], + }, { id: 'chat.sticky-user-header', page: 'chat', @@ -353,7 +378,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ id: 'chat.composer', page: 'chat', titleKey: 'settings.openchamber.visual.section.composer', - keywords: ['input', 'draft', 'spellcheck'], + keywords: ['input', 'draft', 'spellcheck', 'paste'], }, { id: 'chat.spellcheck', @@ -374,9 +399,10 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ page: 'chat', titleKey: 'settings.openchamber.visual.field.enterToSend', descriptionKey: 'settings.openchamber.visual.field.enterToSendHint', - keywords: ['enter', 'shift enter', 'send', 'newline'], + keywords: ['enter', 'shift enter', 'ctrl enter', 'cmd enter', 'mod enter', 'send', 'newline'], }, - { id: 'sessions.default-model', + { + id: 'sessions.default-model', page: 'sessions', titleKey: 'settings.openchamber.defaults.field.defaultModel', keywords: ['model', 'provider', 'new sessions'], @@ -535,66 +561,6 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ // user to an empty spot on the page. isAvailable: (ctx) => !ctx.isVSCode && useUIStore.getState().agentMemoryFeatureAvailable, }, - { - id: 'git.github-account', - page: 'git', - titleKey: 'settings.github.page.actions.connect', - keywords: ['github', 'account', 'oauth', 'prs', 'issues'], - }, - { - id: 'git.gitlab-account', - page: 'git', - titleKey: 'settings.gitlab.page.actions.connect', - keywords: ['gitlab', 'account', 'pat', 'personal access token', 'issues', 'merge requests'], - }, - { - id: 'git.github-api-base-url', - page: 'git', - titleKey: 'settings.github.page.apiBaseUrl.label', - descriptionKey: 'settings.github.page.apiBaseUrl.description', - keywords: ['github', 'api', 'base url', 'enterprise', 'self-hosted', 'server'], - isAvailable: (ctx) => ctx.gitProvidersConnected.github, - }, - { - id: 'git.github-detect-urls', - page: 'git', - titleKey: 'settings.github.page.detectUrls.label', - descriptionKey: 'settings.github.page.detectUrls.description', - keywords: ['github', 'detect', 'remote', 'host', 'domain', 'ssh', 'url', 'self-hosted'], - isAvailable: (ctx) => ctx.gitProvidersConnected.github, - }, - { - id: 'git.gitlab-api-base-url', - page: 'git', - titleKey: 'settings.gitlab.page.apiBaseUrl.label', - descriptionKey: 'settings.gitlab.page.apiBaseUrl.description', - keywords: ['gitlab', 'api', 'base url', 'self-hosted', 'server', 'instance'], - isAvailable: (ctx) => ctx.gitProvidersConnected.gitlab, - }, - { - id: 'git.gitlab-detect-urls', - page: 'git', - titleKey: 'settings.gitlab.page.detectUrls.label', - descriptionKey: 'settings.gitlab.page.detectUrls.description', - keywords: ['gitlab', 'detect', 'remote', 'host', 'domain', 'ssh', 'url', 'self-hosted'], - isAvailable: (ctx) => ctx.gitProvidersConnected.gitlab, - }, - { - id: 'git.gitea-api-base-url', - page: 'git', - titleKey: 'settings.gitea.page.apiBaseUrl.label', - descriptionKey: 'settings.gitea.page.apiBaseUrl.description', - keywords: ['gitea', 'forgejo', 'api', 'base url', 'self-hosted', 'server', 'instance'], - isAvailable: (ctx) => ctx.gitProvidersConnected.gitea, - }, - { - id: 'git.gitea-detect-urls', - page: 'git', - titleKey: 'settings.gitea.page.detectUrls.label', - descriptionKey: 'settings.gitea.page.detectUrls.description', - keywords: ['gitea', 'forgejo', 'detect', 'remote', 'host', 'domain', 'ssh', 'url', 'self-hosted'], - isAvailable: (ctx) => ctx.gitProvidersConnected.gitea, - }, { id: 'git.identities', page: 'git', @@ -675,6 +641,26 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ titleKey: 'settings.openchamber.worktrees.setup.waitForCommands', keywords: ['worktree', 'setup commands', 'bootstrap', 'wait'], }, + { + id: 'projects.worktree.setup.replace', + page: 'projects', + titleKey: 'settings.projects.shared.replaceMode', + keywords: ['worktree', 'setup commands', 'shared', 'team', 'only mine'], + }, + { + id: 'projects.shared', + page: 'projects', + titleKey: 'settings.projects.shared.title', + descriptionKey: 'settings.projects.shared.description', + keywords: ['shared', 'team', 'repository', '.openchamber', 'project.json', 'trust'], + }, + { + id: 'projects.shared.plansDir', + page: 'projects', + titleKey: 'settings.projects.shared.plansDir', + descriptionKey: 'settings.projects.shared.plansDirInfo', + keywords: ['plans', 'folder', 'shared', 'team', 'docs'], + }, { id: 'projects.git-providers', page: 'projects', @@ -1098,7 +1084,8 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ titleKey: 'settings.integrations.linear.mapping.defaultProject', descriptionKey: 'settings.integrations.linear.mapping.defaultProject.info', keywords: ['linear', 'project', 'team', 'map', 'workspace', 'directory'], - isAvailable: (ctx) => !ctx.isVSCode, }, + isAvailable: (ctx) => !ctx.isVSCode, + }, ] as const; interface BuildSettingsSearchResultsOptions { diff --git a/packages/ui/src/lib/settings/surface.ts b/packages/ui/src/lib/settings/surface.ts new file mode 100644 index 00000000..aded9a35 --- /dev/null +++ b/packages/ui/src/lib/settings/surface.ts @@ -0,0 +1,30 @@ +import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; +import { isCapacitorApp } from '@/lib/platform'; +import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; +import type { SettingsSurface } from './registry'; + +/** + * The query parameter that tells the server which surface kind a client is + * (`/api/config/settings?surface=desktop`). A query parameter rather than a + * header so the request stays CORS-simple: the packaged desktop shell and the + * phone app are cross-origin to the server, and an older instance would refuse + * an unknown header at preflight. + */ +export const SETTINGS_SURFACE_QUERY = 'surface'; + +/** + * Which surface kind this client is, for the registry's per-surface profile + * fields: a change made here is stored for this kind only. The phone app and + * the hosted mobile shell are one kind — both are "the phone" to the user. + */ +export const getSettingsSurface = (): SettingsSurface => { + try { + if (isVSCodeRuntime()) return 'vscode'; + if (isDesktopShell()) return 'desktop'; + if (isCapacitorApp() || isMobileSurfaceRuntime()) return 'mobile'; + } catch { + // The detectors read `window.location` and friends; outside a real + // browser document (tests, SSR-like shells) the plain web kind applies. + } + return 'web'; +}; diff --git a/packages/ui/src/lib/sharedTrustConfirmation.test.ts b/packages/ui/src/lib/sharedTrustConfirmation.test.ts new file mode 100644 index 00000000..0b65eded --- /dev/null +++ b/packages/ui/src/lib/sharedTrustConfirmation.test.ts @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; + +import type { ProjectSetup } from './openchamberConfig'; + +const project = { id: 'p', path: '/repo' }; + +let setup: ProjectSetup; +let patches: Array> = []; +let saveOk = true; + +mock.module('./openchamberConfig', () => ({ + getProjectSetup: mock(async () => setup), + updateProjectSetup: mock(async (_project: unknown, patch: Record) => { + patches.push(patch); + return saveOk; + }), +})); + +const { + ensureSharedSetupTrusted, + getSharedTrustConfirmationSnapshot, + resetSharedSetupTrust, + resolveWorktreeSetupCommands, + settleSharedTrustConfirmation, +} = await import('./sharedTrustConfirmation'); + +const baseSetup = (): ProjectSetup => ({ + trust: { hash: 'sha256:abc', trusted: false }, + setupWorktree: ['bun install', 'cp .env.example .env'], + setupWorktreeWait: false, + projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev', source: 'shared' }], + projectActionsPrimaryId: null, + draftStarters: [], + shared: { + status: 'ok', + path: '.openchamber/project.json', + setupWorktree: ['bun install'], + setupWorktreeWait: null, + projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }], + draftStarters: [], + plansDir: null, + }, + personal: { + setupWorktree: ['cp .env.example .env'], + setupWorktreeWait: null, + setupWorktreeMode: 'append', + projectActions: [], + projectActionsPrimaryId: null, + draftStarters: [], + hiddenSharedActionIds: [], + sharedTrust: null, + }, +}); + +describe('shared trust confirmation', () => { + beforeEach(() => { + setup = baseSetup(); + patches = []; + saveOk = true; + if (getSharedTrustConfirmationSnapshot()) settleSharedTrustConfirmation('skip'); + }); + + test('runs without asking when the current commands were trusted before', async () => { + setup.trust.trusted = true; + expect(await ensureSharedSetupTrusted(project, setup)).toBe(true); + expect(getSharedTrustConfirmationSnapshot()).toBeNull(); + expect(patches).toEqual([]); + }); + + test('runs without asking when nothing in the shared file executes', async () => { + setup.trust = { hash: null, trusted: true }; + expect(await ensureSharedSetupTrusted(project, setup)).toBe(true); + expect(getSharedTrustConfirmationSnapshot()).toBeNull(); + }); + + test('asks with the exact commands and records a trust answer against the hash', async () => { + const pending = ensureSharedSetupTrusted(project, setup); + const request = getSharedTrustConfirmationSnapshot(); + expect(request?.sharedPath).toBe('.openchamber/project.json'); + expect(request?.setupCommands).toEqual(['bun install']); + expect(request?.actions).toEqual([{ id: 'dev', name: 'Dev', command: 'bun run dev' }]); + + settleSharedTrustConfirmation('trust'); + expect(await pending).toBe(true); + expect(getSharedTrustConfirmationSnapshot()).toBeNull(); + expect(patches).toEqual([{ sharedTrustHash: 'sha256:abc' }]); + }); + + test('skip runs only the personal commands and records nothing', async () => { + const pending = resolveWorktreeSetupCommands(project); + await Promise.resolve(); + settleSharedTrustConfirmation('skip'); + expect(await pending).toEqual(['cp .env.example .env']); + expect(patches).toEqual([]); + }); + + test('trust resolves the full command list; a trusted project never asks', async () => { + const pending = resolveWorktreeSetupCommands(project); + await Promise.resolve(); + settleSharedTrustConfirmation('trust'); + expect(await pending).toEqual(['bun install', 'cp .env.example .env']); + + setup.trust.trusted = true; + expect(await resolveWorktreeSetupCommands(project)).toEqual(['bun install', 'cp .env.example .env']); + expect(getSharedTrustConfirmationSnapshot()).toBeNull(); + }); + + test('replace mode and a shared file without setup commands never ask', async () => { + setup.personal.setupWorktreeMode = 'replace'; + setup.setupWorktree = ['cp .env.example .env']; + expect(await resolveWorktreeSetupCommands(project)).toEqual(['cp .env.example .env']); + expect(getSharedTrustConfirmationSnapshot()).toBeNull(); + + setup = baseSetup(); + setup.shared.setupWorktree = []; + setup.setupWorktree = ['cp .env.example .env']; + expect(await resolveWorktreeSetupCommands(project)).toEqual(['cp .env.example .env']); + expect(getSharedTrustConfirmationSnapshot()).toBeNull(); + }); + + test('a newer request settles the pending one as skip', async () => { + const first = ensureSharedSetupTrusted(project, setup); + const second = ensureSharedSetupTrusted(project, setup); + expect(await first).toBe(false); + settleSharedTrustConfirmation('trust'); + expect(await second).toBe(true); + }); + + test('a failed record still honours the answer this once', async () => { + saveOk = false; + const pending = ensureSharedSetupTrusted(project, setup); + settleSharedTrustConfirmation('trust'); + expect(await pending).toBe(true); + }); + + test('reset forgets the recorded answer', async () => { + await resetSharedSetupTrust(project); + expect(patches).toEqual([{ sharedTrustHash: null }]); + }); +}); diff --git a/packages/ui/src/lib/sharedTrustConfirmation.ts b/packages/ui/src/lib/sharedTrustConfirmation.ts new file mode 100644 index 00000000..4b94df60 --- /dev/null +++ b/packages/ui/src/lib/sharedTrustConfirmation.ts @@ -0,0 +1,107 @@ +/** + * The trust prompt for a team's shared project setup. + * + * Commands in `/.openchamber/project.json` run on this machine, and a + * `git pull` can change them. So the first time a shared setup command or a + * shared action is about to run, the app shows exactly what would run and + * asks. The answer is recorded on the instance against a hash of those + * commands (`trust.hash`); a pull that changes them brings the prompt back. + * + * One request is active at a time; a newer one settles the pending one as + * `skip`. The dialog (`SharedTrustConfirmDialog`) renders the pending request + * on every surface. + */ + +import { getProjectSetup, updateProjectSetup, type ProjectRef, type ProjectSetup } from './openchamberConfig'; + +export type SharedTrustChoice = 'trust' | 'skip'; + +export type PendingSharedTrustRequest = { + project: ProjectRef; + sharedPath: string; + setupCommands: string[]; + actions: Array<{ id: string; name: string; command: string }>; + resolve: (choice: SharedTrustChoice) => void; +}; + +let pendingRequest: PendingSharedTrustRequest | null = null; +const listeners = new Set<() => void>(); + +const emitChange = (): void => { + for (const listener of listeners) listener(); +}; + +export const getSharedTrustConfirmationSnapshot = (): PendingSharedTrustRequest | null => pendingRequest; + +export const subscribeSharedTrustConfirmation = (listener: () => void): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +export const settleSharedTrustConfirmation = (choice: SharedTrustChoice): void => { + const request = pendingRequest; + pendingRequest = null; + emitChange(); + request?.resolve(choice); +}; + +const askForTrust = (project: ProjectRef, setup: ProjectSetup): Promise => { + if (pendingRequest) { + pendingRequest.resolve('skip'); + } + return new Promise((resolve) => { + pendingRequest = { + project, + sharedPath: setup.shared.path, + setupCommands: setup.shared.setupWorktree, + actions: setup.shared.projectActions.map(({ id, name, command }) => ({ id, name, command })), + resolve, + }; + emitChange(); + }); +}; + +/** + * Make sure the shared commands of `setup` may run. Resolves `true` at once + * when there is nothing to trust or the current commands were trusted before; + * otherwise asks, records a "trust" answer on the instance, and resolves + * `false` when the user chose to run without the shared commands this time. + */ +export const ensureSharedSetupTrusted = async (project: ProjectRef, setup: ProjectSetup): Promise => { + if (setup.trust.trusted || setup.trust.hash === null) { + return true; + } + const choice = await askForTrust(project, setup); + if (choice !== 'trust') { + return false; + } + const recorded = await updateProjectSetup(project, { sharedTrustHash: setup.trust.hash }); + if (!recorded) { + // The commands still run this once: the user said yes to exactly these. + console.warn('Failed to record the trust answer; the prompt will return next time.'); + } + return true; +}; + +/** + * The setup commands a new worktree should run for `project`, after the trust + * prompt when the shared ones have not been trusted yet. A "skip" answer + * leaves only the user's own commands. + */ +export const resolveWorktreeSetupCommands = async (project: ProjectRef): Promise => { + const setup = await getProjectSetup(project); + if (setup.shared.setupWorktree.length === 0 || setup.personal.setupWorktreeMode === 'replace') { + return setup.setupWorktree; + } + if (await ensureSharedSetupTrusted(project, setup)) { + return setup.setupWorktree; + } + return setup.personal.setupWorktree; +}; + +/** Forget the recorded trust answer, so the next shared command asks again. */ +export const resetSharedSetupTrust = (project: ProjectRef): Promise => ( + updateProjectSetup(project, { sharedTrustHash: null }) +); diff --git a/packages/ui/src/lib/shortcuts/DOCUMENTATION.md b/packages/ui/src/lib/shortcuts/DOCUMENTATION.md index 0ed297b4..13f24799 100644 --- a/packages/ui/src/lib/shortcuts/DOCUMENTATION.md +++ b/packages/ui/src/lib/shortcuts/DOCUMENTATION.md @@ -47,6 +47,11 @@ Shared `DropdownMenu` and `Select` can opt into this boundary with `disableGloba Terminal capture, Escape abort priming, and the shifted reverse-agent chord are input-boundary exceptions. They preserve their target-specific semantics and invoke the registered application handler rather than duplicating command behavior. +`[data-btw-composer="true"]` owns Escape instead of main-session abort priming. +While active, main and Mini Chat model/effort shortcuts yield; main agent, +expansion and dictation shortcuts also yield. Footer unmounting alone cannot +disable these global registrations or protect the parent composer's selection. + Local key handling remains appropriate for text editing, IME composition, menu and list navigation, dialog confirmation, terminal input, and other interactions that do not represent configurable application commands. The settings recorder treats Enter and Escape as recordable keys; only its explicit Confirm and Cancel buttons apply or discard a recording. # Adding shortcuts diff --git a/packages/ui/src/lib/surfaces/DOCUMENTATION.md b/packages/ui/src/lib/surfaces/DOCUMENTATION.md index d363530d..d9cf9382 100644 --- a/packages/ui/src/lib/surfaces/DOCUMENTATION.md +++ b/packages/ui/src/lib/surfaces/DOCUMENTATION.md @@ -15,8 +15,10 @@ edge (`components/layout/ContextPanelRail.tsx`) and rendered by rail until a tab of their mode exists, and stay visible for as long as one does — they must not disappear while in use. - `defaultWidthFraction` is the panel width as a fraction of the content area, - used until the user manually resizes that surface (manual widths are stored - per mode in `useUIStore.contextPanelByDirectory[dir].widthByMode`). + used until the user manually resizes that surface. Manual widths are stored + per mode in `useUIStore.contextPanelByDirectory[dir].widthFractionByMode`; + `widthByMode` retains the last pixel size until the available area is known. + Every surface, including walkthrough, restores both values on reload. - Rail order is user-reorderable and persisted globally in `useUIStore.contextRailOrder`; `sortContextSurfaces` applies it on top of the registry's default order and appends any missing surfaces. diff --git a/packages/ui/src/lib/terminalApi.test.ts b/packages/ui/src/lib/terminalApi.test.ts index 01141946..166b6b24 100644 --- a/packages/ui/src/lib/terminalApi.test.ts +++ b/packages/ui/src/lib/terminalApi.test.ts @@ -22,6 +22,8 @@ type WireMessage = { v?: number; d?: string; r?: string; + cols?: number; + rows?: number; history?: string; status?: TerminalStreamEvent['status']; exitCode?: number; @@ -120,6 +122,36 @@ describe('terminal transport', () => { } }); + test('carries the PTY size through snapshots, projection replays, and accepted resizes', async () => { + const socket = new FakeSocket(); + const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket }); + const sizes: Array<[number | undefined, number | undefined]> = []; + transport.subscribe('term-1', { onEvent: (event) => { if (event.type === 'snapshot') sizes.push([event.cols, event.rows]); } }); + await tick(); + socket.open(); + await tick(); + + socket.emit({ t: 'snapshot', v: 3, s: 'term-1', q: 1, history: 'prompt', status: 'running', cols: 94, rows: 56 }); + await tick(); + expect(sizes).toEqual([[94, 56]]); + + const lateSizes: Array<[number | undefined, number | undefined]> = []; + transport.subscribe('term-1', { onEvent: (event) => { if (event.type === 'snapshot') lateSizes.push([event.cols, event.rows]); } }); + expect(lateSizes).toEqual([[94, 56]]); + + transport.noteResize('term-1', 80, 24); + const afterResize: Array<[number | undefined, number | undefined]> = []; + transport.subscribe('term-1', { onEvent: (event) => { if (event.type === 'snapshot') afterResize.push([event.cols, event.rows]); } }); + expect(afterResize).toEqual([[80, 24]]); + + socket.emit({ t: 'snapshot', v: 3, s: 'term-2', q: 0, history: '', status: 'running' }); + const legacy: Array<[number | undefined, number | undefined]> = []; + transport.subscribe('term-2', { onEvent: (event) => { if (event.type === 'snapshot') legacy.push([event.cols, event.rows]); } }); + await tick(); + expect(legacy).toEqual([]); + transport.dispose(); + }); + test('hydrates simultaneous subscribers and rejects duplicate sequences', async () => { const socket = new FakeSocket(); const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket }); diff --git a/packages/ui/src/lib/terminalApi.ts b/packages/ui/src/lib/terminalApi.ts index 2ae2036e..5622fbe5 100644 --- a/packages/ui/src/lib/terminalApi.ts +++ b/packages/ui/src/lib/terminalApi.ts @@ -1,4 +1,5 @@ import type { CreateTerminalOptions, TerminalError, TerminalHandlers, TerminalServerSession, TerminalSession, TerminalSessionPurpose, TerminalShellOption, TerminalStreamEvent } from './api/types'; +import type { TerminalChunkSize } from '@/stores/useTerminalStore'; import { openRuntimeWebSocket } from './relay/runtime-socket'; import type { RelayTunnelSocketMessageEvent, RelayTunnelWebSocket } from './relay/tunnel-client'; import { runtimeFetch } from './runtime-fetch'; @@ -15,6 +16,9 @@ type Subscriber = { handlers: TerminalHandlers; lastSequence: number }; type TerminalProjection = { sequence: number; history: string; + /** Current PTY size: what the server reported at attach, updated by every accepted resize. */ + cols?: number; + rows?: number; status: TerminalStreamEvent['status']; mode?: TerminalSession['mode']; purpose?: TerminalSessionPurpose; @@ -84,6 +88,7 @@ const terminalMessageSchema = z.discriminatedUnion('t', [ z.object({ t: z.literal('snapshot'), s: z.string(), q: z.number().int().nonnegative().default(0), history: z.string().default(''), status: terminalStatusSchema, + cols: z.number().int().positive().optional(), rows: z.number().int().positive().optional(), exitCode: z.number().nullish().transform(value => value ?? undefined), signal: z.number().nullable().optional(), runtime: terminalRuntimeSchema.optional(), ptyBackend: z.string().optional(), ...terminalMessageMetadata, }), @@ -124,6 +129,10 @@ export class TerminalRequestError extends Error { } } +/** The PTY size a snapshot's history was drawn for, when the server reported one. */ +export const terminalSnapshotSize = (event: Pick): TerminalChunkSize | undefined => + event.cols !== undefined && event.rows !== undefined ? { cols: event.cols, rows: event.rows } : undefined; + export const isTerminalCwdMissingError = (error: unknown): boolean => error instanceof TerminalRequestError && error.code === TERMINAL_CWD_MISSING_CODE; @@ -187,7 +196,7 @@ export class TerminalTransport { const projection = this.projections.get(sessionId); if (projection) { subscriber.lastSequence = projection.sequence; - handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, status: projection.status, mode: projection.mode, purpose: projection.purpose, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend }); + handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, cols: projection.cols, rows: projection.rows, status: projection.status, mode: projection.mode, purpose: projection.purpose, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend }); } const socketWasOpen = this.socket?.readyState === SOCKET_OPEN; this.ensureConnected().then(() => { @@ -251,6 +260,17 @@ export class TerminalTransport { this.projections.delete(sessionId); } + /** + * Records a resize the server accepted, so a projection snapshot replayed to + * a later subscriber (tab switch, remount) still names the size the + * terminal's current screen is drawn for. + */ + noteResize(sessionId: string, cols: number, rows: number): void { + const projection = this.projections.get(sessionId); + if (!projection) return; + this.projections.set(sessionId, { ...projection, cols, rows }); + } + private async ensureConnected(): Promise { if (this.disposed) throw new Error('Terminal runtime changed'); if (this.socket?.readyState === SOCKET_OPEN) return; @@ -358,6 +378,8 @@ export class TerminalTransport { const projection: TerminalProjection = { sequence: message.q ?? 0, history: message.history ?? '', + cols: message.cols, + rows: message.rows, status: message.status, mode: message.mode, purpose: message.purpose, @@ -369,7 +391,7 @@ export class TerminalTransport { this.projections.set(message.s, projection); for (const sub of subscribers) { sub.lastSequence = projection.sequence; - sub.handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, status: projection.status, mode: projection.mode, purpose: projection.purpose, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend }); + sub.handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, cols: projection.cols, rows: projection.rows, status: projection.status, mode: projection.mode, purpose: projection.purpose, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend }); } return; } @@ -492,7 +514,10 @@ async function command(path: string, method: string, body?: unknown): Promise { await command(`/api/terminal/${sessionId}/resize`, 'POST', { cols, rows }); } +export async function resizeTerminal(sessionId: string, cols: number, rows: number): Promise { + await command(`/api/terminal/${sessionId}/resize`, 'POST', { cols, rows }); + transport.noteResize(sessionId, cols, rows); +} export async function updateTerminalAppearance(sessionId: string, appearance: Pick): Promise { await command(`/api/terminal/${sessionId}/appearance`, 'POST', appearance); } export async function closeTerminal(sessionId: string): Promise { await command(`/api/terminal/${sessionId}`, 'DELETE'); transport.forget(sessionId); } export async function restartTerminalSession(currentSessionId: string, options: CreateTerminalOptions): Promise { return (await command(`/api/terminal/${currentSessionId}/restart`, 'POST', options)).json() as Promise; } diff --git a/packages/ui/src/lib/terminalOutput.test.ts b/packages/ui/src/lib/terminalOutput.test.ts deleted file mode 100644 index 61df6d49..00000000 --- a/packages/ui/src/lib/terminalOutput.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { - getGhosttySafeResetSequence, - rewriteGhosttyDefaultBackgroundResets, -} from './terminalOutput'; - -describe('terminal output compatibility', () => { - test('builds an explicit default-background reset from supported CSS colors', () => { - expect(getGhosttySafeResetSequence('#f8f7f0')).toBe('\u001b[0;48;2;248;247;240m'); - expect(getGhosttySafeResetSequence('#abc')).toBe('\u001b[0;48;2;170;187;204m'); - expect(getGhosttySafeResetSequence('rgb(12, 34, 56)')).toBe('\u001b[0;48;2;12;34;56m'); - expect(getGhosttySafeResetSequence('var(--surface-background)')).toBeNull(); - }); - - test('rewrites default resets even when escape sequences span chunks', () => { - const safeReset = '\u001b[0;48;2;10;20;30m'; - const first = rewriteGhosttyDefaultBackgroundResets('before\u001b[', '', safeReset); - const second = rewriteGhosttyDefaultBackgroundResets('0mafter\u001b[m', first.carry, safeReset); - - expect(first).toEqual({ data: 'before', carry: '\u001b[' }); - expect(second).toEqual({ data: `${safeReset}after${safeReset}`, carry: '' }); - }); - - test('preserves output when the background cannot be resolved', () => { - expect(rewriteGhosttyDefaultBackgroundResets('0m', '\u001b[', null)).toEqual({ - data: '\u001b[0m', - carry: '', - }); - }); -}); diff --git a/packages/ui/src/lib/terminalOutput.ts b/packages/ui/src/lib/terminalOutput.ts deleted file mode 100644 index 468d1941..00000000 --- a/packages/ui/src/lib/terminalOutput.ts +++ /dev/null @@ -1,52 +0,0 @@ -// ghostty-web 0.4.0 leaves recycled rows dirty after default SGR resets (#138). -// Keep the theme background explicit until a stable release includes the upstream WASM fix. -const DEFAULT_BACKGROUND_RESETS = ['\u001b[0m', '\u001b[m'] as const; - -const parseCssRgb = (color: string): [number, number, number] | null => { - const value = color.trim(); - const hex = /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(value)?.[1]; - if (hex) { - const expanded = hex.length <= 4 - ? hex.slice(0, 3).split('').map((part) => part + part).join('') - : hex.slice(0, 6); - return [ - Number.parseInt(expanded.slice(0, 2), 16), - Number.parseInt(expanded.slice(2, 4), 16), - Number.parseInt(expanded.slice(4, 6), 16), - ]; - } - - const rgb = /^rgba?\(\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*[, ]\s*(\d{1,3})(?:\s*[,/]\s*[\d.]+)?\s*\)$/i.exec(value); - if (!rgb) return null; - const channels = rgb.slice(1, 4).map(Number); - if (channels.some((channel) => channel < 0 || channel > 255)) return null; - return channels as [number, number, number]; -}; - -export const getGhosttySafeResetSequence = (background: string): string | null => { - const rgb = parseCssRgb(background); - return rgb ? `\u001b[0;48;2;${rgb[0]};${rgb[1]};${rgb[2]}m` : null; -}; - -export const rewriteGhosttyDefaultBackgroundResets = ( - data: string, - carry: string, - safeReset: string | null, -): { data: string; carry: string } => { - const combined = carry + data; - if (!safeReset) return { data: combined, carry: '' }; - - let carryLength = 0; - const maxPrefixLength = Math.max(...DEFAULT_BACKGROUND_RESETS.map((reset) => reset.length)) - 1; - for (let length = 1; length <= Math.min(maxPrefixLength, combined.length); length += 1) { - const suffix = combined.slice(-length); - if (DEFAULT_BACKGROUND_RESETS.some((reset) => reset.length > suffix.length && reset.startsWith(suffix))) { - carryLength = length; - } - } - - const nextCarry = carryLength > 0 ? combined.slice(-carryLength) : ''; - let output = carryLength > 0 ? combined.slice(0, -carryLength) : combined; - for (const reset of DEFAULT_BACKGROUND_RESETS) output = output.replaceAll(reset, safeReset); - return { data: output, carry: nextCarry }; -}; diff --git a/packages/ui/src/lib/terminalTheme.ts b/packages/ui/src/lib/terminalTheme.ts index 02fa2810..90e1a1a5 100644 --- a/packages/ui/src/lib/terminalTheme.ts +++ b/packages/ui/src/lib/terminalTheme.ts @@ -1,5 +1,5 @@ -import type { Ghostty } from 'ghostty-web'; import type { Theme } from '@/types/theme'; +import type { GhosttyColor, GhosttyTheme } from '@/lib/ghostty/core'; export interface TerminalTheme { background: string; @@ -62,53 +62,46 @@ export function convertThemeToXterm(theme: Theme): TerminalTheme { }; } -/** - * Get terminal options for Ghostty Web terminal - */ -export function getGhosttyTerminalOptions( - fontFamily: string, - fontSize: number, - theme: TerminalTheme, - ghostty: Ghostty, - disableStdin = false -) { - const powerlineFallbacks = - '"JetBrainsMonoNL Nerd Font", "FiraCode Nerd Font", "Cascadia Code PL", "Fira Code", "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", "Courier New", monospace'; - const augmentedFontFamily = `${fontFamily}, ${powerlineFallbacks}`; +const ANSI_ORDER = [ + 'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', + 'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', 'brightMagenta', 'brightCyan', 'brightWhite', +] as const; +/** Parses #rgb, #rrggbb (alpha digits ignored) or rgb()/rgba() into channels. */ +const parseTerminalColor = (color: string): GhosttyColor | null => { + const value = color.trim(); + const hex = /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(value)?.[1]; + if (hex) { + const expanded = hex.length <= 4 + ? hex.slice(0, 3).split('').map((part) => part + part).join('') + : hex.slice(0, 6); + return { + r: Number.parseInt(expanded.slice(0, 2), 16), + g: Number.parseInt(expanded.slice(2, 4), 16), + b: Number.parseInt(expanded.slice(4, 6), 16), + }; + } + + const rgb = /^rgba?\(\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*[, ]\s*(\d{1,3})(?:\s*[,/]\s*[\d.]+)?\s*\)$/i.exec(value); + if (!rgb) return null; + const [r, g, b] = rgb.slice(1, 4).map(Number); + if ([r, g, b].some((channel) => channel === undefined || channel < 0 || channel > 255)) return null; + return { r: r ?? 0, g: g ?? 0, b: b ?? 0 }; +}; + +/** + * Theme colors as libghostty-vt takes them. Theme JSON values are hex, so a + * parse failure means a broken theme file: fall back to plain white on black + * for that entry rather than sending Ghostty garbage. + */ +export function toGhosttyTheme(theme: TerminalTheme): GhosttyTheme { + const background = parseTerminalColor(theme.background) ?? { r: 0, g: 0, b: 0 }; + const foreground = parseTerminalColor(theme.foreground) ?? { r: 255, g: 255, b: 255 }; return { - // TerminalViewport enables blinking only while its input owns focus. - cursorBlink: false, - cursorStyle: 'bar' as const, - fontSize, - fontFamily: augmentedFontFamily, - allowTransparency: false, - theme: { - background: theme.background, - foreground: theme.foreground, - cursor: theme.cursor, - cursorAccent: theme.cursorAccent, - selectionBackground: theme.selectionBackground, - selectionForeground: theme.selectionForeground, - black: theme.black, - red: theme.red, - green: theme.green, - yellow: theme.yellow, - blue: theme.blue, - magenta: theme.magenta, - cyan: theme.cyan, - white: theme.white, - brightBlack: theme.brightBlack, - brightRed: theme.brightRed, - brightGreen: theme.brightGreen, - brightYellow: theme.brightYellow, - brightBlue: theme.brightBlue, - brightMagenta: theme.brightMagenta, - brightCyan: theme.brightCyan, - brightWhite: theme.brightWhite, - }, - scrollback: 10_000, - ghostty, - disableStdin, + background, + foreground, + cursor: parseTerminalColor(theme.cursor) ?? foreground, + palette: ANSI_ORDER.map((name) => parseTerminalColor(theme[name]) ?? foreground), + selectionBackground: theme.selectionBackground, }; } diff --git a/packages/ui/src/lib/terminalTouchSelection.test.ts b/packages/ui/src/lib/terminalTouchSelection.test.ts deleted file mode 100644 index 4802eed2..00000000 --- a/packages/ui/src/lib/terminalTouchSelection.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { - getTerminalCellFromPoint, - getTerminalWordRange, -} from './terminalTouchSelection'; - -describe('terminal touch selection', () => { - test('maps touch points to clamped terminal cells', () => { - const bounds = { left: 20, top: 40, width: 800, height: 240 }; - - expect(getTerminalCellFromPoint(425, 165, bounds, 80, 24)).toEqual({ column: 40, row: 12 }); - expect(getTerminalCellFromPoint(-100, 500, bounds, 80, 24)).toEqual({ column: 0, row: 23 }); - expect(getTerminalCellFromPoint(20, 40, { ...bounds, width: 0 }, 80, 24)).toBeNull(); - }); - - test('selects the non-whitespace token around a long press', () => { - expect(getTerminalWordRange(Array.from(' /projects/openchamber '), 10)).toEqual({ - startColumn: 2, - endColumn: 22, - }); - expect(getTerminalWordRange(Array.from('foo bar'), 3)).toEqual({ startColumn: 3, endColumn: 3 }); - }); -}); diff --git a/packages/ui/src/lib/terminalTouchSelection.ts b/packages/ui/src/lib/terminalTouchSelection.ts deleted file mode 100644 index e398cd0c..00000000 --- a/packages/ui/src/lib/terminalTouchSelection.ts +++ /dev/null @@ -1,48 +0,0 @@ -export type TerminalCellPosition = { - column: number; - row: number; -}; - -type TerminalViewportRect = { - left: number; - top: number; - width: number; - height: number; -}; - -export const getTerminalCellFromPoint = ( - clientX: number, - clientY: number, - bounds: TerminalViewportRect, - columns: number, - rows: number, -): TerminalCellPosition | null => { - if (bounds.width <= 0 || bounds.height <= 0 || columns <= 0 || rows <= 0) return null; - - const column = Math.floor(((clientX - bounds.left) / bounds.width) * columns); - const row = Math.floor(((clientY - bounds.top) / bounds.height) * rows); - - return { - column: Math.max(0, Math.min(columns - 1, column)), - row: Math.max(0, Math.min(rows - 1, row)), - }; -}; - -export const getTerminalWordRange = ( - cells: string[], - column: number, -): { startColumn: number; endColumn: number } => { - const clampedColumn = Math.max(0, Math.min(cells.length - 1, column)); - const isWordCell = (value: string | undefined) => Boolean(value && !/^\s+$/u.test(value)); - - if (!isWordCell(cells[clampedColumn])) { - return { startColumn: clampedColumn, endColumn: clampedColumn }; - } - - let startColumn = clampedColumn; - let endColumn = clampedColumn; - while (startColumn > 0 && isWordCell(cells[startColumn - 1])) startColumn -= 1; - while (endColumn < cells.length - 1 && isWordCell(cells[endColumn + 1])) endColumn += 1; - - return { startColumn, endColumn }; -}; diff --git a/packages/ui/src/lib/theme/cssGenerator.ts b/packages/ui/src/lib/theme/cssGenerator.ts index a8cab969..f5237cb0 100644 --- a/packages/ui/src/lib/theme/cssGenerator.ts +++ b/packages/ui/src/lib/theme/cssGenerator.ts @@ -370,7 +370,6 @@ const sidebarBaseRgb = hexToRgb(theme.colors.surface.muted); private generateMarkdownColors(markdown: Record, theme: Theme): string[] { const vars: string[] = []; const primary = theme.colors.primary.base; - const chatBackground = theme.colors.chat?.background || theme.colors.surface.background; vars.push(` --markdown-heading1: ${markdown.heading1 || primary};`); vars.push(` --markdown-heading2: ${markdown.heading2 || this.opacity(primary, 0.9)};`); @@ -378,8 +377,6 @@ const sidebarBaseRgb = hexToRgb(theme.colors.surface.muted); vars.push(` --markdown-heading4: ${markdown.heading4 || theme.colors.surface.foreground};`); vars.push(` --markdown-link: ${markdown.link || primary};`); vars.push(` --markdown-link-hover: ${markdown.linkHover || theme.colors.primary.hover || this.darken(primary, 10)};`); - vars.push(` --markdown-inline-code: ${markdown.inlineCode || theme.colors.syntax.base.string};`); - vars.push(` --markdown-inline-code-bg: ${markdown.inlineCodeBackground || chatBackground};`); vars.push(` --markdown-blockquote: ${markdown.blockquote || theme.colors.surface.mutedForeground};`); vars.push(` --markdown-blockquote-border: ${markdown.blockquoteBorder || theme.colors.interactive.border};`); vars.push(` --markdown-list-marker: ${markdown.listMarker || this.opacity(primary, 0.6)};`); @@ -394,7 +391,6 @@ const sidebarBaseRgb = hexToRgb(theme.colors.surface.muted); private generateDefaultMarkdownColors(theme: Theme): string[] { const vars: string[] = []; const primary = theme.colors.primary.base; - const chatBackground = theme.colors.chat?.background || theme.colors.surface.background; vars.push(` --markdown-heading1: ${primary};`); vars.push(` --markdown-heading2: ${this.opacity(primary, 0.9)};`); @@ -402,8 +398,6 @@ const sidebarBaseRgb = hexToRgb(theme.colors.surface.muted); vars.push(` --markdown-heading4: ${theme.colors.surface.foreground};`); vars.push(` --markdown-link: ${primary};`); vars.push(` --markdown-link-hover: ${theme.colors.primary.hover || this.darken(primary, 10)};`); - vars.push(` --markdown-inline-code: ${theme.colors.syntax.base.string};`); - vars.push(` --markdown-inline-code-bg: ${chatBackground};`); vars.push(` --markdown-blockquote: ${theme.colors.surface.mutedForeground};`); vars.push(` --markdown-blockquote-border: ${theme.colors.interactive.border};`); vars.push(` --markdown-list-marker: ${this.opacity(primary, 0.6)};`); diff --git a/packages/ui/src/lib/theme/themes/aura-dark.json b/packages/ui/src/lib/theme/themes/aura-dark.json index 33393f55..f7f6d7a0 100644 --- a/packages/ui/src/lib/theme/themes/aura-dark.json +++ b/packages/ui/src/lib/theme/themes/aura-dark.json @@ -133,8 +133,6 @@ "heading4": "#EDECEE", "link": "#A277FF", "linkHover": "#F694FF", - "inlineCode": "#61FFCA", - "inlineCodeBackground": "#222128", "blockquote": "#6D6D6D", "blockquoteBorder": "#2D2B38", "listMarker": "#A277FF99" diff --git a/packages/ui/src/lib/theme/themes/aura-light.json b/packages/ui/src/lib/theme/themes/aura-light.json index 78dbc041..9c8a4af8 100644 --- a/packages/ui/src/lib/theme/themes/aura-light.json +++ b/packages/ui/src/lib/theme/themes/aura-light.json @@ -133,8 +133,6 @@ "heading4": "#2D2640", "link": "#A277FF", "linkHover": "#C17AC8", - "inlineCode": "#00732E", - "inlineCodeBackground": "#E8E3F2", "blockquote": "#6D6D6D", "blockquoteBorder": "#E0D6F2", "listMarker": "#A277FF99" diff --git a/packages/ui/src/lib/theme/themes/ayu-dark.json b/packages/ui/src/lib/theme/themes/ayu-dark.json index b60fb143..15e51bc3 100644 --- a/packages/ui/src/lib/theme/themes/ayu-dark.json +++ b/packages/ui/src/lib/theme/themes/ayu-dark.json @@ -133,8 +133,6 @@ "heading4": "#D6DAE0", "link": "#66C6F1", "linkHover": "#3FB7E3", - "inlineCode": "#B1C74A", - "inlineCodeBackground": "#1C2126", "blockquote": "#E4A75C", "blockquoteBorder": "#2B3440", "listMarker": "#3FB7E399" diff --git a/packages/ui/src/lib/theme/themes/ayu-light.json b/packages/ui/src/lib/theme/themes/ayu-light.json index d485c690..dfafba06 100644 --- a/packages/ui/src/lib/theme/themes/ayu-light.json +++ b/packages/ui/src/lib/theme/themes/ayu-light.json @@ -133,8 +133,6 @@ "heading4": "#394049", "link": "#2F9BCE", "linkHover": "#4AA8C8", - "inlineCode": "#497700", - "inlineCodeBackground": "#F0EDE7", "blockquote": "#ED982E", "blockquoteBorder": "#E6DDCF", "listMarker": "#4AA8C899" diff --git a/packages/ui/src/lib/theme/themes/carbonfox-dark.json b/packages/ui/src/lib/theme/themes/carbonfox-dark.json index c913b65e..3bfb13c2 100644 --- a/packages/ui/src/lib/theme/themes/carbonfox-dark.json +++ b/packages/ui/src/lib/theme/themes/carbonfox-dark.json @@ -133,8 +133,6 @@ "heading4": "#F2F4F8", "link": "#33B1FF", "linkHover": "#78A9FF", - "inlineCode": "#42BE65", - "inlineCodeBackground": "#232323", "blockquote": "#8D8D8D", "blockquoteBorder": "#393939", "listMarker": "#33B1FF99" diff --git a/packages/ui/src/lib/theme/themes/carbonfox-light.json b/packages/ui/src/lib/theme/themes/carbonfox-light.json index eb067562..7f83a78a 100644 --- a/packages/ui/src/lib/theme/themes/carbonfox-light.json +++ b/packages/ui/src/lib/theme/themes/carbonfox-light.json @@ -133,8 +133,6 @@ "heading4": "#161616", "link": "#0072C3", "linkHover": "#0043CE", - "inlineCode": "#00661E", - "inlineCodeBackground": "#F2F2F2", "blockquote": "#525252", "blockquoteBorder": "#DCDCDC", "listMarker": "#0072C399" diff --git a/packages/ui/src/lib/theme/themes/catppuccin-dark.json b/packages/ui/src/lib/theme/themes/catppuccin-dark.json index 93bbdd59..1516407f 100644 --- a/packages/ui/src/lib/theme/themes/catppuccin-dark.json +++ b/packages/ui/src/lib/theme/themes/catppuccin-dark.json @@ -133,8 +133,6 @@ "heading4": "#CDD6F4", "link": "#89DCEB", "linkHover": "#B4BEFE", - "inlineCode": "#A6E3A1", - "inlineCodeBackground": "#2B2B3B", "blockquote": "#F9E2AF", "blockquoteBorder": "#35324A", "listMarker": "#B4BEFE99" diff --git a/packages/ui/src/lib/theme/themes/catppuccin-light.json b/packages/ui/src/lib/theme/themes/catppuccin-light.json index 471db6fd..9f4a2d6c 100644 --- a/packages/ui/src/lib/theme/themes/catppuccin-light.json +++ b/packages/ui/src/lib/theme/themes/catppuccin-light.json @@ -133,8 +133,6 @@ "heading4": "#2e314a", "link": "#04A5E5", "linkHover": "#7287FD", - "inlineCode": "#1A7A05", - "inlineCodeBackground": "#F2E9E7", "blockquote": "#DF8E1D", "blockquoteBorder": "#E0CFD3", "listMarker": "#7287FD99" diff --git a/packages/ui/src/lib/theme/themes/dracula-dark.json b/packages/ui/src/lib/theme/themes/dracula-dark.json index 9521bbfd..79f64538 100644 --- a/packages/ui/src/lib/theme/themes/dracula-dark.json +++ b/packages/ui/src/lib/theme/themes/dracula-dark.json @@ -133,8 +133,6 @@ "heading4": "#F8F8F2", "link": "#8BE9FD", "linkHover": "#BD93F9", - "inlineCode": "#4aeb72", - "inlineCodeBackground": "#21222C", "blockquote": "#FFB86C", "blockquoteBorder": "#2D2F3C", "listMarker": "#BD93F999" diff --git a/packages/ui/src/lib/theme/themes/dracula-light.json b/packages/ui/src/lib/theme/themes/dracula-light.json index 9bb48015..ff724af0 100644 --- a/packages/ui/src/lib/theme/themes/dracula-light.json +++ b/packages/ui/src/lib/theme/themes/dracula-light.json @@ -133,8 +133,6 @@ "heading4": "#1F1F2F", "link": "#1D7FC5", "linkHover": "#7C6BF5", - "inlineCode": "#007325", - "inlineCodeBackground": "#EBEBE5", "blockquote": "#F7A14D", "blockquoteBorder": "#E2E3DA", "listMarker": "#7C6BF599" diff --git a/packages/ui/src/lib/theme/themes/fields-of-the-shire-dark.json b/packages/ui/src/lib/theme/themes/fields-of-the-shire-dark.json index a1b470a2..d2785bd5 100644 --- a/packages/ui/src/lib/theme/themes/fields-of-the-shire-dark.json +++ b/packages/ui/src/lib/theme/themes/fields-of-the-shire-dark.json @@ -136,8 +136,6 @@ "heading4": "#ebe0d1", "link": "#5a6d7a", "linkHover": "#93a56b", - "inlineCode": "#93a56b", - "inlineCodeBackground": "#282522", "blockquote": "#a89888", "blockquoteBorder": "#f0e6d830", "listMarker": "#c47a3a99" diff --git a/packages/ui/src/lib/theme/themes/fields-of-the-shire-light.json b/packages/ui/src/lib/theme/themes/fields-of-the-shire-light.json index 61f6d03e..a8a33561 100644 --- a/packages/ui/src/lib/theme/themes/fields-of-the-shire-light.json +++ b/packages/ui/src/lib/theme/themes/fields-of-the-shire-light.json @@ -136,8 +136,6 @@ "heading4": "#1a1612", "link": "#3d4f5a", "linkHover": "#4a6030", - "inlineCode": "#4A6030", - "inlineCodeBackground": "#ECE8DE", "blockquote": "#5a5048", "blockquoteBorder": "#1a161230", "listMarker": "#8c552099" diff --git a/packages/ui/src/lib/theme/themes/flexoki-dark.json b/packages/ui/src/lib/theme/themes/flexoki-dark.json index eeee922b..ab348e90 100644 --- a/packages/ui/src/lib/theme/themes/flexoki-dark.json +++ b/packages/ui/src/lib/theme/themes/flexoki-dark.json @@ -135,8 +135,6 @@ "heading4": "#CECDC3", "link": "#4385BE", "linkHover": "#205EA6", - "inlineCode": "#A0AF53", - "inlineCodeBackground": "#242222", "blockquote": "#878580", "blockquoteBorder": "#343331", "listMarker": "#D0A21599" diff --git a/packages/ui/src/lib/theme/themes/flexoki-light.json b/packages/ui/src/lib/theme/themes/flexoki-light.json index 17ca1a1f..5d6bcc79 100644 --- a/packages/ui/src/lib/theme/themes/flexoki-light.json +++ b/packages/ui/src/lib/theme/themes/flexoki-light.json @@ -135,8 +135,6 @@ "heading4": "#100F0F", "link": "#205EA6", "linkHover": "#4385BE", - "inlineCode": "#0A6961", - "inlineCodeBackground": "#F2F0E7", "blockquote": "#6F6E69", "blockquoteBorder": "#DAD8CE", "listMarker": "#AD830199" diff --git a/packages/ui/src/lib/theme/themes/gruvbox-dark.json b/packages/ui/src/lib/theme/themes/gruvbox-dark.json index 57bb4009..cba0dc62 100644 --- a/packages/ui/src/lib/theme/themes/gruvbox-dark.json +++ b/packages/ui/src/lib/theme/themes/gruvbox-dark.json @@ -133,8 +133,6 @@ "heading4": "#EBDBB2", "link": "#8EC07C", "linkHover": "#83A598", - "inlineCode": "#B8BB26", - "inlineCodeBackground": "#353535", "blockquote": "#928374", "blockquoteBorder": "#504945", "listMarker": "#83A59899" diff --git a/packages/ui/src/lib/theme/themes/gruvbox-light.json b/packages/ui/src/lib/theme/themes/gruvbox-light.json index 5682779f..16cdf75d 100644 --- a/packages/ui/src/lib/theme/themes/gruvbox-light.json +++ b/packages/ui/src/lib/theme/themes/gruvbox-light.json @@ -133,8 +133,6 @@ "heading4": "#3C3836", "link": "#427B58", "linkHover": "#076678", - "inlineCode": "#5F5A00", - "inlineCodeBackground": "#EBE4C8", "blockquote": "#928374", "blockquoteBorder": "#D5C4A1", "listMarker": "#07667899" diff --git a/packages/ui/src/lib/theme/themes/jetbrains-dark.json b/packages/ui/src/lib/theme/themes/jetbrains-dark.json index 72b35af0..050b3ea4 100644 --- a/packages/ui/src/lib/theme/themes/jetbrains-dark.json +++ b/packages/ui/src/lib/theme/themes/jetbrains-dark.json @@ -137,8 +137,6 @@ "heading4": "#BCBEC4", "link": "#56A8F5", "linkHover": "#6796f5", - "inlineCode": "#6AAB73", - "inlineCodeBackground": "#2B2C2F", "blockquote": "#7A7E85", "blockquoteBorder": "#393B41", "listMarker": "#B3AE6099" diff --git a/packages/ui/src/lib/theme/themes/jetbrains-light.json b/packages/ui/src/lib/theme/themes/jetbrains-light.json index 7b76988b..026f8733 100644 --- a/packages/ui/src/lib/theme/themes/jetbrains-light.json +++ b/packages/ui/src/lib/theme/themes/jetbrains-light.json @@ -137,8 +137,6 @@ "heading4": "#404040", "link": "#006DCC", "linkHover": "#3573F0", - "inlineCode": "#067D17", - "inlineCodeBackground": "#F2F2F2", "blockquote": "#8C8C8C", "blockquoteBorder": "#C9CCD6", "listMarker": "#9E880D99" diff --git a/packages/ui/src/lib/theme/themes/kanagawa-dark.json b/packages/ui/src/lib/theme/themes/kanagawa-dark.json index d362f4dd..cc150f53 100644 --- a/packages/ui/src/lib/theme/themes/kanagawa-dark.json +++ b/packages/ui/src/lib/theme/themes/kanagawa-dark.json @@ -135,8 +135,6 @@ "heading4": "#DCD7BA", "link": "#7FB4CA", "linkHover": "#7E9CD8", - "inlineCode": "#98BB6C", - "inlineCodeBackground": "#2C2C35", "blockquote": "#54546D", "blockquoteBorder": "#363646", "listMarker": "#FF9E3B99" diff --git a/packages/ui/src/lib/theme/themes/kanagawa-light.json b/packages/ui/src/lib/theme/themes/kanagawa-light.json index 11bc1467..0cfd0e8f 100644 --- a/packages/ui/src/lib/theme/themes/kanagawa-light.json +++ b/packages/ui/src/lib/theme/themes/kanagawa-light.json @@ -135,8 +135,6 @@ "heading4": "#545464", "link": "#5D57A3", "linkHover": "#4D699B", - "inlineCode": "#496328", - "inlineCodeBackground": "#E9E6C9", "blockquote": "#716E61", "blockquoteBorder": "#716E61", "listMarker": "#836F4A99" diff --git a/packages/ui/src/lib/theme/themes/mono-dark.json b/packages/ui/src/lib/theme/themes/mono-dark.json index 6182e020..b46ff001 100644 --- a/packages/ui/src/lib/theme/themes/mono-dark.json +++ b/packages/ui/src/lib/theme/themes/mono-dark.json @@ -135,8 +135,6 @@ "heading4": "#D9D9D9", "link": "#CCCCCC", "linkHover": "#FFFFFF", - "inlineCode": "#B3B3B3", - "inlineCodeBackground": "#0D0D0D", "blockquote": "#808080", "blockquoteBorder": "#333333", "listMarker": "#99999999" diff --git a/packages/ui/src/lib/theme/themes/mono-light.json b/packages/ui/src/lib/theme/themes/mono-light.json index 4cc7da53..e25c06f4 100644 --- a/packages/ui/src/lib/theme/themes/mono-light.json +++ b/packages/ui/src/lib/theme/themes/mono-light.json @@ -135,8 +135,6 @@ "heading4": "#262626", "link": "#333333", "linkHover": "#000000", - "inlineCode": "#4D4D4D", - "inlineCodeBackground": "#F2F2F2", "blockquote": "#808080", "blockquoteBorder": "#D9D9D9", "listMarker": "#66666699" diff --git a/packages/ui/src/lib/theme/themes/mono-plus-dark.json b/packages/ui/src/lib/theme/themes/mono-plus-dark.json index a976272a..1d0ce191 100644 --- a/packages/ui/src/lib/theme/themes/mono-plus-dark.json +++ b/packages/ui/src/lib/theme/themes/mono-plus-dark.json @@ -113,8 +113,6 @@ "heading4": "#D9D9D9", "link": "#CCCCCC", "linkHover": "#a2bee8", - "inlineCode": "#a2bee8", - "inlineCodeBackground": "#0D0D0D", "blockquote": "#808080", "blockquoteBorder": "#333333", "listMarker": "#99999999" diff --git a/packages/ui/src/lib/theme/themes/mono-plus-light.json b/packages/ui/src/lib/theme/themes/mono-plus-light.json index 8feb2bca..1e1ffc2a 100644 --- a/packages/ui/src/lib/theme/themes/mono-plus-light.json +++ b/packages/ui/src/lib/theme/themes/mono-plus-light.json @@ -113,8 +113,6 @@ "heading4": "#262626", "link": "#333333", "linkHover": "#4a6a9e", - "inlineCode": "#4A6A9E", - "inlineCodeBackground": "#F2F2F2", "blockquote": "#808080", "blockquoteBorder": "#D9D9D9", "listMarker": "#66666699" diff --git a/packages/ui/src/lib/theme/themes/monokai-dark.json b/packages/ui/src/lib/theme/themes/monokai-dark.json index c63dcf7d..ce15bcc3 100644 --- a/packages/ui/src/lib/theme/themes/monokai-dark.json +++ b/packages/ui/src/lib/theme/themes/monokai-dark.json @@ -133,8 +133,6 @@ "heading4": "#F8F8F2", "link": "#66D9EF", "linkHover": "#AE81FF", - "inlineCode": "#A6E22E", - "inlineCodeBackground": "#30312B", "blockquote": "#FD971F", "blockquoteBorder": "#343528", "listMarker": "#AE81FF99" diff --git a/packages/ui/src/lib/theme/themes/monokai-light.json b/packages/ui/src/lib/theme/themes/monokai-light.json index cc0c9823..95b7b5f1 100644 --- a/packages/ui/src/lib/theme/themes/monokai-light.json +++ b/packages/ui/src/lib/theme/themes/monokai-light.json @@ -133,8 +133,6 @@ "heading4": "#292318", "link": "#2D9AD7", "linkHover": "#BF7BFF", - "inlineCode": "#0F750B", - "inlineCodeBackground": "#F0EBDF", "blockquote": "#F1A948", "blockquoteBorder": "#E9E0CF", "listMarker": "#BF7BFF99" diff --git a/packages/ui/src/lib/theme/themes/nightowl-dark.json b/packages/ui/src/lib/theme/themes/nightowl-dark.json index 8efdf461..114fb6fd 100644 --- a/packages/ui/src/lib/theme/themes/nightowl-dark.json +++ b/packages/ui/src/lib/theme/themes/nightowl-dark.json @@ -133,8 +133,6 @@ "heading4": "#D6DEEB", "link": "#7FDBCA", "linkHover": "#82AAFF", - "inlineCode": "#C5E478", - "inlineCodeBackground": "#0E2334", "blockquote": "#5F7E97", "blockquoteBorder": "#1D3B53", "listMarker": "#82AAFF99" diff --git a/packages/ui/src/lib/theme/themes/nightowl-light.json b/packages/ui/src/lib/theme/themes/nightowl-light.json index 70e74892..5a484e03 100644 --- a/packages/ui/src/lib/theme/themes/nightowl-light.json +++ b/packages/ui/src/lib/theme/themes/nightowl-light.json @@ -133,8 +133,6 @@ "heading4": "#403F53", "link": "#2AA298", "linkHover": "#4876D6", - "inlineCode": "#00746A", - "inlineCodeBackground": "#EEEEEE", "blockquote": "#7A8181", "blockquoteBorder": "#D9D9D9", "listMarker": "#4876D699" diff --git a/packages/ui/src/lib/theme/themes/nord-dark.json b/packages/ui/src/lib/theme/themes/nord-dark.json index 9b083cd2..89c1f33d 100644 --- a/packages/ui/src/lib/theme/themes/nord-dark.json +++ b/packages/ui/src/lib/theme/themes/nord-dark.json @@ -133,8 +133,6 @@ "heading4": "#E5E9F0", "link": "#81A1C1", "linkHover": "#88C0D0", - "inlineCode": "#A3BE8C", - "inlineCodeBackground": "#2C313D", "blockquote": "#D08770", "blockquoteBorder": "#343A47", "listMarker": "#88C0D099" diff --git a/packages/ui/src/lib/theme/themes/nord-light.json b/packages/ui/src/lib/theme/themes/nord-light.json index 5ee1123e..cf654361 100644 --- a/packages/ui/src/lib/theme/themes/nord-light.json +++ b/packages/ui/src/lib/theme/themes/nord-light.json @@ -133,8 +133,6 @@ "heading4": "#2E3440", "link": "#81A1C1", "linkHover": "#5E81AC", - "inlineCode": "#35561A", - "inlineCodeBackground": "#DFE2E7", "blockquote": "#D08770", "blockquoteBorder": "#D5DBE7", "listMarker": "#5E81AC99" diff --git a/packages/ui/src/lib/theme/themes/onedarkpro-dark.json b/packages/ui/src/lib/theme/themes/onedarkpro-dark.json index 83a3ca6b..711a8f1d 100644 --- a/packages/ui/src/lib/theme/themes/onedarkpro-dark.json +++ b/packages/ui/src/lib/theme/themes/onedarkpro-dark.json @@ -133,8 +133,6 @@ "heading4": "#ABB2BF", "link": "#56B6C2", "linkHover": "#61AFEF", - "inlineCode": "#a0c288", - "inlineCodeBackground": "#2B2F37", "blockquote": "#E5C07B", "blockquoteBorder": "#323848", "listMarker": "#61AFEF99" diff --git a/packages/ui/src/lib/theme/themes/onedarkpro-light.json b/packages/ui/src/lib/theme/themes/onedarkpro-light.json index b7e97bf1..97d324c4 100644 --- a/packages/ui/src/lib/theme/themes/onedarkpro-light.json +++ b/packages/ui/src/lib/theme/themes/onedarkpro-light.json @@ -133,8 +133,6 @@ "heading4": "#2B303B", "link": "#61AFEF", "linkHover": "#528BFF", - "inlineCode": "#186332", - "inlineCodeBackground": "#E8E9EB", "blockquote": "#D19A66", "blockquoteBorder": "#DEE2EB", "listMarker": "#528BFF99" diff --git a/packages/ui/src/lib/theme/themes/openchamber-dark.json b/packages/ui/src/lib/theme/themes/openchamber-dark.json index e7b3e7c7..678585c4 100644 --- a/packages/ui/src/lib/theme/themes/openchamber-dark.json +++ b/packages/ui/src/lib/theme/themes/openchamber-dark.json @@ -137,8 +137,6 @@ "heading4": "#c9c5ba", "link": "#5d99a9", "linkHover": "#6ba7b8", - "inlineCode": "#76ad4f", - "inlineCodeBackground": "#1F1C1B", "blockquote": "#8f8b81", "blockquoteBorder": "#302e2b", "listMarker": "#4d934e99" diff --git a/packages/ui/src/lib/theme/themes/openchamber-light.json b/packages/ui/src/lib/theme/themes/openchamber-light.json index d2c01fc6..83b2e91c 100644 --- a/packages/ui/src/lib/theme/themes/openchamber-light.json +++ b/packages/ui/src/lib/theme/themes/openchamber-light.json @@ -137,8 +137,6 @@ "heading4": "#393a34", "link": "#2e808f", "linkHover": "#296aa3", - "inlineCode": "#006A2C", - "inlineCodeBackground": "#F0EFED", "blockquote": "#6b6b63", "blockquoteBorder": "#d8d5d0", "listMarker": "#1e754f99" diff --git a/packages/ui/src/lib/theme/themes/solarized-dark.json b/packages/ui/src/lib/theme/themes/solarized-dark.json index c48fbd75..b6ee0c1b 100644 --- a/packages/ui/src/lib/theme/themes/solarized-dark.json +++ b/packages/ui/src/lib/theme/themes/solarized-dark.json @@ -133,8 +133,6 @@ "heading4": "#93A1A1", "link": "#2AA198", "linkHover": "#6C71C4", - "inlineCode": "#859900", - "inlineCodeBackground": "#0D2B32", "blockquote": "#B58900", "blockquoteBorder": "#20373F", "listMarker": "#6C71C499" diff --git a/packages/ui/src/lib/theme/themes/solarized-light.json b/packages/ui/src/lib/theme/themes/solarized-light.json index 6dc7fd69..98167269 100644 --- a/packages/ui/src/lib/theme/themes/solarized-light.json +++ b/packages/ui/src/lib/theme/themes/solarized-light.json @@ -133,8 +133,6 @@ "heading4": "#586E75", "link": "#2AA198", "linkHover": "#268BD2", - "inlineCode": "#576B00", - "inlineCodeBackground": "#F0E9D6", "blockquote": "#B58900", "blockquoteBorder": "#E3E0CD", "listMarker": "#268BD299" diff --git a/packages/ui/src/lib/theme/themes/tokyonight-dark.json b/packages/ui/src/lib/theme/themes/tokyonight-dark.json index 7b7378c8..0f91d575 100644 --- a/packages/ui/src/lib/theme/themes/tokyonight-dark.json +++ b/packages/ui/src/lib/theme/themes/tokyonight-dark.json @@ -133,8 +133,6 @@ "heading4": "#C0CAF5", "link": "#7DCFFF", "linkHover": "#7AA2F7", - "inlineCode": "#9ECE6A", - "inlineCodeBackground": "#1C1E27", "blockquote": "#E0AF68", "blockquoteBorder": "#25283B", "listMarker": "#7AA2F799" diff --git a/packages/ui/src/lib/theme/themes/tokyonight-light.json b/packages/ui/src/lib/theme/themes/tokyonight-light.json index 58fe8708..bca34814 100644 --- a/packages/ui/src/lib/theme/themes/tokyonight-light.json +++ b/packages/ui/src/lib/theme/themes/tokyonight-light.json @@ -133,8 +133,6 @@ "heading4": "#273153", "link": "#007197", "linkHover": "#2E7DE9", - "inlineCode": "#3E5B1F", - "inlineCodeBackground": "#D4D5DA", "blockquote": "#8C6C3E", "blockquoteBorder": "#CDD0DC", "listMarker": "#2E7DE999" diff --git a/packages/ui/src/lib/theme/themes/vesper-dark.json b/packages/ui/src/lib/theme/themes/vesper-dark.json index aedd7943..21104d48 100644 --- a/packages/ui/src/lib/theme/themes/vesper-dark.json +++ b/packages/ui/src/lib/theme/themes/vesper-dark.json @@ -133,8 +133,6 @@ "heading4": "#FFFFFF", "link": "#A0A0A0", "linkHover": "#FFC799", - "inlineCode": "#bba2a2", - "inlineCodeBackground": "#222222", "blockquote": "#FFFFFF", "blockquoteBorder": "#1C1C1C", "listMarker": "#FFFFFF99" diff --git a/packages/ui/src/lib/theme/themes/vesper-light.json b/packages/ui/src/lib/theme/themes/vesper-light.json index f4f04573..47be05cc 100644 --- a/packages/ui/src/lib/theme/themes/vesper-light.json +++ b/packages/ui/src/lib/theme/themes/vesper-light.json @@ -133,8 +133,6 @@ "heading4": "#101010", "link": "#717070", "linkHover": "#c48959", - "inlineCode": "#665050", - "inlineCodeBackground": "#F2F2F2", "blockquote": "#101010", "blockquoteBorder": "#E8E8E8", "listMarker": "#10101099" diff --git a/packages/ui/src/lib/theme/themes/vitesse-dark-dark.json b/packages/ui/src/lib/theme/themes/vitesse-dark-dark.json index 8e10df2f..a5d3a30d 100644 --- a/packages/ui/src/lib/theme/themes/vitesse-dark-dark.json +++ b/packages/ui/src/lib/theme/themes/vitesse-dark-dark.json @@ -113,8 +113,6 @@ "heading4": "#dbd7caee", "link": "#4d9375", "linkHover": "#4d9375", - "inlineCode": "#80a665", - "inlineCodeBackground": "#1F1F1F", "blockquote": "#dedcd550", "blockquoteBorder": "#ffffff15", "listMarker": "#4d937599" diff --git a/packages/ui/src/lib/theme/themes/vitesse-light-light.json b/packages/ui/src/lib/theme/themes/vitesse-light-light.json index 09327d20..af1bbd4e 100644 --- a/packages/ui/src/lib/theme/themes/vitesse-light-light.json +++ b/packages/ui/src/lib/theme/themes/vitesse-light-light.json @@ -113,8 +113,6 @@ "heading4": "#2c2c28", "link": "#1e754f", "linkHover": "#1c6b48", - "inlineCode": "#3A631E", - "inlineCodeBackground": "#F2F2F2", "blockquote": "#2c2c2850", "blockquoteBorder": "#00000015", "listMarker": "#1c6b4899" diff --git a/packages/ui/src/lib/theme/vscode/adapter.ts b/packages/ui/src/lib/theme/vscode/adapter.ts index b7136720..13524262 100644 --- a/packages/ui/src/lib/theme/vscode/adapter.ts +++ b/packages/ui/src/lib/theme/vscode/adapter.ts @@ -605,9 +605,6 @@ export const buildVSCodeThemeFromPalette = (palette: VSCodeThemePalette): Theme heading4: foreground, link: accentMuted, linkHover: read('textLink.activeForeground', accentHover), - inlineCode: read('textPreformat.foreground', syntaxString), - inlineCodeBackground: subtle, - inlineCodeBorder: read('textPreformat.border', read('chat.requestCodeBorder', effectiveBorder)), blockquote: foreground, blockquoteBackground: read('textBlockQuote.background', 'transparent'), blockquoteBorder: read('textBlockQuote.border', effectiveBorder), diff --git a/packages/ui/src/lib/typography.ts b/packages/ui/src/lib/typography.ts index f56fa313..bb08e40d 100644 --- a/packages/ui/src/lib/typography.ts +++ b/packages/ui/src/lib/typography.ts @@ -1,23 +1,23 @@ export const SEMANTIC_TYPOGRAPHY = { - markdown: '0.9375rem', - code: '0.8125rem', - uiHeader: '0.9375rem', - uiLabel: '0.8750rem', - meta: '0.875rem', - micro: '0.875rem', + markdown: '0.875rem', + code: '0.75rem', + uiHeader: '0.875rem', + uiLabel: '0.8125rem', + meta: '0.8125rem', + micro: '0.8125rem', /** Settings page / detail-pane title — larger than section headers */ - settingsPageTitle: '1.125rem', + settingsPageTitle: '1.0625rem', } as const; export const VSCODE_TYPOGRAPHY = { // Keep VS Code webview typography slightly tighter; VS Code UI chrome already provides density. - markdown: '0.9063rem', - code: '0.8750rem', - uiHeader: '0.9063rem', - uiLabel: '0.8438rem', - meta: '0.8438rem', - micro: '0.7813rem', - settingsPageTitle: '1.0625rem', + markdown: '0.8438rem', + code: '0.8125rem', + uiHeader: '0.8438rem', + uiLabel: '0.7813rem', + meta: '0.7813rem', + micro: '0.75rem', + settingsPageTitle: '1rem', } as const; export type SemanticTypographyKey = keyof typeof SEMANTIC_TYPOGRAPHY; diff --git a/packages/ui/src/lib/walkthrough/types.ts b/packages/ui/src/lib/walkthrough/types.ts index e714a0ef..eb2f8fd1 100644 --- a/packages/ui/src/lib/walkthrough/types.ts +++ b/packages/ui/src/lib/walkthrough/types.ts @@ -11,6 +11,7 @@ export type WalkthroughWorkingTreeScope = 'all' | 'staged' | 'working'; export type WalkthroughSource = | { kind: 'working-tree'; scope: WalkthroughWorkingTreeScope } | { kind: 'branch'; baseRef: string; headRef: string } + | { kind: 'commit'; hash: string } | { kind: 'pr'; number: number }; export type WalkthroughChapterIcon = 'bug' | 'wrench' | 'path' | 'flask' | 'doc' | 'gear'; diff --git a/packages/ui/src/lib/web-update.test.ts b/packages/ui/src/lib/web-update.test.ts new file mode 100644 index 00000000..13ff0183 --- /dev/null +++ b/packages/ui/src/lib/web-update.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from 'bun:test'; +import { installWebUpdate, waitForUpdateApplied } from './web-update'; + +describe('browser host updates', () => { + test('keeps the native target version returned by the install request', async () => { + const result = await installWebUpdate(async () => Response.json({ + success: true, updateOwner: 'electron-updater', version: '1.22.3', autoRestart: true, + })); + expect(result).toEqual({ success: true, autoRestart: true, target: { owner: 'electron', version: '1.22.3' } }); + }); + + test('rejects a native success response without its target version', async () => { + expect(await installWebUpdate(async () => Response.json({ + success: true, updateOwner: 'electron-updater', + }))).toEqual({ success: false }); + }); + + test('rejects malformed success instead of starting the reconnect loop', async () => { + expect(await installWebUpdate(async () => Response.json({}))).toEqual({ success: false }); + }); + + test('keeps native polling on the web host route until the target is installed', async () => { + let requests = 0; + const result = await waitForUpdateApplied({ owner: 'electron', version: '1.22.3' }, '1.22.2', { + intervalMs: 1, maxWaitMs: 1000, + fetchUpdate: async (url, init) => { + expect(url).toBe('/api/openchamber/update-check?appType=web&reportUsage=false&updateStatus=true'); + expect(init?.signal).toBeInstanceOf(AbortSignal); + requests += 1; + // The old package feed can say no update while native installation is + // still pending. That does not prove the requested version is running. + return Response.json({ available: false, currentVersion: requests === 1 ? '1.22.2' : '1.22.3' }); + }, + }); + expect(requests).toBe(2); + expect(result).toEqual({ status: 'applied' }); + }); + + test('reports a rejected native restart without treating it as a transient disconnect', async () => { + const result = await waitForUpdateApplied({ owner: 'electron', version: '1.22.3' }, '1.22.2', { + fetchUpdate: async () => Response.json({ + code: 'DESKTOP_UPDATE_RESTART_FAILED', error: 'Signature rejected', + }, { status: 503 }), + }); + expect(result).toEqual({ status: 'failed', error: 'Signature rejected' }); + }); + + test('does not accept authentication loss as native upgrade completion', async () => { + const requests: string[] = []; + const result = await waitForUpdateApplied({ owner: 'electron', version: '1.22.3' }, '1.22.2', { + intervalMs: 1, maxWaitMs: 20, + fetchUpdate: async (url) => { + requests.push(String(url)); + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + }, + }); + expect(result).toEqual({ status: 'timeout' }); + expect(requests).not.toContain('/health'); + }); + + test('a blocked poll cannot extend the overall deadline', async () => { + const result = await waitForUpdateApplied({ owner: 'electron', version: '1.22.3' }, '1.22.2', { + maxWaitMs: 20, + fetchUpdate: async (_url, init) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new Error('Timed out')), { once: true }); + }), + }); + expect(result).toEqual({ status: 'timeout' }); + }); + + test('preserves package-manager completion after a version change', async () => { + const result = await waitForUpdateApplied({ owner: 'package-manager' }, '1.22.2', { + fetchUpdate: async () => Response.json({ available: false, currentVersion: '1.22.3' }), + }); + expect(result).toEqual({ status: 'applied' }); + }); +}); diff --git a/packages/ui/src/lib/web-update.ts b/packages/ui/src/lib/web-update.ts new file mode 100644 index 00000000..6d72f48f --- /dev/null +++ b/packages/ui/src/lib/web-update.ts @@ -0,0 +1,87 @@ +import { z } from 'zod'; +import { runtimeFetch } from './runtime-fetch'; + +const installResponse = z.object({ + success: z.literal(true), + autoRestart: z.boolean().optional(), + updateOwner: z.string().optional(), + version: z.string().min(1).optional(), +}); +const checkResponse = z.object({ + available: z.boolean(), + currentVersion: z.string().optional(), + error: z.string().optional(), +}); +const errorResponse = z.object({ error: z.string(), code: z.string().optional() }); + +type UpdateTarget = { owner: 'electron'; version: string } | { owner: 'package-manager' }; +type InstallResult = + | { success: true; autoRestart: boolean; target: UpdateTarget } + | { success: false; error?: string }; +type AppliedResult = { status: 'applied' } | { status: 'timeout' } | { status: 'failed'; error: string }; + +export async function installWebUpdate(fetchUpdate = runtimeFetch): Promise { + try { + const response = await fetchUpdate('/api/openchamber/update-install', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + const payload = await response.json(); + if (!response.ok) { + const error = errorResponse.safeParse(payload); + return { success: false, error: error.success ? error.data.error : undefined }; + } + const parsed = installResponse.safeParse(payload); + if (!parsed.success) return { success: false }; + const data = parsed.data; + if (data.updateOwner === 'electron-updater') { + if (!data.version) return { success: false }; + return { success: true, autoRestart: data.autoRestart !== false, target: { owner: 'electron', version: data.version } }; + } + return { success: true, autoRestart: data.autoRestart !== false, target: { owner: 'package-manager' } }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : undefined }; + } +} + +export async function waitForUpdateApplied( + target: UpdateTarget, + previousVersion?: string, + { fetchUpdate = runtimeFetch, maxWaitMs = 10 * 60 * 1000, intervalMs = 2000 } = {}, +): Promise { + const deadline = Date.now() + maxWaitMs; + while (Date.now() < deadline) { + const signal = AbortSignal.timeout(Math.max(1, Math.min(10_000, deadline - Date.now()))); + try { + const response = await fetchUpdate('/api/openchamber/update-check?appType=web&reportUsage=false&updateStatus=true', { + method: 'GET', headers: { Accept: 'application/json' }, signal, + }); + if (response.ok) { + const parsed = checkResponse.safeParse(await response.json()); + if (parsed.success && !parsed.data.error) { + const data = parsed.data; + const applied = target.owner === 'electron' + ? data.currentVersion === target.version + : data.available === false || (previousVersion !== undefined && data.currentVersion !== undefined && data.currentVersion !== previousVersion); + if (applied) return { status: 'applied' }; + } + } else { + const parsed = errorResponse.safeParse(await response.json().catch(() => null)); + if (parsed.success && parsed.data.code === 'DESKTOP_UPDATE_RESTART_FAILED') { + return { status: 'failed', error: parsed.data.error }; + } + // Package-manager restarts can replace the browser session. A native + // update must still prove its target version after authentication. + if (target.owner === 'package-manager' && (response.status === 401 || response.status === 403)) { + const health = await fetchUpdate('/health', { headers: { Accept: 'application/json' }, signal }); + if (health.ok) return { status: 'applied' }; + } + } + } catch { + // A restarting host can disconnect or time out; retry within the deadline. + } + const remaining = deadline - Date.now(); + if (remaining > 0) await new Promise(resolve => setTimeout(resolve, Math.min(intervalMs, remaining))); + } + return { status: 'timeout' }; +} diff --git a/packages/ui/src/lib/worktreeSessionCreator.ts b/packages/ui/src/lib/worktreeSessionCreator.ts index afb8c162..86960a9b 100644 --- a/packages/ui/src/lib/worktreeSessionCreator.ts +++ b/packages/ui/src/lib/worktreeSessionCreator.ts @@ -14,7 +14,8 @@ import { checkIsGitRepository, previewGitWorktree } from '@/lib/gitApi'; import { generateBranchName } from '@/lib/git/branchNameGenerator'; import { parseModelIdentifier } from '@/lib/modelIdentifier'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; -import { getWorktreeSetupCommands, getWorktreeSetupWaitEnabled } from '@/lib/openchamberConfig'; +import { getWorktreeSetupWaitEnabled } from '@/lib/openchamberConfig'; +import { resolveWorktreeSetupCommands } from '@/lib/sharedTrustConfirmation'; import { removeProjectWorktree, type ProjectRef, @@ -70,7 +71,7 @@ export const createQuickWorktree = async ( options: { preferredName?: string; startRef?: string } = {}, ) => { const preferredName = options.preferredName ?? generateBranchName(); - const setupCommands = await getWorktreeSetupCommands(project); + const setupCommands = await resolveWorktreeSetupCommands(project); return createWorktreeWithDefaults(project, { preferredName, mode: 'new', @@ -362,7 +363,7 @@ export async function createWorktreeSessionForNewBranch( return null; } - const setupCommands = await getWorktreeSetupCommands(projectRef); + const setupCommands = await resolveWorktreeSetupCommands(projectRef); const rootBranch = await getRootBranch(projectRef.path); try { const metadata = await createWorktreeWithDefaults(projectRef, { diff --git a/packages/ui/src/lib/worktrees/worktreeManager.test.ts b/packages/ui/src/lib/worktrees/worktreeManager.test.ts index aab8f46e..14c10c11 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.test.ts @@ -117,10 +117,8 @@ const { createWorktree, getLatestWorktreeMetadata, listProjectWorktrees, - notifyWorktreeTopologyChanged, partitionWorktreesByRegisteredProject, removeProjectWorktree, - subscribeWorktreeTopologyChanged, validateWorktreeCreate, worktreeMapsEqual, } = await import('./worktreeManager'); @@ -679,21 +677,4 @@ describe('worktreeManager missing worktrees', () => { expect(worktreeMapsEqual(new Map([['/repo', [ready]]]), new Map([['/repo', [missing]]]))).toBe(false); }); - test('a topology-changed signal drops the cached listing and reaches subscribers', async () => { - const project = { id: 'project-signal', path: '/repo-signal/' }; - listImplementation = async () => []; - await listProjectWorktrees(project, { force: true }); - await listProjectWorktrees(project); - expect(listCalls).toEqual(['/repo-signal']); - - const notified: string[] = []; - const unsubscribe = subscribeWorktreeTopologyChanged((directory) => notified.push(directory)); - notifyWorktreeTopologyChanged('/repo-signal/'); - unsubscribe(); - notifyWorktreeTopologyChanged('/repo-signal'); - - expect(notified).toEqual(['/repo-signal']); - await listProjectWorktrees(project); - expect(listCalls).toEqual(['/repo-signal', '/repo-signal']); - }); }); diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index 99caa339..f3c22d80 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -400,29 +400,6 @@ const invalidateWorktreeList = (projectDirectory: string): void => { _worktreeListCache.delete(projectDirectory); }; -type WorktreeTopologyListener = (projectDirectory: string) => void; -const worktreeTopologyListeners = new Set(); - -/** - * Subscribe to in-app evidence that a project's worktree topology changed - * outside the flows that publish it themselves (a session relocated out of a - * directory the server confirmed missing). The sidebar rediscovers on this - * signal the same way it does for the server's `session-created` event, so - * the topology stays event-driven with no idle polling. - */ -export const subscribeWorktreeTopologyChanged = (listener: WorktreeTopologyListener): (() => void) => { - worktreeTopologyListeners.add(listener); - return () => { - worktreeTopologyListeners.delete(listener); - }; -}; - -export const notifyWorktreeTopologyChanged = (projectDirectory: string): void => { - const normalized = normalizePath(projectDirectory); - invalidateWorktreeList(normalized); - for (const listener of worktreeTopologyListeners) listener(normalized); -}; - const readProjectWorktrees = async (projectDirectory: string): Promise => { const metadataProjectDirectory = await resolveProjectRoot(projectDirectory).catch(() => projectDirectory); const normalizedProjectDirectory = normalizePath(projectDirectory); diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index a038fe39..89e1ba05 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -29,8 +29,41 @@ These are the most performance-sensitive. These stores act like centralized keyed caches. UI should consume narrow slices from them instead of re-fetching the same data in multiple places. +`useQuotaStore` keeps the last authoritative provider results separately from +`refreshErrors`. Transport failures and configured-provider errors preserve the +last usage sample and its timestamp. An explicit unconfigured response replaces +old configuration; a first-load transport failure leaves it unknown. Concurrent +refreshes share one request per provider. Runtime reset aborts those requests, +and generation checks prevent their completions from changing the next runtime. +`lib/quota/fetchQuota.ts` validates response payloads and bounds the complete +request, including JSON body delivery. Compact usage cards and Settings display +refresh errors alongside retained data. The mobile popover makes at most one +refresh attempt per opening, so a failed first load cannot create a retry loop. + ### UI state stores +Sidebar visibility and its persisted width are independent. Opening or closing +the sidebar never writes a width; only resizing changes the saved choice. +The initial width is separate from the component's minimum resize width. + +`useCommitSelectionStore.ts` shares the selected commit between desktop/mobile +Changes and walkthrough. Choices are session-only and keyed by runtime, directory, and +checked-out branch, with at most 100 remembered choices. The picker history +belongs to `useCommitComparison`, loads only while Commit mode is active, and +is limited to the latest 50 commits. History failure stays distinct from an +empty list; stale directory/runtime requests cannot replace current history or +selection. A refreshed list preserves an explicit selection even when newer +commits have pushed it beyond the latest 50. + +`hooks/useGitComparison.ts` owns the local file-list state used by desktop and +mobile comparisons. Its key contains runtime, directory, and the complete +branch/commit source. A source change hides the old list immediately; failed +reads remain errors, and manual retries cannot publish into a superseded scope. +The hook also resolves per-file patch requests, including a commit rename's +previous path. Views own their lazy patch caches through `useRangeKeyedCache`. +Mobile requests only the active detail path and suspends reads while its +keep-alive workspace pane is hidden. + Examples: - `useUIStore.ts` @@ -64,7 +97,27 @@ These stores coordinate persistent project/session metadata across multiple view `useProjectContextStore.ts` caches server-owned project notes, todos, and plan links, keyed by the path-derived project id. It replaced a pair of `window` CustomEvents that made every mounted notes panel re-read the whole project config. Writes are optimistic and roll back on failure; they are serialized per project, because the server's own store does a read-modify-write and two concurrent saves would otherwise race it. A load that resolves while a write is in flight keeps the local value for that field group only, so a slow snapshot cannot undo newer typing while still delivering the plan list it fetched. A failed load sets `error` and preserves the cached snapshot — an unreachable server must never render as "this project has no notes". Note and plan creation are deliberately not optimistic, since ids and timestamps are assigned by the server. Notes, todos, and plans are written through separate routes and tracked by separate in-flight flags, so a todo toggle cannot clobber a note edit in the same window. Pinned notes and plans are assembled into a synthetic context part by `lib/projectContextPinning.ts` at send time; that module tracks per-session what it already sent so an unchanged pinned set is not re-sent every turn. -`messageQueueStore.ts` has two owners, decided by `isServerOwnedMessageQueue()`. On web, desktop, and mobile the OpenChamber server owns the queue (`packages/web/server/lib/message-queue/`): it delivers queued messages when the session goes idle whether or not any UI is open, and the store is a projection of it — `hydrate()` loads the server snapshot for the active runtime, `openchamber:message-queue.updated` broadcasts keep it current, and every mutation is optimistic locally then settled on the server's copy of that session (a failed round-trip re-reads the server instead of guessing). A per-key server revision rejects stale snapshots. An empty session that arrives without a directory (servers before 1.22.2 dropped it once the queue emptied) clears every projection of that session id in the runtime, because a session id is unique across directories. Projection items carry attachment metadata only and no captured context; `popToInput()`/`takeForSend()` remove the message on the server and get the full payload back, which is why both are async. +`messageQueueStore.ts` has two owners, decided by `isServerOwnedMessageQueue()`. +On web, desktop, and mobile the server delivers the queue independently of the +UI. The store projects authoritative snapshots and revisioned session updates. +`sync/message-queue-sync.ts` receives queue events through the shared control SSE +stream at `/api/openchamber/events`, including while OpenCode uses SSE fallback. +It adds no poller or per-session connection. Either stream reconnecting requests +`resync()`, independently of directory-bootstrap suppression. + +Hydration and recovery share one in-flight request per runtime. A recovery edge +during its snapshot read earns one trailing read; legacy uploads are attempted +once per runtime rather than repeated on reconnect or snapshot failure. Snapshot +reads have a 15-second deadline. Failure preserves the projection and runtime +switches reject stale completions. Full-snapshot revisions also cover omitted +sessions, so a delayed mutation response cannot resurrect a cleared queue; +session events newer than that snapshot survive reconciliation. + +Mutations are optimistic and then settled on the server's copy; failed +round-trips re-read instead of guessing. Empty legacy events without a directory +clear all projections of their session in that runtime. Projection items carry +attachment metadata only, so `popToInput()` and `takeForSend()` asynchronously +remove the message on the server and retrieve its complete captured payload. A queued message is captured whole, so whoever delivers it sends exactly what the composer would have: `text` (the content with its agent mention stripped and `@file` mentions already resolved into `attachments`), `agentMention`, and `context` — every chip the composer had attached (inline comments, terminal selections, browser annotations, PR comments/checks, quotes, linked issue/PR/Linear references, pending synthetic parts) plus the skill instruction derived from the text. `QueuedContextPart` distinguishes attached items (restored to the chips when the message is edited) from derived instructions (re-derived on send, never restored) and from synthetic parts other surfaces handed the composer (restored as pending). Context is captured by `buildComposerContext` and delivered by `queuedContextToParts` (`components/chat/composer/submit/buildOutgoingMessage.ts`), the same functions the composer uses for its own send. Nothing is re-resolved at delivery: the server has no agent list, no confirmed mentions, and no draft store. Messages a previous build left in this browser are uploaded once on the first hydration of a runtime and then dropped from persistence for that runtime (`partialize` skips server-owned runtime keys). VS Code has no server and keeps the local queue with the foreground auto-send hook (`useQueuedMessageAutoSend`, enabled only there); `useMessageQueueHoldSync` tells the server to hold a session's queue while a UI-driven auto-review run is going. @@ -88,11 +141,11 @@ Permission auto-accept policy is authoritative in the active Web server or VS Co Shared safe storage treats durable failures per key. A quota or access failure creates an ephemeral override or tombstone for that key without disabling reads and writes for unrelated keys; later writes retry the durable backend. Deferred adapters retain failed operations for a later flush, and malformed Zustand JSON is removed and treated as missing so hydration can recover. -Project and UI settings use successful settings synchronization as authority. Omitted fields in a complete snapshot reset to canonical client defaults, including an omitted project list becoming empty. Theme fields are the exception: only bootstrap-grade theme adoption applies fields supplied by the server, while omitted fields preserve this window's current runtime-scoped theme and settings save echoes never adopt a theme. VS Code settings broadcasts may still adopt shared workspace pointers without replacing each webview's editor-derived theme. Transport or settings-load failure dispatches no synchronization event and preserves current state. Settings save responses are partial patches and must not clear unrelated in-memory preferences or local mirrors. Debounced settings writes flush best-effort on page hide, document hidden, app freeze, and unload — canceling the pending timer so the write happens exactly once — because a write lost inside the debounce window lets the stale server snapshot override the change on next startup; a hard process kill can still lose the in-flight request. The unload flush uses `keepalive: true` on the HTTP write, because a plain fetch started from `pagehide`/`beforeunload` is cancelled with the document; `navigator.sendBeacon` is not used, as it cannot carry the runtime bearer header. On Capacitor neither `pagehide` nor `beforeunload` fires when the OS suspends the app, so the flush also runs on `App.appStateChange` going inactive. +Settings fields are declared once in the settings registry (`packages/ui/src/lib/settings/DOCUMENTATION.md`); the sync described here iterates that registry rather than naming keys. Project and UI settings use successful settings synchronization as authority for the fields the snapshot supplies. A field the server omits is "unset", not "reset": the window keeps whatever value it already holds and nothing is written back — a bootstrap never seeds the server from local state. The one exception is the project list, whose omission still means an empty list (`useProjectsStore`). Theme fields follow the same keep-what-you-hold rule and additionally adopt only on bootstrap-grade syncs; settings save echoes never adopt a theme. A write reaches the server only because a person changed something in this window: the theme context writes only from its user-facing setters (never on mount or on adoption), and the store-subscribing auto-savers (`appearanceAutoSave`, `modelPrefsAutoSave`) treat changes made while `isApplyingServerSettings()` is true as a new baseline rather than a change to send. `updateDesktopSettings` additionally drops any key whose value equals the last value the server was seen holding for this runtime, so an echo or a toggle back to the server's value inside the debounce window produces no request. Device-scoped registry fields (window controls, mobile keyboard mode, input bar offset) never leave the install: they are dropped from writes, persisted only locally, and adopted from a pre-split server document once per runtime as a seed. Per-surface profile fields arrive already resolved for this client's surface kind (`lib/settings/surface.ts`); the stores never see another kind's value. VS Code settings broadcasts may still adopt shared workspace pointers without replacing each webview's editor-derived theme. Transport or settings-load failure dispatches no synchronization event and preserves current state. Settings save responses are partial patches and must not clear unrelated in-memory preferences or local mirrors. Debounced settings writes flush best-effort on page hide, document hidden, app freeze, and unload — canceling the pending timer so the write happens exactly once — because a write lost inside the debounce window lets the stale server snapshot override the change on next startup; a hard process kill can still lose the in-flight request. The unload flush uses `keepalive: true` on the HTTP write, because a plain fetch started from `pagehide`/`beforeunload` is cancelled with the document; `navigator.sendBeacon` is not used, as it cannot carry the runtime bearer header. On Capacitor neither `pagehide` nor `beforeunload` fires when the OS suspends the app, so the flush also runs on `App.appStateChange` going inactive. Project ordering defaults to manual. Session display persistence v3 migrates the previously shipped `recent` project order to `manual` while preserving every other explicit sort mode. -Session display persistence keeps a hydrated local cache for the independent all-projects/single-project mode, session grouping, project sort, and Recent preference; successful server settings snapshots are authoritative and the UI seeds missing server fields once from that cache for upgrades. The last confirmed or manually selected project and sticky-header preference stay local to the device. Draft target changes do not write the picker selection; materialized session navigation updates it from the resolved project directory. +Session display persistence keeps a hydrated local cache for the independent all-projects/single-project mode, session grouping, project sort, and Recent preference; successful server settings snapshots are authoritative for the fields they carry, and a field the server omits leaves the local cache untouched (it is not seeded back to the server). The last confirmed or manually selected project and sticky-header preference stay local to the device. Draft target changes do not write the picker selection; materialized session navigation updates it from the resolved project directory. Session folders persist in runtime-specific v2 browser keys without silently evicting older runtime namespaces. Runtime switch, page hide, app freeze, and unload synchronously flush the pending browser snapshot before lifecycle suspension or namespace replacement. A runtime switch then cancels stale old-runtime disk work and starts generation-owned disk hydration. Missing or malformed server files are not authoritative empty snapshots; disk data may replace browser state only when it carries a real revision and no newer local folder mutation occurred. Server writes are serialized and reject non-newer revisions so delayed or duplicate requests cannot overwrite the current state. File-search cache and in-flight keys include runtime plus directory and are cleared on endpoint reset. @@ -190,6 +243,8 @@ Important properties: - branch persistence is versioned, bounded, runtime-scoped, and claims the ambiguous legacy cache once - diff data has per-directory and aggregate count/UTF-8-byte limits; oversized single entries are rejected +Diff prefetch admits at most two outstanding transport requests per runtime and directory across overlapping batches. Its 15-second deadline stops waiting for a result; it does not cancel server work. A timed-out request retains its path and concurrency slot until the transport settles, including across cache resets, so later batches cannot repeat it or exceed the limit. Saturated prefetch skips further work instead of queueing retries. Late timed-out results never enter the cache, and successful or rejected transport completion releases capacity. Duplicate or saturated demand does not invalidate a batch already running. The Git view schedules prefetch only while active; explicit file opens remain independent of background prefetch capacity. + ### `useGitHubPrStatusStore.ts` `useGitHubPrStatusStore` is a centralized PR cache keyed by a collision-safe tuple of runtime, directory, branch, and requested remote. diff --git a/packages/ui/src/stores/messageQueueStore.server.test.ts b/packages/ui/src/stores/messageQueueStore.server.test.ts index 4b9a6686..2ce2abb7 100644 --- a/packages/ui/src/stores/messageQueueStore.server.test.ts +++ b/packages/ui/src/stores/messageQueueStore.server.test.ts @@ -6,7 +6,7 @@ import type { MessageQueueUpdatedEvent } from "./messageQueueStore" type FetchCall = { path: string; method: string; body: ReturnType } let calls: FetchCall[] = [] let activeRuntimeKey = "runtime-a" -let respond: (call: FetchCall) => Response = () => new Response("{}", { status: 200 }) +let respond: (call: FetchCall) => Response | Promise = () => new Response("{}", { status: 200 }) mock.module("@/lib/runtime-fetch", () => ({ runtimeFetch: async (path: string, init?: RequestInit) => { @@ -44,6 +44,15 @@ type ServerReply = { const json = (value: ServerReply, status = 200) => new Response(JSON.stringify(value), { status }) +const deferredResponse = () => { + let complete: ((response: Response) => void) | undefined + const promise = new Promise((resolve) => { complete = resolve }) + return { promise, resolve: (response: Response) => { + if (!complete) throw new Error("Deferred response was not initialized") + complete(response) + } } +} + const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")! const key = getMessageQueueKey(target) @@ -82,6 +91,7 @@ const attachment: AttachedFile = { } beforeEach(() => { + useMessageQueueStore.getState().resetForRuntimeSwitch(activeRuntimeKey) activeRuntimeKey = "runtime-a" useInputHistoryStore.setState({ globalBuckets: {}, sessionBuckets: {} }) calls = [] @@ -123,6 +133,128 @@ describe("server-owned message queue", () => { expect(useMessageQueueStore.getState().sendingIds[key]).toEqual(["q1"]) }) + test("hydrate keeps a queue newer than its snapshot", async () => { + applyMessageQueueUpdatedEvent(updated(10, session([serverItem("q1", "queued after the read started")])), "runtime-a") + respond = () => json({ revision: 9, sessions: [] }) + await useMessageQueueStore.getState().hydrate() + + expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["q1"]) + }) + + test("resync can establish the initial snapshot before bootstrap", async () => { + activeRuntimeKey = "runtime-never-hydrated" + respond = () => json({ revision: 1, sessions: [] }) + await useMessageQueueStore.getState().resync() + expect(calls).toHaveLength(1) + }) + + test("a reconnect during the initial snapshot retains one trailing refresh", async () => { + const first = deferredResponse() + respond = () => calls.length === 1 ? first.promise : json({ revision: 12, sessions: [] }) + const bootstrap = useMessageQueueStore.getState().hydrate() + const reconnect = useMessageQueueStore.getState().resync() + const secondReconnect = useMessageQueueStore.getState().resync() + expect(calls).toHaveLength(1) + first.resolve(json({ revision: 10, sessions: [session([serverItem("q1", "delivered after snapshot")])] })) + await Promise.all([bootstrap, reconnect, secondReconnect]) + expect(calls).toHaveLength(2) + expect(useMessageQueueStore.getState().queuedMessages[key]).toBeUndefined() + }) + + test("concurrent bootstrap and recovery migrate a legacy message only once", async () => { + activeRuntimeKey = "runtime-legacy-recovery" + const legacyTarget = createMessageQueueTarget("session-1", "/repo", activeRuntimeKey) + if (!legacyTarget) throw new Error("Missing test target") + const legacyKey = getMessageQueueKey(legacyTarget) + useMessageQueueStore.setState({ queuedMessages: { [legacyKey]: [{ id: "local", content: "legacy", text: "legacy", createdAt: 1, sendConfig: { providerID: "p", modelID: "m" } }] } }) + const upload = deferredResponse() + respond = (call) => call.method === "POST" ? upload.promise : json({ revision: 2, sessions: [] }) + const bootstrap = useMessageQueueStore.getState().hydrate() + const recovery = useMessageQueueStore.getState().resync() + upload.resolve(json({ revision: 2, session: session([]) })) + await Promise.all([bootstrap, recovery]) + expect(calls.filter((call) => call.method === "POST")).toHaveLength(1) + expect(useMessageQueueStore.getState().queuedMessages[legacyKey]).toBeUndefined() + }) + + test("an empty snapshot prevents delayed responses from resurrecting omitted queues", async () => { + applyMessageQueueUpdatedEvent(updated(10, session([serverItem("q1", "queued")], "q1")), "runtime-a") + respond = () => json({ revision: 12, sessions: [] }) + await useMessageQueueStore.getState().hydrate() + applyMessageQueueUpdatedEvent(updated(11, session([serverItem("q1", "stale")], "q1")), "runtime-a") + expect(useMessageQueueStore.getState().queuedMessages[key]).toBeUndefined() + expect(useMessageQueueStore.getState().sendingIds[key]).toBeUndefined() + const other = { ...session([serverItem("q2", "unseen stale")]), sessionId: "unseen" } + applyMessageQueueUpdatedEvent(updated(11, other), "runtime-a") + expect(Object.keys(useMessageQueueStore.getState().queuedMessages)).toHaveLength(0) + }) + + test("recovery demand survives a failed in-flight snapshot", async () => { + const first = deferredResponse() + applyMessageQueueUpdatedEvent(updated(10, session([serverItem("q1", "delivered")])), "runtime-a") + respond = () => calls.length === 1 ? first.promise : json({ revision: 12, sessions: [] }) + const bootstrap = useMessageQueueStore.getState().hydrate() + const recovery = useMessageQueueStore.getState().resync() + first.resolve(new Response(null, { status: 503 })) + await Promise.all([bootstrap, recovery]) + expect(calls).toHaveLength(2) + expect(useMessageQueueStore.getState().queuedMessages[key]).toBeUndefined() + }) + + test("returning to a runtime migrates its unattempted legacy messages without repeating the first upload", async () => { + activeRuntimeKey = "runtime-partial-migration" + const legacyTarget = createMessageQueueTarget("session-1", "/repo", activeRuntimeKey) + if (!legacyTarget) throw new Error("Missing test target") + const legacyKey = getMessageQueueKey(legacyTarget) + useMessageQueueStore.setState({ queuedMessages: { [legacyKey]: ["first", "second"].map((id) => ({ id, content: id, text: id, createdAt: 1, sendConfig: { providerID: "p", modelID: "m" } })) } }) + const first = deferredResponse() + respond = (call) => call.method === "POST" + ? calls.length === 1 ? first.promise : json({ revision: 2, session: session([]) }) + : json({ revision: 3, sessions: [] }) + const initial = useMessageQueueStore.getState().hydrate() + useMessageQueueStore.getState().resetForRuntimeSwitch(activeRuntimeKey) + activeRuntimeKey = "runtime-other" + first.resolve(json({ revision: 1, session: session([]) })) + await initial + activeRuntimeKey = "runtime-partial-migration" + await useMessageQueueStore.getState().hydrate() + expect(calls.filter((call) => call.method === "POST").map((call) => call.body.item.content)).toEqual(["first", "second"]) + }) + + test("a failed refresh preserves the projection and a later recovery retries", async () => { + applyMessageQueueUpdatedEvent(updated(10, session([serverItem("q1", "queued")])), "runtime-a") + respond = () => new Response(null, { status: 503 }) + await expect(useMessageQueueStore.getState().resync()).rejects.toThrow() + expect(useMessageQueueStore.getState().queuedMessages[key]).toHaveLength(1) + respond = () => json({ revision: 12, sessions: [] }) + await useMessageQueueStore.getState().resync() + expect(useMessageQueueStore.getState().queuedMessages[key]).toBeUndefined() + }) + + test("a runtime switch rejects an old snapshot and its pending recovery", async () => { + const old = deferredResponse() + respond = () => old.promise + const bootstrap = useMessageQueueStore.getState().hydrate() + const recovery = useMessageQueueStore.getState().resync() + useMessageQueueStore.getState().resetForRuntimeSwitch(activeRuntimeKey) + activeRuntimeKey = "runtime-b" + respond = () => json({ revision: 1, sessions: [] }) + await useMessageQueueStore.getState().hydrate() + old.resolve(json({ revision: 99, sessions: [session([serverItem("q1", "old runtime")])] })) + await Promise.all([bootstrap, recovery]) + expect(Object.keys(useMessageQueueStore.getState().queuedMessages)).toHaveLength(0) + expect(calls).toHaveLength(2) + }) + + test("resync drops a queue the server no longer lists", async () => { + respond = () => json({ revision: 3, sessions: [session([serverItem("q1", "queued")], "q1")] }) + await useMessageQueueStore.getState().hydrate() + + respond = () => json({ revision: 4, sessions: [] }) + await useMessageQueueStore.getState().resync() + expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined) + }) + test("addToQueue shows the message at once and settles on the server's copy", async () => { respond = () => json({ revision: 5, session: session([serverItem("srv-1", "hi @reviewer", { agentMention: "reviewer" })]) }) const pending = useMessageQueueStore.getState().addToQueue(target, { @@ -270,6 +402,16 @@ describe("server-owned message queue", () => { expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["q1"]) }) + test("a failed take re-reads the server so a stale projection is cleared", async () => { + useMessageQueueStore.setState({ queuedMessages: { [key]: [{ id: "q1", content: "already delivered", text: "already delivered", createdAt: 1 }] } }) + respond = (call) => (call.path.endsWith("/take") + ? new Response(JSON.stringify({ error: "queued message not found" }), { status: 404 }) + : json({ revision: 12, sessions: [] })) + await expect(useMessageQueueStore.getState().takeForSend(target, "q1")).rejects.toThrow() + + expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined) + }) + test("broadcasts update the projection but never move it backwards", () => { applyMessageQueueUpdatedEvent(updated(4, session([serverItem("q1", "newer")])), "runtime-a") expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.content)).toEqual(["newer"]) diff --git a/packages/ui/src/stores/messageQueueStore.ts b/packages/ui/src/stores/messageQueueStore.ts index fa48213e..8aa62377 100644 --- a/packages/ui/src/stores/messageQueueStore.ts +++ b/packages/ui/src/stores/messageQueueStore.ts @@ -14,7 +14,7 @@ import { normalizePath } from '@/lib/pathNormalization'; export type FollowUpBehavior = 'steer' | 'queue'; -export const DEFAULT_FOLLOW_UP_BEHAVIOR: FollowUpBehavior = 'queue'; +const DEFAULT_FOLLOW_UP_BEHAVIOR: FollowUpBehavior = 'queue'; export const isFollowUpBehavior = (value: unknown): value is FollowUpBehavior => ( value === 'steer' || value === 'queue' @@ -328,9 +328,16 @@ const sessionPath = (sessionId: string) => `/api/message-queue/sessions/${encode * stale local copy would resurrect messages the server already delivered. */ const serverOwnedRuntimeKeys = new Set(); +type LegacyQueueMigration = { + items: Array<{ target: MessageQueueTarget; message: QueuedMessage }>; + pending: Promise | null; +}; +const legacyMigrations = new Map(); /** Server revision last applied per queue key; older snapshots are ignored. */ const appliedRevisions = new Map(); +/** A full snapshot also owns sessions it omits, including previously unseen keys. */ +const snapshotRevisions = new Map(); let hydrationGeneration = 0; interface MessageQueueState { @@ -375,6 +382,8 @@ interface MessageQueueActions { getQueueForTarget: (target: MessageQueueTarget) => QueuedMessage[]; /** Server-owned queue: load the authoritative queue for the active runtime. */ hydrate: () => Promise; + /** Server-owned queue: re-read after an event-stream gap. */ + resync: () => Promise; /** Server-owned queue: apply one session's authoritative state (broadcast or response). */ applyServerSession: (session: ServerQueueSession, revision: number, expectedRuntimeKey: string) => void; /** Server-owned queue: tell the server to hold or release a session's delivery. */ @@ -454,8 +463,11 @@ export const useMessageQueueStore = create()( devtools( persist( (set, get) => { + let hydration: { runtimeKey: string; promise: Promise } | null = null; + let resyncRequested = false; const applyServerSession = (session: ServerQueueSession, revision: number, expectedRuntimeKey: string) => { if (expectedRuntimeKey !== getRuntimeKey()) return; + if ((snapshotRevisions.get(expectedRuntimeKey) ?? -1) > revision) return; const target = createMessageQueueTarget(session.sessionId, session.directory, expectedRuntimeKey); if (!target) { // Servers before 1.22.2 drop a session's directory once its @@ -609,18 +621,23 @@ export const useMessageQueueStore = create()( takeForSend: async (target, messageId) => { const key = getMessageQueueKey(target); if (isServerOwnedMessageQueue()) { - if (messageId) { - const result = await requestJson( - serverTakeResponseSchema, - `${sessionPath(target.sessionId)}/items/${encodeURIComponent(messageId)}/take`, - jsonInit('POST'), - ); + try { + if (messageId) { + const result = await requestJson( + serverTakeResponseSchema, + `${sessionPath(target.sessionId)}/items/${encodeURIComponent(messageId)}/take`, + jsonInit('POST'), + ); + applyServerSession(result.session, result.revision, target.runtimeKey); + return [toQueuedMessage(result.item)]; + } + const result = await requestJson(serverTakeAllResponseSchema, `${sessionPath(target.sessionId)}/take`, jsonInit('POST')); applyServerSession(result.session, result.revision, target.runtimeKey); - return [toQueuedMessage(result.item)]; + return result.items.map(toQueuedMessage); + } catch (error) { + await refreshSession(target); + throw error; } - const result = await requestJson(serverTakeAllResponseSchema, `${sessionPath(target.sessionId)}/take`, jsonInit('POST')); - applyServerSession(result.session, result.revision, target.runtimeKey); - return result.items.map(toQueuedMessage); } const state = get(); @@ -707,63 +724,98 @@ export const useMessageQueueStore = create()( return get().queuedMessages[getMessageQueueKey(target)] ?? []; }, - hydrate: async () => { - if (!isServerOwnedMessageQueue()) return; + hydrate: () => { + if (!isServerOwnedMessageQueue()) return Promise.resolve(); const runtimeKey = getRuntimeKey(); + if (hydration?.runtimeKey === runtimeKey) return hydration.promise; const generation = ++hydrationGeneration; const isCurrent = () => generation === hydrationGeneration && runtimeKey === getRuntimeKey(); - - // Messages queued by an older build live in this browser only. - // Hand them to the server once so they are still delivered; - // whatever cannot be uploaded is superseded by the server's queue. - const legacyEntries = Object.entries(get().queuedMessages) - .map(([key, queue]) => ({ target: parseMessageQueueKey(key), queue })) - .filter((entry): entry is { target: MessageQueueTarget; queue: QueuedMessage[] } => ( - entry.target !== null && entry.target.runtimeKey === runtimeKey && !serverOwnedRuntimeKeys.has(runtimeKey) - )); - for (const { target, queue } of legacyEntries) { - for (const message of queue) { - if (!message.sendConfig) continue; - try { - await requestJson(serverSessionResponseSchema, `${sessionPath(target.sessionId)}/items`, jsonInit('POST', { - directory: target.directory, - item: toServerItemInput(message, message.sendConfig), - })); - } catch (error) { - console.warn('[queue] failed to migrate a locally queued message to the server:', error); - } + const promise = (async () => { + // Migration and recovery share one request owner so + // reconnects cannot upload a legacy message twice. + let migration = legacyMigrations.get(runtimeKey); + if (!migration) { + const items = Object.entries(get().queuedMessages).flatMap(([key, queue]) => { + const target = parseMessageQueueKey(key); + if (!target || target.runtimeKey !== runtimeKey) return []; + return queue.map((message) => ({ target, message })); + }); + migration = { items, pending: null }; + legacyMigrations.set(runtimeKey, migration); + } + while (migration.pending || migration.items.length > 0) { if (!isCurrent()) return; - } - } - - const snapshot = await requestJson(serverSnapshotSchema, '/api/message-queue'); - if (!isCurrent()) return; - serverOwnedRuntimeKeys.add(runtimeKey); - set((state) => { - const queuedMessages: Record = {}; - const sendingIds: Record = {}; - for (const [key, queue] of Object.entries(state.queuedMessages)) { - if (parseMessageQueueKey(key)?.runtimeKey !== runtimeKey) queuedMessages[key] = queue; - } - for (const [key, ids] of Object.entries(state.sendingIds)) { - if (parseMessageQueueKey(key)?.runtimeKey !== runtimeKey) sendingIds[key] = ids; - } - for (const session of snapshot.sessions) { - const target = createMessageQueueTarget(session.sessionId, session.directory, runtimeKey); - if (!target) continue; - const key = getMessageQueueKey(target); - if ((appliedRevisions.get(key) ?? -1) > snapshot.revision) { - // A broadcast newer than this snapshot already landed; keep it. - if (state.queuedMessages[key]) queuedMessages[key] = state.queuedMessages[key]; - if (state.sendingIds[key]) sendingIds[key] = state.sendingIds[key]; + if (migration.pending) { + await migration.pending; continue; } - appliedRevisions.set(key, snapshot.revision); - if (session.items.length > 0) queuedMessages[key] = session.items.map(toQueuedMessage); - if (session.sendingId) sendingIds[key] = [session.sendingId]; + const next = migration.items.shift(); + if (!next?.message.sendConfig) continue; + const { target, message } = next; + const upload = requestJson(serverSessionResponseSchema, `${sessionPath(target.sessionId)}/items`, jsonInit('POST', { + directory: target.directory, + item: toServerItemInput(message, next.message.sendConfig), + })).then(() => undefined).catch((error) => { + console.warn('[queue] failed to migrate a locally queued message to the server:', error); + }); + migration.pending = upload; + const owner = migration; + void upload.then(() => { if (owner.pending === upload) owner.pending = null; }); + await upload; } - return { queuedMessages, sendingIds }; - }); + if (!isCurrent()) return; + + do { + resyncRequested = false; + let snapshot: z.infer; + try { + snapshot = await requestJson(serverSnapshotSchema, '/api/message-queue', { signal: AbortSignal.timeout(15_000) }); + } catch (error) { + if (!isCurrent()) return; + if (resyncRequested) continue; + throw error; + } + if (!isCurrent()) return; + serverOwnedRuntimeKeys.add(runtimeKey); + if ((snapshotRevisions.get(runtimeKey) ?? -1) > snapshot.revision) continue; + snapshotRevisions.set(runtimeKey, snapshot.revision); + set((state) => { + // A broadcast newer than this snapshot wins, listed in it or not. + const isNewerThanSnapshot = (key: string) => (appliedRevisions.get(key) ?? -1) > snapshot.revision; + const keep = (key: string) => parseMessageQueueKey(key)?.runtimeKey !== runtimeKey || isNewerThanSnapshot(key); + const queuedMessages: Record = {}; + const sendingIds: Record = {}; + for (const [key, queue] of Object.entries(state.queuedMessages)) { + if (keep(key)) queuedMessages[key] = queue; + } + for (const [key, ids] of Object.entries(state.sendingIds)) { + if (keep(key)) sendingIds[key] = ids; + } + for (const session of snapshot.sessions) { + const target = createMessageQueueTarget(session.sessionId, session.directory, runtimeKey); + if (!target) continue; + const key = getMessageQueueKey(target); + if (isNewerThanSnapshot(key)) continue; + appliedRevisions.set(key, snapshot.revision); + if (session.items.length > 0) queuedMessages[key] = session.items.map(toQueuedMessage); + if (session.sendingId) sendingIds[key] = [session.sendingId]; + } + return { queuedMessages, sendingIds }; + }); + } while (resyncRequested && isCurrent()); + })(); + const run = { runtimeKey, promise }; + hydration = run; + const release = () => { if (hydration === run) hydration = null; }; + void promise.then(release, release); + return promise; + }, + + resync: () => { + // Share legacy migration with bootstrap. A recovery edge + // during its snapshot read still earns one trailing read. + if (hydration?.runtimeKey === getRuntimeKey()) resyncRequested = true; + return get().hydrate(); }, applyServerSession, @@ -776,6 +828,14 @@ export const useMessageQueueStore = create()( resetForRuntimeSwitch: (previousRuntimeKey) => { hydrationGeneration += 1; + hydration = null; + resyncRequested = false; + if (previousRuntimeKey) { + snapshotRevisions.delete(previousRuntimeKey); + for (const key of appliedRevisions.keys()) { + if (parseMessageQueueKey(key)?.runtimeKey === previousRuntimeKey) appliedRevisions.delete(key); + } + } if (!previousRuntimeKey || !serverOwnedRuntimeKeys.has(previousRuntimeKey)) return; // The previous runtime's projection belongs to its server; // switching back re-hydrates it from there. @@ -817,19 +877,17 @@ export const useMessageQueueStore = create()( ) ); -const serverUpdatedEventSchema = z.object({ +export const messageQueueUpdatedEventSchema = z.object({ + type: z.literal('openchamber:message-queue.updated'), properties: z.object({ revision: z.number(), session: serverSessionSchema }), }); -export type MessageQueueUpdatedEvent = { - type: 'openchamber:message-queue.updated'; - properties: z.infer['properties']; -}; +export type MessageQueueUpdatedEvent = z.infer; /** `openchamber:message-queue.updated` broadcast → projection. */ export const applyMessageQueueUpdatedEvent = (payload: Event | MessageQueueUpdatedEvent, expectedRuntimeKey: string): void => { if (!isServerOwnedMessageQueue()) return; - const parsed = serverUpdatedEventSchema.safeParse(payload); + const parsed = messageQueueUpdatedEventSchema.safeParse(payload); if (!parsed.success) return; const { session, revision } = parsed.data.properties; useMessageQueueStore.getState().applyServerSession(session, revision, expectedRuntimeKey); diff --git a/packages/ui/src/stores/useBtwStore.test.ts b/packages/ui/src/stores/useBtwStore.test.ts index 9711d182..e04cb2b4 100644 --- a/packages/ui/src/stores/useBtwStore.test.ts +++ b/packages/ui/src/stores/useBtwStore.test.ts @@ -1,5 +1,15 @@ import { beforeEach, describe, expect, test } from 'bun:test'; -import { useBtwStore } from './useBtwStore'; +import { resolveBtwSelection, useBtwStore } from './useBtwStore'; +import { useSelectionStore } from '@/sync/selection-store'; + +const composerModel = { providerId: 'openai', modelId: 'gpt-5.6-terra' }; +const input = { + agents: [{ name: 'build', mode: 'primary' as const }, { name: 'plan', mode: 'primary' as const }], + savedAgent: null, + savedModel: null, + composerModel, + composerVariant: 'medium', +}; describe('useBtwStore', () => { beforeEach(() => { @@ -35,4 +45,52 @@ describe('useBtwStore', () => { useBtwStore.getState().clearPanelState('missing'); expect(useBtwStore.getState().byParent).toBe(before); }); + + test('inherits model and effort from the main composer, not from the plan agent', () => { + expect(resolveBtwSelection(input)).toEqual({ agent: 'plan', model: composerModel, variant: 'medium' }); + expect(resolveBtwSelection({ ...input, composerVariant: null }).variant).toBeNull(); + expect(resolveBtwSelection({ ...input, composerModel: null }).model).toBeNull(); + expect(resolveBtwSelection({ ...input, agents: [ + { name: 'hidden', mode: 'primary', hidden: true }, + { name: 'custom', mode: 'primary' }, + ] })).toEqual({ agent: 'custom', model: composerModel, variant: 'medium' }); + }); + + test('prefers the saved BTW selection over the current composer', () => { + const savedModel = { providerId: 'one', modelId: 'selected' }; + expect(resolveBtwSelection({ ...input, savedModel, savedVariant: null })) + .toEqual({ agent: 'plan', model: savedModel, variant: null }); + expect(resolveBtwSelection({ ...input, savedModel }).variant).toBe(undefined); + }); + + test('publishes BTW effort edits and cancellation without changing the parent', () => { + const store = useSelectionStore.getState(); + const parent = 'selection-cleanup-parent'; + const pending = `btw-pending:${parent}`; + for (const session of [parent, pending]) { + store.saveSessionModelSelection(session, 'one', 'model'); + store.saveSessionAgentSelection(session, 'plan'); + store.saveAgentModelForSession(session, 'plan', 'one', 'model'); + store.saveAgentModelVariantForSession(session, 'plan', 'one', 'model', 'high'); + } + const observed: Array = []; + const unsubscribe = useSelectionStore.subscribe((state) => { + observed.push(state.getAgentModelVariantForSession(pending, 'plan', 'one', 'model')); + }); + try { + store.saveAgentModelVariantForSession(pending, 'plan', 'one', 'model', null); + store.clearSessionSelections(pending); + } finally { + unsubscribe(); + } + expect(observed).toEqual([null, undefined]); + expect(store.getSessionModelSelection(pending)).toBeNull(); + expect(store.getSessionAgentSelection(pending)).toBeNull(); + expect(store.getAgentModelForSession(pending, 'plan')).toBeNull(); + expect(store.getAgentModelVariantForSession(pending, 'plan', 'one', 'model')).toBe(undefined); + expect(store.getSessionModelSelection(parent)).toEqual({ providerId: 'one', modelId: 'model' }); + expect(store.getSessionAgentSelection(parent)).toBe('plan'); + expect(store.getAgentModelForSession(parent, 'plan')).toEqual({ providerId: 'one', modelId: 'model' }); + expect(store.getAgentModelVariantForSession(parent, 'plan', 'one', 'model')).toBe('high'); + }); }); diff --git a/packages/ui/src/stores/useBtwStore.ts b/packages/ui/src/stores/useBtwStore.ts index c885f3bc..3d4c575f 100644 --- a/packages/ui/src/stores/useBtwStore.ts +++ b/packages/ui/src/stores/useBtwStore.ts @@ -1,4 +1,31 @@ import { create } from 'zustand'; +import type { Agent } from '@opencode-ai/sdk/v2'; + +type BtwModelSelection = { providerId: string; modelId: string }; +export type BtwSelection = { + agent: string | undefined; + model: BtwModelSelection | null; + variant: string | null | undefined; +}; + +export const resolveBtwSelection = ({ agents, savedAgent, savedModel, savedVariant, composerModel, composerVariant }: { + agents: readonly Pick[]; + savedAgent: string | null; + savedModel: BtwModelSelection | null; + savedVariant?: string | null; + composerModel: BtwModelSelection | null; + composerVariant: string | null | undefined; +}): BtwSelection => { + const selectable = agents.filter((agent) => !agent.hidden && (agent.mode === 'primary' || agent.mode === 'all')); + const agent = selectable.find((candidate) => candidate.name === savedAgent) + ?? selectable.find((candidate) => candidate.name === 'plan') + ?? selectable[0]; + return { + agent: agent?.name, + model: savedModel ?? composerModel, + variant: savedModel ? savedVariant : composerVariant, + }; +}; /** * UI-only state for the `/btw` peek panel. @@ -15,11 +42,15 @@ import { create } from 'zustand'; * landing, so the panel can show its starting state immediately. * - `destroying`: close was clicked; hides the panel optimistically while the * unlink/delete round-trip completes. + * - `pending`: `/btw` has opened an unsent local composer. No fork exists yet. */ type BtwPanelUIState = { collapsed?: boolean; creating?: boolean; destroying?: boolean; + pending?: boolean; + pendingAutoAccept?: boolean; + pendingSend?: symbol; }; type BtwStore = { diff --git a/packages/ui/src/stores/useCommitSelectionStore.test.ts b/packages/ui/src/stores/useCommitSelectionStore.test.ts new file mode 100644 index 00000000..664b10cd --- /dev/null +++ b/packages/ui/src/stores/useCommitSelectionStore.test.ts @@ -0,0 +1,20 @@ +import { afterEach, expect, test } from 'bun:test'; +import { commitSelectionKey, useCommitSelectionStore } from './useCommitSelectionStore'; + +afterEach(() => useCommitSelectionStore.setState({ selections: new Map() })); + +test('shares a commit choice within its runtime, repository and checked-out branch only', () => { + const key = commitSelectionKey('/repo', 'feature', 'runtime-a'); + const commit = { + hash: 'a'.repeat(40), message: 'Selected commit', date: '2026-09-09T09:22:00Z', + author_name: 'Test Author', author_email: 'test@example.com', refs: '', body: '', + filesChanged: 1, insertions: 1, deletions: 0, parents: [], + }; + useCommitSelectionStore.getState().select(key, commit); + expect(useCommitSelectionStore.getState().selections.get(key)).toEqual(commit); + for (const otherKey of [ + commitSelectionKey('/other', 'feature', 'runtime-a'), + commitSelectionKey('/repo', 'other', 'runtime-a'), + commitSelectionKey('/repo', 'feature', 'runtime-b'), + ]) expect(useCommitSelectionStore.getState().selections.get(otherKey)).toBeUndefined(); +}); diff --git a/packages/ui/src/stores/useCommitSelectionStore.ts b/packages/ui/src/stores/useCommitSelectionStore.ts new file mode 100644 index 00000000..a3ffd6dc --- /dev/null +++ b/packages/ui/src/stores/useCommitSelectionStore.ts @@ -0,0 +1,28 @@ +import { create } from 'zustand'; +import type { GitLogEntry } from '@/lib/api/types'; +import { getRuntimeKey } from '@/lib/runtime-switch'; + +export const commitSelectionKey = (directory: string, branch: string | null, runtimeKey = getRuntimeKey()): string => + JSON.stringify([runtimeKey, directory, branch]); + +interface CommitSelectionState { + selections: Map; + select: (key: string, commit: GitLogEntry) => void; +} + +// Shared between Changes and walkthrough. Selections are session-only and the +// branch is part of the key, so a checkout starts with that branch's history. +export const useCommitSelectionStore = create((set) => ({ + selections: new Map(), + select: (key, commit) => set((state) => { + const selections = new Map(state.selections); + selections.delete(key); + selections.set(key, commit); + // Bound remembered choices on explicit selection, never while acquiring a view. + if (selections.size > 100) { + const oldest = selections.keys().next().value; + if (oldest !== undefined) selections.delete(oldest); + } + return { selections }; + }), +})); diff --git a/packages/ui/src/stores/useConfigStore.test.ts b/packages/ui/src/stores/useConfigStore.test.ts index a1daf455..96451798 100644 --- a/packages/ui/src/stores/useConfigStore.test.ts +++ b/packages/ui/src/stores/useConfigStore.test.ts @@ -210,7 +210,10 @@ mock.module('@/lib/runtime-fetch', () => ({ })); mock.module('@/lib/persistence', () => ({ - updateDesktopSettings: mock(async () => undefined), + updateDesktopSettings: mock(async () => ({ ok: true })), + // The store reads the shared document through this; an empty document + // keeps every OpenChamber default unset, like the settings route used to. + loadDesktopSettings: mock(async () => ({})), })); mock.module('@/lib/startupTrace', () => ({ diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 4e595896..fb42250a 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -10,9 +10,7 @@ import { filterVisibleAgents } from "./useAgentsStore"; import { isPrimaryMode } from "@/components/chat/mobileControlsUtils"; import { useSessionUIStore } from "@/sync/session-ui-store"; import { useSelectionStore } from "@/sync/selection-store"; -import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"; -import { updateDesktopSettings } from "@/lib/persistence"; -import { useGitProviderDomainsStore } from "@/stores/useGitProviderDomainsStore"; +import { loadDesktopSettings, updateDesktopSettings } from "@/lib/persistence"; import { useDirectoryStore } from "@/stores/useDirectoryStore"; import { useProjectsStore } from "@/stores/useProjectsStore"; import { resolveProjectForSessionDirectory } from "@/lib/projectResolution"; @@ -37,21 +35,6 @@ const GIT_UTILITY_PROVIDER_ID = "zen"; const GIT_UTILITY_PREFERRED_MODEL_ID = "big-pickle"; const PROVIDER_CONFIG_REFRESH_CONCURRENCY = 4; -const normalizeSttProvider = (value: unknown): 'local' | 'openai-compatible' | undefined => { - if (value === 'local' || value === 'openai-compatible') { - return value; - } - // Legacy providers: 'server' used an OpenAI-compatible endpoint; - // 'browser' and 'wasm' map to the local default. - if (value === 'server') { - return 'openai-compatible'; - } - if (value === 'browser' || value === 'wasm') { - return 'local'; - } - return undefined; -}; - interface OpenChamberDefaults { defaultModel?: string; defaultVariant?: string; @@ -66,8 +49,6 @@ interface OpenChamberDefaults { sttModel?: string; sttLocalModel?: string; sttLanguage?: string; - /** Raw `gitProviders` section of server settings (per-provider apiBaseUrl/detectUrls). */ - gitProviders?: unknown; } // Directory activation re-reads the OpenChamber defaults, which are global, @@ -103,93 +84,29 @@ const requestOpenChamberDefaults = async (): Promise => { return result; }; try { - // 1. Runtime settings API (VSCode) - const runtimeSettings = getRegisteredRuntimeAPIs()?.settings; - if (runtimeSettings) { - try { - const result = await runtimeSettings.load(); - const data = result?.settings; - if (data) { - const defaultModel = typeof data?.defaultModel === 'string' ? data.defaultModel.trim() : ''; - const defaultVariant = typeof data?.defaultVariant === 'string' ? data.defaultVariant.trim() : ''; - const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : ''; - const gitmojiEnabled = typeof data?.gitmojiEnabled === 'boolean' ? data.gitmojiEnabled : undefined; - const defaultFileViewerPreview = typeof data?.defaultFileViewerPreview === 'boolean' ? data.defaultFileViewerPreview : undefined; - const zenModel = typeof data?.zenModel === 'string' ? data.zenModel.trim() : ''; - const messageStreamTransport = - data?.messageStreamTransport === 'ws' || data?.messageStreamTransport === 'sse' || data?.messageStreamTransport === 'auto' - ? data.messageStreamTransport - : undefined; - const sttProvider = normalizeSttProvider(data?.sttProvider); - const sttServerUrl = typeof data?.sttServerUrl === 'string' ? data.sttServerUrl.trim() : undefined; - const sttModel = typeof data?.sttModel === 'string' ? data.sttModel.trim() : undefined; - const sttLocalModel = typeof data?.sttLocalModel === 'string' ? data.sttLocalModel.trim() : undefined; - const sttLanguage = typeof data?.sttLanguage === 'string' ? data.sttLanguage.trim() : undefined; - const gitProviders = data?.gitProviders; - - return finish('runtime-settings', { - defaultModel: defaultModel.length > 0 ? defaultModel : undefined, - defaultVariant: defaultVariant.length > 0 ? defaultVariant : undefined, - defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined, - autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined, - gitmojiEnabled, - defaultFileViewerPreview, - zenModel: zenModel.length > 0 ? zenModel : undefined, - messageStreamTransport, - sttProvider, - sttServerUrl, - sttModel, - sttLocalModel, - sttLanguage, - gitProviders, - }); - } - } catch { - // Fall through to fetch - } + const data = await loadDesktopSettings(); + if (!data) { + return finish('settings-unavailable', {}); } + const defaultModel = data.defaultModel?.trim() ?? ''; + const defaultVariant = data.defaultVariant?.trim() ?? ''; + const defaultAgent = data.defaultAgent?.trim() ?? ''; + const zenModel = data.zenModel ?? ''; - // 2. Fetch API (Web/server) - const response = await runtimeFetch('/api/config/settings', { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - if (!response.ok) { - return finish('settings-route-not-ok', {}); - } - const data = await response.json(); - const defaultModel = typeof data?.defaultModel === 'string' ? data.defaultModel.trim() : ''; - const defaultVariant = typeof data?.defaultVariant === 'string' ? data.defaultVariant.trim() : ''; - const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : ''; - const gitmojiEnabled = typeof data?.gitmojiEnabled === 'boolean' ? data.gitmojiEnabled : undefined; - const defaultFileViewerPreview = typeof data?.defaultFileViewerPreview === 'boolean' ? data.defaultFileViewerPreview : undefined; - const zenModel = typeof data?.zenModel === 'string' ? data.zenModel.trim() : ''; - const messageStreamTransport = - data?.messageStreamTransport === 'ws' || data?.messageStreamTransport === 'sse' || data?.messageStreamTransport === 'auto' - ? data.messageStreamTransport - : undefined; - const sttProvider = normalizeSttProvider(data?.sttProvider); - const sttServerUrl = typeof data?.sttServerUrl === 'string' ? data.sttServerUrl.trim() : undefined; - const sttModel = typeof data?.sttModel === 'string' ? data.sttModel.trim() : undefined; - const sttLocalModel = typeof data?.sttLocalModel === 'string' ? data.sttLocalModel.trim() : undefined; - const sttLanguage = typeof data?.sttLanguage === 'string' ? data.sttLanguage.trim() : undefined; - const gitProviders = data?.gitProviders; - - return finish('settings-route', { + return finish('settings', { defaultModel: defaultModel.length > 0 ? defaultModel : undefined, defaultVariant: defaultVariant.length > 0 ? defaultVariant : undefined, defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined, - autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined, - gitmojiEnabled, - defaultFileViewerPreview, + autoCreateWorktree: data.autoCreateWorktree, + gitmojiEnabled: data.gitmojiEnabled, + defaultFileViewerPreview: data.defaultFileViewerPreview, zenModel: zenModel.length > 0 ? zenModel : undefined, - messageStreamTransport, - sttProvider, - sttServerUrl, - sttModel, - sttLocalModel, - sttLanguage, - gitProviders, + messageStreamTransport: data.messageStreamTransport, + sttProvider: data.sttProvider, + sttServerUrl: data.sttServerUrl, + sttModel: data.sttModel, + sttLocalModel: data.sttLocalModel, + sttLanguage: data.sttLanguage, }); } catch (error) { markStartupTrace('config.defaults:error', { error: error instanceof Error ? error.message : String(error) }); @@ -731,28 +648,12 @@ const toDirectoryKey = (directory: string | null | undefined): string => { const fromDirectoryKey = (key: string): string | null => (key === DIRECTORY_KEY_GLOBAL ? null : key); -/** - * The directory store is part of this store's circular import cluster - * (useConfigStore → persistence → session-ui-store → useConfigStore, with - * useDirectoryStore in the same strongly-connected component). In the bundled - * chunk its module body may not have run yet when this module evaluates, so the - * static import binding is in TDZ. Read it through the window registration that - * useDirectoryStore publishes as soon as it initializes; fall back to the - * client directory, which the directory store seeds at the same time. - */ -const getDirectoryStore = (): typeof useDirectoryStore | null => { - if (typeof window === 'undefined') { - return null; - } - return window.__zustand_directory_store__ ?? null; -}; - const resolveInitialDirectoryKey = (): string => { if (typeof window === 'undefined') { return DIRECTORY_KEY_GLOBAL; } - const directory = opencodeClient.getDirectory() ?? getDirectoryStore()?.getState().currentDirectory; + const directory = opencodeClient.getDirectory() ?? useDirectoryStore.getState().currentDirectory; return toConfigDirectoryKey(directory); }; @@ -2136,18 +2037,6 @@ export const useConfigStore = create()( const safeAgents = Array.isArray(agents) ? agents : []; - // Seed the git provider domains store from the server - // `gitProviders` settings (authoritative); the runtime - // settings path or the fetch path above provided them. - // Failure here must not break the agent load. - if (openChamberDefaults.gitProviders !== undefined) { - try { - useGitProviderDomainsStore.getState().hydrateFromServer(openChamberDefaults.gitProviders); - } catch { - // Ignore — non-authoritative state stays as-is. - } - } - const providerLoad = _inFlightProviders.get(directoryKey); if (providerLoad) { markStartupTrace('loadAgents:awaitProviders', { directoryKey, source }); @@ -2255,8 +2144,6 @@ export const useConfigStore = create()( if (shouldPersistResolvedZenModel && resolvedZenModel) { updateDesktopSettings({ zenModel: resolvedZenModel, - gitProviderId: '', - gitModelId: '', }).catch(() => { // Ignore errors - best effort cleanup }); @@ -3618,24 +3505,14 @@ if (!unsubscribeConfigStoreSyncConfigChanges) { } if (typeof window !== "undefined" && !unsubscribeConfigStoreDirectoryChanges) { - // useDirectoryStore's module body may not have run yet when this module - // evaluates (the two stores share a circular import cluster, and the - // bundled chunk can evaluate either body first). Defer subscription setup - // until after module evaluation completes so the import binding is no - // longer in TDZ. The subscription is registered before any user-driven - // directory change can occur; the initial directory is reconciled by - // initializeApp. - queueMicrotask(() => { - if (unsubscribeConfigStoreDirectoryChanges) return; - unsubscribeConfigStoreDirectoryChanges = useDirectoryStore.subscribe((state, prevState) => { - const nextKey = toDirectoryKey(state.currentDirectory); - const prevKey = toDirectoryKey(prevState.currentDirectory); - if (nextKey === prevKey) { - return; - } + unsubscribeConfigStoreDirectoryChanges = useDirectoryStore.subscribe((state, prevState) => { + const nextKey = toDirectoryKey(state.currentDirectory); + const prevKey = toDirectoryKey(prevState.currentDirectory); + if (nextKey === prevKey) { + return; + } - markStartupTrace('directoryStore:changed', { previous: prevKey, next: nextKey }); - void useConfigStore.getState().activateDirectory(state.currentDirectory); - }); + markStartupTrace('directoryStore:changed', { previous: prevKey, next: nextKey }); + void useConfigStore.getState().activateDirectory(state.currentDirectory); }); } diff --git a/packages/ui/src/stores/useGitIdentitiesStore.ts b/packages/ui/src/stores/useGitIdentitiesStore.ts index 848e8179..7a11f93a 100644 --- a/packages/ui/src/stores/useGitIdentitiesStore.ts +++ b/packages/ui/src/stores/useGitIdentitiesStore.ts @@ -10,9 +10,7 @@ import { discoverGitCredentials, getGlobalGitIdentity } from "@/lib/gitApi"; -import { reportSettingsSaveState, updateDesktopSettings } from "@/lib/persistence"; -import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"; -import { runtimeFetch } from "@/lib/runtime-fetch"; +import { loadDesktopSettings, reportSettingsSaveState, updateDesktopSettings } from "@/lib/persistence"; export type GitIdentityAuthType = 'ssh' | 'token'; @@ -136,45 +134,9 @@ export const useGitIdentitiesStore = create()( }, loadDefaultGitIdentityId: async () => { - const normalize = (value: unknown): string | null => { - if (typeof value !== 'string') { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; - }; - try { - let defaultId: string | null = null; - - if (defaultId === null) { - const runtimeSettings = getRegisteredRuntimeAPIs()?.settings; - if (runtimeSettings) { - try { - const result = await runtimeSettings.load(); - const settings = (result?.settings || {}) as Record; - defaultId = normalize(settings.defaultGitIdentityId); - } catch { - // fall through - } - } - } - - if (defaultId === null) { - try { - const response = await runtimeFetch('/api/config/settings', { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - if (response.ok) { - const data = (await response.json().catch(() => null)) as Record | null; - defaultId = normalize(data?.defaultGitIdentityId); - } - } catch { - // ignore - } - } - + const settings = await loadDesktopSettings(); + const defaultId = settings?.defaultGitIdentityId?.trim() || null; set({ defaultGitIdentityId: defaultId }); return true; } catch (error) { diff --git a/packages/ui/src/stores/useGitStore.test.ts b/packages/ui/src/stores/useGitStore.test.ts index 9ba64150..1e00f6bd 100644 --- a/packages/ui/src/stores/useGitStore.test.ts +++ b/packages/ui/src/stores/useGitStore.test.ts @@ -91,6 +91,114 @@ describe('useGitStore', () => { useGitStore.getState().resetForRuntimeSwitch(getRuntimeKey()); }); + test('keeps timed-out diff requests inside the concurrency limit until they settle', async () => { + const paths = ['one.ts', 'two.ts', 'three.ts', 'four.ts']; + setDirectoryStatus(createStatus({}, paths.map((path) => ({ path, index: ' ', working_dir: 'M' })))); + const pending: Array<{ path: string; request: Deferred>> }> = []; + const git = createGitApi(async () => createStatus()); + git.getGitFileDiff = (_directory, { path }) => { + const request = createDeferred>>(); + pending.push({ path, request }); + return request.promise; + }; + + const prefetch = useGitStore.getState().prefetchDiffs('/repo', git, paths); + try { + expect(pending.length).toBe(2); + // Exercise the real 15-second prefetch deadline. Expiring the UI wait + // does not settle the injected transport or stop its server-side work. + await new Promise((resolve) => setTimeout(resolve, 15_100)); + expect(pending.length).toBe(2); + await useGitStore.getState().prefetchDiffs('/repo', git, paths); + expect(pending.length).toBe(2); + expect(useGitStore.getState().getDirectoryState('/repo')?.diffCache.size).toBe(0); + } finally { + for (const { path, request } of pending) request.resolve({ path, original: 'before', modified: 'after' }); + await prefetch; + } + // Late responses were discarded, but their real completion frees capacity. + expect(useGitStore.getState().getDirectoryState('/repo')?.diffCache.size).toBe(0); + git.getGitFileDiff = async (_directory, { path }) => ({ path, original: 'fresh', modified: 'fresh' }); + await useGitStore.getState().prefetchDiffs('/repo', git, paths); + expect(useGitStore.getState().getDirectoryState('/repo')?.diffCache.size).toBe(4); + }, 40_000); + + test('limits overlapping diff batches per directory and retains capacity across a cache reset', async () => { + const paths = ['one.ts', 'two.ts', 'three.ts']; + const status = createStatus({}, paths.map((path) => ({ path, index: ' ', working_dir: 'M' }))); + const directories = ['/repo-a', '/repo-b', '/repo-c']; + const populate = () => useGitStore.setState({ + directories: new Map(directories.map((directory) => [directory, createDirectoryState(status)])), + }); + populate(); + const request = createDeferred>>(); + const calls: string[] = []; + const git = createGitApi(async () => status); + git.getGitFileDiff = (directory) => { + calls.push(directory); + return request.promise; + }; + const batches = directories.flatMap((directory) => paths.map((path) => ( + useGitStore.getState().prefetchDiffs(directory, git, [path]) + ))); + try { + expect(calls.length).toBe(6); + for (const directory of directories) expect(calls.filter((value) => value === directory).length).toBe(2); + useGitStore.getState().resetForRuntimeSwitch(getRuntimeKey()); + populate(); + await Promise.all(directories.map((directory) => useGitStore.getState().prefetchDiffs(directory, git, paths))); + expect(calls.length).toBe(6); + } finally { + request.resolve({ path: 'one.ts', original: '', modified: '' }); + await Promise.all(batches); + } + for (const directory of directories) expect(useGitStore.getState().getDirectoryState(directory)?.diffCache.size).toBe(0); + }); + + test('repeated status refreshes for three directories share their outstanding requests', async () => { + const status = createStatus(); + const directories = ['/status-a', '/status-b', '/status-c']; + useGitStore.setState({ directories: new Map(directories.map((directory) => [directory, createDirectoryState(status)])) }); + const request = createDeferred(); + let calls = 0; + const git = createGitApi(async () => { + calls += 1; + return request.promise; + }); + const refreshes = Array.from({ length: 20 }, () => directories.map((directory) => ( + useGitStore.getState().fetchStatus(directory, git, { silent: true }) + ))).flat(); + try { + expect(calls).toBe(3); + } finally { + request.resolve(status); + await Promise.all(refreshes); + } + }); + + test('a duplicate diff demand does not discard the first batch or leave failure slots occupied', async () => { + const paths = ['one.ts', 'two.ts', 'three.ts']; + setDirectoryStatus(createStatus({}, paths.map((path) => ({ path, index: ' ', working_dir: 'M' })))); + const request = createDeferred>>(); + const git = createGitApi(async () => createStatus()); + let calls = 0; + git.getGitFileDiff = async (_directory, { path }) => { + calls += 1; + if (path === 'one.ts') return request.promise; + if (path === 'two.ts') throw new Error('failed read'); + return { path, original: '', modified: 'fresh' }; + }; + const first = useGitStore.getState().prefetchDiffs('/repo', git, paths); + await useGitStore.getState().prefetchDiffs('/repo', git, paths); + request.resolve({ path: 'one.ts', original: '', modified: 'fresh' }); + await first; + expect(calls).toBe(3); + const cache = useGitStore.getState().getDirectoryState('/repo')?.diffCache; + expect(cache?.has('one.ts')).toBe(true); + expect(cache?.has('two.ts')).toBe(false); + expect(cache?.has('three.ts')).toBe(true); + }); + test('does not reuse an in-flight light status request for full status', async () => { setDirectoryStatus(createStatus()); const requests: Deferred[] = []; diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts index a91795e3..dac65843 100644 --- a/packages/ui/src/stores/useGitStore.ts +++ b/packages/ui/src/stores/useGitStore.ts @@ -657,7 +657,11 @@ export const useGitStore = create()( inFlightStatusFetches.clear(); inFlightEnsureAllByDirectory.clear(); inFlightNestedRepoDiscovery.clear(); - inFlightDiffFetchesByDirectory.clear(); + // Outstanding transports still consume capacity on their captured + // runtime, even after its visible cache has been reset. + for (const [key, requests] of inFlightDiffFetchesByDirectory) { + if (requests.size === 0) inFlightDiffFetchesByDirectory.delete(key); + } diffFetchGenerationByDirectory.clear(); set({ runtimeKey, @@ -1141,7 +1145,6 @@ export const useGitStore = create()( }, prefetchDiffs: async (directory, git, filePaths, options = {}) => { - const token = startRequest(directory, 'diff'); const dirState = get().directories.get(directory); if (!dirState?.status?.files || dirState.status.files.length === 0 || filePaths.length === 0) return; @@ -1175,7 +1178,7 @@ export const useGitStore = create()( } const limitedFilePaths = dedupedPaths.slice(0, Math.max(1, maxFiles)); - if (limitedFilePaths.length === 0) return; + if (limitedFilePaths.length === 0 || inFlight.size >= DIFF_PREFETCH_CONCURRENCY) return; const generation = getDiffFetchGeneration(directory); @@ -1183,7 +1186,7 @@ export const useGitStore = create()( return; } - limitedFilePaths.forEach((path) => inFlight.add(path)); + const token = startRequest(directory, 'diff'); let nextIndex = 0; const results: Array<{ path: string; diff: { original: string; modified: string; isBinary?: boolean } }> = []; @@ -1195,30 +1198,44 @@ export const useGitStore = create()( }; const fetchWithTimeout = async (filePath: string) => { - const fetchPromise = git.getGitFileDiff(directory, { path: filePath }); + inFlight.add(filePath); + const fetchPromise = (async () => { + try { + return await git.getGitFileDiff(directory, { path: filePath }); + } finally { + // A UI deadline only stops waiting. Keep the path and capacity + // reserved until the transport actually settles. + inFlight.delete(filePath); + } + })(); + let timeout: ReturnType | undefined; const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error(`Timed out after ${DIFF_PREFETCH_TIMEOUT_MS}ms`)), DIFF_PREFETCH_TIMEOUT_MS); + timeout = setTimeout(() => reject(new Error(`Timed out after ${DIFF_PREFETCH_TIMEOUT_MS}ms`)), DIFF_PREFETCH_TIMEOUT_MS); }); - const response = await Promise.race([fetchPromise, timeoutPromise]); - return { - path: filePath, - diff: { original: response.original ?? '', modified: response.modified ?? '', isBinary: response.isBinary }, - }; + try { + const response = await Promise.race([fetchPromise, timeoutPromise]); + return { + path: filePath, + diff: { original: response.original ?? '', modified: response.modified ?? '', isBinary: response.isBinary }, + }; + } finally { + clearTimeout(timeout); + } }; const worker = async () => { for (;;) { - if (generation !== getDiffFetchGeneration(directory) || !isRequestCurrent(token, directory)) { + if (generation !== getDiffFetchGeneration(directory) || !isRequestCurrent(token, directory) + || inFlight.size >= DIFF_PREFETCH_CONCURRENCY) { return; } const next = takeNext(); if (!next) return; + if (inFlight.has(next)) continue; try { results.push(await fetchWithTimeout(next)); } catch { // Ignore individual failures/timeouts during prefetch. - } finally { - inFlight.delete(next); } } }; @@ -1226,8 +1243,6 @@ export const useGitStore = create()( const workerCount = Math.min(DIFF_PREFETCH_CONCURRENCY, limitedFilePaths.length); await Promise.allSettled(Array.from({ length: workerCount }, () => worker())); - limitedFilePaths.forEach((path) => inFlight.delete(path)); - if (generation !== getDiffFetchGeneration(directory) || !isRequestCurrent(token, directory)) { return; } diff --git a/packages/ui/src/stores/useProjectContextStore.test.ts b/packages/ui/src/stores/useProjectContextStore.test.ts index 6f8f7cbe..33d4585d 100644 --- a/packages/ui/src/stores/useProjectContextStore.test.ts +++ b/packages/ui/src/stores/useProjectContextStore.test.ts @@ -86,6 +86,8 @@ mock.module('@/lib/projectContextApi', () => ({ calls.deleteNote += 1; return handlers.deleteNote(); }, + shareProjectPlan: async () => null, + unshareProjectPlan: async () => null, setProjectPlanPinned: () => { calls.pinPlan += 1; return handlers.pinPlan(); @@ -148,7 +150,7 @@ beforeEach(() => { describe('getEntry', () => { test('returns a stable empty entry for an unknown project', () => { - expect(entry()).toEqual({ notes: [], todos: [], plans: [], loaded: false, loading: false, error: null }); + expect(entry()).toEqual({ notes: [], todos: [], plans: [], sharedPlansDir: null, loaded: false, loading: false, error: null }); }); test('returns the empty entry for a project without a path', () => { diff --git a/packages/ui/src/stores/useProjectContextStore.ts b/packages/ui/src/stores/useProjectContextStore.ts index f5b202b2..af3fb661 100644 --- a/packages/ui/src/stores/useProjectContextStore.ts +++ b/packages/ui/src/stores/useProjectContextStore.ts @@ -17,6 +17,8 @@ import { deleteProjectNote, deleteProjectPlan, fetchProjectContext, + shareProjectPlan, + unshareProjectPlan, resolveProjectContextId, saveProjectTodos, setProjectPlanPinned, @@ -33,6 +35,8 @@ interface ProjectContextEntry { notes: ProjectNote[]; todos: ProjectTodoItem[]; plans: ProjectPlanLink[]; + /** The team's shared plans folder, when the project has one; sharing a plan needs it. */ + sharedPlansDir: string | null; /** True once an authoritative load has succeeded at least once. */ loaded: boolean; loading: boolean; @@ -68,6 +72,8 @@ interface ProjectContextActions { savePlan: (project: ProjectRef, planId: string, raw: string) => Promise; setPlanPinned: (project: ProjectRef, planId: string, pinned: boolean) => Promise; deletePlan: (project: ProjectRef, planId: string) => Promise; + /** Move a plan into the team's shared folder, or back; the plan gets a new id. */ + movePlan: (project: ProjectRef, planId: string, direction: 'share' | 'unshare') => Promise; reset: () => void; } @@ -77,6 +83,7 @@ export const EMPTY_PROJECT_CONTEXT_ENTRY: ProjectContextEntry = { notes: [], todos: [], plans: [], + sharedPlansDir: null, loaded: false, loading: false, error: null, @@ -166,6 +173,7 @@ export const useProjectContextStore = create((set, get) => notes: flags.notes ? committed.notes : data.notes, todos: flags.todos ? committed.todos : data.todos, plans: flags.plans ? committed.plans : data.plans, + sharedPlansDir: data.sharedPlansDir, loaded: true, loading: false, error: null, @@ -415,6 +423,31 @@ export const useProjectContextStore = create((set, get) => } }, + movePlan: async (project, planId, direction) => { + const projectId = resolveProjectContextId(project); + if (!projectId) return false; + + const flags = flagsFor(projectId); + flags.plans = true; + + try { + const result = await enqueueWrite(projectId, () => ( + direction === 'share' ? shareProjectPlan(project, planId) : unshareProjectPlan(project, planId) + )); + if (!result) { + patchEntry(projectId, { plans: currentEntry(projectId).plans.filter((plan) => plan.id !== planId) }); + return false; + } + patchEntry(projectId, { plans: result.context.plans, sharedPlansDir: result.context.sharedPlansDir, error: null }); + return true; + } catch (error) { + patchEntry(projectId, { error: errorMessage(error, direction === 'share' ? 'Failed to share plan' : 'Failed to make plan personal') }); + return false; + } finally { + flags.plans = false; + } + }, + deletePlan: async (project, planId) => { const projectId = resolveProjectContextId(project); if (!projectId) return false; diff --git a/packages/ui/src/stores/useProjectsStore.vscodeAddProject.test.ts b/packages/ui/src/stores/useProjectsStore.vscodeAddProject.test.ts index fc33684d..185ad479 100644 --- a/packages/ui/src/stores/useProjectsStore.vscodeAddProject.test.ts +++ b/packages/ui/src/stores/useProjectsStore.vscodeAddProject.test.ts @@ -74,7 +74,8 @@ mock.module('@/lib/opencode/client', () => ({ opencodeClient: opencodeClientStub, })); mock.module('@/lib/persistence', () => ({ - updateDesktopSettings: async () => {}, + updateDesktopSettings: async () => ({ ok: true }), + loadDesktopSettings: async () => null, })); const addWorkspaceFolderCalls: string[] = []; diff --git a/packages/ui/src/stores/useQuotaStore.refresh.test.ts b/packages/ui/src/stores/useQuotaStore.refresh.test.ts new file mode 100644 index 00000000..76648f06 --- /dev/null +++ b/packages/ui/src/stores/useQuotaStore.refresh.test.ts @@ -0,0 +1,163 @@ +import { afterAll, afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; +import type { ProviderResult, QuotaProviderId } from '@/types'; +import { fetchQuota } from '@/lib/quota/fetchQuota'; +import { useQuotaStore } from './useQuotaStore'; + +const result = (providerId: QuotaProviderId = 'claude'): ProviderResult => ({ + providerId, providerName: providerId, ok: true, configured: true, fetchedAt: 123, + usage: { windows: { session: { + usedPercent: 42, remainingPercent: 58, windowSeconds: 18000, + resetAfterSeconds: 100, resetAt: 1000, resetAtFormatted: null, resetAfterFormatted: null, + } } }, +}); +const json = (body: ProviderResult) => Response.json(body); +const pause = () => new Promise(resolve => setTimeout(resolve, 5)); +const deferredResponse = () => { + let complete: ((response: Response) => void) | undefined; + const promise = new Promise(resolve => { complete = resolve; }); + return { promise, complete: (response: Response) => complete?.(response) }; +}; + +let handleRequest: (url: string, signal?: AbortSignal | null) => Promise; +const network = spyOn(globalThis, 'fetch'); + +beforeEach(() => { + useQuotaStore.getState().resetForRuntimeSwitch(); + handleRequest = async () => json(result()); + network.mockImplementation((input, init) => handleRequest(input.toString(), init?.signal)); +}); +afterEach(() => { + useQuotaStore.getState().resetForRuntimeSwitch(); + network.mockReset(); +}); +afterAll(() => network.mockRestore()); + +describe('quota refresh failure is not empty success', () => { + test('keeps the exact previous snapshot, configuration and timestamp on network failure', async () => { + await useQuotaStore.getState().fetchQuotas(['claude']); + const before = useQuotaStore.getState(); + handleRequest = async () => { throw new Error('network down'); }; + expect(await useQuotaStore.getState().fetchQuotas(['claude'])).toBe(false); + const after = useQuotaStore.getState(); + expect(after.results).toBe(before.results); + expect(after.results[0].configured).toBe(true); + expect(after.results[0].usage?.windows.session.usedPercent).toBe(42); + expect(after.lastUpdated).toBe(before.lastUpdated); + expect(after.refreshErrors.claude).toBe('network down'); + expect(after.isLoading).toBe(false); + }); + + test('a first-load failure leaves provider configuration unknown', async () => { + handleRequest = async () => { throw new Error('offline'); }; + await useQuotaStore.getState().fetchProviderQuota('claude'); + expect(useQuotaStore.getState().results).toEqual([]); + expect(useQuotaStore.getState().refreshErrors.claude).toBe('offline'); + expect(useQuotaStore.getState().lastUpdated).toBeNull(); + }); + + test('another provider succeeding does not clear a failed provider or its error', async () => { + await useQuotaStore.getState().fetchProviderQuota('claude'); + handleRequest = async url => { + if (url.endsWith('/claude')) throw new Error('claude unreachable'); + await pause(); + return json(result('codex')); + }; + expect(await useQuotaStore.getState().fetchQuotas(['claude', 'codex'])).toBe(true); + expect(useQuotaStore.getState().results).toHaveLength(2); + expect(useQuotaStore.getState().refreshErrors).toEqual({ claude: 'claude unreachable' }); + expect(useQuotaStore.getState().error).toBe('claude unreachable'); + handleRequest = async () => json(result()); + await useQuotaStore.getState().fetchProviderQuota('claude'); + expect(useQuotaStore.getState().refreshErrors).toEqual({}); + expect(useQuotaStore.getState().error).toBeNull(); + }); + + test('concurrent refreshes share one provider request', async () => { + const reply = deferredResponse(); + handleRequest = () => reply.promise; + const first = useQuotaStore.getState().fetchProviderQuota('claude'); + const second = useQuotaStore.getState().fetchProviderQuota('claude'); + await pause(); + expect(network.mock.calls).toHaveLength(1); + expect(useQuotaStore.getState().isLoading).toBe(true); + reply.complete(json(result())); + expect(await Promise.all([first, second])).toEqual([true, true]); + expect(useQuotaStore.getState().isLoading).toBe(false); + }); + + test('a runtime reset aborts old work without clearing the new request or its loading state', async () => { + const oldReply = deferredResponse(); + let oldSignal: AbortSignal | null | undefined; + handleRequest = (_url, signal) => { oldSignal = signal; return oldReply.promise; }; + const old = useQuotaStore.getState().fetchProviderQuota('claude'); + await pause(); + useQuotaStore.getState().resetForRuntimeSwitch(); + const newReply = deferredResponse(); + handleRequest = () => newReply.promise; + const current = useQuotaStore.getState().fetchProviderQuota('claude'); + expect(await old).toBe(false); + expect(oldSignal?.aborted).toBe(true); + expect(useQuotaStore.getState().isLoading).toBe(true); + oldReply.complete(json(result())); + await pause(); + expect(useQuotaStore.getState().results).toEqual([]); + newReply.complete(json({ ...result(), fetchedAt: 456 })); + expect(await current).toBe(true); + expect(useQuotaStore.getState().results[0].fetchedAt).toBe(456); + expect(useQuotaStore.getState().refreshErrors).toEqual({}); + }); + + test('malformed success payloads preserve previous data', async () => { + await useQuotaStore.getState().fetchProviderQuota('claude'); + const previous = useQuotaStore.getState().results; + for (const payload of [null, {}, result('codex')]) { + handleRequest = async () => Response.json(payload); + expect(await useQuotaStore.getState().fetchProviderQuota('claude')).toBe(false); + expect(useQuotaStore.getState().results).toBe(previous); + } + }); + + test('authoritative unconfigured success replaces old configuration', async () => { + await useQuotaStore.getState().fetchProviderQuota('claude'); + handleRequest = async () => json({ ...result(), configured: false, usage: null }); + expect(await useQuotaStore.getState().fetchProviderQuota('claude')).toBe(true); + expect(useQuotaStore.getState().results[0].configured).toBe(false); + expect(useQuotaStore.getState().results[0].usage).toBeNull(); + }); + + test('a provider failure reported inside HTTP 200 also preserves the last usage sample', async () => { + await useQuotaStore.getState().fetchProviderQuota('claude'); + const before = useQuotaStore.getState(); + handleRequest = async () => json({ ...result(), ok: false, usage: null, error: 'Provider API unavailable', fetchedAt: 456 }); + // The instance answered, even though its provider did not. + expect(await useQuotaStore.getState().fetchProviderQuota('claude')).toBe(true); + expect(useQuotaStore.getState().results).toBe(before.results); + expect(useQuotaStore.getState().lastUpdated).toBe(before.lastUpdated); + expect(useQuotaStore.getState().refreshErrors.claude).toBe('Provider API unavailable'); + }); +}); + +describe('quota request deadline', () => { + test('bounds a transport that never returns headers, even if it ignores abort', async () => { + let signal: AbortSignal | null | undefined; + const pending = deferredResponse(); + handleRequest = (_url, nextSignal) => { signal = nextSignal; return pending.promise; }; + await expect(fetchQuota('claude', { timeoutMs: 15 })).rejects.toThrow('Quota request timed out'); + expect(signal?.aborted).toBe(true); + pending.complete(json(result())); + }); + + test('the deadline includes an unfinished JSON response body', async () => { + let controller: ReadableStreamDefaultController | undefined; + handleRequest = async () => new Response(new ReadableStream({ start(next) { controller = next; } })); + await expect(fetchQuota('claude', { timeoutMs: 15 })).rejects.toThrow('Quota request timed out'); + controller?.error(new Error('fixture cleanup')); + }); + + test('a pre-aborted request never reaches the network', async () => { + const controller = new AbortController(); + controller.abort(); + await expect(fetchQuota('claude', { signal: controller.signal })).rejects.toThrow('aborted'); + expect(network.mock.calls).toHaveLength(0); + }); +}); diff --git a/packages/ui/src/stores/useQuotaStore.ts b/packages/ui/src/stores/useQuotaStore.ts index 934231e4..33c16be3 100644 --- a/packages/ui/src/stores/useQuotaStore.ts +++ b/packages/ui/src/stores/useQuotaStore.ts @@ -3,11 +3,10 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import type { ProviderResult, QuotaProviderId } from '@/types'; import { QUOTA_PROVIDERS } from '@/lib/quota'; -import { isVSCodeRuntime } from '@/lib/desktop'; -import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import type { DesktopSettings } from '@/lib/desktop'; import { getDefaultModels } from '@/lib/quota/model-families'; -import { updateDesktopSettings } from '@/lib/persistence'; -import { runtimeFetch } from '@/lib/runtime-fetch'; +import { loadDesktopSettings, updateDesktopSettings } from '@/lib/persistence'; +import { fetchQuota } from '@/lib/quota/fetchQuota'; import { getRuntimeKey, isTransientRuntimeKey } from '@/lib/runtime-switch'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -17,6 +16,7 @@ const QUOTA_REFRESH_INTERVAL_MS = 3 * 60 * 1000; // response in flight for the previous instance cannot land in the new one. let quotaGeneration = 0; let inFlightRuntimeLoad: Promise | null = null; +const quotaRequests = new Map }>(); let quotaAutoRefreshConsumers = 0; let quotaAutoRefreshInterval: number | null = null; @@ -36,6 +36,8 @@ interface QuotaStore extends QuotaSettingsState { isFetchingProvider: Record; lastUpdated: number | null; error: string | null; + /** Refresh failures are not authoritative provider configuration or usage. */ + refreshErrors: Partial>; loadSettings: () => Promise; fetchAllQuotas: () => Promise; @@ -65,45 +67,22 @@ interface QuotaStore extends QuotaSettingsState { resetForRuntimeSwitch: () => void; } -const parseSettings = (data: Record | null): QuotaSettingsState => { +const parseSettings = (data: DesktopSettings): QuotaSettingsState => { const allProviderIds = QUOTA_PROVIDERS.map((provider) => provider.id); - const displayMode = data?.usageDisplayMode === 'remaining' ? 'remaining' : 'usage'; - const rawDropdownProviders = Array.isArray(data?.usageDropdownProviders) - ? data?.usageDropdownProviders - : null; - const dropdownProviderIds = rawDropdownProviders - ? rawDropdownProviders.filter((entry): entry is QuotaProviderId => - typeof entry === 'string' && allProviderIds.includes(entry as QuotaProviderId) + const displayMode = data.usageDisplayMode === 'remaining' ? 'remaining' : 'usage'; + const dropdownProviderIds = data.usageDropdownProviders + ? data.usageDropdownProviders.filter((entry): entry is QuotaProviderId => + allProviderIds.some((id) => id === entry) ) : allProviderIds; - // Parse selected models (providerId -> array of model names) - const selectedModels: Record = {}; - const rawSelectedModels = data?.usageSelectedModels; - if (rawSelectedModels && typeof rawSelectedModels === 'object') { - for (const [providerId, models] of Object.entries(rawSelectedModels)) { - if (Array.isArray(models)) { - selectedModels[providerId] = models.filter((m): m is string => typeof m === 'string'); - } - } - } - - // Parse expanded families (inverted collapsed logic for header dropdown) - const expandedFamilies: Record = {}; - const rawExpandedFamilies = data?.usageExpandedFamilies; - if (rawExpandedFamilies && typeof rawExpandedFamilies === 'object') { - for (const [providerId, families] of Object.entries(rawExpandedFamilies)) { - if (Array.isArray(families)) { - expandedFamilies[providerId] = families.filter((f): f is string => typeof f === 'string'); - } - } - } - return { displayMode, dropdownProviderIds, - selectedModels, - expandedFamilies, + // Map of providerId -> selected model names + selectedModels: data.usageSelectedModels ?? {}, + // Expanded families (inverted collapsed logic for header dropdown) + expandedFamilies: data.usageExpandedFamilies ?? {}, }; }; @@ -115,29 +94,8 @@ const defaultQuotaSettings = (): QuotaSettingsState => ({ }); const loadSettingsFromRuntime = async (): Promise => { - const runtimeSettings = getRegisteredRuntimeAPIs()?.settings; - if (runtimeSettings) { - try { - const result = await runtimeSettings.load(); - const settings = result?.settings as Record | undefined; - return parseSettings(settings ?? null); - } catch { - // fall through - } - } - - if (!isVSCodeRuntime()) { - const response = await runtimeFetch('/api/config/settings', { - method: 'GET', - headers: { Accept: 'application/json' } - }); - if (response.ok) { - const data = await response.json().catch(() => null); - return parseSettings(data as Record | null); - } - } - - return defaultQuotaSettings(); + const settings = await loadDesktopSettings(); + return settings ? parseSettings(settings) : defaultQuotaSettings(); }; export const useQuotaStore = create()( @@ -150,6 +108,7 @@ export const useQuotaStore = create()( isFetchingProvider: {}, lastUpdated: null, error: null, + refreshErrors: {}, displayMode: 'usage', dropdownProviderIds: QUOTA_PROVIDERS.map((provider) => provider.id), selectedModels: {}, @@ -168,21 +127,16 @@ export const useQuotaStore = create()( fetchQuotas: async (providerIds) => { const generation = quotaGeneration; - set({ isLoading: true, error: null }); try { const answered = await Promise.all( providerIds.map((providerId) => get().fetchProviderQuota(providerId)) ); if (generation !== quotaGeneration) return false; - set({ - isLoading: false, - lastUpdated: Date.now() - }); return answered.some(Boolean); } catch (error) { if (generation !== quotaGeneration) return false; const message = error instanceof Error ? error.message : 'Failed to fetch quotas'; - set({ isLoading: false, error: message }); + set({ error: message }); return false; } }, @@ -192,50 +146,53 @@ export const useQuotaStore = create()( }, fetchProviderQuota: async (providerId) => { + const existing = quotaRequests.get(providerId); + if (existing) return existing.promise; const generation = quotaGeneration; - set((state) => ({ - isFetchingProvider: { ...state.isFetchingProvider, [providerId]: true } - })); - try { - const response = await runtimeFetch(`/api/quota/${encodeURIComponent(providerId)}`); - const payload = await response.json().catch(() => null); - if (!response.ok) { - throw new Error(payload?.error || 'Failed to fetch quota'); + const controller = new AbortController(); + const promise = Promise.resolve().then(async () => { + try { + const result = await fetchQuota(providerId, { signal: controller.signal }); + if (generation !== quotaGeneration) return false; + // A reachable instance can still report that its provider request + // failed. Configuration is known, but there is no new usage sample. + if (!result.ok && result.configured) { + const message = result.error || 'Failed to fetch quota'; + set(state => { + const previous = state.results.find(entry => entry.providerId === providerId); + const results = previous?.configured + ? state.results + : [...state.results.filter(entry => entry.providerId !== providerId), result]; + return { results, refreshErrors: { ...state.refreshErrors, [providerId]: message }, error: message }; + }); + return true; + } + set((state) => { + const refreshErrors = { ...state.refreshErrors }; + delete refreshErrors[providerId]; + const results = state.results.filter(entry => entry.providerId !== providerId); + results.push(result); + return { results, refreshErrors, error: Object.values(refreshErrors)[0] ?? null, lastUpdated: Date.now() }; + }); + return true; + } catch (error) { + if (generation !== quotaGeneration) return false; + const message = error instanceof Error ? error.message : 'Failed to fetch quota'; + set(state => ({ refreshErrors: { ...state.refreshErrors, [providerId]: message }, error: message })); + return false; + } finally { + if (quotaRequests.get(providerId)?.controller === controller) quotaRequests.delete(providerId); + if (generation === quotaGeneration) { + set((state) => ({ + isFetchingProvider: { ...state.isFetchingProvider, [providerId]: false }, + isLoading: quotaRequests.size > 0, + })); + } } - - if (generation !== quotaGeneration) return false; - const result = payload as ProviderResult; - set((state) => { - const next = state.results.filter((entry) => entry.providerId !== providerId); - next.push(result); - return { results: next, error: null }; - }); - return true; - } catch (error) { - if (generation !== quotaGeneration) return false; - const message = error instanceof Error ? error.message : 'Failed to fetch quota'; - const fallback: ProviderResult = { - providerId, - providerName: providerId, - ok: false, - configured: false, - error: message, - usage: null, - fetchedAt: Date.now() - }; - set((state) => { - const next = state.results.filter((entry) => entry.providerId !== providerId); - next.push(fallback); - return { results: next, error: message }; - }); - return false; - } finally { - if (generation === quotaGeneration) { - set((state) => ({ - isFetchingProvider: { ...state.isFetchingProvider, [providerId]: false } - })); - } - } + }); + quotaRequests.set(providerId, { controller, promise }); + set(state => ({ isLoading: true, isFetchingProvider: { ...state.isFetchingProvider, [providerId]: true } })); + return promise; }, ensureLoadedForRuntime: async () => { @@ -260,13 +217,15 @@ export const useQuotaStore = create()( // instance was never attempted again — Usage would stay empty until // the three-minute refresh, or forever after a switch. if (answered && generation === quotaGeneration) set({ loadedRuntimeKey: runtimeKey }); - })().finally(() => { inFlightRuntimeLoad = null; }); + })().finally(() => { if (generation === quotaGeneration) inFlightRuntimeLoad = null; }); return inFlightRuntimeLoad; }, resetForRuntimeSwitch: () => { quotaGeneration += 1; + for (const request of quotaRequests.values()) request.controller.abort(); + quotaRequests.clear(); inFlightRuntimeLoad = null; set({ // Display mode, the provider selection and the per-provider model @@ -281,6 +240,7 @@ export const useQuotaStore = create()( isFetchingProvider: {}, lastUpdated: null, error: null, + refreshErrors: {}, }); }, diff --git a/packages/ui/src/stores/useTerminalStore.test.ts b/packages/ui/src/stores/useTerminalStore.test.ts index bcbaf37e..df7398cb 100644 --- a/packages/ui/src/stores/useTerminalStore.test.ts +++ b/packages/ui/src/stores/useTerminalStore.test.ts @@ -423,6 +423,20 @@ describe('terminal state reconciliation', () => { expect(buffer(tabId).chunks).toBe(previous); }); + test('records the PTY size a snapshot was drawn for and treats a size change as a new snapshot', () => { + const tabId = setup(); + useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 8, { cols: 94, rows: 56 }); + expect(buffer(tabId).chunks[0].size).toEqual({ cols: 94, rows: 56 }); + const previous = buffer(tabId).chunks; + useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 8, { cols: 94, rows: 56 }); + expect(buffer(tabId).chunks).toBe(previous); + useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 8, { cols: 80, rows: 24 }); + expect(buffer(tabId).chunks).not.toBe(previous); + expect(buffer(tabId).chunks[0].size).toEqual({ cols: 80, rows: 24 }); + useTerminalStore.getState().appendToBuffer('/repo', tabId, ' live', 9); + expect(buffer(tabId).chunks[1].size).toBe(undefined); + }); + test('caps multibyte scrollback by UTF-8 bytes', () => { const tabId = setup(); useTerminalStore.getState().appendToBuffer('/repo', tabId, '界'.repeat(200_000), 1); diff --git a/packages/ui/src/stores/useTerminalStore.ts b/packages/ui/src/stores/useTerminalStore.ts index 689ae912..f9112920 100644 --- a/packages/ui/src/stores/useTerminalStore.ts +++ b/packages/ui/src/stores/useTerminalStore.ts @@ -7,11 +7,20 @@ import { getSafeSessionStorage } from '@/stores/utils/safeStorage'; import type { TerminalServerSession } from '@/lib/api/types'; import { normalizeTerminalDirectory } from '@/lib/pathNormalization'; +export type TerminalChunkSize = { cols: number; rows: number }; + export interface TerminalChunk { id: number; data: string; replayData?: string; byteLength: number; + /** + * PTY size this chunk was drawn for. Only snapshot history carries it: the + * viewport replays such a chunk at this size and then re-fits, because + * shell output laid out for one width turns into stray fragments when it is + * written into an emulator of another width. + */ + size?: TerminalChunkSize; } /** @@ -25,7 +34,7 @@ export type TerminalBuffer = { lastSequence: number; }; -export const EMPTY_TERMINAL_BUFFER: TerminalBuffer = Object.freeze({ +const EMPTY_TERMINAL_BUFFER: TerminalBuffer = Object.freeze({ chunks: Object.freeze([]) as unknown as TerminalChunk[], byteLength: 0, lastSequence: -1, @@ -99,7 +108,7 @@ interface TerminalStore { setTabSessionId: (directory: string, tabId: string, sessionId: string | null, options?: { expectedExecutionId?: string | null }) => void; setTabLifecycle: (directory: string, tabId: string, lifecycle: TerminalTabLifecycle, options?: { expectedExecutionId?: string | null }) => void; setConnecting: (directory: string, tabId: string, isConnecting: boolean, options?: { expectedExecutionId?: string | null }) => void; - replaceBuffer: (directory: string, tabId: string, content: string, sequence: number) => void; + replaceBuffer: (directory: string, tabId: string, content: string, sequence: number, size?: TerminalChunkSize) => void; appendToBuffer: (directory: string, tabId: string, chunk: string, sequence?: number, replayData?: string) => void; setTabPreviewUrl: (directory: string, tabId: string, url: string | null, options?: { locked?: boolean; autoOpened?: boolean; expectedExecutionId?: string | null }) => void; markPreviewAutoOpened: (directory: string, tabId: string) => void; @@ -976,7 +985,7 @@ export const useTerminalStore = create()( }); }, - replaceBuffer: (directory: string, tabId: string, content: string, sequence: number) => { + replaceBuffer: (directory: string, tabId: string, content: string, sequence: number, size?: TerminalChunkSize) => { const key = normalizeDirectory(directory); set((state) => { const existing = state.sessions.get(key); @@ -985,17 +994,22 @@ export const useTerminalStore = create()( const buffer = state.buffers.get(entryKey) ?? EMPTY_TERMINAL_BUFFER; if (buffer.lastSequence > sequence) return state; const retained = trimToBufferLimit(content); + const previousSize = buffer.chunks[0]?.size; if ( buffer.lastSequence === sequence && buffer.byteLength === retained.byteLength && - buffer.chunks.map((chunk) => chunk.data).join('') === retained.text + buffer.chunks.map((chunk) => chunk.data).join('') === retained.text && + previousSize?.cols === size?.cols && + previousSize?.rows === size?.rows ) { return state; } const chunkId = state.nextChunkId; const buffers = new Map(state.buffers); buffers.set(entryKey, { - chunks: retained.text ? [{ id: chunkId, data: retained.text, byteLength: retained.byteLength }] : [], + chunks: retained.text + ? [{ id: chunkId, data: retained.text, byteLength: retained.byteLength, ...(size ? { size } : {}) }] + : [], byteLength: retained.byteLength, lastSequence: sequence, }); diff --git a/packages/ui/src/stores/useUIStore.contextPanel.test.ts b/packages/ui/src/stores/useUIStore.contextPanel.test.ts index d934da2d..e44178cd 100644 --- a/packages/ui/src/stores/useUIStore.contextPanel.test.ts +++ b/packages/ui/src/stores/useUIStore.contextPanel.test.ts @@ -6,6 +6,7 @@ import { useUIStore } from './useUIStore'; const getContextPanelTabs = (directory: string) => useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? []; const getTerminalTab = (directory: string) => getContextPanelTabs(directory).find((tab) => tab.mode === 'terminal'); +const originalPersistOptions = useUIStore.persist.getOptions(); beforeEach(() => { useUIStore.setState({ contextPanelByDirectory: {}, contextRailOrder: [] }); @@ -13,6 +14,13 @@ beforeEach(() => { }); describe('useUIStore context panel tabs', () => { + test('preserves Commit mode when context tabs are normalized', () => { + useUIStore.getState().openContextPanelTab('/repo', { mode: 'diff', diffScope: 'commit' }); + useUIStore.getState().openContextPanelTab('/repo', { mode: 'file', targetPath: '/repo/README.md' }); + const diffTab = getContextPanelTabs('/repo').find((tab) => tab.mode === 'diff'); + expect(diffTab?.diffScope).toBe('commit'); + }); + test('updates readOnly when an existing chat tab is reopened', () => { const directory = '/repo'; @@ -173,6 +181,48 @@ describe('useUIStore context panel tabs', () => { expect(tabs.some((tab) => tab.mode === 'plan')).toBe(true); }); + test('drops invalid persisted context-panel width fractions', async () => { + const directory = '/repo'; + useUIStore.persist.setOptions({ storage: { + getItem: () => ({ + version: 20, + state: { + contextPanelByDirectory: { + [directory]: { + isOpen: true, + expanded: false, + widthByMode: { walkthrough: 800 }, + widthFractionByMode: { + diff: 0, + file: 1.25, + context: Number.NaN, + plan: '0.4', + chat: 0.4, + walkthrough: 0.8, + }, + touchedAt: 1, + activeTabId: null, + tabs: [], + }, + }, + }, + }), + setItem: () => undefined, + removeItem: () => undefined, + } }); + + try { + useUIStore.setState(useUIStore.getInitialState(), true); + await useUIStore.persist.rehydrate(); + + const panel = useUIStore.getState().contextPanelByDirectory[directory]; + expect(panel?.widthFractionByMode).toEqual({ chat: 0.4, walkthrough: 0.8 }); + expect(panel?.widthByMode.walkthrough).toBe(800); + } finally { + useUIStore.persist.setOptions(originalPersistOptions); + } + }); + test('drops a persisted saved-plan tab carrying an owner but no plan id', () => { const directory = '/repo'; const persisted = { @@ -665,9 +715,30 @@ describe('useUIStore per-surface panel widths', () => { const state = useUIStore.getState().contextPanelByDirectory[directory]; expect(state?.widthByMode.diff).toBe(700); - expect(state?.widthByMode.git).toBe(380); + expect(state?.widthByMode.git).toBe(320); expect(state?.widthByMode.browser).toBe(undefined); }); + + test('captures the clamped width as a responsive ratio when the panel area is known', () => { + useUIStore.getState().openContextPanelTab(directory, { mode: 'diff' }); + useUIStore.getState().setContextPanelWidth(directory, 'diff', 100, 1000); + useUIStore.getState().setContextPanelWidth(directory, 'git', 700, 1000); + + const state = useUIStore.getState().contextPanelByDirectory[directory]; + expect(state?.widthByMode.diff).toBe(320); + expect(state?.widthFractionByMode.diff).toBe(0.32); + expect(state?.widthFractionByMode.git).toBe(0.7); + expect(state?.widthFractionByMode.browser).toBe(undefined); + }); + + test('a pixel resize without a valid area replaces the previous ratio', () => { + const store = useUIStore.getState(); + store.setContextPanelWidth(directory, 'walkthrough', 800, 1000); + store.setContextPanelWidth(directory, 'walkthrough', 600, Number.POSITIVE_INFINITY); + const panel = useUIStore.getState().contextPanelByDirectory[directory]; + expect(panel?.widthByMode.walkthrough).toBe(600); + expect(panel?.widthFractionByMode.walkthrough).toBeUndefined(); + }); }); describe('useUIStore contextRailOrder', () => { diff --git a/packages/ui/src/stores/useUIStore.scrollbars.test.ts b/packages/ui/src/stores/useUIStore.scrollbars.test.ts new file mode 100644 index 00000000..784cf717 --- /dev/null +++ b/packages/ui/src/stores/useUIStore.scrollbars.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { useUIStore } from './useUIStore'; +import { AUTO_SAVE_KEYS, buildSettingsRegistrySnapshot, parseSettingsDocument } from '@/lib/settings/registry'; + +const originalOptions = useUIStore.persist.getOptions(); +const originalState = useUIStore.getState(); + +afterEach(() => { + useUIStore.persist.setOptions(originalOptions); + useUIStore.setState(originalState, true); +}); + +describe('scrollbar preference', () => { + test('defaults to auto-hide when an existing install has no preference', async () => { + expect(useUIStore.getInitialState().alwaysShowScrollbars).toBe(false); + useUIStore.persist.setOptions({ storage: { + getItem: () => ({ version: originalOptions.version, state: { dockBadgeEnabled: false } }), + setItem: () => undefined, + removeItem: () => undefined, + } }); + useUIStore.setState(useUIStore.getInitialState(), true); + await useUIStore.persist.rehydrate(); + expect(useUIStore.getState().alwaysShowScrollbars).toBe(false); + expect(useUIStore.getState().dockBadgeEnabled).toBe(false); + }); + + for (const enabled of [true, false]) { + test(`round-trips ${enabled} through the persisted store`, async () => { + let saved: Parameters['setItem']>[1] = { + state: useUIStore.getInitialState(), version: originalOptions.version, + }; + useUIStore.persist.setOptions({ storage: { + getItem: () => saved, + setItem: (_name, value) => { saved = value; }, + removeItem: () => undefined, + } }); + useUIStore.getState().setAlwaysShowScrollbars(enabled); + useUIStore.persist.setOptions({ storage: { + getItem: () => saved, + setItem: () => undefined, + removeItem: () => undefined, + } }); + useUIStore.getState().setAlwaysShowScrollbars(!enabled); + await useUIStore.persist.rehydrate(); + expect(useUIStore.getState().alwaysShowScrollbars).toBe(enabled); + }); + } + + test('stays local to the device rather than syncing to other surfaces', () => { + expect(buildSettingsRegistrySnapshot().fields.alwaysShowScrollbars).toEqual({ scope: 'device', local: true }); + expect(AUTO_SAVE_KEYS).not.toContain('alwaysShowScrollbars'); + expect(parseSettingsDocument({ alwaysShowScrollbars: true })).toEqual({}); + }); +}); diff --git a/packages/ui/src/stores/useUIStore.sidebar.test.ts b/packages/ui/src/stores/useUIStore.sidebar.test.ts new file mode 100644 index 00000000..726dd34a --- /dev/null +++ b/packages/ui/src/stores/useUIStore.sidebar.test.ts @@ -0,0 +1,52 @@ +import { afterEach, expect, test } from 'bun:test'; +import { useUIStore } from './useUIStore'; + +const originalOptions = useUIStore.persist.getOptions(); +const originalState = useUIStore.getState(); + +afterEach(() => { + useUIStore.persist.setOptions(originalOptions); + useUIStore.setState(originalState, true); +}); + +test('the initial sidebar width is independent of its resize minimum', () => { + expect(useUIStore.getInitialState().sidebarWidth).toBe(280); +}); + +for (const width of [168, 280, 360]) { + test(`reopening a persisted ${width}px sidebar preserves its width`, async () => { + useUIStore.persist.setOptions({ storage: { + getItem: () => ({ version: originalOptions.version, state: { sidebarWidth: width, isSidebarOpen: true } }), + setItem: () => undefined, + removeItem: () => undefined, + } }); + useUIStore.setState(useUIStore.getInitialState(), true); + await useUIStore.persist.rehydrate(); + + const actions = useUIStore.getState(); + actions.toggleSidebar(); + expect(useUIStore.getState().isSidebarOpen).toBe(false); + expect(useUIStore.getState().sidebarWidth).toBe(width); + actions.toggleSidebar(); + expect(useUIStore.getState().isSidebarOpen).toBe(true); + expect(useUIStore.getState().sidebarWidth).toBe(width); + + actions.setSidebarOpen(false); + actions.setSidebarOpen(true); + expect(useUIStore.getState().sidebarWidth).toBe(width); + const openState = useUIStore.getState(); + actions.setSidebarOpen(true); + expect(useUIStore.getState()).toBe(openState); + }); +} + +test('a manual resize stays authoritative across repeated visibility changes', () => { + useUIStore.setState(useUIStore.getInitialState(), true); + const actions = useUIStore.getState(); + actions.setSidebarWidth(420); + actions.setSidebarOpen(false); + actions.setSidebarOpen(true); + actions.toggleSidebar(); + actions.toggleSidebar(); + expect(useUIStore.getState().sidebarWidth).toBe(420); +}); diff --git a/packages/ui/src/stores/useUIStore.telemetry.test.ts b/packages/ui/src/stores/useUIStore.telemetry.test.ts new file mode 100644 index 00000000..fd580f46 --- /dev/null +++ b/packages/ui/src/stores/useUIStore.telemetry.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { useUIStore } from './useUIStore'; + +const originalOptions = useUIStore.persist.getOptions(); +const originalState = useUIStore.getState(); +afterEach(() => { + useUIStore.persist.setOptions(originalOptions); + useUIStore.setState(originalState, true); +}); + +describe('telemetry settings migration', () => { + test('shows telemetry by default', () => { + expect(useUIStore.getInitialState().workStatusHiddenSections).toEqual([]); + }); + + for (const version of [18, 19, 20]) { + test(`migrates real v${version} hydration without losing existing hidden sections`, async () => { + useUIStore.persist.setOptions({ storage: { + getItem: () => ({ version, state: { ...useUIStore.getInitialState(), workStatusHiddenSections: ['mcp', 'telemetry'] } }), + setItem: () => undefined, + removeItem: () => undefined, + } }); + await useUIStore.persist.rehydrate(); + expect(useUIStore.getState().workStatusHiddenSections).toEqual(['mcp']); + expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(false); + expect(useUIStore.persist.getOptions().version).toBe(21); + }); + } + + test('preserves an explicitly hidden section from v20', async () => { + useUIStore.persist.setOptions({ storage: { + getItem: () => ({ version: 20, state: { ...useUIStore.getInitialState(), workStatusHiddenSections: ['mcp', 'telemetry'], workStatusHiddenSectionsExplicit: true } }), + setItem: () => undefined, + removeItem: () => undefined, + } }); + await useUIStore.persist.rehydrate(); + expect(useUIStore.getState().workStatusHiddenSections).toEqual(['mcp', 'telemetry']); + expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(true); + }); + + test('explicit hiding round-trips through the actual persisted projection and hydration', async () => { + let saved: Parameters['setItem']>[1] = { state: useUIStore.getInitialState(), version: originalOptions.version }; + useUIStore.persist.setOptions({ storage: { + getItem: () => saved, + setItem: (_name, value) => { saved = value; }, + removeItem: () => undefined, + } }); + useUIStore.setState({ workStatusHiddenSections: ['mcp'], workStatusHiddenSectionsExplicit: false }); + useUIStore.getState().setWorkStatusSectionVisible('telemetry', false); + useUIStore.persist.setOptions({ storage: { getItem: () => saved, setItem: () => undefined, removeItem: () => undefined } }); + useUIStore.setState({ workStatusHiddenSections: [], workStatusHiddenSectionsExplicit: false }); + await useUIStore.persist.rehydrate(); + expect(useUIStore.getState().workStatusHiddenSections).toEqual(['mcp', 'telemetry']); + expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(true); + }); + + test('can show telemetry again after hiding it', () => { + useUIStore.setState({ workStatusHiddenSections: ['mcp', 'telemetry'], workStatusHiddenSectionsExplicit: true }); + useUIStore.getState().setWorkStatusSectionVisible('telemetry', true); + expect(useUIStore.getState().workStatusHiddenSections).toEqual(['mcp']); + expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(true); + }); +}); diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 0c07fd7f..cbf9de35 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { z } from 'zod'; import { devtools, persist } from 'zustand/middleware'; import type { SidebarSection } from '@/constants/sidebar'; import { createDeferredSafeJSONStorage } from './utils/safeStorage'; @@ -15,8 +16,13 @@ import { isWindowsArm64 } from '@/lib/platform'; import { isVSCodeRuntime } from '@/lib/desktop'; import { getRuntimeKey, isTransientRuntimeKey } from '@/lib/runtime-switch'; -export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch'; -export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'linear' | 'notes' | 'terminal'; +export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch' | 'commit'; +const contextPanelModeSchema = z.enum(['diff', 'walkthrough', 'file', 'context', 'plan', 'chat', 'browser', 'git', 'pr', 'linear', 'notes', 'terminal']); +export type ContextPanelMode = z.infer; +const persistedPanelWidthsSchema = z.object({ + widthByMode: z.record(z.string(), z.number().finite().optional().catch(undefined)).catch({}), + widthFractionByMode: z.record(z.string(), z.number().positive().max(1).optional().catch(undefined)).catch({}), +}); export type MermaidRenderingMode = 'svg' | 'ascii'; export type UserMessageRenderingMode = 'markdown' | 'plain'; export type ChatRenderMode = 'sorted' | 'live'; @@ -146,9 +152,12 @@ type ContextPanelDirectoryState = { expanded: boolean; tabs: ContextPanelTab[]; activeTabId: string | null; - // Manual per-surface widths (px), populated only by user resize; surfaces - // without an entry fall back to their registry defaultWidthFraction. + // Legacy pixel widths and the last resize value, used until the panel's + // available area is known and a responsive ratio can be captured. widthByMode: Partial>; + // Ratios captured when a user resizes a surface. These remain responsive + // across window sizes while widthByMode preserves older persisted values. + widthFractionByMode: Partial>; touchedAt: number; }; @@ -203,12 +212,12 @@ const isLegacyDefaultTemplates = (value: unknown): boolean => { }; const CONTEXT_PANEL_DEFAULT_WIDTH = 380; -const CONTEXT_PANEL_MIN_WIDTH = 380; +const CONTEXT_PANEL_MIN_WIDTH = 320; const CONTEXT_PANEL_MAX_WIDTH = 1400; /** Per surface, not per panel: see clampContextPanelTabs. */ const CONTEXT_PANEL_MAX_TABS = 12; const CONTEXT_PANEL_MAX_LABEL_LENGTH = 120; -const LEFT_SIDEBAR_MIN_WIDTH = 280; +const LEFT_SIDEBAR_DEFAULT_WIDTH = 280; /** Separates browser tabs opened in the same millisecond. */ let browserTabSequence = 0; @@ -280,7 +289,7 @@ const normalizeContextTabLabel = (value: string | null | undefined): string | nu }; const normalizePendingDiffScope = (value: unknown): PendingDiffScope | null => { - return value === 'working' || value === 'staged' || value === 'turn' || value === 'branch' ? value : null; + return value === 'working' || value === 'staged' || value === 'turn' || value === 'branch' || value === 'commit' ? value : null; }; /** A plan tab's owner must be a complete project reference or nothing; a @@ -513,6 +522,7 @@ const touchContextPanelState = (prev?: ContextPanelDirectoryState): ContextPanel tabs: [], activeTabId: null, widthByMode: {}, + widthFractionByMode: {}, touchedAt: Date.now(), }; }; @@ -703,16 +713,13 @@ const sanitizeContextPanelByDirectory = ( // Legacy single `width` values are intentionally dropped: widths are now // per-surface, seeded from registry defaults until the user resizes. const widthByMode: Partial> = {}; - if (candidate.widthByMode && typeof candidate.widthByMode === 'object') { - for (const [mode, value] of Object.entries(candidate.widthByMode as Record)) { - if ( - (mode === 'diff' || mode === 'file' || mode === 'context' || mode === 'plan' || mode === 'chat' || mode === 'browser' || mode === 'git' || mode === 'pr' || mode === 'linear' || mode === 'notes' || mode === 'terminal') - && typeof value === 'number' - && Number.isFinite(value) - ) { - widthByMode[mode] = clampContextPanelWidth(value); - } - } + const widthFractionByMode: Partial> = {}; + const savedWidths = persistedPanelWidthsSchema.parse(rawState); + for (const mode of contextPanelModeSchema.options) { + const pixels = savedWidths.widthByMode[mode]; + const fraction = savedWidths.widthFractionByMode[mode]; + if (pixels !== undefined) widthByMode[mode] = clampContextPanelWidth(pixels); + if (fraction !== undefined) widthFractionByMode[mode] = fraction; } next[directory] = { @@ -721,6 +728,7 @@ const sanitizeContextPanelByDirectory = ( tabs: clampedTabs, activeTabId: resolveActiveContextPanelTabID(clampedTabs, resolvedActiveTabId), widthByMode, + widthFractionByMode, touchedAt: typeof candidate.touchedAt === 'number' && Number.isFinite(candidate.touchedAt) ? candidate.touchedAt : Date.now(), @@ -754,7 +762,6 @@ interface UIStore { multiRunLauncherPrefillPrompt: string; isSidebarOpen: boolean; sidebarWidth: number; - hasManuallyResizedLeftSidebar: boolean; contextPanelByDirectory: Record; contextRailOrder: string[]; /** Surface ids the user hid from the context rail; stored as the hidden set @@ -790,6 +797,8 @@ interface UIStore { * Persisted to server settings, not just this browser. */ workStatusHiddenSections: string[]; + /** Explicitly chosen hidden-section state. False keeps the default opt-in seed. */ + workStatusHiddenSectionsExplicit: boolean; isSessionSwitcherOpen: boolean; isSessionDropdownOpen: boolean; pendingDiffFile: string | null; @@ -899,6 +908,7 @@ interface UIStore { notifyOnSubtasks: boolean; // Desktop dock badge showing the count of sessions with unseen activity (macOS). dockBadgeEnabled: boolean; + alwaysShowScrollbars: boolean; // Event toggles (which events trigger notifications) notifyOnCompletion: boolean; @@ -990,7 +1000,7 @@ interface UIStore { closeContextPanelTabs: (directory: string, tabIds: readonly string[]) => void; closeContextPanel: (directory: string) => void; toggleContextPanelExpanded: (directory: string) => void; - setContextPanelWidth: (directory: string, mode: ContextPanelMode, width: number) => void; + setContextPanelWidth: (directory: string, mode: ContextPanelMode, width: number, availableWidth?: number) => void; setNotesPanelHeight: (height: number) => void; setWorkStatusSectionExpanded: (sectionId: string, expanded: boolean) => void; setWorkStatusScrollTop: (scrollTop: number) => void; @@ -1109,6 +1119,7 @@ interface UIStore { setSessionTabsEnabled: (value: boolean) => void; setNotifyOnSubtasks: (value: boolean) => void; setDockBadgeEnabled: (value: boolean) => void; + setAlwaysShowScrollbars: (value: boolean) => void; setNotifyOnCompletion: (value: boolean) => void; setNotifyOnError: (value: boolean) => void; setNotifyOnQuestion: (value: boolean) => void; @@ -1172,8 +1183,7 @@ export const useUIStore = create()( isMultiRunLauncherOpen: false, multiRunLauncherPrefillPrompt: '', isSidebarOpen: true, - sidebarWidth: LEFT_SIDEBAR_MIN_WIDTH, - hasManuallyResizedLeftSidebar: false, + sidebarWidth: LEFT_SIDEBAR_DEFAULT_WIDTH, contextPanelByDirectory: {}, contextRailOrder: [], contextRailHiddenSurfaces: [], @@ -1187,6 +1197,7 @@ export const useUIStore = create()( workStatusPanelFits: false, workStatusOverlayOpen: false, workStatusHiddenSections: [], + workStatusHiddenSectionsExplicit: false, isSessionSwitcherOpen: false, isSessionDropdownOpen: false, pendingDiffFile: null, @@ -1271,6 +1282,7 @@ export const useUIStore = create()( notificationMode: 'hidden-only', notifyOnSubtasks: true, dockBadgeEnabled: true, + alwaysShowScrollbars: false, // Event toggles (which events trigger notifications) notifyOnCompletion: true, @@ -1333,45 +1345,15 @@ export const useUIStore = create()( }, toggleSidebar: () => { - set((state) => { - const newOpen = !state.isSidebarOpen; - - if (newOpen && !state.hasManuallyResizedLeftSidebar) { - return { - isSidebarOpen: newOpen, - sidebarWidth: LEFT_SIDEBAR_MIN_WIDTH, - }; - } - return { isSidebarOpen: newOpen }; - }); + set((state) => ({ isSidebarOpen: !state.isSidebarOpen })); }, setSidebarOpen: (open) => { - set((state) => { - if (state.isSidebarOpen === open) { - if (!open) { - return state; - } - if (!state.hasManuallyResizedLeftSidebar && state.sidebarWidth !== LEFT_SIDEBAR_MIN_WIDTH) { - return { - isSidebarOpen: open, - sidebarWidth: LEFT_SIDEBAR_MIN_WIDTH, - }; - } - return state; - } - if (open && !state.hasManuallyResizedLeftSidebar) { - return { - isSidebarOpen: open, - sidebarWidth: LEFT_SIDEBAR_MIN_WIDTH, - }; - } - return { isSidebarOpen: open }; - }); + set((state) => state.isSidebarOpen === open ? state : { isSidebarOpen: open }); }, setSidebarWidth: (width) => { - set({ sidebarWidth: width, hasManuallyResizedLeftSidebar: true }); + set({ sidebarWidth: width }); }, setContextRailOrder: (order) => { @@ -1735,7 +1717,7 @@ export const useUIStore = create()( }); }, - setContextPanelWidth: (directory, mode, width) => { + setContextPanelWidth: (directory, mode, width, availableWidth) => { const normalizedDirectory = normalizeDirectoryPath((directory || '').trim()); if (!normalizedDirectory) { return; @@ -1744,14 +1726,22 @@ export const useUIStore = create()( set((state) => { const prev = state.contextPanelByDirectory[normalizedDirectory]; const current = touchContextPanelState(prev); + const clampedWidth = clampContextPanelWidth(width); + const widthFractionByMode = { ...current.widthFractionByMode }; + if (availableWidth !== undefined && Number.isFinite(availableWidth) && availableWidth > 0) { + widthFractionByMode[mode] = Math.min(1, clampedWidth / availableWidth); + } else { + delete widthFractionByMode[mode]; + } const byDirectory = { ...state.contextPanelByDirectory, [normalizedDirectory]: { ...current, widthByMode: { ...current.widthByMode, - [mode]: clampContextPanelWidth(width), + [mode]: clampedWidth, }, + widthFractionByMode, }, }; @@ -1809,6 +1799,7 @@ export const useUIStore = create()( const isHidden = hidden.includes(sectionId); if (visible === !isHidden) return state; return { + workStatusHiddenSectionsExplicit: true, workStatusHiddenSections: visible ? hidden.filter((entry) => entry !== sectionId) : [...hidden, sectionId], @@ -1817,7 +1808,7 @@ export const useUIStore = create()( }, setWorkStatusHiddenSections: (sectionIds) => { - set({ workStatusHiddenSections: [...new Set(sectionIds)] }); + set({ workStatusHiddenSections: [...new Set(sectionIds)], workStatusHiddenSectionsExplicit: true }); }, setContextRailSurfaceVisible: (surfaceId, visible) => { @@ -2133,21 +2124,20 @@ export const useUIStore = create()( const entries = Object.entries(SEMANTIC_TYPOGRAPHY) as Array<[SemanticTypographyKey, string]>; - // Default must be SEMANTIC_TYPOGRAPHY (from CSS). Remove overrides. + // Scale the root rem unit so regular utility classes, icons, spacing, + // and semantic typography all respond to the same interface setting. if (scale === 1) { + root.style.removeProperty('font-size'); for (const [key] of entries) { root.style.removeProperty(getTypographyVariable(key)); } return; } - for (const [key, baseValue] of entries) { - const numericValue = parseFloat(baseValue); - if (!Number.isFinite(numericValue)) { - continue; - } - root.style.setProperty(getTypographyVariable(key), `${numericValue * scale}rem`); - } + root.style.fontSize = `${scale * 100}%`; + + // The variables remain authored in rem and inherit the root scale. + for (const [key] of entries) root.style.removeProperty(getTypographyVariable(key)); }, applyPadding: () => { @@ -2552,6 +2542,9 @@ export const useUIStore = create()( setDockBadgeEnabled: (value) => { set({ dockBadgeEnabled: value }); }, + setAlwaysShowScrollbars: (value) => { + set({ alwaysShowScrollbars: value }); + }, setNotifyOnCompletion: (value) => { set({ notifyOnCompletion: value }); }, setNotifyOnError: (value) => { set({ notifyOnError: value }); }, @@ -2710,13 +2703,21 @@ export const useUIStore = create()( { name: 'ui-store', storage: createDeferredSafeJSONStorage(), - version: 19, + version: 21, migrate: (persistedState, version) => { if (!persistedState || typeof persistedState !== 'object') { return persistedState; } const state = persistedState as Record; + // v20 -> v21: enable telemetry by default; preserve explicit choices. + if (version < 21 && state.workStatusHiddenSectionsExplicit !== true) { + state.workStatusHiddenSections = Array.isArray(state.workStatusHiddenSections) + ? state.workStatusHiddenSections.filter((id) => id !== 'telemetry') + : []; + state.workStatusHiddenSectionsExplicit = false; + } + // v15 -> v16: the main-area surface concept is gone from persistence // (the chat always owns the desktop main area; panel surfaces have // their own state). Drop the historic fields so a stored non-chat @@ -2964,6 +2965,7 @@ export const useUIStore = create()( workStatusScrollTop: state.workStatusScrollTop, workStatusPanelEnabled: state.workStatusPanelEnabled, workStatusHiddenSections: state.workStatusHiddenSections, + workStatusHiddenSectionsExplicit: state.workStatusHiddenSectionsExplicit, isSessionSwitcherOpen: state.isSessionSwitcherOpen, sidebarSection: state.sidebarSection, settingsPage: state.settingsPage, @@ -3022,6 +3024,7 @@ export const useUIStore = create()( sessionTabsEnabled: state.sessionTabsEnabled, notifyOnSubtasks: state.notifyOnSubtasks, dockBadgeEnabled: state.dockBadgeEnabled, + alwaysShowScrollbars: state.alwaysShowScrollbars, notifyOnCompletion: state.notifyOnCompletion, notifyOnError: state.notifyOnError, notifyOnQuestion: state.notifyOnQuestion, @@ -3051,6 +3054,7 @@ export const useUIStore = create()( weekStartPreference: state.weekStartPreference, desktopWindowControlsPosition: state.desktopWindowControlsPosition, desktopWindowControlsStyle: state.desktopWindowControlsStyle, + inputBarOffset: state.inputBarOffset, mermaidRenderingMode: state.mermaidRenderingMode, userMessageRenderingMode: state.userMessageRenderingMode, collapsibleUserMessages: state.collapsibleUserMessages, diff --git a/packages/ui/src/stores/useWalkthroughStore.test.ts b/packages/ui/src/stores/useWalkthroughStore.test.ts index 892d07de..66375d6d 100644 --- a/packages/ui/src/stores/useWalkthroughStore.test.ts +++ b/packages/ui/src/stores/useWalkthroughStore.test.ts @@ -168,6 +168,18 @@ describe('useWalkthroughStore — model selection', () => { expect(useWalkthroughStore.getState().getSelectedModel('/repo', SOURCE)) .toBe('anthropic/claude-haiku-4-5'); }); + + test('keeps commit walkthroughs separate and selecting one does not generate', () => { + const generatedBefore = generateCalls; + const first: WalkthroughSource = { kind: 'commit', hash: 'a'.repeat(40) }; + const second: WalkthroughSource = { kind: 'commit', hash: 'b'.repeat(40) }; + useWalkthroughStore.getState().selectModel('/repo', first, 'anthropic/claude-haiku-4-5'); + useWalkthroughStore.getState().requestSource('/repo', second); + expect(useWalkthroughStore.getState().getSelectedModel('/repo', first)).toBe('anthropic/claude-haiku-4-5'); + expect(useWalkthroughStore.getState().getSelectedModel('/repo', second)).toBeUndefined(); + expect(useWalkthroughStore.getState().requestedSource['/repo']).toEqual(second); + expect(generateCalls).toBe(generatedBefore); + }); }); describe('useWalkthroughStore — walkthrough language', () => { diff --git a/packages/ui/src/stores/useWalkthroughStore.ts b/packages/ui/src/stores/useWalkthroughStore.ts index dbb6206d..4182f6ad 100644 Binary files a/packages/ui/src/stores/useWalkthroughStore.ts and b/packages/ui/src/stores/useWalkthroughStore.ts differ diff --git a/packages/ui/src/styles/design-system.css b/packages/ui/src/styles/design-system.css index 3318b75a..fc28494b 100644 --- a/packages/ui/src/styles/design-system.css +++ b/packages/ui/src/styles/design-system.css @@ -24,13 +24,16 @@ --oc-glass-saturation: 1.24; /* Semantic typography defaults (must match SEMANTIC_TYPOGRAPHY) */ - --text-markdown: 0.9375rem; - --text-code: 0.8125rem; - --text-ui-header: 0.9375rem; - --text-ui-label: 0.8750rem; - --text-meta: 0.875rem; - --text-micro: 0.875rem; - --text-settings-page-title: 1.125rem; + --text-markdown: 0.875rem; + --text-code: 0.75rem; + --text-ui-header: 0.875rem; + --text-ui-label: 0.8125rem; + --text-meta: 0.8125rem; + --text-micro: 0.8125rem; + --text-settings-page-title: 1.0625rem; + + /* Text selection colour shared by native ::selection and the comment overlay */ + --oc-text-selection: color-mix(in srgb, var(--primary) 30%, transparent); /* Default Light Theme */ --background: oklch(0.97 0.02 85); /* Warm sand background */ @@ -154,25 +157,62 @@ font-family: var(--font-mono, ui-monospace, SFMono-Regular, 'Liberation Mono', Menlo, monospace) !important; } - /* Restore default list styling inside markdown content - override Tailwind preflight */ + .markdown-content details[data-md-details] { + margin-block: 0.75rem; + min-width: 0; + } + + .markdown-content details[data-md-details] > summary { + display: flex; + align-items: baseline; + gap: 0.375rem; + list-style: none; + cursor: pointer; + padding-block: 0.375rem; + overflow-wrap: anywhere; + font-weight: 500; + border-radius: 0.25rem; + } + + .markdown-content details[data-md-details] > summary::-webkit-details-marker { + display: none; + } + + .markdown-content [data-md-disclosure-icon] { + display: inline-flex; + flex-shrink: 0; + align-self: flex-start; + margin-top: 0.25em; + color: var(--surface-muted-foreground); + } + + .markdown-content details[data-md-details][open] > summary > [data-md-disclosure-icon] { + transform: rotate(90deg); + } + + .markdown-content details[data-md-details] > summary:focus-visible { + outline: 2px solid var(--interactive-focus-ring); + outline-offset: 2px; + } + + /* Restore list styling inside markdown content - override Tailwind preflight. + Native markers with a compact gutter; nesting cycles the marker shape so + depth stays readable without extra indentation. */ .markdown-content ul, .markdown-content ol { - padding-left: 2em !important; + padding-left: 1.25rem !important; } .markdown-content ul { - list-style-type: none !important; + list-style-type: disc !important; } - .markdown-content ul > li { - position: relative; + .markdown-content ul ul { + list-style-type: circle !important; } - .markdown-content ul > li::before { - content: "–"; - position: absolute; - left: -1.25em; - color: inherit; + .markdown-content ul ul ul { + list-style-type: square !important; } .markdown-content ol { @@ -180,10 +220,40 @@ list-style-position: outside !important; } + .markdown-content ol ol { + list-style-type: lower-alpha !important; + } + + .markdown-content ol ol ol { + list-style-type: lower-roman !important; + } + .markdown-content li { display: list-item !important; } + .markdown-content li::marker { + color: currentColor; + } + + .markdown-content ol > li::marker { + font-variant-numeric: tabular-nums; + } + + .markdown-content li + li { + margin-top: 0.25rem; + } + + /* GitHub-style task lists: the checkbox replaces the list marker. */ + .markdown-content li:has(> input[type="checkbox"]:first-child) { + list-style-type: none !important; + } + + .markdown-content li > input[type="checkbox"]:first-child { + margin: 0 0.35em 0.15em -1.25rem; + vertical-align: middle; + } + /* Remove focus rings globally (except inputs/textareas/dropdowns/selects/explicit accent rings) */ *:focus:not(input):not(textarea):not([data-slot="dropdown-menu-content"]):not([data-slot="dropdown-menu-sub-content"]):not([data-slot="select-content"]):not(.focus-ring-accent):not([data-focus-ring="accent"]) { outline: none !important; @@ -425,13 +495,11 @@ --markdown-link: var(--markdown-link); --markdown-link-hover: var(--markdown-link-hover); --markdown-list-marker: var(--markdown-list-marker); - --markdown-inline-code: var(--markdown-inline-code); - --markdown-inline-code-bg: var(--markdown-inline-code-bg); --markdown-blockquote: var(--markdown-blockquote); --markdown-blockquote-border: var(--markdown-blockquote-border); /* Markdown spacing defaults */ - --markdown-paragraph-spacing: 0.75rem; + --markdown-paragraph-spacing: 0.65rem; --markdown-heading-primary-top: 0.75rem; --markdown-heading-primary-bottom: 0.35rem; --markdown-heading-secondary-top: 0.6rem; diff --git a/packages/ui/src/styles/mobile.css b/packages/ui/src/styles/mobile.css index 9a5eb3bd..5adbd400 100644 --- a/packages/ui/src/styles/mobile.css +++ b/packages/ui/src/styles/mobile.css @@ -56,6 +56,12 @@ min-width: 36px; } + /* The Changes section and source pickers take their height from dropdownTriggerVariants, + rather than the generic mobile button minimum. */ + :root.mobile-pointer:not(.desktop-runtime) button[data-mobile-comparison-trigger] { + min-height: 0; + } + /* Composer footer action buttons (sessions / attach / auto-accept): hug the icon so the group stays tight. The container only renders in the mobile JSX, so no pointer/runtime gating is needed; !important overrides both the @@ -456,6 +462,22 @@ padding-top: var(--oc-safe-area-top); } +/* Android 15+ enforces edge-to-edge, and once the WebView can read the insets + itself Capacitor stops padding the view and just hands them over — so the app + draws under the navigation/gesture bar and every full-cover surface has to + keep its own content clear of it. Marked surfaces opt in through + .oc-bottom-safe-surface; iOS deliberately stays on its softer visual inset, + where the home indicator is a thin overlay rather than a bar that eats taps. + Capacitor reports a zero bottom inset while the keyboard is up, so this never + stacks with --oc-keyboard-inset. */ +:root.oc-capacitor-app.oc-platform-android { + --oc-app-bottom-safe: max(16px, var(--oc-safe-area-bottom)); +} + +:root.oc-capacitor-app.oc-platform-android .oc-bottom-safe-surface { + padding-bottom: var(--oc-safe-area-bottom); +} + :root.oc-capacitor-app.oc-platform-android [data-sonner-toaster][data-y-position='top'] { top: calc(var(--oc-safe-area-top) + 16px) !important; } diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index 49b5459f..2301ad98 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -52,7 +52,7 @@ So: | `viewport-store.ts` | Scroll anchors, session memory, loading indicators | App UI state | | `attachment-files.ts` | Attachment picker allowlists, MIME/content validation, structured-text sanitization, and HEIC conversion | Local chat attachments across shared UI runtimes | | `document-attachments.ts` | Bounded Office/OpenDocument extraction, document text serialization, embedded-image extraction, and positional citations | DOCX, PPTX, XLSX, ODT, ODP, and ODS chat attachments | -| `input-store.ts` | Draft input state, attached files, synthetic parts | App UI state | +| `input-store.ts` | Draft input state, attached files, synthetic parts, destination-scoped fork replay handoff | App UI state; fork replay targets runtime + directory + session | | `selection-store.ts` | Model/agent/variant selections | App UI state | | `voice-store.ts` | Voice state | App UI state | @@ -195,12 +195,15 @@ Rules: 4. Async commits are generation-checked. Runtime switches, forced refreshes, eviction, and disposal must reject stale completion. 5. Prefetch coverage and persisted directory data are runtime-scoped. Legacy persisted directory entries may seed startup continuity, but they are not live truth. 6. Message and part materialization preserves references for unchanged records and maintains direct message-to-parts lookup. Consumers subscribe to the selected session's records rather than broad message/part containers. + Directory `sessionStatusReady` records successful status-snapshot authority independently of bootstrap's general readiness. Before that flag or an explicit session status arrives, telemetry treats an omitted status as unknown. A failed status request cannot grant idle authority; the flag is not persisted. 7. Pagination demand must carry the selected session's effective directory. It must not fall back to the sync provider directory because the visible session may belong to another worktree. 8. The ref-stable loader is disposed only after the current task when its provider unmounts. This lets React Strict Mode's development setup → cleanup → setup probe retain a usable loader for child effects, while real disposal still invalidates the preceding lifecycle's work. 9. Transcript arrays are chronological by `message.time.created`, with message ID used only as a deterministic equal-time tie-breaker. Message IDs are identity and reconciliation keys, not chronology: OpenCode's fixed-width sortable timestamp prefix rolls over, so a newer `msg_000...` can follow an older `msg_fff...`. Fetch, pagination, materialization, optimistic insertion, events, reconnect inspection, rendering, and revert/undo/redo must preserve this contract. 10. Session-scoped ArrowUp and ArrowDown recall merges the visible transcript's user prompts (`useUserMessageHistory`) with the persisted input-history bucket for runtime + normalized directory + session identity. Revert markers hide prompts from the transcript source only; the persisted bucket still recalls them. Global scope reads the persisted runtime bucket alone. 11. Part arrays preserve authoritative response/event order. Part IDs are identity keys and have the same rollover limitation; identity lookup/removal must not require a part array to be lexically ID-sorted. +A successful local session creation publishes its session record and calls `SessionMessageLoader.initializeCreatedSession` before selection starts navigation loading. The create response establishes an empty transcript only if no transcript has arrived yet. Initialization supersedes an earlier unresolved history load, preserves any messages or metadata received before the create response, and uses the server-returned directory. Opening that new session needs no history read; forced recovery and later eviction still use normal fetching. Creation responses from a previous runtime cannot select or initialize a session in the current runtime. + Initial loads use smaller pages on constrained VS Code/mobile surfaces. Prefetch resolves only the initial renderable page; it does not eagerly download older history. The mounted chat timeline requests older pages when its viewport is underfilled or the user scrolls toward history, while mobile uses its explicit load-older action. Timeline caches, pending work, prepend snapshots, and stale checks use runtime + directory + session identity so equal session IDs in different worktrees cannot share lifecycle state. Older pages are fetched through the same loader and merged with optimistic records before publication. The same chronology contract applies in the VS Code webview because it consumes this shared loader and sync store; the extension bridge must transport OpenCode records without introducing its own ID-based ordering. ## Failed-turn diagnostics @@ -245,7 +248,9 @@ Incomplete-session materialization is deduplicated by runtime, directory, and se When `session.idle` or `session.error` settles a session but the trailing assistant message still contains a `pending` or `running` tool, sync refreshes that session tail. This narrowly reconciles a missed terminal tool-part event without refetching normally completed turns or stale tools from older turns. A stale refresh or delayed part event cannot regress a locally observed terminal tool to an active status. -When a session is authoritatively settled — `session.idle`/`session.error` event, or an authoritative status snapshot that lowers a previously busy session — and the trailing assistant message is still *unfinished* (`time.completed` missing) with no pending question/permission, the turn is treated as interrupted (managed OpenCode process died mid-turn; the server never finalizes the message or parts, see openchamber#2577 / anomalyco/opencode#19023). The unfinished assistant message is completed locally with `MessageAbortedError`, including text-only turns and turns whose tools had already finished, so the chat shows a visible interrupted state. Any active parts are also finalized as `error`/`Interrupted` with an end time, so tool timers stop and cards render the error state. The mark is gated on an explicit idle status (absent status is "unknown", never judged), never applies while the session is busy (including question/permission waits), and a later terminal event can supersede it while a stale unfinished refresh cannot regress the locally finalized message or parts. +A completed assistant message is authoritative for its own tool parts. During materialization, a `pending` or `running` tool under `time.completed` becomes `error`/`Interrupted` with an end time. This handles stale persisted tool state during reload. The merge preserves a terminal part already observed live, and a later terminal server snapshot can replace the local interrupted marker. + +When a session is authoritatively settled — `session.idle`/`session.error` event, or an authoritative status snapshot that lowers a previously busy session — and the trailing assistant message is still *unfinished* (`time.completed` missing) with no pending question/permission, the turn is treated as interrupted (managed OpenCode process died mid-turn; the server never finalizes the message or parts, see openchamber#2577 / anomalyco/opencode#19023). The unfinished assistant message is completed locally with `MessageAbortedError`, including text-only turns and turns whose tools had already finished, so the chat shows a visible interrupted state. Any active parts are also finalized as `error`/`Interrupted` with an end time, so tool timers stop and cards render the error state. The mark is gated on an explicit idle status (absent status is "unknown", never judged), never applies while the session is busy (including question/permission waits), and a later terminal event can supersede it while a stale unfinished refresh cannot regress the locally finalized message or parts. A successful authoritative snapshot records explicit idle for previously unknown candidates, and message hydration retries this reconciliation after the transcript arrives; a failed status fetch leaves the session unknown. Recovery rejects responses after a runtime or SDK switch, request invalidation, or directory-store disposal before publishing local or global state. Directory stores also own session-keyed sidecar notification channels for permissions, questions, and message materialization. High-frequency realtime part events annotate the exact session/message before committing, so visible records, user history, renderability, and sidebar permission and question rows are not notified by unrelated sessions. Structural message replacements notify only changed subscribed session buckets; unannotated bulk part replacement conservatively resets active message subscribers so bootstrap, pagination, rollback, and legacy writers cannot leave stale projections. @@ -269,7 +274,7 @@ The discriminator is whether the server confirmed the path, not whether the valu Rules: -1. Ownership comes from the session record's own `directory`. `getSyncSessionDirectory()` reports *containment*, not ownership, and is only the fallback for a record without a directory: a project's session list includes the sessions of its worktrees so the sidebar can group them, so the parent repository holds worktree sessions too, and reading ownership from membership routes a worktree session to its parent. `null` means "not indexed yet", never "no directory". +1. Ownership comes from the session record's own `directory`. When directory sync has no owning record yet, the global session index supplies that record's directory before local selection, worktree, or remembered hints. `getSyncSessionDirectory()` reports *containment*, not ownership, and is only the fallback for a record without a directory: a project's session list includes the sessions of its worktrees so the sidebar can group them, so the parent repository holds worktree sessions too, and reading ownership from membership routes a worktree session to its parent. `null` means "not indexed yet", never "no directory". 2. `attachment` and `worktreeMetadata` hold the worktree path this client asked for, before the server canonicalized it. They are a hint for a session sync has not indexed yet, never a correction of a confirmed directory — otherwise a stale local path re-creates the very mismatch this precedence exists to prevent. 3. Never persist or rank a guessed directory. `selectSession` may fall back to the active directory to keep routing usable, but that value is not written to runtime memory, not written to the last-active snapshot, and not passed as `selected` — a persisted guess outlives the race that produced it and survives reloads and restarts. 4. Components must not read `currentSessionDirectory` to build request or queue keys; use `getDirectoryForSession()` so every consumer resolves identically. @@ -287,7 +292,7 @@ Rules: 4. Sending after a revert commits the new branch optimistically: remove the reverted tail and marker before inserting the new message, and restore both if the send is rejected. 5. Composer and queued sends carry their captured runtime, directory, and session through asynchronous preparation. A runtime change cancels the send instead of re-resolving it against the new runtime. Outside VS Code the queue itself is server-owned (`packages/web/server/lib/message-queue/`): the UI hands the server the captured send configuration, resolved text, attachments, and attached context at queue time and the server delivers on idle; the composer only sends a queued message itself after taking it back from the server (`takeForSend`). See the `messageQueueStore.ts` section in `stores/DOCUMENTATION.md`. 6. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session. -7. Regular new-chat drafts that inherit the persisted current/last directory must not create a session against a confirmed-missing path. Fall back to the active project only when OpenCode reports the directory missing; keep explicit worktree targets, in-flight worktree creation, and unknown/offline probes unchanged, and do not persist the fallback until session creation succeeds. A concurrent draft rewrite to that same active-project fallback must not abort session creation. +7. Regular new-chat drafts that inherit the persisted current/last directory must not create a session against a confirmed-missing path. Fall back to the active project only when OpenChamber's directory stat reports the directory missing; keep explicit worktree targets, in-flight worktree creation, and unknown/offline probes unchanged, and do not persist the fallback until session creation succeeds. A concurrent draft rewrite to that same active-project fallback must not abort session creation. 8. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message. 9. `SessionLiveActivity` has three answers and `unknown` is never `idle`. `getSessionLiveActivity` reports `active` when any child store or the global session-status index holds a non-idle status, `idle` only when a child store actually covers the session's directory, and `unknown` otherwise — child stores are evicted for background directories, and the global index keeps only non-idle entries, so absence of a status is not proof of idleness. Callers that gate a destructive action (worktree moves) must refuse on `unknown`. 10. Revert and unrevert cascade through known descendant sessions before mutating the parent. Revert uses the first descendant user message at or after the parent's target timestamp, including equal timestamps because message IDs do not define chronology. A descendant failure is logged and does not block its siblings or the parent. The parent runs last so its shared-directory file snapshot remains authoritative. A busy descendant is aborted before it is reverted, like the parent, so nothing keeps writing past the revert boundary. Redo clears the revert marker on every descendant, including markers the user set on a subagent independently of the parent undo. @@ -340,25 +345,9 @@ feedback stays truthful. Callers whose confirmation can span a runtime switch may pass an `expectedRuntimeKey` captured earlier; ordinary callers are guarded by default. -When the session being restored belongs to a worktree that no longer exists, -writing `time.archived = 0` alone would leave it grouped under a directory the -sidebar can never surface. Restore therefore probes the session's owned -directory with `getDirectoryAvailability` and, only on an exact `missing` -result, relocates it: it resolves the owning OpenCode project's primary -directory by the session's server `projectID` (from `project.list()`, never a -local project ID or the active project), then unarchives and moves the whole -subtree still stranded in the missing directory to that project directory -through `moveSessionToDirectory(..., false)`. `available`, `unknown`, an -availability probe failure, a missing project record, and non-worktree sessions -keep the plain restore path. The subtree is drawn from the global cache so -archived descendants that never materialized in a live child store are still -relocated, and a node is kept while it is archived **or** still owns the -missing directory, so a retry after a partial restore (root already unarchived -but not yet moved) completes the move instead of reporting a false success. -`moveSessionToDirectory` accepts the captured `expectedRuntimeKey` and skips all -local store/routing publication when the runtime changed during the -control-plane request, so the server move can complete without seeding the new -runtime with stale directory state. +`unarchiveSession` clears the archive timestamp in the session's existing directory. It never moves the session, including when that directory is missing. Server failure keeps the session archived locally; confirmation updates the global cache. `unarchiveSessions` preserves partial results and stops committing when its captured runtime changes. + +### Deletion runtime guard Deletion needs this guard more than archiving does. Session IDs are not unique across runtimes, and a committed deletion does more than hide a row: it evicts @@ -382,30 +371,9 @@ reports failure instead of committing. The deletion already accepted by the server stays deleted there; its persisted state is left as harmless stale metadata and the next authoritative load reconciles it. -### Missing directory relocation (active sessions) +### Missing worktree directories -The same directory can disappear under an active session: a worktree removed -by the agent or by hand leaves the session, its tabs, and its prompts pointed -at a path that no longer exists, and the terminal server answers every create -and restart with `Invalid working directory`. `relocateSessionFromMissingDirectory` -(`session-actions.ts`) applies the restore fallback's gate to a live session: -an exact `missing` probe, the destination resolved from the server `projectID`, -and `available`, `unknown`, probe failures, project-root sessions, and sessions -without a project left untouched. Every session of the root's subtree still -stranded in that directory moves with it, root first, so the session the user -is looking at is usable even when a descendant move fails; the result names -the sessions already moved. Moves carry no changes because the source is gone. - -`session-ui-store.recoverMissingSessionDirectory` owns the user-visible side: -one shared attempt per runtime and session, the worktree hint cleared for each -moved session (it is the first thing every directory lookup reads), the current -session re-selected through `setCurrentSession` so the active directory, -project, and OpenCode client follow it, and one toast naming the destination. -It runs from two places: a terminal create/restart rejected with the server's -`TERMINAL_CWD_MISSING` code, and session activation for any session whose -directory is neither a registered project root nor a managed chat directory -(the same probe a reopened draft performs on its inherited directory). VS Code -registers no worktrees, so activation never probes there. +Existing sessions keep their directory when a worktree disappears. Session activation makes no directory-availability probe, and terminal failures and archive restoration never move sessions. Manual movement still goes through `moveSessionToDirectory`. Worktree deletion still archives its sessions before removing the worktree. Missing-worktree groups stay visible with a warning so users can choose either action. ## The golden rule @@ -457,6 +425,12 @@ During streaming, `message.part.delta` fires ~60 times/sec. Eagerly cloning all ## Event → field mapping +Queue recovery is independent of the directory-bootstrap debounce. The sync +provider subscribes to `message-queue-sync.ts` for control-stream updates and +requests a queue refresh on every main-stream connection or transport switch, +including the first connection. The queue store coalesces these requests with +bootstrap and owns snapshot ordering and legacy-upload lifetime. + Keep this in sync with `handleDirectoryEvent` in `sync-context.tsx`: | Event type | Fields to clone | diff --git a/packages/ui/src/sync/__tests__/event-reducer.test.ts b/packages/ui/src/sync/__tests__/event-reducer.test.ts index 2dda4914..0c626b30 100644 --- a/packages/ui/src/sync/__tests__/event-reducer.test.ts +++ b/packages/ui/src/sync/__tests__/event-reducer.test.ts @@ -128,6 +128,15 @@ describe("applyDirectoryEvent", () => { properties: { part: serverText }, } as Event)).toBe(true) expect(draft.part.msg_1).toEqual([serverText, optimisticFile]) + + // The file echo follows the text echo; it must claim the optimistic file + // even though the first slot now holds a server part. + const serverFile = { id: "prt_server_file", messageID: "msg_1", sessionID: "ses_1", type: "file", filename: "a.png" } as Part + expect(applyDirectoryEvent(draft, { + type: "message.part.updated", + properties: { part: serverFile }, + } as Event)).toBe(true) + expect(draft.part.msg_1).toEqual([serverText, serverFile]) }) test("returns typed materialization when delta arrives before parts", () => { @@ -346,3 +355,98 @@ describe("applyDirectoryEvent", () => { expect(draft.question.ses_1).toEqual([]) }) }) + +describe("question reducer invariants (main contract)", () => { + const questionRequest = (id: string, sessionID = "ses_1"): QuestionRequest => ({ + id, + sessionID, + questions: [], + }) + + const askedEvent = (id: string, sessionID = "ses_1"): Event => ({ + id: `evt_${id}`, + type: "question.asked", + properties: questionRequest(id, sessionID), + }) + + const repliedEvent = (requestID: string, sessionID = "ses_1"): Event => ({ + id: `evt_${requestID}`, + type: "question.replied", + properties: { sessionID, requestID, answers: [] }, + }) + + const rejectedEvent = (requestID: string, sessionID = "ses_1"): Event => ({ + id: `evt_${requestID}`, + type: "question.rejected", + properties: { sessionID, requestID }, + }) + + test("question.asked is an idempotent upsert-by-id — replaying does not duplicate", () => { + const draft = state({ question: { ses_1: [questionRequest("ques_1")] } }) + + expect(applyDirectoryEvent(draft, askedEvent("ques_1"))).toBe(true) + expect(applyDirectoryEvent(draft, askedEvent("ques_1"))).toBe(true) + + expect(draft.question.ses_1).toHaveLength(1) + expect(draft.question.ses_1[0]?.id).toBe("ques_1") + }) + + test("question.asked replaces the stored record in place (not first-wins)", () => { + const draft = state({ question: { ses_1: [questionRequest("ques_1")] } }) + const replacement: QuestionRequest = { + id: "ques_1", + sessionID: "ses_1", + questions: [ + { question: "updated?", header: "Build", options: [{ label: "Yes", description: "Go" }] }, + ], + } + + expect(applyDirectoryEvent(draft, { + id: "evt_ques_1", + type: "question.asked", + properties: replacement, + })).toBe(true) + + expect(draft.question.ses_1).toHaveLength(1) + expect(draft.question.ses_1[0]).toEqual(replacement) + }) + + test("question.replied and question.rejected remove exactly the matching request; unknown removal is a no-op returning false", () => { + const draft = state({ + question: { ses_1: [questionRequest("ques_1"), questionRequest("ques_2")] }, + }) + + expect(applyDirectoryEvent(draft, repliedEvent("ques_1"))).toBe(true) + expect(draft.question.ses_1.map((q) => q.id)).toEqual(["ques_2"]) + + expect(applyDirectoryEvent(draft, rejectedEvent("ques_2"))).toBe(true) + expect(draft.question.ses_1).toEqual([]) + + // Removal for an unknown request is a safe no-op. + expect(applyDirectoryEvent(draft, repliedEvent("ques_missing"))).toBe(false) + expect(applyDirectoryEvent(draft, rejectedEvent("ques_missing"))).toBe(false) + expect(draft.question.ses_1).toEqual([]) + }) + + test("a duplicate terminal event after removal is a safe no-op", () => { + const draft = state({ question: { ses_1: [questionRequest("ques_1")] } }) + + expect(applyDirectoryEvent(draft, repliedEvent("ques_1"))).toBe(true) + expect(draft.question.ses_1).toEqual([]) + + // Replayed terminal event: no error, no duplicate, no state change. + expect(applyDirectoryEvent(draft, repliedEvent("ques_1"))).toBe(false) + expect(draft.question.ses_1).toEqual([]) + }) + + test("a late question.asked after a terminal event re-registers the request (no tombstone)", () => { + const draft = state({ question: { ses_1: [questionRequest("ques_1")] } }) + + expect(applyDirectoryEvent(draft, repliedEvent("ques_1"))).toBe(true) + expect(draft.question.ses_1).toEqual([]) + + // Ordered-stream replay: a late asked re-inserts; there is no tombstone. + expect(applyDirectoryEvent(draft, askedEvent("ques_1"))).toBe(true) + expect(draft.question.ses_1.map((q) => q.id)).toEqual(["ques_1"]) + }) +}) diff --git a/packages/ui/src/sync/__tests__/issue-2039.test.ts b/packages/ui/src/sync/__tests__/issue-2039.test.ts index 763b815d..6ec763c2 100644 --- a/packages/ui/src/sync/__tests__/issue-2039.test.ts +++ b/packages/ui/src/sync/__tests__/issue-2039.test.ts @@ -309,7 +309,6 @@ mock.module("../session-actions", () => ({ unrevertSession: mock(async () => undefined), forkFromMessage: mock(async () => undefined), fetchMessagesForSession: mock(async () => undefined), - relocateSessionFromMissingDirectory: mock(async () => ({ status: "unchanged" })), getSessionLastAssistantModel: () => null, patchSessionMetadata: mock(async () => undefined), abortCurrentOperation: mock(async () => undefined), @@ -323,6 +322,9 @@ mock.module("@/lib/openchamberConfig", () => ({ getWorktreeSetupCommands: async () => [], getWorktreeSetupWaitEnabled: async () => false, })) +mock.module("@/lib/sharedTrustConfirmation", () => ({ + resolveWorktreeSetupCommands: async () => [], +})) mock.module("@/lib/worktrees/worktreeBootstrap", () => ({ waitForWorktreeBootstrap: async () => undefined, diff --git a/packages/ui/src/sync/__tests__/materialization.test.ts b/packages/ui/src/sync/__tests__/materialization.test.ts index 19251632..7d833ea2 100644 --- a/packages/ui/src/sync/__tests__/materialization.test.ts +++ b/packages/ui/src/sync/__tests__/materialization.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import type { Message, Part, ToolPart } from "@opencode-ai/sdk/v2/client" import { getSessionMaterializationRequestKey, getSessionMaterializationStatus, @@ -16,6 +16,23 @@ function userMessage(id: string, sessionID = "ses_1"): Message { return { id, sessionID, role: "user", time: { created: 1 } } as Message } +function completedAssistantMessage(id: string, sessionID = "ses_1"): Message { + return { + id, + sessionID, + role: "assistant", + time: { created: 1, completed: 4000 }, + parentID: "msg_parent", + modelID: "model", + providerID: "provider", + mode: "mode", + agent: "agent", + path: { cwd: "/repo", root: "/repo" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } +} + function part(id: string, messageID: string, type = "text", text = id): Part { return { id, messageID, sessionID: "ses_1", type, text } as Part } @@ -28,6 +45,69 @@ describe("getSessionMaterializationRequestKey", () => { }) describe("materializeSessionSnapshots", () => { + test("finalizes an active tool under a completed assistant message", () => { + const completedMessage = completedAssistantMessage("msg_1") + const staleRunningTool = { + id: "prt_1", + messageID: "msg_1", + sessionID: "ses_1", + type: "tool", + tool: "bash", + state: { status: "running", input: { command: "ls" }, time: { start: 1000 } }, + callID: "call-prt_1", + } satisfies ToolPart + + const result = materializeSessionSnapshots( + { message: {}, part: {} }, + "ses_1", + [{ info: completedMessage, parts: [staleRunningTool] }], + ) + + const reconciledPart = result.part.msg_1[0] + if (!reconciledPart || reconciledPart.type !== "tool") throw new Error("Expected tool part") + if (reconciledPart.state.status !== "error") throw new Error("Expected interrupted tool part") + expect(reconciledPart.state.error).toBe("Interrupted") + expect(reconciledPart.state.time).toEqual({ start: 1000, end: 4000 }) + expect(getStaleRunningToolMessageID(result, "ses_1")).toBe(undefined) + }) + + test("preserves a terminal tool already observed when a completed snapshot is stale", () => { + const completedMessage = completedAssistantMessage("msg_1") + const terminalTool = { + id: "prt_1", + messageID: "msg_1", + sessionID: "ses_1", + type: "tool", + tool: "bash", + state: { + status: "completed", + input: { command: "ls" }, + output: "done", + title: "bash", + metadata: {}, + time: { start: 1000, end: 2000 }, + }, + callID: "call-prt_1", + } satisfies ToolPart + const staleRunningTool = { + ...terminalTool, + state: { status: "running", input: {}, time: { start: 1000 } }, + } satisfies ToolPart + const state = { + message: { ses_1: [completedMessage] }, + part: { msg_1: [terminalTool] }, + } + + const result = materializeSessionSnapshots( + state, + "ses_1", + [{ info: completedMessage, parts: [staleRunningTool] }], + ) + + expect(result.part).toBe(state.part) + expect(result.part.msg_1[0]).toBe(terminalTool) + }) + test("marks an empty successful page as materialized", () => { const result = materializeSessionSnapshots( { message: {}, part: {} }, diff --git a/packages/ui/src/sync/__tests__/message-completion-status-poll.test.ts b/packages/ui/src/sync/__tests__/message-completion-status-poll.test.ts index 09aa3d58..86c981a5 100644 --- a/packages/ui/src/sync/__tests__/message-completion-status-poll.test.ts +++ b/packages/ui/src/sync/__tests__/message-completion-status-poll.test.ts @@ -7,7 +7,7 @@ */ import { beforeEach, describe, expect, mock, test } from "bun:test" import { create, type StoreApi } from "zustand" -import type { SessionStatus } from "@opencode-ai/sdk/v2/client" +import type { Message, Part, SessionStatus } from "@opencode-ai/sdk/v2/client" import { INITIAL_STATE } from "../types" import type { DirectoryStore } from "../child-store" @@ -15,9 +15,12 @@ type StatusSnapshot = Record let respondWithSnapshot: () => Promise = () => Promise.resolve({ ses_1: { type: "idle" } }) const statusSnapshotCalls: string[] = [] +let runtimeKey = "test-runtime" +let sdkIdentity = {} mock.module("@/lib/opencode/client", () => ({ opencodeClient: { + getSdkClient: () => sdkIdentity, getSessionStatusForDirectory: mock((directory: string) => { statusSnapshotCalls.push(directory) return respondWithSnapshot() @@ -26,20 +29,50 @@ mock.module("@/lib/opencode/client", () => ({ })) mock.module("@/lib/runtime-switch", () => ({ - getRuntimeKey: () => "test-runtime", + getRuntimeKey: () => runtimeKey, })) -import { maybePollStatusAfterMessageCompletion, MESSAGE_COMPLETION_STATUS_POLL_DELAY_MS } from "../sync-context" +import { applyGlobalSessionStatusSnapshot, useGlobalSessionStatusStore } from "../global-session-status" +import { useSessionOrderingStore } from "../session-ordering" +import { useSessionActivityTimingStore } from "../session-activity-timing" -const createStore = (status: SessionStatus): StoreApi => { +import { + maybePollStatusAfterMessageCompletion, + MESSAGE_COMPLETION_STATUS_POLL_DELAY_MS, + recoverInterruptedTurnAfterMessageLoad, +} from "../sync-context" + +const createStore = (status?: SessionStatus): StoreApi => { + const session_status: DirectoryStore["session_status"] = {} + if (status) session_status.ses_1 = status return create()((set) => ({ ...INITIAL_STATE, - session_status: { ses_1: status }, + session_status, patch: (partial) => set(partial), replace: (next) => set(next), })) } +// SAFETY: The recovery path reads only the identity, role, and completion time +// fields from this synthetic assistant message. +const unfinishedAssistant = { + id: "msg_1", + sessionID: "ses_1", + role: "assistant", + time: { created: 1 }, +} as Message + +// SAFETY: The recovery path reads only the tool discriminator and state fields +// from this synthetic part. +const runningTool = { + id: "part_1", + messageID: "msg_1", + sessionID: "ses_1", + type: "tool", + tool: "bash", + state: { status: "running", time: { start: 1 }, input: {} }, +} as Part + const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) /** Past the deferral, plus room for the background-network task chain. */ @@ -52,6 +85,8 @@ describe("maybePollStatusAfterMessageCompletion (issue OPE-193)", () => { beforeEach(() => { respondWithSnapshot = () => Promise.resolve({ ses_1: { type: "idle" } }) statusSnapshotCalls.length = 0 + runtimeKey = "test-runtime" + sdkIdentity = {} }) test("does not poll when the store believes the session is already idle", async () => { @@ -138,4 +173,55 @@ describe("maybePollStatusAfterMessageCompletion (issue OPE-193)", () => { expect(statusSnapshotCalls).toEqual(["/test/project", "/test/project"]) expect(store.getState().session_status?.ses_1?.type).toBe("idle") }) + + test("recovers an unfinished turn after reload when status was initially unknown", async () => { + const store = createStore() + store.getState().patch({ + message: { ses_1: [unfinishedAssistant] }, + part: { msg_1: [runningTool] }, + }) + + await recoverInterruptedTurnAfterMessageLoad("/test/project", store, "ses_1") + + expect(statusSnapshotCalls).toEqual(["/test/project"]) + expect(store.getState().session_status?.ses_1?.type).toBe("idle") + const message = store.getState().message.ses_1[0] + expect(message?.role).toBe("assistant") + if (message?.role === "assistant") expect(message.time.completed).toBeDefined() + const part = store.getState().part.msg_1[0] + expect(part?.type).toBe("tool") + if (part?.type === "tool") expect(part.state.status).toBe("error") + }) + + for (const change of ["runtime", "sdk", "request"] as const) { + test(`discards delayed recovery after ${change} ownership changes`, async () => { + const store = createStore() + store.getState().patch({ + message: { ses_1: [unfinishedAssistant] }, + part: { msg_1: [runningTool] }, + }) + const before = store.getState() + let resolveSnapshot: (snapshot: StatusSnapshot) => void = () => { throw new Error("Request not started") } + respondWithSnapshot = () => new Promise((resolve) => { resolveSnapshot = resolve }) + let stale = false + const recovery = recoverInterruptedTurnAfterMessageLoad("/test/project", store, "ses_1", () => stale) + expect(statusSnapshotCalls).toEqual(["/test/project"]) + + if (change === "runtime") runtimeKey = "runtime-b" + if (change === "sdk") sdkIdentity = {} + if (change === "request") stale = true + applyGlobalSessionStatusSnapshot("/test/project", { ses_new: { type: "busy" } }) + const statuses = useGlobalSessionStatusStore.getState() + const ordering = useSessionOrderingStore.getState() + const timing = useSessionActivityTimingStore.getState() + resolveSnapshot({ ses_old: { type: "busy" } }) + await recovery + + expect(store.getState()).toBe(before) + expect(useGlobalSessionStatusStore.getState()).toBe(statuses) + expect(useSessionOrderingStore.getState()).toBe(ordering) + expect(useSessionActivityTimingStore.getState()).toBe(timing) + }) + } + }) diff --git a/packages/ui/src/sync/__tests__/session-status-snapshot.test.ts b/packages/ui/src/sync/__tests__/session-status-snapshot.test.ts index 67d030c5..9b4854f4 100644 --- a/packages/ui/src/sync/__tests__/session-status-snapshot.test.ts +++ b/packages/ui/src/sync/__tests__/session-status-snapshot.test.ts @@ -86,6 +86,13 @@ describe("applySessionStatusSnapshot", () => { expect(changed).toBe(true) expect(store.getState().session_status.ses_a).toEqual({ type: "idle" }) }) + + test("seeds idle for a candidate with no previous status entry", () => { + const store = createDirectoryStore({ session_status: {} }) + const changed = applySessionStatusSnapshot(store, {} as StatusSnapshot, ["ses_a"], "authoritative") + expect(changed).toBe(true) + expect(store.getState().session_status.ses_a).toEqual({ type: "idle" }) + }) }) }) diff --git a/packages/ui/src/sync/bootstrap.test.ts b/packages/ui/src/sync/bootstrap.test.ts index eaa8661e..71cea60a 100644 --- a/packages/ui/src/sync/bootstrap.test.ts +++ b/packages/ui/src/sync/bootstrap.test.ts @@ -1,18 +1,22 @@ import { describe, expect, test } from "bun:test" -import type { OpencodeClient, Project } from "@opencode-ai/sdk/v2/client" +import type { OpencodeClient, Project, QuestionRequest } from "@opencode-ai/sdk/v2/client" import { bootstrapDirectory } from "./bootstrap" import { INITIAL_STATE, type State } from "./types" -const createSdk = (options?: { commandList?: () => Promise<{ data: unknown[] }> }) => ({ +const createSdk = (options?: { + commandList?: () => Promise<{ data: unknown[] }> + sessionStatus?: () => Promise<{ data: State['session_status'] }> + questionList?: () => Promise<{ data?: unknown[]; error?: unknown; response?: { status?: number } }> +}) => ({ project: { current: async () => ({ data: { id: "project-a" } }) }, config: { get: async () => ({ data: {} }) }, path: { get: async () => ({ data: { state: "", config: "", worktree: "/repo", directory: "/repo", home: "/home" } }) }, - session: { status: async () => ({ data: {} }) }, + session: { status: options?.sessionStatus ?? (async () => ({ data: {} })) }, command: { list: options?.commandList ?? (async () => ({ data: [] })) }, mcp: { status: async () => ({ data: {} }) }, lsp: { status: async () => ({ data: [] }) }, vcs: { get: async () => ({ data: { branch: "main" } }) }, - question: { list: async () => ({ data: [] }) }, + question: { list: options?.questionList ?? (async () => ({ data: [] })) }, permission: { list: async () => ({ data: [] }) }, }) as unknown as OpencodeClient @@ -65,6 +69,7 @@ describe("bootstrapDirectory", () => { expect(await bootstrapping).toBe("complete") expect(state.status).toBe("complete") + expect(state.sessionStatusReady).toBe(true) expect(deferredStarted).toBe(false) await new Promise((resolve) => setTimeout(resolve, 0)) expect(deferredStarted).toBe(true) @@ -108,4 +113,138 @@ describe("bootstrapDirectory", () => { expect(result).toBe("stale") expect(commits).toBe(0) }) + + test("a failed status request cannot grant idle authority even when bootstrap completes", async () => { + let state = createState() + const result = await bootstrapDirectory({ + directory: '/repo', + sdk: createSdk({ sessionStatus: async () => { throw new Error('status unavailable') } }), + getState: () => state, + set: (patch) => { state = { ...state, ...patch } }, + global: { config: {}, projects: [project] }, + loadSessions: async () => undefined, + }) + expect(result).toBe('complete') + expect(state.sessionStatusReady).toBe(undefined) + }) + + test("deferred phase merges fetched questions by session, replacing the pre-fetch record", async () => { + let state = createState() + const preExisting: QuestionRequest = { id: "que_1", sessionID: "ses_1", questions: [] } + const fetched: QuestionRequest[] = [ + { id: "que_2", sessionID: "ses_1", questions: [] }, + { + id: "que_1", + sessionID: "ses_1", + questions: [{ question: "updated?", header: "Build", options: [{ label: "Yes", description: "Go" }] }], + }, + ] + state = { ...state, question: { ses_1: [preExisting] } } + const sdk = createSdk({ questionList: async () => ({ data: fetched }) }) + + await bootstrapDirectory({ + directory: "/repo", + sdk, + getState: () => state, + set: (patch) => { state = { ...state, ...patch } }, + global: { config: {}, projects: [project] }, + loadSessions: async () => undefined, + }) + // Deferred phase runs on a setTimeout(0); give it a tick. + await new Promise((resolve) => setTimeout(resolve, 20)) + + // The fetched (sorted) records replace the pre-fetch snapshot entirely. + expect(state.question["ses_1"]?.map((q) => q.id)).toEqual(["que_1", "que_2"]) + expect(state.question["ses_1"]?.[0]?.questions).toEqual(fetched[1].questions) + }) + + test("deferred phase deletes a session's questions when they disappear and the signature is unchanged", async () => { + let state = createState() + const que1: QuestionRequest = { id: "que_1", sessionID: "ses_1", questions: [] } + const que3: QuestionRequest = { id: "que_3", sessionID: "ses_2", questions: [] } + state = { + ...state, + question: { + ses_1: [que1], + ses_2: [que3], + }, + } + const sdk = createSdk({ questionList: async () => ({ data: [] }) }) + + await bootstrapDirectory({ + directory: "/repo", + sdk, + getState: () => state, + set: (patch) => { state = { ...state, ...patch } }, + global: { config: {}, projects: [project] }, + loadSessions: async () => undefined, + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + + // Both sessions vanished from the fetched list and nothing changed in + // between, so the signature guard allows the delete. + expect(state.question).toEqual({}) + }) + + test("deferred phase preserves in-flight question changes when the signature changed (stale guard)", async () => { + let state = createState() + const que1: QuestionRequest = { id: "que_1", sessionID: "ses_1", questions: [] } + const que2: QuestionRequest = { id: "que_2", sessionID: "ses_1", questions: [] } + state = { ...state, question: { ses_1: [que1] } } + const sdk = createSdk({ + questionList: async () => { + // Simulate an event landing while the deferred fetch is in flight: + // the session gains a second question before the fetch resolves. + state = { ...state, question: { ...state.question, ses_1: [que1, que2] } } + return { data: [] } + }, + }) + + await bootstrapDirectory({ + directory: "/repo", + sdk, + getState: () => state, + set: (patch) => { state = { ...state, ...patch } }, + global: { config: {}, projects: [project] }, + loadSessions: async () => undefined, + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + + // The fetched list is empty, but the in-flight change altered the + // signature, so the disappearance must NOT be treated as authoritative. + expect(state.question["ses_1"]?.map((q) => q.id)).toEqual(["que_1", "que_2"]) + }) + + test("deferred phase retries a transient question.list failure and still merges", async () => { + let state = createState() + const que1: QuestionRequest = { id: "que_1", sessionID: "ses_1", questions: [] } + let calls = 0 + let resolveSecondCall!: () => void + const secondCall = new Promise((resolve) => { resolveSecondCall = resolve }) + const sdk = createSdk({ + questionList: async () => { + calls += 1 + if (calls === 1) { + return { error: { name: "ServerError", data: { message: "boom" } }, response: new Response(null, { status: 500 }) } + } + resolveSecondCall() + return { data: [que1] } + }, + }) + + await bootstrapDirectory({ + directory: "/repo", + sdk, + getState: () => state, + set: (patch) => { state = { ...state, ...patch } }, + global: { config: {}, projects: [project] }, + loadSessions: async () => undefined, + }) + // retry() backs off 500ms before the second attempt; wait for it. + await secondCall + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(calls).toBeGreaterThanOrEqual(2) + expect(state.question["ses_1"]?.map((q) => q.id)).toEqual(["que_1"]) + }) }) diff --git a/packages/ui/src/sync/bootstrap.ts b/packages/ui/src/sync/bootstrap.ts index 2622f607..0f8eab6a 100644 --- a/packages/ui/src/sync/bootstrap.ts +++ b/packages/ui/src/sync/bootstrap.ts @@ -172,7 +172,7 @@ export async function bootstrapDirectory(input: { if (next) commit({ project: next }) }), ), - retry(() => sdk.session.status().then((x) => commit({ session_status: unwrap(x, "session.status") }))), + retry(() => sdk.session.status().then((x) => commit({ session_status: unwrap(x, "session.status"), sessionStatusReady: true }))), ]) if (input.isStale?.()) return "stale" diff --git a/packages/ui/src/sync/event-reducer.ts b/packages/ui/src/sync/event-reducer.ts index 13057258..2ac42246 100644 --- a/packages/ui/src/sync/event-reducer.ts +++ b/packages/ui/src/sync/event-reducer.ts @@ -433,11 +433,13 @@ export function applyDirectoryEvent( : part } else { // Replace optimistic part (no sessionID) with server part of same type. - // Gate: only scan if the first part lacks sessionID (optimistic parts are - // always inserted first). Assistant messages never have optimistic parts, - // so this check is effectively free during streaming. - const hasOptimistic = next.length > 0 && !(next[0] as { sessionID?: string }).sessionID - const optimisticIndex = hasOptimistic && (part.type === "text" || part.type === "file") + // Every optimistic part is a candidate, not only the first one: the + // server echoes a just-sent message part by part, and after the text + // echo replaced the first slot the file echo still has to find the + // optimistic file behind it, or the attachment shows twice until the + // next page fetch. The scan runs only when a part with a NEW id + // arrives, which during assistant streaming is once per part. + const optimisticIndex = part.type === "text" || part.type === "file" ? next.findIndex((p) => p.type === part.type && !(p as { sessionID?: string }).sessionID) : -1 if (optimisticIndex >= 0) { diff --git a/packages/ui/src/sync/input-store.test.ts b/packages/ui/src/sync/input-store.test.ts index 5303c2d5..b486f516 100644 --- a/packages/ui/src/sync/input-store.test.ts +++ b/packages/ui/src/sync/input-store.test.ts @@ -50,6 +50,38 @@ const waitForReaderCount = async (count: number) => { const pngBytes = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) +describe("input-store composer restore", () => { + beforeEach(() => { + useInputStore.setState({ pendingComposerRestore: null }) + }) + + test("only the destination can consume a restore, and only once", () => { + const target = { runtimeKey: "runtime", directory: "/repo", sessionId: "fork" } + const pending = { target, text: "replay", files: [] } + useInputStore.setState({ pendingComposerRestore: pending }) + for (const identity of [ + null, + { ...target, sessionId: "source" }, + { ...target, directory: "/elsewhere" }, + { ...target, runtimeKey: "other-runtime" }, + ]) { + expect(useInputStore.getState().consumePendingComposerRestore(identity)).toBeNull() + expect(useInputStore.getState().pendingComposerRestore).toBe(pending) + } + expect(useInputStore.getState().consumePendingComposerRestore(target)).toBe(pending) + expect(useInputStore.getState().consumePendingComposerRestore(target)).toBeNull() + }) + + test("keeps ordinary pending text independent from fork restoration", () => { + const target = { runtimeKey: "runtime", directory: "/repo", sessionId: "fork" } + const pending = { target, text: "", files: [] } + useInputStore.setState({ pendingComposerRestore: pending }) + useInputStore.getState().setPendingInputText("ordinary insertion", "append") + expect(useInputStore.getState().consumePendingInputText()).toEqual({ text: "ordinary insertion", mode: "append" }) + expect(useInputStore.getState().consumePendingComposerRestore(target)).toBe(pending) + }) +}) + describe("input-store attachments", () => { beforeEach(() => { pendingReaders.length = 0 @@ -58,6 +90,7 @@ describe("input-store attachments", () => { pendingInputText: null, pendingInputMode: "replace", pendingSyntheticParts: null, + pendingBtwComposerRequest: null, activeEditorFile: null, }) useInputStore.getState().setAttachedFiles([]) @@ -371,3 +404,27 @@ describe("input-store attachments", () => { expect(useInputStore.getState().attachedFiles).toEqual([]) }) }) + +describe("input-store BTW composer requests", () => { + test("keeps the request scoped to its parent without changing the normal composer", () => { + useInputStore.setState({ + pendingInputText: "normal draft", + pendingInputMode: "replace", + pendingBtwComposerRequest: null, + attachedFiles: [], + }) + useInputStore.getState().requestBtwComposer({ + parentSessionId: "parent-1", + text: "> selected text", + }) + + expect(useInputStore.getState().consumePendingBtwComposerRequest("parent-2")).toBeNull() + expect(useInputStore.getState().pendingInputText).toBe("normal draft") + expect(useInputStore.getState().consumePendingBtwComposerRequest("parent-1")).toEqual({ + parentSessionId: "parent-1", + text: "> selected text", + }) + expect(useInputStore.getState().consumePendingBtwComposerRequest("parent-1")).toBeNull() + expect(useInputStore.getState().pendingInputText).toBe("normal draft") + }) +}) diff --git a/packages/ui/src/sync/input-store.ts b/packages/ui/src/sync/input-store.ts index be2d25e2..6f593929 100644 --- a/packages/ui/src/sync/input-store.ts +++ b/packages/ui/src/sync/input-store.ts @@ -7,6 +7,7 @@ import { create } from "zustand" import type { ContextPartMetadata } from '@/lib/messages/contextParts' import type { AttachedFile } from "@/stores/types/sessionTypes" import { prepareAttachmentFiles } from "./attachment-files" +import { getChatDraftIdentityKey, type ChatDraftIdentity } from "@/lib/chatDraftPersistence" const FILE_URI_PREFIX = "file://" const MAX_ATTACHMENT_PREPARATION_ATTEMPTS = 3 @@ -119,6 +120,11 @@ export type SyntheticContextPart = { metadata?: ContextPartMetadata } +type PendingBtwComposerRequest = { + parentSessionId: string + text: string +} + export type VSCodeActiveEditorFile = { filePath: string fileName: string @@ -128,6 +134,12 @@ export type VSCodeActiveEditorFile = { } export type InputState = { + pendingComposerRestore: { + target: ChatDraftIdentity + text: string + files: Array<{ url: string; mimeType: string; filename: string }> + } | null + consumePendingComposerRestore: (target: ChatDraftIdentity | null) => InputState["pendingComposerRestore"] pendingInputText: string | null pendingInputMode: "replace" | "append" | "append-inline" pendingSyntheticParts: SyntheticContextPart[] | null @@ -137,6 +149,7 @@ export type InputState = { * narrow layouts); consumed by ChatInput, which owns the command-aware submit. */ pendingPresetSubmit: { text: string; type: "command" | "skill" } | null + pendingBtwComposerRequest: PendingBtwComposerRequest | null attachedFiles: AttachedFile[] activeEditorFile: VSCodeActiveEditorFile | null @@ -144,6 +157,8 @@ export type InputState = { consumePendingInputText: () => { text: string; mode: "replace" | "append" | "append-inline" } | null requestPresetSubmit: (text: string, type: "command" | "skill") => void consumePendingPresetSubmit: () => { text: string; type: "command" | "skill" } | null + requestBtwComposer: (request: PendingBtwComposerRequest) => void + consumePendingBtwComposerRequest: (parentSessionId: string | null) => PendingBtwComposerRequest | null setPendingSyntheticParts: (parts: SyntheticContextPart[] | null) => void consumePendingSyntheticParts: () => SyntheticContextPart[] | null addAttachedFile: (file: File) => Promise @@ -158,10 +173,18 @@ export type InputState = { } export const useInputStore = create()((set, get) => ({ + pendingComposerRestore: null, + consumePendingComposerRestore: (target) => { + const pending = get().pendingComposerRestore + if (!pending || !target || getChatDraftIdentityKey(pending.target) !== getChatDraftIdentityKey(target)) return null + set({ pendingComposerRestore: null }) + return pending + }, pendingInputText: null, pendingInputMode: "replace", pendingSyntheticParts: null, pendingPresetSubmit: null, + pendingBtwComposerRequest: null, attachedFiles: [], activeEditorFile: null, @@ -184,6 +207,15 @@ export const useInputStore = create()((set, get) => ({ return pendingPresetSubmit }, + requestBtwComposer: (request) => set({ pendingBtwComposerRequest: request }), + + consumePendingBtwComposerRequest: (parentSessionId) => { + const request = get().pendingBtwComposerRequest + if (!request || request.parentSessionId !== parentSessionId) return null + set({ pendingBtwComposerRequest: null }) + return request + }, + setPendingSyntheticParts: (parts) => set({ pendingSyntheticParts: parts }), consumePendingSyntheticParts: () => { diff --git a/packages/ui/src/sync/materialization.ts b/packages/ui/src/sync/materialization.ts index 89a653d2..5f36b21b 100644 --- a/packages/ui/src/sync/materialization.ts +++ b/packages/ui/src/sync/materialization.ts @@ -98,6 +98,31 @@ function filterMaterializedParts(parts: Part[], skipPartTypes: ReadonlySet !!part?.id && !skipPartTypes.has(part.type)) } +function finalizeActiveToolsInCompletedMessage(message: Message, parts: Part[]): Part[] { + if (message.role !== "assistant" || message.time.completed === undefined) return parts + + const completedAt = message.time.completed + let reconciledParts = parts + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index] + if (part.type !== "tool" || !ACTIVE_TOOL_STATUSES.has(part.state.status)) continue + + const start = getPartStateTime(part)?.start ?? completedAt + if (reconciledParts === parts) reconciledParts = [...parts] + reconciledParts[index] = { + ...part, + state: { + ...part.state, + status: "error" as const, + error: "Interrupted", + time: { start, end: completedAt }, + }, + } + } + + return reconciledParts +} + function haveEquivalentPartSnapshots(left: Part[] | undefined, right: Part[]): boolean { // `undefined` means "parts never fetched", which is NOT equivalent to a // fetched-empty snapshot — the empty array must be committed so @@ -297,12 +322,13 @@ export function materializeSessionSnapshots( const isAssistant = record.info.role === "assistant" const existing = nextPartState[messageID] - const nextParts = mergeMaterializedParts( + const mergedParts = mergeMaterializedParts( existing, filterMaterializedParts(record.parts ?? [], skipPartTypes), skipPartTypes, isAssistant, ) + const nextParts = finalizeActiveToolsInCompletedMessage(record.info, mergedParts) // For non-assistant messages an empty snapshot keeps the old "absent" // representation; only assistant messages need the explicit [] marker // (getSessionMaterializationStatus checks only assistant messages). diff --git a/packages/ui/src/sync/message-queue-sync.ts b/packages/ui/src/sync/message-queue-sync.ts new file mode 100644 index 00000000..6fb37b2a --- /dev/null +++ b/packages/ui/src/sync/message-queue-sync.ts @@ -0,0 +1,15 @@ +import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents'; +import { getRuntimeKey } from '@/lib/runtime-switch'; +import { applyMessageQueueUpdatedEvent, useMessageQueueStore } from '@/stores/messageQueueStore'; + +/** Queue events use the control SSE stream even while OpenCode uses WS. */ +export const subscribeMessageQueueSync = (runtimeKey: string): (() => void) => ( + subscribeOpenchamberEvents((event) => { + if (runtimeKey !== getRuntimeKey()) return; + if (event.type === 'event-stream-ready') { + void useMessageQueueStore.getState().resync().catch(() => undefined); + } else if (event.type === 'openchamber:message-queue.updated') { + applyMessageQueueUpdatedEvent(event, runtimeKey); + } + }) +); diff --git a/packages/ui/src/sync/selection-store.ts b/packages/ui/src/sync/selection-store.ts index 6762ef05..8ca81cbd 100644 --- a/packages/ui/src/sync/selection-store.ts +++ b/packages/ui/src/sync/selection-store.ts @@ -29,6 +29,7 @@ export type SelectionState = { getSessionAgentSelection: (sessionId: string) => string | null saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void getAgentModelForSession: (sessionId: string, agentName: string) => { providerId: string; modelId: string } | null + clearSessionSelections: (sessionId: string) => void /** * `variant` is the effort chosen for this agent/model in this session: * a name, `null` for an explicit "Default" (send no effort), or `undefined` @@ -94,6 +95,20 @@ export const useSelectionStore = create()( getAgentModelForSession: (sessionId, agentName) => get().sessionAgentModelSelections.get(sessionId)?.get(agentName) ?? null, + clearSessionSelections: (sessionId) => set((state) => { + const hadVariant = agentModelVariantSelections.delete(sessionId) + if (!hadVariant && !state.sessionModelSelections.has(sessionId) + && !state.sessionAgentSelections.has(sessionId) + && !state.sessionAgentModelSelections.has(sessionId)) return state + const sessionModelSelections = new Map(state.sessionModelSelections) + const sessionAgentSelections = new Map(state.sessionAgentSelections) + const sessionAgentModelSelections = new Map(state.sessionAgentModelSelections) + sessionModelSelections.delete(sessionId) + sessionAgentSelections.delete(sessionId) + sessionAgentModelSelections.delete(sessionId) + return { sessionModelSelections, sessionAgentSelections, sessionAgentModelSelections } + }), + saveAgentModelVariantForSession: (sessionId, agentName, providerId, modelId, variant) => { const key = `${providerId}/${modelId}` const clears = variant === undefined @@ -118,10 +133,12 @@ export const useSelectionStore = create()( if (agentMap.size === 0) { agentModelVariantSelections.delete(sessionId) } + set((state) => ({ ...state })) return } modelMap.set(key, variant) + set((state) => ({ ...state })) }, getAgentModelVariantForSession: (sessionId, agentName, providerId, modelId) => { diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index a68f5e81..9d7d7ba0 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test, beforeEach, mock } from "bun:test" import type { PermissionRequest } from "@/types/permission" import type { QuestionRequest } from "@/types/question" +import type { InputState } from "./input-store" // Mock SDK client that records permission.reply / question.reply calls const replyCalls: Array<{ method: string; params: Record }> = [] @@ -18,6 +19,10 @@ const failingRevertSessionIds = new Set() const failingUnrevertSessionIds = new Set() let afterUnrevertCall: ((sessionId: string) => void) | null = null let sessionDeleteError: unknown | null = null +let sessionForkResult: Session | null = null +let sessionForkError: Error | null = null +let beforeSessionForkResolve: (() => void) | null = null +const selectedSessions: Array<{ sessionId: string | null; directoryHint?: string | null }> = [] let beforeSessionUpdateResolve: ((sessionId: string) => void) | null = null let beforeSessionDeleteResolve: ((sessionId: string) => void) | null = null let beforeControlPlaneMoveResolve: ((sessionId: string) => void) | null = null @@ -179,6 +184,13 @@ mock.module("@/lib/opencode/client", () => ({ replyCalls.push({ method: "session.messages", params: { sessionID: sessionId, directory } }) return Promise.resolve(sessionMessageRecords.get(sessionId) ?? []) }), + forkSession: mock(async (sessionId: string, messageId?: string, directory?: string | null): Promise => { + replyCalls.push({ method: "session.fork", params: { sessionID: sessionId, messageID: messageId, directory } }) + beforeSessionForkResolve?.() + if (sessionForkError) throw sessionForkError + if (!sessionForkResult) throw new Error("Missing fork session fixture") + return sessionForkResult + }), replyToPermission: mock((requestId: string, reply: string, options?: { directory?: string | null }) => { replyCalls.push({ method: "permission.reply", params: { requestID: requestId, reply, directory: options?.directory } }) return Promise.resolve(true) @@ -236,7 +248,9 @@ mock.module("./session-ui-store", () => ({ return null }, currentSessionId: null, - setCurrentSession: () => {}, + setCurrentSession: (sessionId: string | null, directoryHint?: string | null) => { + selectedSessions.push({ sessionId, directoryHint }) + }, setWorktreeMetadata: () => {}, setSessionDirectory: (sessionID: string, directory: string) => { movedSessionDirectories.push({ sessionID, directory }) @@ -246,15 +260,27 @@ mock.module("./session-ui-store", () => ({ })) // Mock useInputStore -const inputState = { +const inputState: Pick = { + pendingComposerRestore: null, pendingInputText: "", - pendingInputMode: "normal" as const, + pendingInputMode: "replace", attachedFiles: [], clearAttachedFiles: () => { inputState.attachedFiles = [] }, - addRestoredAttachment: (attachment: never) => { - inputState.attachedFiles = [...inputState.attachedFiles, attachment] + addRestoredAttachment: (attachment) => { + inputState.attachedFiles = [...inputState.attachedFiles, { + id: attachment.url, + file: new File([], attachment.filename, { type: attachment.mimeType }), + dataUrl: attachment.url, + mimeType: attachment.mimeType, + filename: attachment.filename, + size: 0, + source: "server", + }] }, } @@ -1112,231 +1138,34 @@ describe("session restore (unarchive)", () => { expect((globalUpsertedSessions[0] as SessionWithDirectory).directory).toBe(worktreeDirectory) }) - test("moves a restored missing-worktree subtree to its matching project directory without changing descendants or cached transcript state", async () => { + test("restores a missing-worktree session in place without relocating it", async () => { const missingWorktreeDirectory = "/projects/main/.worktrees/deleted-branch" - const destinationDirectory = "/projects/main" - const rootMessage = { - id: "message-root", - sessionID: "session-root", - role: "user", - time: { created: 10 }, - } as Message - const rootPart = { id: "part-root", messageID: rootMessage.id, type: "text", text: "root" } as Part - const childMessage = { - id: "message-child", - sessionID: "session-child", - role: "assistant", - time: { created: 11 }, - } as Message - const childPart = { id: "part-child", messageID: childMessage.id, type: "text", text: "child" } as Part - const rootSession = { - id: "session-root", - projectID: "project-main", - directory: missingWorktreeDirectory, - project: { worktree: destinationDirectory }, - time: { created: 1, archived: 2 }, - } as SessionWithDirectory - const childSession = { - id: "session-child", - parentID: "session-root", - projectID: "project-main", - directory: missingWorktreeDirectory, - project: { worktree: destinationDirectory }, - time: { created: 2, archived: 3 }, - } as SessionWithDirectory - globalArchivedSessions.push(rootSession, childSession) - openCodeProjects.push({ id: "project-main", worktree: destinationDirectory } as Project) - directoryAvailability.set(missingWorktreeDirectory, "missing") - sessionUpdateResultsById.set("session-root", { - ...rootSession, - time: { created: 1, updated: 1, archived: 0 }, - }) - sessionUpdateResultsById.set("session-child", { - ...childSession, - time: { created: 2, updated: 2, archived: 0 }, - }) - - const source = createStore({}, { - session: [rootSession, childSession], - sessionTotal: 2, - message: { - "session-root": [rootMessage], - "session-child": [childMessage], - }, - part: { - [rootMessage.id]: [rootPart], - [childMessage.id]: [childPart], - }, - }) - const destination = createStore({}) - const { unarchiveSession, setActionRefs } = await import("./session-actions") - setActionRefs( - mockSdk as unknown as OpencodeClient, - createChildStores([[missingWorktreeDirectory, source], [destinationDirectory, destination]]), - () => missingWorktreeDirectory, - ) - - expect(await unarchiveSession("session-root")).toBe(true) - expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([ - { - method: "controlPlane.moveSession", - params: { - sessionID: "session-root", - destination: { directory: destinationDirectory }, - moveChanges: false, - }, - }, - { - method: "controlPlane.moveSession", - params: { - sessionID: "session-child", - destination: { directory: destinationDirectory }, - moveChanges: false, - }, - }, - ]) - expect(source.getState().session).toEqual([]) - expect(destination.getState().session.map((session) => ({ - id: session.id, - parentID: (session as SessionWithDirectory).parentID ?? null, - directory: (session as SessionWithDirectory).directory ?? null, - }))).toEqual([ - { id: "session-root", parentID: null, directory: destinationDirectory }, - { id: "session-child", parentID: "session-root", directory: destinationDirectory }, - ]) - expect(destination.getState().message["session-root"]?.[0]?.id).toBe(rootMessage.id) - expect(destination.getState().message["session-child"]?.[0]?.id).toBe(childMessage.id) - expect(destination.getState().part[rootMessage.id]?.[0]?.id).toBe(rootPart.id) - expect(destination.getState().part[childMessage.id]?.[0]?.id).toBe(childPart.id) - expect(destination.getState().session.every((session) => !session.time?.archived)).toBe(true) - expect(registeredSessionDirectories).toEqual([ - { sessionID: "session-root", directory: destinationDirectory }, - { sessionID: "session-child", directory: destinationDirectory }, - ]) - expect(movedSessionDirectories).toEqual([ - { sessionID: "session-root", directory: destinationDirectory }, - { sessionID: "session-child", directory: destinationDirectory }, - ]) - expect(globalUpsertedSessions.map((session) => ({ - id: (session as SessionWithDirectory).id, - parentID: (session as SessionWithDirectory).parentID ?? null, - directory: (session as SessionWithDirectory).directory ?? null, - }))).toEqual([ - { id: "session-root", parentID: null, directory: destinationDirectory }, - { id: "session-child", parentID: "session-root", directory: destinationDirectory }, - ]) - }) - - test("restores missing-worktree descendants from the global cache when their directory store is unavailable", async () => { - const missingWorktreeDirectory = "/projects/main/.worktrees/deleted-branch" - const destinationDirectory = "/projects/main" - const rootSession = { - id: "session-root", - projectID: "proj_main", - directory: missingWorktreeDirectory, - project: { worktree: destinationDirectory }, - time: { created: 1, archived: 2 }, - } as SessionWithDirectory - const childSession = { - id: "session-child", - parentID: rootSession.id, - projectID: "proj_main", - directory: missingWorktreeDirectory, - project: { worktree: destinationDirectory }, - time: { created: 2, archived: 3 }, - } as SessionWithDirectory - globalArchivedSessions.push(rootSession, childSession) - openCodeProjects.push({ id: "proj_main", worktree: destinationDirectory } as Project) - directoryAvailability.set(missingWorktreeDirectory, "missing") - sessionUpdateResultsById.set("session-root", { ...rootSession, time: { created: 1, updated: 1, archived: 0 } }) - sessionUpdateResultsById.set("session-child", { ...childSession, time: { created: 2, updated: 2, archived: 0 } }) - - const destination = createStore({}) - const { unarchiveSession, setActionRefs } = await import("./session-actions") - setActionRefs( - mockSdk as unknown as OpencodeClient, - createChildStores([[destinationDirectory, destination]]), - () => missingWorktreeDirectory, - ) - - expect(await unarchiveSession(rootSession.id)).toBe(true) - expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession").map((call) => call.params.sessionID)) - .toEqual([rootSession.id, childSession.id]) - expect(destination.getState().session.map((session) => session.id)).toEqual([rootSession.id, childSession.id]) - expect(destination.getState().session.every((session) => !session.time?.archived)).toBe(true) - }) - - test("does not publish a missing-worktree move after the runtime changes during the control-plane request", async () => { - const missingWorktreeDirectory = "/projects/main/.worktrees/deleted-branch" - const destinationDirectory = "/projects/main" const session = { - id: "session-runtime-switch", - projectID: "proj_main", + id: "session-root", + projectID: "project-main", directory: missingWorktreeDirectory, - project: { worktree: destinationDirectory }, + project: { worktree: "/projects/main" }, time: { created: 1, archived: 2 }, } as SessionWithDirectory globalArchivedSessions.push(session) - openCodeProjects.push({ id: "proj_main", worktree: destinationDirectory } as Project) directoryAvailability.set(missingWorktreeDirectory, "missing") - sessionUpdateResultsById.set(session.id, { ...session, time: { created: 1, updated: 1, archived: 0 } }) - beforeControlPlaneMoveResolve = () => { - runtimeKey = "new-runtime" - } + sessionUpdateResultsById.set("session-root", { + ...session, + time: { created: 1, updated: 1, archived: 0 }, + }) - const destination = createStore({}) + const store = createStore({}) const { unarchiveSession, setActionRefs } = await import("./session-actions") - setActionRefs( - mockSdk as unknown as OpencodeClient, - createChildStores([[destinationDirectory, destination]]), - () => missingWorktreeDirectory, - ) - - expect(await unarchiveSession(session.id)).toBe(false) - expect(destination.getState().session).toEqual([]) - expect(registeredSessionDirectories).toEqual([]) - expect(globalUpsertedSessions).toEqual([]) - }) - - test("re-moves a root left stranded in a missing worktree after a partial restore", async () => { - const missingWorktreeDirectory = "/projects/main/.worktrees/deleted-branch" - const destinationDirectory = "/projects/main" - // A previous restore attempt already unarchived the root (server echo made - // it active), then the control-plane move failed, leaving it stranded in the - // deleted worktree. The retry must still relocate it, not report a false - // success because the root is no longer archived. - const strandedRoot = { - id: "session-root", - projectID: "proj_main", - directory: missingWorktreeDirectory, - project: { worktree: destinationDirectory }, - time: { created: 1, archived: 0 }, - } as SessionWithDirectory - globalActiveSessions.push(strandedRoot) - openCodeProjects.push({ id: "proj_main", worktree: destinationDirectory } as Project) - directoryAvailability.set(missingWorktreeDirectory, "missing") - sessionUpdateResultsById.set("session-root", { ...strandedRoot, time: { created: 1, updated: 1, archived: 0 } }) - - const destination = createStore({}) - const { unarchiveSession, setActionRefs } = await import("./session-actions") - setActionRefs( - mockSdk as unknown as OpencodeClient, - createChildStores([[destinationDirectory, destination]]), - () => missingWorktreeDirectory, - ) + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([[missingWorktreeDirectory, store]]), () => missingWorktreeDirectory) expect(await unarchiveSession("session-root")).toBe(true) - expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([ - { - method: "controlPlane.moveSession", - params: { - sessionID: "session-root", - destination: { directory: destinationDirectory }, - moveChanges: false, - }, - }, + expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([]) + expect(store.getState().session).toEqual([]) + expect(registeredSessionDirectories).toEqual([{ sessionID: "session-root", directory: missingWorktreeDirectory }]) + expect(movedSessionDirectories).toEqual([]) + expect(globalUpsertedSessions).toEqual([ + { ...session, time: { created: 1, updated: 1, archived: 0 } }, ]) - expect(destination.getState().session.map((session) => session.id)).toEqual(["session-root"]) }) test("does not move a restored project session that is not a worktree", async () => { @@ -2132,6 +1961,197 @@ describe("respondToPermission passes directory", () => { }) }) +describe("forkFromMessage composer restore", () => { + const sourceSession: Session = { + id: "session-a", + slug: "source-session", + projectID: "project-a", + directory: "/test/project", + title: "Source session", + version: "1", + time: { created: 1, updated: 1 }, + } + const forkedSession: Session = { ...sourceSession, id: "session-fork", slug: "forked-session" } + const textPart: Part = { + id: "part-text", + sessionID: sourceSession.id, + messageID: "message-fork", + type: "text", + text: "Replay this prompt", + } + const filePart: Part = { + id: "part-file", + sessionID: sourceSession.id, + messageID: "message-fork", + type: "file", + url: "data:image/png;base64,aW1hZ2U=", + mime: "image/png", + filename: "screenshot.png", + } + const restoredFile = { url: filePart.url, mimeType: filePart.mime, filename: filePart.filename } + + beforeEach(() => { + replyCalls.length = 0 + selectedSessions.length = 0 + runtimeKey = "fork-runtime" + sessionForkResult = forkedSession + sessionForkError = null + beforeSessionForkResolve = null + inputState.pendingComposerRestore = null + inputState.pendingInputText = "Keep the source draft" + inputState.pendingInputMode = "append" + inputState.attachedFiles = [{ + id: "source-attachment", + file: new File(["source"], "source.txt", { type: "text/plain" }), + dataUrl: "data:text/plain;base64,c291cmNl", + mimeType: "text/plain", + filename: "source.txt", + size: 6, + source: "local", + }] + }) + + for (const directory of ["/test/project", "/canonical/project"]) { + test(`stages the replay for the returned session in ${directory} without changing the source composer`, async () => { + sessionForkResult = { ...forkedSession, directory } + const source = createStore({}, { + session: [sourceSession], + part: { "message-fork": [textPart, filePart] }, + }) + const sourceInput = { ...inputState } + const { forkFromMessage, setActionRefs } = await import("./session-actions") + setActionRefs(actionSdk, createChildStores([[sourceSession.directory, source]]), () => "/other/project") + + await forkFromMessage(sourceSession.id, "message-fork") + + expect(replyCalls).toEqual([{ + method: "session.fork", + params: { sessionID: sourceSession.id, messageID: "message-fork", directory: sourceSession.directory }, + }]) + expect(inputState.pendingComposerRestore).toEqual({ + target: { runtimeKey: "fork-runtime", directory, sessionId: forkedSession.id }, + text: "Replay this prompt", + files: [restoredFile], + }) + expect(inputState.pendingInputText).toBe(sourceInput.pendingInputText) + expect(inputState.pendingInputMode).toBe(sourceInput.pendingInputMode) + expect(inputState.attachedFiles).toBe(sourceInput.attachedFiles) + expect(inputState.attachedFiles).toHaveLength(1) + expect(selectedSessions).toEqual([{ sessionId: forkedSession.id, directoryHint: directory }]) + expect(source.getState().session).toEqual([sourceSession, sessionForkResult]) + }) + } + + test("uses the returned project worktree when the fork has no directory", async () => { + const forkWithProject: Session & { project: { worktree: string } } = { + ...forkedSession, directory: "", project: { worktree: "/canonical/worktree" }, + } + sessionForkResult = forkWithProject + const source = createStore({}, { session: [sourceSession], part: { "message-fork": [textPart] } }) + const { forkFromMessage, setActionRefs } = await import("./session-actions") + setActionRefs(actionSdk, createChildStores([[sourceSession.directory, source]]), () => sourceSession.directory) + + await forkFromMessage(sourceSession.id, "message-fork") + + expect(inputState.pendingComposerRestore?.target.directory).toBe("/canonical/worktree") + expect(selectedSessions).toEqual([{ sessionId: forkedSession.id, directoryHint: "/canonical/worktree" }]) + }) + + test("stages a file-only prompt with empty text without replacing source attachments", async () => { + const source = createStore({}, { + session: [sourceSession], + part: { "message-fork": [filePart] }, + }) + const sourceInput = { ...inputState } + const { forkFromMessage, setActionRefs } = await import("./session-actions") + setActionRefs(actionSdk, createChildStores([[sourceSession.directory, source]]), () => sourceSession.directory) + + await forkFromMessage(sourceSession.id, "message-fork") + + expect(inputState.pendingComposerRestore).toEqual({ + target: { runtimeKey: "fork-runtime", directory: sourceSession.directory, sessionId: forkedSession.id }, + text: "", + files: [restoredFile], + }) + expect(inputState.pendingInputText).toBe(sourceInput.pendingInputText) + expect(inputState.attachedFiles).toBe(sourceInput.attachedFiles) + expect(selectedSessions).toEqual([{ sessionId: forkedSession.id, directoryHint: sourceSession.directory }]) + }) + + test("excludes synthetic text and files from the staged replay", async () => { + const syntheticFile: Part & { synthetic: boolean } = { + ...filePart, + id: "part-synthetic-file", + url: "file:///test/project/generated.txt", + mime: "text/plain", + filename: "generated.txt", + synthetic: true, + } + const source = createStore({}, { + session: [sourceSession], + part: { "message-fork": [ + { ...textPart, id: "part-synthetic-text", text: "Generated file contents", synthetic: true }, + textPart, + syntheticFile, + filePart, + ] }, + }) + const { forkFromMessage, setActionRefs } = await import("./session-actions") + setActionRefs(actionSdk, createChildStores([[sourceSession.directory, source]]), () => sourceSession.directory) + + await forkFromMessage(sourceSession.id, "message-fork") + + expect(inputState.pendingComposerRestore).toEqual({ + target: { runtimeKey: "fork-runtime", directory: sourceSession.directory, sessionId: forkedSession.id }, + text: "Replay this prompt", + files: [restoredFile], + }) + }) + + test("leaves input, selection, and sessions unchanged when the fork fails", async () => { + sessionForkError = new Error("fork failed") + const source = createStore({}, { + session: [sourceSession], + part: { "message-fork": [textPart, filePart] }, + }) + const sourceState = source.getState() + const sourceInput = { ...inputState } + const { forkFromMessage, setActionRefs } = await import("./session-actions") + setActionRefs(actionSdk, createChildStores([[sourceSession.directory, source]]), () => sourceSession.directory) + + await expect(forkFromMessage(sourceSession.id, "message-fork")).rejects.toThrow("fork failed") + + expect(inputState).toEqual(sourceInput) + expect(inputState.attachedFiles).toBe(sourceInput.attachedFiles) + expect(selectedSessions).toEqual([]) + expect(source.getState()).toBe(sourceState) + }) + + test("does not select, mutate, or stage a fork resolved after the runtime changes", async () => { + beforeSessionForkResolve = () => { runtimeKey = "other-runtime" } + const source = createStore({}, { + session: [sourceSession], + part: { "message-fork": [textPart, filePart] }, + }) + const sourceState = source.getState() + const sourceInput = { ...inputState } + const { forkFromMessage, setActionRefs } = await import("./session-actions") + setActionRefs(actionSdk, createChildStores([[sourceSession.directory, source]]), () => sourceSession.directory) + + await forkFromMessage(sourceSession.id, "message-fork") + + expect(replyCalls).toEqual([{ + method: "session.fork", + params: { sessionID: sourceSession.id, messageID: "message-fork", directory: sourceSession.directory }, + }]) + expect(runtimeKey).toBe("other-runtime") + expect(inputState).toEqual(sourceInput) + expect(inputState.attachedFiles).toBe(sourceInput.attachedFiles) + expect(selectedSessions).toEqual([]) + expect(source.getState()).toBe(sourceState) + }) +}) + describe("revertToMessage passes session directory", () => { beforeEach(() => { replyCalls.length = 0 @@ -2141,7 +2161,7 @@ describe("revertToMessage passes session directory", () => { failingRevertSessionIds.clear() Object.assign(inputState, { pendingInputText: "previous draft", - pendingInputMode: "normal" as const, + pendingInputMode: "replace", attachedFiles: [], }) }) @@ -3019,148 +3039,3 @@ describe("dismissOpenPermissionsForSession", () => { } }) }) - -describe("relocateSessionFromMissingDirectory", () => { - const missingWorktree = "/projects/main/.worktrees/gone" - const projectDirectory = "/projects/main" - const worktreeSession = (id: string, parentID: string | null, directory = missingWorktree, archived = 0): Session & { project: { worktree: string } } => ({ - id, - slug: id, - projectID: "project-main", - directory, - title: id, - version: "1", - project: { worktree: projectDirectory }, - time: { created: 1, updated: 1, archived }, - parentID: parentID ?? undefined, - }) - const mainProject: Project = { id: "project-main", worktree: projectDirectory, time: { created: 1, updated: 1 }, sandboxes: [] } - const stores = () => createChildStores([[missingWorktree, createStore({})], [projectDirectory, createStore({})]]) - const movesOf = () => replyCalls - .filter((call) => call.method === "controlPlane.moveSession") - .map((call) => ({ sessionID: call.params.sessionID, destination: call.params.destination, moveChanges: call.params.moveChanges })) - - beforeEach(() => { - replyCalls.length = 0 - registeredSessionDirectories.length = 0 - movedSessionDirectories.length = 0 - globalUpsertedSessions.length = 0 - globalActiveSessions = [] - globalArchivedSessions.length = 0 - openCodeProjects.length = 0 - directoryAvailability.clear() - controlPlaneMoveErrorsById.clear() - beforeDirectoryAvailabilityResolve = null - runtimeKey = "default-runtime" - }) - - test("moves the whole stranded subtree, root first, to the project directory without carrying changes", async () => { - const root = worktreeSession("root", null) - const child = worktreeSession("child", "root") - const archivedChild = worktreeSession("archived-child", "root", missingWorktree, 42) - const elsewhere = worktreeSession("elsewhere", "root", projectDirectory) - globalActiveSessions = [root, child, elsewhere] - globalArchivedSessions.push(archivedChild) - openCodeProjects.push(mainProject) - directoryAvailability.set(missingWorktree, "missing") - const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") - setActionRefs(actionSdk, stores(), () => missingWorktree) - - const result = await relocateSessionFromMissingDirectory("root") - - expect(result).toEqual({ - status: "moved", - sourceDirectory: missingWorktree, - destinationDirectory: projectDirectory, - movedSessionIds: ["root", "child", "archived-child"], - }) - expect(movesOf()).toEqual([ - { sessionID: "root", destination: { directory: projectDirectory }, moveChanges: false }, - { sessionID: "child", destination: { directory: projectDirectory }, moveChanges: false }, - { sessionID: "archived-child", destination: { directory: projectDirectory }, moveChanges: false }, - ]) - expect(movedSessionDirectories).toEqual([ - { sessionID: "root", directory: projectDirectory }, - { sessionID: "child", directory: projectDirectory }, - { sessionID: "archived-child", directory: projectDirectory }, - ]) - }) - - for (const availability of ["available", "unknown"] as const) { - test(`leaves the session alone when its directory is ${availability}`, async () => { - globalActiveSessions = [worktreeSession("root", null)] - openCodeProjects.push(mainProject) - directoryAvailability.set(missingWorktree, availability) - const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") - setActionRefs(actionSdk, stores(), () => missingWorktree) - - expect(await relocateSessionFromMissingDirectory("root")).toEqual({ status: "unchanged" }) - expect(movesOf()).toEqual([]) - }) - } - - test("leaves a session that already lives in its project directory alone", async () => { - globalActiveSessions = [worktreeSession("root", null, projectDirectory)] - openCodeProjects.push(mainProject) - directoryAvailability.set(projectDirectory, "missing") - const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") - setActionRefs(actionSdk, stores(), () => projectDirectory) - - expect(await relocateSessionFromMissingDirectory("root")).toEqual({ status: "unchanged" }) - expect(movesOf()).toEqual([]) - }) - - test("never relocates to the filesystem root OpenCode reports for its global project", async () => { - const chatDirectory = "/Users/tester/.config/openchamber/chats/2026-09-05/session-gone" - const chat = { ...worktreeSession("chat", null, chatDirectory), projectID: "global", project: { worktree: "/" } } - globalActiveSessions = [chat] - openCodeProjects.push({ id: "global", worktree: "/", time: { created: 1, updated: 1 }, sandboxes: [] }) - directoryAvailability.set(chatDirectory, "missing") - const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") - setActionRefs(actionSdk, stores(), () => chatDirectory) - - expect(await relocateSessionFromMissingDirectory("chat")).toEqual({ status: "unchanged" }) - expect(movesOf()).toEqual([]) - }) - - test("leaves the session alone when OpenCode knows no project for it", async () => { - globalActiveSessions = [worktreeSession("root", null)] - directoryAvailability.set(missingWorktree, "missing") - const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") - setActionRefs(actionSdk, stores(), () => missingWorktree) - - expect(await relocateSessionFromMissingDirectory("root")).toEqual({ status: "unchanged" }) - expect(movesOf()).toEqual([]) - }) - - test("reports the sessions already moved when a descendant move fails", async () => { - globalActiveSessions = [worktreeSession("root", null), worktreeSession("child", "root")] - openCodeProjects.push(mainProject) - directoryAvailability.set(missingWorktree, "missing") - controlPlaneMoveErrorsById.set("child", new Error("destination busy")) - const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") - setActionRefs(actionSdk, stores(), () => missingWorktree) - - const result = await relocateSessionFromMissingDirectory("root") - - expect(result.status).toBe("failed") - expect(result.status === "failed" ? result.movedSessionIds : null).toEqual(["root"]) - expect(movedSessionDirectories).toEqual([{ sessionID: "root", directory: projectDirectory }]) - }) - - test("publishes nothing when the runtime changes while the directory is being probed", async () => { - globalActiveSessions = [worktreeSession("root", null)] - openCodeProjects.push(mainProject) - directoryAvailability.set(missingWorktree, "missing") - const { switchRuntimeEndpoint } = await import("../lib/runtime-switch") - beforeDirectoryAvailabilityResolve = () => { - switchRuntimeEndpoint({ apiBaseUrl: "http://other.test", runtimeKey: "other-runtime" }) - } - const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") - setActionRefs(actionSdk, stores(), () => missingWorktree) - - expect(await relocateSessionFromMissingDirectory("root")).toEqual({ status: "stale" }) - expect(movesOf()).toEqual([]) - expect(movedSessionDirectories).toEqual([]) - }) -}) diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index c170425b..4fb1f807 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -3,7 +3,7 @@ * Replaces the action methods from the old useSessionStore. */ -import type { OpencodeClient, Session, Message, Part } from "@opencode-ai/sdk/v2/client" +import type { FilePart, OpencodeClient, Session, Message, Part } from "@opencode-ai/sdk/v2/client" import { Binary } from "./binary" import { useSessionUIStore } from "./session-ui-store" import { useInputStore } from "./input-store" @@ -43,6 +43,7 @@ import { normalizePath } from "@/lib/pathNormalization" import { mergeMessages } from "./optimistic" import { messagesBefore, messagesFrom } from "./message-ordering" import { deleteChatDirectory } from "@/lib/chatDirectories" +import { createChatDraftIdentity } from "@/lib/chatDraftPersistence" const MESSAGE_REFETCH_LIMIT = 100 const SEND_CONFIRMATION_REFETCH_LIMIT = 30 @@ -890,6 +891,7 @@ export async function createSession( metadata?: Record, selectionTransition?: "submitted-draft", ): Promise { + const runtimeKey = getRuntimeKey() try { // Capture the effective directory used for session creation so we can fall // back to it when the server response omits the `directory` field. @@ -903,11 +905,22 @@ export async function createSession( metadata, }, effectiveDirectory) + if (getRuntimeKey() !== runtimeKey) return null const sessionDirectory = (session as { directory?: string | null }).directory ?? effectiveDirectory ?? null // Pre-populate routing index so SSE events arriving before session.created // can be routed to the correct child store if (sessionDirectory) { registerSessionDirectory(session.id, sessionDirectory) + const store = _childStores?.ensureChild(sessionDirectory, { bootstrap: false }) + if (store) { + const current = store.getState().session + const existing = Binary.search(current, session.id, (candidate) => candidate.id) + // An event may have published newer metadata before the create response. + if (!existing.found) { + store.setState({ session: [...current.slice(0, existing.index), session, ...current.slice(existing.index)] }) + } + } + getImperativeSessionMessageLoader()?.initializeCreatedSession({ directory: sessionDirectory, sessionID: session.id }) } useSessionUIStore.getState().setCurrentSession(session.id, sessionDirectory, selectionTransition) useSessionUIStore.getState().markSessionAsOpenChamberCreated(session.id) @@ -1531,134 +1544,6 @@ function commitArchivedSessions(sessions: Session[], directory: string): void { */ const UNARCHIVED_TIMESTAMP = 0 -async function getProjectPrimaryDirectory(projectID?: string): Promise { - if (!projectID) return null - - try { - const result = await sdk().project.list() - const projects = assertSdkData(result, "project.list") - const projectDirectory = projects.find((candidate) => candidate.id === projectID)?.worktree?.trim() - return projectDirectory ? normalizePath(projectDirectory) ?? projectDirectory : null - } catch { - return null - } -} - -type MissingWorktreeRelocation = { sourceDirectory: string; destinationDirectory: string } - -const isFilesystemRoot = (directory: string): boolean => directory === "/" || /^[A-Za-z]:\/?$/.test(directory) - -async function resolveMissingWorktreeRelocation( - session: Session & { project?: { worktree?: string | null } | null }, -): Promise { - const ownedDirectory = resolveSessionOwnedDirectory(session) - const projectWorktree = session.project?.worktree?.trim() - if (!ownedDirectory || !projectWorktree) return null - - let availability: Awaited> - try { - availability = await opencodeClient.getDirectoryAvailability(ownedDirectory) - } catch { - return null - } - if (availability !== "missing") return null - - const projectDirectory = await getProjectPrimaryDirectory(session.projectID) - if (!projectDirectory || projectDirectory === ownedDirectory) return null - // OpenCode files a directory outside any Git repository under its global - // project, whose "worktree" is the filesystem root. That is not a home for - // a session; a managed chat whose directory vanished stays where it is. - if (isFilesystemRoot(projectDirectory)) return null - return { sourceDirectory: ownedDirectory, destinationDirectory: projectDirectory } -} - -type OwnedSubtreeEntry = { session: Session; ownedDirectory: string | null } - -/** - * The root's subtree as the global cache knows it, root first. Drawn from the - * global cache rather than a live child store so archived descendants that - * never materialized in a directory store are still included. - */ -function getGlobalSubtree(rootSession: Session): OwnedSubtreeEntry[] { - const global = useGlobalSessionsStore.getState() - const sessionsById = new Map() - - for (const session of [...global.activeSessions, ...global.archivedSessions]) { - const current = sessionsById.get(session.id) - if (!current || Boolean(session.time?.archived)) sessionsById.set(session.id, session) - } - sessionsById.set(rootSession.id, rootSession) - - return [...computeSubtreeIds([...sessionsById.values()], rootSession.id)] - .map((id) => sessionsById.get(id)) - .filter((session): session is Session => Boolean(session)) - .map((session) => ({ session, ownedDirectory: resolveSessionOwnedDirectory(session) })) -} - -function getRestoreSubtree(rootSession: Session, sourceDirectory: string): Array<{ session: Session; sourceDirectory: string }> { - return getGlobalSubtree(rootSession) - // Keep a node while it is still archived or still stranded in the - // confirmed-missing worktree. The second clause matters on retry: a prior - // attempt may have already unarchived the root (server echo made it active) - // but failed to move it, so filtering on `archived` alone would drop the - // root and report a false success while it stays in the deleted worktree. - .filter((entry) => Boolean(entry.session.time?.archived) || entry.ownedDirectory === sourceDirectory) - .map((entry) => (entry.ownedDirectory ? { session: entry.session, sourceDirectory: entry.ownedDirectory } : null)) - .filter((entry): entry is { session: Session; sourceDirectory: string } => entry !== null) -} - -export type MissingDirectoryRelocation = - /** The session's directory is gone; its subtree now lives in the project directory. */ - | { status: "moved"; sourceDirectory: string; destinationDirectory: string; movedSessionIds: string[] } - /** The directory is available, its state is unknown, or the session has no project to move to. */ - | { status: "unchanged" } - /** The runtime changed while the relocation was in flight; nothing local was published. */ - | { status: "stale" } - /** A control-plane move failed; `movedSessionIds` already live in the destination. */ - | { status: "failed"; movedSessionIds: string[]; error: unknown } - -/** - * Move an active session whose worktree no longer exists into its project's - * primary directory. - * - * Same gate as the archived-session restore fallback: only a server-confirmed - * `missing` directory qualifies, the destination is the OpenCode project the - * session belongs to, and `available`, `unknown`, probe failures, and sessions - * without a project leave everything untouched. Every session of the root's - * subtree still stranded in that directory moves with it, root first, so the - * session the user is looking at is usable even if a descendant move fails. - * Moves carry no changes (`moveChanges: false`): the directory is gone, so - * there is nothing to carry. - */ -export async function relocateSessionFromMissingDirectory( - sessionId: string, - expectedRuntimeKey = getRuntimeKey(), -): Promise { - if (isStaleRuntime(expectedRuntimeKey)) return { status: "stale" } - const rootSession = getGlobalSessionSnapshot(sessionId) - if (!rootSession) return { status: "unchanged" } - - const relocation = await resolveMissingWorktreeRelocation(rootSession) - if (isStaleRuntime(expectedRuntimeKey)) return { status: "stale" } - if (!relocation) return { status: "unchanged" } - - const stranded = getGlobalSubtree(rootSession) - .filter((entry) => entry.ownedDirectory === relocation.sourceDirectory) - .map((entry) => entry.session) - const movedSessionIds: string[] = [] - for (const session of stranded) { - try { - await moveSessionToDirectory(session, relocation.sourceDirectory, relocation.destinationDirectory, false, expectedRuntimeKey) - } catch (error) { - console.error("[session-actions] relocateSessionFromMissingDirectory failed", error) - return { status: "failed", movedSessionIds, error } - } - if (isStaleRuntime(expectedRuntimeKey)) return { status: "stale" } - movedSessionIds.push(session.id) - } - return { status: "moved", ...relocation, movedSessionIds } -} - /** * Restore one archived session back to the active list. * @@ -1671,34 +1556,8 @@ export async function relocateSessionFromMissingDirectory( */ export async function unarchiveSession(sessionId: string, expectedRuntimeKey = getRuntimeKey()): Promise { if (isStaleRuntime(expectedRuntimeKey)) return false - const globalSession = getGlobalSessionSnapshot(sessionId) const sessionDirectory = getSessionDirectory(sessionId) try { - const restore = globalSession - ? await resolveMissingWorktreeRelocation(globalSession) - : null - if (isStaleRuntime(expectedRuntimeKey)) return false - - if (globalSession && restore) { - for (const { session, sourceDirectory } of getRestoreSubtree(globalSession, restore.sourceDirectory)) { - const restored = await opencodeClient.updateSession( - session.id, - { time: { archived: UNARCHIVED_TIMESTAMP } }, - sourceDirectory, - ) - if (isStaleRuntime(expectedRuntimeKey)) return false - if (!restored) { - throw new Error("session.update failed: server did not return the restored session") - } - if (restored.time?.archived) { - throw new Error("session.update failed: server kept the session archived") - } - await moveSessionToDirectory(restored, sourceDirectory, restore.destinationDirectory, false, expectedRuntimeKey) - if (isStaleRuntime(expectedRuntimeKey)) return false - } - return true - } - const restored = await opencodeClient.updateSession(sessionId, { time: { archived: UNARCHIVED_TIMESTAMP } }, sessionDirectory) if (isStaleRuntime(expectedRuntimeKey)) return false if (!restored) { @@ -1754,9 +1613,15 @@ export async function unarchiveSessions( return { restoredIds, failedIds } } -export async function updateSessionTitle(sessionId: string, title: string): Promise { - const sessionDirectory = getSessionDirectory(sessionId) +export async function updateSessionTitle( + sessionId: string, + title: string, + options?: { directory?: string | null; expectedRuntimeKey?: string }, +): Promise { + if (isStaleRuntime(options?.expectedRuntimeKey)) throw new Error("runtime changed") + const sessionDirectory = options?.directory ?? getSessionDirectory(sessionId) const session = await opencodeClient.updateSession(sessionId, { title }, sessionDirectory) + if (isStaleRuntime(options?.expectedRuntimeKey)) throw new Error("runtime changed") useGlobalSessionsStore.getState().upsertSession(session) mirrorSessionIntoLiveStores(session, sessionDirectory) } @@ -2579,9 +2444,10 @@ export async function unrevertSession(sessionId: string): Promise { * 1. Extract text from the message for input restoration * 2. Call the runtime fork endpoint * 3. Insert the new session into the child store (so sidebar updates immediately) - * 4. Switch to new session and set pending input text + * 4. Switch to the new session and stage its composer replay */ export async function forkFromMessage(sessionId: string, messageId: string): Promise { + const expectedRuntimeKey = getRuntimeKey() const { store, directory } = dirStoreForSession(sessionId) const state = store.getState() @@ -2596,9 +2462,12 @@ export async function forkFromMessage(sessionId: string, messageId: string): Pro .map((p: Part) => ((p as Record).text as string) || ((p as Record).content as string) || "") .join("\n") .trim() - const fileParts = parts.filter((p) => p.type === "file" && !isSyntheticPart(p)) as Array> + const fileParts = parts.filter((part): part is FilePart => part.type === "file" && !isSyntheticPart(part)) const forkedSession = await opencodeClient.forkSession(sessionId, messageId, directory) + if (isStaleRuntime(expectedRuntimeKey)) return + const target = createChatDraftIdentity(expectedRuntimeKey, resolveSessionOwnedDirectory(forkedSession) ?? directory, forkedSession.id) + if (!target) throw new Error("Forked session has no composer directory") // Insert new session into child store so sidebar updates immediately const current = store.getState() @@ -2610,22 +2479,24 @@ export async function forkFromMessage(sessionId: string, messageId: string): Pro } // Switch to new session - useSessionUIStore.getState().setCurrentSession(forkedSession.id) + useSessionUIStore.getState().setCurrentSession(forkedSession.id, target.directory) - // Restore forked message text and file attachments to input - if (messageText) { - useInputStore.setState({ - pendingInputText: messageText, - pendingInputMode: "replace" as const, - }) - } - // Clear existing attachments and restore file parts from the forked message. - restoreFilePartsToInput(fileParts) + // Navigation is deferred in the chat column. Leave the source composer alone + // until the rendered draft identity matches the fork, including for file-only prompts. + useInputStore.setState({ + pendingComposerRestore: { + target, + text: messageText, + files: fileParts.filter((part) => part.url).map((part) => ({ + url: part.url, + mimeType: part.mime, + filename: part.filename ?? "attachment", + })), + }, + }) // The forked session is a fresh draft target, so the attached context of the // forked message follows the text into its composer. - if (directory) { - restoreContextPartsToInput(parts, { directory, sessionKey: forkedSession.id }) - } + restoreContextPartsToInput(parts, { directory: target.directory, sessionKey: forkedSession.id }) } export async function fetchMessagesForSession(sessionID: string, directory?: string | null): Promise { diff --git a/packages/ui/src/sync/session-creation-loading.test.ts b/packages/ui/src/sync/session-creation-loading.test.ts new file mode 100644 index 00000000..845281f5 --- /dev/null +++ b/packages/ui/src/sync/session-creation-loading.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { createOpencodeClient, type Session } from "@opencode-ai/sdk/v2/client" +import { opencodeClient } from "@/lib/opencode/client" +import { getRuntimeKey } from "@/lib/runtime-switch" +import { ChildStoreManager } from "./child-store" +import { createSession, setActionRefs } from "./session-actions" +import { SessionMessageLoader, setImperativeSessionMessageLoader } from "./session-message-loader" +import { useSessionUIStore } from "./session-ui-store" + +const originalCreateSession = opencodeClient.createSession +const originalDirectory = opencodeClient.getDirectory() +const originalSelection = useSessionUIStore.getState() +let childStores: ChildStoreManager +let loader: SessionMessageLoader +let requests = 0 +const sdk = createOpencodeClient({ + baseUrl: "http://session-creation.test", + fetch: async () => { + requests += 1 + return Response.json({ message: "not found" }, { status: 404 }) + }, +}) +const session: Session = { + id: "session-created", + slug: "created", + projectID: "project-created", + directory: "C:/canonical/worktree", + title: "New session", + version: "1", + time: { created: 1, updated: 1 }, +} + +beforeEach(() => { + requests = 0 + childStores = new ChildStoreManager() + loader = new SessionMessageLoader(childStores, { sdk, runtimeKey: getRuntimeKey() }) + setActionRefs(sdk, childStores, () => "/requested") + setImperativeSessionMessageLoader(loader) +}) + +afterEach(() => { + opencodeClient.createSession = originalCreateSession + opencodeClient.setDirectory(originalDirectory) + useSessionUIStore.setState(originalSelection) + setImperativeSessionMessageLoader(null) + loader.dispose() + childStores.disposeAll() +}) + +describe("confirmed session creation", () => { + test("publishes the new transcript before navigation can issue a failing history read", async () => { + opencodeClient.createSession = async () => session + + expect(await createSession(undefined, "/requested")).toBe(session) + const target = { directory: session.directory, sessionID: session.id } + await loader.ensure(target, { reason: "reactive" }) + + expect(requests).toBe(0) + expect(useSessionUIStore.getState().currentSessionDirectory).toBe(session.directory) + expect(childStores.getChild(session.directory)?.getState().session).toEqual([session]) + expect(childStores.getChild(session.directory)?.getState().message[session.id]).toEqual([]) + expect(loader.getSnapshot(target).status).toBe("ready") + expect(childStores.getChild("/requested")?.getState().message[session.id]).toBeUndefined() + }) + + test("retains newer metadata and the first prompt delivered before the create response", async () => { + const store = childStores.ensureChild(session.directory, { bootstrap: false }) + const newerSession = { ...session, title: "Already renamed", time: { created: 1, updated: 2 } } + const record = { + id: "msg_first", + sessionID: session.id, + role: "user", + time: { created: 2 }, + agent: "build", + model: { providerID: "test", modelID: "test" }, + } satisfies import("@opencode-ai/sdk/v2/client").UserMessage + opencodeClient.createSession = async () => { + store.setState({ session: [newerSession], message: { [session.id]: [record] } }) + return session + } + + await createSession(undefined, "/requested") + + expect(requests).toBe(0) + expect(store.getState().session).toEqual([newerSession]) + expect(store.getState().message[session.id]).toEqual([record]) + }) + + test("a rejected create does not seed an empty successful transcript", async () => { + opencodeClient.createSession = async () => { throw new Error("offline") } + const previousSelection = useSessionUIStore.getState().currentSessionId + + expect(await createSession(undefined, "/requested")).toBeNull() + + expect(useSessionUIStore.getState().currentSessionId).toBe(previousSelection) + expect(childStores.getChild(session.directory)).toBeUndefined() + expect(requests).toBe(0) + }) +}) diff --git a/packages/ui/src/sync/session-directory-adoption.test.ts b/packages/ui/src/sync/session-directory-adoption.test.ts index 4857872c..84620bb2 100644 --- a/packages/ui/src/sync/session-directory-adoption.test.ts +++ b/packages/ui/src/sync/session-directory-adoption.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, test } from "bun:test" import { ChildStoreManager } from "./child-store" import { setSyncRefs } from "./sync-refs" import { useSessionUIStore } from "./session-ui-store" +import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore" /** * Selecting a session whose directory this client has not indexed yet routes it @@ -39,6 +40,27 @@ beforeEach(() => { }) describe("adoptAuthoritativeSessionDirectory", () => { + test("opens a globally indexed Windows worktree session before its child store bootstraps", () => { + const sessionId = "ses_windows_global_directory" + useGlobalSessionsStore.getState().upsertSession({ + id: sessionId, + slug: "windows-worktree", + projectID: "windows-project", + directory: "c:\\repo\\.worktrees\\feature", + title: "Worktree session", + version: "1", + time: { created: 1, updated: 1 }, + }) + try { + useSessionUIStore.getState().setCurrentSession(sessionId) + + expect(useSessionUIStore.getState().currentSessionDirectory).toBe("C:/repo/.worktrees/feature") + expect(useSessionUIStore.getState().getDirectoryForSession(sessionId)).toBe("C:/repo/.worktrees/feature") + } finally { + useGlobalSessionsStore.getState().removeSessions([sessionId]) + } + }) + test("promotes a guessed selection once the owning directory is indexed", () => { useSessionUIStore.getState().setCurrentSession(SESSION_ID) expect(useSessionUIStore.getState().currentSessionDirectory).not.toBe(WORKTREE) diff --git a/packages/ui/src/sync/session-message-loader.test.ts b/packages/ui/src/sync/session-message-loader.test.ts index 83ab5fc7..a87122a3 100644 --- a/packages/ui/src/sync/session-message-loader.test.ts +++ b/packages/ui/src/sync/session-message-loader.test.ts @@ -38,6 +38,72 @@ const createLoader = (messages: (input: { } describe("SessionMessageLoader", () => { + test("opens a confirmed new session without fetching history", async () => { + let calls = 0 + const { childStores, loader } = createLoader(async () => { + calls += 1 + return { error: { message: "not found" }, response: { status: 404 } } + }) + const target = { directory: "/created-repo", sessionID: "session-created" } + + loader.initializeCreatedSession(target) + await loader.ensure(target, { reason: "navigation" }) + await loader.ensure(target, { reason: "reactive" }) + + expect(calls).toBe(0) + expect(loader.getSnapshot(target)).toMatchObject({ status: "ready", resolved: true, complete: true }) + expect(childStores.getChild(target.directory)?.getState().message[target.sessionID]).toEqual([]) + + const record = createRecord(target.sessionID) + loader.optimisticAdd({ ...target, message: record.info, parts: record.parts }) + await loader.ensure(target) + expect(calls).toBe(0) + expect(childStores.getChild(target.directory)?.getState().message[target.sessionID]).toEqual([record.info]) + + // Explicit recovery still reaches the server and exposes a real failure. + await loader.ensure(target, { force: true }) + expect(calls).toBe(1) + expect(loader.getSnapshot(target).status).toBe("error") + expect(childStores.getChild(target.directory)?.getState().message[target.sessionID]).toEqual([record.info]) + loader.dispose() + childStores.disposeAll() + }) + + test("creation supersedes an early history failure without losing the first prompt", async () => { + const pending = deferred<{ error: { message: string }; response: { status: number } }>() + const { childStores, loader } = createLoader(() => pending.promise) + const target = { directory: "/created-race", sessionID: "session-created" } + const earlyLoad = loader.ensure(target) + + loader.initializeCreatedSession(target) + const record = createRecord(target.sessionID) + loader.optimisticAdd({ ...target, message: record.info, parts: record.parts }) + pending.resolve({ error: { message: "not found" }, response: { status: 404 } }) + await earlyLoad + + expect(loader.getSnapshot(target).status).toBe("ready") + expect(childStores.getChild(target.directory)?.getState().message[target.sessionID]).toEqual([record.info]) + expect(childStores.getChild(target.directory)?.getState().part[record.info.id]).toEqual(record.parts) + loader.dispose() + childStores.disposeAll() + }) + + test("creation preserves messages and history coverage received before its response", async () => { + const record = createRecord("session-created") + const { childStores, loader } = createLoader(async () => response([record], "older-cursor")) + const target = { directory: "/created-events", sessionID: "session-created" } + await loader.ensure(target) + const before = childStores.getChild(target.directory)?.getState() + const coverage = loader.getSnapshot(target) + + loader.initializeCreatedSession(target) + + expect(childStores.getChild(target.directory)?.getState()).toBe(before) + expect(loader.getSnapshot(target)).toBe(coverage) + loader.dispose() + childStores.disposeAll() + }) + test("deduplicates navigation and reactive loading for the same target", async () => { const pending = deferred>() let calls = 0 diff --git a/packages/ui/src/sync/session-message-loader.ts b/packages/ui/src/sync/session-message-loader.ts index af1b2c9f..a61bf4b4 100644 --- a/packages/ui/src/sync/session-message-loader.ts +++ b/packages/ui/src/sync/session-message-loader.ts @@ -176,6 +176,30 @@ export class SessionMessageLoader { this.disposed = false } + initializeCreatedSession(target: SessionMessageTarget): void { + const normalized = this.normalizeTarget(target) + if (!normalized || this.disposed) return + const store = this.childStores.ensureChild(normalized.directory, { bootstrap: false }) + const current = store.getState() + // The create response establishes an empty transcript, but events or a + // prompt may already have materialized a newer snapshot while it travelled. + if (current.message[normalized.sessionID] !== undefined) return + const entry = this.getEntry(normalized) + this.bumpGeneration(entry) + entry.inflight = null + store.setState({ message: { ...current.message, [normalized.sessionID]: [] } }) + this.patchEntry(entry, { + status: "ready", + loadingKind: null, + error: null, + resolved: true, + cursor: undefined, + complete: true, + updatedAt: Date.now(), + }) + this.persistCoverage(normalized, entry.snapshot) + } + ensure( target: SessionMessageTarget, options?: { force?: boolean; reason?: "navigation" | "reactive" | "prefetch" }, diff --git a/packages/ui/src/sync/session-ui-store.test.js b/packages/ui/src/sync/session-ui-store.test.js index ef086eae..d95fcc8d 100644 --- a/packages/ui/src/sync/session-ui-store.test.js +++ b/packages/ui/src/sync/session-ui-store.test.js @@ -15,7 +15,6 @@ import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; -import { subscribeWorktreeTopologyChanged } from '@/lib/worktrees/worktreeManager'; import { createContextPart } from '@/lib/messages/contextParts'; /** @@ -1347,52 +1346,17 @@ describe('missing session directory recovery', () => { useSessionUIStore.setState({ currentSessionId: null, currentSessionDirectory: null, worktreeMetadata: new Map() }); }); - test('moves the current session to its project, drops the worktree hint, and shares one attempt between callers', async () => { - const root = worktreeSession('root', missingWorktree); - const child = worktreeSession('child', missingWorktree, 'root'); - useGlobalSessionsStore.setState({ activeSessions: [root, child], archivedSessions: [] }); - useSessionUIStore.setState({ currentSessionId: 'root', currentSessionDirectory: missingWorktree }); - useSessionUIStore.getState().setWorktreeMetadata('root', { path: missingWorktree, branch: 'gone' }); - useSessionUIStore.getState().setWorktreeMetadata('child', { path: missingWorktree, branch: 'gone' }); - - const topologyChanges = []; - const unsubscribe = subscribeWorktreeTopologyChanged((directory) => topologyChanges.push(directory)); - const store = useSessionUIStore.getState(); - const [first, second] = await Promise.all([ - store.recoverMissingSessionDirectory('root'), - store.recoverMissingSessionDirectory('root'), - ]); - unsubscribe(); - - expect(first).toBe(second); - expect(topologyChanges).toEqual([projectDirectory]); - expect(first.status).toBe('moved'); - expect(moves.map((move) => move.sessionID)).toEqual(['root', 'child']); - expect(moves.every((move) => move.destination.directory === projectDirectory && move.moveChanges === false)).toBe(true); - expect(useSessionUIStore.getState().worktreeMetadata.has('root')).toBe(false); - expect(useSessionUIStore.getState().worktreeMetadata.has('child')).toBe(false); - expect(useSessionWorktreeStore.getState().getAttachment('root')).toBeUndefined(); - expect(useSessionUIStore.getState().getDirectoryForSession('root')).toBe(projectDirectory); - expect(useSessionUIStore.getState().currentSessionDirectory).toBe(projectDirectory); - expect(useDirectoryStore.getState().currentDirectory).toBe(projectDirectory); - }); - - test('probes a worktree session on activation and relocates it only when the directory is confirmed missing', async () => { + test('leaves a missing worktree session in place on activation and does not probe or relocate it', async () => { const root = worktreeSession('root', missingWorktree); useGlobalSessionsStore.setState({ activeSessions: [root], archivedSessions: [] }); - availability = 'available'; useSessionUIStore.getState().setCurrentSession('root', missingWorktree); await settle(); - expect(probes).toEqual([missingWorktree]); + + expect(probes).toEqual([]); expect(moves).toEqual([]); expect(useSessionUIStore.getState().currentSessionDirectory).toBe(missingWorktree); - - availability = 'missing'; - useSessionUIStore.getState().setCurrentSession('root', missingWorktree); - await settle(); - expect(moves.map((move) => move.sessionID)).toEqual(['root']); - expect(useSessionUIStore.getState().currentSessionDirectory).toBe(projectDirectory); + expect(useSessionUIStore.getState().getDirectoryForSession('root')).toBe(missingWorktree); }); test('never probes a session that lives in its project root or in a managed chat directory', async () => { diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index a1d8df90..c8532cc0 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -31,8 +31,7 @@ import { useSkillsStore } from "@/stores/useSkillsStore" import { getDeferredSafeStorage } from "@/stores/utils/safeStorage" import { markPendingUserSendAnimation } from "@/lib/userSendAnimation" import { normalizePath } from "@/lib/pathNormalization" -import type { ProjectEntry } from "@/lib/api/types" -import { CHAT_DRAFT_PROJECT_ID, createChatDirectory, deleteChatDirectory, getChatsRootFromDirectory, isChatDirectoryForHome, isChatDirectoryPath, warmChatsRootDirectory } from "@/lib/chatDirectories" +import { CHAT_DRAFT_PROJECT_ID, createChatDirectory, deleteChatDirectory, getChatsRootFromDirectory, isChatDirectoryPath, warmChatsRootDirectory } from "@/lib/chatDirectories" import { isVSCodeRuntime } from "@/lib/desktop" import { composeForkSessionMessage } from "@/lib/messages/executionMeta" import { findLatestUserModelChoice } from "@/lib/messages/userModelChoice" @@ -72,9 +71,7 @@ import { unrevertSession as unrevertSessionAction, forkFromMessage as forkFromMessageAction, fetchMessagesForSession, - relocateSessionFromMissingDirectory, type ArchiveSessionsOptions, - type MissingDirectoryRelocation, type DeleteSessionOptions, type DeleteSessionsOptions, type UnarchiveSessionsOptions, @@ -330,6 +327,8 @@ export type NewSessionDraftState = { projectContextPins?: { notes: string[]; plans: string[] } target: NewSessionDraftTarget preparedChatDirectory?: string | null + /** Opened as a programmatic fallback (no session active at boot), not by the user. */ + openedAutomatically?: boolean } export type ViewportAnchor = { @@ -378,13 +377,6 @@ export type SessionUIState = { transition?: "submitted-draft", ) => void clearMaterializedDraftSession: (sessionId: string) => void - /** - * Move a session whose directory no longer exists (a worktree deleted - * outside OpenChamber) into its project directory. Concurrent calls for the - * same session share one attempt. Resolves `unchanged` when the directory is - * available, unknown, or the session has no project to move to. - */ - recoverMissingSessionDirectory: (sessionId: string) => Promise prepareForRuntimeSwitch: (apiBaseUrl?: string | null) => void restoreForRuntimeSwitch: (apiBaseUrl?: string | null) => void openNewSessionDraft: (options?: Partial & { automatic?: boolean }) => void @@ -534,6 +526,11 @@ const getAuthoritativeSessionDirectory = (sessionId: string): string | null => { const target = getAllSyncSessions().find((s) => s.id === sessionId) const recordDirectory = target ? resolveDirectoryKey(target) : null if (recordDirectory) return normalizePath(recordDirectory) + // The sidebar can know this session before its directory store bootstraps. + // Use that record's own directory before falling back to local routing hints. + const globalSession = useGlobalSessionsStore.getState().entityById.get(sessionId) + const globalDirectory = normalizePath(globalSession?.directory) + if (globalDirectory) return globalDirectory const owningDirectory = getSyncSessionDirectory(sessionId) return owningDirectory ? normalizePath(owningDirectory) : null } @@ -768,27 +765,6 @@ const resolveCreatableDraftDirectory = async ( } } -const pendingDirectoryRecoveries = new Map>() - -/** - * Only a directory that is neither a registered project root nor a managed - * chat directory can be a deleted worktree. Project roots and chat directories - * have nowhere to relocate to, so they are never probed. - */ -const isRelocatableSessionDirectory = (directory: string, projects: readonly ProjectEntry[]): boolean => { - if (isChatDirectoryForHome(directory, useDirectoryStore.getState().homeDirectory)) return false - return !projects.some((project) => normalizePath(project.path) === directory) -} - -const notifySessionRelocated = async (destinationDirectory: string): Promise => { - const { toast } = await import("sonner") - const { useI18nStore, formatMessage } = await import("@/lib/i18n/store") - const project = useProjectsStore.getState().projects.find((entry) => normalizePath(entry.path) === destinationDirectory) - toast.info(formatMessage(useI18nStore.getState().dictionary, "sessions.missingDirectory.movedToProject", { - project: project?.label ?? destinationDirectory, - })) -} - const recoverStaleDraftDirectory = async (openedDraft: NewSessionDraftState): Promise => { const resolved = await resolveCreatableDraftDirectory(openedDraft, openedDraft.directoryOverride) if (resolved.status !== "ok") return @@ -1109,16 +1085,6 @@ export const useSessionUIStore = create()((set, get) => ({ console.warn("Failed to set OpenCode directory for session switch:", e) } - // A worktree session may have lost its directory while it was in the - // background. Probe on activation, the same way a reopened draft probes - // its inherited directory, so the session is relocated before its tabs - // and prompts run against a path that is gone. VS Code registers no - // worktrees, so every session there is its workspace root. - if (id && !isGuessedDir && resolvedDir && !isVSCodeRuntime() - && isRelocatableSessionDirectory(resolvedDir, projectsState.projects)) { - void get().recoverMissingSessionDirectory(id) - } - // Defer viewport anchor save for previous session — not needed for the // skeleton to render and reads messages which can be expensive. if (previousSessionId && previousSessionId !== id) { @@ -1210,39 +1176,7 @@ export const useSessionUIStore = create()((set, get) => ({ // --------------------------------------------------------------------------- // openNewSessionDraft // --------------------------------------------------------------------------- - recoverMissingSessionDirectory: (sessionId) => { - const runtimeKey = getRuntimeKey() - const key = `${runtimeKey}:${sessionId}` - const pending = pendingDirectoryRecoveries.get(key) - if (pending) return pending - const recovery = relocateSessionFromMissingDirectory(sessionId, runtimeKey) - .then(async (result) => { - if (result.status !== "moved" && result.status !== "failed") return result - // The worktree hint was the first thing every directory lookup read; - // with the worktree gone it would keep routing tabs to the dead path. - for (const movedId of result.movedSessionIds) { - get().setWorktreeMetadata(movedId, null) - } - if (result.status !== "moved") return result - if (get().currentSessionId === sessionId) { - // Re-select through the normal path so the active directory, project, - // and OpenCode client all follow the session to its new home. - get().setCurrentSession(sessionId, result.destinationDirectory) - } - // The server just confirmed a worktree directory is gone; the sidebar's - // worktree topology for that project is stale, so let it rediscover. - const { notifyWorktreeTopologyChanged } = await import("@/lib/worktrees/worktreeManager") - notifyWorktreeTopologyChanged(result.destinationDirectory) - await notifySessionRelocated(result.destinationDirectory) - return result - }) - .finally(() => { - pendingDirectoryRecoveries.delete(key) - }) - pendingDirectoryRecoveries.set(key, recovery) - return recovery - }, openNewSessionDraft: (options) => { // A USER-initiated draft open is a navigation choice: the next cold launch @@ -1353,6 +1287,7 @@ export const useSessionUIStore = create()((set, get) => ({ syntheticParts: options?.syntheticParts, targetFolderId: options?.targetFolderId, projectContextPins: options?.projectContextPins, + openedAutomatically: options?.automatic === true, } set({ @@ -2160,14 +2095,15 @@ export const useSessionUIStore = create()((set, get) => ({ throw new Error("Project is not registered in OpenChamber") } - const [branchNameModule, configModule, createModule] = await Promise.all([ + const [branchNameModule, configModule, trustModule, createModule] = await Promise.all([ import("@/lib/git/branchNameGenerator"), import("@/lib/openchamberConfig"), + import("@/lib/sharedTrustConfirmation"), import("@/lib/worktrees/worktreeCreate"), ]) const branchName = branchNameModule.generateBranchName() createdWorktreeProject = { id: project.id, path: project.path } - const setupCommands = await configModule.getWorktreeSetupCommands(createdWorktreeProject) + const setupCommands = await trustModule.resolveWorktreeSetupCommands(createdWorktreeProject) createdWorktree = await createModule.createWorktreeWithDefaults(createdWorktreeProject, { preferredName: branchName, mode: "new", diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 45e33cde..31622f51 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -49,6 +49,7 @@ import { messagesBefore } from "./message-ordering" import { opencodeClient } from "@/lib/opencode/client" import { usePermissionStore } from "@/stores/permissionStore" import { applyMessageQueueUpdatedEvent, useMessageQueueStore } from "@/stores/messageQueueStore" +import { subscribeMessageQueueSync } from "./message-queue-sync" import { processVSCodePermissionAutoAccept, processVSCodeReconciledPermissionAutoAccept, @@ -428,7 +429,11 @@ function enqueueSessionMaterialization( return } countSyncPerformance("materializationRequests") - await materializeSessionFromServer(directory, sessionID, store, request) + await materializeSessionFromServer(directory, sessionID, store, { + ...request, + isStale: () => childStores.children.get(directory) !== store + || pendingSessionMaterializations.get(k) !== pending, + }) } catch { // Transient failure — next SSE event or reconnect will catch up. } finally { @@ -458,6 +463,10 @@ async function materializeSessionFromServer( store: StoreApi, options?: SessionMaterializationRequest & { isStale?: () => boolean }, ) { + const runtimeKey = getRuntimeKey() + const sdk = opencodeClient.getSdkClient() + const isStale = () => options?.isStale?.() || getRuntimeKey() !== runtimeKey + || opencodeClient.getSdkClient() !== sdk const statusBeforeMaterialization = store.getState().session_status?.[sessionID] syncDebug.recovery.materializing({ reason: options?.reason ?? "ensure-session-messages", @@ -467,14 +476,18 @@ async function materializeSessionFromServer( partID: options?.partID, }) const loader = getImperativeSessionMessageLoader() - if (!loader || options?.isStale?.()) return + if (!loader || isStale()) return await loader.refreshTail({ directory, sessionID }, SESSION_MATERIALIZATION_MESSAGE_LIMIT) + if (isStale()) return if (loader.getSnapshot({ directory, sessionID }).status === "error") { throw loader.getSnapshot({ directory, sessionID }).error ?? new Error("Session materialization failed") } - if (statusBeforeMaterialization && statusBeforeMaterialization.type !== "idle" && !options?.isStale?.()) { - await resyncDirectorySessionStatuses(directory, store, [sessionID], "authoritative") + if (statusBeforeMaterialization && statusBeforeMaterialization.type !== "idle" && !isStale()) { + await resyncDirectorySessionStatuses(directory, store, [sessionID], "authoritative", isStale) + } + if (!isStale()) { + await recoverInterruptedTurnAfterMessageLoad(directory, store, sessionID, isStale) } } @@ -718,7 +731,10 @@ export function applySessionStatusSnapshot( if (mode === "monotonic") continue const existing = current[sessionId] - if (existing && existing.type !== "idle") { + // Keep the successful snapshot distinguishable from "status has never + // been observed". Interrupted-turn recovery requires this explicit + // settle marker after a cold reload. + if (!existing || existing.type !== "idle") { draft()[sessionId] = { type: "idle" } changed = true } @@ -735,13 +751,15 @@ async function resyncDirectorySessionStatuses( store: StoreApi, candidateSessionIds: string[], mode: StatusSnapshotMode, + isStale?: () => boolean, ): Promise { const nextStatuses = await opencodeClient.getSessionStatusForDirectory(directory) // null = fetch failed; preserve existing state. {} or populated = a snapshot // of active sessions — reconciled per `mode` (absence ≠ idle under monotonic). - if (nextStatuses === null) return null + if (nextStatuses === null || isStale?.()) return null applySessionStatusSnapshot(store, nextStatuses, candidateSessionIds, mode) if (mode === "authoritative") { + store.setState({ sessionStatusReady: true }) applyGlobalSessionStatusSnapshot(directory, nextStatuses, candidateSessionIds) // An authoritative snapshot that settles sessions previously observed // busy/retry can leave their trailing assistant message and tool parts @@ -750,20 +768,7 @@ async function resyncDirectorySessionStatuses( // which is the gate the helper requires — a session the snapshot reports // busy stays untouched. for (const sessionId of candidateSessionIds) { - const interrupted = interruptedTurnToolParts(store.getState(), sessionId) - if (interrupted) { - if (!interrupted.parts) { - store.setState((state) => ({ - message: { ...state.message, [sessionId]: interrupted.messages }, - })) - continue - } - const interruptedParts = interrupted.parts - store.setState((state) => ({ - message: { ...state.message, [sessionId]: interrupted.messages }, - part: { ...state.part, [interrupted.messageID]: interruptedParts }, - })) - } + applyInterruptedTurnReconciliation(store, sessionId) } } return nextStatuses @@ -1526,12 +1531,15 @@ async function resyncDirectoryAfterReconnect( store: StoreApi, routingIndex: EventRoutingIndex, reason: SessionMaterializationReason, + isStale: () => boolean, ) { + if (isStale()) return const current = store.getState() const candidateSessionIds = getActiveSessionCandidateIds(directory, current) if (candidateSessionIds.length === 0) return - await resyncDirectorySessionStatuses(directory, store, candidateSessionIds, "authoritative") + await resyncDirectorySessionStatuses(directory, store, candidateSessionIds, "authoritative", isStale) + if (isStale()) return const scopedClient = opencodeClient.getScopedSdkClient(directory) await Promise.all(candidateSessionIds.map(async (sessionId) => { @@ -1545,6 +1553,9 @@ async function resyncDirectoryAfterReconnect( }).catch(() => null), loader?.refreshTail({ directory, sessionID: sessionId }, RECONNECT_MESSAGE_LIMIT) ?? Promise.resolve(), ]) + if (isStale()) return + await recoverInterruptedTurnAfterMessageLoad(directory, store, sessionId, isStale) + if (isStale()) return const session = sessionResponse?.data if (!session) return @@ -1568,8 +1579,10 @@ async function resyncDirectoryAfterReconnect( setIndexedSessionMessages(routingIndex, sessionId, directory, store.getState().message[sessionId] ?? []) })) + if (isStale()) return await resyncBlockingRequestsForDirectory(directory, store, candidateSessionIds) + if (isStale()) return ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState()) } @@ -2143,6 +2156,72 @@ export function interruptedTurnToolParts( } } +function hasUnfinishedAssistantTurn(state: DirectoryStore, sessionID: string): boolean { + const messages = state.message[sessionID] ?? [] + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] + if (message.role === "user") return false + if (message.role !== "assistant") continue + return message.time.completed === undefined + } + return false +} + +function applyInterruptedTurnReconciliation(store: StoreApi, sessionID: string): void { + const interrupted = interruptedTurnToolParts(store.getState(), sessionID) + if (!interrupted) return + + const interruptedParts = interrupted.parts + if (!interruptedParts) { + store.setState((state) => ({ + message: { ...state.message, [sessionID]: interrupted.messages }, + })) + return + } + + store.setState((state) => ({ + message: { ...state.message, [sessionID]: interrupted.messages }, + part: { ...state.part, [interrupted.messageID]: interruptedParts }, + })) +} + +/** + * Re-checks a hydrated session whose trailing assistant turn is unfinished. + * A cold reload can hydrate messages after the initial status snapshot, so the + * settle decision must be repeated after the message records are available. + * If no per-session status exists yet, fetch one authoritative snapshot first; + * a successful snapshot that omits the session establishes it as idle. + */ +export async function recoverInterruptedTurnAfterMessageLoad( + directory: string, + store: StoreApi, + sessionID: string, + isStale?: () => boolean, +): Promise { + if (isStale?.()) return + const runtimeKey = getRuntimeKey() + const sdk = opencodeClient.getSdkClient() + const initial = store.getState() + if (!hasUnfinishedAssistantTurn(initial, sessionID)) return + if ((initial.question?.[sessionID] ?? []).length > 0) return + if ((initial.permission?.[sessionID] ?? []).length > 0) return + + if (!initial.session_status?.[sessionID]) { + const snapshot = await opencodeClient.getSessionStatusForDirectory(directory) + if (snapshot === null || isStale?.() + || getRuntimeKey() !== runtimeKey || opencodeClient.getSdkClient() !== sdk) return + + // Do not overwrite a live status event that arrived while the snapshot was + // in flight. The snapshot only fills the previously unknown state. + if (!store.getState().session_status?.[sessionID]) { + applySessionStatusSnapshot(store, snapshot, [sessionID], "authoritative") + applyGlobalSessionStatusSnapshot(directory, snapshot, [sessionID]) + } + } + + applyInterruptedTurnReconciliation(store, sessionID) +} + // --------------------------------------------------------------------------- // Provider // --------------------------------------------------------------------------- @@ -2223,7 +2302,11 @@ export function SyncProvider(props: { lastFullResyncAtByDirectoryRef.current.set(directory, Date.now()) resyncing.add(directory) - void resyncDirectoryAfterReconnect(directory, store, routingIndex, reason) + const sdk = opencodeClient.getSdkClient() + const expectedRuntimeKey = getRuntimeKey() + const isStale = () => getRuntimeKey() !== expectedRuntimeKey + || opencodeClient.getSdkClient() !== sdk || childStores.children.get(directory) !== store + void resyncDirectoryAfterReconnect(directory, store, routingIndex, reason, isStale) .catch(() => { // Transient failure — the watchdog, next SSE event, or reconnect will catch up. }) @@ -2426,6 +2509,10 @@ export function SyncProvider(props: { // Event pipeline — created once per mount. No class, no start/stop. // Abort controller owned by the pipeline closure. Cleanup aborts + flushes. useEffect(() => { + const unsubscribeQueueEvents = subscribeMessageQueueSync(runtimeKey) + const resyncAfterStreamGap = (reason: SessionMaterializationReason) => { + for (const dir of childStores.children.keys()) triggerDirectoryResync(dir, reason) + } const pipeline = createEventPipeline({ sdk: props.sdk, transport: messageStreamTransport, @@ -2459,6 +2546,8 @@ export function SyncProvider(props: { } }, onReconnect: () => { + // Queue recovery is independent of the directory-bootstrap debounce. + void useMessageQueueStore.getState().resync().catch(() => undefined) useConfigStore.setState({ isConnected: true, hasEverConnected: true, @@ -2472,9 +2561,7 @@ export function SyncProvider(props: { if (isRecentBoot()) { return } - for (const dir of childStores.children.keys()) { - triggerDirectoryResync(dir, "stream-reconnect") - } + resyncAfterStreamGap("stream-reconnect") }, onDisconnect: (reason) => { if (!pipelineHasConnectedRef.current) { @@ -2488,6 +2575,7 @@ export function SyncProvider(props: { }) }, onTransportSwitch: () => { + void useMessageQueueStore.getState().resync().catch(() => undefined) // Transport changes are gap-prone in real networks. Treat them like a // reconnect and refresh active session snapshots from HTTP. useConfigStore.setState({ @@ -2495,9 +2583,7 @@ export function SyncProvider(props: { hasEverConnected: true, connectionPhase: "connected", }) - for (const dir of childStores.children.keys()) { - triggerDirectoryResync(dir, "transport-switch") - } + resyncAfterStreamGap("transport-switch") }, }) pipelineReconnectRef.current = pipeline.reconnect @@ -2506,6 +2592,7 @@ export function SyncProvider(props: { pipelineReconnectRef.current = null } pipeline.cleanup() + unsubscribeQueueEvents() } }, [props.sdk, childStores, routingIndex, messageStreamTransport, runtimeKey, triggerDirectoryResync]) diff --git a/packages/ui/src/sync/types.ts b/packages/ui/src/sync/types.ts index 22f1301a..e5bdd34d 100644 --- a/packages/ui/src/sync/types.ts +++ b/packages/ui/src/sync/types.ts @@ -56,6 +56,8 @@ export type State = { sessionEventRevision?: Record sessionDeletedRevision?: Record session_status: Record + /** A successful status snapshot makes omitted sessions authoritatively idle. */ + sessionStatusReady?: boolean session_diff: Record todo: Record permission: Record diff --git a/packages/ui/src/sync/use-sync.ts b/packages/ui/src/sync/use-sync.ts index a2bb5d65..834e1573 100644 --- a/packages/ui/src/sync/use-sync.ts +++ b/packages/ui/src/sync/use-sync.ts @@ -15,6 +15,7 @@ import { useSyncRuntime, resyncBlockingRequestsForDirectory, buildSessionMessageRecordsSnapshot, + recoverInterruptedTurnAfterMessageLoad, } from "./sync-context" import { stripSessionDiffSnapshots } from "./sanitize" import { isVSCodeRuntime } from "@/lib/desktop" @@ -235,16 +236,21 @@ export function useSync() { // knows it is stale and should not write to the store. const generation = (syncSessionGenerationByKey.get(key) ?? 0) + 1 syncSessionGenerationByKey.set(key, generation) - const isStale = () => syncSessionGenerationByKey.get(key) !== generation const targetStore = targetDirectory === directory ? store : childStores.ensureChild(targetDirectory, { bootstrap: false }) + const isStale = () => getRuntimeKey() !== runtimeKey + || syncSessionGenerationByKey.get(key) !== generation + || childStores.children.get(targetDirectory) !== targetStore const current = targetStore.getState() const materialization = getSessionMaterializationStatus(current, sessionID) const cachedReady = materialization.hasMessages && materialization.renderable const hasSession = Binary.search(current.session, sessionID, (s) => s.id).found - if (cachedReady && hasSession && !force) return + if (cachedReady && hasSession && !force) { + await recoverInterruptedTurnAfterMessageLoad(targetDirectory, targetStore, sessionID, isStale) + return + } const shouldLoadMessages = Boolean(!cachedReady || force) const shouldFetchSession = shouldFetchSessionForRenderableSync({ hasSession, shouldLoadMessages, force: Boolean(force) }) const promise = (async () => { @@ -271,10 +277,15 @@ export function useSync() { })() : Promise.resolve(), shouldLoadMessages - ? messageLoader.ensure( - { directory: targetDirectory, sessionID }, - { force, reason: "reactive" }, - ) + ? (async () => { + await messageLoader.ensure( + { directory: targetDirectory, sessionID }, + { force, reason: "reactive" }, + ) + if (!isStale()) { + await recoverInterruptedTurnAfterMessageLoad(targetDirectory, targetStore, sessionID, isStale) + } + })() : Promise.resolve(), ]) })() diff --git a/packages/ui/src/types/bun-test.d.ts b/packages/ui/src/types/bun-test.d.ts index 931daf52..ccd0eb78 100644 --- a/packages/ui/src/types/bun-test.d.ts +++ b/packages/ui/src/types/bun-test.d.ts @@ -13,6 +13,8 @@ declare module "bun:test" { toThrow(expected?: string | RegExp | (new (...args: never[]) => unknown)): void; toContain(expected: unknown): void; toBeDefined(): void; + toBeUndefined(): void; + toMatchObject(expected: unknown): void; rejects: { toThrow(expected?: string | RegExp | (new (...args: never[]) => unknown)): Promise; }; @@ -57,3 +59,16 @@ declare module "bun:test" { function restore(): void; } } + +// Vite asset-query imports need a URL loader when real UI modules run in Bun. +declare module "bun" { + export function plugin(options: { + name: string; + setup(build: { + onLoad(options: { filter: RegExp }, callback: (args: { path: string }) => { + contents: string; + loader: "js" | "ts"; + }): void; + }): void; + }): void; +} diff --git a/packages/ui/src/types/ghostty-web.d.ts b/packages/ui/src/types/ghostty-web.d.ts deleted file mode 100644 index d0266e16..00000000 --- a/packages/ui/src/types/ghostty-web.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -export {}; - -declare module 'ghostty-web' { - export interface ITerminalOptions { - lineHeight?: number; - } - - export interface RendererOptions { - lineHeight?: number; - } -} diff --git a/packages/ui/src/types/quota.ts b/packages/ui/src/types/quota.ts index 1524ca51..c05a6dd9 100644 --- a/packages/ui/src/types/quota.ts +++ b/packages/ui/src/types/quota.ts @@ -3,6 +3,7 @@ export type QuotaProviderId = | 'codex' | 'cursor' | 'claude' + | 'cline-pass' | 'github-copilot' | 'github-copilot-addon' | 'google' @@ -19,6 +20,7 @@ export type QuotaProviderId = | 'crof' | 'deepseek' | 'exe-dev' + | 'hyper' | 'neuralwatt' | 'xai'; diff --git a/packages/ui/src/vite-env.d.ts b/packages/ui/src/vite-env.d.ts index 0c853341..2e3fa7b4 100644 --- a/packages/ui/src/vite-env.d.ts +++ b/packages/ui/src/vite-env.d.ts @@ -1,7 +1,6 @@ /// interface Window { - __openchamberEnsureNerdFonts?: () => Promise; __opencodeDebug?: { getLastAssistantMessage: () => unknown; getAllMessages: (truncate?: boolean) => unknown[]; diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 5a4fcca3..d7352391 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,3 +1,43 @@ +## [1.23.0] - 2026-09-09 + +### New + +- Chat: Replies can contain collapsible Markdown sections that stay open as the answer streams. +- Projects: Store worktree setup commands and draft starters in the repository from Project settings. Repository commands require trust before running and after changes. +- Usage: ClinePass now shows five-hour, weekly, and monthly usage limits (thanks to @NemeZZiZZ). +- Usage: Charm Hyper shows your remaining Hypercredits and their dollar value (thanks to @airtaxi). +- Settings: "Always show scrollbars" keeps scrollbars visible when the pointer leaves a scrollable area. + +### Improvements + +- **Chat:** `/btw` now has a separate composer with its own draft, model, and effort. The "By the way…" text-selection action prefills a question with the selected passage (thanks to @ChangeHow). +- Chat: Completed live Activity can collapse into a tool and file-change summary while the final answer stays visible, following your Activity Default setting. +- Settings: VS Code keeps its own appearance and chat layout preferences, separate from web, desktop, and mobile. +- Settings: In narrow panels, Back returns from an item to its list before returning to the settings menu. +- Chat: Ctrl+N/P navigation works across model lists, menus, and autocomplete. The model picker reopens with your selected model in view (thanks to @ChangeHow). +- Settings/Chat: Send-shortcut and large-text paste options have clearer descriptions (thanks to @ChangeHow). +- Chat: More compact Markdown, smaller action buttons, and a softer final-answer divider make replies easier to scan. +- Chat: Text selection and comment highlights use a consistent, readable accent tint across themes. + +### Fixes + +- Sessions: New sessions and worktree sessions open without false history-loading errors. +- Settings: A failed screen load no longer triggers a broken reload of the chat. +- Chat: Forking a user message fills the destination composer with its prompt and attachments while keeping the original session's draft intact (thanks to @karimodm). +- Chat: Tools interrupted before a reload no longer keep a running timer indefinitely (thanks to @alvins82). +- Chat: Images attached to a sent message appear only once. +- Chat: Resizing the chat keeps the latest reply in view when following the end. Sending or collapsing Activity no longer creates a large blank space below it. +- Chat: Message details adapt to narrow panels without leaving gaps between the model, effort, and duration. +- Chat: Long Thinking output stays in a capped scroll box while streaming; scrolling upward pauses its automatic scrolling (thanks to @alvins82). +- Chat: Narrow tables keep their border and toolbar close to the columns (thanks to @ChangeHow). +- Usage: Failed refreshes keep the last known figures visible with an error, while other providers continue to load. +- Usage: OpenRouter reports key spending and limits accurately, including monthly spending for unlimited keys (thanks to @leducmaxime). +- Usage: Ollama Cloud dollar-based plans show monthly spending and extra credits; credential checks reject unreadable usage pages (thanks to @kydorn). +- Usage: NeuralWatt shows allowance percentages correctly in both used and remaining modes (thanks to @kydorn). +- Usage: Provider requests have enough time to connect on slower networks, fixing premature "fetch failed" errors (thanks to @ouyangjian28). +- Scrollbars: Hover reveals scrollbars in chat, Settings, and shared dialogs without moving the content sideways (thanks to @sergiofspedro). +- Language/Turkish: Activity and input-history settings use consistent agent and prompt terminology (thanks to @fitzgpt). + ## [1.22.2] - 2026-09-05 ### New diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 58aee894..f9f6be2e 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -2,7 +2,7 @@ "name": "openchamber", "displayName": "OpenChamber", "description": "%extension.description%", - "version": "1.22.2", + "version": "1.23.0", "publisher": "fedaykindev", "private": true, "repository": { @@ -288,7 +288,7 @@ }, "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "1.18.29", + "@opencode-ai/sdk": "1.18.30", "adm-zip": "^0.6.0", "jsonc-parser": "^3.3.1", "react": "^19.1.1", diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index fa988275..21980107 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -17,6 +17,7 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t - `bridge-git-special-runtime.ts` - Specialized Git flows (`pr-description`, `conflict-details`) and generation helpers. + - Generation model choice lives in `bridge-git-generation-model.ts`: request model first, then the user's small-model override (`smallModelUseDefault === false` plus `smallModelOverride` as `provider/model`) when the catalog has it, then the zen fallback. The old `gitProviderId`/`gitModelId` pair is no longer read. - `bridge-git-process-runtime.ts` - Git process execution and environment setup (`execGit`), including SSH agent socket resolution. @@ -49,6 +50,7 @@ The webview build emits each worker as one self-contained file. VS Code webviews - `bridge-localfs-proxy-runtime.ts` - Local `/api/fs/read` and `/api/fs/raw` proxy helpers and shared proxy utility helpers. + - `/api/fs/directory-stat` returns 501 locally. Directory-availability probes remain unknown in VS Code rather than falling through to OpenCode. - Workspace-contained Markdown gallery images use these local filesystem routes without calling the server grant route. Grant requests for OpenCode temporary-directory images return an explicit unsupported response instead @@ -64,8 +66,15 @@ The webview build emits each worker as one self-contained file. VS Code webviews - Includes OpenCode resolution diagnostics parity handler used by shared UI (`/api/config/opencode-resolution`). - OpenCode JSONC reads in `opencodeConfig.ts` fail closed on a partial or non-object `jsonc-parser` tree (`INVALID_JSONC`) so mutations cannot rewrite a `$schema`-only stub over an existing config. Comment-only files read as empty, while other content that yields no JSON value (YAML, plain text) fails closed. A broken layer is omitted from the merge and recorded on `layerErrors`; valid sibling layers still load, including plugin list/read via `getPluginConfigSources`. Writes still refuse to overwrite the broken file. +- `bridge-project-setup-runtime.ts` + - Extension-host side of `GET/PUT /api/projects/:projectId/config` (the webview handles the route locally and bridges `api:project-setup:get` / `api:project-setup:update`). Reads and writes the client-owned keys of `~/.config/openchamber/projects/.json` (worktree setup commands, project actions, draft starters) with the rules in `project-setup.ts`, a mirror of the server's `packages/web/server/lib/projects/project-setup.js`; keep the two in sync. Writes to one file are chained; server-owned and unknown keys survive. The read also merges the team's optional `/.openchamber/project.json` (checkout path decoded from the `path_` id) by the same rules as the server, so the webview sees one view with `shared` / `personal` blocks. The shared UI (`openchamberConfig.ts`) no longer composes that path or reads it through the fs bridge. - `bridge-settings-runtime.ts` - Settings read/write and OpenCode skills discovery via API for bridge consumers. + - Writes are gated by the generated registry snapshot (`settings-registry.json`, via `settings-registry-gate.ts`): keys the registry does not list, or marks `computed`, `local`, or `owner: desktop-shell`, never reach the shared settings files. Regenerate the snapshot with `bun run settings-registry:generate` when the UI registry changes. + - Shared settings live in two files under `~/.config/openchamber/`, split by `settings-files.ts` (a pure mirror of the server's `settings-files.js`; both write the same bytes): `settings.json` holds instance facts and legacy keys, `preferences.json` (`{ version: 1, fields: { key: { value, updatedAt } } }`) holds every registry `profile` key. `updatedAt` is stamped by the extension host only when a value actually changes. Reads return the merged view (preferences win). A missing `preferences.json` is seeded once from the profile keys still in `settings.json`; every write keeps a copy of the profile's base values in `settings.json` too, so a build from before the split (which reads only that file) still finds the user's preferences; it is ignored by current builds. + - An existing but unparseable `preferences.json` is a failure, not an empty profile: it is never seeded over or rewritten, one warning is logged per process, reads return `settings.json` only, and writes drop the profile part until a later read succeeds. + - Both files are written atomically (tmp file + rename). Write failures throw, so `persistSettings` rejects and the webview sees the save fail instead of a silent success. + - The extension host is always the `vscode` surface kind: per-surface profile keys it changes land under `surfaces.vscode` in `preferences.json` and reads resolve `vscode` first, base otherwise (mirrors the server's header-driven behaviour). - `bridge-system-runtime.ts` - System/editor/provider/quota/notification/update-check message handlers. @@ -74,6 +83,7 @@ The webview build emits each worker as one self-contained file. VS Code webviews - Owns managed OpenCode upgrade status and mutation handlers, including capability reporting, upgrade serialization, and process restart after a successful upgrade. - Provider handlers cover source lookup, disconnect (`DELETE /api/provider/:id/auth`), and custom provider upsert (`PUT /api/provider`; create/update OpenAI Chat Completions, OpenAI Responses, or Anthropic Messages config with explicit `scope` for user/project/custom layers; requires `env` or stored auth; secrets via OpenCode auth API). Updates preserve existing provider, option, and retained-model fields that the form does not manage while honoring explicit model, header, and env removal. Legacy `providers` entries migrate to the canonical `provider` key when edited. - Quota handlers keep managed exe.dev, Ollama Cloud, and Cursor credentials in the extension data directory with the same private-file contract as the web runtime. exe.dev uses one command-scoped usage token for the aggregate billing shared by every `exe-*` model provider. + - `ollamaQuota.ts` owns the Ollama settings request and parser shared by credential validation and quota refresh. Both reject redirects, failed HTTP responses, and pages without parsed windows, with a 15-second request timeout. Validation finishes before the bridge writes a replacement cookie. Monthly dollar quotas and legacy session/weekly/premium quotas remain supported; zero extra-credit balances are omitted. - `opencode-upgrade-runtime.ts` - Owns managed-versus-external capability decisions, latest-version checks, serialized OpenCode self-upgrades, and restart-after-upgrade behavior. @@ -93,6 +103,17 @@ The webview build emits each worker as one self-contained file. VS Code webviews Message and part ordering is owned by [`packages/ui/src/sync/DOCUMENTATION.md`](../../ui/src/sync/DOCUMENTATION.md#session-message-loading). The VS Code webview consumes that shared sync implementation; bridge and proxy runtimes pass OpenCode records through without adding runtime-specific ordering. +The OpenChamber control stream (`/api/openchamber/events`) requires the +OpenChamber server, which the extension does not run. `subscribeOpenchamberEvents` +therefore returns a no-op subscription in VS Code before resolving URLs or +opening a connection. Session sync still uses the OpenCode SSE bridge and +global session polling. Sending the control stream to the webview origin caused +repeated `403` responses and URL-token requests to `/auth/url-token`. + +Shared lazy imports retry a failed chunk load, but skip browser-navigation +recovery in VS Code. `window.location.reload()` is unsupported inside webviews; +the original import error must reach the UI error boundary instead. + ## Extension guideline When adding new bridge route families: @@ -174,12 +195,20 @@ Handlers with no reachable caller in the VS Code webview. | `api:fs:write`, `api:fs:rename`, `api:fs:delete`, `api:fs:reveal`, `api:fs:mkdir` | `FilesView`, `SidebarFilesTree`, `PlanView` only | | `api:fs:exec` | Terminal API is a throwing stub; no other caller | -Reachable filesystem routes: `api:fs:read` (attachments, config), `api:fs:search` +Reachable filesystem routes: `api:fs:read` (attachments), `api:fs:search` (`useFileSearchStore` behind composer file mentions), `api:fs:list`, `api:fs:stat`. Maintenance: reviews, changelog entries, and parity claims consult this map; whoever mounts or unmounts a surface updates it in the same change. +## Network connections + +Extension activation applies `networkDefaults.ts` before registering handlers. +It gives Node connection attempts 5 seconds, matching the web runtime, so quota +requests to distant providers can connect. This is an extension-host process +default, including other Node connections in that host. Address-family selection +stays unchanged; runtimes without the setter retain their existing behavior. + ## Global OpenCode paths `opencodeConfigPaths.ts` owns the global config directory for config CRUD, diff --git a/packages/vscode/src/bridge-git-generation-model.test.ts b/packages/vscode/src/bridge-git-generation-model.test.ts new file mode 100644 index 00000000..a8fe0eb0 --- /dev/null +++ b/packages/vscode/src/bridge-git-generation-model.test.ts @@ -0,0 +1,86 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { BRIDGE_ZEN_DEFAULT_MODEL, chooseBridgeGitGenerationModel } from './bridge-git-generation-model'; + +const catalogOf = (...refs: string[]) => { + const set = new Set(refs); + return (providerID: string, modelID: string) => set.has(`${providerID}/${modelID}`); +}; + +describe('chooseBridgeGitGenerationModel', () => { + test('request payload model wins when it is in the catalog', () => { + const choice = chooseBridgeGitGenerationModel( + { providerId: 'anthropic', modelId: 'claude-sonnet-4' }, + { smallModelUseDefault: false, smallModelOverride: 'openai/gpt-4.1-mini' }, + catalogOf('anthropic/claude-sonnet-4', 'openai/gpt-4.1-mini'), + ); + assert.deepEqual(choice, { providerID: 'anthropic', modelID: 'claude-sonnet-4' }); + }); + + test('small-model override is honoured when present in the catalog', () => { + const choice = chooseBridgeGitGenerationModel( + {}, + { smallModelUseDefault: false, smallModelOverride: 'openai/gpt-4.1-mini' }, + catalogOf('openai/gpt-4.1-mini'), + ); + assert.deepEqual(choice, { providerID: 'openai', modelID: 'gpt-4.1-mini' }); + }); + + test('override model ids may contain slashes; only the first splits provider from model', () => { + const choice = chooseBridgeGitGenerationModel( + {}, + { smallModelUseDefault: false, smallModelOverride: 'openrouter/meta/llama-3' }, + catalogOf('openrouter/meta/llama-3'), + ); + assert.deepEqual(choice, { providerID: 'openrouter', modelID: 'meta/llama-3' }); + }); + + test('override is ignored when smallModelUseDefault is not false', () => { + const hasModel = catalogOf('openai/gpt-4.1-mini'); + for (const useDefault of [true, undefined, 'false']) { + const choice = chooseBridgeGitGenerationModel( + {}, + { smallModelUseDefault: useDefault, smallModelOverride: 'openai/gpt-4.1-mini' }, + hasModel, + ); + assert.deepEqual(choice, { providerID: 'zen', modelID: BRIDGE_ZEN_DEFAULT_MODEL }); + } + }); + + test('override is ignored when it is not in the catalog or malformed', () => { + const hasModel = catalogOf('openai/gpt-4.1-mini'); + for (const override of ['openai/gpt-4o', 'openai', '/gpt-4.1-mini', 'openai/', ' ', 42]) { + const choice = chooseBridgeGitGenerationModel( + {}, + { smallModelUseDefault: false, smallModelOverride: override }, + hasModel, + ); + assert.deepEqual(choice, { providerID: 'zen', modelID: BRIDGE_ZEN_DEFAULT_MODEL }); + } + }); + + test('the removed gitProviderId/gitModelId pair is no longer read', () => { + const choice = chooseBridgeGitGenerationModel( + {}, + { gitProviderId: 'openai', gitModelId: 'gpt-4.1-mini' }, + catalogOf('openai/gpt-4.1-mini'), + ); + assert.deepEqual(choice, { providerID: 'zen', modelID: BRIDGE_ZEN_DEFAULT_MODEL }); + }); + + test('zen fallback prefers the request zen model, then settings, then the default', () => { + const none = () => false; + assert.deepEqual( + chooseBridgeGitGenerationModel({ zenModel: ' gpt-5-mini ' }, { zenModel: 'other' }, none), + { providerID: 'zen', modelID: 'gpt-5-mini' }, + ); + assert.deepEqual( + chooseBridgeGitGenerationModel({}, { zenModel: 'other' }, none), + { providerID: 'zen', modelID: 'other' }, + ); + assert.deepEqual( + chooseBridgeGitGenerationModel({}, {}, none), + { providerID: 'zen', modelID: BRIDGE_ZEN_DEFAULT_MODEL }, + ); + }); +}); diff --git a/packages/vscode/src/bridge-git-generation-model.ts b/packages/vscode/src/bridge-git-generation-model.ts new file mode 100644 index 00000000..25780ce5 --- /dev/null +++ b/packages/vscode/src/bridge-git-generation-model.ts @@ -0,0 +1,64 @@ +// Which model a bridge Git generation flow (PR description) talks to. Pure so +// the choice is unit-tested without `vscode`; the catalog lookup is injected. +// +// Order: the request's explicit model, then the user's small-model override +// from OpenChamber settings (the same setting every other utility generation in +// the product uses), then the zen fallback. + +export const BRIDGE_ZEN_DEFAULT_MODEL = 'gpt-5-nano'; + +export type BridgeGitGenerationPayloadModel = { + providerId?: string; + modelId?: string; + zenModel?: string; +}; + +type BridgeGitGenerationModelChoice = { providerID: string; modelID: string }; + +// Bridge settings are the merged persisted dictionary; a value is a string +// only when the stored file says so, hence the narrowing here. +const readStringField = (settings: Record, key: string): string => { + const candidate = settings[key]; + return typeof candidate === 'string' ? candidate.trim() : ''; +}; + +/** + * `smallModelOverride` is stored as `provider/model`; the model id may itself + * contain slashes, so only the first one separates the two. + */ +const readSmallModelOverride = (settings: Record): BridgeGitGenerationModelChoice | null => { + if (settings.smallModelUseDefault !== false) return null; + const override = readStringField(settings, 'smallModelOverride'); + const separator = override.indexOf('/'); + if (separator <= 0) return null; + const providerID = override.slice(0, separator).trim(); + const modelID = override.slice(separator + 1).trim(); + if (!providerID || !modelID) return null; + return { providerID, modelID }; +}; + +export const chooseBridgeGitGenerationModel = ( + payloadModel: BridgeGitGenerationPayloadModel, + settings: Record, + hasModel: (providerID: string, modelID: string) => boolean, +): BridgeGitGenerationModelChoice => { + // The payload reaches here from a webview message that is cast, not parsed, + // so a wrong-typed field must degrade to "absent" instead of throwing. + const requestProviderId = typeof payloadModel.providerId === 'string' ? payloadModel.providerId.trim() : ''; + const requestModelId = typeof payloadModel.modelId === 'string' ? payloadModel.modelId.trim() : ''; + if (requestProviderId && requestModelId && hasModel(requestProviderId, requestModelId)) { + return { providerID: requestProviderId, modelID: requestModelId }; + } + + const override = readSmallModelOverride(settings); + if (override && hasModel(override.providerID, override.modelID)) { + return override; + } + + const payloadZenModel = typeof payloadModel.zenModel === 'string' ? payloadModel.zenModel.trim() : ''; + const settingsZenModel = readStringField(settings, 'zenModel'); + return { + providerID: 'zen', + modelID: payloadZenModel || settingsZenModel || BRIDGE_ZEN_DEFAULT_MODEL, + }; +}; diff --git a/packages/vscode/src/bridge-git-special-runtime.ts b/packages/vscode/src/bridge-git-special-runtime.ts index 2169b613..e2923e74 100644 --- a/packages/vscode/src/bridge-git-special-runtime.ts +++ b/packages/vscode/src/bridge-git-special-runtime.ts @@ -2,6 +2,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { createOpencodeClient } from '@opencode-ai/sdk/v2'; import * as gitService from './gitService'; +import { chooseBridgeGitGenerationModel, type BridgeGitGenerationPayloadModel } from './bridge-git-generation-model'; import type { BridgeContext, BridgeResponse } from './bridge'; type BridgeMessageInput = { @@ -17,7 +18,6 @@ type SpecialGitDeps = { execGit: (args: string[], cwd: string) => Promise; }; -const BRIDGE_ZEN_DEFAULT_MODEL = 'gpt-5-nano'; const BRIDGE_GIT_GENERATION_TIMEOUT_MS = 2 * 60 * 1000; const BRIDGE_GIT_GENERATION_POLL_INTERVAL_MS = 500; const BRIDGE_GIT_MODEL_CATALOG_CACHE_TTL_MS = 30 * 1000; @@ -71,13 +71,6 @@ const createBridgeGitClient = (apiUrl: string, authHeaders?: Record { - if (!value || typeof value !== 'object') return ''; - const record = value as Record; - const candidate = record[key]; - return typeof candidate === 'string' ? candidate.trim() : ''; -}; - const fetchBridgeGitModelCatalog = async ( apiUrl: string, authHeaders?: Record @@ -115,7 +108,7 @@ const fetchBridgeGitModelCatalog = async ( }; const resolveBridgeGitGenerationModel = async ( - payloadModel: { providerId?: string; modelId?: string; zenModel?: string }, + payloadModel: BridgeGitGenerationPayloadModel, settings: Record, apiUrl: string, authHeaders?: Record @@ -134,24 +127,7 @@ const resolveBridgeGitGenerationModel = async ( return catalog.has(`${providerID}/${modelID}`); }; - const requestProviderId = typeof payloadModel.providerId === 'string' ? payloadModel.providerId.trim() : ''; - const requestModelId = typeof payloadModel.modelId === 'string' ? payloadModel.modelId.trim() : ''; - if (requestProviderId && requestModelId && hasModel(requestProviderId, requestModelId)) { - return { providerID: requestProviderId, modelID: requestModelId }; - } - - const settingsProviderId = readStringField(settings, 'gitProviderId'); - const settingsModelId = readStringField(settings, 'gitModelId'); - if (settingsProviderId && settingsModelId && hasModel(settingsProviderId, settingsModelId)) { - return { providerID: settingsProviderId, modelID: settingsModelId }; - } - - const payloadZenModel = typeof payloadModel.zenModel === 'string' ? payloadModel.zenModel.trim() : ''; - const settingsZenModel = readStringField(settings, 'zenModel'); - return { - providerID: 'zen', - modelID: payloadZenModel || settingsZenModel || BRIDGE_ZEN_DEFAULT_MODEL, - }; + return chooseBridgeGitGenerationModel(payloadModel, settings, hasModel); }; const extractTextFromMessageParts = (parts: unknown): string => { diff --git a/packages/vscode/src/bridge-localfs-proxy-runtime.test.js b/packages/vscode/src/bridge-localfs-proxy-runtime.test.js index 617eac7a..c551856f 100644 --- a/packages/vscode/src/bridge-localfs-proxy-runtime.test.js +++ b/packages/vscode/src/bridge-localfs-proxy-runtime.test.js @@ -61,6 +61,11 @@ describe('bridge local fs proxy', () => { expect(response?.status).toBe(404); }); + it('does not forward directory availability probes to OpenCode', async () => { + const response = await tryHandleLocalFsProxy('GET', '/api/fs/directory-stat?path=%2Fmissing-dir'); + expect(response?.status).toBe(501); + }); + it('reads from the active directory when it is the second workspace root', async () => { existingFiles.add('/workspace-two/image.png'); const response = await tryHandleLocalFsProxy( diff --git a/packages/vscode/src/bridge-localfs-proxy-runtime.ts b/packages/vscode/src/bridge-localfs-proxy-runtime.ts index ba1f3093..9254de54 100644 --- a/packages/vscode/src/bridge-localfs-proxy-runtime.ts +++ b/packages/vscode/src/bridge-localfs-proxy-runtime.ts @@ -56,6 +56,9 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string) } const fsProxyPath = normalizeFsProxyPath(parsed.pathname); + if (parsed.pathname === '/api/fs/directory-stat') { + return buildProxyJsonError(501, 'Directory availability probes are not supported in the VS Code runtime'); + } if (/^\/api\/openchamber\/sessions\/[^/]+\/markdown-image-grants$/.test(parsed.pathname)) { return buildProxyJsonError(501, 'Markdown image grants are not supported in the VS Code runtime'); } diff --git a/packages/vscode/src/bridge-project-setup-runtime.ts b/packages/vscode/src/bridge-project-setup-runtime.ts new file mode 100644 index 00000000..4ef2ca2e --- /dev/null +++ b/packages/vscode/src/bridge-project-setup-runtime.ts @@ -0,0 +1,192 @@ +// Extension-host side of the project setup routes +// (`GET/PUT /api/projects/:projectId/config`): the webview cannot reach the +// filesystem, so it bridges here and this module reads and writes +// `~/.config/openchamber/projects/.json` with the same rules the +// OpenChamber server applies (`project-setup.ts`). Server-owned keys in the +// file (`version`, `scheduledTasks`) and keys from newer builds survive a +// write untouched. + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + EMPTY_SHARED_PROJECT_CONFIG, + ProjectSetupValidationError, + SHARED_CONFIG_RELATIVE_PATH, + applySharedProjectSetupPatch, + isSharedProjectConfigEmpty, + mergeProjectSetup, + parseSharedProjectConfig, + personalProjectSetupOf, + projectSetupPatchToStored, + serializeSharedProjectConfig, + sharedTrustHashOf, + type ProjectSetupView, + type SharedProjectConfigRead, +} from './project-setup'; + +export type ProjectSetupBridgeMessage = { id: string; type: string; payload?: unknown }; +export type ProjectSetupBridgeResponse = { id: string; type: string; success: boolean; data?: unknown; error?: string }; + +export type ProjectSetupStore = { + read: (projectId: string) => Promise; + update: (projectId: string, patch: unknown) => Promise; + updateShared: (projectId: string, patch: unknown) => Promise; +}; + +const PROJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]+$/; + +/** The checkout a `path_` id names, or `''` for ids of another form. */ +export const projectPathFromId = (projectId: string): string => { + if (!projectId.startsWith('path_')) return ''; + const encoded = projectId.slice('path_'.length); + if (!encoded || !/^[A-Za-z0-9_-]+$/.test(encoded)) return ''; + return Buffer.from(encoded, 'base64url').toString('utf8'); +}; + +const isObjectRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); + +const sanitizeProjectId = (value: unknown): string => { + const projectId = typeof value === 'string' ? value.trim() : ''; + if (!projectId) throw new ProjectSetupValidationError('projectId is required'); + if (!PROJECT_ID_PATTERN.test(projectId)) throw new ProjectSetupValidationError('projectId contains unsupported characters'); + return projectId; +}; + +const readJsonDocument = async (filePath: string): Promise> => { + let raw: string; + try { + raw = await fs.promises.readFile(filePath, 'utf8'); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return {}; + throw error; + } + const parsed: unknown = JSON.parse(raw); + return isObjectRecord(parsed) ? parsed : {}; +}; + +const writeJsonAtomic = async (filePath: string, text: string): Promise => { + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + try { + await fs.promises.writeFile(tmp, text, 'utf8'); + await fs.promises.rename(tmp, filePath); + } catch (error) { + await fs.promises.rm(tmp, { force: true }).catch(() => {}); + throw error; + } +}; + +/** A store over one projects directory; the default is the shared OpenChamber one. */ +export const createProjectSetupStore = ( + projectsDir: string = path.join(os.homedir(), '.config', 'openchamber', 'projects'), +): ProjectSetupStore => { + const filePathFor = (projectId: string): string => path.join(projectsDir, `${sanitizeProjectId(projectId)}.json`); + // Writes to one file are chained so two quick saves from the webview cannot + // interleave their read-modify-write. + const writeChains = new Map>(); + + // The shared file lives in the checkout the id names (the personal file's + // `projectPath` is the fallback). A missing file is the normal case; an + // unreadable or unparsable one is reported, never treated as empty. + const projectPathOf = (projectId: string, personalRaw: Record): string => { + const storedPath = personalRaw.projectPath; + return projectPathFromId(projectId) || (typeof storedPath === 'string' ? storedPath.trim() : ''); + }; + const sharedConfigPathOf = (projectPath: string): string => path.join(projectPath, ...SHARED_CONFIG_RELATIVE_PATH.split('/')); + + const readShared = async (projectId: string, personalRaw: Record): Promise => { + const projectPath = projectPathOf(projectId, personalRaw); + if (!projectPath) return { status: 'missing' }; + let raw: string; + try { + raw = await fs.promises.readFile(sharedConfigPathOf(projectPath), 'utf8'); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return { status: 'missing' }; + return { status: 'invalid', reason: error instanceof Error ? error.message : String(error) }; + } + return parseSharedProjectConfig(raw); + }; + + const mergedViewOf = async (projectId: string, personalRaw: Record): Promise => + mergeProjectSetup(personalProjectSetupOf(personalRaw), await readShared(projectId, personalRaw)); + + const read = async (projectId: string): Promise => mergedViewOf(projectId, await readJsonDocument(filePathFor(projectId))); + + const update = async (projectId: string, patch: unknown): Promise => { + const filePath = filePathFor(projectId); + const stored = projectSetupPatchToStored(patch); + const previous = writeChains.get(filePath) ?? Promise.resolve(); + const next = previous.then(async () => { + const existing = await readJsonDocument(filePath); + const merged: Record = { ...existing, ...stored }; + for (const [key, value] of Object.entries(stored)) { + if (value === undefined) delete merged[key]; + } + await writeJsonAtomic(filePath, JSON.stringify(merged, null, 2)); + return mergedViewOf(projectId, merged); + }); + writeChains.set(filePath, next.catch(() => undefined)); + return next; + }; + + // The team's shared file in the checkout; same rules as the server: a + // broken file counts as empty, an empty result removes the file, and the + // writer's own trust record is set to the new hash. + const updateShared = async (projectId: string, patch: unknown): Promise => { + const filePath = filePathFor(projectId); + const previous = writeChains.get(filePath) ?? Promise.resolve(); + const next = previous.then(async () => { + const personalRaw = await readJsonDocument(filePath); + const projectPath = projectPathOf(projectId, personalRaw); + if (!projectPath) throw new ProjectSetupValidationError('project checkout not found'); + const isDirectory = await fs.promises.stat(projectPath).then((stat) => stat.isDirectory()).catch(() => false); + if (!isDirectory) throw new ProjectSetupValidationError('project checkout not found'); + const currentRead = await readShared(projectId, personalRaw); + const current = currentRead.status === 'ok' ? currentRead.config : EMPTY_SHARED_PROJECT_CONFIG; + const nextShared = applySharedProjectSetupPatch(current, patch); + const sharedPath = sharedConfigPathOf(projectPath); + if (isSharedProjectConfigEmpty(nextShared)) { + await fs.promises.rm(sharedPath, { force: true }); + await fs.promises.rmdir(path.dirname(sharedPath)).catch(() => {}); + } else { + await writeJsonAtomic(sharedPath, serializeSharedProjectConfig(nextShared)); + } + const hash = sharedTrustHashOf(nextShared); + const personalNext: Record = { ...personalRaw }; + if (hash) personalNext.sharedTrust = { hash, trustedAt: Date.now() }; + else delete personalNext.sharedTrust; + await writeJsonAtomic(filePath, JSON.stringify(personalNext, null, 2)); + return mergedViewOf(projectId, personalNext); + }); + writeChains.set(filePath, next.catch(() => undefined)); + return next; + }; + + return { read, update, updateShared }; +}; + +export async function handleProjectSetupBridgeMessage( + message: ProjectSetupBridgeMessage, + store: ProjectSetupStore, +): Promise { + const { id, type, payload } = message; + if (type !== 'api:project-setup:get' && type !== 'api:project-setup:update' && type !== 'api:project-setup:update-shared') return null; + + try { + const request = isObjectRecord(payload) ? payload : {}; + const projectId = sanitizeProjectId(request.projectId); + const data = type === 'api:project-setup:get' + ? await store.read(projectId) + : type === 'api:project-setup:update' + ? await store.update(projectId, request.patch) + : await store.updateShared(projectId, request.patch); + return { id, type, success: true, data }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Project config request failed'; + return { id, type, success: false, error: message }; + } +} + diff --git a/packages/vscode/src/bridge-settings-runtime.ts b/packages/vscode/src/bridge-settings-runtime.ts index c1976aa7..29bb1b08 100644 --- a/packages/vscode/src/bridge-settings-runtime.ts +++ b/packages/vscode/src/bridge-settings-runtime.ts @@ -5,9 +5,24 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { BUILT_IN_SKILL_LOCATION, type DiscoveredSkill, type SkillScope, type SkillSource } from './opencodeConfig'; import type { BridgeContext } from './bridge'; +import { filterPersistableSettingsChanges, withoutSecretSettings } from './settings-registry-gate'; +import { + buildPreferencesFields, + flattenPreferences, + instancePartOf, + legacySettingsDocumentOf, + profilePartOf, + parsePreferencesDocument, + preferencesFilePathFor, + seedPreferencesFrom, + serializePreferencesDocument, + type PreferenceFields, + VSCODE_SETTINGS_SURFACE, +} from './settings-files'; const SETTINGS_KEY = 'openchamber.settings'; const OPENCHAMBER_SHARED_SETTINGS_PATH = path.join(os.homedir(), '.config', 'openchamber', 'settings.json'); +const OPENCHAMBER_PREFERENCES_PATH = preferencesFilePathFor(OPENCHAMBER_SHARED_SETTINGS_PATH); const OPENCHAMBER_MAGIC_PROMPTS_PATH = path.join(os.homedir(), '.config', 'openchamber', 'magic-prompts.json'); const MAGIC_PROMPTS_FILE_VERSION = 1; const MAGIC_PROMPT_ID_PATTERN = /^[a-z0-9._-]{1,160}$/; @@ -160,11 +175,21 @@ export const fetchOpenCodeSkillsFromApi = async ( } }; -const readSharedSettingsFromDisk = (): Record => { +// Settings live in two files beside each other (see `settings-files.ts`): +// `settings.json` holds instance facts and legacy keys, `preferences.json` +// holds the profile keys with their `updatedAt` stamps. Reads return the +// merged view; writes split a merged document back into the two files. +// +// A settings.json parse failure (corrupt or non-object file) is still coerced +// to `{}`, which lets the next write replace it; tracked in the settings-scopes +// plan. preferences.json already fails closed below. +const readSettingsJsonFromDisk = (): Record => { try { const raw = fs.readFileSync(OPENCHAMBER_SHARED_SETTINGS_PATH, 'utf8'); + // SAFETY: JSON.parse returns untyped data; the check below keeps only a plain object. const parsed = JSON.parse(raw) as unknown; if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + // SAFETY: a non-array object parsed from JSON is a string-keyed dictionary. return parsed as Record; } return {}; @@ -173,22 +198,122 @@ const readSharedSettingsFromDisk = (): Record => { } }; -const writeSharedSettingsToDisk = async (changes: Record): Promise => { - let tmp: string | null = null; +type PreferencesReadResult = + | { status: 'ok'; fields: PreferenceFields } + | { status: 'missing' } + | { status: 'unreadable'; reason: string }; + +// True after preferences.json was found but could not be read or parsed. While +// set, the file is left alone: reads return settings.json only and writes drop +// profile keys instead of replacing a file whose content we cannot see. +let preferencesUnavailable = false; +let preferencesUnavailableLogged = false; + +const readPreferencesFromDisk = (): PreferencesReadResult => { + let result: PreferencesReadResult; try { - await fs.promises.mkdir(path.dirname(OPENCHAMBER_SHARED_SETTINGS_PATH), { recursive: true }); - const current = readSharedSettingsFromDisk(); - const next: Record = { ...current, ...changes }; - // Atomic write: tmp file + rename. Readers never see a partial/truncated - // JSON that would fail to parse and silently get coerced to {}. - tmp = `${OPENCHAMBER_SHARED_SETTINGS_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - await fs.promises.writeFile(tmp, JSON.stringify(next, null, 2), 'utf8'); - await fs.promises.rename(tmp, OPENCHAMBER_SHARED_SETTINGS_PATH); - } catch { - if (tmp) { - await fs.promises.rm(tmp, { force: true }).catch(() => {}); - } + const parsed = parsePreferencesDocument(fs.readFileSync(OPENCHAMBER_PREFERENCES_PATH, 'utf8')); + result = parsed.ok ? { status: 'ok', fields: parsed.fields } : { status: 'unreadable', reason: parsed.reason }; + } catch (error) { + // SAFETY: fs errors carry a `code` string; anything else is reported by message. + const code = (error as NodeJS.ErrnoException | null)?.code; + result = code === 'ENOENT' + ? { status: 'missing' } + : { status: 'unreadable', reason: error instanceof Error ? error.message : String(error) }; } + + if (result.status === 'unreadable') { + preferencesUnavailable = true; + if (!preferencesUnavailableLogged) { + preferencesUnavailableLogged = true; + console.warn(`[OpenChamber] ${OPENCHAMBER_PREFERENCES_PATH} could not be read (${result.reason}); profile settings are unavailable until the file is fixed or removed.`); + } + } else { + preferencesUnavailable = false; + } + return result; +}; + +// Atomic write: tmp file + rename, so readers never see a partial JSON. Throws +// on failure (after removing the tmp file) so a failed save is reported, not +// mistaken for success. +const writeJsonAtomic = async (filePath: string, text: string): Promise => { + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + try { + await fs.promises.writeFile(tmp, text, 'utf8'); + await fs.promises.rename(tmp, filePath); + } catch (error) { + await fs.promises.rm(tmp, { force: true }).catch(() => {}); + throw error; + } +}; + +const writeJsonAtomicSync = (filePath: string, text: string): void => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + try { + fs.writeFileSync(tmp, text, 'utf8'); + fs.renameSync(tmp, filePath); + } catch (error) { + try { + fs.rmSync(tmp, { force: true }); + } catch { + // Nothing more to clean up. + } + throw error; + } +}; + +// Merged view of both files. A missing preferences.json is seeded once from the +// profile keys settings.json still carries; every write keeps a copy of the +// profile's base values in settings.json, so an older build can still read it. +const readSharedSettingsFromDisk = (): Record => { + const settings = readSettingsJsonFromDisk(); + let preferences = readPreferencesFromDisk(); + if (preferences.status === 'missing') { + const seeded = seedPreferencesFrom(stripDerived(settings), Date.now()); + try { + writeJsonAtomicSync(OPENCHAMBER_PREFERENCES_PATH, serializePreferencesDocument(seeded)); + } catch (error) { + console.warn('[OpenChamber] Failed to seed preferences.json:', error instanceof Error ? error.message : String(error)); + } + preferences = { status: 'ok', fields: seeded }; + } + if (preferences.status !== 'ok') { + return settings; + } + return { ...settings, ...flattenPreferences(preferences.fields, VSCODE_SETTINGS_SURFACE) }; +}; + +// Write a complete merged document: profile keys go to preferences.json (keeping +// the stamps of unchanged values), everything else to settings.json. A key the +// document no longer carries leaves whichever file owned it. +const writeSharedSettingsToDisk = async ( + document: Record, + changedKeys: Iterable | null = null, +): Promise => { + const preferences = readPreferencesFromDisk(); + if (preferencesUnavailable) { + console.warn('[OpenChamber] preferences.json is unreadable; profile settings were not saved.'); + // settings.json keeps whatever legacy profile copy it already holds. + const onDisk = readSettingsJsonFromDisk(); + await writeJsonAtomic(OPENCHAMBER_SHARED_SETTINGS_PATH, JSON.stringify({ + ...instancePartOf(document), + ...profilePartOf(onDisk), + }, null, 2)); + return; + } + const previousFields = preferences.status === 'ok' ? preferences.fields : {}; + // This host is always the VS Code surface kind: per-surface profile keys it + // changed land under `surfaces.vscode`; keys it did not change keep their entry. + const nextFields = buildPreferencesFields(previousFields, document, Date.now(), { + surface: VSCODE_SETTINGS_SURFACE, + changedKeys, + }); + await writeJsonAtomic(OPENCHAMBER_PREFERENCES_PATH, serializePreferencesDocument(nextFields)); + // The legacy copy of the profile's base values rides along for older builds. + await writeJsonAtomic(OPENCHAMBER_SHARED_SETTINGS_PATH, JSON.stringify(legacySettingsDocumentOf(document, nextFields), null, 2)); }; // Fields derived from runtime context — never persisted, always recomputed. @@ -299,15 +424,19 @@ const readPersistedSettings = (ctx?: BridgeContext): Record => } if (Object.keys(missingFromDisk).length > 0) { // Fire-and-forget; readers already have an in-memory merged view. - void writeSharedSettingsToDisk(missingFromDisk); + void writeSharedSettingsToDisk({ ...fromDisk, ...missingFromDisk }).catch((error: unknown) => { + console.warn('[OpenChamber] Failed to migrate settings from globalState:', error instanceof Error ? error.message : String(error)); + }); } } return { ...fromGlobalState, ...fromDisk }; }; +// Everything the webview may see: the persisted document minus the keys the +// registry marks `secret` (a UI password, tunnel tokens), which are write-only. export const readSettings = (ctx?: BridgeContext): Record => { - const persisted = readPersistedSettings(ctx); + const persisted = withoutSecretSettings(readPersistedSettings(ctx)); const persistedOpencodeBinary = typeof persisted.opencodeBinary === 'string' ? String(persisted.opencodeBinary).trim() : ''; const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; @@ -327,7 +456,8 @@ export const readSettings = (ctx?: BridgeContext): Record => { export const persistSettings = async (changes: Record, ctx?: BridgeContext): Promise> => { const current = readSettings(ctx); - const restChanges = stripDerived({ ...(changes || {}) }); + // Only keys the settings registry knows as stored shared fields reach disk. + const restChanges = filterPersistableSettingsChanges(stripDerived({ ...(changes || {}) })); const keysToClear = new Set(); @@ -386,15 +516,15 @@ export const persistSettings = async (changes: Record, ctx?: Br delete persistable[key]; } - // Write to the shared file (canonical, cross-client). Also mirror into - // globalState so older builds can still read recent values if a user - // downgrades the extension. - await writeSharedSettingsToDisk(persistable); + // Write to the shared files (canonical, cross-client); a failed write rejects + // so the webview reports the save as failed. Also mirror into globalState so + // older builds can still read recent values if a user downgrades the extension. + await writeSharedSettingsToDisk(persistable, [...Object.keys(restChanges), ...keysToClear]); await ctx?.context?.globalState.update(SETTINGS_KEY, persistable); - // Return the same shape as readSettings (with derived fields re-applied). + // Return the same shape as readSettings (derived fields re-applied, secrets withheld). return { - ...persistable, + ...withoutSecretSettings(persistable), themeVariant: current.themeVariant, lastDirectory: current.lastDirectory, opencodeBinary: diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index 300f6973..211a7dc7 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -7,6 +7,7 @@ import { handleConfigBridgeMessage } from './bridge-config-runtime'; import { handleSystemBridgeMessage } from './bridge-system-runtime'; import { handleProxyBridgeMessage } from './bridge-proxy-runtime'; import { handlePermissionAutoAcceptBridgeMessage } from './bridge-permission-auto-accept-runtime'; +import { createProjectSetupStore, handleProjectSetupBridgeMessage } from './bridge-project-setup-runtime'; import { fetchOpenCodeSkillsFromApi, persistSettings, @@ -55,6 +56,7 @@ export interface BridgeContext { } const CLIENT_RELOAD_DELAY_MS = 800; +const projectSetupStore = createProjectSetupStore(); const UPDATE_CHECK_URL = process.env.OPENCHAMBER_UPDATE_API_URL || 'https://api.openchamber.dev/v1/update/check'; const GITHUB_BACKEND_DISABLED_ERROR = 'OpenChamber VS Code backend GitHub integration is disabled. Use native VS Code GitHub integrations.'; @@ -88,6 +90,10 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo if (specialGitResponse) { return specialGitResponse; } + const projectSetupResponse = await handleProjectSetupBridgeMessage({ id, type, payload }, projectSetupStore); + if (projectSetupResponse) { + return projectSetupResponse; + } const fsResponse = await handleFsBridgeMessage( { id, type, payload }, { diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 036f543f..32c8a098 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -7,6 +7,7 @@ import { startGlobalEventWatcher, stopGlobalEventWatcher, setChatViewProvider } import { pathsEqualWithNormalizedDriveLetter } from './pathUtils'; import { resolveWorkspaceFolders } from './workspaceResolver'; import { InlineCommentThreads, SIDEBAR_SURFACE_ID } from './InlineCommentThreads'; +import { applyConnectAttemptTimeout } from './networkDefaults'; let chatViewProvider: ChatViewProvider | undefined; @@ -52,6 +53,7 @@ const formatDurationMs = (value: number | null | undefined) => { }; export async function activate(context: vscode.ExtensionContext) { + applyConnectAttemptTimeout(); outputChannel = vscode.window.createOutputChannel('OpenChamber'); let moveToRightSidebarScheduled = false; diff --git a/packages/vscode/src/networkDefaults.test.ts b/packages/vscode/src/networkDefaults.test.ts new file mode 100644 index 00000000..032033aa --- /dev/null +++ b/packages/vscode/src/networkDefaults.test.ts @@ -0,0 +1,24 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as net from 'node:net'; +import { applyConnectAttemptTimeout } from './networkDefaults'; + +test('allows slow connections without changing address-family selection', () => { + const previousTimeout = net.getDefaultAutoSelectFamilyAttemptTimeout(); + const previousFamily = net.getDefaultAutoSelectFamily(); + try { + net.setDefaultAutoSelectFamilyAttemptTimeout(250); + assert.equal(applyConnectAttemptTimeout(), true); + assert.equal(net.getDefaultAutoSelectFamilyAttemptTimeout(), 5_000); + assert.equal(net.getDefaultAutoSelectFamily(), previousFamily); + } finally { + net.setDefaultAutoSelectFamilyAttemptTimeout(previousTimeout); + } +}); + +test('unsupported runtimes retain their existing behavior', () => { + assert.equal(applyConnectAttemptTimeout({}), false); + assert.equal(applyConnectAttemptTimeout({ + setDefaultAutoSelectFamilyAttemptTimeout() { throw new Error('unsupported'); }, + }), false); +}); diff --git a/packages/vscode/src/networkDefaults.ts b/packages/vscode/src/networkDefaults.ts new file mode 100644 index 00000000..293b7214 --- /dev/null +++ b/packages/vscode/src/networkDefaults.ts @@ -0,0 +1,15 @@ +import * as net from 'node:net'; + +// Mirrors the web runtime policy for distant quota endpoints. The extension +// host has its own Node fetch stack and does not inherit server defaults. +export function applyConnectAttemptTimeout( + netModule: Partial> = net, +): boolean { + try { + if (!netModule.setDefaultAutoSelectFamilyAttemptTimeout) return false; + netModule.setDefaultAutoSelectFamilyAttemptTimeout(5_000); + return true; + } catch { + return false; + } +} diff --git a/packages/vscode/src/ollamaQuota.ts b/packages/vscode/src/ollamaQuota.ts new file mode 100644 index 00000000..3814a897 --- /dev/null +++ b/packages/vscode/src/ollamaQuota.ts @@ -0,0 +1,64 @@ +type OllamaWindow = { usedPercent: number | null; valueLabel?: string }; +type OllamaFetch = (url: string, init: RequestInit) => Promise; + +export const fetchOllamaUsage = async (cookie: string, fetchImpl: OllamaFetch = fetch) => { + const response = await fetchImpl('https://ollama.com/settings', { + method: 'GET', + headers: { + Cookie: cookie, + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', + }, + redirect: 'manual', + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) throw new Error('Ollama Cloud authentication failed'); + + const html = await response.text(); + const windows: Record = {}; + for (const [key, pattern] of [ + ['session', /Session\s+usage[^0-9]*([0-9.]+)%/i], + ['weekly', /Weekly\s+usage[^0-9]*([0-9.]+)%/i], + ] as const) { + const match = html.match(pattern); + if (!match) continue; + const usedPercent = Number(match[1]); + if (Number.isFinite(usedPercent)) { + windows[key] = { usedPercent }; + } + } + + const premium = html.match(/Premium[^0-9]*([0-9]+)\s*\/\s*([0-9]+)/i); + if (premium) { + const used = Number(premium[1]); + const total = Number(premium[2]); + if (Number.isFinite(used) && Number.isFinite(total)) { + windows.premium = { + usedPercent: total > 0 ? Math.min(100, (used / total) * 100) : null, + valueLabel: `${used} / ${total}`, + }; + } + } + + const monthly = html.match(/Monthly\s+usage[\s\S]{0,200}?\$([0-9][0-9,.]*)\s+of\s+\$([0-9][0-9,.]*)/i); + if (monthly) { + const used = Number(monthly[1].replace(/,/g, '')); + const total = Number(monthly[2].replace(/,/g, '')); + if (Number.isFinite(used) && Number.isFinite(total)) { + windows.monthly = { + usedPercent: total > 0 ? Math.min(100, (used / total) * 100) : null, + valueLabel: `$${monthly[1]} / $${monthly[2]}`, + }; + } + } + + // Anchor on the balance label, not nearby purchase or auto-reload amounts. + const balanceMatch = html.match(/Balance\s+remaining[\s\S]{0,200}?\$([0-9][0-9,.]*)/i); + if (balanceMatch) { + const balance = Number(balanceMatch[1].replace(/,/g, '')); + if (Number.isFinite(balance) && balance > 0) { + windows.credits_balance = { usedPercent: null, valueLabel: `$${balanceMatch[1]}` }; + } + } + if (Object.keys(windows).length === 0) throw new Error('Ollama Cloud usage data could not be parsed'); + return windows; +}; diff --git a/packages/vscode/src/project-setup.test.ts b/packages/vscode/src/project-setup.test.ts new file mode 100644 index 00000000..f61a01d4 --- /dev/null +++ b/packages/vscode/src/project-setup.test.ts @@ -0,0 +1,258 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + ProjectSetupValidationError, + mergeProjectSetup, + normalizePlansDir, + parseSharedProjectConfig, + personalProjectSetupOf, + projectSetupPatchToStored, + sanitizeDraftStarters, + sanitizeProjectActions, + sanitizeSetupCommands, + sharedTrustHashOf, + type PersonalProjectSetup, +} from './project-setup'; +import { createProjectSetupStore, handleProjectSetupBridgeMessage, projectPathFromId } from './bridge-project-setup-runtime'; + +const emptyPersonal: PersonalProjectSetup = { + setupWorktree: [], + setupWorktreeWait: null, + setupWorktreeMode: 'append', + projectActions: [], + projectActionsPrimaryId: null, + draftStarters: [], + hiddenSharedActionIds: [], + sharedTrust: null, +}; + +const projectIdFor = (projectPath: string): string => `path_${Buffer.from(projectPath, 'utf8').toString('base64url')}`; + +describe('project setup sanitizers', () => { + test('keeps only non-empty trimmed setup commands', () => { + assert.deepEqual(sanitizeSetupCommands([' bun install ', '', 42, '\n']), ['bun install']); + assert.deepEqual(sanitizeSetupCommands('bun install'), []); + }); + + test('drops incomplete actions and duplicate ids, keeps only set optional fields', () => { + assert.deepEqual(sanitizeProjectActions([ + { id: 'a', name: 'Dev', command: 'bun run dev', runIn: 'parent', platforms: ['macos', 'plan9'], icon: '' }, + { id: 'a', name: 'Again', command: 'x' }, + { id: '', name: 'No id', command: 'x' }, + { id: 'b', name: 'B', command: 'x', runIn: 'worktree' }, + ]), [ + { id: 'a', name: 'Dev', command: 'bun run dev', icon: null, platforms: ['macos'], runIn: 'parent' }, + { id: 'b', name: 'B', command: 'x', icon: null }, + ]); + }); + + test('dedupes draft starters by type and name', () => { + assert.deepEqual(sanitizeDraftStarters([ + { type: 'skill', name: 'triage-prs' }, + { type: 'skill', name: 'triage-prs' }, + { type: 'agent', name: 'nope' }, + ]), [{ type: 'skill', name: 'triage-prs' }]); + }); + + test('builds the personal view from on-disk keys and nulls a dangling primary action', () => { + assert.deepEqual(personalProjectSetupOf({ + 'setup-worktree': ['bun install'], + 'setup-worktree-wait': true, + setupWorktreeMode: 'replace', + projectActions: [{ id: 'a', name: 'A', command: 'x' }], + projectActionsPrimaryId: 'missing', + hiddenSharedActionIds: ['dev', 'dev', 3], + }), { + setupWorktree: ['bun install'], + setupWorktreeWait: true, + setupWorktreeMode: 'replace', + projectActions: [{ id: 'a', name: 'A', command: 'x', icon: null }], + projectActionsPrimaryId: null, + draftStarters: [], + hiddenSharedActionIds: ['dev'], + sharedTrust: null, + }); + assert.deepEqual(personalProjectSetupOf(null), emptyPersonal); + }); + + test('parses a shared file and refuses a broken one', () => { + const ok = parseSharedProjectConfig(JSON.stringify({ version: 1, setupWorktree: ['bun install'], plansDir: 'docs/plans' })); + assert.equal(ok.status, 'ok'); + if (ok.status === 'ok') { + assert.deepEqual(ok.config, { setupWorktree: ['bun install'], setupWorktreeWait: null, projectActions: [], draftStarters: [], plansDir: 'docs/plans' }); + } + assert.equal(parseSharedProjectConfig('{ nope').status, 'invalid'); + assert.equal(parseSharedProjectConfig('{"version":2}').status, 'invalid'); + assert.equal(parseSharedProjectConfig('{"version":1,"plansDir":"../x"}').status, 'invalid'); + assert.equal(normalizePlansDir('./docs/plans/'), 'docs/plans'); + assert.equal(normalizePlansDir('/abs'), null); + }); + + test('merges shared and personal by the agreed rules', () => { + const merged = mergeProjectSetup({ + ...emptyPersonal, + setupWorktree: ['mine'], + projectActions: [{ id: 'test', name: 'My test', command: 'x', icon: null }], + hiddenSharedActionIds: ['lint'], + draftStarters: [{ type: 'command', name: 'both' }, { type: 'command', name: 'mine' }], + }, { + status: 'ok', + config: { + setupWorktree: ['bun install'], + setupWorktreeWait: true, + projectActions: [ + { id: 'dev', name: 'Dev', command: 'd', icon: null }, + { id: 'test', name: 'Test', command: 't', icon: null }, + { id: 'lint', name: 'Lint', command: 'l', icon: null }, + ], + draftStarters: [{ type: 'command', name: 'both' }], + plansDir: null, + }, + }); + assert.deepEqual(merged.setupWorktree, ['bun install', 'mine']); + assert.equal(merged.setupWorktreeWait, true); + assert.deepEqual(merged.projectActions.map((action) => `${action.id}:${action.source}`), ['dev:shared', 'test:personal']); + assert.deepEqual(merged.draftStarters.map((starter) => `${starter.name}:${starter.source}`), ['both:shared', 'mine:personal']); + assert.equal(merged.trust.trusted, false); + assert.match(merged.trust.hash ?? '', /^sha256:/); + }); + + test('trusts only the recorded hash and nothing when nothing executes', () => { + const shared = { setupWorktree: ['bun install'], setupWorktreeWait: null, projectActions: [], draftStarters: [], plansDir: null }; + const hash = sharedTrustHashOf(shared); + assert.equal(mergeProjectSetup({ ...emptyPersonal, sharedTrust: { hash: hash ?? '', trustedAt: 1 } }, { status: 'ok', config: shared }).trust.trusted, true); + assert.equal(mergeProjectSetup({ ...emptyPersonal, sharedTrust: { hash: 'sha256:old', trustedAt: 1 } }, { status: 'ok', config: shared }).trust.trusted, false); + assert.deepEqual(mergeProjectSetup(emptyPersonal, { status: 'missing' }).trust, { hash: null, trusted: true }); + assert.equal(sharedTrustHashOf({ ...shared, setupWorktree: [] }), null); + assert.deepEqual(projectSetupPatchToStored({ sharedTrustHash: null }), { sharedTrust: undefined }); + assert.throws(() => projectSetupPatchToStored({ sharedTrustHash: '' }), ProjectSetupValidationError); + }); + + test('rejects wrongly shaped patch keys', () => { + assert.throws(() => projectSetupPatchToStored({ setupWorktree: 'x' }), ProjectSetupValidationError); + assert.throws(() => projectSetupPatchToStored(null), ProjectSetupValidationError); + assert.deepEqual(projectSetupPatchToStored({ projectActionsPrimaryId: null }), { projectActionsPrimaryId: undefined }); + }); +}); + +describe('project setup bridge', () => { + const withStore = async (run: (store: ReturnType, dir: string) => Promise) => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'oc-vscode-project-setup-')); + try { + await run(createProjectSetupStore(dir), dir); + } finally { + await fs.promises.rm(dir, { recursive: true, force: true }); + } + }; + + test('round-trips a patch through the bridge and preserves foreign keys', async () => { + await withStore(async (store, dir) => { + await fs.promises.writeFile(path.join(dir, 'project-a.json'), JSON.stringify({ + version: 1, + scheduledTasks: [{ id: 'keep' }], + 'setup-worktree': ['old'], + })); + + const updated = await handleProjectSetupBridgeMessage( + { id: '1', type: 'api:project-setup:update', payload: { projectId: 'project-a', patch: { setupWorktree: ['bun install'], projectPath: '/repo' } } }, + store, + ); + assert.equal(updated?.success, true); + const view = updated?.data as { setupWorktree: string[]; setupWorktreeWait: boolean; shared: { status: string } }; + assert.deepEqual(view.setupWorktree, ['bun install']); + assert.equal(view.setupWorktreeWait, false); + assert.equal(view.shared.status, 'missing'); + + const raw = JSON.parse(await fs.promises.readFile(path.join(dir, 'project-a.json'), 'utf8')); + assert.deepEqual(raw.scheduledTasks, [{ id: 'keep' }]); + assert.equal(raw.projectPath, '/repo'); + + const read = await handleProjectSetupBridgeMessage({ id: '2', type: 'api:project-setup:get', payload: { projectId: 'project-a' } }, store); + assert.deepEqual(read?.data, updated?.data); + }); + }); + + test('answers a bad patch or project id with a failure, and ignores other messages', async () => { + await withStore(async (store) => { + const bad = await handleProjectSetupBridgeMessage( + { id: '1', type: 'api:project-setup:update', payload: { projectId: 'project-a', patch: { setupWorktree: 'x' } } }, + store, + ); + assert.equal(bad?.success, false); + assert.match(bad?.error ?? '', /setupWorktree must be/); + + const badId = await handleProjectSetupBridgeMessage({ id: '2', type: 'api:project-setup:get', payload: { projectId: '../etc' } }, store); + assert.equal(badId?.success, false); + + assert.equal(await handleProjectSetupBridgeMessage({ id: '3', type: 'api:fs:read', payload: {} }, store), null); + }); + }); + + test('reads the shared file from the checkout the id names', async () => { + await withStore(async (store, dir) => { + const repo = path.join(dir, 'repo'); + await fs.promises.mkdir(path.join(repo, '.openchamber'), { recursive: true }); + await fs.promises.writeFile(path.join(repo, '.openchamber', 'project.json'), JSON.stringify({ + version: 1, + setupWorktree: ['bun install'], + projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }], + })); + const projectId = projectIdFor(repo); + assert.equal(projectPathFromId(projectId), repo); + const view = await store.update(projectId, { setupWorktree: ['mine'], hiddenSharedActionIds: ['dev'] }); + assert.equal(view.shared.status, 'ok'); + assert.deepEqual(view.setupWorktree, ['bun install', 'mine']); + assert.deepEqual(view.projectActions, []); + await fs.promises.writeFile(path.join(repo, '.openchamber', 'project.json'), '{ broken'); + const broken = await store.read(projectId); + assert.equal(broken.shared.status, 'invalid'); + assert.deepEqual(broken.setupWorktree, ['mine']); + }); + }); + + test('writes and removes the shared file through the bridge, trusting the writer', async () => { + await withStore(async (store, dir) => { + const repo = path.join(dir, 'repo'); + await fs.promises.mkdir(repo, { recursive: true }); + const projectId = projectIdFor(repo); + const shared = await handleProjectSetupBridgeMessage( + { id: '1', type: 'api:project-setup:update-shared', payload: { projectId, patch: { setupWorktree: ['bun install'], plansDir: 'docs/plans' } } }, + store, + ); + assert.equal(shared?.success, true); + const view = shared?.data as { trust: { trusted: boolean }; shared: { status: string; plansDir: string | null } }; + assert.equal(view.shared.status, 'ok'); + assert.equal(view.shared.plansDir, 'docs/plans'); + assert.equal(view.trust.trusted, true); + const raw = JSON.parse(await fs.promises.readFile(path.join(repo, '.openchamber', 'project.json'), 'utf8')); + assert.deepEqual(raw, { version: 1, setupWorktree: ['bun install'], plansDir: 'docs/plans' }); + + const emptied = await store.updateShared(projectId, { setupWorktree: [], plansDir: null }); + assert.equal(emptied.shared.status, 'missing'); + assert.equal(fs.existsSync(path.join(repo, '.openchamber')), false); + + const missing = await handleProjectSetupBridgeMessage( + { id: '2', type: 'api:project-setup:update-shared', payload: { projectId: projectIdFor(path.join(dir, 'nope')), patch: {} } }, + store, + ); + assert.equal(missing?.success, false); + assert.match(missing?.error ?? '', /checkout not found/); + }); + }); + + test('serializes two quick updates to one file', async () => { + await withStore(async (store, dir) => { + await Promise.all([ + store.update('project-a', { setupWorktree: ['a'] }), + store.update('project-a', { draftStarters: [{ type: 'skill', name: 's' }] }), + ]); + const raw = JSON.parse(await fs.promises.readFile(path.join(dir, 'project-a.json'), 'utf8')); + assert.deepEqual(raw['setup-worktree'], ['a']); + assert.deepEqual(raw.draftStarters, [{ type: 'skill', name: 's' }]); + }); + }); +}); diff --git a/packages/vscode/src/project-setup.ts b/packages/vscode/src/project-setup.ts new file mode 100644 index 00000000..da0429e4 --- /dev/null +++ b/packages/vscode/src/project-setup.ts @@ -0,0 +1,445 @@ +// The client-owned part of a project's config file +// (`~/.config/openchamber/projects/.json`): worktree setup +// commands, project actions, and pinned draft starters. A mirror of the +// server's `packages/web/server/lib/projects/project-setup.js`; keep the +// sanitizing rules in sync so a value written from VS Code reads back the +// same on every other surface. +// +// Kept free of `vscode` imports so it is unit-tested directly. + +import crypto from 'node:crypto'; + +const ACTION_NAME_MAX_LENGTH = 80; +const ACTION_COMMAND_MAX_LENGTH = 4000; +const ACTION_OPEN_URL_MAX_LENGTH = 2000; +const ACTION_DESKTOP_FORWARD_MAX_LENGTH = 300; +const SETUP_COMMAND_MAX_LENGTH = 4000; +const SETUP_COMMANDS_MAX = 50; + +type ActionPlatform = 'macos' | 'linux' | 'windows'; +const ACTION_PLATFORMS: ReadonlySet = new Set(['macos', 'linux', 'windows']); + +export type ProjectAction = { + id: string; + name: string; + command: string; + icon: string | null; + autoOpenUrl?: true; + openUrl?: string; + desktopOpenSshForward?: string; + platforms?: ActionPlatform[]; + runIn?: 'parent'; +}; + +export type DraftStarter = { type: 'command' | 'skill'; name: string }; + +export type SetupWorktreeMode = 'append' | 'replace'; + +/** The personal file's part of the setup; the wait flag is `null` when the file does not set it. */ +export type PersonalProjectSetup = { + setupWorktree: string[]; + setupWorktreeWait: boolean | null; + setupWorktreeMode: SetupWorktreeMode; + projectActions: ProjectAction[]; + projectActionsPrimaryId: string | null; + draftStarters: DraftStarter[]; + hiddenSharedActionIds: string[]; + /** The recorded answer to the trust prompt: which shared commands were trusted, and when. */ + sharedTrust: { hash: string; trustedAt: number } | null; +}; + +export type SharedProjectConfig = { + setupWorktree: string[]; + setupWorktreeWait: boolean | null; + projectActions: ProjectAction[]; + draftStarters: DraftStarter[]; + plansDir: string | null; +}; + +export type SharedProjectConfigRead = + | { status: 'missing' } + | { status: 'ok'; config: SharedProjectConfig } + | { status: 'invalid'; reason: string }; + +export type ProjectSetupSource = 'shared' | 'personal'; + +/** The merged view every client sees; see `mergeProjectSetup` for the rules. */ +export type ProjectSetupView = { + /** Nothing to trust when `hash` is null; otherwise trusted only for the recorded hash. */ + trust: { hash: string | null; trusted: boolean }; + setupWorktree: string[]; + setupWorktreeWait: boolean; + projectActions: Array; + projectActionsPrimaryId: string | null; + draftStarters: Array; + shared: SharedProjectConfig & { status: SharedProjectConfigRead['status']; reason?: string; path: string }; + personal: PersonalProjectSetup; +}; + +export const SHARED_CONFIG_RELATIVE_PATH = '.openchamber/project.json'; +const SHARED_CONFIG_VERSION = 1; + +/** + * The on-disk keys this module owns inside the personal config document, as + * a patch: a key set to `undefined` is removed from the document. + */ +type StoredProjectSetupPatch = { + 'setup-worktree'?: string[]; + 'setup-worktree-wait'?: boolean; + setupWorktreeMode?: SetupWorktreeMode; + projectActions?: ProjectAction[]; + projectActionsPrimaryId?: string | undefined; + draftStarters?: DraftStarter[]; + hiddenSharedActionIds?: string[]; + sharedTrust?: { hash: string; trustedAt: number } | undefined; + projectPath?: string; +}; + +export class ProjectSetupValidationError extends Error {} + +const isObjectRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); + +const clamp = (value: string, maxLength: number): string => (value.length > maxLength ? value.slice(0, maxLength) : value); + +const trimmedString = (value: unknown): string => (typeof value === 'string' ? value.trim() : ''); + +export const sanitizeSetupCommands = (value: unknown): string[] => { + if (!Array.isArray(value)) return []; + const commands: string[] = []; + for (const entry of value) { + const command = clamp(trimmedString(entry), SETUP_COMMAND_MAX_LENGTH); + if (!command) continue; + commands.push(command); + if (commands.length >= SETUP_COMMANDS_MAX) break; + } + return commands; +}; + +const sanitizeActionPlatforms = (value: unknown): ActionPlatform[] => { + if (!Array.isArray(value)) return []; + const platforms: ActionPlatform[] = []; + for (const entry of value) { + const platform = trimmedString(entry).toLowerCase(); + if (!ACTION_PLATFORMS.has(platform)) continue; + // SAFETY: membership in ACTION_PLATFORMS was just checked. + const known = platform as ActionPlatform; + if (!platforms.includes(known)) platforms.push(known); + } + return platforms; +}; + +export const sanitizeProjectActions = (value: unknown): ProjectAction[] => { + if (!Array.isArray(value)) return []; + const actions: ProjectAction[] = []; + const seenIds = new Set(); + for (const entry of value) { + if (!isObjectRecord(entry)) continue; + const id = trimmedString(entry.id); + const name = clamp(trimmedString(entry.name), ACTION_NAME_MAX_LENGTH); + const command = clamp(trimmedString(entry.command), ACTION_COMMAND_MAX_LENGTH); + if (!id || !name || !command || seenIds.has(id)) continue; + seenIds.add(id); + + const icon = trimmedString(entry.icon); + const platforms = sanitizeActionPlatforms(entry.platforms); + const openUrl = clamp(trimmedString(entry.openUrl), ACTION_OPEN_URL_MAX_LENGTH); + const desktopOpenSshForward = clamp(trimmedString(entry.desktopOpenSshForward), ACTION_DESKTOP_FORWARD_MAX_LENGTH); + + const action: ProjectAction = { id, name, command, icon: icon || null }; + if (entry.autoOpenUrl === true) action.autoOpenUrl = true; + if (openUrl) action.openUrl = openUrl; + if (desktopOpenSshForward) action.desktopOpenSshForward = desktopOpenSshForward; + if (platforms.length > 0) action.platforms = platforms; + if (entry.runIn === 'parent') action.runIn = 'parent'; + actions.push(action); + } + return actions; +}; + +export const sanitizeDraftStarters = (value: unknown): DraftStarter[] => { + if (!Array.isArray(value)) return []; + const starters: DraftStarter[] = []; + const seen = new Set(); + for (const entry of value) { + if (!isObjectRecord(entry)) continue; + const type = entry.type === 'command' || entry.type === 'skill' ? entry.type : null; + const name = trimmedString(entry.name); + if (!type || !name) continue; + const key = `${type}:${name}`; + if (seen.has(key)) continue; + seen.add(key); + starters.push({ type, name }); + } + return starters; +}; + +const sanitizeIdList = (value: unknown): string[] => { + if (!Array.isArray(value)) return []; + const ids: string[] = []; + for (const entry of value) { + const id = trimmedString(entry); + if (id && !ids.includes(id)) ids.push(id); + } + return ids; +}; + +const setupWorktreeModeOf = (value: unknown): SetupWorktreeMode => (value === 'replace' ? 'replace' : 'append'); + +/** The personal part of the view, straight from the personal file. */ +export const personalProjectSetupOf = (raw: unknown): PersonalProjectSetup => { + const document = isObjectRecord(raw) ? raw : {}; + const projectActions = sanitizeProjectActions(document.projectActions); + const primaryRaw = trimmedString(document.projectActionsPrimaryId); + const wait = document['setup-worktree-wait']; + return { + setupWorktree: sanitizeSetupCommands(document['setup-worktree']), + setupWorktreeWait: typeof wait === 'boolean' ? wait : null, + setupWorktreeMode: setupWorktreeModeOf(document.setupWorktreeMode), + projectActions, + projectActionsPrimaryId: primaryRaw && projectActions.some((action) => action.id === primaryRaw) ? primaryRaw : null, + draftStarters: sanitizeDraftStarters(document.draftStarters), + hiddenSharedActionIds: sanitizeIdList(document.hiddenSharedActionIds), + sharedTrust: sharedTrustOf(document.sharedTrust), + }; +}; + +const sharedTrustOf = (value: unknown): PersonalProjectSetup['sharedTrust'] => { + if (!isObjectRecord(value)) return null; + const hash = trimmedString(value.hash); + if (!hash) return null; + const trustedAt = value.trustedAt; + return { hash, trustedAt: typeof trustedAt === 'number' && Number.isFinite(trustedAt) ? trustedAt : 0 }; +}; + +/** + * What a trust answer covers: the shared setup commands and the shared + * actions' commands, canonical order, hashed; `null` when nothing executes. + */ +export const sharedTrustHashOf = (shared: SharedProjectConfig): string | null => { + const commands = shared.setupWorktree; + const actions = shared.projectActions + .map((action) => { + const executable: { id: string; command: string; runIn?: 'parent' } = { id: action.id, command: action.command }; + if (action.runIn) executable.runIn = action.runIn; + return executable; + }) + .sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)); + if (commands.length === 0 && actions.length === 0) return null; + const digest = crypto.createHash('sha256').update(JSON.stringify({ setupWorktree: commands, projectActions: actions })).digest('hex'); + return `sha256:${digest}`; +}; + +/** + * A `plansDir` is a relative path inside the repo: no absolute paths, no + * drive letters, no `..` segments, forward slashes. + */ +export const normalizePlansDir = (value: unknown): string | null => { + const raw = trimmedString(value).replace(/\\/g, '/'); + if (!raw) return null; + if (raw.startsWith('/') || /^[A-Za-z]:/.test(raw)) return null; + const segments = raw.split('/').filter((segment) => segment.length > 0 && segment !== '.'); + if (segments.length === 0 || segments.some((segment) => segment === '..')) return null; + return segments.join('/'); +}; + +const EMPTY_SHARED: SharedProjectConfig = { + setupWorktree: [], + setupWorktreeWait: null, + projectActions: [], + draftStarters: [], + plansDir: null, +}; + +/** + * Parse the text of a shared file. Anything that is not a version-1 object + * is `invalid` with a reason, never an empty config. + */ +export const parseSharedProjectConfig = (raw: string): SharedProjectConfigRead => { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + return { status: 'invalid', reason: `invalid JSON: ${error instanceof Error ? error.message : String(error)}` }; + } + if (!isObjectRecord(parsed)) return { status: 'invalid', reason: 'not an object' }; + if (parsed.version !== SHARED_CONFIG_VERSION) return { status: 'invalid', reason: `unsupported version ${JSON.stringify(parsed.version)}` }; + if ('setupWorktree' in parsed && !Array.isArray(parsed.setupWorktree)) return { status: 'invalid', reason: 'setupWorktree must be an array' }; + if ('setupWorktreeWait' in parsed && typeof parsed.setupWorktreeWait !== 'boolean') return { status: 'invalid', reason: 'setupWorktreeWait must be a boolean' }; + if ('projectActions' in parsed && !Array.isArray(parsed.projectActions)) return { status: 'invalid', reason: 'projectActions must be an array' }; + if ('draftStarters' in parsed && !Array.isArray(parsed.draftStarters)) return { status: 'invalid', reason: 'draftStarters must be an array' }; + let plansDir: string | null = null; + if ('plansDir' in parsed && parsed.plansDir !== null) { + plansDir = normalizePlansDir(parsed.plansDir); + if (!plansDir) return { status: 'invalid', reason: 'plansDir must be a relative path inside the repository' }; + } + const wait = parsed.setupWorktreeWait; + return { + status: 'ok', + config: { + setupWorktree: sanitizeSetupCommands(parsed.setupWorktree), + setupWorktreeWait: typeof wait === 'boolean' ? wait : null, + projectActions: sanitizeProjectActions(parsed.projectActions), + draftStarters: sanitizeDraftStarters(parsed.draftStarters), + plansDir, + }, + }; +}; + +const withSource = (entries: T[], source: ProjectSetupSource): Array => + entries.map((entry) => ({ ...entry, source })); + +/** + * One merged view from the personal part and the shared read. Same rules as + * the server: shared setup commands first (unless personal replaces), the + * personal wait flag wins when set, actions union by id with personal + * replacing shared and hidden shared ids dropped, starters union by key. + */ +export const mergeProjectSetup = (personal: PersonalProjectSetup, sharedRead: SharedProjectConfigRead): ProjectSetupView => { + const shared = sharedRead.status === 'ok' ? sharedRead.config : EMPTY_SHARED; + const hidden = new Set(personal.hiddenSharedActionIds); + const personalIds = new Set(personal.projectActions.map((action) => action.id)); + const sharedActions = shared.projectActions.filter((action) => !hidden.has(action.id) && !personalIds.has(action.id)); + const starterKeys = new Set(shared.draftStarters.map((starter) => `${starter.type}:${starter.name}`)); + const personalStarters = personal.draftStarters.filter((starter) => !starterKeys.has(`${starter.type}:${starter.name}`)); + const trustHash = sharedTrustHashOf(shared); + return { + trust: { hash: trustHash, trusted: trustHash === null || personal.sharedTrust?.hash === trustHash }, + setupWorktree: personal.setupWorktreeMode === 'replace' + ? personal.setupWorktree + : [...shared.setupWorktree, ...personal.setupWorktree], + setupWorktreeWait: personal.setupWorktreeWait !== null + ? personal.setupWorktreeWait + : shared.setupWorktreeWait === true, + projectActions: [...withSource(sharedActions, 'shared'), ...withSource(personal.projectActions, 'personal')], + projectActionsPrimaryId: personal.projectActionsPrimaryId, + draftStarters: [...withSource(shared.draftStarters, 'shared'), ...withSource(personalStarters, 'personal')], + shared: sharedBlockOf(sharedRead, shared), + personal, + }; +}; + +const sharedBlockOf = (sharedRead: SharedProjectConfigRead, shared: SharedProjectConfig): ProjectSetupView['shared'] => { + const block: ProjectSetupView['shared'] = { status: sharedRead.status, path: SHARED_CONFIG_RELATIVE_PATH, ...shared }; + if (sharedRead.status === 'invalid') block.reason = sharedRead.reason; + return block; +}; + +/** An action without an icon is written without the key; readers fall back to the play icon. */ +const withoutEmptyIcon = (action: ProjectAction): Omit & { icon?: string } => { + const { icon, ...rest } = action; + return icon === null ? rest : { ...rest, icon }; +}; + +/** True when the shared config carries nothing: the file should not exist. */ +export const isSharedProjectConfigEmpty = (config: SharedProjectConfig): boolean => ( + config.setupWorktree.length === 0 + && config.setupWorktreeWait === null + && config.projectActions.length === 0 + && config.draftStarters.length === 0 + && config.plansDir === null +); + +/** The bytes of a shared file: version first, only the keys that carry something, pretty-printed. */ +export const serializeSharedProjectConfig = (config: SharedProjectConfig): string => { + const document: Record = { version: SHARED_CONFIG_VERSION }; + if (config.setupWorktree.length > 0) document.setupWorktree = config.setupWorktree; + if (config.setupWorktreeWait !== null) document.setupWorktreeWait = config.setupWorktreeWait; + if (config.projectActions.length > 0) document.projectActions = sanitizeProjectActions(config.projectActions).map(withoutEmptyIcon); + if (config.draftStarters.length > 0) document.draftStarters = config.draftStarters; + if (config.plansDir !== null) document.plansDir = config.plansDir; + return `${JSON.stringify(document, null, 2)}\n`; +}; + +export const EMPTY_SHARED_PROJECT_CONFIG: SharedProjectConfig = EMPTY_SHARED; + +/** The next shared config after a client patch over the current one; wrong shapes are validation errors. */ +export const applySharedProjectSetupPatch = (current: SharedProjectConfig, patch: unknown): SharedProjectConfig => { + if (!isObjectRecord(patch)) throw new ProjectSetupValidationError('patch must be an object'); + const next: SharedProjectConfig = { ...current }; + if ('setupWorktree' in patch) { + if (!Array.isArray(patch.setupWorktree)) throw new ProjectSetupValidationError('setupWorktree must be an array of commands'); + next.setupWorktree = sanitizeSetupCommands(patch.setupWorktree); + } + if ('setupWorktreeWait' in patch) { + const wait = patch.setupWorktreeWait; + if (wait !== null && typeof wait !== 'boolean') throw new ProjectSetupValidationError('setupWorktreeWait must be a boolean or null'); + next.setupWorktreeWait = wait; + } + if ('projectActions' in patch) { + if (!Array.isArray(patch.projectActions)) throw new ProjectSetupValidationError('projectActions must be an array'); + next.projectActions = sanitizeProjectActions(patch.projectActions); + } + if ('draftStarters' in patch) { + if (!Array.isArray(patch.draftStarters)) throw new ProjectSetupValidationError('draftStarters must be an array'); + next.draftStarters = sanitizeDraftStarters(patch.draftStarters); + } + if ('plansDir' in patch) { + const raw = patch.plansDir; + if (raw === null || (typeof raw === 'string' && !raw.trim())) { + next.plansDir = null; + } else { + const plansDir = normalizePlansDir(raw); + if (!plansDir) throw new ProjectSetupValidationError('plansDir must be a relative path inside the repository'); + next.plansDir = plansDir; + } + } + return next; +}; + +/** + * The stored keys a client patch changes; `undefined` marks a key to remove. + * A key with the wrong shape is a validation error, never silently dropped. + */ +export const projectSetupPatchToStored = (patch: unknown): StoredProjectSetupPatch => { + if (!isObjectRecord(patch)) { + throw new ProjectSetupValidationError('patch must be an object'); + } + const stored: StoredProjectSetupPatch = {}; + if ('setupWorktree' in patch) { + if (!Array.isArray(patch.setupWorktree)) throw new ProjectSetupValidationError('setupWorktree must be an array of commands'); + stored['setup-worktree'] = sanitizeSetupCommands(patch.setupWorktree); + } + if ('setupWorktreeWait' in patch) { + if (typeof patch.setupWorktreeWait !== 'boolean') throw new ProjectSetupValidationError('setupWorktreeWait must be a boolean'); + stored['setup-worktree-wait'] = patch.setupWorktreeWait; + } + if ('projectActions' in patch) { + if (!Array.isArray(patch.projectActions)) throw new ProjectSetupValidationError('projectActions must be an array'); + stored.projectActions = sanitizeProjectActions(patch.projectActions); + } + if ('projectActionsPrimaryId' in patch) { + const primary = patch.projectActionsPrimaryId; + if (primary !== null && typeof primary !== 'string') { + throw new ProjectSetupValidationError('projectActionsPrimaryId must be a string or null'); + } + stored.projectActionsPrimaryId = trimmedString(primary) || undefined; + } + if ('draftStarters' in patch) { + if (!Array.isArray(patch.draftStarters)) throw new ProjectSetupValidationError('draftStarters must be an array'); + stored.draftStarters = sanitizeDraftStarters(patch.draftStarters); + } + if ('hiddenSharedActionIds' in patch) { + if (!Array.isArray(patch.hiddenSharedActionIds)) throw new ProjectSetupValidationError('hiddenSharedActionIds must be an array'); + stored.hiddenSharedActionIds = sanitizeIdList(patch.hiddenSharedActionIds); + } + if ('setupWorktreeMode' in patch) { + if (patch.setupWorktreeMode !== 'append' && patch.setupWorktreeMode !== 'replace') { + throw new ProjectSetupValidationError('setupWorktreeMode must be "append" or "replace"'); + } + stored.setupWorktreeMode = patch.setupWorktreeMode; + } + if ('sharedTrustHash' in patch) { + const hash = patch.sharedTrustHash; + if (hash !== null && (typeof hash !== 'string' || !hash.trim())) { + throw new ProjectSetupValidationError('sharedTrustHash must be a non-empty string or null'); + } + stored.sharedTrust = hash === null ? undefined : { hash: hash.trim(), trustedAt: Date.now() }; + } + if ('projectPath' in patch) { + if (typeof patch.projectPath !== 'string') throw new ProjectSetupValidationError('projectPath must be a string'); + const projectPath = patch.projectPath.trim(); + if (projectPath) stored.projectPath = projectPath; + } + return stored; +}; diff --git a/packages/vscode/src/quotaCredentials.ts b/packages/vscode/src/quotaCredentials.ts index b7f5968d..a4c17214 100644 --- a/packages/vscode/src/quotaCredentials.ts +++ b/packages/vscode/src/quotaCredentials.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { fetchExeDevUsage } from './exeDevQuota'; +import { fetchOllamaUsage } from './ollamaQuota'; export type ManagedProvider = 'exe-dev' | 'ollama-cloud' | 'cursor'; export type ManagedCredential = Record; @@ -53,13 +54,10 @@ export const importCursorCredential = () => { return credential; }; -export const validateCredential = async (provider: ManagedProvider, credential: ManagedCredential) => { +export const validateCredential = async (provider: ManagedProvider, credential: ManagedCredential, fetchImpl: (url: string, init: RequestInit) => Promise = fetch) => { if (provider === 'exe-dev') await fetchExeDevUsage(credential.usageToken); if (provider === 'ollama-cloud') { - const response = await fetch('https://ollama.com/settings', { headers: { Cookie: credential.cookie }, redirect: 'manual', signal: AbortSignal.timeout(15_000) }); - if (!response.ok || (response.status >= 300 && response.status < 400)) throw new Error('Ollama Cloud authentication failed'); - const html = await response.text(); - if (!/Session\s+usage|Weekly\s+usage|Premium[^0-9]*[0-9]+\s*\/\s*[0-9]+/i.test(html)) throw new Error('Ollama Cloud usage data could not be parsed'); + await fetchOllamaUsage(credential.cookie, fetchImpl); } if (provider === 'cursor') { if (!credential.accessToken && credential.refreshToken) { diff --git a/packages/vscode/src/quotaProviders.test.ts b/packages/vscode/src/quotaProviders.test.ts index 62a8f014..90dc7af6 100644 --- a/packages/vscode/src/quotaProviders.test.ts +++ b/packages/vscode/src/quotaProviders.test.ts @@ -15,17 +15,21 @@ const ORIGINAL_FS = { ...fs }; const AUTH = JSON.stringify({ openai: { access: 'test-token' }, crof: { key: 'test-token' }, + 'cline-pass': { key: 'test-token' }, neuralwatt: { key: 'test-token' }, 'opencode-go': { key: 'test-token' }, + openrouter: { key: 'test-token' }, 'zai-coding-plan': { key: 'test-token' }, deepseek: { key: 'test-token' }, + hyper: { key: 'test-token' }, 'github-copilot': { access: 'test-token' }, anthropic: { access: 'test-token', refresh: 'test-refresh' }, }); ((fs as unknown) as { existsSync: () => boolean }).existsSync = () => true; ((fs as unknown) as { readFileSync: () => string }).readFileSync = () => AUTH; -import { fetchQuotaForProvider } from './quotaProviders'; +import { fetchClinePassQuota, fetchHyperQuota, fetchOllamaCloudQuota, fetchQuotaForProvider } from './quotaProviders'; +import { validateCredential } from './quotaCredentials'; type MockResponseInit = { ok?: boolean; status?: number }; @@ -84,6 +88,13 @@ const stubFetchFailing = (json: () => Promise, init: MockResponseInit): globalThis.fetch = (async () => ({ json, ...init }) as unknown as Response) as typeof fetch; }; +test('dispatches Charm Hyper through the generic quota API', async () => { + stubFetchReturning(async () => Response.json({ balance: 100 })); + const result = await fetchQuotaForProvider('hyper'); + assert.equal(result.ok, true); + assert.equal(result.usage?.windows.credits?.valueLabel, '100'); +}); + describe('OpenCode Go quota provider (VS Code parity)', () => { test('uses the opencode-go key from auth.json', async () => { let request: RequestInit | undefined; @@ -105,6 +116,180 @@ describe('OpenCode Go quota provider (VS Code parity)', () => { }); }); +describe('OpenRouter quota provider (VS Code parity)', () => { + const documentedPayload = { + data: { + label: 'test-key', + usage: 3.17561396, + usage_daily: 0.0000018, + usage_weekly: 0.0000018, + usage_monthly: 3.17561396, + limit: 30, + limit_remaining: 29.9999982, + limit_reset: 'daily', + is_free_tier: true, + is_management_key: false, + include_byok_in_limit: false, + byok_usage: 0, + }, + }; + + test('reads the documented key endpoint and emits the current reset window', async () => { + let requestedUrl = ''; + let requestInit: RequestInit | undefined; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + requestedUrl = url; + requestInit = init; + return mockResponse(documentedPayload); + }) as typeof fetch; + + const result = await fetchQuotaForProvider('openrouter'); + + assert.equal(requestedUrl, 'https://openrouter.ai/api/v1/key'); + assert.equal(requestedUrl.includes('/api/v1/credits'), false); + assert.equal(new Headers(requestInit?.headers).get('Authorization'), 'Bearer test-token'); + assert.equal(new Headers(requestInit?.headers).get('Accept-Encoding'), 'identity'); + assert.ok(requestInit?.signal instanceof AbortSignal); + assert.deepEqual(Object.keys(result.usage!.windows), ['daily']); + assert.equal(result.usage!.windows.daily!.windowSeconds, 86400); + assert.equal(result.usage!.windows.daily!.valueLabel, '$0.00 / $30.00'); + assert.ok(typeof result.usage!.windows.daily!.resetAt === 'number'); + }); + + test('maps an unlimited null-limit key to a monthly spent window', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ + data: { limit: null, limit_remaining: null, limit_reset: null, usage_monthly: 12.5, is_management_key: false }, + }))); + + const result = await fetchQuotaForProvider('openrouter'); + + assert.equal(result.ok, true); + assert.deepEqual(Object.keys(result.usage!.windows), ['monthly']); + assert.equal(result.usage!.windows.monthly!.usedPercent, null); + assert.equal(result.usage!.windows.monthly!.windowSeconds, 30 * 86400); + assert.equal(result.usage!.windows.monthly!.valueLabel, '$12.50 spent'); + assert.ok(typeof result.usage!.windows.monthly!.resetAt === 'number'); + }); + + test('maps a lifetime cap to a credits window without reset metadata', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ + data: { limit: 30, limit_remaining: 25, limit_reset: null, usage_monthly: 5 }, + }))); + + const result = await fetchQuotaForProvider('openrouter'); + const window = result.usage!.windows.credits; + + assert.ok(window); + assert.equal(window!.windowSeconds, null); + assert.equal(window!.resetAt, null); + }); + + test('maps an unrecognized reset period to a credits window', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ + data: { limit: 30, limit_remaining: 25, limit_reset: 'yearly', usage_monthly: 5 }, + }))); + + const result = await fetchQuotaForProvider('openrouter'); + + assert.ok(result.usage!.windows.credits); + assert.equal(result.usage!.windows.credits!.windowSeconds, null); + assert.equal(result.usage!.windows.credits!.resetAt, null); + }); + + test('clamps percent at 100 while leaving the money label unclamped', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ + data: { limit: 30, limit_remaining: -1, limit_reset: 'monthly', usage_monthly: 31 }, + }))); + + const result = await fetchQuotaForProvider('openrouter'); + const window = result.usage!.windows.monthly; + + assert.equal(window!.usedPercent, 100); + assert.equal(window!.valueLabel, '$31.00 / $30.00'); + }); + + test('uses a weekly window and derives its reset on Monday UTC', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ + data: { limit: 30, limit_remaining: 25, limit_reset: 'weekly', usage_monthly: 5 }, + }))); + + const result = await fetchQuotaForProvider('openrouter'); + const window = result.usage!.windows.weekly; + + assert.ok(window); + assert.equal(window!.windowSeconds, 604800); + assert.equal(new Date(window!.resetAt!).getUTCDay(), 1); + }); + + test('rejects management keys with an inference-key error', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ data: { is_management_key: true } }))); + + const result = await fetchQuotaForProvider('openrouter'); + + assert.equal(result.ok, false); + assert.equal(result.configured, true); + assert.equal(result.usage, null); + assert.equal(result.error, 'Management key configured — quota needs an inference API key'); + }); + + for (const status of [401, 403]) { + test(`maps HTTP ${status} to session expiry`, async () => { + stubFetchFailing(async () => ({}), { ok: false, status }); + + const result = await fetchQuotaForProvider('openrouter'); + + assert.equal(result.ok, false); + assert.equal(result.error, 'Session expired — please re-authenticate with OpenRouter'); + }); + } + + test('reports invalid JSON as a parse failure', async () => { + globalThis.fetch = (async () => ({ + ok: true, + status: 200, + json: async () => { throw new SyntaxError('Unexpected token'); }, + }) as unknown as Response) as typeof fetch; + + const result = await fetchQuotaForProvider('openrouter'); + + assert.equal(result.ok, false); + assert.equal(result.error, 'Invalid response from provider'); + }); + + test('normalizes timeout failures', async () => { + stubFetchReturning(() => Promise.reject(new DOMException('Timed out', 'TimeoutError'))); + + const result = await fetchQuotaForProvider('openrouter'); + + assert.equal(result.ok, false); + assert.equal(result.error, 'Request timed out'); + }); + + test('rejects a response without usable quota data', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ data: { limit: 30, limit_remaining: null } }))); + + const result = await fetchQuotaForProvider('openrouter'); + + assert.equal(result.ok, false); + assert.equal(result.configured, true); + assert.equal(result.usage, null); + assert.equal(result.error, 'No quota data in response'); + }); + + for (const payload of [{ data: {} }, { data: null }]) { + test(`rejects ${JSON.stringify(payload)} without quota data`, async () => { + stubFetchReturning(() => Promise.resolve(mockResponse(payload))); + + const result = await fetchQuotaForProvider('openrouter'); + + assert.equal(result.ok, false); + assert.equal(result.configured, true); + assert.equal(result.usage, null); + assert.equal(result.error, 'No quota data in response'); + }); + } +}); + describe('Crof quota provider (VS Code parity)', () => { test('reports credits balance as valueLabel with null percent', async () => { @@ -152,6 +337,121 @@ describe('Crof quota provider (VS Code parity)', () => { }); }); +describe('ClinePass quota provider (VS Code parity)', () => { + // Live-verified response shape of + // GET https://api.cline.bot/api/v1/users/me/plan/usage-limits + const documentedPayload = { + data: { + limits: [ + { type: 'five_hour', percentUsed: 43, resetsAt: '2026-09-08T17:00:44.598174595Z' }, + { type: 'weekly', percentUsed: 17, resetsAt: '2026-09-13T17:00:44.598174595Z' }, + { type: 'monthly', percentUsed: 8, resetsAt: '2026-10-01T00:00:00Z' }, + ], + }, + success: true, + }; + + test('maps documented limit kinds to 5h/weekly/monthly windows', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse(documentedPayload))); + + const result = await fetchQuotaForProvider('cline-pass'); + + assert.equal(result.ok, true); + assert.equal(result.providerId, 'cline-pass'); + assert.equal(result.usage?.windows['5h']?.usedPercent, 43); + assert.equal(result.usage?.windows['5h']?.windowSeconds, 18_000); + assert.equal(result.usage?.windows['5h']?.resetAt, Date.parse('2026-09-08T17:00:44.598174595Z')); + assert.equal(result.usage?.windows.weekly?.usedPercent, 17); + assert.equal(result.usage?.windows.weekly?.windowSeconds, 604_800); + assert.equal(result.usage?.windows.monthly?.usedPercent, 8); + assert.equal(result.usage?.windows.monthly?.windowSeconds, null); + }); + + test('ignores unknown limit types and rejects responses without quota data', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ data: { limits: [{ type: 'quarterly', percentUsed: 5 }] } }))); + + const result = await fetchQuotaForProvider('cline-pass'); + + assert.equal(result.ok, false); + assert.equal(result.configured, true); + assert.equal(result.usage, null); + assert.equal(result.error, 'No quota data in response'); + }); + + test('maps 401 to session-expired with ClinePass branding', async () => { + stubFetchFailing(async () => ({}), { ok: false, status: 401 }); + + const result = await fetchQuotaForProvider('cline-pass'); + + assert.equal(result.ok, false); + assert.equal(result.configured, true); + assert.equal(result.error, 'Session expired — please re-authenticate with ClinePass'); + }); + + test('reports invalid-response on JSON parse failure', async () => { + stubFetchFailing(async () => { throw new SyntaxError('Unexpected token'); }, { ok: true, status: 200 }); + + const result = await fetchQuotaForProvider('cline-pass'); + + assert.equal(result.ok, false); + assert.equal(result.error, 'Invalid response from provider'); + }); + + const readAuth = () => ({ 'cline-pass': { key: 'test-token' } }); + + for (const limit of [ + { type: 'constructor', percentUsed: 5 }, { type: 'toString', percentUsed: 5 }, + { type: '__proto__', percentUsed: 5 }, { type: 'weekly', percentUsed: '' }, + { type: 'weekly', percentUsed: ' ' }, { type: 'weekly', percentUsed: true }, + { type: 'weekly', percentUsed: null }, null, + ]) { + test(`skips malformed windows independently: ${JSON.stringify(limit)}`, async () => { + const invalid = await fetchClinePassQuota({ readAuth, fetchImpl: async () => Response.json({ data: { limits: [limit] } }) }); + assert.equal(invalid.ok, false); + assert.equal(invalid.usage, null); + const mixed = await fetchClinePassQuota({ readAuth, fetchImpl: async () => Response.json({ data: { limits: [limit, { type: 'monthly', percentUsed: 8 }] } }) }); + assert.equal(mixed.ok, true); + assert.ok(mixed.usage); + assert.deepEqual(Object.keys(mixed.usage.windows), ['monthly']); + }); + } + + for (const percentUsed of [0, '0', '51']) { + test(`accepts percentage ${JSON.stringify(percentUsed)}`, async () => { + const result = await fetchClinePassQuota({ readAuth, fetchImpl: async () => Response.json({ data: { limits: [{ type: 'weekly', percentUsed }] } }) }); + assert.equal(result.ok, true); + assert.equal(result.usage?.windows.weekly?.usedPercent, Number(percentUsed)); + }); + } + + for (const entry of [{ key: '' }, { key: ' ' }, { key: 42 }, {}]) { + test(`falls back to a usable token: ${JSON.stringify(entry)}`, async () => { + const result = await fetchClinePassQuota({ + readAuth: () => ({ 'cline-pass': { ...entry, token: 'test-token' } }), + fetchImpl: async (_url, options) => { + assert.equal(new Headers(options.headers).get('Authorization'), 'Bearer test-token'); + return Response.json(documentedPayload); + }, + }); + assert.equal(result.ok, true); + }); + } + + test('does not request usage without usable credentials', async () => { + const result = await fetchClinePassQuota({ readAuth: () => ({ 'cline-pass': { key: 42 } }), fetchImpl: async () => { throw new Error('Unexpected fetch'); } }); + assert.equal(result.configured, false); + assert.equal(result.error, 'Not configured'); + }); + + test('recognizes the timeout exception from AbortSignal.timeout', async () => { + const result = await fetchClinePassQuota({ readAuth, fetchImpl: async () => { throw new DOMException('Timed out', 'TimeoutError'); } }); + assert.equal(result.ok, false); + assert.equal(result.configured, true); + assert.equal(result.usage, null); + assert.equal(result.error, 'Request timed out'); + }); +}); + describe('Codex quota provider (VS Code parity)', () => { test('coalesces concurrent refreshes for the same provider', async () => { let resolveResponse: ((response: Response) => void) | undefined; @@ -421,7 +721,7 @@ describe('NeuralWatt quota provider (VS Code parity)', () => { assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$32.68'); }); - test('surfaces subscription and allowance windows (allowance keyed by period, key name in valueLabel)', async () => { + test('surfaces subscription and allowance windows (allowance keyed by period, percent value)', async () => { const payload = { ...DOCUMENTED_SUBSCRIPTION_PAYLOAD, balance: { credits_remaining_usd: 200 }, @@ -439,11 +739,11 @@ describe('NeuralWatt quota provider (VS Code parity)', () => { assert.ok(Math.abs((subWindow!.usedPercent as number) - (13.9023 / 20.0) * 100) < 1e-2); // Allowance window is keyed by the localized period label ("monthly"); - // key name flows through valueLabel for identification. + // the usage value stays a percent — no key-name valueLabel. const allowWindow = result.usage!.windows.monthly; assert.ok(allowWindow); assert.equal(allowWindow!.usedPercent, 25); - assert.equal(allowWindow!.valueLabel, 'Prod'); + assert.equal(allowWindow!.valueLabel, undefined); assert.equal(allowWindow!.resetAt, Date.parse('2026-08-01T00:00:00Z')); assert.equal(result.usage!.windows.credits_balance, undefined); @@ -468,7 +768,7 @@ describe('NeuralWatt quota provider (VS Code parity)', () => { assert.ok(Math.abs((window!.usedPercent as number) - (25 / 55) * 100) < 1e-2); assert.equal(window!.windowSeconds, 30 * 86400); assert.equal(window!.resetAt, Date.parse('2026-08-01T00:00:00Z')); - assert.equal(window!.valueLabel, 'prod-key'); + assert.equal(window!.valueLabel, undefined); assert.equal(result.usage!.windows.credits_balance, undefined); }); @@ -507,7 +807,7 @@ describe('NeuralWatt quota provider (VS Code parity)', () => { assert.ok(window); assert.equal(window!.windowSeconds, 604800); assert.equal(window!.resetAt, Date.parse('2026-07-04T00:00:00Z')); - assert.equal(window!.valueLabel, 'Prod'); + assert.equal(window!.valueLabel, undefined); }); test('uses daily as the allowance key when period is daily', async () => { @@ -547,7 +847,7 @@ describe('NeuralWatt quota provider (VS Code parity)', () => { assert.equal(window!.usedPercent, 25); }); - test('marks blocked allowance as 100% with valueLabel set', async () => { + test('marks blocked allowance as 100% with percent value', async () => { const payload = { balance: { credits_remaining_usd: 30 }, subscription: null, @@ -563,7 +863,7 @@ describe('NeuralWatt quota provider (VS Code parity)', () => { const window = result.usage!.windows.monthly; assert.ok(window); assert.equal(window!.usedPercent, 100); - assert.equal(window!.valueLabel, 'sample'); + assert.equal(window!.valueLabel, undefined); }); test('falls back to credits_balance when neither subscription nor allowance exists', async () => { @@ -714,3 +1014,228 @@ describe('DeepSeek quota provider (VS Code parity)', () => { fsMock.readFileSync = ORIGINAL_FS.readFileSync; }); }); + +describe('Ollama Cloud quota validation and refresh', () => { + const credential = { cookie: 'test-ollama-cookie' }; + const readCookie = () => credential.cookie; + + for (const { html, expected } of [ + { html: '

Monthly usage

$25.00 of $100.00

', expected: { monthly: { usedPercent: 25, valueLabel: '$25.00 / $100.00' } } }, + { html: 'Monthly usage $1,250.00 of $2,500.00', expected: { monthly: { usedPercent: 50, valueLabel: '$1,250.00 / $2,500.00' } } }, + { html: 'Session usage 12% Weekly usage 34% Premium 2 / 10', expected: { session: { usedPercent: 12 }, weekly: { usedPercent: 34 }, premium: { usedPercent: 20, valueLabel: '2 / 10' } } }, + { html: 'Monthly usage $0 of $100 Balance remaining $5.25 Add $5', expected: { monthly: { usedPercent: 0, valueLabel: '$0 / $100' }, credits_balance: { usedPercent: null, valueLabel: '$5.25' } } }, + { html: 'Monthly usage $0 of $100 Balance remaining $0.00 Add $5', expected: { monthly: { usedPercent: 0, valueLabel: '$0 / $100' } } }, + { html: 'Monthly usage $125 of $100 Add $5', expected: { monthly: { usedPercent: 100, valueLabel: '$125 / $100' } } }, + ]) { + test(`accepts and displays ${html}`, async () => { + let requests = 0; + const fetchImpl = async (url: string, init: RequestInit) => { + requests += 1; + assert.equal(url, 'https://ollama.com/settings'); + assert.equal(init.redirect, 'manual'); + assert.equal(init.method, 'GET'); + assert.equal(new Headers(init.headers).get('Cookie'), credential.cookie); + assert.ok(init.signal instanceof AbortSignal); + return new Response(html); + }; + await validateCredential('ollama-cloud', credential, fetchImpl); + const result = await fetchOllamaCloudQuota({ readCookie, fetchImpl }); + assert.equal(requests, 2); + assert.equal(result.ok, true); + assert.ok(result.usage); + assert.deepEqual(Object.keys(result.usage.windows), Object.keys(expected)); + for (const [key, expectedWindow] of Object.entries(expected)) { + const window: NonNullable['windows'][string] = result.usage.windows[key]; + assert.ok(window); + assert.equal(window.usedPercent, expectedWindow.usedPercent); + if ('valueLabel' in expectedWindow) assert.equal(window.valueLabel, expectedWindow.valueLabel); + assert.equal(window.resetAt, null); + } + assert.equal(JSON.stringify(result).includes(credential.cookie), false); + }); + } + + for (const html of ['', '

Monthly usage

', 'Session usage', 'Session usage 1.2.3%', 'Weekly usage 1.2.3%', 'Add $5', 'Monthly usage $1.2.3 of $100', 'Balance remaining $1.2.3']) { + test(`rejects unparseable HTML ${JSON.stringify(html)} in both consumers`, async () => { + const fetchImpl = async () => new Response(html); + await assert.rejects(validateCredential('ollama-cloud', credential, fetchImpl), /usage data could not be parsed/); + const result = await fetchOllamaCloudQuota({ readCookie, fetchImpl }); + assert.equal(result.ok, false); + assert.equal(result.configured, true); + assert.equal(result.usage, null); + assert.equal(result.error, 'Ollama Cloud usage data could not be parsed'); + }); + } + + for (const status of [302, 307, 401, 403, 429, 500]) { + test(`rejects HTTP ${status} in both consumers`, async () => { + const fetchImpl = async () => new Response('Monthly usage $25 of $100', { status }); + await assert.rejects(validateCredential('ollama-cloud', credential, fetchImpl), /authentication failed/); + const result = await fetchOllamaCloudQuota({ readCookie, fetchImpl }); + assert.equal(result.ok, false); + assert.equal(result.usage, null); + assert.equal(result.error, 'Ollama Cloud authentication failed'); + }); + } + + for (const failure of [new DOMException('Request timed out', 'TimeoutError'), new Error('Network unavailable')]) { + test(`reports ${failure.message} in both consumers`, async () => { + const fetchImpl = async () => { throw failure; }; + await assert.rejects(validateCredential('ollama-cloud', credential, fetchImpl), failure); + const result = await fetchOllamaCloudQuota({ readCookie, fetchImpl }); + assert.equal(result.ok, false); + assert.equal(result.usage, null); + assert.equal(result.error, failure.message); + }); + } + + test('does not request usage without a cookie', async () => { + const result = await fetchOllamaCloudQuota({ readCookie: () => undefined, fetchImpl: async () => { assert.fail('Unexpected request'); } }); + assert.equal(result.configured, false); + assert.equal(result.ok, false); + }); + + test('reports response body failures in both consumers', async () => { + const failure = new Error('Response body interrupted'); + const fetchImpl = async () => new Response(new ReadableStream({ + start(controller) { + controller.error(failure); + }, + })); + + await assert.rejects(validateCredential('ollama-cloud', credential, fetchImpl), failure); + const result = await fetchOllamaCloudQuota({ readCookie, fetchImpl }); + assert.equal(result.ok, false); + assert.equal(result.configured, true); + assert.equal(result.usage, null); + assert.equal(result.error, failure.message); + assert.deepEqual(credential, { cookie: 'test-ollama-cookie' }); + }); +}); + +describe('Charm Hyper quota provider (VS Code parity)', () => { + const readAuth = () => ({ hyper: { key: 'test-token' } }); + + for (const { balance, credits, dollars } of [ + { balance: 100, credits: '100', dollars: '$5.00' }, + { balance: '50', credits: '50', dollars: '$2.50' }, + { balance: 25.5, credits: '25.50', dollars: '$1.28' }, + { balance: 0, credits: '0', dollars: '$0.00' }, + { balance: '0', credits: '0', dollars: '$0.00' }, + ]) { + test(`formats balance ${JSON.stringify(balance)} without an untranslated unit`, async () => { + const result = await fetchHyperQuota({ readAuth, fetchImpl: async () => Response.json({ balance }) }); + assert.equal(result.ok, true); + assert.equal(result.providerId, 'hyper'); + assert.equal(result.configured, true); + assert.ok(result.usage); + assert.equal(result.usage.windows.credits?.valueLabel, credits); + assert.equal(result.usage.windows.credits_balance?.valueLabel, dollars); + for (const window of Object.values(result.usage.windows)) { + assert.equal(window.usedPercent, null); + assert.equal(window.remainingPercent, null); + assert.equal(window.windowSeconds, null); + assert.equal(window.resetAt, null); + assert.equal(window.resetAfterSeconds, null); + } + }); + } + + for (const payload of [ + {}, null, [], { balance: '' }, { balance: ' \t ' }, { balance: 'NaN' }, + { balance: 'Infinity' }, { balance: null }, { balance: true }, { balance: [] }, + { balance: {} }, + ]) { + test(`rejects invalid payload ${JSON.stringify(payload)} instead of showing zero`, async () => { + const result = await fetchHyperQuota({ readAuth, fetchImpl: async () => Response.json(payload) }); + assert.equal(result.ok, false); + assert.equal(result.configured, true); + assert.equal(result.error, 'No quota data in response'); + assert.equal(result.usage, null); + }); + } + + for (const [index, auth] of [ + { hyper: { key: 'test-token' } }, + { hyper: { token: 'test-token' } }, + { hyper: 'test-token' }, + { hyper: { key: ' ', token: 'test-token' } }, + { hyper: { key: 42, token: 'test-token' } }, + ].entries()) { + test(`uses validated credential variant ${index} for the documented request`, async () => { + let requests = 0; + const result = await fetchHyperQuota({ + readAuth: () => auth, + fetchImpl: async (url, options) => { + requests += 1; + assert.equal(url, 'https://hyper.charm.land/v1/credits'); + assert.equal(options.method, 'GET'); + assert.equal(new Headers(options.headers).get('Authorization'), 'Bearer test-token'); + assert.ok(options.signal instanceof AbortSignal); + return Response.json({ balance: 100 }); + }, + }); + assert.equal(requests, 1); + assert.equal(result.ok, true); + assert.equal(JSON.stringify(result).includes('test-token'), false); + }); + } + + for (const [index, readInvalidAuth] of [ + () => ({}), + () => ({ hyper: { key: '' } }), + () => ({ hyper: { key: ' ' } }), + () => ({ hyper: { key: 42 } }), + ].entries()) { + test(`does not request usage with missing or invalid credential variant ${index}`, async () => { + let requests = 0; + const result = await fetchHyperQuota({ + readAuth: readInvalidAuth, + fetchImpl: async () => { + requests += 1; + return Response.json({ balance: 100 }); + }, + }); + assert.equal(requests, 0); + assert.equal(result.ok, false); + assert.equal(result.configured, false); + assert.equal(result.error, 'Not configured'); + }); + } + + for (const { status, error } of [ + { status: 401, error: 'Session expired — please re-authenticate with Charm Hyper' }, + { status: 403, error: 'Session expired — please re-authenticate with Charm Hyper' }, + { status: 429, error: 'API error: 429' }, + { status: 500, error: 'API error: 500' }, + ]) { + test(`reports HTTP ${status} as a failure`, async () => { + const result = await fetchHyperQuota({ readAuth, fetchImpl: async () => new Response(null, { status }) }); + assert.equal(result.ok, false); + assert.equal(result.configured, true); + assert.equal(result.error, error); + assert.equal(result.usage, null); + }); + } + + test('reports invalid JSON as a parse failure', async () => { + const result = await fetchHyperQuota({ readAuth, fetchImpl: async () => new Response('{') }); + assert.equal(result.error, 'Invalid response from provider'); + assert.equal(result.ok, false); + assert.equal(result.configured, true); + assert.equal(result.usage, null); + }); + + for (const { failure, message } of [ + { failure: new DOMException('Timed out', 'TimeoutError'), message: 'Request timed out' }, + { failure: new Error('Network unavailable'), message: 'Network unavailable' }, + ]) { + test(`reports ${message}`, async () => { + const result = await fetchHyperQuota({ readAuth, fetchImpl: async () => { throw failure; } }); + assert.equal(result.error, message); + assert.equal(result.ok, false); + assert.equal(result.configured, true); + assert.equal(result.usage, null); + }); + } +}); diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index 74be878f..045c9b9e 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -6,6 +6,7 @@ import { fetchOpenCodeGoUsage } from './opencodeGoQuota'; import { deleteLegacyOpenCodeGoCredential, readCredential } from './quotaCredentials'; import { getProviderAuth, updateProviderAuth } from './opencodeAuth'; import { fetchExeDevUsage } from './exeDevQuota'; +import { fetchOllamaUsage } from './ollamaQuota'; type AuthEntry = Record | string; type AuthFile = Record; @@ -145,6 +146,11 @@ type CrofPayload = { credits?: number | string; }; +type ClineWindowKind = { + key: string; + windowSeconds: number | null; +}; + type DeepseekPayload = { is_available?: boolean; balance_infos?: Array<{ @@ -843,6 +849,11 @@ export const listConfiguredQuotaProviders = () => { configured.add('crof'); } + const clineAuth = normalizeAuthEntry(getAuthEntry(auth, ['cline-pass'])); + if (clineAuth && (asNonEmptyString(clineAuth.key) || asNonEmptyString(clineAuth.token))) { + configured.add('cline-pass'); + } + const neuralwattAuth = normalizeAuthEntry(getAuthEntry(auth, ['neuralwatt'])); if (neuralwattAuth && ((neuralwattAuth as Record).key || (neuralwattAuth as Record).token)) { configured.add('neuralwatt'); @@ -853,6 +864,10 @@ export const listConfiguredQuotaProviders = () => { configured.add('deepseek'); } + if (getHyperApiKey(auth)) { + configured.add('hyper'); + } + let xaiAuth: XaiAuthEntry | null = null; try { xaiAuth = resolveXaiAuth(); @@ -1862,44 +1877,14 @@ const fetchMiniMaxCnCodingPlanQuota = () => fetchMiniMaxQuota({ usageFieldsAreRemaining: true, }); -const parseOllamaSettingsHtml = (html: string) => { - const windows: Record = {}; - const sessionMatch = html.match(/Session\s+usage[^0-9]*([0-9.]+)%/i); - if (sessionMatch) { - windows.session = toUsageWindow({ - usedPercent: toNumber(sessionMatch[1]), - windowSeconds: null, - resetAt: null, - }); - } - - const weeklyMatch = html.match(/Weekly\s+usage[^0-9]*([0-9.]+)%/i); - if (weeklyMatch) { - windows.weekly = toUsageWindow({ - usedPercent: toNumber(weeklyMatch[1]), - windowSeconds: null, - resetAt: null, - }); - } - - const premiumMatch = html.match(/Premium[^0-9]*([0-9]+)\s*\/\s*([0-9]+)/i); - if (premiumMatch) { - const used = toNumber(premiumMatch[1]); - const total = toNumber(premiumMatch[2]); - const usedPercent = total && used !== null ? Math.min(100, (used / total) * 100) : null; - windows.premium = toUsageWindow({ - usedPercent, - windowSeconds: null, - resetAt: null, - valueLabel: `${used ?? 0} / ${total ?? 0}`, - }); - } - - return windows; -}; - -const fetchOllamaCloudQuota = async (): Promise => { - const cookie = readCredential('ollama-cloud')?.cookie; +export const fetchOllamaCloudQuota = async ({ + readCookie = () => readCredential('ollama-cloud')?.cookie, + fetchImpl = fetch, +}: { + readCookie?: () => string | undefined; + fetchImpl?: (url: string, init: RequestInit) => Promise; +} = {}): Promise => { + const cookie = readCookie(); if (!cookie) { return buildResult({ @@ -1912,30 +1897,17 @@ const fetchOllamaCloudQuota = async (): Promise => { } try { - const response = await fetch('https://ollama.com/settings', { - method: 'GET', - headers: { - Cookie: cookie, - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', - }, - }); - - if (!response.ok) { - return buildResult({ - providerId: 'ollama-cloud', - providerName: 'Ollama Cloud', - ok: false, - configured: true, - error: `API error: ${response.status}`, - }); - } + const parsed = await fetchOllamaUsage(cookie, fetchImpl); + const windows = Object.fromEntries(Object.entries(parsed).map(([key, value]) => [ + key, toUsageWindow({ ...value, windowSeconds: null, resetAt: null }), + ])); return buildResult({ providerId: 'ollama-cloud', providerName: 'Ollama Cloud', ok: true, configured: true, - usage: { windows: parseOllamaSettingsHtml(await response.text()) }, + usage: { windows }, }); } catch (error) { return buildResult({ @@ -1971,6 +1943,28 @@ const fetchCursorQuota = async (): Promise => { } catch (error) { return buildResult({ providerId: 'cursor', providerName: 'Cursor', ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' }); } }; +const openRouterResetAt = (period: string | null, nowMs: number): number | null => { + const now = new Date(nowMs); + const year = now.getUTCFullYear(); + const month = now.getUTCMonth(); + const day = now.getUTCDate(); + + if (period === 'daily') return Date.UTC(year, month, day + 1); + if (period === 'weekly') { + const daysUntilMonday = ((8 - now.getUTCDay()) % 7) || 7; + return Date.UTC(year, month, day + daysUntilMonday); + } + if (period === 'monthly') return Date.UTC(year, month + 1, 1); + return null; +}; + +const PERIOD_SECONDS = { daily: 86400, weekly: 604800, monthly: 30 * 86400 }; +type OpenRouterPeriod = keyof typeof PERIOD_SECONDS; + +const isOpenRouterPeriod = (value: unknown): value is OpenRouterPeriod => ( + typeof value === 'string' && Object.prototype.hasOwnProperty.call(PERIOD_SECONDS, value) +); + const fetchOpenRouterQuota = async (): Promise => { const auth = readAuthFile(); const entry = normalizeAuthEntry(getAuthEntry(auth, ['openrouter'])) as Record | null; @@ -1986,13 +1980,16 @@ const fetchOpenRouterQuota = async (): Promise => { }); } + const timeoutSignal = AbortSignal.timeout(15_000); + try { - const response = await fetch('https://openrouter.ai/api/v1/credits', { + const response = await fetch('https://openrouter.ai/api/v1/key', { method: 'GET', headers: { Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', + 'Accept-Encoding': 'identity', }, + signal: timeoutSignal, }); if (!response.ok) { @@ -2001,20 +1998,86 @@ const fetchOpenRouterQuota = async (): Promise => { providerName: 'OpenRouter', ok: false, configured: true, - error: `API error: ${response.status}`, + error: response.status === 401 || response.status === 403 + ? 'Session expired — please re-authenticate with OpenRouter' + : `API error: ${response.status}`, }); } - const payload = await response.json() as Record; - const credits = payload.data as Record | undefined; - const totalCredits = toNumber(credits?.total_credits); - const totalUsage = toNumber(credits?.total_usage); - const remaining = totalCredits !== null && totalUsage !== null - ? Math.max(0, totalCredits - totalUsage) - : null; - let valueLabel: string | null = null; - if (remaining !== null && totalUsage !== null) { - valueLabel = `$${formatMoney(remaining)} left · $${formatMoney(totalUsage)} spent`; + const payload = await response.json() as unknown; + const dataContainer = asObject(payload); + const data = asObject(dataContainer?.data); + if (data === null) { + return buildResult({ + providerId: 'openrouter', + providerName: 'OpenRouter', + ok: false, + configured: true, + error: 'No quota data in response', + }); + } + + if (data.is_management_key === true) { + return buildResult({ + providerId: 'openrouter', + providerName: 'OpenRouter', + ok: false, + configured: true, + error: 'Management key configured — quota needs an inference API key', + }); + } + + const limit = toNumber(data.limit); + const limitRemaining = toNumber(data.limit_remaining); + if (limit !== null && limitRemaining === null) { + return buildResult({ + providerId: 'openrouter', + providerName: 'OpenRouter', + ok: false, + configured: true, + error: 'No quota data in response', + }); + } + + const usageMonthly = toNumber(data.usage_monthly); + if (limit === null && usageMonthly === null) { + return buildResult({ + providerId: 'openrouter', + providerName: 'OpenRouter', + ok: false, + configured: true, + error: 'No quota data in response', + }); + } + + const nowMs = Date.now(); + let windowKey: string; + let windowSeconds: number | null; + let resetAt: number | null; + let usedPercent: number | null; + let valueLabel: string; + + if (limit === null) { + windowKey = 'monthly'; + windowSeconds = PERIOD_SECONDS.monthly; + resetAt = openRouterResetAt('monthly', nowMs); + usedPercent = null; + valueLabel = `$${formatMoney(usageMonthly)} spent`; + } else { + const used = Math.max(0, limit - (limitRemaining ?? 0)); + const percent = limit > 0 ? (used / limit) * 100 : null; + usedPercent = percent === null ? null : Math.min(100, percent); + valueLabel = `$${formatMoney(used)} / $${formatMoney(limit)}`; + + if (isOpenRouterPeriod(data.limit_reset)) { + windowKey = data.limit_reset; + windowSeconds = PERIOD_SECONDS[data.limit_reset]; + resetAt = openRouterResetAt(data.limit_reset, nowMs); + } else { + windowKey = 'credits'; + windowSeconds = null; + resetAt = null; + } } return buildResult({ @@ -2024,22 +2087,28 @@ const fetchOpenRouterQuota = async (): Promise => { configured: true, usage: { windows: { - credits: toUsageWindow({ - usedPercent: null, - windowSeconds: null, - resetAt: null, + [windowKey]: toUsageWindow({ + usedPercent, + windowSeconds, + resetAt, valueLabel, }), }, }, }); } catch (error) { + const isTimeout = error instanceof DOMException && (error.name === 'TimeoutError' || (error.name === 'AbortError' && timeoutSignal.aborted)); + const isParseError = error instanceof SyntaxError; return buildResult({ providerId: 'openrouter', providerName: 'OpenRouter', ok: false, configured: true, - error: error instanceof Error ? error.message : 'Request failed', + error: isTimeout + ? 'Request timed out' + : isParseError + ? 'Invalid response from provider' + : error instanceof Error ? error.message : 'Request failed', }); } }; @@ -2508,7 +2577,6 @@ const fetchNeuralwattQuota = async (): Promise => { const subscription = payload?.subscription ?? null; const inOverage = Boolean(subscription?.in_overage); const allowance = payload?.key?.allowance ?? null; - const keyName = payload?.key?.name ?? null; const creditsRemaining = toNumber(payload?.balance?.credits_remaining_usd); const windows: Record = {}; @@ -2554,19 +2622,17 @@ const fetchNeuralwattQuota = async (): Promise => { : (spent !== null && effectiveLimit !== null && effectiveLimit > 0 ? Math.max(0, Math.min(100, (spent / effectiveLimit) * 100)) : null); - // Window title is the localized period label (daily/weekly/monthly); key - // name is attached via valueLabel for identification (wafer precedent). + // Window title is the localized period label (daily/weekly/monthly); the + // usage value stays a percent so the UI's display-mode toggle applies. const periodKey = (period === 'daily' || period === 'weekly' || period === 'monthly' || period === 'month') ? (period === 'month' ? 'monthly' : period) : 'billing_cycle'; - const labelName = typeof keyName === 'string' && keyName.trim() ? keyName.trim() : null; const resetAt = toTimestamp(allowance.reset_at); const windowSeconds = period ? neuralwattWindowSeconds(period) : null; windows[periodKey] = toUsageWindow({ usedPercent, windowSeconds, resetAt, - ...(labelName ? { valueLabel: labelName } : {}), }); } else if (creditsRemaining !== null) { windows.credits_balance = toUsageWindow({ @@ -2689,6 +2755,118 @@ const fetchCrofQuota = async (): Promise => { } }; +const CLINE_PASS_USAGE_URL = 'https://api.cline.bot/api/v1/users/me/plan/usage-limits'; + +// Cline reports a rolling five-hour window, a rolling weekly window, and a +// calendar-month limit. Each window carries its duration so consumers can rank +// limits by how soon they run out; the calendar month has no fixed duration. +const CLINE_WINDOW_KINDS = new Map([ + ['five_hour', { key: '5h', windowSeconds: 5 * 60 * 60 }], + ['weekly', { key: 'weekly', windowSeconds: 7 * 24 * 60 * 60 }], + ['monthly', { key: 'monthly', windowSeconds: null }], +]); + +type ClineQuotaDependencies = { + readAuth?: () => AuthFile; + fetchImpl?: (url: string, options: RequestInit) => Promise; +}; + +export const fetchClinePassQuota = async ({ readAuth = readAuthFile, fetchImpl = fetch }: ClineQuotaDependencies = {}): Promise => { + const auth = readAuth(); + const entry = normalizeAuthEntry(getAuthEntry(auth, ['cline-pass'])); + const apiKey = asNonEmptyString(entry?.key) ?? asNonEmptyString(entry?.token); + + if (!apiKey) { + return buildResult({ + providerId: 'cline-pass', + providerName: 'ClinePass', + ok: false, + configured: false, + error: 'Not configured', + }); + } + + const timeoutSignal = AbortSignal.timeout(15_000); + + try { + const response = await fetchImpl(CLINE_PASS_USAGE_URL, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Accept-Encoding': 'identity', + }, + signal: timeoutSignal, + }); + + if (!response.ok) { + return buildResult({ + providerId: 'cline-pass', + providerName: 'ClinePass', + ok: false, + configured: true, + error: response.status === 401 + ? 'Session expired — please re-authenticate with ClinePass' + : `API error: ${response.status}`, + }); + } + + const payload = asObject(await response.json()); + const data = asObject(payload?.data); + const limits = Array.isArray(data?.limits) ? data.limits : []; + + const windows: Record = {}; + for (const item of limits) { + const limit = asObject(item); + if (!limit) continue; + const limitType = asNonEmptyString(limit.type); + const kind = limitType === null ? undefined : CLINE_WINDOW_KINDS.get(limitType); + if (!kind) continue; + const usedPercent = toNumber(asNonEmptyString(limit.percentUsed) + ?? (Number.isFinite(limit.percentUsed) ? limit.percentUsed : null)); + if (usedPercent === null) continue; + windows[kind.key] = toUsageWindow({ + usedPercent, + windowSeconds: kind.windowSeconds, + resetAt: toTimestamp(limit.resetsAt), + }); + } + + if (Object.keys(windows).length === 0) { + return buildResult({ + providerId: 'cline-pass', + providerName: 'ClinePass', + ok: false, + configured: true, + error: 'No quota data in response', + }); + } + + return buildResult({ + providerId: 'cline-pass', + providerName: 'ClinePass', + ok: true, + configured: true, + usage: { windows }, + }); + } catch (error) { + const isTimeout = error instanceof DOMException && ( + error.name === 'TimeoutError' || (error.name === 'AbortError' && timeoutSignal.aborted) + ); + const isParseError = error instanceof SyntaxError; + return buildResult({ + providerId: 'cline-pass', + providerName: 'ClinePass', + ok: false, + configured: true, + error: isTimeout + ? 'Request timed out' + : isParseError + ? 'Invalid response from provider' + : (error instanceof Error ? error.message : 'Request failed'), + }); + } +}; + const DEEPSEEK_QUOTA_URL = 'https://api.deepseek.com/user/balance'; const fetchDeepseekQuota = async (): Promise => { @@ -2786,6 +2964,113 @@ const fetchDeepseekQuota = async (): Promise => { } }; +const HYPER_QUOTA_URL = 'https://hyper.charm.land/v1/credits'; +const HYPER_CREDIT_TO_USD = 0.05; + +const getHyperApiKey = (auth: AuthFile) => { + const entry = normalizeAuthEntry(getAuthEntry(auth, ['hyper'])); + return asNonEmptyString(entry?.key) ?? asNonEmptyString(entry?.token); +}; + +type HyperQuotaDependencies = { + readAuth?: () => AuthFile; + fetchImpl?: (url: string, options: RequestInit) => Promise; +}; + +export const fetchHyperQuota = async ({ readAuth = readAuthFile, fetchImpl = fetch }: HyperQuotaDependencies = {}): Promise => { + const apiKey = getHyperApiKey(readAuth()); + + if (!apiKey) { + return buildResult({ + providerId: 'hyper', + providerName: 'Charm Hyper', + ok: false, + configured: false, + error: 'Not configured', + }); + } + + const timeoutSignal = AbortSignal.timeout(15_000); + + try { + const response = await fetchImpl(HYPER_QUOTA_URL, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Accept-Encoding': 'identity', + }, + signal: timeoutSignal, + }); + + if (!response.ok) { + return buildResult({ + providerId: 'hyper', + providerName: 'Charm Hyper', + ok: false, + configured: true, + error: response.status === 401 || response.status === 403 + ? 'Session expired — please re-authenticate with Charm Hyper' + : `API error: ${response.status}`, + }); + } + + const payload = asObject(await response.json()); + const rawBalance = payload?.balance; + const balance = toNumber(asNonEmptyString(rawBalance) + ?? (Number.isFinite(rawBalance) ? rawBalance : null)); + + if (balance === null) { + return buildResult({ + providerId: 'hyper', + providerName: 'Charm Hyper', + ok: false, + configured: true, + error: 'No quota data in response', + }); + } + + const creditsLabel = Number.isInteger(balance) ? String(balance) : formatMoney(balance); + const windows = { + credits_balance: toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt: null, + valueLabel: `$${formatMoney(balance * HYPER_CREDIT_TO_USD)}`, + }), + credits: toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt: null, + valueLabel: creditsLabel, + }), + }; + + return buildResult({ + providerId: 'hyper', + providerName: 'Charm Hyper', + ok: true, + configured: true, + usage: { windows }, + }); + } catch (error) { + const isTimeout = error instanceof DOMException && ( + error.name === 'TimeoutError' || (error.name === 'AbortError' && timeoutSignal.aborted) + ); + const isParseError = error instanceof SyntaxError; + return buildResult({ + providerId: 'hyper', + providerName: 'Charm Hyper', + ok: false, + configured: true, + error: isTimeout + ? 'Request timed out' + : isParseError + ? 'Invalid response from provider' + : (error instanceof Error ? error.message : 'Request failed'), + }); + } +}; + const fetchXaiQuota = async (): Promise => { try { const entry = resolveXaiAuth(); @@ -2905,8 +3190,12 @@ const fetchQuotaForProviderUncoalesced = async (providerId: string): Promise { + const key = Object.keys(SETTINGS_REGISTRY_FIELDS).find((candidate) => SETTINGS_REGISTRY_FIELDS[candidate].scope === scope); + assert.ok(key, `snapshot has a ${scope} key`); + return key; +}; +const deviceKey = firstKeyWithScope('device'); + +describe('parsePreferencesDocument', () => { + test('rejects invalid JSON', () => { + const result = parsePreferencesDocument('{ not json'); + assert.equal(result.ok, false); + assert.ok(!result.ok && result.reason.startsWith('invalid JSON')); + }); + + test('rejects a wrong version', () => { + const result = parsePreferencesDocument(JSON.stringify({ version: 2, fields: {} })); + assert.deepEqual(result, { ok: false, reason: 'not a version-1 preferences document' }); + }); + + test('rejects non-object fields', () => { + assert.equal(parsePreferencesDocument(JSON.stringify({ version: 1, fields: [] })).ok, false); + assert.equal(parsePreferencesDocument(JSON.stringify({ version: 1, fields: 'x' })).ok, false); + assert.equal(parsePreferencesDocument(JSON.stringify({ version: 1 })).ok, false); + }); + + test('rejects an entry without a value', () => { + const result = parsePreferencesDocument(JSON.stringify({ version: 1, fields: { themeId: { updatedAt: 5 } } })); + assert.deepEqual(result, { ok: false, reason: 'field "themeId" is not a { value, updatedAt } entry' }); + }); + + test('accepts an empty document and defaults a missing stamp to 0', () => { + assert.deepEqual(parsePreferencesDocument(JSON.stringify({ version: 1, fields: {} })), { ok: true, fields: {} }); + const result = parsePreferencesDocument(JSON.stringify({ version: 1, fields: { themeId: { value: 'nord' } } })); + assert.deepEqual(result, { ok: true, fields: { themeId: { value: 'nord', updatedAt: 0 } } }); + }); +}); + +describe('buildPreferencesFields', () => { + const previous = { + themeId: { value: 'nord', updatedAt: 100 }, + defaultModel: { value: 'zen/gpt-5', updatedAt: 100 }, + darkThemeId: { value: 'dracula', updatedAt: 100 }, + }; + + test('keeps the stamp for unchanged values and restamps changed ones', () => { + const next = buildPreferencesFields(previous, { themeId: 'nord', defaultModel: 'zen/gpt-5-mini', darkThemeId: 'dracula' }, 200); + assert.deepEqual(next, { + themeId: { value: 'nord', updatedAt: 100 }, + defaultModel: { value: 'zen/gpt-5-mini', updatedAt: 200 }, + darkThemeId: { value: 'dracula', updatedAt: 100 }, + }); + }); + + test('compares structurally, so an equal object keeps its stamp', () => { + const before = { themeId: { value: { a: 1, b: [1, 2] }, updatedAt: 7 } }; + const next = buildPreferencesFields(before, { themeId: { a: 1, b: [1, 2] } }, 9); + assert.deepEqual(next, before); + }); + + test('drops profile keys the document no longer carries and ignores non-profile keys', () => { + const next = buildPreferencesFields(previous, { themeId: 'nord', opencodeBinary: '/usr/bin/opencode', [deviceKey]: '#fff', unknownKey: 1 }, 200); + assert.deepEqual(next, { themeId: { value: 'nord', updatedAt: 100 } }); + }); + + test('skips undefined values', () => { + assert.deepEqual(buildPreferencesFields({}, { themeId: undefined }, 1), {}); + }); +}); + +describe('instancePartOf', () => { + test('excludes profile keys and keeps instance and unknown legacy keys', () => { + const document = { themeId: 'nord', defaultModel: 'x', opencodeBinary: '/bin/oc', legacyKey: true, dropped: undefined }; + assert.deepEqual(instancePartOf(document), { opencodeBinary: '/bin/oc', legacyKey: true }); + }); +}); + +describe('scope helpers', () => { + test('classify keys by the checked-in registry snapshot', () => { + assert.equal(isProfileSettingsKey('themeId'), true); + assert.equal(isProfileSettingsKey('opencodeBinary'), false); + assert.equal(isDeviceSettingsKey(deviceKey), true); + assert.equal(isDeviceSettingsKey('themeId'), false); + assert.equal(isProfileSettingsKey('constructor'), false); + assert.equal(isProfileSettingsKey('nope'), false); + }); + + test('preferences.json sits beside settings.json', () => { + assert.equal(preferencesFilePathFor('/home/u/.config/openchamber/settings.json'), '/home/u/.config/openchamber/preferences.json'); + }); +}); + +describe('round trip', () => { + test('serialize then parse yields the same fields, and flatten yields the values', () => { + const fields = seedPreferencesFrom({ themeId: 'nord', defaultModel: 'zen/gpt-5', opencodeBinary: '/bin/oc' }, 42); + assert.deepEqual(fields, { + themeId: { value: 'nord', updatedAt: 42 }, + defaultModel: { value: 'zen/gpt-5', updatedAt: 42 }, + }); + const text = serializePreferencesDocument(fields); + assert.ok(text.startsWith('{\n "version": 1,\n "fields": {')); + const parsed = parsePreferencesDocument(text); + assert.deepEqual(parsed, { ok: true, fields }); + assert.deepEqual(flattenPreferences(fields), { themeId: 'nord', defaultModel: 'zen/gpt-5' }); + }); +}); + +describe('per-surface keys', () => { + const perSurfaceKey = Object.keys(SETTINGS_REGISTRY_FIELDS).find((key) => SETTINGS_REGISTRY_FIELDS[key].perSurface === true); + const plainProfileKey = Object.keys(SETTINGS_REGISTRY_FIELDS).find( + (key) => SETTINGS_REGISTRY_FIELDS[key].scope === 'profile' && SETTINGS_REGISTRY_FIELDS[key].perSurface !== true, + ); + + test('the snapshot names at least one per-surface profile key', () => { + assert.ok(perSurfaceKey && isPerSurfaceSettingsKey(perSurfaceKey)); + assert.ok(plainProfileKey && !isPerSurfaceSettingsKey(plainProfileKey)); + }); + + test('a surface write lands under the surface and leaves the base as it was', () => { + assert.ok(perSurfaceKey && plainProfileKey); + const previous = { [perSurfaceKey]: { value: 'base', updatedAt: 1 } }; + const next = buildPreferencesFields(previous, { [perSurfaceKey]: 'mine', [plainProfileKey]: 'shared' }, 5, { + surface: 'vscode', + changedKeys: [perSurfaceKey, plainProfileKey], + }); + assert.deepEqual(next[perSurfaceKey], { value: 'base', updatedAt: 1, surfaces: { vscode: { value: 'mine', updatedAt: 5 } } }); + assert.deepEqual(next[plainProfileKey], { value: 'shared', updatedAt: 5 }); + assert.equal(flattenPreferences(next, 'vscode')[perSurfaceKey], 'mine'); + assert.equal(flattenPreferences(next, 'mobile')[perSurfaceKey], 'base'); + assert.equal(flattenPreferences(next)[perSurfaceKey], 'base'); + }); + + test('a per-surface key the write did not change keeps its whole entry', () => { + assert.ok(perSurfaceKey && plainProfileKey); + const previous = { [perSurfaceKey]: { value: 'base', updatedAt: 1, surfaces: { mobile: { value: 'phone', updatedAt: 2 } } } }; + const next = buildPreferencesFields(previous, { [perSurfaceKey]: 'base', [plainProfileKey]: 'x' }, 9, { + surface: 'vscode', + changedKeys: [plainProfileKey], + }); + assert.deepEqual(next[perSurfaceKey], previous[perSurfaceKey]); + }); + + test('a per-surface key first set from one surface has no base', () => { + assert.ok(perSurfaceKey); + const next = buildPreferencesFields({}, { [perSurfaceKey]: 'mine' }, 3, { surface: 'vscode', changedKeys: [perSurfaceKey] }); + assert.equal('value' in next[perSurfaceKey], false); + assert.deepEqual(next[perSurfaceKey].surfaces, { vscode: { value: 'mine', updatedAt: 3 } }); + const parsed = parsePreferencesDocument(serializePreferencesDocument(next)); + assert.ok(parsed.ok); + assert.equal(flattenPreferences(parsed.fields, 'mobile')[perSurfaceKey], undefined); + }); + + test('rejects an unknown surface in the file', () => { + const result = parsePreferencesDocument(JSON.stringify({ version: 1, fields: { x: { surfaces: { toaster: { value: 1 } } } } })); + assert.equal(result.ok, false); + }); +}); diff --git a/packages/vscode/src/settings-files.ts b/packages/vscode/src/settings-files.ts new file mode 100644 index 00000000..c8668b44 --- /dev/null +++ b/packages/vscode/src/settings-files.ts @@ -0,0 +1,201 @@ +// The two settings files and how a merged document is split between them. +// +// `settings.json` holds instance facts (and, untouched, whatever legacy keys +// older builds left there). `preferences.json` holds the user's profile: the +// keys the settings registry marks `profile`, each with the time the store +// last accepted a new value for it. Device keys never reach either file. +// +// Mirrors the server implementation in +// `packages/web/server/lib/opencode/settings-files.js`; both sides must write +// byte-compatible files, so keep format changes in sync. +// +// Kept free of `vscode` imports so it is unit-tested directly. +import * as path from 'path'; +import { SETTINGS_REGISTRY_FIELDS } from './settings-registry-gate'; + +const PREFERENCES_FILE_NAME = 'preferences.json'; +const PREFERENCES_DOCUMENT_VERSION = 1; + +type SettingsSurface = 'web' | 'desktop' | 'vscode' | 'mobile'; +const SETTINGS_SURFACES: readonly SettingsSurface[] = ['web', 'desktop', 'vscode', 'mobile']; +// SAFETY: widening the tuple to `readonly string[]` only for the membership test; the guard's result is what narrows. +const isSettingsSurface = (value: string): value is SettingsSurface => (SETTINGS_SURFACES as readonly string[]).includes(value); + +/** The extension host is always the VS Code surface kind. */ +export const VSCODE_SETTINGS_SURFACE: SettingsSurface = 'vscode'; + +// Boundary parser: values are whatever JSON the file (or the webview) carries. +type SurfaceValue = { value: unknown; updatedAt: number }; +// The base value is optional: a per-surface key first set from one surface kind has none. +type PreferenceField = { value?: unknown; updatedAt: number; surfaces?: Partial> }; +export type PreferenceFields = Record; + +type ParsedPreferencesDocument = + | { ok: true; fields: PreferenceFields } + | { ok: false; reason: string }; + +/** The registry scope for a key, or `null` when the registry does not know it. */ +const getSettingsScope = (key: string): string | null => + Object.prototype.hasOwnProperty.call(SETTINGS_REGISTRY_FIELDS, key) ? SETTINGS_REGISTRY_FIELDS[key].scope : null; + +export const isProfileSettingsKey = (key: string): boolean => getSettingsScope(key) === 'profile'; +export const isDeviceSettingsKey = (key: string): boolean => getSettingsScope(key) === 'device'; +/** Profile keys the owner chose to store per surface kind. */ +export const isPerSurfaceSettingsKey = (key: string): boolean => + Object.prototype.hasOwnProperty.call(SETTINGS_REGISTRY_FIELDS, key) && SETTINGS_REGISTRY_FIELDS[key].perSurface === true; + +export const preferencesFilePathFor = (settingsFilePath: string): string => + path.join(path.dirname(settingsFilePath), PREFERENCES_FILE_NAME); + +const isPlainObject = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); + +const parseStamp = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0); + +const sameValue = (left: unknown, right: unknown): boolean => { + if (left === right) return true; + if (left === undefined || right === undefined) return false; + return JSON.stringify(left) === JSON.stringify(right); +}; + +/** + * Parse the text of a preferences file. A missing file is the caller's case + * (ENOENT); anything that is not a version-1 document with a `fields` object + * is a failure, never an empty profile. + */ +export const parsePreferencesDocument = (raw: string): ParsedPreferencesDocument => { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + return { ok: false, reason: `invalid JSON: ${error instanceof Error ? error.message : String(error)}` }; + } + if (!isPlainObject(parsed) || parsed.version !== PREFERENCES_DOCUMENT_VERSION || !isPlainObject(parsed.fields)) { + return { ok: false, reason: 'not a version-1 preferences document' }; + } + const fields: PreferenceFields = {}; + for (const [key, entry] of Object.entries(parsed.fields)) { + if (!isPlainObject(entry) || (!('value' in entry) && !isPlainObject(entry.surfaces))) { + return { ok: false, reason: `field "${key}" is not a { value, updatedAt } entry` }; + } + const next: PreferenceField = { updatedAt: parseStamp(entry.updatedAt) }; + if ('value' in entry) next.value = entry.value; + if (isPlainObject(entry.surfaces)) { + const surfaces: Partial> = {}; + for (const [surface, surfaceEntry] of Object.entries(entry.surfaces)) { + if (!isSettingsSurface(surface) || !isPlainObject(surfaceEntry) || !('value' in surfaceEntry)) { + return { ok: false, reason: `field "${key}" has an invalid surface entry "${surface}"` }; + } + surfaces[surface] = { value: surfaceEntry.value, updatedAt: parseStamp(surfaceEntry.updatedAt) }; + } + next.surfaces = surfaces; + } + fields[key] = next; + } + return { ok: true, fields }; +}; + +export const serializePreferencesDocument = (fields: PreferenceFields): string => + JSON.stringify({ version: PREFERENCES_DOCUMENT_VERSION, fields }, null, 2); + +/** + * The plain key → value view of preference fields as one surface kind sees it: + * that surface's own value first, the base value otherwise; a key with neither + * is absent (the webview keeps what it holds, or its default). + */ +export const flattenPreferences = (fields: PreferenceFields, surface: SettingsSurface | null = null): Record => { + const values: Record = {}; + for (const [key, entry] of Object.entries(fields)) { + const own = surface ? entry.surfaces?.[surface] : undefined; + if (own) { + values[key] = own.value; + } else if ('value' in entry) { + values[key] = entry.value; + } + } + return values; +}; + +/** + * The next preference fields for a merged document: every profile key it + * carries, stamped `now` when its value differs from what the file held and + * keeping the earlier stamp otherwise. Profile keys the document no longer + * carries are dropped (that is how a cleared key leaves the file). + */ +export const buildPreferencesFields = ( + previousFields: PreferenceFields, + document: Record, + now: number, + options: { surface?: SettingsSurface | null; changedKeys?: Iterable | null } = {}, +): PreferenceFields => { + const surface = options.surface ?? null; + const changed = options.changedKeys ? new Set(options.changedKeys) : null; + const fields: PreferenceFields = {}; + for (const [key, value] of Object.entries(document)) { + if (value === undefined || !isProfileSettingsKey(key)) continue; + const previous = previousFields[key]; + // Per-surface keys: a surface's write lands under its own entry and leaves + // the base as it was; a key the write did not change keeps its whole entry + // (the document only carries this surface's resolved view of it). + if (surface && isPerSurfaceSettingsKey(key)) { + if (changed && !changed.has(key)) { + if (previous) fields[key] = previous; + continue; + } + const previousOwn = previous?.surfaces?.[surface]; + const own: SurfaceValue = previousOwn && sameValue(previousOwn.value, value) ? previousOwn : { value, updatedAt: now }; + fields[key] = { + ...(previous ?? { updatedAt: 0 }), + surfaces: { ...(previous?.surfaces ?? {}), [surface]: own }, + }; + continue; + } + if (previous && 'value' in previous && sameValue(previous.value, value)) { + fields[key] = previous; + } else { + fields[key] = { ...(previous ?? {}), value, updatedAt: now }; + } + } + return fields; +}; + +/** + * The part of a merged document that belongs in `settings.json`: everything + * that is not a profile key. Device keys are already filtered by the registry + * gate on the write path; ones older builds persisted stay in place. + */ +export const instancePartOf = (document: Record): Record => { + const instance: Record = {}; + for (const [key, value] of Object.entries(document)) { + if (value === undefined || isProfileSettingsKey(key)) continue; + instance[key] = value; + } + return instance; +}; + +/** The profile keys of a document, as they would seed a fresh preferences file. */ +/** The profile keys of a document (the part `instancePartOf` leaves out). */ +export const profilePartOf = (document: Record): Record => { + const profile: Record = {}; + for (const [key, value] of Object.entries(document)) { + if (value !== undefined && isProfileSettingsKey(key)) profile[key] = value; + } + return profile; +}; + +/** + * What `settings.json` holds after a write: the instance part plus a copy of + * the profile's base values, so a build from before the split (which reads + * only this file) still finds the user's preferences. Current builds ignore + * the copy: `preferences.json` wins in the merged read. + */ +export const legacySettingsDocumentOf = ( + document: Record, + preferenceFields: PreferenceFields, +): Record => ({ + ...instancePartOf(document), + ...flattenPreferences(preferenceFields), +}); + +export const seedPreferencesFrom = (document: Record, now: number): PreferenceFields => + buildPreferencesFields({}, document, now); diff --git a/packages/vscode/src/settings-registry-gate.test.ts b/packages/vscode/src/settings-registry-gate.test.ts new file mode 100644 index 00000000..7fc9003e --- /dev/null +++ b/packages/vscode/src/settings-registry-gate.test.ts @@ -0,0 +1,72 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { SETTINGS_REGISTRY_FIELDS, filterPersistableSettingsChanges, withoutSecretSettings, type SettingsRegistryGateFields } from './settings-registry-gate'; + +const fields: SettingsRegistryGateFields = { + themeId: { scope: 'profile' }, + smallModelOverride: { scope: 'profile' }, + hasDesktopSettings: { scope: 'instance', computed: true }, + sidebarWidth: { scope: 'device', local: true }, + windowBounds: { scope: 'instance', owner: 'desktop-shell' }, + desktopUiPassword: { scope: 'instance', secret: true }, +}; + +describe('withoutSecretSettings', () => { + test('withholds secret keys and keeps everything else', () => { + assert.deepEqual(withoutSecretSettings({ desktopUiPassword: 'pw', themeId: 'a' }, fields), { themeId: 'a' }); + }); + + test('the real registry marks the UI password and tunnel tokens secret', () => { + const stripped = withoutSecretSettings({ + desktopUiPassword: 'pw', + managedRemoteTunnelToken: 't', + managedRemoteTunnelPresetTokens: { a: 't' }, + themeId: 'a', + }, SETTINGS_REGISTRY_FIELDS); + assert.deepEqual(stripped, { themeId: 'a' }); + }); +}); + +describe('filterPersistableSettingsChanges', () => { + test('keeps stored shared fields and preserves their values as sent', () => { + const result = filterPersistableSettingsChanges( + { themeId: 'nord', smallModelOverride: '', unrelated: 1 }, + fields, + ); + assert.deepEqual(result, { themeId: 'nord', smallModelOverride: '' }); + }); + + test('drops keys the registry does not know', () => { + assert.deepEqual(filterPersistableSettingsChanges({ gitProviderId: 'zen', gitModelId: 'x' }, fields), {}); + }); + + test('drops computed, local, and desktop-shell owned keys', () => { + const result = filterPersistableSettingsChanges( + { hasDesktopSettings: true, sidebarWidth: 320, windowBounds: { x: 0 }, themeId: 'a' }, + fields, + ); + assert.deepEqual(result, { themeId: 'a' }); + }); + + test('ignores prototype keys that are not registry fields', () => { + assert.deepEqual(filterPersistableSettingsChanges({ constructor: 'x', toString: 'y' }, fields), {}); + }); + + test('the checked-in snapshot drops derived-at-read and desktop-shell keys but keeps profile settings', () => { + const result = filterPersistableSettingsChanges({ + themeId: 'nord', + smallModelUseDefault: false, + smallModelOverride: 'zen/gpt-5-nano', + gitProviderId: 'zen', + gitModelId: 'gpt-5-nano', + }); + assert.deepEqual(result, { themeId: 'nord', smallModelUseDefault: false, smallModelOverride: 'zen/gpt-5-nano' }); + + const computedKeys = Object.entries(SETTINGS_REGISTRY_FIELDS).filter(([, field]) => field.computed).map(([key]) => key); + const localKeys = Object.entries(SETTINGS_REGISTRY_FIELDS).filter(([, field]) => field.local).map(([key]) => key); + const shellKeys = Object.entries(SETTINGS_REGISTRY_FIELDS).filter(([, field]) => field.owner === 'desktop-shell').map(([key]) => key); + assert.ok(computedKeys.length > 0 && localKeys.length > 0 && shellKeys.length > 0, 'snapshot exercises every gate branch'); + const blocked = Object.fromEntries([...computedKeys, ...localKeys, ...shellKeys].map((key) => [key, 'value'])); + assert.deepEqual(filterPersistableSettingsChanges(blocked), {}); + }); +}); diff --git a/packages/vscode/src/settings-registry-gate.ts b/packages/vscode/src/settings-registry-gate.ts new file mode 100644 index 00000000..24c19c2a --- /dev/null +++ b/packages/vscode/src/settings-registry-gate.ts @@ -0,0 +1,60 @@ +// Gate for the bridge's settings write path. The generated registry snapshot +// (`settings-registry.json`, produced from the UI package's settings registry) +// names every key OpenChamber persists; anything else the webview sends is +// dropped here so the shared settings file never grows keys the rest of the +// product does not know about. +// +// Kept free of `vscode` imports so it is unit-tested directly. +import registrySnapshot from './settings-registry.json'; + +type SettingsRegistryGateField = { + scope: string; + perSurface?: boolean; + computed?: boolean; + secret?: boolean; + local?: boolean; + owner?: string; +}; + +export type SettingsRegistryGateFields = Record; + +export const SETTINGS_REGISTRY_FIELDS: SettingsRegistryGateFields = registrySnapshot.fields; + +/** + * A key is persistable through the bridge only when the registry lists it as a + * stored, shared field: not computed at read time, not local to one webview's + * store, and not owned by the desktop shell (which keeps its own values). + */ +const isPersistableField = (field: SettingsRegistryGateField | undefined): boolean => { + if (!field) return false; + if (field.computed === true) return false; + if (field.local === true) return false; + if (field.owner === 'desktop-shell') return false; + return true; +}; + +export const filterPersistableSettingsChanges = ( + changes: Record, + fields: SettingsRegistryGateFields = SETTINGS_REGISTRY_FIELDS, +): Record => { + const next: Record = {}; + for (const [key, value] of Object.entries(changes)) { + if (!Object.prototype.hasOwnProperty.call(fields, key)) continue; + if (!isPersistableField(fields[key])) continue; + next[key] = value; + } + return next; +}; + +/** Drop the keys the registry marks `secret`: accepted on write, never handed back to a webview. */ +export const withoutSecretSettings = ( + settings: Record, + fields: SettingsRegistryGateFields = SETTINGS_REGISTRY_FIELDS, +): Record => { + const next: Record = {}; + for (const [key, value] of Object.entries(settings)) { + if (fields[key]?.secret === true) continue; + next[key] = value; + } + return next; +}; diff --git a/packages/vscode/src/settings-registry.json b/packages/vscode/src/settings-registry.json new file mode 100644 index 00000000..3b319f08 --- /dev/null +++ b/packages/vscode/src/settings-registry.json @@ -0,0 +1,747 @@ +{ + "version": 1, + "fields": { + "themeId": { + "scope": "profile", + "perSurface": true + }, + "useSystemTheme": { + "scope": "profile", + "perSurface": true + }, + "themeVariant": { + "scope": "profile", + "derived": true + }, + "lightThemeId": { + "scope": "profile", + "perSurface": true + }, + "darkThemeId": { + "scope": "profile", + "perSurface": true + }, + "lastDirectory": { + "scope": "instance", + "adopt": "bootstrap-only" + }, + "homeDirectory": { + "scope": "instance" + }, + "opencodeBinary": { + "scope": "instance" + }, + "projects": { + "scope": "instance" + }, + "activeProjectId": { + "scope": "instance", + "adopt": "bootstrap-only" + }, + "securityScopedBookmarks": { + "scope": "instance", + "surfaces": [ + "desktop" + ] + }, + "pinnedDirectories": { + "scope": "instance" + }, + "desktopLanAccessEnabled": { + "scope": "instance", + "surfaces": [ + "desktop" + ] + }, + "desktopKeepAwakeEnabled": { + "scope": "instance", + "surfaces": [ + "desktop" + ] + }, + "desktopMinimizeToTrayEnabled": { + "scope": "instance", + "surfaces": [ + "desktop" + ] + }, + "desktopMacMenuBarEnabled": { + "scope": "instance", + "surfaces": [ + "desktop" + ] + }, + "desktopUiPassword": { + "scope": "instance", + "surfaces": [ + "desktop" + ], + "secret": true + }, + "hasDesktopUiPassword": { + "scope": "instance", + "surfaces": [ + "desktop" + ], + "computed": true + }, + "desktopLanAccessActive": { + "scope": "instance", + "surfaces": [ + "desktop" + ], + "computed": true + }, + "desktopLanAccessBlockedReason": { + "scope": "instance", + "surfaces": [ + "desktop" + ], + "computed": true + }, + "githubClientId": { + "scope": "instance" + }, + "githubScopes": { + "scope": "instance" + }, + "skillCatalogs": { + "scope": "instance" + }, + "defaultGitIdentityId": { + "scope": "instance" + }, + "permissionAutoAccept": { + "scope": "instance" + }, + "agentControlToolEnabled": { + "scope": "instance" + }, + "agentWebToolEnabled": { + "scope": "instance" + }, + "agentMemoryToolEnabled": { + "scope": "instance" + }, + "agentMemoryFeatureAvailable": { + "scope": "instance", + "computed": true + }, + "openCodeUpdateToastDismissedVersion": { + "scope": "instance" + }, + "autoDeleteEnabled": { + "scope": "instance" + }, + "autoDeleteAfterDays": { + "scope": "instance" + }, + "sessionRetentionAction": { + "scope": "instance" + }, + "terminalShell": { + "scope": "instance" + }, + "terminalLoginShells": { + "scope": "instance" + }, + "openInAppId": { + "scope": "instance" + }, + "dictationEnabled": { + "scope": "profile" + }, + "sttProvider": { + "scope": "instance" + }, + "sttServerUrl": { + "scope": "instance" + }, + "sttModel": { + "scope": "instance" + }, + "sttLocalModel": { + "scope": "instance" + }, + "sttLanguage": { + "scope": "profile" + }, + "tunnelProvider": { + "scope": "instance" + }, + "tunnelMode": { + "scope": "instance" + }, + "tunnelBootstrapTtlMs": { + "scope": "instance" + }, + "tunnelSessionTtlMs": { + "scope": "instance" + }, + "managedLocalTunnelConfigPath": { + "scope": "instance" + }, + "managedRemoteTunnelHostname": { + "scope": "instance" + }, + "managedRemoteTunnelToken": { + "scope": "instance", + "secret": true + }, + "hasManagedRemoteTunnelToken": { + "scope": "instance", + "computed": true + }, + "managedRemoteTunnelPresets": { + "scope": "instance" + }, + "managedRemoteTunnelSelectedPresetId": { + "scope": "instance" + }, + "managedRemoteTunnelPresetTokens": { + "scope": "instance", + "secret": true + }, + "sidebarProjectDisplayMode": { + "scope": "profile" + }, + "sidebarSessionGroupingMode": { + "scope": "profile" + }, + "sidebarProjectSortOrder": { + "scope": "profile" + }, + "sidebarShowRecentSection": { + "scope": "profile" + }, + "workStatusPanelEnabled": { + "scope": "profile" + }, + "workStatusHiddenSections": { + "scope": "profile" + }, + "workStatusHiddenSectionsExplicit": { + "scope": "profile" + }, + "showReasoningTraces": { + "scope": "profile" + }, + "streamingAutoFollowEnabled": { + "scope": "profile", + "perSurface": true + }, + "collapsibleThinkingBlocks": { + "scope": "profile" + }, + "showTextJustificationActivity": { + "scope": "profile" + }, + "chatRenderMode": { + "scope": "profile" + }, + "activityRenderMode": { + "scope": "profile" + }, + "mermaidRenderingMode": { + "scope": "profile" + }, + "userMessageRenderingMode": { + "scope": "profile" + }, + "collapsibleUserMessages": { + "scope": "profile" + }, + "stickyUserHeader": { + "scope": "profile", + "perSurface": true + }, + "promptNavigatorEnabled": { + "scope": "profile", + "perSurface": true + }, + "wideChatLayoutEnabled": { + "scope": "profile", + "perSurface": true + }, + "showSplitAssistantMessageActions": { + "scope": "profile" + }, + "showToolFileIcons": { + "scope": "profile" + }, + "codeBlockLineWrap": { + "scope": "profile" + }, + "showTurnChangedFiles": { + "scope": "profile" + }, + "showExpandedBashTools": { + "scope": "profile" + }, + "showExpandedEditTools": { + "scope": "profile" + }, + "toolJsonViewMode": { + "scope": "profile" + }, + "timeFormatPreference": { + "scope": "profile" + }, + "weekStartPreference": { + "scope": "profile" + }, + "messageStreamTransport": { + "scope": "profile" + }, + "diffLayoutPreference": { + "scope": "profile" + }, + "diffWrapLines": { + "scope": "profile" + }, + "gitChangesViewMode": { + "scope": "profile" + }, + "gitmojiEnabled": { + "scope": "profile" + }, + "defaultFileViewerPreview": { + "scope": "profile" + }, + "directoryShowHidden": { + "scope": "profile" + }, + "filesViewShowGitignored": { + "scope": "profile" + }, + "fileEditorKeymap": { + "scope": "profile" + }, + "autoSaveEnabled": { + "scope": "profile" + }, + "autoCreateWorktree": { + "scope": "profile" + }, + "sessionTabsEnabled": { + "scope": "profile", + "surfaces": [ + "web", + "desktop", + "vscode" + ] + }, + "showOpenCodeRestartConfirm": { + "scope": "profile" + }, + "allowPromptingSubagentSessions": { + "scope": "profile" + }, + "inputSpellcheckEnabled": { + "scope": "profile" + }, + "enterToSend": { + "scope": "profile" + }, + "enterToSendConfigured": { + "scope": "profile" + }, + "persistChatDraft": { + "scope": "profile" + }, + "largeTextPasteBehavior": { + "scope": "profile" + }, + "followUpBehavior": { + "scope": "profile" + }, + "queueModeEnabled": { + "scope": "profile" + }, + "inputHistoryScope": { + "scope": "profile" + }, + "inputHistoryLimit": { + "scope": "profile" + }, + "draftStarters": { + "scope": "profile" + }, + "draftStartersVisible": { + "scope": "profile" + }, + "draftStartersCraftGoalAdded": { + "scope": "profile" + }, + "draftStartersScheduleTaskAdded": { + "scope": "profile" + }, + "fontSize": { + "scope": "profile", + "perSurface": true + }, + "terminalFontSize": { + "scope": "profile", + "perSurface": true + }, + "editorFontSize": { + "scope": "profile", + "perSurface": true + }, + "uiFont": { + "scope": "profile" + }, + "monoFont": { + "scope": "profile" + }, + "padding": { + "scope": "profile", + "perSurface": true + }, + "cornerRadius": { + "scope": "profile", + "perSurface": true + }, + "shortcutOverrides": { + "scope": "profile" + }, + "defaultModel": { + "scope": "profile" + }, + "defaultVariant": { + "scope": "profile" + }, + "defaultAgent": { + "scope": "profile" + }, + "smallModelUseDefault": { + "scope": "profile" + }, + "smallModelOverride": { + "scope": "profile" + }, + "walkthroughModelOverride": { + "scope": "profile" + }, + "zenModel": { + "scope": "profile" + }, + "favoriteModels": { + "scope": "profile" + }, + "hiddenModels": { + "scope": "profile" + }, + "collapsedModelProviders": { + "scope": "profile" + }, + "recentModels": { + "scope": "profile" + }, + "recentAgents": { + "scope": "profile" + }, + "recentEfforts": { + "scope": "profile" + }, + "providerOrder": { + "scope": "profile" + }, + "sessionRecapEnabled": { + "scope": "profile" + }, + "sessionSuggestionEnabled": { + "scope": "profile" + }, + "sessionGoalEnabled": { + "scope": "profile" + }, + "sessionGoalDefaultBudgetEnabled": { + "scope": "profile" + }, + "sessionGoalDefaultBudget": { + "scope": "profile" + }, + "summarizeLastMessage": { + "scope": "profile" + }, + "summaryThreshold": { + "scope": "profile" + }, + "summaryLength": { + "scope": "profile" + }, + "maxLastMessageLength": { + "scope": "profile" + }, + "showDeletionDialog": { + "scope": "profile" + }, + "nativeNotificationsEnabled": { + "scope": "profile" + }, + "notificationMode": { + "scope": "profile" + }, + "notifyOnSubtasks": { + "scope": "profile" + }, + "notifyOnCompletion": { + "scope": "profile" + }, + "notifyOnError": { + "scope": "profile" + }, + "notifyOnQuestion": { + "scope": "profile" + }, + "notificationTemplates": { + "scope": "profile" + }, + "showOpenCodeUpdateNotifications": { + "scope": "profile" + }, + "reportUsage": { + "scope": "profile" + }, + "usageDisplayMode": { + "scope": "profile" + }, + "usageDropdownProviders": { + "scope": "profile" + }, + "usageSelectedModels": { + "scope": "profile" + }, + "usageCollapsedFamilies": { + "scope": "profile" + }, + "usageExpandedFamilies": { + "scope": "profile" + }, + "usageModelGroups": { + "scope": "profile" + }, + "globalBehaviorPrompt": { + "scope": "profile" + }, + "responseStyleEnabled": { + "scope": "profile" + }, + "responseStylePreset": { + "scope": "profile" + }, + "responseStyleCustomInstructions": { + "scope": "profile" + }, + "optimizeSystemPrompt": { + "scope": "profile" + }, + "pwaAppName": { + "scope": "instance", + "surfaces": [ + "web" + ] + }, + "pwaOrientation": { + "scope": "instance", + "surfaces": [ + "web" + ] + }, + "mobileKeyboardMode": { + "scope": "device", + "surfaces": [ + "mobile" + ] + }, + "desktopWindowControlsPosition": { + "scope": "device", + "surfaces": [ + "desktop" + ] + }, + "desktopWindowControlsStyle": { + "scope": "device", + "surfaces": [ + "desktop" + ] + }, + "inputBarOffset": { + "scope": "device", + "surfaces": [ + "mobile", + "web" + ] + }, + "theme": { + "scope": "device", + "local": true + }, + "isSidebarOpen": { + "scope": "device", + "local": true + }, + "sidebarWidth": { + "scope": "device", + "local": true + }, + "contextPanelByDirectory": { + "scope": "device", + "local": true + }, + "contextRailOrder": { + "scope": "device", + "local": true + }, + "contextRailHiddenSurfaces": { + "scope": "device", + "local": true + }, + "contextEditorTreeVisible": { + "scope": "device", + "local": true + }, + "contextEditorTreeWidth": { + "scope": "device", + "local": true + }, + "notesPanelHeight": { + "scope": "device", + "local": true + }, + "workStatusExpandedSections": { + "scope": "device", + "local": true + }, + "workStatusScrollTop": { + "scope": "device", + "local": true + }, + "isSessionSwitcherOpen": { + "scope": "device", + "local": true + }, + "sidebarSection": { + "scope": "device", + "local": true + }, + "settingsPage": { + "scope": "device", + "local": true + }, + "settingsHasOpenedOnce": { + "scope": "device", + "local": true + }, + "settingsProjectsSelectedId": { + "scope": "device", + "local": true + }, + "settingsRemoteInstancesSelectedId": { + "scope": "device", + "local": true + }, + "isSessionCreateDialogOpen": { + "scope": "device", + "local": true + }, + "autoDeleteLastRunAt": { + "scope": "device", + "local": true + }, + "messageLimit": { + "scope": "device", + "local": true + }, + "walkthroughTocWidth": { + "scope": "device", + "local": true + }, + "linearIssueListStatus": { + "scope": "device", + "local": true + }, + "linearIssueListAssignee": { + "scope": "device", + "local": true + }, + "linearIssueListTeamIdByRuntime": { + "scope": "device", + "local": true + }, + "linearIssueListPriority": { + "scope": "device", + "local": true + }, + "showTerminalQuickKeysOnDesktop": { + "scope": "device", + "local": true + }, + "dockBadgeEnabled": { + "scope": "device", + "local": true + }, + "alwaysShowScrollbars": { + "scope": "device", + "local": true + }, + "agentMemoryViewedAt": { + "scope": "device", + "local": true + }, + "projectContextSidebarWidth": { + "scope": "device", + "local": true + }, + "desktopSplashColors": { + "scope": "instance", + "owner": "desktop-shell", + "surfaces": [ + "desktop" + ] + }, + "desktopHosts": { + "scope": "instance", + "owner": "desktop-shell", + "surfaces": [ + "desktop" + ] + }, + "desktopDefaultHostId": { + "scope": "instance", + "owner": "desktop-shell", + "surfaces": [ + "desktop" + ] + }, + "desktopInstallId": { + "scope": "instance", + "owner": "desktop-shell", + "surfaces": [ + "desktop" + ] + }, + "desktopLocalPort": { + "scope": "instance", + "owner": "desktop-shell", + "surfaces": [ + "desktop" + ] + }, + "desktopSshInstances": { + "scope": "instance", + "owner": "desktop-shell", + "surfaces": [ + "desktop" + ] + }, + "desktopWindowState": { + "scope": "instance", + "owner": "desktop-shell", + "surfaces": [ + "desktop" + ] + } + } +} diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index 2ce556d7..7e37d6c8 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -398,6 +398,27 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R return unsupportedWebRouteResponse('Scheduled tasks'); } + // Project setup (worktree setup commands, project actions, draft starters) + // lives in the user's OpenChamber config dir; the extension host owns the + // file the way the OpenChamber server does elsewhere. + const projectSetupMatch = normalizedPathname.match(/^\/api\/projects\/([^/]+)\/config(\/shared)?$/); + if (projectSetupMatch && (method === 'GET' || method === 'PUT') && !(method === 'GET' && projectSetupMatch[2])) { + const projectId = decodeURIComponent(projectSetupMatch[1]); + const payload = method === 'GET' + ? { projectId } + : { projectId, patch: await extractJsonBody(input, init, method) }; + const bridgeType = method === 'GET' + ? 'api:project-setup:get' + : projectSetupMatch[2] ? 'api:project-setup:update-shared' : 'api:project-setup:update'; + try { + const data = await sendBridgeMessage(bridgeType, payload); + return jsonResponse(data, 200); + } catch (error) { + const message = error instanceof Error ? error.message : 'Project config request failed'; + return jsonResponse({ error: message }, /must be|is required|unsupported characters/.test(message) ? 400 : 500); + } + } + if (normalizedPathname === '/api/fs/git-dirs') { return unsupportedWebRouteResponse('Nested git repository discovery'); } diff --git a/packages/web/bin/cli.js b/packages/web/bin/cli.js index dd56e0ad..cdf1f201 100755 --- a/packages/web/bin/cli.js +++ b/packages/web/bin/cli.js @@ -72,6 +72,13 @@ import { printJson, logStatus, } from './cli-output.js'; +import { applyConnectAttemptTimeout } from '../server/lib/network-defaults.js'; + +// The CLI process performs provider fetches (quota/usage, update notes) under +// Node/undici, whose happy-eyeballs default aborts each connect attempt after +// 250ms — distant provider endpoints routinely need longer handshakes, surfacing +// as "fetch failed" (#3399). No-op on runtimes without the setter. +applyConnectAttemptTimeout(); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); diff --git a/packages/web/index.html b/packages/web/index.html index a7b7ffdd..ad939681 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -599,44 +599,6 @@ }, 10000); - - -