merge: resolve v1.23.0 upstream conflicts, preserve custom git provider config

Resolved conflicts in 8 files by taking upstream refactored code:
- desktop.ts: re-export DesktopSettings from registry
- openchamberConfig.ts: simplified project setup client
- persistence.ts: registry-derived settings, add git provider hydration
- search.ts: upstream search entries + git provider entries
- useConfigStore.ts: loadDesktopSettings() path
- settings-helpers.js: add gitProviderId/gitModelId/gitProviders sanitization
- DOCUMENTATION.md: upstream walkthrough docs
- vite.config.ts: upstream SW glob patterns

Custom fork additions preserved:
- gitProviderId, gitModelId, gitProviders fields in settings registry
- Git provider domain store hydration in persistence.ts
- Git provider search entries in search.ts
- Git provider sanitization in settings-helpers.js
This commit is contained in:
2026-09-10 10:11:50 +00:00
559 changed files with 36139 additions and 9087 deletions
+4 -3
View File
@@ -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 `<Tooltip>`-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.
@@ -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
<SettingsCheckboxRow
checked={value}
@@ -54,10 +57,21 @@ for text that must stay visible (warnings, dynamic status).
## Mutually Exclusive Options
Use radios for mutually exclusive modes, not independent checkboxes. Keep
self-explanatory choices compact; a title or description is not mandatory.
When the skill's Description Policy calls for a visible explanation, wrap
the controls in `SettingsControlGroup`: title, description, then options.
Explain the choice once at group level. Use checkbox rows for independent
choices and radio options for mutually exclusive choices. Group spacing is
defined in `layout.md`.
```tsx
<SettingsControlGroup title={t('...group')} description={t('...description')}>
<SettingsRadioGroup aria-label={t('...group')}>
<SettingsRadioOption selected={…} onSelect={…} label={t('...')} ariaLabel={t('...')} />
</SettingsRadioGroup>
</SettingsControlGroup>
```
Skip per-option descriptions when labels are self-explanatory. For short
@@ -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.
+9
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+1 -1
View File
@@ -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
+15
View File
@@ -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"
}
+76
View File
@@ -2,6 +2,82 @@
<!-- Legacy copy for app versions up to 1.22.1, which fetch this file for their update notes. Generated from changelog/*.md while it exists; delete it after 2026-09-19 and nothing will recreate it. -->
## [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 <question>` 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
+1 -1
View File
@@ -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
@@ -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) {
+13 -16
View File
@@ -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=="],
+121
View File
@@ -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 <question>` 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).
+116
View File
@@ -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 <question>` 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",
+5 -4
View File
@@ -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"
}
}
@@ -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
@@ -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/)
@@ -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.
@@ -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
@@ -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/)
@@ -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
@@ -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/)
@@ -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 サーバーが読み取れるディレクトリを指定してください。既存のチャットは移動されません。
@@ -25,4 +25,6 @@ description: よく実行するコマンドを保存し、ワンクリックで
## 関連
- [リポジトリ設定](/repository-config/) — アクションとセットアップコマンドをリポジトリに置いてチーム全体で使う
- [プレビューと開発サーバー](/preview/) — 実行中の開発サーバーを OpenChamber 内で開く
@@ -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/)
@@ -25,4 +25,6 @@ description: 자주 실행하는 명령을 저장하고 클릭 한 번으로 실
## 관련 항목
- [저장소 설정](/repository-config/) — 작업과 설정 명령을 저장소에 두어 팀 전체가 사용
- [Preview & Dev Servers](/ko/preview/) — 실행 중인 개발 서버를 OpenChamber 안에서 여세요
@@ -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/)
@@ -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.
@@ -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
@@ -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/)
@@ -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
@@ -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
@@ -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/)
@@ -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/)
@@ -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
@@ -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/)
@@ -25,4 +25,6 @@ description: Зберігайте команди, які часто запуск
## Пов'язане
- [Конфіг у репозиторії](/repository-config/) — тримайте дії й команди налаштування в репозиторії для всієї команди
- [Перегляд і dev-сервери](/uk/preview/) — відкрийте запущений dev-сервер усередині OpenChamber
@@ -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/)
@@ -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 服务器可读取的目录。现有聊天不会被移动。
@@ -25,4 +25,6 @@ description: 保存你经常运行的命令,一键启动它们。
## 相关内容
- [仓库配置](/repository-config/) — 把操作和设置命令放进仓库,供整个团队使用
- [预览与开发服务器](/zh-cn/preview/) — 在 OpenChamber 内部打开正在运行的开发服务器
@@ -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/)
+16
View File
@@ -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/",
+30 -1
View File
@@ -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.
@@ -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);
});
+134
View File
@@ -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);
}
};
@@ -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']);
});
+10
View File
@@ -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);
};
@@ -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');
});
+5 -1
View File
@@ -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)));
};
@@ -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');
});
+114 -125
View File
@@ -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 `<!doctype html>
<html>
@@ -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' },
+3 -2
View File
@@ -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",
+1
View File
@@ -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
+4 -4
View File
@@ -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",
+174
View File
@@ -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" <<EOF
#!/bin/sh
case "\$*" in
*--show-sdk-path*) echo "${shim_sdk}" ;;
*) exec /usr/bin/xcrun "\$@" ;;
esac
EOF
chmod +x "${shim_root}/bin/xcrun"
export PATH="${shim_root}/bin:${PATH}"
log "using patched macOS SDK stubs from ${shim_sdk}"
}
ensure_ghostty_source() {
require_cmd git
if [[ ! -d "${GHOSTTY_SOURCE_DIR}/.git" ]]; then
mkdir -p "$(dirname "${GHOSTTY_SOURCE_DIR}")"
log "cloning Ghostty ${GHOSTTY_REVISION}"
git clone --filter=blob:none --no-checkout https://github.com/ghostty-org/ghostty.git \
"${GHOSTTY_SOURCE_DIR}"
fi
local actual_revision
actual_revision="$(git -C "${GHOSTTY_SOURCE_DIR}" rev-parse HEAD 2>/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
+12
View File
@@ -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);
}
+3
View File
@@ -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}
/>
<AppLinkConfirmDialog />
<SharedTrustConfirmDialog />
</div>
</TooltipProvider>
</RuntimeAPIProvider>
@@ -957,6 +959,7 @@ function App({ apis }: AppProps) {
<MainLayout />
<Toaster />
<AppLinkConfirmDialog />
<SharedTrustConfirmDialog />
{!isBootShell && (
<>
<ConfigUpdateOverlay />
@@ -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) {
<div className="h-full text-foreground bg-background">
<ElectronMiniChatContent config={config} />
<AppLinkConfirmDialog />
<SharedTrustConfirmDialog />
<Toaster />
</div>
</TooltipProvider>
+19
View File
@@ -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);
}} />
<AppLinkConfirmDialog />
<SharedTrustConfirmDialog />
<Toaster position="top-center" offset="calc(var(--oc-safe-area-top, 0px) + 16px)" />
{isInitialized ? <ConfigUpdateOverlay /> : null}
</div>
@@ -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<string, PropertyDescriptor | undefined>();
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<Response>(() => {});
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(<I18nProvider><RuntimeAPIContext.Provider value={apis}>
<MobileChangesPane rootDirectory={directory}
repository={{ rootIsGitRepo: true, gitDirectory: directory, nestedRepos: null, nestedRepoSelection: null }}
visible={visible} initialDiff={initialDiff} />
</RuntimeAPIContext.Provider></I18nProvider>);
});
const click = async (selector: string) => {
const element = document.querySelector<HTMLElement>(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<HTMLElement>('[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);
}
}
});
+268 -36
View File
@@ -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<MobileChangesSurfaceProps> = ({ onClose, initialDiffPath, initialDiffStaged = false }) => {
export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = (props) => {
const rootDirectory = normalizePath(useEffectiveDirectory() ?? null);
const repository = useNestedGitDirectory(rootDirectory || null, { enabled: props.visible ?? true });
return <MobileChangesPane {...props} rootDirectory={rootDirectory} repository={repository} />;
};
interface MobileChangesPaneProps extends MobileChangesSurfaceProps {
rootDirectory: string;
repository: ReturnType<typeof useNestedGitDirectory>;
}
/** Repository-scoped navigation and actions, separate from session directory resolution. */
export const MobileChangesPane: React.FC<MobileChangesPaneProps> = ({ 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<MobileChangesSurfaceProps> = ({ 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<ChangesNavigation>(() => ({
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<SyncAction>(null);
const [commitAction, setCommitAction] = React.useState<CommitAction>(null);
const [commitMessage, setCommitMessage] = React.useState('');
@@ -110,6 +177,54 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
const [pendingDirtySwitchBranch, setPendingDirtySwitchBranch] = React.useState<string | null>(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<GitComparisonSource | null>(() => {
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<ComparisonDiff> => {
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<ComparisonDiff>(
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<string, (typeof files)[number]>();
@@ -199,7 +314,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ 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<MobileChangesSurfaceProps> = ({ 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<MobileChangesSurfaceProps> = ({ 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<MobileChangesSurfaceProps> = ({ 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<MobileChangesSurfaceProps> = ({ onCl
return () => {
cancelled = true;
};
}, [currentDirectory, diffRetryNonce, getDiff, git, route, setDiff]);
}, [currentDirectory, diffRetryNonce, getDiff, git, route, setDiff, visible]);
const handleSyncAction = async (action: Exclude<SyncAction, null>, remote?: GitRemote) => {
if (!currentDirectory) return;
@@ -349,7 +465,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ 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<MobileChangesSurfaceProps> = ({ 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 (
<MobileDiffDetail
path={activeComparisonPath}
subtitle={[modeLabel, sourceLabel].filter(Boolean).join(' · ')}
diff={activeComparisonDiff.status === 'ready' ? activeComparisonDiff.diff : null}
fileExists={!comparison.files || comparison.files.some((file) => 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 <MobileChangesState
loading={!branchComparison.resolved}
message={branchComparison.resolved ? t('gitView.pr.toast.baseBranchRequired') : t('diffView.branch.resolvingBase')}
/>;
}
if (mode === 'commit' && !selectedCommitHash) {
return <div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
{commitComparison.loading && <Icon name="loader-4" className="size-5 animate-spin text-muted-foreground" />}
<p className="typography-ui-label text-muted-foreground">{commitComparison.loading ? t('diffView.state.loadingChanges') : commitComparison.error ?? t('commitComparison.noCommits')}</p>
{commitComparison.error && <Button variant="outline" size="lg" onClick={() => void commitComparison.refresh()}>{t('diffView.actions.retry')}</Button>}
</div>;
}
if (comparison.error) {
return <div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
<p className="typography-ui-label font-semibold">{t('diffView.state.failedToLoadDiff')}</p>
<p className="typography-meta text-muted-foreground">{comparison.error}</p>
<Button variant="outline" size="lg" onClick={() => void comparison.refresh()}>{t('diffView.actions.retry')}</Button>
</div>;
}
if (!comparisonFiles) return <MobileChangesState loading message={t('diffView.state.loadingChanges')} />;
if (comparisonFiles.length === 0) {
return <MobileChangesState icon={mode === 'branch'} message={mode === 'commit'
? t('commitComparison.emptyDiff')
: t('diffView.branch.empty', { base: sourceLabel ?? '' })} />;
}
return <MobileComparisonFileList files={comparisonFiles} onSelect={(path) => {
if (comparison.key) setRoute({ type: 'comparison', path, sourceKey: comparison.key });
}} />;
};
return (
<div className="flex h-full flex-col overflow-hidden bg-background text-foreground">
<header className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3 text-foreground">
<header className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
{onClose ? (
<button
type="button"
@@ -594,8 +763,38 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
<Icon name="close" className="size-5" />
</button>
) : null}
<div className="min-w-0 flex-1 px-1">
<h2 className="typography-ui-label text-foreground">{t('mobile.nav.changes')}</h2>
<DropdownMenu open={modeMenuOpen} onOpenChange={setModeMenuOpen}>
<DropdownMenuTrigger asChild>
<Button variant="outline" className={dropdownTriggerVariants({ size: 'default' })} data-mobile-comparison-trigger aria-label={t('diffView.scope.selectorAria')}>
<span>{modeLabel}</span>
<Icon name="arrow-down-s" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup value={mode} onValueChange={(value) => {
if (value === 'working' || value === 'branch' || value === 'commit') changeMode(value);
}}>
<DropdownMenuRadioItem value="working" className="min-h-8 items-center">{t('mobile.nav.changes')}</DropdownMenuRadioItem>
{showBranchOption && <DropdownMenuRadioItem value="branch" className="min-h-8 items-center">{t('diffView.scope.branch')}</DropdownMenuRadioItem>}
<DropdownMenuRadioItem value="commit" className="min-h-8 items-center">{t('commitComparison.mode')}</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{visible && mode === 'branch' && (
<BranchComparisonSelector mobile key={JSON.stringify([ownerKey, currentBranch])}
branches={branches?.all ?? []} currentBranch={currentBranch} base={branchComparison.base}
onSelect={(base) => { if (currentBranch) setBaseOverride(currentDirectory, currentBranch, base); }} />
)}
{visible && mode === 'commit' && (
<CommitComparisonSelector mobile key={JSON.stringify([ownerKey, currentBranch])}
commits={commitComparison.commits} selectedHash={selectedCommitHash}
loading={commitComparison.loading} error={commitComparison.error}
onSelect={commitComparison.select} onRefresh={() => void commitComparison.refresh()} />
)}
</header>
{mode === 'working' && (
<div className="flex shrink-0 items-center gap-2 px-3 py-2">
<div className="min-w-0 flex-1">
<BranchSelector
currentBranch={status?.current}
localBranches={localBranches}
@@ -621,8 +820,11 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
trackingRemoteName={status?.tracking?.split('/')[0]}
hasUncommittedChanges={changeEntries.length > 0}
/>
</header>
{changeEntries.length > 0 ? (
</div>
)}
{mode !== 'working' ? (
<div className="min-h-0 flex-1">{renderComparison()}</div>
) : changeEntries.length > 0 ? (
<div className="flex min-h-0 flex-1 flex-col">
{/* File list scrolls inside ChangesPanel; the commit footer stays pinned. */}
<div className="min-h-0 flex-1 overflow-hidden px-4 pt-4">
@@ -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<{
</button>
<div className="min-w-0 flex-1 px-2">
<h2 className="truncate typography-ui-header text-foreground">{path}</h2>
{subtitle && <p className="truncate typography-meta text-muted-foreground">{subtitle}</p>}
</div>
</header>
<div className="min-h-0 flex-1 overflow-hidden">
@@ -774,7 +978,7 @@ const MobileDiffDetail: React.FC<{
<MobileChangesState loading message={t('diffView.state.loadingDiff')} />
) : diff.isBinary ? (
<MobileChangesState icon message={t('diffView.binary.unavailable')} />
) : isImageFile(path) ? (
) : isImageFile(path) && !diff.fileDiff ? (
<MobileChangesState icon message={t('mobile.changes.diffDetail.imageUnavailable')} />
) : (
<ScrollShadow
@@ -785,6 +989,7 @@ const MobileDiffDetail: React.FC<{
<PierreDiffViewer
original={diff.original}
modified={diff.modified}
fileDiff={diff.fileDiff}
language={language}
fileName={path}
renderSideBySide={false}
@@ -797,3 +1002,30 @@ const MobileDiffDetail: React.FC<{
</div>
);
};
const MobileComparisonFileList: React.FC<{ files: GitComparisonFile[]; onSelect: (path: string) => void }> = ({ files, onSelect }) => {
const { t } = useI18n();
return (
<ScrollShadow className="h-full overflow-y-auto overflow-x-hidden px-3 py-2">
<ul aria-label={t('gitView.changes.changedFilesAria')} className="flex flex-col gap-1">
{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 <li key={file.path}>
<Button variant="ghost" size="lg" className="w-full justify-start gap-2.5 text-left normal-case" onClick={() => onSelect(file.path)}>
<span className="w-4 shrink-0 text-center typography-meta font-semibold uppercase" style={{ color: statusColor }} aria-label={statusLabel}>{file.status}</span>
<FileTypeIcon filePath={file.path} className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate typography-ui-label" title={file.path}>{file.path}</span>
{(file.insertions > 0 || file.deletions > 0) && <span className="shrink-0 typography-meta text-muted-foreground">+{file.insertions} -{file.deletions}</span>}
<Icon name="arrow-right-s" className="size-4 shrink-0 text-muted-foreground" />
</Button>
</li>;
})}
</ul>
</ScrollShadow>
);
};
@@ -175,7 +175,7 @@ export const MobileFullscreenSurface: React.FC<MobileFullscreenSurfaceProps> = (
'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<MobileFullscreenSurfaceProps> = (
return createPortal(
<div
className="oc-keyboard-inset-surface fixed inset-0 z-50 flex items-center justify-center p-4 transition-opacity duration-200 ease-out"
className="oc-keyboard-inset-surface oc-bottom-safe-surface fixed inset-0 z-50 flex items-center justify-center p-4 transition-opacity duration-200 ease-out"
style={{
background: 'rgb(0 0 0 / 0.45)',
opacity: entered ? 1 : 0,
+12 -3
View File
@@ -332,6 +332,8 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta
),
);
const quotaResults = useQuotaStore((state) => 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) {
+231 -85
View File
@@ -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<string>();
// 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 }) => (
<button
type="button"
className={cn(
'flex size-9 shrink-0 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]',
className,
)}
aria-label={label}
title={label}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
style={{ touchAction: 'manipulation' }}
>
<Icon name="add" className="size-4" />
</button>
);
// 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' }}
>
<div className="absolute inset-y-0 right-0 flex items-stretch" style={{ width: actionsWidth }} aria-hidden={!revealed}>
<div className="absolute inset-y-0 left-0 flex items-stretch" style={{ width: actionsWidth }} aria-hidden={!revealed}>
{actions}
</div>
<div ref={contentRef} className="relative flex w-full items-center bg-background">
@@ -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 ? (
<div
className="absolute inset-y-0 right-0 flex items-stretch"
className="absolute inset-y-0 left-0 flex items-stretch"
style={{ width: ROW_ACTIONS_WIDTH }}
aria-hidden={!revealed}
>
{/* Icon-only actions on the row's own background — they read as the
row extending to reveal extra controls, not a separate panel. */}
<button
type="button"
tabIndex={revealed ? 0 : -1}
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
aria-label={t('mobile.sessions.renameSessionAria', { title })}
onClick={onRequestRename}
style={{ touchAction: 'manipulation' }}
>
<RiEdit2Line className="size-[18px]" />
</button>
<button
type="button"
tabIndex={revealed ? 0 : -1}
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
aria-label={t('mobile.sessions.archiveSessionAria', { title })}
onClick={onArchive}
style={{ touchAction: 'manipulation' }}
>
<RiArchiveLine className="size-[18px]" />
</button>
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. */}
<button
type="button"
tabIndex={revealed ? 0 : -1}
@@ -597,6 +624,26 @@ const SessionRow: React.FC<{
>
<RiDeleteBinLine className="size-[18px]" />
</button>
<button
type="button"
tabIndex={revealed ? 0 : -1}
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
aria-label={t('mobile.sessions.archiveSessionAria', { title })}
onClick={onArchive}
style={{ touchAction: 'manipulation' }}
>
<RiArchiveLine className="size-[18px]" />
</button>
<button
type="button"
tabIndex={revealed ? 0 : -1}
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
aria-label={t('mobile.sessions.renameSessionAria', { title })}
onClick={onRequestRename}
style={{ touchAction: 'manipulation' }}
>
<RiEdit2Line className="size-[18px]" />
</button>
</div>
) : null}
<div
@@ -884,6 +931,9 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ 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<MobileSessionsSheetProps> = ({ open,
const toggleParent = useMobileSessionExpansionStore((state) => state.toggleParent);
const [query, setQuery] = React.useState('');
const [editingProjectId, setEditingProjectId] = React.useState<string | null>(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<string | null>(null);
const [confirmingDeleteSessionId, setConfirmingDeleteSessionId] = React.useState<string | null>(null);
const [renamingSessionId, setRenamingSessionId] = React.useState<string | null>(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<string | null>(null);
const [confirmingRemoveProjectId, setConfirmingRemoveProjectId] = React.useState<string | null>(null);
@@ -910,6 +960,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ 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<string | null>(null);
@@ -995,6 +1046,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
const projectsMeta = React.useMemo<ProjectMeta[]>(
() =>
sortProjectsByOrder(
projects.map((project) => ({
id: project.id,
label: project.label?.trim() || getProjectLabel(project.path),
@@ -1008,8 +1060,13 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
worktreeOrderByProject[project.id],
worktreesByProject.get(normalizePath(project.path)) ?? [],
),
addedAt: project.addedAt,
lastOpenedAt: project.lastOpenedAt,
})),
[gitProjectPaths, projects, worktreeOrderByProject, worktreesByProject],
projectSortOrder,
manualProjectOrder,
),
[gitProjectPaths, manualProjectOrder, projectSortOrder, projects, worktreeOrderByProject, worktreesByProject],
);
/**
@@ -1345,6 +1402,16 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ 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<MobileSessionsSheetProps> = ({ 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<MobileSessionsSheetProps> = ({ open,
);
}, [normalizedQuery, pinnedSessionIds, projectsMeta, sessionOrderRanks, sessions]);
const searchProjectMatches = React.useMemo(() => {
if (!normalizedQuery) return [] as Array<ProjectMeta & { sessionCount: number }>;
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<ProjectMeta[]>(() => {
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 ? (
<Button
type="button"
variant="chip"
size="sm"
aria-label={t('sessions.sidebar.header.actions.sortProjects')}
title={t('sessions.sidebar.header.actions.sortProjects')}
onClick={() => setSortPanelOpen(true)}
style={{ touchAction: 'manipulation' }}
>
<Icon name="equalizer-2" className="size-4" />
</Button>
) : null;
const editToggle = canEditOrder ? (
<Button
@@ -1473,12 +1560,16 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
</Button>
) : 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<MobileSessionsSheetProps> = ({ open,
<span className="block min-w-0 flex-1 truncate typography-ui-label text-foreground">
{project.label}
</span>
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">
{project.sessionCount}
</span>
</button>
{project.isGitRepo ? (
<NewWorktreeIconButton
className="mr-2"
onClick={() => handleNewWorktree(project.id)}
/>
<NewWorktreeIconButton onClick={() => handleNewWorktree(project.id)} />
) : null}
<NewSessionIconButton
className="mr-2"
label={t('mobile.sessions.newSessionInProjectAria', { label: project.label })}
onClick={() => handleNewSessionInProject(project)}
/>
</div>
))}
</div>
@@ -1703,19 +1793,6 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
onRevealedChange={(nextRevealed) => handleRowKeyRevealedChange(`project:${node.project.id}`, nextRevealed)}
actions={(
<>
<button
type="button"
tabIndex={revealedRowId === `project:${node.project.id}` ? 0 : -1}
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
aria-label={t('mobile.sessions.editProjectAria', { label: node.project.label })}
onClick={() => {
setRevealedRowId(null);
setEditingProjectId(node.project.id);
}}
style={{ touchAction: 'manipulation' }}
>
<RiEdit2Line className="size-[18px]" />
</button>
<button
type="button"
tabIndex={revealedRowId === `project:${node.project.id}` ? 0 : -1}
@@ -1742,6 +1819,19 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
>
<RiDeleteBinLine className="size-[18px]" />
</button>
<button
type="button"
tabIndex={revealedRowId === `project:${node.project.id}` ? 0 : -1}
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
aria-label={t('mobile.sessions.editProjectAria', { label: node.project.label })}
onClick={() => {
setRevealedRowId(null);
setEditingProjectId(node.project.id);
}}
style={{ touchAction: 'manipulation' }}
>
<RiEdit2Line className="size-[18px]" />
</button>
</>
)}
>
@@ -1768,17 +1858,15 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
<span className="block min-w-0 flex-1 truncate typography-ui-label font-semibold text-foreground">
{node.project.label}
</span>
{node.isActive ? <ActiveDot ariaLabel={t('mobile.sessions.activeProjectAria')} /> : null}
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">
{node.totalSessions}
</span>
</button>
{node.project.isGitRepo ? (
<NewWorktreeIconButton
className="mr-2"
onClick={() => handleNewWorktree(node.project.id)}
/>
<NewWorktreeIconButton onClick={() => handleNewWorktree(node.project.id)} />
) : null}
<NewSessionIconButton
className="mr-2"
label={t('mobile.sessions.newSessionInProjectAria', { label: node.project.label })}
onClick={() => handleNewSessionInProject(node.project)}
/>
</div>
</MobileSwipeActionsRow>
@@ -1965,6 +2053,33 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
onClose={() => setEditingProjectId(null)}
onWorktreesChanged={() => setWorktreeRefreshKey((value) => value + 1)}
/>
<MobileOverlayPanel
open={sortPanelOpen}
onClose={() => setSortPanelOpen(false)}
title={t('sessions.sidebar.header.actions.sortProjects')}
>
<div className="flex flex-col">
{PROJECT_SORT_OPTIONS.map(([order, labelKey]) => (
<button
key={order}
type="button"
className={cn(
'flex min-h-11 w-full items-center justify-between rounded-lg px-3 text-left transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary',
projectSortOrder === order ? 'text-primary' : 'text-foreground',
)}
onClick={() => {
handleProjectSortChange(order);
setSortPanelOpen(false);
}}
style={{ touchAction: 'manipulation' }}
>
<span className="typography-ui-label">{t(labelKey)}</span>
{projectSortOrder === order ? <Icon name="check" className="size-4" /> : null}
</button>
))}
</div>
</MobileOverlayPanel>
{worktreeToDelete ? (
<MobileDeleteWorktreeDialog
open
@@ -1998,6 +2113,19 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
<MobileSessionsDrawerContainer
open={open}
onClose={() => 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')}
>
<div className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3">
@@ -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<HTMLElement | null>(null);
const drawerRef = React.useRef<HTMLElement>(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(
<section
ref={drawerRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
+19 -6
View File
@@ -17,6 +17,7 @@ import { useMcpStore } from '@/stores/useMcpStore';
import { MobileChangesSurface } from './MobileChangesSurface';
import { MobileFilesSurface } from './MobileFilesSurface';
import { useEdgeSwipe } from './useEdgeSwipe';
const DRAWER_ROOT_ID = 'mobile-surface-root';
const ENTER_DELAY_MS = 16;
@@ -96,8 +97,9 @@ const McpWorkspacePane: React.FC<{ onOpenMcpSettings: () => 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<HTMLElement | null>(null);
const drawerRef = React.useRef<HTMLElement>(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<ReadonlySet<MobileWorkspaceTab>>(() => new Set());
React.useEffect(() => {
@@ -224,14 +236,14 @@ export const MobileWorkspaceDrawer: React.FC<{
{visitedTabs.has('changes') ? (
<div
// A newly requested per-file diff remounts the pane so
// initialDiffPath applies; plain reopens keep the state.
// initialDiff applies; plain reopens keep the state.
key={pendingChangesDiff ? `changes:${pendingChangesDiff.path}:${pendingChangesDiff.staged}` : 'changes'}
className={cn('h-full', tab !== 'changes' && 'hidden')}
>
<ErrorBoundary>
<MobileChangesSurface
initialDiffPath={pendingChangesDiff?.path ?? null}
initialDiffStaged={pendingChangesDiff?.staged === true}
visible={open && tab === 'changes'}
initialDiff={pendingChangesDiff}
/>
</ErrorBoundary>
</div>
@@ -278,11 +290,12 @@ export const MobileWorkspaceDrawer: React.FC<{
return createPortal(
<section
ref={drawerRef}
role="dialog"
aria-modal="true"
aria-label={t('mobile.header.openWorkspaceAria')}
aria-hidden={!open}
className="oc-keyboard-inset-surface fixed inset-0 z-50 flex flex-col bg-background text-foreground"
className="oc-keyboard-inset-surface oc-bottom-safe-surface fixed inset-0 z-50 flex flex-col bg-background text-foreground"
style={{
paddingTop: 'var(--oc-safe-area-top, 0px)',
// Settled state drops the transform entirely so the drawer isn't kept
+3
View File
@@ -9,6 +9,7 @@ import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { SharedTrustConfirmDialog } from '@/components/projects/SharedTrustConfirmDialog';
import { VSCodeLayout } from '@/components/layout/VSCodeLayout';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling';
@@ -114,6 +115,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
<AgentManagerView />
<AppLinkConfirmDialog />
<SharedTrustConfirmDialog />
<OpenCodeUpdateToast />
<Toaster position="top-center" />
</div>
@@ -134,6 +136,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
<VSCodeLayout />
<AppLinkConfirmDialog />
<SharedTrustConfirmDialog />
<OpenCodeUpdateToast />
<Toaster position="top-center" />
<ConfigUpdateOverlay />
+11 -4
View File
@@ -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]);
};
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="24" height="24">
<title>Cline</title>
<path d="m39.06 22.594-2.403-4.826V14.99c0-4.606-3.697-8.336-8.257-8.336h-4.107c.297-.61.46-1.297.46-2.021 0-2.56-2.06-4.632-4.605-4.632s-4.606 2.072-4.606 4.632c0 .724.163 1.41.46 2.02h-4.107c-4.56 0-8.256 3.731-8.256 8.337v2.78l-2.454 4.81a1.7 1.7 0 0 0 0 1.545l2.454 4.758v2.78c0 4.605 3.697 8.336 8.256 8.336H28.4c4.56 0 8.257-3.73 8.257-8.337v-2.779l2.399-4.774a1.7 1.7 0 0 0 .004-1.516m-21.424 3.932c0 2.093-1.688 3.79-3.769 3.79-2.08 0-3.768-1.697-3.768-3.79V19.79c0-2.093 1.688-3.79 3.768-3.79s3.769 1.697 3.769 3.79zm12.142 0c0 2.093-1.688 3.79-3.769 3.79-2.08 0-3.768-1.697-3.768-3.79V19.79c0-2.093 1.688-3.79 3.768-3.79s3.769 1.697 3.769 3.79z"/>
</svg>

After

Width:  |  Height:  |  Size: 773 B

@@ -111,6 +111,7 @@ const WebviewBrowser: React.FC<BrowserPaneProps> = ({ 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<BrowserViewport>(FILL_VIEWPORT);
// Read inside agent actions, which are not re-created when the viewport
@@ -580,6 +581,7 @@ const WebviewBrowser: React.FC<BrowserPaneProps> = ({ 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<BrowserPaneProps> = ({ 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,
@@ -171,10 +171,8 @@ type ChatViewportProps = {
scrollRef: React.RefObject<HTMLDivElement | null>;
messageListRef: React.RefObject<MessageListHandle | null>;
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<ChatContainerProps> = ({
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<ChatContainerProps> = ({
currentSessionKey,
sessionMessageCount,
composerOverlayHeight,
lastUserMessageId,
sessionIsWorking,
revealGate,
onActiveTurnChange: handleActiveTurnChange,
@@ -1549,10 +1531,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
directory={effectiveSessionDirectory}
scrollRef={scrollRef}
registerList={registerList}
anchorMessageId={anchorMessageId}
onAnchorReady={onAnchorReady}
onAnchorSizeChanged={onAnchorSizeChanged}
onIsAtEndChange={onIsAtEndChange}
onListMetricsChange={onListMetricsChange}
onTimelineDataChange={onTimelineDataChange}
messageListRef={messageListRef}
renderedMessages={timelineController.renderedMessages}
File diff suppressed because it is too large Load Diff
@@ -68,9 +68,11 @@ const SortableChip: React.FC<{
item: ResolvedStarter;
onSubmit: (starter: ResolvedStarter) => void;
onRemove: () => void;
/** Project chips only: move the starter into the team's shared file, or back out of it. */
onToggleShared?: () => void;
/** Hide the per-chip hover "x" (mobile uses the trash drop-zone instead). */
hideRemove?: boolean;
}> = ({ item, onSubmit, onRemove, hideRemove }) => {
}> = ({ item, onSubmit, onRemove, onToggleShared, hideRemove }) => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id });
@@ -92,13 +94,27 @@ const SortableChip: React.FC<{
{...attributes}
{...listeners}
onClick={() => onSubmit(item)}
className="group inline-flex touch-none select-none items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-[var(--interactive-hover)] hover:text-foreground"
className="group inline-flex touch-none select-none items-center gap-1.5 rounded-full border px-3 py-1.5 typography-ui-label text-muted-foreground transition-colors hover:bg-[var(--interactive-hover)] hover:text-foreground"
style={chipStyle}
title={item.shared ? t('chat.draftStarters.sharedTitle') : undefined}
>
<Icon name={item.icon} className="h-3.5 w-3.5 shrink-0 opacity-70 transition-opacity group-hover:opacity-100" />
<span className="whitespace-nowrap">{item.label}</span>
</button>
{hideRemove ? null : (
{onToggleShared && !hideRemove ? (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onToggleShared(); }}
aria-label={t(item.shared ? 'chat.draftStarters.makePersonal' : 'chat.draftStarters.share')}
title={t(item.shared ? 'chat.draftStarters.makePersonal' : 'chat.draftStarters.share')}
className="absolute -left-1.5 -top-1.5 hidden h-4 w-4 items-center justify-center rounded-full border text-muted-foreground shadow-sm hover:text-foreground group-hover/chip:flex"
style={chipStyle}
>
<Icon name={item.shared ? 'user' : 'team'} className="h-2.5 w-2.5" />
</button>
) : null}
{/* A shared starter is the team's: it leaves only through the repo file. */}
{hideRemove || item.shared ? null : (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onRemove(); }}
@@ -118,8 +134,9 @@ const StarterGroup: React.FC<{
items: ResolvedStarter[];
onSubmit: (starter: ResolvedStarter) => void;
onRemove: (item: ResolvedStarter) => void;
onToggleShared?: (item: ResolvedStarter) => void;
hideRemove?: boolean;
}> = ({ items, onSubmit, onRemove, hideRemove }) => (
}> = ({ items, onSubmit, onRemove, onToggleShared, hideRemove }) => (
<SortableContext items={items.map((i) => i.id)} strategy={rectSortingStrategy}>
{items.map((item) => (
<SortableChip
@@ -127,6 +144,7 @@ const StarterGroup: React.FC<{
item={item}
onSubmit={onSubmit}
onRemove={() => onRemove(item)}
onToggleShared={onToggleShared ? () => onToggleShared(item) : undefined}
hideRemove={hideRemove}
/>
))}
@@ -256,7 +274,7 @@ const AddStarterPicker: React.FC<{
* ignored.
*/
const DraftPresetChipsContent: React.FC<DraftPresetChipsProps> = ({ onSubmit, className }) => {
const { global, project, pinnable, ensureLoaded, addStarter, removeStarter, reorder } = useDraftStarters();
const { global, project, pinnable, ensureLoaded, addStarter, removeStarter, reorder, shareStarter, unshareStarter } = useDraftStarters();
const { isMobile } = useDeviceInfo();
const [isDragging, setIsDragging] = React.useState(false);
@@ -320,6 +338,7 @@ const DraftPresetChipsContent: React.FC<DraftPresetChipsProps> = ({ onSubmit, cl
items={project}
onSubmit={onSubmit}
onRemove={(item) => removeStarter('project', item.ref)}
onToggleShared={(item) => (item.shared ? unshareStarter(item.ref) : shareStarter(item.ref))}
hideRemove={isMobile}
/>
) : null}
@@ -326,6 +326,50 @@ afterAll(() => {
});
describe('MarkdownRenderer DOM mount performance contract', () => {
test('preserves disclosure choices through streaming, settlement, and redecorating', async () => {
const host = document.createElement('div');
document.body.replaceChildren(host);
const root = createRoot(host);
const prefix = 'Introduction\n\n<details><summary>Review</summary>\n\n';
const render = async (content: string, streaming: boolean) => {
await act(async () => {
root.render(<MarkdownRenderer content={content} messageId="disclosures" isAnimated={false} isStreaming={streaming} enableFileReferences={false} />);
await waitForSettledEffects();
});
await act(async () => waitForSettledEffects());
};
try {
await render(`${prefix}First`, true);
const first = host.querySelector<HTMLDetailsElement>('details');
expect(first).not.toBeNull();
expect(first?.open).toBe(false);
expect(first?.querySelector('summary [data-md-disclosure-icon] use')?.getAttribute('href')).toBe('#oc-arrow-right-s');
if (!first) throw new Error('Expected disclosure');
first.open = true;
for (let count = 1; count <= 5; count += 1) {
await render(`${prefix}First\n\n${'More text. '.repeat(count)}`, true);
expect(host.querySelector<HTMLDetailsElement>('details')?.open).toBe(true);
}
const settled = `${prefix}First\n\n</details>\n\n<details open><summary>Second</summary>\n\nBody\n\n</details>`;
await render(settled, false);
const disclosures = host.querySelectorAll<HTMLDetailsElement>('details');
expect(disclosures).toHaveLength(2);
expect(disclosures[0]?.open).toBe(true);
expect(disclosures[1]?.open).toBe(true);
disclosures[1]!.open = false;
// The fixture supplies a fresh theme/translation context on each render,
// exercising whole-block replacement with unchanged source as well.
await render(settled, false);
expect(host.querySelectorAll<HTMLDetailsElement>('details')[0]?.open).toBe(true);
expect(host.querySelectorAll<HTMLDetailsElement>('details')[1]?.open).toBe(false);
expect(host.querySelectorAll('summary [data-md-disclosure-icon]')).toHaveLength(2);
await render('<details><summary>Different</summary>\n\nNew body\n\n</details>', false);
expect(host.querySelector<HTMLDetailsElement>('details')?.open).toBe(false);
} finally {
await act(async () => root.unmount());
}
});
test('fixes body-sized table columns once the stream settles', async () => {
const content = [
'| An intentionally oversized header | Another oversized header | A third oversized header |',
@@ -383,6 +427,9 @@ describe('MarkdownRenderer DOM mount performance contract', () => {
expect(table?.classList.contains('min-w-full')).toBe(false);
expect(table?.classList.contains('w-full')).toBe(false);
expect(table?.parentElement?.classList.contains('overflow-x-auto')).toBe(true);
const wrapper = table?.closest('[data-markdown="table-wrapper"]');
expect(wrapper?.classList.contains('w-fit')).toBe(true);
expect(wrapper?.classList.contains('max-w-full')).toBe(true);
expect(cells.length).toBeGreaterThan(0);
expect(cells.every((cell) => cell.classList.contains('min-w-[120px]'))).toBe(true);
expect(cells.every((cell) => cell.classList.contains('max-w-[320px]'))).toBe(true);
@@ -1019,6 +1019,12 @@ const useMorphdomMarkdown = ({
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
if (!active || renderRevisionRef.current !== renderRevision) return;
const existing = Array.from(target.children) as HTMLElement[];
// Capture before block reconciliation: streaming completion changes the
// wrapper layout, and theme changes can replace entire decorated blocks.
// Match by disclosure order plus heading so unrelated replacements cannot
// inherit the previous disclosure's state. No persistent/global state.
const disclosureStates = Array.from(target.querySelectorAll<HTMLDetailsElement>('details[data-md-details]'))
.map((details) => ({ summary: details.querySelector('summary')?.textContent, open: details.open }));
// Reconcile per block: only re-morph blocks whose content changed, leaving
// stable leading blocks untouched. Keeps per-stream-step DOM work bounded
@@ -1081,7 +1087,13 @@ const useMorphdomMarkdown = ({
const tempHasMermaidBlock = shouldRefreshMermaidViewers(temp);
morphdom(el, temp, {
childrenOnly: true,
onBeforeElUpdated: (fromEl, toEl) => !fromEl.isEqualNode(toEl),
onBeforeElUpdated: (fromEl, toEl) => {
if (fromEl.matches('details[data-md-details]') && toEl.matches('details[data-md-details]')
&& fromEl.querySelector('summary')?.textContent === toEl.querySelector('summary')?.textContent) {
toEl.toggleAttribute('open', fromEl.hasAttribute('open'));
}
return !fromEl.isEqualNode(toEl);
},
});
el.setAttribute('data-md-id', block.id);
el.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
@@ -1102,6 +1114,14 @@ const useMorphdomMarkdown = ({
if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) {
refreshMermaidViewers();
}
if (disclosureStates.length > 0) {
target.querySelectorAll<HTMLDetailsElement>('details[data-md-details]').forEach((details, index) => {
const previous = disclosureStates[index];
if (previous && previous.summary === details.querySelector('summary')?.textContent) {
details.open = previous.open;
}
});
}
mountedDomRef.current = domCacheKey
? { key: domCacheKey, copiedLabel: ctx.labels.copied }
: null;
+72 -79
View File
@@ -3,8 +3,11 @@ import type { Part } from '@opencode-ai/sdk/v2';
import { LegendList, type LegendListRef } from '@legendapp/list/react';
import ChatMessage from './ChatMessage';
import { filterVisibleParts, isEmptyTextPart } from './message/partUtils';
import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
import TurnItem from './components/TurnItem';
import { LiveTurnActivity } from './components/LiveTurnActivity';
import { getTurnsWithLaterAssistant, hasLiveActivity } from './lib/turns/liveActivity';
import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types';
import { useTurnRecords } from './hooks/useTurnRecords';
import { applyRetryOverlay } from './lib/turns/applyRetryOverlay';
@@ -20,7 +23,7 @@ import type { StreamPhase } from './message/types';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useSessionPartsForMessages } from '@/sync/sync-context';
import type { ReviewTransferDirection } from '@/lib/reviewFlow';
import { resolveChatListAnchoredEndSpace, resolveTimelineIsAtEnd } from './lib/scroll/timelineScrollAnchoring';
import { resolveTimelineIsAtEnd } from './lib/scroll/timelineScrollAnchoring';
import {
USER_SHELL_MARKER,
isUserShellMarkerMessage,
@@ -43,8 +46,6 @@ const EMPTY_UNGROUPED_MESSAGE_IDS = new Set<string>();
// • `maintainVisibleContentPosition` preserves the read position when older
// history is prepended, replacing the manual anchor-hold and the mobile
// quiet-window prepend deferral.
// • `anchoredEndSpace` reserves the tail space that parks a just-sent
// message near the top of the viewport.
const TIMELINE_ESTIMATED_ENTRY_SIZE = 320;
// Anchor hold for an explicit viewport restore (session re-entry): row
@@ -54,9 +55,6 @@ const TIMELINE_ESTIMATED_ENTRY_SIZE = 320;
const ANCHOR_HOLD_STABLE_FRAMES = 30;
const ANCHOR_HOLD_MAX_FRAMES = 180;
// Reserved tail space that parks an anchored row near the top of the viewport.
// `onReady` fires once the list has measured the anchor, `onSizeChanged` when
// the reserved size is recomputed.
// Presentation-only props forwarded to the scroll container the list renders.
// Deliberately narrow: the list owns scroll and layout callbacks on that
// element, so only styling, focus and click-through are caller-controlled.
@@ -69,13 +67,6 @@ type TimelineScrollContainerProps = {
'data-scroll-shadow'?: string;
};
type TimelineAnchoredEndSpace = {
anchorIndex: number;
anchorOffset?: number;
onReady?: (info: { anchorIndex: number | undefined; anchorKey: string | undefined; size: number }) => void;
onSizeChanged?: (size: number) => void;
};
const useStableEvent = <TArgs extends unknown[], TResult>(handler: (...args: TArgs) => TResult) => {
const handlerRef = React.useRef(handler);
React.useEffect(() => {
@@ -324,13 +315,9 @@ interface MessageListProps {
// True while a real gesture owns the scroll; releases the list's own
// end pinning so the state machine, not the library heuristic, decides.
endPinningReleased?: boolean;
// The anchored row is identified by message id; the index it maps to is a
// property of the row model, which only this component knows.
anchorMessageId?: string | null;
onAnchorReady?: (messageId: string, anchorIndex: number) => void;
onAnchorSizeChanged?: (messageId: string) => void;
composerOverlayHeight?: number;
onIsAtEndChange?: (isAtEnd: boolean) => void;
onListMetricsChange?: (metrics: { readonly footerSize: number }) => void;
onTimelineDataChange?: () => void;
// Content that used to sit as siblings of the list inside the scroll
// container. The list owns that container now, so they render as its
@@ -358,9 +345,10 @@ type RenderEntry =
previousMessage?: ChatMessageEntry;
nextMessage?: ChatMessageEntry;
}
| { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean; nextEntryFirstMessage?: ChatMessageEntry };
| { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean; hasLaterAssistant?: boolean; nextEntryFirstMessage?: ChatMessageEntry };
type TurnUiState = { isExpanded: boolean };
type TurnUiState = { isExpanded: boolean; isLiveExpanded?: boolean };
type ToggleTurnGroup = (turnId: string, mode?: 'sorted' | 'live') => void;
@@ -427,12 +415,13 @@ MessageRow.displayName = 'MessageRow';
interface TurnBlockProps {
turn: TurnRecord;
hasLaterAssistant?: boolean;
isLastTurn: boolean;
nextEntryFirstMessage?: ChatMessageEntry;
sessionIsWorking: boolean;
defaultActivityExpanded: boolean;
turnUiStates: Map<string, TurnUiState>;
onToggleTurnGroup: (turnId: string) => void;
onToggleTurnGroup: ToggleTurnGroup;
chatRenderMode: 'sorted' | 'live';
scrollToBottom?: () => void;
stickyUserHeader?: boolean;
@@ -445,6 +434,7 @@ interface TurnBlockProps {
const TurnBlock = React.memo(({
turn,
hasLaterAssistant = false,
isLastTurn,
nextEntryFirstMessage,
sessionIsWorking,
@@ -462,6 +452,7 @@ const TurnBlock = React.memo(({
}: TurnBlockProps) => {
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
const showReasoningTraces = useUIStore((state) => state.showReasoningTraces);
const userMessageHidden = React.useMemo(
() => isHiddenUserMessage(turn.userMessage, { planModeEnabled }),
[planModeEnabled, turn.userMessage]
@@ -470,6 +461,9 @@ const TurnBlock = React.memo(({
const handleToggleTurnGroup = React.useCallback(() => {
onToggleTurnGroup(turn.turnId);
}, [onToggleTurnGroup, turn.turnId]);
const handleToggleLiveActivity = React.useCallback(() => {
onToggleTurnGroup(turn.turnId, 'live');
}, [onToggleTurnGroup, turn.turnId]);
const messageOrder = React.useMemo(() => {
const ordered = [turn.userMessage, ...turn.assistantMessages];
@@ -653,6 +647,9 @@ const TurnBlock = React.memo(({
activityOwnerMessageId,
isFirstAssistantInTurn: isFirstAssistant,
isLastAssistantInTurn: isLastAssistant,
hasEarlierAssistantText: chatRenderMode === 'live' && isLastAssistant && visibleAssistantMessages.some((assistant, index) => (
index < assistantIndex && filterVisibleParts(assistant.parts).some((part) => part.type === 'text' && !isEmptyTextPart(part))
)),
isLatestTurn: isLastTurn,
isWorking: isLastTurn && sessionIsWorking && (
chatRenderMode === 'sorted'
@@ -736,6 +733,15 @@ const TurnBlock = React.memo(({
turn={renderableTurn}
stickyUserHeader={stickyUserHeader && !userMessageHidden}
renderMessage={renderMessage}
assistantContent={chatRenderMode === 'live' && !defaultActivityExpanded && hasLiveActivity(turn, showReasoningTraces) ? (
<LiveTurnActivity
turn={renderableTurn}
hasLaterAssistant={hasLaterAssistant}
expanded={turnUiState.isLiveExpanded === true}
onToggle={handleToggleLiveActivity}
renderMessage={renderMessage}
/>
) : undefined}
/>
);
});
@@ -789,7 +795,7 @@ interface MessageListEntryProps {
sessionIsWorking: boolean;
defaultActivityExpanded: boolean;
turnUiStates: Map<string, TurnUiState>;
onToggleTurnGroup: (turnId: string) => void;
onToggleTurnGroup: ToggleTurnGroup;
chatRenderMode: 'sorted' | 'live';
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
onUserAnimationConsumed: (messageId: string) => void;
@@ -845,6 +851,7 @@ const MessageListEntry = React.memo(({
return (
<TurnBlock
turn={entry.turn}
hasLaterAssistant={entry.hasLaterAssistant}
isLastTurn={entry.isLastTurn}
nextEntryFirstMessage={entry.nextEntryFirstMessage}
sessionIsWorking={sessionIsWorking}
@@ -873,7 +880,7 @@ type TimelineRowContextValue = {
stickyUserHeader: boolean;
defaultActivityExpanded: boolean;
turnUiStates: Map<string, TurnUiState>;
onToggleTurnGroup: (turnId: string) => void;
onToggleTurnGroup: ToggleTurnGroup;
chatRenderMode: 'sorted' | 'live';
showTurnChangedFiles: boolean;
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
@@ -951,14 +958,9 @@ type TimelineListProps = {
streamingTailKey: string | null;
registerList: (list: LegendListRef | null) => void;
endPinningReleased: boolean;
anchoredEndSpace?: {
anchorIndex: number;
anchorOffset?: number;
onReady?: (info: { anchorIndex: number | undefined; anchorKey: string | undefined; size: number }) => void;
onSizeChanged?: (size: number) => void;
};
composerOverlayHeight: number;
onIsAtEndChange: (isAtEnd: boolean) => void;
onListMetricsChange: (metrics: { readonly footerSize: number }) => void;
onTimelineDataChange: () => void;
listHeader?: React.ReactNode;
listFooter?: React.ReactNode;
@@ -970,9 +972,9 @@ const TimelineList = React.memo(({
entries,
registerList,
endPinningReleased,
anchoredEndSpace,
composerOverlayHeight,
onIsAtEndChange,
onListMetricsChange,
onTimelineDataChange,
listHeader,
listFooter,
@@ -1064,33 +1066,31 @@ const TimelineList = React.memo(({
// animations); recycling a container into a different row would
// carry that state across.
recycleItems={false}
{...(anchoredEndSpace ? { anchoredEndSpace } : {})}
contentInsetEndAdjustment={composerOverlayHeight}
// While a turn is anchored, the reserved end space — not the
// live edge — defines where the viewport rests.
// Also released while the width resizes: re-pinning against
// rows that are still re-measuring shakes the pinned
// viewport; once the resize settles the owning hook
// re-asserts the end for a streaming session and releases
// the pin for an idle one.
maintainScrollAtEnd={anchoredEndSpace || !streamingAutoFollowEnabled || isWidthResizing || endPinningReleased
// Live only while the session streams: outside a stream the
// owning hook keeps a pinned reader on the end with same-frame
// writes, and the list's own correction runs a frame later
// against a content length that can still be stale (a
// re-wrap, a late measurement) — that is the visible bounce
// an idle reader saw on every panel toggle. Also off while the
// width resizes, where the hook holds the measured end itself.
maintainScrollAtEnd={!streamingAutoFollowEnabled || !rowContext.sessionIsWorking || isWidthResizing || endPinningReleased
? false
// Animated only while the session actively streams: there
// the block-step growth turns each correction into a glide
// and reveal + scroll read as one motion. Outside of a live
// stream — opening a historical session, late measurements —
// corrections must be instant: an animated catch-up scrolls
// visibly through the whole conversation on open, and an
// in-flight glide can supersede explicit navigation.
// Animated: the block-step growth turns each correction
// into a glide and reveal + scroll read as one motion.
: {
animated: rowContext.sessionIsWorking,
animated: true,
on: { dataChange: true, itemLayout: true, layout: true, footerLayout: true },
}}
// Prepending older history must not move what the user is
// reading. Size restoration applies only during a width
// resize — see the observer above.
maintainVisibleContentPosition={{ data: true, size: isWidthResizing }}
// resize (see the observer above) and only for a reader who
// left the end: a pinned reader is held on the end by the
// owning hook, and compensating the rows above them would pull
// the viewport away from it.
maintainVisibleContentPosition={{ data: true, size: isWidthResizing && endPinningReleased }}
onScroll={handleScroll}
onMetricsChange={onListMetricsChange}
ListHeaderComponent={header}
ListFooterComponent={footer}
{...scrollContainerProps}
@@ -1109,7 +1109,7 @@ const StreamingTailContent: React.FC<{
sessionIsWorking: boolean;
defaultActivityExpanded: boolean;
turnUiStates: Map<string, TurnUiState>;
onToggleTurnGroup: (turnId: string) => void;
onToggleTurnGroup: ToggleTurnGroup;
chatRenderMode: 'sorted' | 'live';
showTurnChangedFiles: boolean;
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
@@ -1183,11 +1183,9 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
directory,
registerList,
endPinningReleased = false,
anchorMessageId = null,
onAnchorReady,
onAnchorSizeChanged,
composerOverlayHeight = 0,
onIsAtEndChange,
onListMetricsChange,
onTimelineDataChange,
listHeader,
listFooter,
@@ -1215,13 +1213,15 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
React.useEffect(() => {
setTurnUiStates(new Map());
}, [activityRenderMode]);
}, [activityRenderMode, sessionKey]);
const toggleTurnGroup = React.useCallback((turnId: string) => {
const toggleTurnGroup = React.useCallback((turnId: string, mode: 'sorted' | 'live' = 'sorted') => {
setTurnUiStates((previous) => {
const next = new Map(previous);
const current = next.get(turnId) ?? { isExpanded: defaultActivityExpanded };
next.set(turnId, { isExpanded: !current.isExpanded });
next.set(turnId, mode === 'live'
? { ...current, isLiveExpanded: !current.isLiveExpanded }
: { ...current, isExpanded: !current.isExpanded });
return next;
});
}, [defaultActivityExpanded]);
@@ -1314,6 +1314,15 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
planModeEnabled,
});
const hasUngroupedStaticEntries = projection.ungroupedMessageIds.size > 0;
const tailHasAssistant = Boolean(streamingTurn?.assistantMessages.length);
const turnsWithLaterAssistant = React.useMemo(() => {
if (chatRenderMode !== 'live' || defaultActivityExpanded) return new Set<string>();
const retired = getTurnsWithLaterAssistant(staticTurns);
if (tailHasAssistant) {
for (const turn of staticTurns) retired.add(turn.turnId);
}
return retired;
}, [chatRenderMode, defaultActivityExpanded, staticTurns, tailHasAssistant]);
const staticEntryMessages = hasUngroupedStaticEntries ? displayMessages : EMPTY_STATIC_ENTRY_MESSAGES;
const staticEntryUngroupedIds = hasUngroupedStaticEntries ? projection.ungroupedMessageIds : EMPTY_UNGROUPED_MESSAGE_IDS;
const staticRenderEntries = React.useMemo<RenderEntry[]>(() => streamPerfMeasure('ui.message_list.render_entries_ms', () => {
@@ -1322,6 +1331,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
key: `turn:${turn.turnId}`,
turn,
isLastTurn: turn.turnId === projection.lastTurnId,
hasLaterAssistant: turnsWithLaterAssistant.has(turn.turnId),
}));
if (staticEntryUngroupedIds.size === 0) {
@@ -1355,7 +1365,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
});
return orderedEntries;
}), [projection.lastTurnId, staticEntryMessages, staticEntryUngroupedIds, staticTurns]);
}), [projection.lastTurnId, staticEntryMessages, staticEntryUngroupedIds, staticTurns, turnsWithLaterAssistant]);
const trailingStreamingEntry = React.useMemo<RenderEntry | undefined>(() => {
if (streamingTurn) {
@@ -1435,6 +1445,10 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
onTimelineDataChange?.();
});
const stableListMetricsChange = useStableEvent((metrics: { readonly footerSize: number }) => {
onListMetricsChange?.(metrics);
});
const currentUserOrder = React.useMemo(() => {
return messages
.filter((message) => resolveMessageRole(message) === 'user')
@@ -1793,27 +1807,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
};
}, [findMessageElement, historyEntries.length, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, settleNavigationTarget, turnIndexMap, ref]);
const anchoredEndSpace = React.useMemo<TimelineAnchoredEndSpace | undefined>(() => {
const resolved = resolveChatListAnchoredEndSpace(
allEntries,
anchorMessageId,
(entry) => (entry.kind === 'turn' ? entry.turn.userMessage.info.id : entry.message.info.id),
);
if (!resolved || !anchorMessageId) {
return undefined;
}
return {
...resolved,
onReady: (info) => {
if (info.anchorIndex === undefined) return;
onAnchorReady?.(anchorMessageId, info.anchorIndex);
},
onSizeChanged: () => {
onAnchorSizeChanged?.(anchorMessageId);
},
};
}, [allEntries, anchorMessageId, onAnchorReady, onAnchorSizeChanged]);
const rowContext = React.useMemo(() => ({
scrollToBottom: stableScrollToBottom,
stickyUserHeader,
@@ -1859,9 +1852,9 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
entries={allEntries}
streamingTailKey={trailingStreamingEntry?.key ?? null}
registerList={handleRegisterList}
anchoredEndSpace={anchoredEndSpace}
composerOverlayHeight={composerOverlayHeight}
onIsAtEndChange={stableIsAtEndChange}
onListMetricsChange={stableListMetricsChange}
onTimelineDataChange={stableTimelineDataChange}
listHeader={listHeader}
listFooter={listFooter}
@@ -8,14 +8,15 @@ import { useI18n } from '@/lib/i18n';
interface MobileModelButtonProps {
onOpenModel: () => void;
className?: string;
model?: { providerId: string; modelId: string } | null;
}
export const MobileModelButton: React.FC<MobileModelButtonProps> = ({ onOpenModel, className }) => {
export const MobileModelButton: React.FC<MobileModelButtonProps> = ({ onOpenModel, className, model }) => {
const { t } = useI18n();
const currentModelId = useConfigStore((state) => state.currentModelId);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider);
const currentProvider = getCurrentProvider();
const currentModelId = useConfigStore((state) => model === undefined ? state.currentModelId : model?.modelId);
const currentProviderId = useConfigStore((state) => model === undefined ? state.currentProviderId : model?.providerId);
const providers = useConfigStore((state) => state.providers);
const currentProvider = providers.find((provider) => provider.id === currentProviderId);
const modelLabel = getModelDisplayName(currentProvider, currentModelId, t('chat.modelControls.selectModel'));
return (
@@ -1,5 +1,6 @@
import React from 'react';
import { focusChatInput } from './composer/editor/dom';
import { MobileModelButton } from './MobileModelButton';
import type { EditPermissionMode } from '@/stores/types/sessionTypes';
import type { ModelMetadata } from '@/types';
import {
@@ -17,7 +18,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons";
import { ModelPickerList, type ModelPickerEntry, type ModelPickerProvider } from '@/components/model-picker/ModelPickerList';
import { ModelPickerList, type ModelPickerEntry } from '@/components/model-picker/ModelPickerList';
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import { isDesktopShell } from '@/lib/desktop';
import { getAgentColor } from '@/lib/agentColors';
@@ -46,6 +47,7 @@ import {
shouldPreserveManualModelOverride,
} from '@/lib/messages/userModelChoice';
import { getSyncParts } from '@/sync/sync-refs';
import type { BtwSelection } from '@/stores/useBtwStore';
type IconComponent = IconName;
@@ -307,33 +309,36 @@ const formatDate = (value?: string) => {
return formatReleaseDate(parsedDate);
};
interface ModelControlsProps {
type ModelControlsProps = {
className?: string;
mobilePanel?: MobileControlsPanel;
onMobilePanelChange?: (panel: MobileControlsPanel) => void;
}
} & ({ selection?: never; sessionId?: never } | { selection: BtwSelection; sessionId: string | null });
export const ModelControls: React.FC<ModelControlsProps> = ({
className,
mobilePanel,
onMobilePanelChange,
selection,
sessionId: controlledSessionId,
}) => {
const { t } = useI18n();
const { isReady, isUnavailable } = useOpenCodeReadiness();
const readinessLabel = isUnavailable ? t('common.unavailable') : t('common.loading');
const providers = useConfigStore((state) => state.providers);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
const currentModelId = useConfigStore((state) => state.currentModelId);
const currentProviderId = useConfigStore((state) => selection ? selection.model?.providerId ?? '' : state.currentProviderId);
const currentModelId = useConfigStore((state) => selection ? selection.model?.modelId ?? '' : state.currentModelId);
const effectiveCurrentVariant = useConfigStore((state) => state.currentVariant);
const currentVariantSelection = useConfigStore((state) => state.currentVariantSelection);
// What the picker shows is what the next send carries: an explicit choice
// when there is one, "Default" when "Default" was picked, and otherwise the
// inherited effort — showing "Default" while an inherited effort is in
// force is how a switch away from it looks like it did not stick.
const currentVariant = currentVariantSelection.override === null
let currentVariant = currentVariantSelection.override === null
? undefined
: currentVariantSelection.override ?? effectiveCurrentVariant;
const currentAgentName = useConfigStore((state) => state.currentAgentName);
if (selection) currentVariant = selection.variant ?? undefined;
const currentAgentName = useConfigStore((state) => selection ? selection.agent : state.currentAgentName);
const settingsDefaultVariant = useConfigStore((state) => state.settingsDefaultVariant);
const settingsDefaultAgent = useConfigStore((state) => state.settingsDefaultAgent);
const setProvider = useConfigStore((state) => state.setProvider);
@@ -354,7 +359,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const tracedReadyRef = React.useRef(false);
React.useEffect(() => {
if (tracedReadyRef.current || !isReady) return;
if (selection || tracedReadyRef.current || !isReady) return;
tracedReadyRef.current = true;
markStartupTrace('ModelControls:ready', {
providers: providers.length,
@@ -363,9 +368,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
currentModelId,
currentAgentName,
});
}, [agents.length, currentAgentName, currentModelId, currentProviderId, isReady, providers.length]);
}, [agents.length, currentAgentName, currentModelId, currentProviderId, isReady, providers.length, selection]);
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
// Controlled selections never restore from the main session or its history.
const currentSessionId = useSessionUIStore((s) => selection ? null : s.currentSessionId);
const getDirectoryForSession = useSessionUIStore((s) => s.getDirectoryForSession);
const sync = useSync();
@@ -409,7 +415,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const addRecentModel = useUIStore((state) => state.addRecentModel);
const addRecentAgent = useUIStore((state) => state.addRecentAgent);
const addRecentEffort = useUIStore((state) => state.addRecentEffort);
const isModelSelectorOpen = useUIStore((state) => state.isModelSelectorOpen);
const globalModelSelectorOpen = useUIStore((state) => !selection && state.isModelSelectorOpen);
const [localModelSelectorOpen, setLocalModelSelectorOpen] = React.useState(false);
const isModelSelectorOpen = selection ? localModelSelectorOpen : globalModelSelectorOpen;
const setModelSelectorOpen = useUIStore((state) => state.setModelSelectorOpen);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
@@ -457,7 +465,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
});
// Use global state for model selector (allows Ctrl+M shortcut)
const agentMenuOpen = isModelSelectorOpen;
const setAgentMenuOpen = setModelSelectorOpen;
const setAgentMenuOpen = selection ? setLocalModelSelectorOpen : setModelSelectorOpen;
const openAddProviderSettings = React.useCallback(() => {
setSelectedProvider(ADD_PROVIDER_ID);
setSettingsPage('providers');
@@ -524,13 +532,13 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
// Handle agent selector close behavior
const [agentSearchQuery, setAgentSearchQuery] = React.useState('');
React.useEffect(() => {
if (!isAgentSelectorOpen) {
if (!selection && !isAgentSelectorOpen) {
setAgentSearchQuery('');
if (!isCompact) {
requestAnimationFrame(focusChatInput);
}
}
}, [isAgentSelectorOpen, isCompact]);
}, [isAgentSelectorOpen, isCompact, selection]);
const selectableDesktopAgents = React.useMemo(() => {
return agents.filter((agent) => isPrimaryMode(agent.mode));
@@ -564,7 +572,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const controlTextSize = isCompact ? 'typography-micro' : 'typography-meta';
const inlineGapClass = sizeVariant === 'mobile' ? 'gap-x-1' : sizeVariant === 'vscode' ? 'gap-x-2' : 'gap-x-3';
const currentProvider = getCurrentProvider();
const currentProvider = selection ? providers.find((provider) => provider.id === currentProviderId) : getCurrentProvider();
const models = Array.isArray(currentProvider?.models) ? currentProvider.models : [];
const visibleProviders = React.useMemo(() => {
@@ -623,7 +631,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
// Compute from current model each render to avoid stale variants
// in draft/session transitions.
const availableVariants = getCurrentModelVariants();
const availableVariants = selection
? Object.keys(currentProvider?.models.find((model) => model.id === currentModelId)?.variants ?? {})
: getCurrentModelVariants();
const hasVariants = availableVariants.length > 0;
const costRows = [
@@ -650,14 +660,22 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
// Skip synthetic subagent-completion nudges — restoring from them resets a
// manual model override back to the agent default (issue #2404).
const latestLoadedUserChoice = React.useMemo(() => {
if (selection) return null;
return findLatestUserModelChoice(
currentSessionMessagesFromSync,
(messageId) => getSyncParts(messageId, currentSessionDirectory ?? undefined),
);
}, [currentSessionDirectory, currentSessionMessagesFromSync]);
}, [currentSessionDirectory, currentSessionMessagesFromSync, selection]);
const tryApplyModelSelection = React.useCallback(
(providerId: string, modelId: string, agentName?: string): ModelApplyResult => {
if (selection) {
if (controlledSessionId) {
saveSessionModelSelection(controlledSessionId, providerId, modelId);
if (selection.agent) saveAgentModelForSession(controlledSessionId, selection.agent, providerId, modelId);
}
return 'applied';
}
if (!providerId || !modelId) {
return 'model-missing';
}
@@ -691,7 +709,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return 'applied';
},
[providers, currentProviderId, currentModelId, setProvider, setModel, currentSessionId, saveAgentModelForSession, saveSessionModelSelection],
[providers, currentProviderId, currentModelId, setProvider, setModel, currentSessionId, saveAgentModelForSession, saveSessionModelSelection, controlledSessionId, selection],
);
const getModelVariantOptions = React.useCallback((providerId: string, modelId: string) => {
@@ -739,8 +757,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}
const effectiveAgentName = uiAgentName || currentAgentName;
if (currentSessionId && effectiveAgentName) {
const savedVariant = getAgentModelVariantForSession(currentSessionId, effectiveAgentName, providerId, modelId);
const selectionSessionId = selection ? controlledSessionId : currentSessionId;
if (selectionSessionId && effectiveAgentName) {
const savedVariant = getAgentModelVariantForSession(selectionSessionId, effectiveAgentName, providerId, modelId);
// An explicit "Default" is a choice: it stops the fallbacks below.
if (savedVariant === null) {
return null;
@@ -764,9 +783,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
getAgentModelVariantForSession,
getModelVariantOptions,
uiAgentName,
controlledSessionId,
selection,
]);
const resolveLiveAgentName = React.useCallback(() => {
if (selection) return selection.agent;
const liveConfigAgentName = useConfigStore.getState().currentAgentName;
if (currentSessionId) {
return useSelectionStore.getState().getSessionAgentSelection(currentSessionId)
@@ -775,7 +797,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|| currentAgentName;
}
return liveConfigAgentName || currentAgentName;
}, [currentAgentName, currentSessionId]);
}, [currentAgentName, currentSessionId, selection]);
/**
* Records `variant` as this session's effort for the model, in the same
@@ -787,6 +809,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
* user having chosen "Default".
*/
const commitVariantSelectionForModel = React.useCallback((providerId: string, modelId: string, variant: string | null | undefined, agentNameOverride?: string | null) => {
if (selection) {
if (controlledSessionId && selection.agent) {
saveAgentModelVariantForSession(controlledSessionId, selection.agent, providerId, modelId, variant);
}
return;
}
const variantOptions = getModelVariantOptions(providerId, modelId);
if (variantOptions.length === 0) {
manualVariantSelectionRef.current = false;
@@ -814,6 +842,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
saveAgentModelVariantForSession,
setCurrentVariant,
setCurrentVariantOverride,
controlledSessionId,
selection,
]);
const applyModelSelectionWithVariant = React.useCallback((providerId: string, modelId: string, variant: string | null | undefined, agentNameOverride?: string | null) => {
@@ -823,10 +853,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return result;
}
addRecentModel(providerId, modelId);
if (!selection) addRecentModel(providerId, modelId);
commitVariantSelectionForModel(providerId, modelId, variant, effectiveAgentName);
return 'applied';
}, [addRecentModel, commitVariantSelectionForModel, resolveLiveAgentName, tryApplyModelSelection]);
}, [addRecentModel, commitVariantSelectionForModel, resolveLiveAgentName, tryApplyModelSelection, selection]);
React.useEffect(() => {
if (!currentSessionId) {
@@ -1074,7 +1104,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
]);
React.useEffect(() => {
if (!contextHydrated) {
if (selection || !contextHydrated) {
return;
}
const abortController = new AbortController();
@@ -1127,9 +1157,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
getAgentModelForSession,
tryApplyModelSelection,
contextHydrated,
selection,
]);
React.useEffect(() => {
if (selection) return;
if (!contextHydrated || !currentAgentName) {
manualVariantSelectionRef.current = false;
setCurrentVariant(undefined);
@@ -1199,6 +1231,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
setCurrentVariant,
setCurrentVariantOverride,
settingsDefaultVariant,
selection,
]);
React.useEffect(() => {
@@ -1214,6 +1247,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}, [commitVariantSelectionForModel, currentModelId, currentProviderId]);
const handleAgentChange = React.useCallback((agentName: string, options?: { closeModelSelector?: boolean }) => {
if (selection) return;
try {
setAgent(agentName);
addRecentAgent(agentName);
@@ -1238,6 +1272,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
saveSessionAgentSelection,
setAgent,
setAgentMenuOpen,
selection,
]);
const handleCycleAgentFromModelPicker = React.useCallback((direction: 1 | -1) => {
@@ -1249,6 +1284,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}, [agents, currentAgentName, handleAgentChange]);
const getCycleAgentDirectionFromEvent = React.useCallback((event: KeyboardEvent | React.KeyboardEvent): 1 | -1 | null => {
if (selection) return null;
const cycleAgentBackwardShortcut = cycleAgentShortcut && !cycleAgentShortcut.includes('shift')
? normalizeCombo(`shift+${cycleAgentShortcut}`)
: '';
@@ -1262,7 +1298,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}
return null;
}, [cycleAgentShortcut]);
}, [cycleAgentShortcut, selection]);
const handleProviderAndModelChange = (
providerId: string,
@@ -1284,7 +1320,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}
return;
}
if (!options?.applyVariant) {
if (!selection && !options?.applyVariant) {
// Add to recent models on successful selection.
addRecentModel(providerId, modelId);
}
@@ -2184,6 +2220,13 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
);
const renderModelSelector = () => {
if (isCompact && selection) {
return <MobileModelButton
model={selection.model}
onOpenModel={() => setActiveMobilePanel('model')}
className="model-controls__model-trigger flex-shrink-0"
/>;
}
const handleThinkingVariantKey = (e: React.KeyboardEvent, selectedItem: ModelPickerEntry) => {
keyboardOwnsModelSelectionRef.current = true;
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return false;
@@ -2364,7 +2407,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
collisionAvoidance={{ side: 'none', align: 'shift' }}
onKeyDownCapture={handleModelShortcutKeyDownCapture}
>
<div className="p-1 border-b border-border/40">
{!selection && <div className="p-1 border-b border-border/40">
<button
type="button"
onClick={openAddProviderSettings}
@@ -2375,9 +2418,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</span>
<span className="font-medium text-foreground">{t('chat.modelControls.addNewProvider')}</span>
</button>
</div>
</div>}
<ModelPickerList
providers={providers as ModelPickerProvider[]}
providers={providers}
favoriteModels={favoriteModelsList}
recentModels={recentModelsList}
modelsMetadata={useConfigStore.getState().modelsMetadata}
@@ -2413,7 +2456,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return (
<div className="flex items-center gap-x-2 whitespace-nowrap overflow-hidden">
<span>{t('chat.modelControls.keyboardHintNavigate')}</span>
<span>{t('chat.modelControls.keyboardHintSwitchAgent', { shortcut: 'Tab' })}</span>
{!selection && <span>{t('chat.modelControls.keyboardHintSwitchAgent', { shortcut: 'Tab' })}</span>}
{activeHasThinkingVariants ? <span>{t('chat.modelControls.keyboardHintThinking')}</span> : null}
</div>
);
@@ -2616,6 +2659,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<button
type="button"
onClick={() => setActiveMobilePanel('variant')}
onMouseDown={(event) => event.preventDefault()}
onPointerDownCapture={(event) => {
if (event.pointerType === 'touch') event.preventDefault();
}}
className={cn(
'model-controls__variant-trigger flex items-center gap-1.5 transition-opacity min-w-0 focus:outline-none',
buttonHeight,
@@ -2690,7 +2737,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent side="top">
<p className="typography-meta">Thinking: {displayVariant}</p>
<p className="typography-meta">{t('chat.modelControls.thinking')}: {displayVariant}</p>
</TooltipContent>
</Tooltip>
);
@@ -2875,6 +2922,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
);
};
const inlineMobileSelection = isMobile && Boolean(selection);
const inlineClassName = cn(
'@container/model-controls flex items-center min-w-0',
// Only force full-width + truncation behaviors on true mobile layouts.
@@ -2888,22 +2936,24 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<div className={inlineClassName}>
<div
className={cn(
'flex items-center min-w-0 flex-1 justify-end',
'flex items-center min-w-0 flex-1',
inlineMobileSelection ? 'justify-start' : 'justify-end',
inlineGapClass,
isMobile && 'overflow-hidden'
)}
>
{renderVariantSelector()}
{!inlineMobileSelection && renderVariantSelector()}
{renderModelSelector()}
{renderAgentSelector()}
{inlineMobileSelection && renderVariantSelector()}
{!selection && renderAgentSelector()}
</div>
</div>
{renderMobileModelPanel()}
{renderMobileVariantPanel()}
{renderMobileAgentPanel()}
{!selection && renderMobileAgentPanel()}
{renderMobileModelTooltip()}
{renderMobileAgentTooltip()}
{!selection && renderMobileAgentTooltip()}
</>
);
@@ -1,5 +1,5 @@
import React, { act } from 'react';
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import { beforeEach, describe, expect, mock, spyOn, test } from 'bun:test';
import { createRoot } from 'react-dom/client';
import { Window } from 'happy-dom';
import { create } from 'zustand';
@@ -122,9 +122,9 @@ type SelectionState = {
getSessionAgentSelection: () => string | null;
getAgentModelForSession: () => { providerId: string; modelId: string } | null;
getAgentModelVariantForSession: () => VariantChoice;
saveSessionModelSelection: () => void;
saveSessionModelSelection: (sessionId: string, providerId: string, modelId: string) => void;
saveSessionAgentSelection: () => void;
saveAgentModelForSession: () => void;
saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void;
saveAgentModelVariantForSession: (
sessionId: string,
agentName: string,
@@ -206,9 +206,11 @@ mock.module('@/sync/use-sync', () => ({ useSync: () => ({ sessions: [] }) }));
mock.module('@/sync/sync-refs', () => ({ getSyncParts: () => [] }));
mock.module('@/components/ui/dropdown-menu', () => ({
DropdownMenu: passthrough,
DropdownMenu: ({ children, open }: React.PropsWithChildren<{ open?: boolean }>) => <div data-menu-open={open}>{children}</div>,
DropdownMenuContent: passthrough,
DropdownMenuItem: passthrough,
DropdownMenuItem: ({ children, onSelect }: React.PropsWithChildren<{ onSelect?: () => void }>) => (
<button onClick={onSelect}>{children}</button>
),
DropdownMenuLabel: passthrough,
DropdownMenuSeparator: () => null,
DropdownMenuTrigger: passthrough,
@@ -216,7 +218,9 @@ mock.module('@/components/ui/dropdown-menu', () => ({
mock.module('@/components/ui/input', () => ({
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
}));
mock.module('@/components/ui/MobileOverlayPanel', () => ({ MobileOverlayPanel: passthrough }));
mock.module('@/components/ui/MobileOverlayPanel', () => ({
MobileOverlayPanel: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) => open ? <div>{children}</div> : null,
}));
mock.module('@/components/ui/ProviderLogo', () => ({ ProviderLogo: () => null }));
mock.module('@/components/ui/ScrollableOverlay', () => ({ ScrollableOverlay: passthrough }));
mock.module('@/components/ui/tooltip', () => ({
@@ -225,9 +229,13 @@ mock.module('@/components/ui/tooltip', () => ({
TooltipTrigger: passthrough,
}));
mock.module('@/components/icon/Icon', () => ({ Icon: () => null }));
mock.module('@/components/model-picker/ModelPickerList', () => ({ ModelPickerList: () => null }));
mock.module('@/components/model-picker/ModelPickerList', () => ({
ModelPickerList: ({ onSelect }: React.ComponentProps<typeof import('@/components/model-picker/ModelPickerList').ModelPickerList>) => (
<button onClick={() => onSelect({ providerID: PROVIDER_ID, modelID: MODEL_ID, model })}>{MODEL_ID}</button>
),
}));
mock.module('@/hooks/useRuntimeAPIs', () => ({ useIsVSCodeRuntime: () => false }));
mock.module('@/hooks/useModelLists', () => ({ useModelLists: () => ({ favoriteModels: [], recentModels: [] }) }));
mock.module('@/hooks/useModelLists', () => ({ useModelLists: () => ({ favoriteModelsList: [], recentModelsList: [] }) }));
mock.module('@/hooks/useIsTextTruncated', () => ({ useIsTextTruncated: () => false }));
mock.module('@/hooks/useOpenCodeReadiness', () => ({
useOpenCodeReadiness: () => ({ isReady: true, isUnavailable: false }),
@@ -304,12 +312,12 @@ const installDom = () => {
};
};
const renderModelControls = async () => {
const renderModelControls = async (props: React.ComponentProps<typeof ModelControls> = {}) => {
const dom = installDom();
const root = createRoot(dom.container);
await act(async () => root.render(
<I18nProvider>
<ModelControls />
<ModelControls {...props} />
</I18nProvider>,
));
return {
@@ -327,6 +335,7 @@ describe('ModelControls effort restore', () => {
overrideWrites.length = 0;
latestUserChoice = null;
forcePreserveManualOverride = null;
useUIStore.setState({ isMobile: false, isModelSelectorOpen: false });
useSelectionStore.setState({ savedVariant: undefined });
useConfigStore.setState({
currentProviderId: PROVIDER_ID,
@@ -341,9 +350,11 @@ describe('ModelControls effort restore', () => {
test('restores the concrete effort the session history carries', async () => {
latestUserChoice = { id: 'msg-1', agent: AGENT, providerID: PROVIDER_ID, modelID: MODEL_ID, variant: 'low' };
useUIStore.setState({ isModelSelectorOpen: true });
const { cleanup } = await renderModelControls();
const { dom, cleanup } = await renderModelControls();
try {
expect(dom.container.querySelector('.model-controls__model-trigger')?.closest('[data-menu-open]')?.getAttribute('data-menu-open')).toBe('true');
expect(variantWrites).toContain('low');
expect(variantWrites).not.toContain(null);
expect(useSelectionStore.getState().savedVariant).toBe('low');
@@ -403,4 +414,55 @@ describe('ModelControls effort restore', () => {
await cleanup();
}
});
for (const [isMobile, variant] of [
[false, 'low'], [true, null],
] as const) {
test(`controlled BTW selection stays independent (mobile: ${isMobile}, effort: ${variant})`, async () => {
const btwSessionId = 'btw-pending:ses_restore';
latestUserChoice = { id: 'msg-main', agent: AGENT, providerID: PROVIDER_ID, modelID: MODEL_ID, variant: 'high' };
useUIStore.setState({ isMobile, isModelSelectorOpen: true });
useConfigStore.setState({
currentProviderId: 'main-provider', currentModelId: 'main-model',
currentVariant: 'low', currentVariantSelection: { override: undefined, inherited: 'low' },
});
const selections = useSelectionStore.getState();
const saveModel = spyOn(selections, 'saveSessionModelSelection');
const saveAgentModel = spyOn(selections, 'saveAgentModelForSession');
const saveVariant = spyOn(selections, 'saveAgentModelVariantForSession');
const { dom, cleanup } = await renderModelControls({
sessionId: btwSessionId,
selection: { model: { providerId: PROVIDER_ID, modelId: MODEL_ID }, agent: 'plan', variant },
});
try {
expect(overrideWrites).toEqual([]);
expect(variantWrites).toEqual([]);
expect(saveModel.mock.calls).toEqual([]);
expect(dom.container.querySelector('.model-controls__agent-label')).toBeNull();
expect(dom.container.querySelector('.model-controls__variant-label')?.textContent?.trim()).toBe(variant ?? 'Default');
if (!isMobile) {
expect(dom.container.querySelector('.model-controls__model-trigger')?.closest('[data-menu-open]')?.getAttribute('data-menu-open')).toBe('false');
}
await act(async () => dom.container.querySelector<HTMLButtonElement>('.model-controls__model-trigger')?.click());
const modelButton = Array.from(dom.container.querySelectorAll<HTMLButtonElement>('button:not(.model-controls__model-trigger)')).find((button) => button.textContent?.trim() === MODEL_ID);
await act(async () => modelButton?.click());
expect(saveModel.mock.calls.at(-1)).toEqual([btwSessionId, PROVIDER_ID, MODEL_ID]);
expect(saveAgentModel.mock.calls.at(-1)).toEqual([btwSessionId, 'plan', PROVIDER_ID, MODEL_ID]);
await act(async () => dom.container.querySelector<HTMLButtonElement>('.model-controls__variant-trigger')?.click());
const defaultButton = Array.from(dom.container.querySelectorAll('button')).find((button) => button.textContent?.trim() === 'Default');
await act(async () => defaultButton?.click());
expect(saveVariant.mock.calls.at(-1)).toEqual([btwSessionId, 'plan', PROVIDER_ID, MODEL_ID, null]);
const config = useConfigStore.getState();
expect([config.currentProviderId, config.currentModelId, config.currentAgentName, config.currentVariant])
.toEqual(['main-provider', 'main-model', AGENT, 'low']);
expect(overrideWrites).toEqual([]);
} finally {
await cleanup();
for (const write of [saveModel, saveAgentModel, saveVariant]) write.mockRestore();
useSelectionStore.setState(selections);
}
});
}
});
@@ -1,6 +1,7 @@
import React from 'react';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { useI18n } from '@/lib/i18n';
import { isIMECompositionEvent } from '@/lib/ime';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
@@ -40,11 +41,13 @@ const IDLE_SESSION_STATUS = { type: 'idle' as const };
* and the app navigates to it), destroy (the fork is deleted; the main
* conversation is never touched).
*/
export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState }> = ({
export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState; onExit: () => void }> = ({
parentSessionId,
panel,
onExit,
}) => {
const { t } = useI18n();
useEscapeToExit(onExit, !panel.collapsed && Boolean(panel.pending || panel.creating || panel.btwSessionId));
if (panel.btwSessionId && panel.btwDirectory) {
return (
@@ -54,7 +57,6 @@ export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState
btwSessionId: panel.btwSessionId,
directory: panel.btwDirectory,
}}
title={panel.btwSession?.title?.trim() || t('chat.btw.titleFallback')}
boundaryMessageID={panel.boundaryMessageID}
collapsed={panel.collapsed}
/>
@@ -63,7 +65,7 @@ export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState
if (panel.creating) {
return (
<BtwFrame title={t('chat.btw.titleFallback')}>
<BtwFrame>
<div className="flex items-center gap-2 px-4 py-4 text-sm text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
<span>{t('chat.btw.loading')}</span>
@@ -72,6 +74,27 @@ export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState
);
}
if (panel.pending) {
return (
<BtwFrame
draftHint={t('chat.btw.draftHint')}
collapsed={panel.collapsed}
actions={(
<Button
type="button"
variant="ghost"
size="icon"
onClick={onExit}
aria-label={t('chat.btw.cancelAria')}
title={t('chat.btw.cancelAria')}
>
<Icon name="close" className="size-4" />
</Button>
)}
/>
);
}
return null;
};
@@ -161,21 +184,18 @@ const useBtwSessionData = (
};
};
/** Esc collapses the sheet (never destroys) unless focus is in a text field. */
const useEscapeToCollapse = (onCollapse: () => void): void => {
/** Composer and popup handlers get first refusal; the owner decides cancel versus collapse. */
const useEscapeToExit = (onExit: () => void, enabled: boolean): void => {
React.useEffect(() => {
if (!enabled) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
// SAFETY: keydown targets are DOM elements (or null on window).
const target = event.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
onCollapse();
if (event.key !== 'Escape' || event.defaultPrevented || isIMECompositionEvent(event)) return;
event.preventDefault();
onExit();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onCollapse]);
}, [enabled, onExit]);
};
/**
@@ -217,14 +237,14 @@ const useAutoScroll = (
};
const BtwFrame: React.FC<{
title: string;
actions?: React.ReactNode;
onTitleClick?: () => void;
titleClickLabel?: string;
collapsed?: boolean;
headerSpinner?: boolean;
draftHint?: string;
children?: React.ReactNode;
}> = ({ title, actions, onTitleClick, titleClickLabel, collapsed, headerSpinner, children }) => (
}> = ({ actions, onTitleClick, titleClickLabel, collapsed, headerSpinner, draftHint, children }) => (
<div
className="chat-input-column absolute bottom-full left-0 right-0 z-30 mb-3"
role="dialog"
@@ -245,17 +265,12 @@ const BtwFrame: React.FC<{
) : (
<Icon name="chat-ai-3" className="size-3.5 shrink-0" />
)}
<span className="typography-ui-label min-w-0 truncate font-semibold">
{title}
</span>
<Icon name={collapsed ? 'arrow-up-s' : 'arrow-down-s'} className="size-4 shrink-0" />
</button>
) : (
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
<Icon name="chat-ai-3" className="size-3.5 shrink-0" />
<h2 className="typography-ui-label min-w-0 truncate font-semibold">
{title}
</h2>
{draftHint ? <span className="typography-ui-label truncate">{draftHint}</span> : null}
</span>
)}
<div className="min-w-0 flex-1" />
@@ -273,23 +288,20 @@ const BtwFrame: React.FC<{
const BtwSheet: React.FC<{
sessionRef: BtwSessionRef;
title: string;
boundaryMessageID: string | null;
collapsed: boolean;
}> = ({ sessionRef, title, boundaryMessageID, collapsed }) => {
}> = ({ sessionRef, boundaryMessageID, collapsed }) => {
const { t } = useI18n();
const handleDestroy = useBtwDestroy(sessionRef);
const setCollapsed = React.useCallback((next: boolean) => {
useBtwStore.getState().setPanelState(sessionRef.parentSessionId, { collapsed: next });
}, [sessionRef.parentSessionId]);
const handleToggleCollapsed = React.useCallback(() => setCollapsed(!collapsed), [collapsed, setCollapsed]);
const handleCollapse = React.useCallback(() => setCollapsed(true), [setCollapsed]);
const handlePromote = React.useCallback(() => {
void promoteBtwSession(sessionRef).catch(() => {
toast.error(t('chat.btw.toast.promoteFailed'));
});
}, [sessionRef, t]);
useEscapeToCollapse(handleCollapse);
const toggleLabel = collapsed ? t('chat.btw.expandAria') : t('chat.btw.collapseAria');
const headerButtonClass = 'size-7 rounded-lg text-muted-foreground transition-colors hover:text-foreground hover:!bg-transparent active:!bg-transparent';
@@ -324,7 +336,6 @@ const BtwSheet: React.FC<{
return (
<BtwCollapsedStrip
sessionRef={sessionRef}
title={title}
actions={actions}
onExpand={handleToggleCollapsed}
expandLabel={toggleLabel}
@@ -335,7 +346,6 @@ const BtwSheet: React.FC<{
return (
<BtwExpandedSheet
sessionRef={sessionRef}
title={title}
boundaryMessageID={boundaryMessageID}
actions={actions}
onTitleClick={handleToggleCollapsed}
@@ -351,16 +361,14 @@ const BtwSheet: React.FC<{
*/
const BtwCollapsedStrip: React.FC<{
sessionRef: BtwSessionRef;
title: string;
actions: React.ReactNode;
onExpand: () => void;
expandLabel: string;
}> = ({ sessionRef, title, actions, onExpand, expandLabel }) => {
}> = ({ sessionRef, actions, onExpand, expandLabel }) => {
const status = useSessionStatus(sessionRef.btwSessionId, sessionRef.directory) ?? IDLE_SESSION_STATUS;
const isBusy = status.type === 'busy' || status.type === 'retry';
return (
<BtwFrame
title={title}
actions={actions}
onTitleClick={onExpand}
titleClickLabel={expandLabel}
@@ -372,12 +380,11 @@ const BtwCollapsedStrip: React.FC<{
const BtwExpandedSheet: React.FC<{
sessionRef: BtwSessionRef;
title: string;
boundaryMessageID: string | null;
actions: React.ReactNode;
onTitleClick: () => void;
titleClickLabel: string;
}> = ({ sessionRef, title, boundaryMessageID, actions, onTitleClick, titleClickLabel }) => {
}> = ({ sessionRef, boundaryMessageID, actions, onTitleClick, titleClickLabel }) => {
const data = useBtwSessionData(sessionRef.btwSessionId, sessionRef.directory, boundaryMessageID);
const bodyRef = React.useRef<HTMLDivElement | null>(null);
const contentRef = React.useRef<HTMLDivElement | null>(null);
@@ -395,7 +402,7 @@ const BtwExpandedSheet: React.FC<{
: undefined;
return (
<BtwFrame title={title} actions={actions} onTitleClick={onTitleClick} titleClickLabel={titleClickLabel} collapsed={false}>
<BtwFrame actions={actions} onTitleClick={onTitleClick} titleClickLabel={titleClickLabel} collapsed={false}>
<ChatSurfaceProvider mode="peek">
<BtwMessages
data={data}
@@ -16,6 +16,7 @@ export type BtwPanelState = {
boundaryMessageID: string | null;
collapsed: boolean;
creating: boolean;
pending: boolean;
};
/**
@@ -53,5 +54,6 @@ export function useBtwPanelState(
boundaryMessageID: btwSessionId ? getBtwBoundaryMessageID(btwSession) : null,
collapsed: Boolean(uiState?.collapsed),
creating: Boolean(uiState?.creating),
pending: Boolean(uiState?.pending),
};
}
@@ -0,0 +1,118 @@
import React, { act, StrictMode, Suspense } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { Window } from 'happy-dom';
import { LiveActivityCollapse } from './LiveActivityCollapse';
describe('live Activity collapse layout lifecycle', () => {
let root: Root;
let container: HTMLDivElement;
let restore: () => void;
beforeEach(() => {
const win = new Window({ url: 'http://localhost' });
const globals = {
window: win, document: win.document, HTMLElement: win.HTMLElement,
Element: win.Element, SVGElement: win.SVGElement, NodeList: win.NodeList,
requestAnimationFrame: win.requestAnimationFrame.bind(win),
cancelAnimationFrame: win.cancelAnimationFrame.bind(win),
getComputedStyle: win.getComputedStyle.bind(win),
IS_REACT_ACT_ENVIRONMENT: true,
};
const previous = Object.keys(globals).map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const);
for (const [name, value] of Object.entries(globals)) {
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
}
restore = () => {
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
};
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
restore();
});
test('settles the target height when React cleans up and replays a collapse layout effect', async () => {
await act(async () => root.render(
<StrictMode>
<LiveActivityCollapse expanded={false} animateOnMount>
<div ref={(node) => {
if (!node?.parentElement) return;
// Happy DOM has no layout engine. Supply the height
// measured before a real historical turn collapses.
Object.defineProperty(node.parentElement, 'scrollHeight', { configurable: true, get: () => 2400 });
}}>Historical activity</div>
</LiveActivityCollapse>
</StrictMode>,
));
const region = container.querySelector<HTMLElement>('[data-live-activity-content]');
expect(region?.style.height).toBe('0px');
expect(region?.childElementCount).toBe(0);
});
test('settles a collapse after a Suspense hide/reveal in production lifecycle', async () => {
let suspended = false;
let release: () => void = () => undefined;
const pending = new Promise<void>((resolve) => { release = resolve; });
function LoadingSibling() {
if (suspended) throw pending;
return null;
}
const render = () => (
<Suspense fallback={<div>Loading history</div>}>
<LiveActivityCollapse expanded={false} animateOnMount>
<div ref={(node) => {
if (!node?.parentElement) return;
Object.defineProperty(node.parentElement, 'scrollHeight', { configurable: true, get: () => 2400 });
}}>Historical activity</div>
</LiveActivityCollapse>
<LoadingSibling />
</Suspense>
);
await act(async () => root.render(render()));
suspended = true;
await act(async () => root.render(render()));
suspended = false;
await act(async () => { release(); });
const region = container.querySelector<HTMLElement>('[data-live-activity-content]');
expect(region?.style.height).toBe('0px');
expect(region?.childElementCount).toBe(0);
});
test('restores natural height when an expansion is interrupted by Suspense', async () => {
let expanded = false;
let suspended = false;
let release: () => void = () => undefined;
const pending = new Promise<void>((resolve) => { release = resolve; });
function LoadingSibling() {
if (suspended) throw pending;
return null;
}
const render = () => (
<Suspense fallback={<div>Loading history</div>}>
<LiveActivityCollapse expanded={expanded}>
<div>Historical activity</div>
</LiveActivityCollapse>
<LoadingSibling />
</Suspense>
);
await act(async () => root.render(render()));
expanded = true;
await act(async () => root.render(render()));
suspended = true;
await act(async () => root.render(render()));
suspended = false;
await act(async () => { release(); });
const region = container.querySelector<HTMLElement>('[data-live-activity-content]');
expect(region?.style.height).toBe('auto');
expect(region?.style.overflow).toBe('visible');
expect(region?.textContent).toBe('Historical activity');
});
});
@@ -0,0 +1,72 @@
import React from 'react';
import { animate } from 'motion';
interface LiveActivityCollapseProps {
expanded: boolean;
children: React.ReactNode;
id?: string;
animateOnMount?: boolean;
}
export function LiveActivityCollapse({ expanded, children, id, animateOnMount = false }: LiveActivityCollapseProps) {
const ref = React.useRef<HTMLDivElement>(null);
const previousExpanded = React.useRef(expanded || animateOnMount);
const [retained, setRetained] = React.useState(expanded || animateOnMount);
const mounted = expanded || retained;
React.useLayoutEffect(() => {
const element = ref.current;
if (!element) return;
const settle = () => {
element.style.height = expanded ? 'auto' : '0px';
element.style.overflow = expanded ? 'visible' : 'hidden';
setRetained(expanded);
};
// Suspense can clean up a layout effect while retaining its DOM, then
// replay setup on reveal. The old animation was stopped, but the ref
// still records its target. Skipping setup here would freeze the
// measured pre-collapse height and retain an empty historical region.
if (previousExpanded.current === expanded) {
settle();
return;
}
previousExpanded.current = expanded;
if (expanded) setRetained(true);
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
settle();
return;
}
element.style.height = expanded ? '0px' : `${element.scrollHeight}px`;
element.style.overflow = 'hidden';
const animation = animate(element, { height: expanded ? 'auto' : '0px' }, {
duration: 0.18,
ease: [0.16, 1, 0.3, 1],
});
let cancelled = false;
const finish = () => {
if (!cancelled) settle();
};
void animation.finished.then(finish, finish);
return () => {
cancelled = true;
animation.stop();
};
}, [expanded]);
return (
<div
ref={ref}
id={id}
aria-hidden={!expanded}
inert={!expanded}
data-live-activity-content="true"
style={{
height: mounted ? 'auto' : 0,
overflow: mounted ? 'visible' : 'hidden',
overflowAnchor: 'none',
}}
>
{mounted ? children : null}
</div>
);
}
@@ -0,0 +1,201 @@
import React, { act } from 'react';
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { plugin } from 'bun';
import { pathToFileURL } from 'node:url';
import { readFileSync, readdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { createRoot, type Root } from 'react-dom/client';
import { Window } from 'happy-dom';
import { createOpencodeClient, type Part, type AssistantMessage } from '@opencode-ai/sdk/v2';
import { I18nProvider } from '@/lib/i18n';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import type { RuntimeAPIs } from '@/lib/api/types';
import { SyncProvider } from '@/sync/sync-context';
import { useUIStore } from '@/stores/useUIStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { projectTurnRecords } from '../lib/turns/projectTurnRecords';
import type { ChatMessageEntry, TurnRecord } from '../lib/turns/types';
import { LiveTurnActivity } from './LiveTurnActivity';
plugin({
name: 'live-activity-worker-url',
setup(build) {
build.onLoad({ filter: /markdown-shiki\.worker\.ts\?worker&url$/ }, ({ path }) => ({
contents: `export default ${JSON.stringify(pathToFileURL(path.split('?')[0]).href)};`, loader: 'js',
}));
// Expand Vite's eager asset glob into the same real file URL map for
// Bun. This is a loader transform, not a replacement of the logo hook.
build.onLoad({ filter: /useProviderLogo\.ts$/ }, ({ path }) => {
const folder = resolve(dirname(path), '../assets/provider-logos');
const logos = Object.fromEntries(readdirSync(folder).filter((name) => name.endsWith('.svg'))
.map((name) => [`../assets/provider-logos/${name}`, pathToFileURL(resolve(folder, name)).href]));
const contents = readFileSync(path, 'utf8').replace(/import\.meta\.glob<string>\([\s\S]*?\);/, `${JSON.stringify(logos)};`);
return { contents, loader: 'ts' };
});
},
});
const unavailable = (): never => { throw new Error('Activity rendering must not call runtime APIs'); };
const runtimeApis: RuntimeAPIs = {
runtime: { platform: 'web', isDesktop: false, isVSCode: false },
get terminal() { return unavailable(); },
get git() { return unavailable(); },
get files() { return unavailable(); },
get settings() { return unavailable(); },
get permissions() { return unavailable(); },
get notifications() { return unavailable(); },
get tools() { return unavailable(); },
};
const sdk = createOpencodeClient({ baseUrl: 'http://localhost', fetch: async () => new Response('[]', { headers: { 'Content-Type': 'application/json' } }) });
let MessageBody: typeof import('../message/MessageBody').default;
function assistant(id: string, parts: Part[], finish?: string): ChatMessageEntry {
const info: AssistantMessage = {
id, sessionID: 'session', role: 'assistant', parentID: 'user', time: { created: 2, completed: finish ? 3 : undefined },
modelID: 'model', providerID: 'provider', mode: 'build', agent: 'build', path: { cwd: '/project', root: '/project' },
cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, finish,
};
return { info, parts };
}
function text(id: string, content: string): Part {
return { type: 'text', id, text: content, sessionID: 'session', messageID: 'message' };
}
const readPart: Part = {
type: 'tool', tool: 'read', id: 'read', callID: 'read', sessionID: 'session', messageID: 'progress',
state: { status: 'completed', input: { filePath: '/project/source.ts' }, output: 'code', title: 'Read', metadata: {}, time: { start: 1, end: 2 } },
};
function turn(messages: ChatMessageEntry[]): TurnRecord {
return projectTurnRecords([{
info: { id: 'user', sessionID: 'session', role: 'user', time: { created: 1 }, agent: 'build', model: { providerID: 'provider', modelID: 'model' } },
parts: [text('request', 'Request')],
}, ...messages]).turns[0];
}
function Harness({ record, retired = false }: { record: TurnRecord; retired?: boolean }) {
const [expanded, setExpanded] = React.useState(false);
const renderMessage = (message: ChatMessageEntry) => (
<div key={message.info.id} data-fixture-message={message.info.id}>
<MessageBody
messageId={message.info.id} parts={message.parts} isUser={false}
isMessageCompleted={message.info.role === 'assistant' && Boolean(message.info.finish)}
messageFinish={message.info.role === 'assistant' ? message.info.finish : undefined}
isMobile={false} copiedCode={null} onCopyCode={() => undefined} expandedTools={new Set()}
onToggleTool={() => undefined} onShowPopup={() => undefined} streamPhase="completed" allowAnimation={false}
hasTextContent={message.parts.some((part) => part.type === 'text')} showReasoningTraces
turnGroupingContext={{
turnId: 'user', isFirstAssistantInTurn: message === record.assistantMessages[0],
isLastAssistantInTurn: message === record.assistantMessages.at(-1),
isLatestTurn: true, isWorking: false, hasTools: record.hasTools, hasReasoning: record.hasReasoning,
}}
/>
</div>
);
return <RuntimeAPIContext.Provider value={runtimeApis}>
<SyncProvider sdk={sdk} directory="/project">
<I18nProvider>
<LiveTurnActivity turn={record} hasLaterAssistant={retired} expanded={expanded}
onToggle={() => setExpanded((value) => !value)} renderMessage={renderMessage} />
</I18nProvider>
</SyncProvider>
</RuntimeAPIContext.Provider>;
}
describe('live Activity with the real message body', () => {
let root: Root;
let container: HTMLDivElement;
let restore: () => void;
beforeEach(async () => {
const win = new Window({ url: 'http://localhost', settings: { device: { prefersReducedMotion: 'reduce' } } });
const globals = {
window: win, document: win.document, navigator: win.navigator, localStorage: win.localStorage,
customElements: win.customElements,
Node: win.Node, NodeList: win.NodeList, Element: win.Element, HTMLElement: win.HTMLElement, SVGElement: win.SVGElement,
HTMLAnchorElement: win.HTMLAnchorElement,
MutationObserver: win.MutationObserver, ResizeObserver: win.ResizeObserver,
requestAnimationFrame: win.requestAnimationFrame.bind(win), cancelAnimationFrame: win.cancelAnimationFrame.bind(win),
getComputedStyle: win.getComputedStyle.bind(win), IS_REACT_ACT_ENVIRONMENT: true,
};
const previous = Object.keys(globals).map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const);
for (const [name, value] of Object.entries(globals)) Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
// Reduced motion makes the disclosure lifecycle deterministic without
// replacing the real component or animation module.
restore = () => {
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
};
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
useUIStore.setState({ chatRenderMode: 'live', collapsibleThinkingBlocks: false, showSplitAssistantMessageActions: false });
useDirectoryStore.setState({ currentDirectory: '/project' });
MessageBody = (await import('../message/MessageBody')).default;
});
afterEach(async () => {
await act(async () => root.unmount());
restore();
});
test('keeps active prose and tools visible; stop folds history but leaves the answer', async () => {
const progress = assistant('progress', [text('progress-text', 'Checking the source'), readPart], 'tool-calls');
await act(async () => root.render(<Harness record={turn([progress])} />));
expect(container.textContent).toContain('Checking the source');
expect(container.querySelector('[aria-controls]')).toBeNull();
expect(container.textContent).not.toContain('Activity');
const final = assistant('final', [text('final-text', 'The final answer')], 'stop');
await act(async () => root.render(<Harness record={turn([progress, final])} />));
expect(container.textContent).toContain('The final answer');
expect(container.textContent).not.toContain('Checking the source');
const header = container.querySelector<HTMLButtonElement>('button[aria-controls]');
expect(header?.getAttribute('aria-expanded')).toBe('false');
expect(header?.textContent).toContain('Explored codebase');
await act(async () => header?.click());
expect(header?.textContent).toContain('Explored codebase');
expect(container.textContent).toContain('Checking the source');
expect(container.textContent).toContain('The final answer');
await act(async () => root.render(<Harness record={turn([progress, { ...final, parts: [...final.parts] }])} />));
expect(header?.getAttribute('aria-expanded')).toBe('true');
});
test('keeps thinking in the final message inside Activity, not outside with the answer', async () => {
const thinking: Part = { type: 'reasoning', id: 'thinking', messageID: 'final', sessionID: 'session', text: 'Private reasoning content', time: { start: 1, end: 2 } };
const final = assistant('final', [thinking, text('final-text', 'Public answer')], 'stop');
await act(async () => root.render(<Harness record={turn([final])} />));
expect(container.textContent).toContain('Public answer');
expect(container.textContent).not.toContain('Private reasoning content');
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-controls]')?.click());
expect(container.textContent).toContain('Private reasoning content');
expect(container.textContent).toContain('Public answer');
});
test('an interrupted turn folds all prose without fabricating a final answer', async () => {
const record = turn([assistant('progress', [text('progress-text', 'Still working'), readPart], 'tool-calls')]);
await act(async () => root.render(<Harness record={record} />));
expect(container.textContent).toContain('Still working');
expect(container.textContent).not.toContain('Activity');
await act(async () => root.render(<Harness record={record} retired />));
expect(container.textContent).not.toContain('Still working');
expect(container.textContent).toContain('Activity');
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-controls]')?.click());
expect(container.textContent).toContain('Still working');
});
test('keeps file statistics visible when expanded and uses an ASCII minus', async () => {
const edit: Part = {
type: 'tool', tool: 'edit', id: 'edit', callID: 'edit', sessionID: 'session', messageID: 'progress',
state: { status: 'completed', input: { filePath: '/project/source.ts' }, output: '', title: 'Edit',
metadata: { diff: '@@ -1,1 +1,2 @@\n-old\n+new\n+added' }, time: { start: 1, end: 2 } },
};
await act(async () => root.render(<Harness record={turn([
assistant('progress', [edit], 'tool-calls'),
assistant('final', [text('answer', 'Done')], 'stop'),
])} />));
const header = container.querySelector<HTMLButtonElement>('button[aria-controls]');
expect(header?.textContent).toContain('Changed 1 file');
expect(header?.textContent).toContain('+2/-1');
await act(async () => header?.click());
expect(header?.textContent).toContain('Changed 1 file');
expect(header?.textContent).toContain('+2/-1');
});
});
@@ -0,0 +1,86 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
import { useI18n } from '@/lib/i18n';
import type { ChatMessageEntry, TurnRecord } from '../lib/turns/types';
import { getLiveFinalMessage } from '../lib/turns/liveActivity';
import { summarizeLiveActivity } from '../lib/turns/liveActivitySummary';
import { LiveActivityCollapse } from './LiveActivityCollapse';
import { LiveFinalActivityContext } from './liveActivityContext';
interface LiveTurnActivityProps {
turn: TurnRecord;
hasLaterAssistant: boolean;
expanded: boolean;
onToggle: () => void;
renderMessage: (message: ChatMessageEntry) => React.ReactNode;
}
export function LiveTurnActivity({ turn, hasLaterAssistant, expanded, onToggle, renderMessage }: LiveTurnActivityProps) {
const { t } = useI18n();
const contentId = React.useId();
const finalContentId = React.useId();
const finalMessage = getLiveFinalMessage(turn.assistantMessages);
const settled = Boolean(finalMessage) || hasLaterAssistant;
const isExpanded = !settled || expanded;
const previouslySettled = React.useRef(settled);
const animateFinalCollapse = settled && !previouslySettled.current;
React.useLayoutEffect(() => { previouslySettled.current = settled; }, [settled]);
const finalContext = React.useMemo(() => finalMessage ? {
messageId: finalMessage.info.id, expanded: isExpanded, contentId: finalContentId, animateCollapse: animateFinalCollapse,
} : null, [finalMessage, isExpanded, finalContentId, animateFinalCollapse]);
// No diff parsing at token frequency. The report is only shown once the
// turn settles; later authoritative tool metadata can refine it.
const summary = React.useMemo(() => settled ? summarizeLiveActivity(turn.assistantMessages) : null,
[settled, turn.assistantMessages]);
const fileLabel = summary && summary.files > 0
? t(summary.files === 1 ? 'chat.liveActivity.changedFile' : 'chat.liveActivity.changedFiles', { count: summary.files })
: null;
const details = summary ? [
summary.explored ? t('chat.liveActivity.explored') : null,
summary.commands > 0 ? t(summary.commands === 1 ? 'chat.liveActivity.ranCommand' : 'chat.liveActivity.ranCommands', { count: summary.commands }) : null,
summary.researched ? t('chat.liveActivity.researched') : null,
summary.subagents > 0 ? t(summary.subagents === 1 ? 'chat.liveActivity.usedSubagent' : 'chat.liveActivity.usedSubagents', { count: summary.subagents }) : null,
].filter(Boolean).join(' · ') : '';
const label = (
<>
<Icon name="stack" className="size-3.5 shrink-0 text-[var(--tools-icon)]" />
<span className="shrink-0 font-semibold text-[var(--tools-title)]">{t('chat.liveActivity.title')}</span>
{settled ? <Icon name={isExpanded ? 'arrow-down-s' : 'arrow-right-s'} className="size-3 shrink-0" /> : null}
{fileLabel ? (
<span className="flex min-w-0 items-center gap-1 typography-meta @min-[560px]:shrink-0">
<span className="truncate">{fileLabel}</span>
{summary?.hasCompleteDiff && (summary.additions > 0 || summary.deletions > 0) ? (
<span className="shrink-0 tabular-nums">
<span className="text-[var(--status-success)]">+{summary.additions}</span>
<span aria-hidden="true">/</span>
<span className="text-[var(--status-error)]">-{summary.deletions}</span>
</span>
) : null}
</span>
) : null}
{details ? <span className="hidden min-w-0 flex-1 truncate text-left typography-meta @min-[560px]:inline" title={details}>{fileLabel ? '· ' : ''}{details}</span> : null}
</>
);
const headerClass = 'w-full justify-start normal-case !pl-px !pr-2 text-[var(--tools-description)] hover:!bg-transparent active:!bg-transparent';
return (
<div className="relative z-0" data-live-turn-activity={turn.turnId}>
{settled ? (
<div className="chat-message-column @container">
<div className="mt-1">
<Button variant="ghost" size="sm" className={headerClass} onClick={onToggle}
aria-expanded={isExpanded} aria-controls={finalMessage ? `${contentId} ${finalContentId}` : contentId}>
{label}
</Button>
</div>
</div>
) : null}
<LiveActivityCollapse expanded={isExpanded} id={contentId}>
{turn.assistantMessages.map((message) => message === finalMessage ? null : renderMessage(message))}
</LiveActivityCollapse>
<LiveFinalActivityContext.Provider value={finalContext}>
{finalMessage ? renderMessage(finalMessage) : null}
</LiveFinalActivityContext.Provider>
</div>
);
}
@@ -7,6 +7,7 @@ interface TurnItemProps {
turn: Turn;
stickyUserHeader?: boolean;
renderMessage: (message: ChatMessageEntry) => React.ReactNode;
assistantContent?: React.ReactNode;
}
/**
@@ -23,7 +24,7 @@ const STICKY_HEADER_BACKGROUND: React.CSSProperties = {
'linear-gradient(to bottom, var(--surface-background) calc(100% - 0.75rem), transparent)',
};
const TurnItem: React.FC<TurnItemProps> = ({ turn, stickyUserHeader = true, renderMessage }) => {
const TurnItem: React.FC<TurnItemProps> = ({ turn, stickyUserHeader = true, renderMessage, assistantContent }) => {
return (
<section
className="relative w-full"
@@ -44,7 +45,7 @@ const TurnItem: React.FC<TurnItemProps> = ({ turn, stickyUserHeader = true, rend
renderMessage(turn.userMessage)
)}
<TurnAssistantBlock assistantMessages={turn.assistantMessages} renderMessage={renderMessage} />
{assistantContent ?? <TurnAssistantBlock assistantMessages={turn.assistantMessages} renderMessage={renderMessage} />}
</section>
);
};
@@ -0,0 +1,9 @@
import { createContext } from 'react';
/** Only the final message splits its non-text parts from its answer/footer. */
export const LiveFinalActivityContext = createContext<{
messageId: string;
expanded: boolean;
contentId: string;
animateCollapse: boolean;
} | null>(null);
@@ -171,7 +171,7 @@ and the send path reading the same grammar.
attached context is consumed. Commands that act on session or UI state
(`/undo`, `/redo`, `/compact`, `/timeline`, `/handoff-review`) take only
their command text and leave comments, files, and linked context attached;
commands that produce a prompt (`/btw` and the magic prompts) send that
magic prompt commands send that
context with the prompt they produce. Session actions are planned only when
a session exists, so typing one into a new-session draft stays on the normal
send path. A local command is never queued as text: queueing runs it
@@ -183,14 +183,30 @@ and the send path reading the same grammar.
draft. Two orderings are load-bearing: the debounced write is skipped once
while a draft is being restored, and a deleted draft's empty signature is
recorded before a queued write could resurrect it.
Fork replay text and files arrive in `input-store.pendingComposerRestore`,
addressed to the fork's runtime, directory, and session. The hook consumes
them after loading that identity's draft. Selection alone is not enough:
the deferred chat column can still show the source composer. Ordinary
pending text insertions keep their existing path in `ChatInput`.
- `state/useDraftTarget.ts` — the draft can target a directory that does not
exist yet (a worktree being created). It must survive not appearing in the
branch list, or the selector snaps back to the project root mid-creation. It
also owns the advisory dirty state for the selected directory, clearing it as
soon as the target changes so a warning never names a previous branch.
- `ui/DraftTargetSelectors.tsx` owns the controlled project/worktree picker
state and registers its application shortcuts locally. The selectors only
consume their shared prefix while the draft target UI is mounted.
state and registers its application shortcuts locally. The desktop project
picker is a searchable popup: it ranks the current projects with
`rankByQuery` over display label and path, keeps the query and the active
result as transient local state that resets on every close, and commits
through the existing project-change flow only on explicit activation.
Filtering changes the result area below the anchored input without moving
the search field. The worktree picker remains a Select; mobile keeps its
bottom sheets. The selectors only consume their shared prefix while the
draft target UI is mounted.
Keyboard selection returns focus to the current form's composer, including
when the selected value is unchanged.
- `ChatInput.tsx` maps Ctrl+N/P to the active command, skill, snippet, or
mention picker after its IME guard.
## Input recall ownership
@@ -216,6 +232,34 @@ session bucket, which adds attachments and keeps prompts a revert hid from the
timeline. A prompt present in both collapses to the persisted entry. Global
scope reads the persisted runtime bucket only.
## BTW composer
An empty `/btw` opens an unsent draft. `/btw <question>` opens BTW and sends
that question immediately after its own draft and model selection are active.
**By the way…** opens an unsent draft with Quote-formatted selection text.
The first send creates the fork; Enter follows the user's preference. Pending text and references then
move to the fork's draft identity. Normal and BTW drafts remain independent,
including in memory when persistence is disabled.
Both modes reuse `ComposerEditor` and `ModelControls`; BTW transitions put the
caret at the end. BTW copies the main model/effort once, including explicit
Default, and uses `plan` or the first selectable agent. Its controlled model
path only writes BTW selections. Attachments, goals, expansion, shell, and
agent selection and file/agent mention autocomplete are unavailable. Auto-accept is applied before the first send.
On mobile, model and effort controls sit in the input's upper-left row; the
footer only contains auto-accept and send/stop controls.
Escape closes menus first. Otherwise it returns to normal: an unsent BTW is
discarded with its text, references, selections and panel; a creating or real
fork is only collapsed. Neither exit sends, aborts, or deletes a server session,
nor consumes the main draft's files, queue, or linked context. Pending snippet
expansion belongs to the unsent panel. Discarding that panel invalidates the
send, and a runtime change prevents fork creation and stale UI recovery.
The unsent panel shows "Ask your question" until fork creation starts.
Existing panels hide titles. Promotion retains the existing internal title, without
transcript fetching or Small Model generation.
## Mobile
`state/useMobileComposerShell.ts` and `state/useMobileViewportPin.ts` are
@@ -247,10 +291,13 @@ suites that install module mocks are order-dependent.
## Enter preference
`keyboardPolicy.ts` owns the submission decision. Until the Chat setting is
changed, desktop Enter sends, mobile and focus mode require Ctrl/Cmd+Enter,
and Shift-modified Enter does not send. An explicit choice applies across
shared composers; Ctrl/Cmd+Enter sends in either configured mode.
`keyboardPolicy.ts` owns the submission decision. The expanded desktop composer
always inserts a newline with Enter, including Shift+Enter, and sends with
Ctrl/Cmd+Enter; it ignores the Enter-to-send preference. Outside expanded mode,
until the Chat setting is changed, desktop Enter sends, mobile requires
Ctrl/Cmd+Enter, and Shift-modified Enter does not send. An explicit choice
applies across the other shared composers; Ctrl/Cmd+Enter sends in either
configured mode.
CodeMirror's deferred mobile Enter loses modifier information. Untouched
settings restore Shift to keep the original policy. Once configured, with mobile
@@ -26,6 +26,10 @@ const enterPolicyCases: Array<[string, Partial<EnterKeyPolicyInput>, boolean]> =
['configured enabled Shift+Enter inserts a newline', { enterToSendConfigured: true, enterToSend: true, shiftKey: true }, false],
['configured disabled Enter inserts a newline', { enterToSendConfigured: true, enterToSend: false }, false],
['configured disabled Shift+Enter sends', { enterToSendConfigured: true, enterToSend: false, shiftKey: true }, true],
['expanded composer Enter inserts a newline when Enter-to-send is enabled', { isDesktopExpanded: true, enterToSendConfigured: true, enterToSend: true }, false],
['expanded composer Shift+Enter inserts a newline when Enter-to-send is disabled', { isDesktopExpanded: true, enterToSendConfigured: true, enterToSend: false, shiftKey: true }, false],
['expanded composer Ctrl+Enter sends despite Enter-to-send being disabled', { isDesktopExpanded: true, enterToSendConfigured: true, ctrlKey: true }, true],
['expanded composer Cmd+Enter sends despite Enter-to-send being enabled', { isDesktopExpanded: true, enterToSendConfigured: true, enterToSend: true, metaKey: true }, true],
['configured Ctrl+Enter always sends', { enterToSendConfigured: true, isMobile: true, isDesktopExpanded: true, shiftKey: true, ctrlKey: true }, true],
['configured Meta+Enter always sends', { enterToSendConfigured: true, isMobile: true, isDesktopExpanded: true, shiftKey: true, metaKey: true }, true],
];
@@ -33,8 +37,9 @@ const enterPolicyCases: Array<[string, Partial<EnterKeyPolicyInput>, boolean]> =
describe('Enter key policy', () => {
for (const surface of [{}, { isMobile: true }, { isDesktopExpanded: true }]) {
for (const modifiers of [{}, { ctrlKey: true }, { metaKey: true }, { ctrlKey: true, metaKey: true }]) {
test(`untouched Shift+Enter does not submit: ${JSON.stringify({ ...surface, ...modifiers })}`, () => {
expect(shouldSubmitEnter(policy({ ...surface, ...modifiers, shiftKey: true }))).toBe(false);
test(`untouched Shift+Enter only submits with a modifier in expanded mode: ${JSON.stringify({ ...surface, ...modifiers })}`, () => {
expect(shouldSubmitEnter(policy({ ...surface, ...modifiers, shiftKey: true })))
.toBe(Boolean(surface.isDesktopExpanded && (modifiers.ctrlKey || modifiers.metaKey)));
});
}
}
@@ -9,8 +9,10 @@ export interface EnterKeyPolicyInput {
}
export const shouldSubmitEnter = (input: EnterKeyPolicyInput): boolean => {
const enterSendsByDefault = !input.isMobile && !input.isDesktopExpanded;
const isCtrlEnter = input.ctrlKey || input.metaKey;
if (input.isDesktopExpanded) return isCtrlEnter;
const enterSendsByDefault = !input.isMobile;
if (!input.enterToSendConfigured) {
return !input.shiftKey && (enterSendsByDefault || isCtrlEnter);
}
@@ -125,3 +125,12 @@ describe('precedence and disabling', () => {
expect(at('|')).toBeNull();
});
});
test('BTW leaves file and agent references as text while retaining other pickers', () => {
const btw: TriggerContext = { inputMode: 'normal', mentionsEnabled: false };
expect(at('@src/file|', btw)).toBeNull();
expect(at('@plan|', btw)).toBeNull();
expect(at('#snippet|', btw)).toEqual({ kind: 'snippet', query: 'snippet' });
expect(at('@plan|')?.kind).toBe('mention');
});
@@ -30,6 +30,7 @@ export interface AutocompleteTrigger {
export interface TriggerContext {
/** Shell mode (`!cmd`) disables every picker. */
inputMode: 'normal' | 'shell';
mentionsEnabled?: boolean;
/** Whether the change that moved the caret came from a paste. */
inputSource?: FileMentionAutocompleteInputSource;
/** The text that change inserted, when known. */
@@ -106,6 +107,7 @@ function matchMention(
cursorPosition: number,
context: TriggerContext,
): AutocompleteTrigger | null {
if (context.mentionsEnabled === false) return null;
const query = getFileMentionAutocompleteQuery({
value,
cursorPosition,
@@ -0,0 +1,164 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { installHookTestDom } from '@/components/session/sidebar/test-utils/testDom';
import { readChatDraft, writeChatDraft, type ChatDraftIdentity } from '@/lib/chatDraftPersistence';
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import { useInputStore } from '@/sync/input-store';
import { useComposerDraft } from '../useComposerDraft';
const source: ChatDraftIdentity = { runtimeKey: 'runtime-a', directory: '/repo', sessionId: 'source' };
const fork: ChatDraftIdentity = { ...source, sessionId: 'fork' };
const replayFile = { url: 'data:text/plain;base64,aGVsbG8=', mimeType: 'text/plain', filename: 'replay.txt' };
function renderComposer(persistEnabled: boolean) {
const dom = installHookTestDom();
const originalRaf = globalThis.requestAnimationFrame;
const originalCancelRaf = globalThis.cancelAnimationFrame;
const frames = new Map<number, FrameRequestCallback>();
let frameId = 0;
globalThis.requestAnimationFrame = (callback) => {
frames.set(++frameId, callback);
return frameId;
};
globalThis.cancelAnimationFrame = (id) => { frames.delete(id); };
const root = createRoot(dom.container);
const restored: string[] = [];
const result = { text: '', mentions: new Set<string>(), restored };
function Probe({ identity }: { identity: ChatDraftIdentity }) {
const [message, setMessage] = React.useState('source draft @source.ts');
const messageRef = React.useRef(message);
const confirmedMentionsRef = React.useRef(new Set(['source.ts']));
React.useEffect(() => { messageRef.current = message; }, [message]);
useComposerDraft({
message, messageRef, setMessage, confirmedMentionsRef, identity, persistEnabled,
initialDraft: { text: '', identity: source },
onDraftRestored: (reason) => { result.restored.push(reason); },
});
result.text = message;
result.mentions = confirmedMentionsRef.current;
return null;
}
const render = (identity: ChatDraftIdentity) => {
act(() => { root.render(React.createElement(Probe, { identity })); });
};
render(source);
return {
result,
render,
flushFrames: () => {
const pending = [...frames.values()];
frames.clear();
act(() => { for (const callback of pending) callback(0); });
},
teardown: () => {
act(() => { root.unmount(); });
globalThis.requestAnimationFrame = originalRaf;
globalThis.cancelAnimationFrame = originalCancelRaf;
dom.restore();
},
};
}
beforeEach(() => {
getDeferredSafeStorage().removeItem('openchamber.chatDrafts.v2');
useInputStore.setState({ pendingComposerRestore: null });
useInputStore.getState().clearAttachedFiles();
});
describe('fork composer restoration', () => {
for (const persistEnabled of [true, false]) {
test(`waits for the rendered fork and preserves the source, persistence=${persistEnabled}`, () => {
writeChatDraft(fork, 'previous fork draft @old.ts', ['old.ts']);
useInputStore.getState().addRestoredAttachment({ ...replayFile, filename: 'source.txt' });
const sourceFiles = useInputStore.getState().attachedFiles;
const composer = renderComposer(persistEnabled);
try {
// Selection already changed, but the deferred chat column still renders source.
act(() => {
useInputStore.setState({ pendingComposerRestore: { target: fork, text: 'replay prompt', files: [replayFile] } });
});
expect(composer.result.text).toBe('source draft @source.ts');
expect(useInputStore.getState().attachedFiles).toBe(sourceFiles);
expect(useInputStore.getState().pendingComposerRestore).not.toBeNull();
composer.render(fork);
expect(composer.result.text).toBe('replay prompt');
expect(composer.result.mentions.size).toBe(0);
expect(useInputStore.getState().attachedFiles.map((file) => file.filename)).toEqual(['replay.txt']);
expect(useInputStore.getState().pendingComposerRestore).toBeNull();
composer.flushFrames();
expect(composer.result.restored).toContain('fork');
expect(readChatDraft(source).text).toBe(persistEnabled ? 'source draft @source.ts' : '');
composer.render(source);
expect(composer.result.text).toBe('source draft @source.ts');
expect(readChatDraft(fork).text).toBe(persistEnabled ? 'replay prompt' : '');
} finally {
composer.teardown();
}
});
}
test('waits through unrelated session, directory, and runtime renders', () => {
const composer = renderComposer(true);
try {
act(() => {
useInputStore.setState({ pendingComposerRestore: { target: fork, text: 'replay', files: [] } });
});
for (const identity of [
{ ...fork, sessionId: 'other' },
{ ...fork, directory: '/other' },
{ ...fork, runtimeKey: 'runtime-b' },
]) {
composer.render(identity);
expect(composer.result.text).toBe('');
expect(useInputStore.getState().pendingComposerRestore).not.toBeNull();
}
composer.render(fork);
expect(composer.result.text).toBe('replay');
expect(useInputStore.getState().pendingComposerRestore).toBeNull();
} finally {
composer.teardown();
}
});
test('persists a replay even when its text equals the outgoing source draft', async () => {
const composer = renderComposer(true);
try {
const text = composer.result.text;
act(() => {
useInputStore.setState({ pendingComposerRestore: { target: fork, text, files: [] } });
});
composer.render(fork);
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 550)); });
expect(readChatDraft(fork)).toEqual({ text, confirmedMentions: new Set() });
expect(readChatDraft(source)).toEqual({ text, confirmedMentions: new Set(['source.ts']) });
} finally {
composer.teardown();
}
});
test('restores file-only and empty prompts without keeping destination text or files', () => {
const composer = renderComposer(true);
try {
writeChatDraft(fork, 'stale destination text', []);
act(() => {
useInputStore.setState({ pendingComposerRestore: { target: fork, text: '', files: [replayFile] } });
});
composer.render(fork);
expect(composer.result.text).toBe('');
expect(useInputStore.getState().attachedFiles.map((file) => file.filename)).toEqual(['replay.txt']);
act(() => {
useInputStore.setState({ pendingComposerRestore: { target: fork, text: '', files: [] } });
});
expect(composer.result.text).toBe('');
expect(useInputStore.getState().attachedFiles).toEqual([]);
} finally {
composer.teardown();
}
});
});
@@ -12,13 +12,16 @@
*/
import React from 'react';
import { useInputStore } from '@/sync/input-store';
import {
clearChatDraft,
getChatDraftIdentityKey,
readChatDraft,
subscribeChatDraftDeletion,
writeChatDraft,
type ChatDraftIdentity,
type ChatDraftSnapshot,
} from '@/lib/chatDraftPersistence';
const PERSIST_DEBOUNCE_MS = 500;
@@ -46,14 +49,14 @@ export interface ComposerDraftOptions {
confirmedMentionsRef: React.RefObject<Set<string>>;
/** The draft this composer currently belongs to. */
identity: ChatDraftIdentity | null;
/** User setting: when off, drafts are discarded rather than stored. */
/** User setting: when off, drafts stay in memory without durable writes. */
persistEnabled: boolean;
/** The draft restored on mount, if any. */
initialDraft: { text: string; identity: ChatDraftIdentity | null };
/** Called when the composer switches to a different draft identity. */
onIdentityChange?: () => void;
/** Called after a non-empty draft is restored, to select its text. */
onDraftRestored?: () => void;
/** Called after restoring a saved draft or fork replay, to select its text. */
onDraftRestored?: (source: 'saved' | 'fork') => void;
}
export interface ComposerDraftControls {
@@ -62,6 +65,12 @@ export interface ComposerDraftControls {
* cleared composer must be stored before the send resolves.
*/
persistNow: (identity: ChatDraftIdentity | null, draft: string) => void;
/** Consume a command in the current draft while opening another draft. */
handoffDraft: (identity: ChatDraftIdentity | null, draft: string | null) => void;
/** Restore a draft after a failed send without using persistence as state. */
restoreDraft: (identity: ChatDraftIdentity | null, draft: string, confirmedMentions: Set<string>) => void;
/** Move an in-memory draft to an identity materialized during an async flow. */
migrateDraft: (from: ChatDraftIdentity | null, to: ChatDraftIdentity | null) => void;
}
export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftControls {
@@ -81,6 +90,16 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
const skipNextPersistRef = React.useRef(false);
const lastPersistedRef = React.useRef<Map<string, string>>(new Map());
const currentIdentityRef = React.useRef<ChatDraftIdentity | null>(initialDraft.identity);
const draftMemoryRef = React.useRef(new Map<string, ChatDraftSnapshot>());
const skipOutgoingDraftRef = React.useRef(false);
const initialKey = initialDraft.identity ? getChatDraftIdentityKey(initialDraft.identity) : null;
if (persistEnabled && initialKey && !draftMemoryRef.current.has(initialKey) && initialDraft.text) {
draftMemoryRef.current.set(initialKey, {
text: initialDraft.text,
confirmedMentions: new Set(confirmedMentionsRef.current),
});
}
const pendingComposerRestore = useInputStore((state) => state.pendingComposerRestore);
// Callbacks reach the effects through a ref so a caller passing inline
// functions does not re-run the persistence effects on every render.
@@ -91,8 +110,13 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
currentIdentityRef.current = identity;
}, [identity]);
React.useEffect(() => {
// Persistence off keeps in-memory drafts, but must not retain old disk copies.
if (!persistEnabled && identity) clearChatDraft(identity);
}, [identity, persistEnabled]);
const persistNow = React.useCallback((target: ChatDraftIdentity | null, draft: string) => {
if (!target) return;
if (!persistEnabled || !target) return;
const key = getChatDraftIdentityKey(target);
// Only keep confirmed mentions the draft still contains: a mention the
@@ -108,7 +132,7 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
writeChatDraft(target, draft, activeMentions);
lastPersistedRef.current.set(key, signature);
}, [confirmedMentionsRef]);
}, [confirmedMentionsRef, persistEnabled]);
const clearPending = React.useCallback(() => {
if (!persistTimerRef.current) return;
@@ -125,11 +149,12 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
if (!initialDraft.text) return;
if (!persistEnabled) {
messageRef.current = '';
confirmedMentionsRef.current = new Set();
setMessage('');
writeChatDraft(initialDraft.identity, '', []);
return;
}
requestAnimationFrame(() => callbacksRef.current.onDraftRestored?.());
requestAnimationFrame(() => callbacksRef.current.onDraftRestored?.('saved'));
// Runs once; the initial draft is captured at mount by design.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [persistEnabled]);
@@ -149,27 +174,55 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
// debounced effect must not immediately write it back out.
skipNextPersistRef.current = true;
if (!persistEnabled) {
setMessage('');
confirmedMentionsRef.current = new Set();
return;
if (!skipOutgoingDraftRef.current && previousKey) {
const outgoing = { text: messageRef.current, confirmedMentions: new Set(confirmedMentionsRef.current) };
draftMemoryRef.current.set(previousKey, outgoing);
if (persistEnabled) persistNow(previous, outgoing.text);
}
skipOutgoingDraftRef.current = false;
persistNow(previous, messageRef.current);
const restored = readChatDraft(identity);
const restored = (currentKey && draftMemoryRef.current.get(currentKey))
|| (persistEnabled ? readChatDraft(identity) : { text: '', confirmedMentions: new Set<string>() });
messageRef.current = restored.text;
setMessage(restored.text);
confirmedMentionsRef.current = restored.confirmedMentions;
confirmedMentionsRef.current = new Set(restored.confirmedMentions);
if (restored.text) {
requestAnimationFrame(() => callbacksRef.current.onDraftRestored?.());
requestAnimationFrame(() => callbacksRef.current.onDraftRestored?.('saved'));
}
}, [clearPending, confirmedMentionsRef, identity, messageRef, persistEnabled, persistNow, setMessage]);
// The chat column can still show the source after navigation selects a fork.
// Apply its replay only after the destination's draft has been loaded above.
React.useEffect(() => {
if (!pendingComposerRestore) return;
const input = useInputStore.getState();
const pending = input.consumePendingComposerRestore(identity);
if (!pending) return;
clearPending();
skipNextPersistRef.current = true;
messageRef.current = pending.text;
confirmedMentionsRef.current = new Set();
setMessage(pending.text);
// Equal source/replay text need not trigger another render to persist.
if (persistEnabled) persistNow(pending.target, pending.text);
input.clearAttachedFiles();
for (const file of pending.files) input.addRestoredAttachment(file);
requestAnimationFrame(() => {
const current = currentIdentityRef.current;
if (current && getChatDraftIdentityKey(current) === getChatDraftIdentityKey(pending.target)) {
callbacksRef.current.onDraftRestored?.('fork');
}
});
}, [clearPending, confirmedMentionsRef, identity, messageRef, pendingComposerRestore, persistEnabled, persistNow, setMessage]);
// A draft deleted elsewhere (session deleted, drafts cleared) clears the
// composer if it is the one on screen.
React.useEffect(() => subscribeChatDraftDeletion((deleted) => {
const deletedKey = getChatDraftIdentityKey(deleted);
// Record the empty signature so a queued write does not resurrect it.
lastPersistedRef.current.set(deletedKey, draftSignature('', []));
draftMemoryRef.current.set(deletedKey, { text: '', confirmedMentions: new Set() });
const current = currentIdentityRef.current;
if (!current || getChatDraftIdentityKey(current) !== deletedKey) return;
@@ -183,11 +236,7 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
// Debounced write while typing.
React.useEffect(() => {
if (!persistEnabled) {
clearPending();
persistNow(identity, '');
return;
}
if (!persistEnabled) return;
if (skipNextPersistRef.current) {
skipNextPersistRef.current = false;
@@ -226,5 +275,62 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
};
}, [clearPending, messageRef, persistEnabled, persistNow]);
return { persistNow };
const restoreDraft = React.useCallback((target: ChatDraftIdentity | null, draft: string, confirmedMentions: Set<string>) => {
const targetKey = target ? getChatDraftIdentityKey(target) : null;
const current = currentIdentityRef.current;
const isCurrent = target && current && getChatDraftIdentityKey(target) === getChatDraftIdentityKey(current);
const existing = isCurrent
? { text: messageRef.current, confirmedMentions: confirmedMentionsRef.current }
: (targetKey && draftMemoryRef.current.get(targetKey)) || (persistEnabled ? readChatDraft(target) : null);
const text = existing?.text && existing.text !== draft ? `${existing.text}\n\n${draft}` : draft;
const mentions = new Set([...(existing?.confirmedMentions ?? []), ...confirmedMentions]);
if (targetKey) draftMemoryRef.current.set(targetKey, { text, confirmedMentions: mentions });
if (isCurrent) {
messageRef.current = text;
confirmedMentionsRef.current = new Set(mentions);
setMessage(text);
}
if (persistEnabled && target) {
writeChatDraft(target, text, mentions);
lastPersistedRef.current.set(getChatDraftIdentityKey(target), draftSignature(text, mentions));
}
}, [confirmedMentionsRef, messageRef, persistEnabled, setMessage]);
const handoffDraft = React.useCallback((target: ChatDraftIdentity | null, draft: string | null) => {
const targetKey = target ? getChatDraftIdentityKey(target) : null;
if (targetKey && draft !== null) draftMemoryRef.current.set(targetKey, { text: draft, confirmedMentions: new Set() });
const currentKey = currentIdentityRef.current ? getChatDraftIdentityKey(currentIdentityRef.current) : null;
if (targetKey === currentKey) {
if (draft !== null) {
messageRef.current = draft;
confirmedMentionsRef.current = new Set();
setMessage(draft);
persistNow(target, draft);
}
return;
}
if (currentKey) draftMemoryRef.current.set(currentKey, { text: '', confirmedMentions: new Set() });
persistNow(currentIdentityRef.current, '');
skipOutgoingDraftRef.current = true;
messageRef.current = '';
confirmedMentionsRef.current = new Set();
setMessage('');
}, [confirmedMentionsRef, messageRef, persistNow, setMessage]);
const migrateDraft = React.useCallback((from: ChatDraftIdentity | null, to: ChatDraftIdentity | null) => {
if (!to) return;
const current = currentIdentityRef.current;
const draft = from && current && getChatDraftIdentityKey(from) === getChatDraftIdentityKey(current)
? { text: messageRef.current, confirmedMentions: new Set(confirmedMentionsRef.current) }
: (from && draftMemoryRef.current.get(getChatDraftIdentityKey(from)))
|| (persistEnabled ? readChatDraft(from) : null);
if (!draft) return;
draftMemoryRef.current.set(getChatDraftIdentityKey(to), { text: draft.text, confirmedMentions: new Set(draft.confirmedMentions) });
if (persistEnabled) {
writeChatDraft(to, draft.text, draft.confirmedMentions);
lastPersistedRef.current.set(getChatDraftIdentityKey(to), draftSignature(draft.text, draft.confirmedMentions));
}
}, [confirmedMentionsRef, messageRef, persistEnabled]);
return { persistNow, handoffDraft, restoreDraft, migrateDraft };
}
@@ -24,6 +24,7 @@ import { ComposerActionButtons } from './ComposerActionButtons';
import { ComposerAttachmentControls } from './ComposerAttachmentControls';
import { FocusModeButton } from './FocusModeButton';
import { PermissionAutoAcceptButton } from './PermissionAutoAcceptButton';
import type { BtwSelection } from '@/stores/useBtwStore';
const MemoModelControls = React.memo(ModelControls);
const MemoComposerDictation = React.memo(ComposerDictation);
@@ -68,6 +69,9 @@ export interface ComposerFooterProps {
onDictationInsert: (text: string) => void;
onDictationInsertAndSend: (text: string) => void;
onDictationContentHeightChange: (height: number | null) => void;
isBtw?: boolean;
modelSessionId?: string | null;
btwSelection: BtwSelection;
}
export function ComposerFooter(props: ComposerFooterProps) {
@@ -109,6 +113,9 @@ export function ComposerFooter(props: ComposerFooterProps) {
onDictationInsert,
onDictationInsertAndSend,
onDictationContentHeightChange,
isBtw = false,
modelSessionId,
btwSelection,
} = props;
const gitProvider = useGitProvider(directory);
@@ -130,7 +137,7 @@ export function ComposerFooter(props: ComposerFooterProps) {
<>
<div className="flex w-full items-center justify-between gap-x-1.5">
<div className="composer-mobile-actions flex items-center gap-x-2 pl-1">
<ComposerAttachmentControls
{!isBtw ? <ComposerAttachmentControls
isVSCode={isVSCode}
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
@@ -142,7 +149,7 @@ export function ComposerFooter(props: ComposerFooterProps) {
openLinearPicker={onOpenLinearPicker}
onOpenSettings={onOpenSettings}
onOpenMobileSheet={onOpenAttachSheet}
/>
/> : null}
<PermissionAutoAcceptButton
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
@@ -150,18 +157,18 @@ export function ComposerFooter(props: ComposerFooterProps) {
permissionAutoAcceptEnabled={permissionAutoAcceptEnabled}
handlePermissionAutoAcceptToggle={onTogglePermissionAutoAccept}
/>
<SessionGoalButton
{!isBtw ? <SessionGoalButton
sessionId={currentSessionId}
directory={directory}
draftOpen={newSessionDraftOpen}
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
/>
<SessionGoalObjectiveCounter length={messageLength} />
/> : null}
{!isBtw ? <SessionGoalObjectiveCounter length={messageLength} /> : null}
</div>
<div className="flex items-center min-w-0 gap-x-1 justify-end">
<div className="flex items-center gap-x-1 flex-shrink-0">
<button
{!isBtw ? <button
type="button"
className={footerIconButtonClass}
// Keep the soft keyboard open (same guard as
@@ -180,7 +187,7 @@ export function ComposerFooter(props: ComposerFooterProps) {
aria-label={t('chat.dictation.start')}
>
<Icon name="mic" className={cn(iconSizeClass, 'text-current')} />
</button>
</button> : null}
<ComposerActionButtons
isMobile={isMobile}
footerIconButtonClass={footerIconButtonClass}
@@ -202,7 +209,7 @@ export function ComposerFooter(props: ComposerFooterProps) {
) : (
<>
<div className={cn("flex items-center flex-shrink-0", footerGapClass)}>
<ComposerAttachmentControls
{!isBtw ? <ComposerAttachmentControls
isVSCode={isVSCode}
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
@@ -213,13 +220,13 @@ export function ComposerFooter(props: ComposerFooterProps) {
showLinearPicker={showLinearPicker}
openLinearPicker={onOpenLinearPicker}
onOpenSettings={onOpenSettings}
/>
<FocusModeButton
/> : null}
{!isBtw ? <FocusModeButton
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
isExpandedInput={isExpandedInput}
onToggle={onToggleExpandedInput}
/>
/> : null}
<PermissionAutoAcceptButton
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
@@ -228,19 +235,19 @@ export function ComposerFooter(props: ComposerFooterProps) {
handlePermissionAutoAcceptToggle={onTogglePermissionAutoAccept}
withTooltip
/>
<SessionGoalButton
{!isBtw ? <SessionGoalButton
sessionId={currentSessionId}
directory={directory}
draftOpen={newSessionDraftOpen}
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
withTooltip
/>
<SessionGoalObjectiveCounter length={messageLength} />
/> : null}
{!isBtw ? <SessionGoalObjectiveCounter length={messageLength} /> : null}
</div>
<div className={cn('flex items-center flex-1 justify-end', footerGapClass, 'md:gap-x-3')}>
<MemoModelControls className={cn('flex-1 min-w-0 justify-end')} />
<MemoComposerDictation
{isBtw ? <ModelControls className="flex-1 min-w-0 justify-end" sessionId={modelSessionId ?? null} selection={btwSelection} /> : <MemoModelControls className={cn('flex-1 min-w-0 justify-end')} />}
{!isBtw ? <MemoComposerDictation
radius={chatInputRadius}
isMobile={isMobile}
footerIconButtonClass={footerIconButtonClass}
@@ -250,7 +257,7 @@ export function ComposerFooter(props: ComposerFooterProps) {
onInsert={onDictationInsert}
onInsertAndSend={onDictationInsertAndSend}
onContentHeightChange={onDictationContentHeightChange}
/>
/> : null}
<ComposerActionButtons
isMobile={isMobile}
footerIconButtonClass={footerIconButtonClass}
@@ -1,10 +1,11 @@
/**
* Where a new session will run: the project and the directory within it.
*
* Desktop uses inline selects; mobile uses bottom sheets, because a native
* select over a keyboard-resized viewport is unusable. Both render the same
* options from the same hook, and both offer creating a worktree inline so the
* user does not have to leave the draft to make one.
* Desktop uses a searchable project popup and an inline branch/worktree
* select; mobile uses bottom sheets, because a native select over a
* keyboard-resized viewport is unusable. Both render the same options from
* the same hook, and both offer creating a worktree inline so the user does
* not have to leave the draft to make one.
*/
import React from 'react';
@@ -12,8 +13,19 @@ import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { Input } from '@/components/ui/input';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { shouldDismissDropdown } from '@/components/ui/dropdown-navigation';
import { Popover } from '@base-ui/react/popover';
import { cn } from '@/lib/utils';
import { dropdownMenuPopupClass } from '@/components/ui/dropdown-menu.styles';
import {
Command,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { handleDropdownNavigationKey, shouldDismissDropdown } from '@/components/ui/dropdown-navigation';
import { isIMECompositionEvent } from '@/lib/ime';
import {
Select,
SelectContent,
@@ -28,6 +40,7 @@ import { useI18n } from '@/lib/i18n';
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
import { shortcutRegistry } from '@/lib/shortcuts';
import { useKeybind } from '@/hooks/useKeybind';
import type { Theme } from '@/types/theme';
import { normalizePath } from '../attachments/filePaths';
@@ -46,6 +59,14 @@ export interface DraftTargetProps {
selectedBranchLabel: string | null;
selectedBranchIsKnown: boolean;
hasUncommittedChanges: boolean;
/**
* Whether the dirty warning may announce itself by opening its tooltip
* unprompted. Off for a draft the app opened on its own at boot: that
* draft is often only a placeholder until the last session restores, and
* a tooltip on an otherwise empty screen reads as a glitch. The warning
* icon still shows on desktop and the tooltip stays reachable by hover.
*/
announceDirtyState: boolean;
projectRootBranchOption: BranchOption | null;
worktreeBranchOptions: readonly BranchOption[];
branchItems: readonly BranchOption[];
@@ -100,26 +121,27 @@ const DIRTY_TOOLTIP_FLASH_MS = 5000;
/**
* Opens the tooltip for a few seconds when the dirty state first appears, so
* the warning is seen without hovering, then hands control back to hover.
* Only when the draft may announce itself — see `announceDirtyState`.
*/
function useDirtyFlashTooltip(hasUncommittedChanges: boolean) {
function useDirtyFlashTooltip(hasUncommittedChanges: boolean, announce: boolean) {
const [open, setOpen] = React.useState(false);
React.useEffect(() => {
if (!hasUncommittedChanges) {
if (!hasUncommittedChanges || !announce) {
setOpen(false);
return;
}
setOpen(true);
const timer = window.setTimeout(() => setOpen(false), DIRTY_TOOLTIP_FLASH_MS);
return () => window.clearTimeout(timer);
}, [hasUncommittedChanges]);
}, [announce, hasUncommittedChanges]);
return { open, onOpenChange: setOpen };
}
export function DraftTargetSelectors(props: DraftTargetProps) {
const { t } = useI18n();
const dirtyTooltip = useDirtyFlashTooltip(props.hasUncommittedChanges);
const dirtyTooltip = useDirtyFlashTooltip(props.hasUncommittedChanges, props.announceDirtyState);
const {
projects,
selectedProject,
@@ -136,17 +158,53 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
theme,
} = props;
const [openPicker, setOpenPicker] = React.useState<'project' | 'worktree' | null>(null);
const [projectQuery, setProjectQuery] = React.useState('');
const [projectActiveId, setProjectActiveId] = React.useState<string | null>(null);
const [projectFocusReturn, setProjectFocusReturn] = React.useState(false);
const projectTriggerRef = React.useRef<HTMLButtonElement>(null);
const worktreeTriggerRef = React.useRef<HTMLButtonElement>(null);
// Controlled Select closes can omit finalFocus's interaction type.
const keyboardCloseRef = React.useRef(false);
const getComposerInput = () => projectTriggerRef.current?.closest('form')?.querySelector<HTMLElement>('[data-chat-input="true"] .cm-content');
const getFinalFocus = () => keyboardCloseRef.current ? getComposerInput() : true;
const projectSearchRef = React.useRef<HTMLInputElement>(null);
// Preserve Select's dialog portal and main-area containment.
const [projectPortalContainer, setProjectPortalContainer] = React.useState<HTMLElement | null>(null);
const [projectCollisionBoundary, setProjectCollisionBoundary] = React.useState<Element | null>(null);
const syncProjectPopupContainers = React.useCallback((target: EventTarget | null) => {
const element = target instanceof HTMLElement ? target : null;
const dialog = element?.closest('[data-slot="dialog-content"], [role="dialog"]');
setProjectPortalContainer(dialog instanceof HTMLElement ? dialog : null);
setProjectCollisionBoundary(element?.closest('main') ?? null);
}, []);
// The popover owns no shortcut suspension (unlike Select/DropdownMenu
// wrappers), so suspend global shortcuts while the project popup is
// open and restore them on close/unmount.
const projectSuspendRef = React.useRef<(() => void) | null>(null);
React.useEffect(() => {
if (openPicker !== 'project') return;
projectSuspendRef.current?.();
projectSuspendRef.current = shortcutRegistry.suspend();
return () => {
projectSuspendRef.current?.();
projectSuspendRef.current = null;
};
}, [openPicker]);
const handlePickerKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
if (openPicker === null || !shouldDismissDropdown(event)) return;
event.preventDefault();
event.stopPropagation();
keyboardCloseRef.current = true;
setOpenPicker(null);
};
useKeybind('open_draft_project_picker', () => {
projectTriggerRef.current?.focus();
setProjectFocusReturn(false);
setProjectQuery('');
setProjectActiveId(
projects.some((project) => project.id === selectedProject.id) ? selectedProject.id : null,
);
setOpenPicker('project');
});
useKeybind('open_draft_worktree_picker', () => {
@@ -155,11 +213,43 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
setOpenPicker('worktree');
});
const filteredProjects = React.useMemo(
() => openPicker === 'project'
? rankByQuery(projects, projectQuery, (project) => [getProjectDisplayLabel(project), project.path])
: projects,
[openPicker, projects, projectQuery],
);
// Transient search must never survive to the next opening, including
// picker switches and draft unmounts.
React.useEffect(() => {
if (openPicker !== 'project') {
setProjectQuery('');
setProjectActiveId(null);
}
}, [openPicker]);
// After a query or project-list change, retain the active ID only
// while it stays visible; otherwise fall to the first result (or none
// when the list is empty) so Enter cannot commit a hidden row.
React.useEffect(() => {
if (openPicker !== 'project') return;
setProjectActiveId((current) => {
if (current && filteredProjects.some((project) => project.id === current)) return current;
return filteredProjects[0]?.id ?? null;
});
}, [filteredProjects, openPicker]);
const handleProjectChange = (projectId: string) => {
onProjectChange(projectId);
setProjectFocusReturn(true);
setOpenPicker(null);
};
const handleProjectSelect = (projectId: string) => {
if (!filteredProjects.some((project) => project.id === projectId)) return;
handleProjectChange(projectId);
};
const handleDirectoryChange = (directory: string) => {
onDirectoryChange(directory);
setOpenPicker(null);
@@ -167,39 +257,161 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
return (
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 px-0.5">
<Select
value={selectedProject.id}
{/* Plain popover (not a menu): the popup owns a combobox +
listbox, so menu/menuitem semantics would be wrong here. */}
<Popover.Root
open={openPicker === 'project'}
onOpenChange={(open) => setOpenPicker(open ? 'project' : null)}
onValueChange={handleProjectChange}
disableGlobalShortcuts
onOpenChange={(open, eventDetails) => {
if (open) {
setProjectFocusReturn(false);
// Seed the active row from the committed project so
// Enter without typing repeats the current choice.
// The clamp below keeps it when still visible and
// falls to the first result otherwise.
setProjectQuery('');
setProjectActiveId(
projects.some((project) => project.id === selectedProject.id)
? selectedProject.id
: null,
);
setOpenPicker('project');
return;
}
setProjectQuery('');
setProjectActiveId(null);
const reason = eventDetails?.reason;
setProjectFocusReturn(reason === 'escape-key');
setOpenPicker(null);
}}
onOpenChangeComplete={(open) => {
// Return focus after Base UI finishes closing so typing
// continues in this form's composer, including reselection.
if (!open && projectFocusReturn) {
(getComposerInput() ?? projectTriggerRef.current)?.focus();
setProjectFocusReturn(false);
}
// Focus the search once the popup mounts; the opening
// shortcut focuses the trigger first.
if (open && openPicker === 'project') projectSearchRef.current?.focus();
}}
>
<SelectTrigger
<Popover.Trigger
render={
<Button
ref={projectTriggerRef}
onKeyDown={handlePickerKeyDown}
variant="ghost"
size="sm"
className="h-7 min-w-0 w-fit max-w-[42vw] sm:max-w-[18rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
aria-haspopup="dialog"
className="h-7 min-w-0 w-fit max-w-[42vw] justify-start gap-1 px-1.5 normal-case sm:max-w-[18rem]"
onPointerDownCapture={(event) => syncProjectPopupContainers(event.currentTarget)}
onFocusCapture={(event) => syncProjectPopupContainers(event.currentTarget)}
/>
}
>
<SelectValue>
<span className="flex min-w-0 items-center gap-1.5">
{selectedProject.kind === 'chat'
? <span className="truncate">{t('chat.chatInput.chooseProject')}</span>
? <span className="truncate typography-ui-label">{t('chat.chatInput.chooseProject')}</span>
: <ProjectLabel project={selectedProject} theme={theme} />}
</SelectValue>
</SelectTrigger>
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain fitContent onKeyDown={handlePickerKeyDown}>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id} showSelectedBackground={false} className="max-w-[24rem] truncate">
<Icon name="arrow-down-s" className="size-4 shrink-0 opacity-50" />
</span>
</Popover.Trigger>
<Popover.Portal container={projectPortalContainer ?? undefined}>
{/* side="bottom" anchors the popup's top edge at the
trigger: the search field stays fixed at the
selector's level while filtering, and only the
results area below changes height. Disabling side
flips keeps the input stationary during filtering.
Horizontal shifting keeps the popup inside main. The
--available-height cap comes free from the shared
popup class, so the list scrolls within the space
below the trigger. */}
<Popover.Positioner
side="bottom"
align="start"
sideOffset={4}
collisionAvoidance={{ side: 'none' }}
collisionBoundary={projectCollisionBoundary ?? undefined}
className="app-region-no-drag z-50"
>
<Popover.Popup
role="dialog"
aria-label={t('chat.chatInput.draftPicker.projectTitle')}
className={cn(dropdownMenuPopupClass, 'flex w-72 max-w-[calc(100vw-2rem)] flex-col p-0')}
initialFocus={false}
finalFocus={false}
>
{/* Filtering and ordering are owned by rankByQuery above;
cmdk's own filter would re-filter and reorder the
already-ranked rows. */}
<Command
className="min-h-0 flex-1"
shouldFilter={false}
value={projectActiveId ?? undefined}
onValueChange={setProjectActiveId}
>
<CommandInput
ref={projectSearchRef}
aria-label={t('chat.chatInput.draftPicker.searchProjects')}
placeholder={t('chat.chatInput.draftPicker.searchProjects')}
value={projectQuery}
onValueChange={setProjectQuery}
onKeyDown={(event) => {
// Command owns active-item navigation, so only
// translate the repository's Ctrl+N/P
// convention into arrows at the input.
// IME-composing keys must never move the
// active row or dismiss the popup.
if (isIMECompositionEvent(event)) {
event.stopPropagation();
return;
}
handleDropdownNavigationKey(event, (navigationKey) => {
event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', {
key: navigationKey,
bubbles: true,
cancelable: true,
}));
});
}}
/>
<CommandList label={t('chat.chatInput.draftPicker.projectTitle')}>
{filteredProjects.length === 0 ? (
<div role="status" className="px-3 py-6 text-center typography-ui-label text-muted-foreground">
{t('chat.chatInput.draftPicker.noProjectsFound')}
</div>
) : (
filteredProjects.map((project) => (
<CommandItem
key={project.id}
value={project.id}
onSelect={handleProjectSelect}
aria-current={project.id === selectedProject.id ? true : undefined}
className="max-w-full"
>
<span className="min-w-0 flex-1 truncate">
<ProjectLabel project={project} theme={theme} />
</SelectItem>
))}
</SelectContent>
</Select>
</span>
{project.id === selectedProject.id ? (
<Icon name="check" className="size-4 shrink-0 text-muted-foreground" />
) : null}
</CommandItem>
))
)}
</CommandList>
</Command>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
{showBranchSelector ? (
<Select
value={selectedDirectory ?? branchItems[0]?.value ?? normalizePath(selectedProject.path) ?? ''}
open={openPicker === 'worktree'}
onOpenChange={(open) => setOpenPicker(open ? 'worktree' : null)}
onOpenChange={(open, details) => {
keyboardCloseRef.current = !open && details.event.type === 'keydown';
setOpenPicker(open ? 'worktree' : null);
}}
onValueChange={handleDirectoryChange}
disableGlobalShortcuts
>
@@ -229,7 +441,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
</TooltipContent>
) : null}
</Tooltip>
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48" onKeyDown={handlePickerKeyDown}>
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48" onKeyDown={handlePickerKeyDown} finalFocus={getFinalFocus}>
{projectRootBranchOption ? (
<SelectGroup>
<SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel>
@@ -271,12 +483,11 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
/** Mobile: buttons that open the bottom sheets below. */
export function MobileDraftTargetTriggers(
props: Pick<DraftTargetProps, 'selectedProject' | 'selectedBranchLabel' | 'showBranchSelector' | 'hasUncommittedChanges' | 'theme'>
props: Pick<DraftTargetProps, 'selectedProject' | 'selectedBranchLabel' | 'showBranchSelector' | 'theme'>
& { onOpenPicker: (picker: 'project' | 'branch') => void },
) {
const { t } = useI18n();
const { selectedProject, selectedBranchLabel, showBranchSelector, hasUncommittedChanges, theme, onOpenPicker } = props;
const dirtyTooltip = useDirtyFlashTooltip(hasUncommittedChanges);
const { selectedProject, selectedBranchLabel, showBranchSelector, theme, onOpenPicker } = props;
return (
<div className="mb-1.5 flex min-w-0 items-center gap-x-2 px-0.5">
@@ -286,35 +497,19 @@ export function MobileDraftTargetTriggers(
onClick={() => onOpenPicker('project')}
>
{selectedProject.kind === 'chat'
? <span className="truncate">{t('chat.chatInput.chooseProject')}</span>
? <span className="truncate typography-ui-label">{t('chat.chatInput.chooseProject')}</span>
: <ProjectLabel project={selectedProject} theme={theme} />}
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
{showBranchSelector ? (
<Tooltip open={dirtyTooltip.open} onOpenChange={dirtyTooltip.onOpenChange}>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex h-7 min-w-0 max-w-[48vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]"
onClick={() => onOpenPicker('branch')}
>
{hasUncommittedChanges ? (
<Icon
name="alert"
className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]"
aria-label={t('chat.draftDirtyNotice.indicatorAria')}
/>
) : null}
<span className="truncate">{selectedBranchLabel ?? t('chat.chatInput.branch')}</span>
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
</TooltipTrigger>
{hasUncommittedChanges ? (
<TooltipContent showArrow side="top" sideOffset={8} className="max-w-72">
<span className="block whitespace-pre-line">{t('chat.draftDirtyNotice.tooltip')}</span>
</TooltipContent>
) : null}
</Tooltip>
) : null}
</div>
);
@@ -103,8 +103,8 @@ const STYLE_CLASS: Record<AnyStyle, string> = {
mentionAgent: 'text-[var(--status-success)]',
mentionCommand: 'text-[var(--primary)]',
mentionSnippet: 'text-[var(--status-warning)]',
code: 'rounded-[6px] bg-[var(--markdown-inline-code-bg)] text-[var(--markdown-inline-code)]',
codeFence: 'bg-[var(--surface-subtle)] text-[var(--markdown-inline-code)]',
code: 'rounded-[6px] bg-[var(--surface-muted)]',
codeFence: 'bg-[var(--surface-subtle)]',
// A `~path` is written for the reader's benefit, not to attach anything —
// it takes the same colour as a file mention, since it names the same kind
// of thing.
@@ -1,10 +1,7 @@
import { describe, expect, test } from 'bun:test';
import {
CHAT_LIST_ANCHOR_OFFSET,
getAnchoredTurnMetrics,
getRowBottom,
resolveChatListAnchoredEndSpace,
resolveRealContentEndOffset,
resolveTimelineIsAtEnd,
type TimelineListMeasurementState,
@@ -48,142 +45,6 @@ describe('getRowBottom', () => {
});
});
describe('getAnchoredTurnMetrics', () => {
test('returns null for an empty timeline', () => {
const state = buildState({ positions: [], sizes: [] });
expect(getAnchoredTurnMetrics({
state,
anchorIndex: 0,
composerOverlayHeight: 180,
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
})).toBeNull();
});
test('treats the active turn as fitting when it fits above the composer', () => {
const state = buildState({
positions: [0, 300, 460],
sizes: [240, 80, 140],
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.turnHeight).toBe(300);
expect(metrics?.usableViewportHeight).toBe(564);
expect(metrics?.overflowsUsableViewport).toBe(false);
expect(metrics?.targetScrollToRevealEnd).toBe(36);
expect(metrics?.scrollDeltaToRevealEnd).toBe(36);
});
test('targets the real row end instead of any temporary reserved tail', () => {
const state = buildState({
positions: [0, 1720, 1880],
sizes: [1600, 80, 120],
scroll: 1900,
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.lastBottom).toBe(2000);
expect(metrics?.targetScrollToRevealEnd).toBe(1436);
expect(metrics?.scrollDeltaToRevealEnd).toBe(0);
});
test('reports overflow only for the current anchored turn', () => {
const state = buildState({
positions: [0, 900, 1180],
sizes: [800, 220, 300],
scroll: 900,
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.turnHeight).toBe(580);
expect(metrics?.usableViewportHeight).toBe(564);
expect(metrics?.overflowsUsableViewport).toBe(true);
});
test('returns the minimal positive scroll delta needed to reveal the turn end', () => {
const state = buildState({
positions: [0, 900, 1180],
sizes: [800, 220, 360],
scroll: 900,
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.lastBottom).toBe(1540);
expect(metrics?.visibleUsableBottom).toBe(1464);
expect(metrics?.scrollDeltaToRevealEnd).toBe(76);
});
test('subtracts composer height from usable viewport height', () => {
const state = buildState({
positions: [0, 300],
sizes: [120, 470],
scrollLength: 700,
});
const withoutComposer = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 0,
anchorOffset: 16,
});
const withComposer = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 220,
anchorOffset: 16,
});
expect(withoutComposer?.overflowsUsableViewport).toBe(false);
expect(withComposer?.overflowsUsableViewport).toBe(true);
});
test('clamps an out-of-range anchor index to the last row', () => {
const state = buildState({
positions: [0, 300],
sizes: [240, 80],
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 99,
composerOverlayHeight: 0,
anchorOffset: 16,
});
expect(metrics?.anchorTop).toBe(300);
expect(metrics?.turnHeight).toBe(80);
});
});
describe('resolveRealContentEndOffset', () => {
test('puts the last row bottom just above the composer overlay', () => {
const state = buildState({
@@ -209,18 +70,14 @@ describe('resolveRealContentEndOffset', () => {
expect(resolveRealContentEndOffset({ state, composerOverlayHeight: 180 })).toBe(0);
});
test('reserves extra slack below the content when asked', () => {
test('counts the footer rendered after the last row as real content', () => {
const state = buildState({
positions: [0, 1000],
sizes: [1000, 200],
scrollLength: 700,
});
expect(resolveRealContentEndOffset({
state,
composerOverlayHeight: 180,
extraInset: CHAT_LIST_ANCHOR_OFFSET,
})).toBe(696);
expect(resolveRealContentEndOffset({ state, composerOverlayHeight: 180, footerSize: 120 })).toBe(800);
});
test('returns null for an empty timeline and for unmeasured last rows', () => {
@@ -237,10 +94,13 @@ describe('resolveRealContentEndOffset', () => {
});
describe('resolveTimelineIsAtEnd', () => {
test('uses a tight distance band against the full content length', () => {
test('uses a 40px band regardless of viewport height', () => {
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1400, scrollLength: 600 })).toBe(true);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1365, scrollLength: 600 })).toBe(true);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1300, scrollLength: 600 })).toBe(false);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1360, scrollLength: 600 })).toBe(true);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1359, scrollLength: 600 })).toBe(false);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1100, scrollLength: 600 })).toBe(false);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1900, scrollLength: 60 })).toBe(true);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1899, scrollLength: 60 })).toBe(false);
});
test('falls back to the list flags when distances are unavailable', () => {
@@ -252,29 +112,3 @@ describe('resolveTimelineIsAtEnd', () => {
expect(resolveTimelineIsAtEnd(undefined)).toBe(undefined);
});
});
describe('resolveChatListAnchoredEndSpace', () => {
const rows = [{ id: 'a' }, { id: 'b' }, { id: 'a' }];
test('returns nothing when no anchor is set', () => {
expect(resolveChatListAnchoredEndSpace(rows, null, (row) => row.id)).toBe(undefined);
});
test('returns nothing when the anchor is not in the list', () => {
expect(resolveChatListAnchoredEndSpace(rows, 'z', (row) => row.id)).toBe(undefined);
});
test('resolves the last occurrence so a resent message anchors to its live row', () => {
expect(resolveChatListAnchoredEndSpace(rows, 'a', (row) => row.id)).toEqual({
anchorIndex: 2,
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
});
});
test('honours an explicit anchor offset', () => {
expect(resolveChatListAnchoredEndSpace(rows, 'b', (row) => row.id, { anchorOffset: 40 })).toEqual({
anchorIndex: 1,
anchorOffset: 40,
});
});
});
@@ -1,29 +1,17 @@
// Anchored-turn scroll geometry for the chat timeline.
// Scroll geometry for the chat timeline.
//
// The timeline has three mutually exclusive scroll modes:
// The timeline has two mutually exclusive scroll modes:
//
// • `following-end` — stay pinned to the live edge as content grows.
// • `anchoring-new-turn` — the just-sent user message is parked near the TOP
// of the viewport and the reply streams into reserved space below it. The
// viewport does NOT move until the turn outgrows the usable viewport.
// • `free-scrolling` — the user took over; nothing moves the scroll
// position until they opt back in.
//
// This module is pure geometry: it reads measurements from the virtualized
// list and answers "how far, if at all, must we scroll to reveal the end of
// the anchored turn". Keeping it free of DOM and React makes the mode machine
// testable without a renderer.
//
// "Usable viewport" is the visible height minus the composer overlay (the
// composer floats over the list) minus the anchor offset, so a turn is only
// considered overflowing when it genuinely cannot be read.
// list and answers where the real content ends and whether the viewport is
// there. Keeping it free of DOM and React makes the rules testable without a
// renderer.
export type TimelineScrollMode = 'following-end' | 'anchoring-new-turn' | 'free-scrolling';
// Distance from the top of the viewport at which an anchored user message
// parks. Small enough to read as "at the top", large enough not to collide
// with the timeline's top fade.
export const CHAT_LIST_ANCHOR_OFFSET = 16;
export type TimelineScrollMode = 'following-end' | 'free-scrolling';
export interface TimelineListMeasurementState {
readonly data: readonly unknown[];
@@ -33,17 +21,6 @@ export interface TimelineListMeasurementState {
readonly sizeAtIndex: (index: number) => number | undefined;
}
export interface AnchoredTurnMetrics {
readonly anchorTop: number;
readonly lastBottom: number;
readonly turnHeight: number;
readonly usableViewportHeight: number;
readonly visibleUsableBottom: number;
readonly overflowsUsableViewport: boolean;
readonly targetScrollToRevealEnd: number;
readonly scrollDeltaToRevealEnd: number;
}
export const getRowBottom = (
state: TimelineListMeasurementState,
index: number,
@@ -58,86 +35,33 @@ export const getRowBottom = (
) {
return null;
}
// Rows measured at zero height would make an anchored turn look empty and
// suppress the reveal scroll; treat them as one pixel tall instead.
// Rows measured at zero height would read as no content at all; treat
// them as one pixel tall instead.
return top + Math.max(1, height);
};
export const getAnchoredTurnMetrics = ({
state,
anchorIndex,
composerOverlayHeight,
anchorOffset,
}: {
readonly state: TimelineListMeasurementState;
readonly anchorIndex: number;
readonly composerOverlayHeight: number;
readonly anchorOffset: number;
}): AnchoredTurnMetrics | null => {
if (state.data.length === 0) return null;
const boundedAnchorIndex = Math.max(0, Math.min(anchorIndex, state.data.length - 1));
const anchorTop = state.positionAtIndex(boundedAnchorIndex);
// The LAST row bottom, not the content length: the reserved anchored end
// space lives past it, and targeting that reserved tail would scroll the
// real content off the top.
const lastBottom = getRowBottom(state, state.data.length - 1);
if (typeof anchorTop !== 'number' || !Number.isFinite(anchorTop) || lastBottom === null) {
return null;
}
const usableViewportHeight = Math.max(
0,
state.scrollLength - composerOverlayHeight - anchorOffset,
);
const turnHeight = Math.max(0, lastBottom - anchorTop);
const visibleUsableBottom = state.scroll + usableViewportHeight;
const targetScrollToRevealEnd = Math.max(0, lastBottom - usableViewportHeight);
// Never negative: revealing the end must not scroll the timeline backwards.
const scrollDeltaToRevealEnd = Math.max(0, targetScrollToRevealEnd - state.scroll);
return {
anchorTop,
lastBottom,
turnHeight,
usableViewportHeight,
visibleUsableBottom,
overflowsUsableViewport: turnHeight > usableViewportHeight,
targetScrollToRevealEnd,
scrollDeltaToRevealEnd,
};
};
// The scroll offset that puts the LAST REAL ROW's bottom just above the
// composer overlay. Distinct from the list's own end offset, which is derived
// from the total content length: that length includes any reserved anchored
// end space and, right after rows re-wrap on a width change, row sizes that
// have not been re-measured yet. Scrolling to it then lands below the real
// content and leaves a blank tail. `extraInset` reserves additional slack
// below the content when a caller wants the row to sit clear of the edge.
// The list footer (question and permission cards, error notices, the tail
// spacer) renders after the last row and is part of the real content; the
// list does not expose its size through getState, so the caller passes the
// last reported value.
export const resolveRealContentEndOffset = ({
state,
composerOverlayHeight,
extraInset = 0,
footerSize = 0,
}: {
readonly state: TimelineListMeasurementState;
readonly composerOverlayHeight: number;
readonly extraInset?: number;
readonly footerSize?: number;
}): number | null => {
const lastIndex = state.data.length - 1;
if (lastIndex < 0) return null;
const lastBottom = getRowBottom(state, lastIndex);
if (lastBottom === null) return null;
const visibleLength = Math.max(0, state.scrollLength - composerOverlayHeight - extraInset);
return Math.max(0, lastBottom - visibleLength);
const visibleLength = Math.max(0, state.scrollLength - composerOverlayHeight);
return Math.max(0, lastBottom + Math.max(0, footerSize) - visibleLength);
};
// "At the end" for follow purposes is a tight band, not the list's isNearEnd
// (half a viewport): that band hid the scroll-to-bottom pill and re-armed
// follow while the user had genuinely scrolled away, yanking them back on the
// next stream chunk. Distance is measured against the full content length —
// reserved anchored end space included — so a parked anchored turn counts as
// the live edge.
// Keep return-to-end detection in a tight band, rather than half a viewport.
export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40;
export const resolveTimelineIsAtEnd = (
@@ -161,31 +85,3 @@ export const resolveTimelineIsAtEnd = (
}
return state.isNearEnd ?? state.isAtEnd;
};
export interface ChatListAnchoredEndSpace {
readonly anchorIndex: number;
readonly anchorOffset: number;
}
// Finds the anchored row from the BACK of the list: a retried or re-sent
// message id can appear more than once, and the live one is always the last.
export const resolveChatListAnchoredEndSpace = <Item, AnchorId>(
items: readonly Item[],
anchorId: AnchorId | null,
getAnchorId: (item: Item) => AnchorId | null,
options: { readonly anchorOffset?: number } = {},
): ChatListAnchoredEndSpace | undefined => {
if (anchorId === null) return undefined;
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (item !== undefined && getAnchorId(item) === anchorId) {
return {
anchorIndex: index,
anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET,
};
}
}
return undefined;
};
@@ -0,0 +1,201 @@
import { describe, expect, test } from 'bun:test';
import type { AssistantMessage, Part, ToolPart, ToolStateCompleted } from '@opencode-ai/sdk/v2';
import { getLiveFinalMessage, getTurnsWithLaterAssistant, hasLiveActivity } from './liveActivity';
import { projectTurnRecords } from './projectTurnRecords';
import { summarizeLiveActivity } from './liveActivitySummary';
import type { ChatMessageEntry } from './types';
function assistant(id: string, parts: Part[], options: Partial<AssistantMessage> = {}): ChatMessageEntry {
return {
info: {
id, sessionID: 'session', role: 'assistant', parentID: 'user',
time: { created: 2 }, modelID: 'model', providerID: 'provider', mode: 'build', agent: 'build',
path: { cwd: '/project', root: '/project' }, cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
...options,
},
parts,
};
}
function user(id = 'user', hidden = false): ChatMessageEntry {
return {
info: { id, sessionID: 'session', role: 'user', time: { created: 1 }, agent: 'build', model: { providerID: 'provider', modelID: 'model' } },
parts: hidden ? [] : [text(`Request ${id}`)],
};
}
function text(content: string): Part {
return { type: 'text', id: content, messageID: 'message', sessionID: 'session', text: content };
}
function tool(id: string, name: string, options: {
status?: 'completed' | 'error' | 'running';
input?: ToolStateCompleted['input'];
metadata?: ToolStateCompleted['metadata'];
error?: string;
} = {}): ToolPart {
const common = { input: options.input ?? {}, metadata: options.metadata ?? {}, time: { start: 1, end: 2 } };
const state: ToolPart['state'] = options.status === 'error'
? { ...common, status: 'error', error: options.error ?? 'failed' }
: options.status === 'running'
? { ...common, status: 'running' }
: { ...common, status: 'completed', output: '', title: name };
return {
id, callID: id, type: 'tool', tool: name, messageID: 'message', sessionID: 'session',
state,
};
}
const diff = '@@ -1,1 +1,2 @@\n-before\n+after\n+added';
describe('live turn boundaries', () => {
test('a queued user message does not collapse the previous turn', () => {
const turns = projectTurnRecords([user(), assistant('a', [tool('read', 'read')]), user('next')]).turns;
expect(getTurnsWithLaterAssistant(turns).size).toBe(0);
});
test('an assistant with the next visible parent retires the previous turn', () => {
const turns = projectTurnRecords([
user(), assistant('a', [text('Checking'), tool('read', 'read')]), user('next'),
assistant('b', [tool('bash', 'bash')], { parentID: 'next' }),
]).turns;
expect([...getTurnsWithLaterAssistant(turns)]).toEqual(['user']);
expect(getLiveFinalMessage(turns[0].assistantMessages)).toBeUndefined();
});
test('hidden user continuations keep their visible turn open', () => {
const turns = projectTurnRecords([
user(), assistant('a', [tool('read', 'read')]), user('hidden', true),
assistant('b', [tool('bash', 'bash')], { parentID: 'hidden' }),
], { mergeHiddenUserTurns: { planModeEnabled: false } }).turns;
expect(turns).toHaveLength(1);
expect(getTurnsWithLaterAssistant(turns).size).toBe(0);
});
test('only stop text is a final answer, not a tool step, compaction or earlier stop', () => {
const final = assistant('final', [text('Done')], { finish: 'stop' });
expect(getLiveFinalMessage([final])).toBe(final);
expect(getLiveFinalMessage([assistant('progress', [text('Checking')], { finish: 'tool-calls' })])).toBeUndefined();
expect(getLiveFinalMessage([assistant('compact', [text('Summary')], { finish: 'stop', summary: true })])).toBeUndefined();
expect(getLiveFinalMessage([final, assistant('continued', [tool('read', 'read')])])).toBeUndefined();
expect(getLiveFinalMessage([assistant('question', [text('Which?'), tool('question', 'question', { status: 'running' })])])).toBeUndefined();
});
test('activity eligibility follows visible sorted activity rather than any assistant prose', () => {
const reasoning: Part = { type: 'reasoning', id: 'thinking', messageID: 'a', sessionID: 'session', text: 'Thinking', time: { start: 1, end: 2 } };
const turn = projectTurnRecords([user(), assistant('a', [reasoning, text('Done')], { finish: 'stop' })]).turns[0];
expect(hasLiveActivity(turn, false)).toBe(false);
expect(hasLiveActivity(turn, true)).toBe(true);
expect(hasLiveActivity(projectTurnRecords([user(), assistant('a', [text('Hello')], { finish: 'stop' })]).turns[0], true)).toBe(false);
});
});
describe('live activity report', () => {
test('groups exploration and web calls without pretending their counts are file counts', () => {
const result = summarizeLiveActivity([assistant('a', [
...['read', 'list', 'glob', 'grep', 'lsp', 'skill', 'webfetch', 'websearch', 'codesearch', 'perplexity'].map((name) => tool(name, name)),
tool('bash1', 'bash', { input: { command: 'first && second' } }),
tool('bash2', 'bash'),
tool('task1', 'task', { metadata: { sessionId: 'child' } }),
tool('task2', 'task', { metadata: { sessionId: 'child' } }),
tool('task3', 'task', { metadata: { sessionId: 'other-child' } }),
])]);
expect(result).toMatchObject({ explored: true, researched: true, commands: 2, subagents: 2, files: 0 });
});
test('does not invent meanings for managed tools, MCP names or unknown aliases', () => {
const result = summarizeLiveActivity([assistant('a', [
...['question', 'todowrite', 'plan_exit', 'StructuredOutput', 'openchamber', 'openchamber_web', 'openchamber_memory', 'linear_save_issue', 'mcp.edit'].map((name) => tool(name, name)),
])]);
expect(result).toMatchObject({ explored: false, researched: false, commands: 0, subagents: 0, files: 0 });
});
test('counts each command call once, including a confirmed nonzero exit but not a permission refusal', () => {
const command = tool('bash', 'bash');
const result = summarizeLiveActivity([assistant('a', [
command, command,
tool('failed', 'bash', { status: 'error', error: 'exit 1', metadata: { exit: 1 } }),
tool('denied', 'bash', { status: 'error', error: 'Permission denied' }),
tool('running', 'bash', { status: 'running' }),
])]);
expect(result.commands).toBe(2);
});
test('sums actual call diffs while deduplicating paths and duplicate call records', () => {
const first = tool('edit1', 'edit', { input: { filePath: './src/a.ts' }, metadata: { diff } });
const result = summarizeLiveActivity([assistant('a', [
first, first,
tool('edit2', 'edit', { input: { filePath: '/project/src/a.ts' }, metadata: { diff: '@@ -1,1 +1,0 @@\n-after' } }),
])]);
expect(result).toMatchObject({ files: 1, additions: 2, deletions: 2, hasCompleteDiff: true });
});
test('takes all patch files and never double-counts their top-level diff', () => {
const result = summarizeLiveActivity([assistant('a', [tool('patch', 'apply_patch', { metadata: {
diff,
files: [
{ filePath: '/project/a', patch: diff, type: 'update' },
{ filePath: '/project/b', diff: '@@ -1,1 +1,0 @@\n-deleted', type: 'delete' },
{ filePath: '/project/c', additions: 3, deletions: 0, type: 'add' },
],
} })])]);
expect(result).toMatchObject({ files: 3, additions: 5, deletions: 2, hasCompleteDiff: true });
});
test('a rename preserves the identity of a file already edited in the turn', () => {
const result = summarizeLiveActivity([assistant('a', [
tool('edit', 'edit', { input: { filePath: 'old.ts' }, metadata: { diff } }),
tool('move', 'apply_patch', { metadata: { files: [{ filePath: '/project/old.ts', movePath: '/project/new.ts', additions: 0, deletions: 0 }] } }),
tool('edit-again', 'edit', { input: { filePath: 'new.ts' }, metadata: { diff } }),
])]);
expect(result).toMatchObject({ files: 1, additions: 4, deletions: 2 });
});
test('uses the whole-call diff when per-file stats are missing, without adding partial numbers', () => {
const result = summarizeLiveActivity([assistant('a', [tool('patch', 'apply_patch', { metadata: {
diff: `${diff}\n@@ -1,1 +1,0 @@\n-deleted`,
files: [{ filePath: '/project/a', patch: diff }, { filePath: '/project/b' }],
} })])]);
expect(result).toMatchObject({ files: 2, additions: 2, deletions: 2, hasCompleteDiff: true });
});
test('write content is not a diff and partial stats are not shown as a complete total', () => {
const result = summarizeLiveActivity([assistant('a', [
tool('edit', 'edit', { input: { filePath: 'a' }, metadata: { diff } }),
tool('write', 'write', { input: { filePath: 'b', content: 'one\ntwo\nthree' } }),
])]);
expect(result).toMatchObject({ files: 2, hasCompleteDiff: false });
});
test('rejects truncated diff counts and counts source lines resembling diff headers', () => {
expect(summarizeLiveActivity([assistant('a', [tool('edit', 'edit', { input: { filePath: 'a' }, metadata: { diff: '@@ -1,1 +1,2 @@\n-old\n+incomplete' } })])]).hasCompleteDiff).toBe(false);
expect(summarizeLiveActivity([assistant('a', [tool('edit', 'edit', { input: { filePath: 'a' }, metadata: { diff: '@@ -1,1 +1,1 @@\n---source\n+++source' } })])])).toMatchObject({ additions: 1, deletions: 1 });
});
test('failed edits and malformed metadata cannot erase another valid change', () => {
const result = summarizeLiveActivity([assistant('a', [
tool('bad', 'edit', { status: 'error', error: 'failed', input: { filePath: 'bad' }, metadata: { diff } }),
tool('good', 'edit', { input: { filePath: 'good' }, metadata: { diff, files: 'invalid' } }),
])]);
expect(result).toMatchObject({ files: 1, additions: 2, deletions: 1, hasCompleteDiff: true });
});
test('normalizes absolute dot segments and Windows path spelling', () => {
expect(summarizeLiveActivity([assistant('unix', [
tool('one', 'edit', { input: { filePath: '/project/src/../a' }, metadata: { diff } }),
tool('two', 'edit', { input: { filePath: 'a' }, metadata: { diff } }),
])]).files).toBe(1);
expect(summarizeLiveActivity([assistant('windows', [
tool('one', 'edit', { input: { filePath: 'C:\\Project\\A.ts' }, metadata: { diff } }),
tool('two', 'edit', { input: { filePath: 'c:/project/./a.ts' }, metadata: { diff } }),
], { path: { cwd: 'C:/Project', root: 'C:/Project' } })]).files).toBe(1);
});
test('a confirmed no-op is not a changed file, but creating an empty file is', () => {
expect(summarizeLiveActivity([assistant('a', [tool('patch', 'apply_patch', { metadata: { files: [
{ filePath: '/project/noop', additions: 0, deletions: 0, type: 'update' },
{ filePath: '/project/empty', additions: 0, deletions: 0, type: 'add' },
] } })])])).toMatchObject({ files: 1, additions: 0, deletions: 0, hasCompleteDiff: true });
});
});
@@ -0,0 +1,28 @@
import type { ChatMessageEntry, TurnRecord } from './types';
/** A queued user message alone does not retire the previous turn. */
export function getTurnsWithLaterAssistant(turns: readonly TurnRecord[]): Set<string> {
const retired = new Set<string>();
let hasLaterAssistant = false;
for (let index = turns.length - 1; index >= 0; index--) {
const turn = turns[index];
if (hasLaterAssistant) retired.add(turn.turnId);
hasLaterAssistant ||= turn.assistantMessages.length > 0;
}
return retired;
}
export function getLiveFinalMessage(messages: readonly ChatMessageEntry[]): ChatMessageEntry | undefined {
const last = messages.at(-1);
// Do not use projectTurnSummary's intermediate-text fallback. Compaction
// summaries are not user-facing final answers either.
return last?.info.role === 'assistant' && last.info.finish === 'stop' && !last.info.summary
&& last.parts.some((part) => part.type === 'text' && part.text.trim().length > 0)
? last : undefined;
}
export function hasLiveActivity(turn: TurnRecord, showReasoning: boolean): boolean {
return turn.activitySegments.some((segment) => segment.parts.some((activity) => (
activity.kind === 'tool' || (showReasoning && activity.kind === 'reasoning')
)));
}

Some files were not shown because too many files have changed in this diff Show More