diff --git a/CHANGELOG.md b/CHANGELOG.md index 22de68b1..ed3da0ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [1.18.1] - 2026-08-04 + +- **Providers:** signing in to an OAuth-only provider now actually completes — the browser login is stored and the provider list updates instead of remaining signed out. OAuth-only providers show a Connect flow instead of an API key form, and their models stay hidden until you are signed in. +- **Sessions:** archived sessions can now be restored to the active list — from the sidebar context menu, the archived-sessions page, or the bulk-selection bar — instead of only offering permanent deletion (thanks to @makeittech). +- Walkthrough: models without a working provider login no longer appear in the walkthrough picker, and Generate stays disabled until a usable model is selected instead of failing with a raw provider error. +- Providers: sign-ins that need extra details (such as GitHub Copilot Enterprise) now ask for them before opening the browser, and device codes come with a working copy button. +- Walkthrough: connecting to a server older than the app now says the server needs updating instead of showing a raw HTML parsing error, and the "Critical" tag is now "Key change" with a tooltip so it no longer reads as a problem found in your code. +- Chat: Ctrl/Cmd+L now adds the selected text to the chat input, or focuses it when nothing is selected; the toggle-sidebar shortcut moved to Ctrl/Cmd+Alt+L. +- Chat: a manually chosen model now stays selected after a delegated subtask finishes, instead of reverting to the agent's default model. +- Agents/CLI: sending a prompt that never reaches its session is now reported as failed, and an unavailable model, agent, or variant is rejected with a clear error before anything is created. +- Desktop/Linux: "Open in Terminal" no longer launches a non-terminal app that is set as the terminal launcher (thanks to @kydorn). + ## [1.18.0] - 2026-08-04 - **Walkthrough:** a new guided walkthrough reorders a diff into a sequence of stops — the model groups related changes, explains what each one does, and orders them so each builds on the last. Start one from the Changes and pull-request views for uncommitted work, a branch against its base, or a pull request; nothing runs on its own. Walkthroughs are written in your interface language by default, and the panel can generate one in any other supported language. diff --git a/bun.lock b/bun.lock index 370b1124..1edf6ebc 100644 --- a/bun.lock +++ b/bun.lock @@ -30,7 +30,7 @@ "@heroui/theme": "^2.4.23", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "1.18.11", + "@opencode-ai/sdk": "1.18.12", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -95,7 +95,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.17.2", + "version": "1.18.0", "dependencies": { "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", @@ -131,7 +131,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.17.2", + "version": "1.18.0", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", @@ -165,7 +165,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "1.18.11", + "@opencode-ai/sdk": "1.18.12", "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.3.0", "@simplewebauthn/browser": "13.3.0", @@ -236,11 +236,11 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.17.2", + "version": "1.18.0", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "1.18.11", - "adm-zip": "^0.5.16", + "@opencode-ai/sdk": "1.18.12", + "adm-zip": "^0.6.0", "jsonc-parser": "^3.3.1", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -259,16 +259,16 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.17.2", + "version": "1.18.0", "bin": { "openchamber": "./bin/cli.js", }, "dependencies": { "@clack/prompts": "^1.1.0", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "1.18.11", + "@opencode-ai/sdk": "1.18.12", "@simplewebauthn/server": "13.3.1", - "adm-zip": "^0.5.16", + "adm-zip": "^0.6.0", "bun-pty": "^0.4.5", "compression": "^1.8.1", "cron-parser": "^4.9.0", @@ -997,7 +997,7 @@ "@openchamber/web": ["@openchamber/web@workspace:packages/web"], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.11", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-yDImmNv4PhxdMgtiHVNWQWEVwQlAm7Dr0y4XU7CT4dOIbzgO+VP+9I02lAP7Zva1FhGeyI7oKMI2tzB9RUsWaQ=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.12", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Skjm0uRWqIiL9BQliZSrvnBflT99q1aGhT2pATNwli2WU8XSKm4lhZJFay7dIzjrA5C9SfgK+YSeoES2PbFugA=="], "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], @@ -1487,7 +1487,7 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "adm-zip": ["adm-zip@0.5.16", "", {}, "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ=="], + "adm-zip": ["adm-zip@0.6.0", "", {}, "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], diff --git a/package.json b/package.json index ac088390..81c55465 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openchamber-monorepo", - "version": "1.18.0", + "version": "1.18.1", "description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes", "private": true, "type": "module", @@ -112,7 +112,7 @@ "@heroui/theme": "^2.4.23", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "1.18.11", + "@opencode-ai/sdk": "1.18.12", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/docs/content/docs/de/walkthrough.mdx b/packages/docs/content/docs/de/walkthrough.mdx index 04744a4f..e4be894b 100644 --- a/packages/docs/content/docs/de/walkthrough.mdx +++ b/packages/docs/content/docs/de/walkthrough.mdx @@ -11,6 +11,20 @@ Es erklärt und ordnet. Es bewertet Ihren Code nicht und fällt kein Urteil — Öffnen Sie es über das **Walkthrough**-Symbol in der rechten Leiste oder über die Schaltfläche **AI walkthrough** in den Bereichen Changes und Pull Request. Beides öffnet nur das Panel; generiert wird erst, wenn Sie **Generate walkthrough** drücken. +## Wie ein Stop markiert ist + +Jeder Stop benennt sein Thema, erklärt es in ein bis zwei Sätzen und zeigt danach genau den Code, den er beschreibt. Manche Stops tragen eine kleine Markierung neben dem Titel: + +| Markierung | Bedeutung | +| --- | --- | +| **Kernänderung** | Dieser Stop trägt die eigentliche Änderung oder den größten Teil ihres Risikos. Lesen Sie ihn genau und zuerst. | +| **Kontext** | Eine unterstützende Änderung, damit der Rest verständlich bleibt. Kann überflogen werden. | +| *(ohne Markierung)* | Ein gewöhnlicher Schritt in der Lesereihenfolge. | + +Die Markierung sagt, **wo Sie Ihre Aufmerksamkeit investieren sollten**, und nichts über die Qualität des Codes. Ein Stop wird nie markiert, weil darin etwas Falsches gefunden wurde — das Walkthrough meldet keine Funde, keine Schweregrade und keine Urteile. Wenn Code bewertet werden soll, ist das die Aktion **Review** in [Git & GitHub](/git/). + +Die einzigen Markierungen, die tatsächlich auf ein Problem hinweisen, sind **Veraltet** und **Nicht abgedeckt** — und beide betreffen das Veralten des Walkthroughs selbst, nicht Ihren Code. Siehe unten. + ## Was es prüfen kann | Bereich | Was enthalten ist | diff --git a/packages/docs/content/docs/scheduled-tasks.mdx b/packages/docs/content/docs/scheduled-tasks.mdx index e7753190..4f5a6554 100644 --- a/packages/docs/content/docs/scheduled-tasks.mdx +++ b/packages/docs/content/docs/scheduled-tasks.mdx @@ -15,6 +15,7 @@ A scheduled task runs a prompt for you on a schedule — for example, a daily "s - **daily** — at one or more times each day - **weekly** — on chosen weekdays and times - **once** — a single date and time + - **cron** — an arbitrary cron expression 4. Set what it does: the prompt to send, and the provider, model, and agent to use. The prompt can be a slash command, like `/review`. 5. Save, and make sure the task is enabled. @@ -22,6 +23,48 @@ You can run any task immediately with **run now** to check it does what you expe Check **Run as goal** to make the run pursue its prompt to completion instead of stopping after one reply — see [Session Goals](/session-goals/). +## Loops: scheduled tasks as markdown files + +A **loop** is a scheduled task defined as a portable markdown file you can commit to your repo. Drop a file into `.agents/loops/` and the task appears on the next sync — no dialog needed: + +```markdown +--- +name: daily-digest +schedule: "0 9 * * *" +enabled: true +model: anthropic/claude-sonnet-4-5 +agent: plan +timezone: Europe/Kyiv +--- +Summarize repository changes since yesterday and post the digest. +``` + +### Where files live + +- **Project scope** — `.agents/loops/*.md` in the project directory or any ancestor directory up to the git worktree root. +- **User scope** — `~/.agents/loops/*.md` applies to every project you open. + +If a project loop and a user loop share a name, the project loop wins. + +### Fields + +| Field | Meaning | +|---|---| +| `name` | Task name (required, max 80 characters). | +| `schedule` | Cron expression (required) — loop files are cron-only. | +| `enabled` | Set `true` to run. Loops are **off by default**, so committing a file never starts running a task on its own. | +| `model` | `provider/model` (required), e.g. `anthropic/claude-sonnet-4-5`. | +| `agent` | Agent to use (optional). | +| `timezone` | IANA timezone (optional, defaults to the server zone). | +| body | The execution prompt (required). Can be a slash command, like `/review src/`. | + +### How loops behave + +- The **file is authoritative** while it exists: edits made in the UI are reverted on the next sync. The scheduled-tasks dialog marks loop tasks and disables their edit/enable/delete actions — **run now** still works. To stop a loop, delete the file (or set `enabled: false`). +- Runtime state (last run, next run, status) lives in the project config and is never written back into the markdown file. +- Renaming the `name` field renames the task in place. If a loop file temporarily fails to parse (mid-edit, merge conflict), its task is kept with the last good definition until the file is fixed. +- `daily`/`weekly`/`once` schedules and goal settings remain UI-only; loop files are always cron. + ## What success looks like After a run, the task shows when it last ran, whether it succeeded, and a link to the session it created. If a run fails, the error is shown there too. diff --git a/packages/docs/content/docs/walkthrough.mdx b/packages/docs/content/docs/walkthrough.mdx index 6d372bf5..742a47a0 100644 --- a/packages/docs/content/docs/walkthrough.mdx +++ b/packages/docs/content/docs/walkthrough.mdx @@ -11,6 +11,20 @@ It explains and orders. It does not judge your code or hand out verdicts — tha Open it from the **Walkthrough** icon in the right rail, or from the **AI walkthrough** button in the Changes and Pull Request panels. Both just open the panel; nothing is generated until you press **Generate walkthrough**. +## How a stop is marked + +Each stop names what it is about, explains it in a sentence or two, and then shows exactly the code it describes. Some stops carry a small tag next to the title: + +| Tag | What it means | +| --- | --- | +| **Key change** | This stop drives the rest of the change, or carries most of its risk. Read it closely and read it first. | +| **Context** | A supporting change, included so the rest makes sense. Safe to skim. | +| *(no tag)* | An ordinary step in the reading order. | + +The tag is about **where to spend your attention**, not about the quality of the code. A stop is never marked because something was found wrong in it — the walkthrough reports no findings, no severities, and no verdicts. If you want code judged, that is the **Review** action in [Git & GitHub](/git/). + +The only marks that do report a problem are **Outdated** and **Not covered**, and both are about the walkthrough itself going out of date rather than about your code — see below. + ## What it can review | Scope | What it covers | diff --git a/packages/electron/README.md b/packages/electron/README.md index 9b971a2b..ac05fabb 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -143,6 +143,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u - Floating Mini Chat windows. - Multiple native windows. - Native notifications. +- User-confirmed local folder selection. The shared UI supplies the requested directory as the picker `defaultPath`; confirmation is required before filesystem access is retried. - One-click open/reveal/open-in-app actions. - Desktop host switcher and deep-link imports. - Local and remote instance handling. diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index e9bca981..8be0f076 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -2463,12 +2463,6 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } browserWindow.on('move', () => { debounceWindowStatePersist(browserWindow, false); }); - browserWindow.on('minimize', (event) => { - if (!shouldHideMainWindowToTray(browserWindow)) return; - debounceWindowStatePersist(browserWindow, true); - event.preventDefault(); - browserWindow.hide(); - }); browserWindow.on('close', (event) => { if (!state.quitRequested && shouldHideMainWindowToTray(browserWindow)) { debounceWindowStatePersist(browserWindow, true); @@ -4448,7 +4442,12 @@ const handleInvoke = async (browserWindow, command, args = {}) => { case 'desktop_minimize_current_window': if (browserWindow && !browserWindow.isDestroyed()) { - browserWindow.minimize(); + if (shouldHideMainWindowToTray(browserWindow)) { + debounceWindowStatePersist(browserWindow, true); + browserWindow.hide(); + } else { + browserWindow.minimize(); + } } return null; diff --git a/packages/electron/package.json b/packages/electron/package.json index 67800186..88b3592a 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/electron", - "version": "1.18.0", + "version": "1.18.1", "private": true, "description": "Electron desktop runtime for OpenChamber", "author": "OpenChamber", diff --git a/packages/ui/package.json b/packages/ui/package.json index b5aab832..252ca74a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/ui", - "version": "1.18.0", + "version": "1.18.1", "private": true, "type": "module", "main": "src/main.tsx", @@ -43,7 +43,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "1.18.11", + "@opencode-ai/sdk": "1.18.12", "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.3.0", "@simplewebauthn/browser": "13.3.0", diff --git a/packages/ui/src/apps/MobileSessionSwitcher.tsx b/packages/ui/src/apps/MobileSessionSwitcher.tsx index 4e2a44a8..c05b9ecf 100644 --- a/packages/ui/src/apps/MobileSessionSwitcher.tsx +++ b/packages/ui/src/apps/MobileSessionSwitcher.tsx @@ -1,7 +1,7 @@ import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; -import { Icon } from '@/components/icon/Icon'; +import { SessionActivityDuration } from '@/components/session/SessionActivityDuration'; import { formatSessionCompactDateLabel } from '@/components/session/sidebar/utils'; import { useSwitcherItems } from '@/components/session/sidebar/hooks/useSwitcherItems'; import { useTabletLayout } from '@/lib/device'; @@ -10,6 +10,7 @@ import { cn } from '@/lib/utils'; import { refreshGlobalSessions, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useSessionUnseenCount } from '@/sync/notification-store'; +import { useHasSessionActivityDuration } from '@/sync/session-activity-timing'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useGlobalSessionStatus } from '@/sync/sync-context'; @@ -35,6 +36,8 @@ const SwitcherRow: React.FC<{ const statusType = status?.type ?? 'idle'; const isStreaming = statusType === 'busy' || statusType === 'retry'; const showUnreadDot = !isStreaming && unseenCount > 0 && !active; + const hasActivityDuration = useHasSessionActivityDuration(session.id, isStreaming); + const showActivityDuration = (isStreaming || showUnreadDot) && hasActivityDuration; const timeLabel = formatSessionCompactDateLabel(session.time?.updated ?? session.time?.created ?? 0); return ( @@ -56,12 +59,24 @@ const SwitcherRow: React.FC<{ ) : null} {/* Activity sits on the right, before the time — no reserved left gutter. */} - {isStreaming ? ( - - ) : showUnreadDot ? ( - + {isStreaming || showUnreadDot ? ( + ) : null} - {timeLabel ? ( + {/* The elapsed turn takes the time slot while it matters, then hands it + back to the relative timestamp. */} + {showActivityDuration ? ( + + ) : timeLabel ? ( {timeLabel} ) : null} diff --git a/packages/ui/src/apps/MobileSessionsSheet.tsx b/packages/ui/src/apps/MobileSessionsSheet.tsx index 5f93651b..43a28f32 100644 --- a/packages/ui/src/apps/MobileSessionsSheet.tsx +++ b/packages/ui/src/apps/MobileSessionsSheet.tsx @@ -64,6 +64,8 @@ import { import { useSessionUIStore } from '@/sync/session-ui-store'; import { useAllLiveSessions, useGlobalSessionStatus } from '@/sync/sync-context'; import { useSessionUnseenCount } from '@/sync/notification-store'; +import { useHasSessionActivityDuration } from '@/sync/session-activity-timing'; +import { SessionActivityDuration } from '@/components/session/SessionActivityDuration'; import type { WorktreeMetadata } from '@/types/worktree'; import { MobileDeleteWorktreeDialog } from './MobileDeleteWorktreeDialog'; @@ -476,6 +478,8 @@ const SessionRow: React.FC<{ const statusType = liveStatus?.type ?? 'idle'; const isStreaming = statusType === 'busy' || statusType === 'retry'; const showUnreadDot = !isStreaming && unseenCount > 0 && !active; + const hasActivityDuration = useHasSessionActivityDuration(session.id, isStreaming); + const showActivityDuration = (isStreaming || showUnreadDot) && hasActivityDuration; const contentRef = React.useRef(null); const startRef = React.useRef<{ x: number; y: number } | null>(null); @@ -616,10 +620,14 @@ const SessionRow: React.FC<{ onToggleChildren?.(); }} > - {isStreaming ? ( - - ) : showUnreadDot ? ( - + {isStreaming || showUnreadDot ? ( + ) : ( )} @@ -664,7 +672,15 @@ const SessionRow: React.FC<{ > {title} - {time ? ( + {/* The elapsed turn takes the time slot while it matters, then + hands it back to the relative timestamp. */} + {showActivityDuration ? ( + + ) : time ? ( {time} ) : null} diff --git a/packages/ui/src/apps/mobileQrScan.test.ts b/packages/ui/src/apps/mobileQrScan.test.ts index cba12b20..14ca8a70 100644 --- a/packages/ui/src/apps/mobileQrScan.test.ts +++ b/packages/ui/src/apps/mobileQrScan.test.ts @@ -83,6 +83,42 @@ describe('scanConnectionQr on Android', () => { expect(removeCalls).toBe(2); }); + test('falls back to string parsing when the WebView URL parser rejects the link (old Android WebView)', async () => { + // Old Android WebViews resolve openchamber://connect?... with hostname "" and + // pathname "//connect", so the URL-based parse fails on an intact string. The test + // runtime's URL parser handles the canonical form fine, so simulate the rejection + // with a case variant the URL parser refuses while the string parser accepts. + const url = encodePairingConnectionPayload(buildPairingConnectionPayload({ + pairingId: 'pair_abc', + secret: 'one-time', + candidates: [{ type: 'lan', url: 'http://192.168.1.20:4096', priority: 10 }], + })); + const mixedCase = url.replace('openchamber://connect', 'OpenChamber://CONNECT'); + const listeners = new Map }) => void>(); + const plugin = { + requestPermissions: mock(async () => ({ camera: 'granted' })), + startScan: mock(async () => { + listeners.get('barcodesScanned')?.({ barcodes: [{ rawValue: mixedCase }] }); + }), + stopScan: mock(async () => undefined), + addListener: mock((event: string, callback: (info: { barcodes?: Array<{ rawValue?: string }> }) => void) => { + listeners.set(event, callback); + return { remove: () => undefined }; + }), + }; + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } }, + }); + + const result = await scanConnectionQr(); + expect(result.status).toBe('pairing'); + if (result.status === 'pairing') { + expect(result.pairing.pairingId).toBe('pair_abc'); + expect(result.pairing.candidates).toEqual([{ type: 'lan', url: 'http://192.168.1.20:4096', priority: 10 }]); + } + }); + test('stops scanning when the caller aborts', async () => { let stopCalls = 0; const stopScan = async () => { stopCalls += 1; }; diff --git a/packages/ui/src/apps/mobileQrScan.ts b/packages/ui/src/apps/mobileQrScan.ts index e1980e99..2e54fec9 100644 --- a/packages/ui/src/apps/mobileQrScan.ts +++ b/packages/ui/src/apps/mobileQrScan.ts @@ -4,7 +4,7 @@ // scan() activity, this path bundles the barcode model in the app and does not need // Google Play Services. iOS keeps the native ready-made scanner. -import { parsePairingConnectionPayload, type PairingConnectionPayload } from '@/lib/connectionPayload'; +import { parsePairingConnectionPayload, parsePairingConnectionPayloadString, type PairingConnectionPayload } from '@/lib/connectionPayload'; export type MobileConnectionPayload = { url: string; @@ -65,8 +65,15 @@ export const parseConnectionPayload = (raw: string): MobileConnectionPayload | M return null; }; -const resultFromRawValue = (raw: string): QrScanResult => { +const resultFromRawValue = (raw: string, options?: { pairingStringFallback?: boolean }): QrScanResult => { const payload = parseConnectionPayload(raw); + if (!payload && options?.pairingStringFallback) { + // Old Android WebViews resolve openchamber://… with hostname "" / pathname "//connect", + // so the URL-based parse above fails even though the scanned string is intact. Retry + // with the URL-API-free string parser before declaring the scan invalid. + const pairing = parsePairingConnectionPayloadString(raw); + if (pairing) return { status: 'pairing', pairing }; + } if (!payload) return { status: 'invalid' }; if ('pairing' in payload) return { status: 'pairing', ...payload }; return { status: 'ok', ...payload }; @@ -100,7 +107,7 @@ const scanWithBundledAndroidScanner = async ( Promise.resolve(plugin.addListener('barcodesScanned', ({ barcodes }) => { const barcode = barcodes?.[0]; const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim(); - if (raw) finish(resultFromRawValue(raw)); + if (raw) finish(resultFromRawValue(raw, { pairingStringFallback: true })); })).then((handle) => { barcodeListener = handle; }), Promise.resolve(plugin.addListener('scanError', () => finish({ status: 'failed' }))) .then((handle) => { errorListener = handle; }), diff --git a/packages/ui/src/apps/runtimeEndpointReset.ts b/packages/ui/src/apps/runtimeEndpointReset.ts index e92a4063..b430cfc9 100644 --- a/packages/ui/src/apps/runtimeEndpointReset.ts +++ b/packages/ui/src/apps/runtimeEndpointReset.ts @@ -17,6 +17,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; import { resetStreamingState } from '@/sync/streaming'; import { useGlobalSessionStatusStore } from '@/sync/global-session-status'; import { resetSessionOrdering } from '@/sync/session-ordering'; +import { resetSessionActivityTiming } from '@/sync/session-activity-timing'; import { syncDesktopSettings } from '@/lib/persistence'; // Same-device transport switch (LAN⇄relay for one paired device): rebind the SDK @@ -56,6 +57,9 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD useGlobalSessionsStore.getState().resetForRuntimeSwitch(); useGlobalSessionStatusStore.setState({ statusById: new Map() }); resetSessionOrdering(); + // Turn timings belong to the previous instance's sessions, and the reset also + // restarts the resume window so the switch is treated as a fresh load. + resetSessionActivityTiming(); usePermissionStore.getState().reset(); useFileSearchStore.getState().resetForRuntimeSwitch(); useGitStore.getState().resetForRuntimeSwitch(detail.runtimeKey); diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index ba493f55..9e055d41 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -32,7 +32,7 @@ import { } from '@/lib/chatDraftPersistence'; import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog'; import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment'; -import ToolOutputDialog from './message/ToolOutputDialog'; +import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import type { ToolPopupContent } from './message/types'; import { QueuedMessageChips } from './QueuedMessageChips'; import { AutoReviewBanner } from './AutoReviewBanner'; @@ -142,6 +142,10 @@ import { RevertedMessageDock } from './composer/ui/RevertedMessageDock'; import { SessionSuggestionChip } from '@/components/chat/SessionSuggestionChip'; import { SessionGoalRow } from '@/components/chat/SessionGoalRow'; +// Lazy like in ChatMessage: a static import would pull the @pierre/diffs and +// Shiki stacks into the eager startup graph for a dialog opened on demand. +const ToolOutputDialog = lazyWithChunkRecovery(() => import('./message/ToolOutputDialog')); + const MAX_VISIBLE_COMPOSER_LINES = 8; /** * Mobile grows the composer with content instead of offering a fullscreen @@ -383,6 +387,15 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo title: '', content: '', }); + // Mount the lazy preview dialog only after its first open; rendering it + // closed would fetch the ToolOutputDialog chunk (with the @pierre/diffs + // stack) on the draft screen before any preview is requested. + const [attachmentPreviewMounted, setAttachmentPreviewMounted] = React.useState(false); + React.useEffect(() => { + if (attachmentPreview.open) { + setAttachmentPreviewMounted(true); + } + }, [attachmentPreview.open]); const attachmentCompatibilityRef = React.useRef({ modelKey: `${currentProviderId ?? ''}/${currentModelId ?? ''}`, modalitySignature: currentModelMetadata?.modalities?.input?.slice().sort().join(',') ?? null, @@ -936,10 +949,18 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo setPrPickerOpen(true); }, []); + const getSubmitErrorMessage = (error: unknown, fallback: string) => { + const message = error instanceof Error ? error.message : ''; + return message.toLowerCase().includes('runtime changed') + ? t('chat.chatInput.toast.messageSendFailed') + : message || fallback; + }; + const handleSubmit = async (options?: SubmitOptions) => { const queuedOnly = options?.queuedOnly ?? false; const queuedMessageId = options?.queuedMessageId; const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined; + const capturedTarget = messageQueueTarget; const inputSnapshot = options?.presetText != null ? { message: options.presetText, @@ -1012,7 +1033,9 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } } - const sendMessageOptions = delivery ? { delivery } : undefined; + const sendMessageOptions = capturedTarget + ? { target: capturedTarget, ...(delivery ? { delivery } : {}) } + : delivery ? { delivery } : undefined; // Inline review comments and synthetic context are consumed before // assembly so a failed send can restore exactly what it took. @@ -1058,10 +1081,10 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (outgoing.isEmpty) return; // Clear queue and input - if (messageQueueTarget && queuedMessageId) { - removeFromQueue(messageQueueTarget, queuedMessageId); - } else if (messageQueueTarget && hasQueuedMessages) { - clearQueue(messageQueueTarget); + if (capturedTarget && queuedMessageId) { + removeFromQueue(capturedTarget, queuedMessageId); + } else if (capturedTarget && hasQueuedMessages) { + clearQueue(capturedTarget); } if (!queuedOnly) { setMessage(''); @@ -1111,7 +1134,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const compactDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory || undefined; await opencodeClient.summarizeSession(currentSessionId, currentProviderId, currentModelId, compactDirectory); } catch (error) { - toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.compactFailed')); + toast.error(getSubmitErrorMessage(error, t('chat.chatInput.toast.compactFailed'))); } return; } @@ -1143,15 +1166,13 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo ); scrollToBottom?.(); } catch (error) { - toast.error(error instanceof Error ? error.message : t(command.errorToastKey)); + toast.error(getSubmitErrorMessage(error, t(command.errorToastKey))); } return; } } - const currentSessionDirectory = currentSessionId - ? useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory - : currentDirectory; + const currentSessionDirectory = capturedTarget?.directory ?? currentDirectory; const shouldAddResponseStyle = newSessionDraftOpen || (currentSessionId ? !hasUserMessages(currentSessionId, currentSessionDirectory) : false); if (shouldAddResponseStyle) { const responseStyleInstruction = await fetchResponseStyleInstruction().catch(() => null); @@ -1258,6 +1279,14 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo return; } + if (normalized.includes('runtime changed')) { + if (allAttachments.length > 0) { + useInputStore.getState().setAttachedFiles(allAttachments); + } + toast.error(t('chat.chatInput.toast.messageSendFailed')); + return; + } + if (allAttachments.length > 0) { useInputStore.getState().setAttachedFiles(allAttachments); } @@ -2770,11 +2799,15 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo submitting={reviewFlowSubmitting} onConfirm={handleStartReviewFlow} /> - + {attachmentPreviewMounted ? ( + + + + ) : null} {/* Single always-mounted picker input. It must NOT live inside ComposerAttachmentControls: that component mounts once per composer diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 5b45b4b3..5ec9a8ac 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -37,6 +37,7 @@ import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { getContextObligatoryMessages } from '@/lib/contextObligatoryMessages'; import { setContextObligatoryMessage } from '@/sync/session-actions'; import { isVSCodeRuntime } from '@/lib/desktop'; +import { focusChatInput } from './composer/editor/dom'; const ToolOutputDialog = lazyWithChunkRecovery(() => import('./message/ToolOutputDialog')); @@ -416,6 +417,10 @@ const ChatMessage: React.FC = ({ createdAt: messageCreatedAt, role: isUser ? 'user' : 'assistant', }, !isPinnedIntoContext); + // Return focus to the composer so the user can keep typing right + // after adding the message to context (matches the refocus pattern + // used by the model/agent selectors). + requestAnimationFrame(focusChatInput); } catch (error) { console.error('[chat-message] failed to update context pin', error); toast.error(t('chat.messageBody.actions.contextPinFailed')); diff --git a/packages/ui/src/components/chat/DiffPreview.tsx b/packages/ui/src/components/chat/DiffPreview.tsx index 295475c4..c749b033 100644 --- a/packages/ui/src/components/chat/DiffPreview.tsx +++ b/packages/ui/src/components/chat/DiffPreview.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { cn } from '@/lib/utils'; import { getLanguageFromExtension } from '@/lib/toolHelpers'; import { useThemeSystem } from '@/contexts/useThemeSystem'; -import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme'; +import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars'; import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines'; import { parseDiffToUnified } from './message/toolRenderers'; diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 64d0d1b5..0a8bef4c 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -20,7 +20,8 @@ import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants'; import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils'; import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore'; -import { ensureMarkdownShikiTheme, getMarkdownSyntaxVars } from './markdown/markdownTheme'; +import { ensureMarkdownShikiTheme } from './markdown/markdownTheme'; +import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars'; import { attachMarkdownInteractions, applyMarkdownCodeBlockWrapState, diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 3fbf959b..262664b9 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -40,6 +40,11 @@ import { getCurrentIntlLocale, useI18n } from '@/lib/i18n'; import { useOpenCodeReadiness } from '@/hooks/useOpenCodeReadiness'; import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts'; import { markStartupTrace } from '@/lib/startupTrace'; +import { + findLatestUserModelChoice, + shouldPreserveManualModelOverride, +} from '@/lib/messages/userModelChoice'; +import { getSyncParts } from '@/sync/sync-refs'; type IconComponent = IconName; @@ -645,37 +650,14 @@ export const ModelControls: React.FC = ({ currentSessionDirectory ?? undefined, ); const currentSessionMessagesFromSync = useSessionMessages(currentSessionId ?? '', currentSessionDirectory ?? undefined); + // Skip synthetic subagent-completion nudges — restoring from them resets a + // manual model override back to the agent default (issue #2404). const latestLoadedUserChoice = React.useMemo(() => { - for (let i = currentSessionMessagesFromSync.length - 1; i >= 0; i -= 1) { - const message = currentSessionMessagesFromSync[i] as typeof currentSessionMessagesFromSync[number] & { - model?: { providerID?: string; modelID?: string; variant?: string }; - variant?: string; - mode?: string; - }; - if (message.role !== 'user') { - continue; - } - - const providerID = typeof message.model?.providerID === 'string' && message.model.providerID.trim().length > 0 - ? message.model.providerID - : undefined; - const modelID = typeof message.model?.modelID === 'string' && message.model.modelID.trim().length > 0 - ? message.model.modelID - : undefined; - const agent = typeof message.agent === 'string' && message.agent.trim().length > 0 - ? message.agent - : (typeof message.mode === 'string' && message.mode.trim().length > 0 ? message.mode : undefined); - // OpenCode 1.4.0 moved variant from top-level to model.variant. - // Prefer the new location, fall back to the legacy one for older servers. - const variantCandidate = message.model?.variant ?? message.variant; - const variant = typeof variantCandidate === 'string' && variantCandidate.trim().length > 0 - ? variantCandidate - : undefined; - - return { id: message.id, agent, providerID, modelID, variant }; - } - return null; - }, [currentSessionMessagesFromSync]); + return findLatestUserModelChoice( + currentSessionMessagesFromSync, + (messageId) => getSyncParts(messageId, currentSessionDirectory ?? undefined), + ); + }, [currentSessionDirectory, currentSessionMessagesFromSync]); const tryApplyModelSelection = React.useCallback( (providerId: string, modelId: string, agentName?: string): ModelApplyResult => { @@ -828,6 +810,25 @@ export const ModelControls: React.FC = ({ return; } + // Manual session override wins over historical / synthetic message metadata. + const savedSessionModel = getSessionModelSelection(currentSessionId); + if (shouldPreserveManualModelOverride({ + selectionSource: useConfigStore.getState().selectionSource, + savedSessionModel, + candidate: latestLoadedUserChoice, + })) { + if (savedSessionModel) { + applyModelSelectionWithVariant( + savedSessionModel.providerId, + savedSessionModel.modelId, + resolveModelVariantSelection(savedSessionModel.providerId, savedSessionModel.modelId), + currentAgentName || undefined, + ); + } + latestLoadedUserChoiceRestoreRef.current = restoreKey; + return; + } + if (latestLoadedUserChoice.agent && currentAgentName !== latestLoadedUserChoice.agent) { setAgent(latestLoadedUserChoice.agent); } @@ -869,6 +870,8 @@ export const ModelControls: React.FC = ({ setAgent, applyModelSelectionWithVariant, getModelVariantOptions, + getSessionModelSelection, + resolveModelVariantSelection, saveSessionAgentSelection, saveAgentModelVariantForSession, saveSessionModelSelection, diff --git a/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx b/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx index f3715dd7..41a2825f 100644 --- a/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx +++ b/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx @@ -130,6 +130,16 @@ function insertedTextOf(transaction: { changes: { iterChanges: (fn: (fromA: numb return inserted; } +/** + * True for keydown events CodeMirror re-dispatches after deferring the real + * one (iOS Enter/Backspace/Delete, Chrome Android Enter): `dispatchKey` + * stamps the replacement event with a `synthetic` expando. These events are + * built from the key name alone, so they carry no modifier keys. + */ +function isDeferredSyntheticEvent(event: KeyboardEvent): boolean { + return Boolean((event as unknown as { synthetic?: boolean }).synthetic); +} + /** * Compartments are configuration keys, not per-view state, so one set can serve * every editor. They live at module scope because a kept-alive view outlives @@ -160,6 +170,14 @@ export const ComposerEditor = React.forwardRef(null); const viewRef = React.useRef(null); + // The real keydown's shift state for the LAST Enter that reached the + // editor. CodeMirror defers Enter on iOS (and Chrome Android) and + // re-dispatches it as a synthetic keydown built from the key name + // alone, dropping every modifier (see `trackRealEnterShift` and the + // `interceptKeys` handler below); this ref is what lets the deferred + // event still tell Shift+Enter from Enter. + const lastRealEnterShiftRef = React.useRef(false); + // Callbacks reach the CodeMirror extensions through a ref: the view is // built once and must not be torn down when a handler identity changes, // which would drop focus mid-typing. When a view store is supplied the @@ -200,7 +218,15 @@ export const ComposerEditor = React.forwardRef handlersRef.current.onKeyDown?.(event) ?? false, + any: (_view, event) => { + // A deferred Enter lost its modifiers in the re-dispatch; + // give the caller's policy (Enter vs Shift+Enter) back the + // shift state it saw on the real keydown. + if (event.key === 'Enter' && isDeferredSyntheticEvent(event) && lastRealEnterShiftRef.current) { + Object.defineProperty(event, 'shiftKey', { value: true }); + } + return handlersRef.current.onKeyDown?.(event) ?? false; + }, }]; const view = new EditorView({ @@ -276,6 +302,25 @@ export const ComposerEditor = React.forwardRef { + if (event.key !== 'Enter' || isDeferredSyntheticEvent(event)) return; + lastRealEnterShiftRef.current = event.shiftKey; + }; + view.contentDOM.addEventListener('keydown', trackRealEnterShift); + return () => { viewRef.current = null; // A stored view is detached, not destroyed: the store owns its diff --git a/packages/ui/src/components/chat/markdown/markdownSyntaxVars.ts b/packages/ui/src/components/chat/markdown/markdownSyntaxVars.ts new file mode 100644 index 00000000..19de389a --- /dev/null +++ b/packages/ui/src/components/chat/markdown/markdownSyntaxVars.ts @@ -0,0 +1,31 @@ +import type { Theme } from '@/types/theme'; + +/** + * Build the `--md-syntax-*` CSS custom properties for the given app theme. + * Apply the result as inline styles on the markdown container so the static + * Shiki theme resolves to the active palette. + * + * Lives apart from `markdownTheme.ts` because that module imports + * `@pierre/diffs` for theme registration; eager consumers of these CSS vars + * (tool output, code blocks) must not pull that stack into the startup graph. + */ +export const getMarkdownSyntaxVars = (theme: Theme): Record => { + const base = theme.colors.syntax.base; + const tokens = theme.colors.syntax.tokens ?? {}; + const status = theme.colors.status; + + return { + '--md-syntax-foreground': base.foreground, + '--md-syntax-comment': base.comment, + '--md-syntax-string': base.string, + '--md-syntax-number': base.number, + '--md-syntax-keyword': base.keyword, + '--md-syntax-operator': base.operator, + '--md-syntax-function': base.function, + '--md-syntax-type': base.type, + '--md-syntax-variable': base.variable, + '--md-syntax-property': tokens.variableProperty ?? base.variable, + '--md-syntax-inserted': status.success, + '--md-syntax-deleted': status.error, + }; +}; diff --git a/packages/ui/src/components/chat/markdown/markdownTheme.ts b/packages/ui/src/components/chat/markdown/markdownTheme.ts index 30044c8e..1b40d5d1 100644 --- a/packages/ui/src/components/chat/markdown/markdownTheme.ts +++ b/packages/ui/src/components/chat/markdown/markdownTheme.ts @@ -1,5 +1,4 @@ import { registerCustomTheme, type ThemeRegistrationResolved } from '@pierre/diffs'; -import type { Theme } from '@/types/theme'; import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition'; // The static Shiki theme name. Its definition (token colors referencing @@ -27,29 +26,3 @@ export const ensureMarkdownShikiTheme = (): void => { Promise.resolve(MARKDOWN_SHIKI_THEME_DEFINITION as unknown as ThemeRegistrationResolved), ); }; - -/** - * Build the `--md-syntax-*` CSS custom properties for the given app theme. - * Apply the result as inline styles on the markdown container so the static - * Shiki theme resolves to the active palette. - */ -export const getMarkdownSyntaxVars = (theme: Theme): Record => { - const base = theme.colors.syntax.base; - const tokens = theme.colors.syntax.tokens ?? {}; - const status = theme.colors.status; - - return { - '--md-syntax-foreground': base.foreground, - '--md-syntax-comment': base.comment, - '--md-syntax-string': base.string, - '--md-syntax-number': base.number, - '--md-syntax-keyword': base.keyword, - '--md-syntax-operator': base.operator, - '--md-syntax-function': base.function, - '--md-syntax-type': base.type, - '--md-syntax-variable': base.variable, - '--md-syntax-property': tokens.variableProperty ?? base.variable, - '--md-syntax-inserted': status.success, - '--md-syntax-deleted': status.error, - }; -}; diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index ae701111..d2e04411 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -284,16 +284,15 @@ const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => { const SHELL_CODE_TAG_STYLE: React.CSSProperties = { background: 'transparent', backgroundColor: 'transparent' }; const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part }) => { - const [expanded, setExpanded] = React.useState(false); + const output = typeof part.shellAction?.output === 'string' ? part.shellAction.output : ''; + const [expanded, setExpanded] = React.useState(true); const [copiedOutput, setCopiedOutput] = React.useState(false); const copiedResetTimeoutRef = React.useRef(null); const { t } = useI18n(); const command = typeof part.shellAction?.command === 'string' ? part.shellAction.command.trim() : ''; - const output = typeof part.shellAction?.output === 'string' ? part.shellAction.output : ''; const status = typeof part.shellAction?.status === 'string' ? part.shellAction.status.trim().toLowerCase() : ''; const hasOutput = output.trim().length > 0; - const clearCopiedResetTimeout = React.useCallback(() => { if (copiedResetTimeoutRef.current !== null && typeof window !== 'undefined') { window.clearTimeout(copiedResetTimeoutRef.current); diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md index 466942f3..d28450ea 100644 --- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md @@ -59,6 +59,7 @@ Use this doc when you ask an agent to change tool/header/description behavior. - Every other tool, including search/fetch, OpenCode built-ins, custom tools, plugins, and MCP tools, is **expandable** and renders through `ToolPart`. - The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card. - `ToolPart` defers expanded content after a user toggle, preventing large tool input/output payloads from mounting during the initial chat render. +- The rich tool diff preview lives in `ToolPartDiffPreview.tsx` and is lazy-loaded from `ToolPart`. It is the only tool-card piece that imports the `@pierre/diffs` + Shiki rendering stack, keeping that stack out of the eager chat startup graph. While its chunk loads (first rendered diff only) the plain-text patch from `PlainDiffFallback.tsx` renders as the Suspense fallback, mirroring the preview's error fallback. `ToolPart` itself must not statically import `@pierre/diffs` runtime modules or `@/lib/shiki/appThemeRegistry`. - Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its fixed-height output viewport follows new output until the user scrolls up, then resumes following when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output normalizes ANSI terminal controls with a bounded synthetic-cell budget, bypasses the throttle, and receives the normal one-time highlighted rendering. - Thinking/Justification duration is hidden in `sorted` mode (handled in `ReasoningPart.tsx` + `JustificationBlock.tsx`). @@ -93,7 +94,7 @@ Why: only navigation tools use the compact static path; all other tools need obs ## Quick map of files in this folder - Text: `AssistantTextPart.tsx`, `UserTextPart.tsx` -- Tools: `ToolPart.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx` +- Tools: `ToolPart.tsx`, `ToolPartDiffPreview.tsx`, `PlainDiffFallback.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx` - Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx` - Status/placeholders: `WorkingPlaceholder.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`, `BusyDots.tsx` - Utility renderers: `VirtualizedCodeBlock.tsx`, `MinDurationShineText.tsx` diff --git a/packages/ui/src/components/chat/message/parts/PlainDiffFallback.tsx b/packages/ui/src/components/chat/message/parts/PlainDiffFallback.tsx new file mode 100644 index 00000000..6e6e91e8 --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/PlainDiffFallback.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +/** + * Plain-text patch rendering used when the rich `@pierre/diffs` preview is + * unavailable: non-diff render modes, preview errors, and while the lazily + * loaded diff preview chunk is still downloading. Lives in its own module so + * `ToolPart` can render it without importing the @pierre/diffs stack. + */ +export const PlainDiffFallback: React.FC<{ diff: string }> = ({ diff }) => ( +
+        {diff}
+    
+); diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index ee4983de..c5a2e4a3 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { useMobileAppActions } from '@/apps/mobileAppContext'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; -import { PatchDiff } from '@pierre/diffs/react'; import { cn } from '@/lib/utils'; import { SimpleMarkdownRenderer } from '../../MarkdownRenderer'; import { MessageFilesDisplay } from '../../FileAttachment'; @@ -10,7 +9,6 @@ import { getToolMetadata } from '@/lib/toolHelpers'; import type { ToolPart as ToolPartType, ToolState as ToolStateUnion, FilePart } from '@opencode-ai/sdk/v2'; import { toolDisplayStyles } from '@/lib/typography'; import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode'; -import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionMessageRecords, useEnsureSessionMessages } from '@/sync/sync-context'; @@ -24,8 +22,8 @@ import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { copyTextToClipboard } from '@/lib/clipboard'; import type { ContentChangeReason } from '@/hooks/useChatAutoFollow'; import type { ToolPopupContent } from '../types'; -import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry'; -import { getDefaultTheme } from '@/lib/theme/themes'; +import { PlainDiffFallback } from './PlainDiffFallback'; +import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import { formatEditOutput, @@ -42,7 +40,7 @@ import { DiffViewToggle, type DiffViewMode } from '../DiffViewToggle'; import { MinDurationShineText } from './MinDurationShineText'; import { ToolRevealOnMount } from './ToolRevealOnMount'; import { getToolIcon } from './toolPresentation'; -import { useDurationTickerNow } from './useDurationTicker'; +import { useDurationTickerNow } from '@/hooks/useDurationTicker'; import { buildTaskSummaryEntriesFromSession, normalizeTaskSummaryEntries, @@ -385,40 +383,6 @@ const getToolDiagnosticSection = ( }; }; -const usePierreThemeConfig = () => { - const themeSystem = useOptionalThemeSystem(); - const fallbackLightTheme = React.useMemo(() => getDefaultTheme(false), []); - const fallbackDarkTheme = React.useMemo(() => getDefaultTheme(true), []); - - const availableThemes = React.useMemo( - () => themeSystem?.availableThemes ?? [fallbackLightTheme, fallbackDarkTheme], - [fallbackDarkTheme, fallbackLightTheme, themeSystem?.availableThemes], - ); - const lightThemeId = themeSystem?.lightThemeId ?? fallbackLightTheme.metadata.id; - const darkThemeId = themeSystem?.darkThemeId ?? fallbackDarkTheme.metadata.id; - - const lightTheme = React.useMemo( - () => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? fallbackLightTheme, - [availableThemes, fallbackLightTheme, lightThemeId], - ); - const darkTheme = React.useMemo( - () => availableThemes.find((theme) => theme.metadata.id === darkThemeId) ?? fallbackDarkTheme, - [availableThemes, darkThemeId, fallbackDarkTheme], - ); - - React.useEffect(() => { - ensurePierreThemeRegistered(lightTheme); - ensurePierreThemeRegistered(darkTheme); - }, [darkTheme, lightTheme]); - - const currentVariant = themeSystem?.currentTheme.metadata.variant ?? 'light'; - - return { - pierreTheme: { light: lightTheme.metadata.id, dark: darkTheme.metadata.id }, - pierreThemeType: currentVariant === 'dark' ? ('dark' as const) : ('light' as const), - }; -}; - // Parse question tool output: "User has answered your questions: "Q1"="A1", "Q2"="A2". You can now..." const parseQuestionOutput = (output: string): Array<{ question: string; answer: string }> | null => { const match = output.match(/^User has answered your questions:\s*(.+?)\.\s*You can now/s); @@ -1138,30 +1102,6 @@ const TaskToolSummary: React.FC<{ ); }; -interface DiffPreviewProps { - diff: string; - pierreTheme: { light: string; dark: string }; - pierreThemeType: 'light' | 'dark'; - diffViewMode: DiffViewMode; -} - -const TOOL_DIFF_UNSAFE_CSS = ` - [data-diff-header], - [data-diff] { - [data-separator] { - height: 24px !important; - } - } -`; - -const TOOL_DIFF_METRICS = { - hunkLineCount: 50, - lineHeight: 24, - diffHeaderHeight: 44, - hunkSeparatorHeight: 24, - spacing: 0, -}; - const TOOL_COLLAPSED_CUSTOM_STYLE: React.CSSProperties = { ...toolDisplayStyles.getCollapsedStyles(), padding: 0, @@ -1260,85 +1200,18 @@ const renderAnimatedPathWithIcon = (path: string, animate = true, grow = true, s ); }; -const PlainDiffFallback: React.FC<{ diff: string }> = ({ diff }) => ( -
-        {diff}
-    
+// The rich diff preview is the only tool-card piece that needs the +// @pierre/diffs + Shiki stack; lazy-loading it keeps that stack out of the +// eager chat graph. While the chunk loads, the plain-text patch renders as the +// Suspense fallback, mirroring the preview's own error fallback. +const LazyToolPartDiffPreview = lazyWithChunkRecovery(() => import('./ToolPartDiffPreview')); + +const DiffPreview: React.FC<{ diff: string; diffViewMode: DiffViewMode }> = ({ diff, diffViewMode }) => ( + }> + + ); -class DiffPreviewErrorBoundary extends React.Component<{ - resetKey: string; - fallback: React.ReactNode; - children: React.ReactNode; -}, { hasError: boolean }> { - state = { hasError: false }; - - static getDerivedStateFromError(): { hasError: boolean } { - return { hasError: true }; - } - - componentDidUpdate(prevProps: { resetKey: string }) { - if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) { - this.setState({ hasError: false }); - } - } - - componentDidCatch(error: Error) { - if (process.env.NODE_ENV === 'development') { - console.warn('Tool diff preview failed; rendering raw patch instead.', error); - } - } - - render() { - if (this.state.hasError) { - return this.props.fallback; - } - return this.props.children; - } -} - -const DiffPreview: React.FC = React.memo(({ diff, pierreTheme, pierreThemeType, diffViewMode }) => { - const options = React.useMemo( - () => ({ - diffStyle: diffViewMode === 'side-by-side' ? 'split' as const : 'unified' as const, - diffIndicators: 'none' as const, - hunkSeparators: 'line-info-basic' as const, - lineDiffType: 'none' as const, - disableFileHeader: true, - maxLineDiffLength: 1000, - expansionLineCount: 20, - overflow: 'wrap' as const, - theme: pierreTheme, - themeType: pierreThemeType, - unsafeCSS: TOOL_DIFF_UNSAFE_CSS, - }), - [diffViewMode, pierreTheme, pierreThemeType] - ); - - const fallback = ; - - return ( -
- - - -
- ); -}); - -DiffPreview.displayName = 'DiffPreview'; - interface ToolExpandedContentProps { part: ToolPartType; state: ToolStateUnion; @@ -1357,7 +1230,6 @@ const ToolExpandedContent: React.FC = React.memo(({ const { t } = useI18n(); const runtime = React.useContext(RuntimeAPIContext); const mobileActions = useMobileAppActions(); - const { pierreTheme, pierreThemeType } = usePierreThemeConfig(); const [diffViewMode, setDiffViewMode] = React.useState('unified'); const stateWithData = state as ToolStateWithMetadata; const metadata = stateWithData.metadata; @@ -1633,8 +1505,6 @@ const ToolExpandedContent: React.FC = React.memo(({ {entry.renderMode === 'diff' ? ( ) : ( @@ -1753,8 +1623,6 @@ const ToolExpandedContent: React.FC = React.memo(({ ) : isWriteLikeTool && writeLikeInputPatch ? ( ) : ( diff --git a/packages/ui/src/components/chat/message/parts/ToolPartDiffPreview.tsx b/packages/ui/src/components/chat/message/parts/ToolPartDiffPreview.tsx new file mode 100644 index 00000000..7cfd6592 --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/ToolPartDiffPreview.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { PatchDiff } from '@pierre/diffs/react'; + +import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; +import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry'; +import { getDefaultTheme } from '@/lib/theme/themes'; +import type { DiffViewMode } from '../DiffViewToggle'; +import { PlainDiffFallback } from './PlainDiffFallback'; + +// Loaded lazily from ToolPart: this is the only part of the tool card that +// needs @pierre/diffs' rendering stack (Shiki core + regex engines), so the +// eager chat graph stays free of it and the chunk downloads on the first +// rendered tool diff. + +const TOOL_DIFF_UNSAFE_CSS = ` + [data-diff-header], + [data-diff] { + [data-separator] { + height: 24px !important; + } + } +`; + +const TOOL_DIFF_METRICS = { + hunkLineCount: 50, + lineHeight: 24, + diffHeaderHeight: 44, + hunkSeparatorHeight: 24, + spacing: 0, +}; + +const usePierreThemeConfig = () => { + const themeSystem = useOptionalThemeSystem(); + const fallbackLightTheme = React.useMemo(() => getDefaultTheme(false), []); + const fallbackDarkTheme = React.useMemo(() => getDefaultTheme(true), []); + + const availableThemes = React.useMemo( + () => themeSystem?.availableThemes ?? [fallbackLightTheme, fallbackDarkTheme], + [fallbackDarkTheme, fallbackLightTheme, themeSystem?.availableThemes], + ); + const lightThemeId = themeSystem?.lightThemeId ?? fallbackLightTheme.metadata.id; + const darkThemeId = themeSystem?.darkThemeId ?? fallbackDarkTheme.metadata.id; + + const lightTheme = React.useMemo( + () => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? fallbackLightTheme, + [availableThemes, fallbackLightTheme, lightThemeId], + ); + const darkTheme = React.useMemo( + () => availableThemes.find((theme) => theme.metadata.id === darkThemeId) ?? fallbackDarkTheme, + [availableThemes, darkThemeId, fallbackDarkTheme], + ); + + // Registration is synchronous module state inside @pierre/diffs; rendering + // a PatchDiff with these theme ids requires it to have happened first, so + // register during render rather than in an effect. + ensurePierreThemeRegistered(lightTheme); + ensurePierreThemeRegistered(darkTheme); + + const currentVariant = themeSystem?.currentTheme.metadata.variant ?? 'light'; + + return { + pierreTheme: { light: lightTheme.metadata.id, dark: darkTheme.metadata.id }, + pierreThemeType: currentVariant === 'dark' ? ('dark' as const) : ('light' as const), + }; +}; + +class DiffPreviewErrorBoundary extends React.Component<{ + resetKey: string; + fallback: React.ReactNode; + children: React.ReactNode; +}, { hasError: boolean }> { + state = { hasError: false }; + + static getDerivedStateFromError(): { hasError: boolean } { + return { hasError: true }; + } + + componentDidUpdate(prevProps: { resetKey: string }) { + if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) { + this.setState({ hasError: false }); + } + } + + componentDidCatch(error: Error) { + if (process.env.NODE_ENV === 'development') { + console.warn('Tool diff preview failed; rendering raw patch instead.', error); + } + } + + render() { + if (this.state.hasError) { + return this.props.fallback; + } + return this.props.children; + } +} + +export interface ToolPartDiffPreviewProps { + diff: string; + diffViewMode: DiffViewMode; +} + +const ToolPartDiffPreview: React.FC = React.memo(({ diff, diffViewMode }) => { + const { pierreTheme, pierreThemeType } = usePierreThemeConfig(); + const options = React.useMemo( + () => ({ + diffStyle: diffViewMode === 'side-by-side' ? 'split' as const : 'unified' as const, + diffIndicators: 'none' as const, + hunkSeparators: 'line-info-basic' as const, + lineDiffType: 'none' as const, + disableFileHeader: true, + maxLineDiffLength: 1000, + expansionLineCount: 20, + overflow: 'wrap' as const, + theme: pierreTheme, + themeType: pierreThemeType, + unsafeCSS: TOOL_DIFF_UNSAFE_CSS, + }), + [diffViewMode, pierreTheme, pierreThemeType] + ); + + const fallback = ; + + return ( +
+ + + +
+ ); +}); + +ToolPartDiffPreview.displayName = 'ToolPartDiffPreview'; + +export default ToolPartDiffPreview; diff --git a/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx b/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx index 11e7763f..5ef116d7 100644 --- a/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx +++ b/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx @@ -13,7 +13,7 @@ import React from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useThemeSystem } from '@/contexts/useThemeSystem'; -import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme'; +import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars'; import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines'; // ── Threshold: files smaller than this render without virtualization ── diff --git a/packages/ui/src/components/code/WorkerHighlightedCode.tsx b/packages/ui/src/components/code/WorkerHighlightedCode.tsx index 441972d8..60c5e3dd 100644 --- a/packages/ui/src/components/code/WorkerHighlightedCode.tsx +++ b/packages/ui/src/components/code/WorkerHighlightedCode.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { cn } from '@/lib/utils'; import { useThemeSystem } from '@/contexts/useThemeSystem'; -import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme'; +import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars'; import { highlightCodeInWorker } from '@/components/chat/markdown/markdown-worker'; // Shared static code highlighter backed by the markdown Shiki Web Worker. diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index f48dc445..a8d5b159 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -4,13 +4,18 @@ import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { DiffViewIcon } from '@/components/icons/DiffIcon'; import { Button } from '@/components/ui/button'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; -import { DiffView } from '@/components/views/DiffView'; -import { FilesView } from '@/components/views/FilesView'; -import { GitView } from '@/components/views/GitView'; import { PullRequestView } from '@/components/views/PullRequestView'; import { TerminalView } from '@/components/views/TerminalView'; -import { WalkthroughView } from '@/components/views/walkthrough/WalkthroughView'; -import { PlanView } from '@/components/views/PlanView'; +import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; + +// Heavy views stay on-demand (same as MainLayout): importing DiffView/FilesView +// or the walkthrough statically pulls the CodeMirror and @pierre/diffs stacks +// into the eager startup graph even when no such tab is open. +const WalkthroughView = lazyWithChunkRecovery(() => import('@/components/views/walkthrough/WalkthroughView').then((m) => ({ default: m.WalkthroughView }))); +const DiffView = lazyWithChunkRecovery(() => import('@/components/views/DiffView').then((m) => ({ default: m.DiffView }))); +const FilesView = lazyWithChunkRecovery(() => import('@/components/views/FilesView').then((m) => ({ default: m.FilesView }))); +const GitView = lazyWithChunkRecovery(() => import('@/components/views/GitView').then((m) => ({ default: m.GitView }))); +const PlanView = lazyWithChunkRecovery(() => import('@/components/views/PlanView').then((m) => ({ default: m.PlanView }))); import { ProjectContextPanel } from './RightSidebarTabs'; import { SidebarFilesTree } from './SidebarFilesTree'; import { useThemeSystem } from '@/contexts/useThemeSystem'; @@ -45,6 +50,7 @@ import { type EmbeddedSessionRuntimeBootstrap, } from './contextPanelEmbeddedChat'; import { getContextSurfaceWidthFraction } from '@/lib/surfaces/registry'; +import { isTerminalEventTarget } from '@/lib/terminalFocus'; import { type PreviewElementMetadata, isPreviewElementMetadata, @@ -2453,6 +2459,13 @@ export const ContextPanel: React.FC = () => { return; } + // Terminal owns Escape so the PTY receives it (e.g. Vim Normal mode). + // ghostty-web listens in the bubble phase; stopping capture here would + // swallow the key before the terminal ever sees it (issue #2644). + if (isTerminalEventTarget(event.target)) { + return; + } + event.preventDefault(); event.stopPropagation(); handleClose(); @@ -2691,13 +2704,13 @@ export const ContextPanel: React.FC = () => { const activeNonChatContent = activeTab?.mode === 'context' ? : activeTab?.mode === 'git' - ? + ? : activeTab?.mode === 'pr' ? : activeTab?.mode === 'notes' ? : activeTab?.mode === 'plan' - ? + ? : activeTab?.mode === 'preview' ? openContextPreview(effectiveDirectory, url)} /> : ( @@ -2909,7 +2922,7 @@ export const ContextPanel: React.FC = () => {
{hasOpenEditorFile ? ( - + ) : (
@@ -2975,16 +2988,18 @@ export const ContextPanel: React.FC = () => { activeTab?.id !== tab.id && 'hidden' )} > - + + +
))} {hasTerminalTab ? ( @@ -2994,7 +3009,9 @@ export const ContextPanel: React.FC = () => { ) : null} {hasWalkthroughTab ? (
- + + +
) : null} {activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' && activeTab?.mode !== 'diff' && activeTab?.mode !== 'terminal' && activeTab?.mode !== 'walkthrough' ? activeNonChatContent : null} diff --git a/packages/ui/src/components/layout/ContextPanelRail.tsx b/packages/ui/src/components/layout/ContextPanelRail.tsx index 5d522e5c..f7f5ce0d 100644 --- a/packages/ui/src/components/layout/ContextPanelRail.tsx +++ b/packages/ui/src/components/layout/ContextPanelRail.tsx @@ -24,18 +24,23 @@ import { useDeviceInfo } from '@/lib/device'; import { isVSCodeRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; import { + getVisibleContextRailSurfaces, sortContextSurfaces, type ContextSurfaceDescriptor, } from '@/lib/surfaces/registry'; +import { + getEffectiveShortcutPrefix, + isShortcutPrefixHeld, +} from '@/lib/shortcuts'; import { cn } from '@/lib/utils'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useGitStatus } from '@/stores/useGitStore'; import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore'; const RAIL_TOOLTIP_DELAY_MS = 150; -// Tablet width and up: below this the walkthrough cannot show a stop and its -// code side by side, which is the whole point of the surface. -const WALKTHROUGH_MIN_WIDTH = 768; +// Hold the surface-switch modifier for this long before revealing the order +// number badges on the rail icons. +const RAIL_NUMBER_HOLD_DELAY_MS = 500; const EMPTY_TABS: never[] = []; type RailItemProps = { @@ -44,21 +49,40 @@ type RailItemProps = { showActivityDot: boolean; label: string; description: string; + /** Numeric badge (e.g. the Git changed-files count); takes precedence over the activity dot. */ + badgeCount?: number | null; + /** Accessible label that includes the badge count; falls back to `label`. */ + badgeAriaLabel?: string | null; + /** Extra tooltip line describing the badge; rendered under the description. */ + badgeDescription?: string | null; + orderNumber?: number | null; + showOrderNumber?: boolean; onSelect: (surface: ContextSurfaceDescriptor) => void; }; +// The badge corner is 16px tall; cap large counts so the pill stays compact +// on the 36px rail button (matching the order-number badge's footprint). +const formatRailBadgeCount = (count: number): string => (count > 99 ? '99+' : String(count)); + const ContextPanelRailItem: React.FC = ({ surface, isActive, showActivityDot, label, description, + badgeCount, + badgeAriaLabel, + badgeDescription, + orderNumber, + showOrderNumber, onSelect, }) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: surface.id, }); + const displayBadgeCount = badgeCount != null && badgeCount > 0 ? formatRailBadgeCount(badgeCount) : null; + return (
= ({ {...attributes} {...listeners} onClick={() => onSelect(surface)} - aria-label={label} + aria-label={badgeAriaLabel ?? label} aria-pressed={isActive} className={cn( 'flex h-9 w-9 touch-none select-none items-center justify-center rounded-md transition-colors', @@ -86,7 +110,21 @@ const ContextPanelRailItem: React.FC = ({ ) : ( )} - {showActivityDot ? ( + {showOrderNumber && orderNumber != null ? ( + + ) : displayBadgeCount ? ( + + ) : showActivityDot ? (
{/* Desktop settings: windowed dialog with blur */} - - - + {settingsWindowMounted ? ( + + + + ) : null} )} diff --git a/packages/ui/src/components/layout/__tests__/contextPanelEscapeClosesTerminal.test.ts b/packages/ui/src/components/layout/__tests__/contextPanelEscapeClosesTerminal.test.ts new file mode 100644 index 00000000..63bca320 --- /dev/null +++ b/packages/ui/src/components/layout/__tests__/contextPanelEscapeClosesTerminal.test.ts @@ -0,0 +1,187 @@ +/** + * Regression guard for https://github.com/openchamber/openchamber/issues/2644 + * + * Escape while focus is inside the terminal must reach the PTY (e.g. Vim + * Normal mode). The context panel still closes on Escape when focus is on + * non-terminal panel chrome. + */ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const contextPanelSource = readFileSync(join(__dirname, '..', 'ContextPanel.tsx'), 'utf-8'); +const mobileWorkspaceDrawerSource = readFileSync( + join(__dirname, '..', '..', '..', 'apps', 'MobileWorkspaceDrawer.tsx'), + 'utf-8', +); + +describe('issue #2644: Escape in terminal must not close the context panel', () => { + test('the context panel captures Escape at the panel level', () => { + expect(contextPanelSource).toContain('onKeyDownCapture={handlePanelKeyDownCapture}'); + }); + + test('the capture handler skips closing when the event target is inside the terminal', () => { + const start = contextPanelSource.indexOf('const handlePanelKeyDownCapture = React.useCallback('); + expect(start).toBeGreaterThan(-1); + const end = contextPanelSource.indexOf('}, [handleClose]);', start); + expect(end).toBeGreaterThan(start); + const handler = contextPanelSource.slice(start, end); + + expect(handler).toContain("event.key !== 'Escape'"); + expect(handler).toContain('isTerminalEventTarget(event.target)'); + expect(handler).toContain('event.preventDefault()'); + expect(handler).toContain('event.stopPropagation()'); + expect(handler).toContain('handleClose()'); + + // Guard must return before preventDefault/stopPropagation so ghostty-web's + // bubble-phase keydown listener can forward Escape to the PTY. + const guardIndex = handler.indexOf('isTerminalEventTarget(event.target)'); + const preventIndex = handler.indexOf('event.preventDefault()'); + expect(guardIndex).toBeGreaterThan(-1); + expect(preventIndex).toBeGreaterThan(guardIndex); + }); + + test('ContextPanel imports the shared terminal focus helper', () => { + expect(contextPanelSource).toContain("from '@/lib/terminalFocus'"); + expect(contextPanelSource).toContain('isTerminalEventTarget'); + }); + + test('mobile drawer keeps its terminal Escape exception', () => { + const handlerStart = mobileWorkspaceDrawerSource.indexOf("if (event.key === 'Escape'"); + expect(handlerStart).toBeGreaterThan(-1); + const handler = mobileWorkspaceDrawerSource.slice(handlerStart, handlerStart + 200); + expect(handler).toContain("tabRef.current !== 'terminal'"); + }); +}); + +type Listener = { capture: boolean; onEvent: (event: SimulatedEvent) => void }; +type SimulatedEvent = { + type: string; + defaultPrevented: boolean; + propagationStopped: boolean; + target: SimNode; + preventDefault(): void; + stopPropagation(): void; +}; + +class SimNode { + readonly children: SimNode[] = []; + private listeners: Listener[] = []; + private parent: SimNode | null = null; + + addListener(listener: Listener): void { + this.listeners.push(listener); + } + + attach(child: SimNode): void { + child.parent = this; + this.children.push(child); + } + + dispatch(type: string): SimulatedEvent { + const buildPath = (target: SimNode): SimNode[] => { + const ancestors: SimNode[] = []; + let cursor: SimNode | null = target; + while (cursor !== null) { + ancestors.push(cursor); + cursor = cursor.parent; + } + ancestors.reverse(); + return ancestors; + }; + const path = buildPath(this); + + const event: SimulatedEvent = { + type, + defaultPrevented: false, + propagationStopped: false, + target: this, + preventDefault() { + event.defaultPrevented = true; + }, + stopPropagation() { + event.propagationStopped = true; + }, + }; + + for (let i = 0; i < path.length; i += 1) { + if (event.propagationStopped) return event; + for (const listener of path[i].listeners) { + if (!listener.capture) continue; + listener.onEvent(event); + if (event.propagationStopped) return event; + } + } + for (let i = path.length - 1; i >= 0; i -= 1) { + if (event.propagationStopped) return event; + for (const listener of path[i].listeners) { + if (listener.capture) continue; + listener.onEvent(event); + if (event.propagationStopped) return event; + } + } + return event; + } +} + +describe('issue #2644: fixed Escape propagation to the terminal', () => { + test('when the panel skips terminal Escape, the terminal bubble handler receives it', () => { + const panel = new SimNode(); + const terminalContainer = new SimNode(); + panel.attach(terminalContainer); + + const calls: string[] = []; + const panelEscapeHandler = (event: SimulatedEvent) => { + // Fixed behavior: do not close / stop when the target is the terminal. + if (event.target === terminalContainer) { + calls.push('panel-capture-skipped'); + return; + } + calls.push('panel-capture-closed'); + event.preventDefault(); + event.stopPropagation(); + }; + const terminalKeydownHandler = () => { + calls.push('terminal-bubble'); + }; + + panel.addListener({ capture: true, onEvent: panelEscapeHandler }); + terminalContainer.addListener({ capture: false, onEvent: terminalKeydownHandler }); + + const event = terminalContainer.dispatch('keydown'); + + expect(calls).toEqual(['panel-capture-skipped', 'terminal-bubble']); + expect(event.propagationStopped).toBe(false); + expect(event.defaultPrevented).toBe(false); + }); + + test('Escape outside the terminal still closes via the capture handler', () => { + const panel = new SimNode(); + const headerButton = new SimNode(); + const terminalContainer = new SimNode(); + panel.attach(headerButton); + panel.attach(terminalContainer); + + const calls: string[] = []; + panel.addListener({ + capture: true, + onEvent: (event) => { + if (event.target === terminalContainer) return; + calls.push('panel-capture-closed'); + event.preventDefault(); + event.stopPropagation(); + }, + }); + terminalContainer.addListener({ + capture: false, + onEvent: () => calls.push('terminal-bubble'), + }); + + const event = headerButton.dispatch('keydown'); + expect(calls).toEqual(['panel-capture-closed']); + expect(event.propagationStopped).toBe(true); + expect(event.defaultPrevented).toBe(true); + }); +}); diff --git a/packages/ui/src/components/model-picker/ModelPickerList.tsx b/packages/ui/src/components/model-picker/ModelPickerList.tsx index f06dc190..99080f9c 100644 --- a/packages/ui/src/components/model-picker/ModelPickerList.tsx +++ b/packages/ui/src/components/model-picker/ModelPickerList.tsx @@ -436,7 +436,10 @@ export const ModelPickerList: React.FC = ({ ); const allowedProviderSet = React.useMemo(() => { - if (!allowedProviderIds || allowedProviderIds.length === 0) return null; + // undefined = no restriction; [] = allow none. Treating empty like + // "unrestricted" would resurface providers without a login in pickers that + // intentionally pass the authenticated-only list. + if (!allowedProviderIds) return null; return new Set(allowedProviderIds); }, [allowedProviderIds]); diff --git a/packages/ui/src/components/sections/openchamber/AboutSettings.tsx b/packages/ui/src/components/sections/openchamber/AboutSettings.tsx index 6bffa19c..95488727 100644 --- a/packages/ui/src/components/sections/openchamber/AboutSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/AboutSettings.tsx @@ -9,6 +9,7 @@ import { Icon } from "@/components/icon/Icon"; import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; +import { InstanceServiceUrls } from './InstanceServiceUrls'; import { SettingsSection, SETTINGS_BRAND_TITLE_CLASS, @@ -135,6 +136,7 @@ export const AboutSettings: React.FC = ({ initialUpdateDialo

{t('aboutDialog.openChamberVersionLabel', { version: currentVersion })}

{t('aboutDialog.openCodeVersionLabel', { version: openCodeVersion || t('settings.openchamber.about.state.unknown') })}

+
@@ -278,6 +280,11 @@ export const AboutSettings: React.FC = ({ initialUpdateDialo
)} +
+ {t('settings.openchamber.about.field.instanceUrls')} + +
+