diff --git a/.agents/skills/changelog-authoring/SKILL.md b/.agents/skills/changelog-authoring/SKILL.md index fc0668fd..06c887e3 100644 --- a/.agents/skills/changelog-authoring/SKILL.md +++ b/.agents/skills/changelog-authoring/SKILL.md @@ -1,12 +1,14 @@ --- name: changelog-authoring -description: Use when drafting or updating user-facing CHANGELOG.md entries for the OpenChamber `[Unreleased]` section, including the VS Code extension changelog, summarizing changes since the latest git tag. +description: Use only when the maintainer explicitly asks to update the changelog — then draft the OpenChamber `[Unreleased]` entries (main app and VS Code extension) summarizing changes since the latest git tag. license: MIT compatibility: opencode --- ## Overview +**Gate: an explicit maintainer request.** The changelog is written once per release, by the maintainer, as a single story. Both `CHANGELOG.md` files stay untouched by fixes, features, PR merges, de-slop follow-ups, and every other task — a change lands without a changelog line, and the maintainer folds it in later. Proceed past this point only when the current message asks to update the changelog; otherwise stop and leave both files as they are. + Draft user-facing bullet points for the `## [Unreleased]` section that summarize changes since the latest git tag up to `HEAD`. Two files are maintained: diff --git a/.agents/skills/triage-issues/SKILL.md b/.agents/skills/triage-issues/SKILL.md index 91ae41a9..e75dbb61 100644 --- a/.agents/skills/triage-issues/SKILL.md +++ b/.agents/skills/triage-issues/SKILL.md @@ -7,7 +7,7 @@ Turn an unbounded issue queue into a short list of maintainer decisions. Three p ## Verdicts -- **FIX-READY** — a real bug with a traced mechanism (`root-cause:found` from intake, or traced during this sweep). Ready action: a one-line fix-backlog entry (file:line, mechanism, suggested fix shape) — these accumulate into the sweep's fix list for agents to implement. +- **FIX-READY** — a real bug with a traced mechanism (`root-cause:found` from intake, or traced during this sweep) and **no open PR for it** (see *Existing PR first*). Ready action: a one-line fix-backlog entry (file:line, mechanism, suggested fix shape) — these accumulate into the sweep's fix list for agents to implement. - **NEEDS-REPORTER** — cannot proceed without the reporter. Ready action: the single unanswerable question, posted once; the issue then lives on a clock (close as stale after ~30 days of silence). - **CLOSE-FIXED** — behavior fixed by a merged change. Ready action: close comment naming the commit/PR and the release that carries it. - **CLOSE-DUPLICATE** — same failure as an existing issue. Keep the issue with the better evidence, close the other naming it. @@ -17,6 +17,8 @@ Turn an unbounded issue queue into a short list of maintainer decisions. Three p - **"ні" (declined)** → post the drafted decline comment (with ache salvage where one underlies it) and close as not planned. - A conditional answer ("так, але тільки як настройка", "ні в такому вигляді, але X — так") is folded into the posted comment verbatim in spirit — the maintainer's condition becomes the recorded scope. +**Existing PR first.** Before any verdict that sends an issue toward implementation (FIX-READY, an `accepted` feature), find out whether someone already has the fix in flight: `gh pr list --search " OR OR " --state open`, plus the issue's own timeline (linked PRs, "opened a PR" comments — the reporter's fix is easy to miss when the PR body says `fixes #N` and the issue thread stays silent). The same check gates every close: an issue with an open PR against it is never closed as stale or silently-fixed — the PR is the activity, and its review decides the issue's fate. An open PR moves the issue out of the fix backlog and into the PR queue: the ready action is a verdict on that PR (apply the `pr-review` skill), never a parallel in-house fix. A contributor who reported a bug and fixed it the same day, then watched a duplicate patch land on top, is owed a public apology and a changelog credit; the check costs one command. + ## Phase 1 — Mechanical sweep Fetch all open issues with `gh issue list --limit` above the real count. Bucket cheaply before any deep reading: @@ -27,7 +29,7 @@ Fetch all open issues with `gh issue list --limit` above the real count. Bucket | Dead needs-info | `needs-info` with no reporter reply > 30 days | close as stale | | Duplicate clusters | title/error-string similarity across open issues | CLOSE-DUPLICATE | | Feature wishes | `enhancement` | FEATURE-DECISION or CLOSE-DECLINE | -| Traced bugs | `root-cause:found` | FIX-READY candidates, verify the trace still applies | +| Traced bugs | `root-cause:found` | FIX-READY candidates, verify the trace still applies and no PR is open for it | ### Silently-fixed detection @@ -37,7 +39,7 @@ Many fixes land without linking the issue they resolve, so an issue can sit open 2. **Repro re-run.** When the intake comment carries an inline reproduction script or test, run it against current main. Passing repro = fixed, with the run as evidence. 3. **Symptom search.** Extract the issue's distinctive strings (error messages, function names, user-visible symptom terms) and search `git log --grep`, `CHANGELOG.md`, and merged PR titles/bodies *since the issue's creation date*. -CLOSE-FIXED always names its evidence (commit, PR, or repro run); a hunch that "this area was reworked" downgrades to a comment asking the reporter to retry on current main, keeping the issue open on the needs-reporter clock. +CLOSE-FIXED always names its evidence (commit, PR, or repro run), and a commit counts only when it is reachable from main — `git merge-base --is-ancestor <sha> origin/main` — because `git log` across all refs happily surfaces fixes that live on abandoned branches; a hunch that "this area was reworked" downgrades to a comment asking the reporter to retry on current main, keeping the issue open on the needs-reporter clock. Every issue/PR reference in maintainer-facing reports is a clickable link (`[#3164](https://github.com/openchamber/openchamber/issues/3164)`), never a bare number; each entry carries 2–4 sentences — enough to decide without a follow-up question — and any manual-check note lives inside the entry, never in a separate number-repeating section. An issue where the maintainer already commented or the reporter replied to a question runs in pickup mode: state the thread first, continue it, never re-ask a decided question. diff --git a/.opencode/commands/bug-work.md b/.opencode/commands/bug-work.md index 335dbfb1..974656de 100644 --- a/.opencode/commands/bug-work.md +++ b/.opencode/commands/bug-work.md @@ -7,7 +7,8 @@ Focus, if any: $ARGUMENTS The maintainer wants to fix real bugs without touching the GitHub UI. Run this as a conversation, not a report: 1. **Gather the menu.** `gh issue list --state open --label root-cause:found --json number,title,labels,comments` — bugs whose intake comment cites a traced mechanism with file:line. -2. **Propose 3–5 candidates**, one line each: the user-visible symptom, the traced mechanism (file:line), and rough size. Order by severity: data-loss and regression first, then whatever matches the maintainer's focus (an area, a platform, "щось маленьке"). Ask which to take — batches of related small fixes in one area are welcome. -3. **Verify before fixing.** Anchors age: confirm the cited mechanism still exists on current main (main moves fast). If it is gone, say so and mark the issue for a fixed-close instead of fixing air. -4. **Fix properly.** Follow AGENTS.md instruction order (matching skills — sync bugs demand `sync-state-invariants`, hot paths `performance-engineering`); minimal fix plus a regression test per local precedent; focused validation. -5. **Close the loop.** When the maintainer confirms and asks to commit, include `fixes #<N>` per bug in the commit message so GitHub closes the issues automatically. Never commit or push without being asked. +2. **Check for a PR in flight.** Before proposing anything, look for an open PR that already fixes it (`gh pr list --state open --search "<N> OR <error string>"`, and the issue's linked PRs). A candidate with an open PR is dropped from the menu and named as such — the fix belongs to its author; the work is reviewing their PR with the `pr-review` skill, never re-implementing it. +3. **Propose 3–5 candidates**, one line each: the user-visible symptom, the traced mechanism (file:line), and rough size. Order by severity: data-loss and regression first, then whatever matches the maintainer's focus (an area, a platform, "щось маленьке"). Ask which to take — batches of related small fixes in one area are welcome. +4. **Verify before fixing.** Anchors age: confirm the cited mechanism still exists on current main (main moves fast). If it is gone, say so and mark the issue for a fixed-close instead of fixing air. +5. **Fix properly.** Follow AGENTS.md instruction order (matching skills — sync bugs demand `sync-state-invariants`, hot paths `performance-engineering`); minimal fix plus a regression test per local precedent; focused validation. +6. **Close the loop.** When the maintainer confirms and asks to commit, include `fixes #<N>` per bug in the commit message so GitHub closes the issues automatically. Never commit or push without being asked. diff --git a/.opencode/commands/feature-work.md b/.opencode/commands/feature-work.md index 83f4029c..07dda109 100644 --- a/.opencode/commands/feature-work.md +++ b/.opencode/commands/feature-work.md @@ -7,8 +7,9 @@ Focus, if any: $ARGUMENTS The maintainer wants to start feature work without touching the GitHub UI. Run this as a conversation, not a report: 1. **Gather the menu.** `gh issue list -R openchamber/openchamber --state open --label accepted --json number,title,labels,comments` — these are features the maintainer already approved; the acceptance comment on each records the approved scope ("welcome shape"), which is binding. -2. **Propose 3–5 candidates**, one line each: what the user gets, rough size (small / medium / large by mechanism, never hours), and which areas it touches. Favor small wins and anything the maintainer's focus hints at. Ask which one to take (or accept "surprise me" — then pick the best value-to-size). -3. **Build it properly.** Re-read the issue and its acceptance comment for the approved scope; follow AGENTS.md instruction order (matching skills, owning DOCUMENTATION.md); implement with tests per local precedent; run the focused validation the change class requires. -4. **Close the loop.** When the maintainer confirms it works and asks to commit, include `fixes #<N>` in the commit message so GitHub closes the issue automatically. Never commit or push without being asked. +2. **Check for a PR in flight.** Before proposing anything, look for an open PR that already implements each candidate (`gh pr list --state open --search "<N> OR <title terms>"`, and the issue's linked PRs). If one exists, the feature is taken — say so and offer to review that PR with the `pr-review` skill instead of building a duplicate. +3. **Propose 3–5 candidates**, one line each: what the user gets, rough size (small / medium / large by mechanism, never hours), and which areas it touches. Favor small wins and anything the maintainer's focus hints at. Ask which one to take (or accept "surprise me" — then pick the best value-to-size). +4. **Build it properly.** Re-read the issue and its acceptance comment for the approved scope; follow AGENTS.md instruction order (matching skills, owning DOCUMENTATION.md); implement with tests per local precedent; run the focused validation the change class requires. +5. **Close the loop.** When the maintainer confirms it works and asks to commit, include `fixes #<N>` in the commit message so GitHub closes the issue automatically. Never commit or push without being asked. If nothing carries the `accepted` label yet, say so and suggest running `/triage-issues enhancements` first to build the menu. diff --git a/AGENTS.md b/AGENTS.md index 92a88286..cae060a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,7 @@ Shared contracts must define intentional behavior for every applicable runtime: - Do not add dependencies unless explicitly requested. - Never add or log secrets, bearer tokens, pairing credentials, or sensitive user data. - Keep changes minimal and preserve unrelated worktree changes. +- `CHANGELOG.md` and `packages/vscode/CHANGELOG.md` are the maintainer's release-time work: they get written once, as one story, when the maintainer asks to update the changelog. Until that request, treat both files as read-only — a fix, feature, or merged PR lands without a changelog line. - Enforce security and correctness in core/runtime logic, not only UI visibility or prompts. - Keep entrypoints and bridges thin; place domain logic in focused owning modules. - Update owning documentation when module ownership, contracts, or invariants change. @@ -100,7 +101,7 @@ process violation. | Settings UI, settings dialogs, configuration surfaces, or settings search | `settings-ui-patterns` | | Sortable or drag-to-reorder behavior, especially `@dnd-kit` and touch/wrapping layouts | `drag-to-reorder` | | iOS Simulator build, launch, preview, gestures, or `serve-sim` control | `serve-sim` | -| Drafting or updating user-facing CHANGELOG entries for the `[Unreleased]` section (main app or VS Code extension) | `changelog-authoring` | +| The maintainer explicitly asks to update the changelog (main app or VS Code extension) — the only time either CHANGELOG is edited | `changelog-authoring` | | Creating or editing skills, `AGENTS.md`, or docs reached through agent instructions/context pointers | `writing-for-agents` | | Reviewing a single pull request or drafting a PR verdict/close/review comment | `pr-review` | | Triaging, cleaning up, or batch-processing the open PR queue | `triage-prs` | diff --git a/CHANGELOG.md b/CHANGELOG.md index 0203c741..dcbc2100 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,40 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -- Work status: the session cost now counts what its subagents spent, with a line under the context meter splitting the session's own cost from the subagents' share, and each subagent's cost shown next to it in the Subagents list. Previously a session that delegated most of its work looked far cheaper than it was. -- Git/Worktrees: session menus can now move an idle session and its sub-sessions into an existing worktree, and opening the target list discovers worktrees created outside OpenChamber without a restart (thanks to @mattv8). -- Files: opening a file over 5,000 lines is no longer blocked — the line-count guard now allows up to 20,000 lines, letting large files reach the virtualized full-file preview instead of being rejected at the open step (thanks @gaojunran). +- **Turkish interface:** OpenChamber can now be used in Turkish (thanks to @fitzgpt). +- **Git/Worktrees:** session menus can now move an idle session and its sub-sessions into an existing worktree. OpenChamber discovers worktrees created elsewhere when the target list opens, asks before transferring uncommitted changes, and keeps those changes safe if a move fails partway (thanks to @mattv8). +- **`/btw` side questions:** a btw session now answers the side question instead of carrying on with the parent's plan, and forks at the last completed turn so a reply that is still streaming is never inherited (thanks to @pocharlies). +- Chat scrolling: with "Follow new content while streaming" off, sending from the middle of a conversation no longer jumps to the new message; a middle-button pan or Shift+Space stops auto-follow like the wheel does, and an upward wheel inside a tool output box scrolls that box instead of the chat (thanks to @pascalandr); PageUp/PageDown in the prompt box no longer shifts the whole window up and hides the title bar. +- Chat no longer crashes or freezes on: very large tool results, which are capped before rendering (thanks to @JSap0914); a code block with JavaScript template strings, which could send the syntax highlighter into endless backtracking (thanks to @makeittech); a diff with a truncated header (thanks to @pascalandr); and a draft or recalled message containing Windows line endings, which threw "Selection points outside of document" on every visit (thanks to @yulia-ivashko). +- Chat: a session no longer looks frozen after a page reload or a late second client — pending permission and question cards come back (thanks to @yangyaofei) — nor after dismissing the agent's questions and sending a new task (thanks to @bashrusakh). +- Work status: the session cost now includes what its subagents spent, split under the context meter and shown per subagent (thanks to @igorvelho), and undoing or redoing a parent session keeps its subagents at the same point in history (thanks to @alexandrereyes). +- Chat rendering: question prompts render Markdown (thanks to @pascalandr); bare links next to CJK or full-width punctuation no longer absorb it (thanks to @gaojunran); inline code and chips are readable in every theme (thanks to @difagume); a completed reasoning block shows in full instead of replaying as if still thinking, the text-selection menu stays inside the viewport, and the sticky user-message header no longer fades over the first lines of the reply (thanks to @makeittech). +- Chat actions: tool cards with a file path get a quick-open button (thanks to @robertoberto); sending without a selected model explains what is missing (thanks to @rvaldemar); `/init` stays in slash-command autocomplete after the conversation starts (thanks to @Dawnfz-Lenfeng); copying a message keeps Markdown paragraph, list, and code-block spacing (thanks to @ChangeHow); Ctrl/Cmd+digit is ignored while typing in a field, and a manually chosen model survives switching between Build and Plan (thanks to @makeittech). +- Composer: pasting a large block of text (about 2,000 characters or 25 lines) now offers to attach it as a `pasted-context-N.txt` file instead of flooding the input, with a `[pasted-context-N.txt]` reference left at the caret; Settings → Chat can make it always attach or always paste inline (thanks to @makeittech). +- Chat: the text the model writes before asking a question is shown right away instead of staying hidden in the Activity group until the turn ends (thanks to @makeittech). +- Chat: when the turn-ending signal from OpenCode is lost, the working spinner now clears within about a second instead of up to ten (thanks to @makeittech). +- Composer: typing three backticks leaves the caret inside the completed code fence, empty inputs keep a visible caret, and platform autocorrect behavior is preserved (thanks to @franzudev, @TTTPOB, and @IbrahimKhan12). - Usage: GitHub Copilot now shows a single AI Credits window, matching Copilot's token-based quota, in place of the old Chat Requests and Completions windows (thanks to @jakoss). -- Settings: fixed the Cloudflare Tunnel download link shown when cloudflared is not installed (thanks to @AyoubAchour). -- Git: picking a remote branch such as `origin/main` in the branch selector now switches you to that branch instead of leaving the repository on a detached `HEAD` with no branch name. -- Projects: the "Add project directory" picker now accepts multiple selections at once — each row has a checkbox (or press Space on the highlighted row) and the primary action adds every selected directory in a single store update, with per-entry validation, deduplication, and one persistence write (thanks to @herjarsa). -- Desktop: "Restart to Update" no longer looks dead when the update cannot be installed — the update window now shows the reason, including when the running copy was not installed from an official signed release, and the button stays available to retry. +- Multi-Run: groups can now contain more than five models, including isolated runs that create one worktree per model (thanks to @tomzx). +- Files: the Markdown preview has an in-document search (Ctrl/Cmd+F) with highlighting and next/previous, and clicking a folder or file in the sidebar tree opens it reliably on macOS trackpads, where a tiny pointer move used to swallow the click (thanks to @makeittech); files up to 20,000 lines open in the full-file preview instead of being rejected at 5,000 (thanks to @gaojunran). +- Panels: right-click an editor, chat, or browser tab to close it, close others, close left/right, or close all (thanks to @adavila0703). +- Plans: saved plans open with their content again for chats, worktrees outside the project path, and tabs restored after a reload, and an edit made right before closing is no longer lost. +- Browser: when the agent captures a page while the browser panel is hidden, the panel is revealed first instead of the capture failing. +- Sidebar: Recent rows show a compact timestamp on web and desktop, and pending permission/question badges are no longer covered by the hover actions (thanks to @makeittech). +- Mobile: Chats — sessions that belong to no project — now appear in the sessions sheet above the project list; opening an already-open agent switches to its editor instead of duplicating it (thanks to @bashrusakh); Android connections can trust user-installed certificate authorities, such as a local proxy's (thanks to @Silvenga). +- Settings: the editor font size survives a restart (thanks to @pascalandr); a change made right before closing the window is saved (thanks to @makeittech); number fields and selects no longer clip at large font sizes (thanks to @makeittech); refreshing GitHub account state no longer interrupts the page (thanks to @floze-the-genius); the Cloudflare Tunnel download link is fixed (thanks to @AyoubAchour); Windows skill paths are classified correctly, so disabled and duplicate skills are hidden as intended (thanks to @Ttungx). +- Small model: the model chosen in Settings now reaches the managed OpenCode process, so session titles use it (thanks to @makeittech); requests send the provider's configured headers, such as an API-gateway subscription key (thanks to @dmitrii-galantsev); a configured Anthropic endpoint is used without a doubled `/v1`, and Google models without reasoning no longer receive a thinking option (thanks to @mpeter and @IngTian). +- Projects: the folder picker can enter a directory that is already a project to browse from there (thanks to @weixiang1862), and sending, forking, and image attachments work in projects whose path has non-ASCII characters, such as `Masaüstü` (thanks to @fitzgpt). +- Git: the status panel refreshes from real repository state after checkout, branch, stash, merge, rebase, or reset, and remote branches that were never fetched appear in branch lists (thanks to @makeittech); the Branch diff scope no longer compares against the wrong base for branches created from the current branch (thanks to @gaojunran); picking `origin/main` in the branch selector checks out the local branch instead of a detached `HEAD` (thanks to @yulia-ivashko); branch search hides non-matching branches (thanks to @bashrusakh). +- Updates: "Update OpenCode" no longer fails with a bare "Bad Request" — OpenChamber names the release to install and shows OpenCode's reason when refused — and the desktop "Restart to Update" button shows why an install failed, including an unsigned local build, and stays available to retry (thanks to @mdatsev and @yulia-ivashko). +- Desktop: a crashed renderer window recovers automatically, with a visible failure page instead of a reload loop after repeated crashes (thanks to @wqpan); a slow or interactive shell startup file no longer stalls startup while OpenChamber looks for OpenCode — each probe gives up after five seconds, which is what left a Homebrew OpenCode looking undetected from a Dock launch (thanks to @mskadu). +- Windows: managed OpenCode restarts clean up orphaned listeners and process trees, closing the app stops OpenCode, and scheduled startup no longer fails on Task Scheduler's command length limit (thanks to @sergiofspedro, @a0000001, and @HAHH9527). +- Server: an `OPENCODE_BINARY` from the environment is no longer discarded when `settings.json` clears its own override (thanks to @bashrusakh); recovery through `OPENCODE_HOST` keeps the configured host and port (thanks to @colinmollenhour); `openchamber connect-url` no longer risks tearing `settings.json` while the desktop app runs, which could unpair every device (thanks to @shijie152). +- Web/PWA: notification clicks focus an existing window, and the installed app uses the shorter "OpenChamber" name (thanks to @bketelsen and @greghaynes). +- VS Code: the extension starts in the current workspace folder instead of one restored from storage (thanks to @makeittech). +- Themes: custom themes loaded through symlinks now work (thanks to @divyam234). +- Debug: the debug panel (Ctrl/Cmd+Shift+D) has a Requests tab showing in-flight requests and their age over the last five minutes (thanks to @tomzx). +- Reliability: switching sessions quickly no longer saves the wrong scroll position, and the log no longer fills with worktree warnings for non-Git folders (thanks to @herjarsa); startup cleanup of leftover processes no longer blocks the server on Windows (thanks to @bashrusakh). ## [1.21.0] - 2026-08-26 @@ -33,7 +59,6 @@ All notable changes to this project will be documented in this file. - Search: every searchable picker uses one matcher now — best matches first, multi-word queries in any order, punctuation ignored ("gpt4o" finds "gpt-4o"). Ctrl/Cmd+P matches whole file paths. - Chat: @ file mentions rank files and directories together by match quality, and long paths keep the folder next to the file name visible. - Chat: a "Follow new content while streaming" checkbox (Settings → Chat → Streaming, on by default) turns automatic following off entirely; with it off, the scroll-to-bottom pill now appears as soon as the reply grows past the visible area. -- Chat: undoing or redoing a parent session now keeps its subagent sessions at the same point in history instead of leaving their later work behind (thanks to @alexandrereyes). - Command palette: rarely used commands (pin session, copy session ID, multi-run launcher, archived sessions, notes, todos, status, theme) are found by typing but stay off the first screen. - Mobile: narrowing a browser window past phone size switches into the mobile layout (and back when widened); the old/new mobile layout setting is gone. - Browser: an agent opening a page with the browser tool no longer pops the browser panel open (or switches the surface you're on) — the page loads in the background and the rail is where you peek at it. diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index cf81988b..daad92af 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -20,6 +20,7 @@ import { useWebNotificationStream } from '@/hooks/useWebNotificationStream'; import { useAgentMemorySync } from '@/hooks/useAgentMemorySync'; import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt'; import { useWindowTitle } from '@/hooks/useWindowTitle'; +import { useRootScrollLock } from '@/hooks/useRootScrollLock'; import { useConfigStore } from '@/stores/useConfigStore'; import { isDesktopLocalOriginActive, isDesktopShell, restartDesktopApp, invokeDesktop } from '@/lib/desktop'; import { @@ -717,6 +718,8 @@ function App({ apis }: AppProps) { useWindowTitle(); + useRootScrollLock(); + useRouter(); const handleToggleMemoryDebug = React.useCallback(() => { diff --git a/packages/ui/src/apps/ElectronMiniChatApp.tsx b/packages/ui/src/apps/ElectronMiniChatApp.tsx index 7aed5ba0..4b59a36e 100644 --- a/packages/ui/src/apps/ElectronMiniChatApp.tsx +++ b/packages/ui/src/apps/ElectronMiniChatApp.tsx @@ -8,6 +8,7 @@ import { MiniChatLayout } from '@/components/mini-chat/MiniChatLayout'; import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; import { useWindowTitle } from '@/hooks/useWindowTitle'; +import { useRootScrollLock } from '@/hooks/useRootScrollLock'; import { opencodeClient } from '@/lib/opencode/client'; import type { RuntimeAPIs } from '@/lib/api/types'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; @@ -318,6 +319,7 @@ export function ElectronMiniChatApp({ apis }: ElectronMiniChatAppProps) { useMiniChatKeyboardShortcuts(); usePushVisibilityBeacon({ enabled: true }); useWindowTitle(); + useRootScrollLock(); return ( <ErrorBoundary> diff --git a/packages/ui/src/apps/MobileSessionsSheet.tsx b/packages/ui/src/apps/MobileSessionsSheet.tsx index a64d94f0..c10b4540 100644 --- a/packages/ui/src/apps/MobileSessionsSheet.tsx +++ b/packages/ui/src/apps/MobileSessionsSheet.tsx @@ -41,6 +41,8 @@ import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { toast } from '@/components/ui'; 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 { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useI18n } from '@/lib/i18n'; import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch'; @@ -1022,6 +1024,27 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open, return merged.filter((session) => !session.time?.archived); }, [globalActiveSessions, liveSessions]); + // Managed Chats (sessions under ~/.config/openchamber/chats) are not owned + // by any registered project; they get their own section above the project + // tree, the same split the desktop sidebar makes. Temporary /btw forks are + // dropped here as well. + const { projectSessions, chatSessions } = React.useMemo( + () => partitionSidebarSessions(sessions, false), + [sessions], + ); + const chatsBucket = React.useMemo<WorktreeBucket>(() => ({ + key: CHAT_DRAFT_PROJECT_ID, + label: '', + path: '', + worktree: null, + sessions: orderSessionsByLifecycleScopes(chatSessions, pinnedSessionIds, sessionOrderRanks), + }), [chatSessions, pinnedSessionIds, sessionOrderRanks]); + const chatsBucketKey = `${CHAT_DRAFT_PROJECT_ID}::${CHAT_DRAFT_PROJECT_ID}`; + const chatRootCount = React.useMemo( + () => chatSessions.filter((session) => !getParentId(session)).length, + [chatSessions], + ); + const normalizedQuery = query.trim().toLowerCase(); // On open, bring the current session (or at least its project) into view — @@ -1070,7 +1093,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open, for (const worktree of node.project.worktrees) ensureBucket(node, worktree.path, worktree); } - for (const session of sessions) { + for (const session of projectSessions) { const directory = getSessionDirectory(session); if (!directory) continue; const normalizedDirectory = normalizePath(directory); @@ -1093,7 +1116,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open, } return nodes; - }, [activeProjectId, pinnedSessionIds, projectsMeta, sessionOrderRanks, sessions]); + }, [activeProjectId, pinnedSessionIds, projectSessions, projectsMeta, sessionOrderRanks]); const normalizedDirectory = normalizePath(currentDirectory); @@ -1149,8 +1172,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open, // Paginated, tree-aware list of a bucket's sessions: top-level sessions paginate, // and a parent with subsessions can be expanded to reveal its children (nested, // recursively). Pagination counts only top-level sessions. - const renderBucketSessions = (node: ProjectNode, bucket: WorktreeBucket, indent: number) => { - const bucketKey = `${node.project.id}::${bucket.key}`; + const renderBucketSessions = (bucketKey: string, bucket: WorktreeBucket, indent: number) => { // Group children by parent within this bucket, and treat sessions whose parent // is not in this bucket as top-level so nothing is hidden. @@ -1336,13 +1358,14 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open, const buildSessionContextLabel = React.useCallback( (session: Session): string => { const directory = getSessionDirectory(session); + if (isChatDirectoryPath(directory)) return t('mobile.sessions.section.chats'); const project = findExactProjectMatch(projectsMeta, directory); if (!project) return getProjectLabel(directory) || directory; const matchedWorktree = findExactWorktreeMatch(project, normalizePath(directory)); if (matchedWorktree?.branch) return `${project.label} · ${matchedWorktree.branch}`; return project.label; }, - [projectsMeta], + [projectsMeta, t], ); const handleSelectProject = (project: ProjectMeta) => { @@ -1481,7 +1504,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open, ) : null} </div> </div> - {projectsMeta.length === 0 ? ( + {projectsMeta.length === 0 && chatSessions.length === 0 ? ( <MobileSessionsEmpty title={t('mobile.sessions.empty.noProjectsTitle')} description={t('mobile.sessions.empty.noProjectsDescription')} @@ -1601,7 +1624,56 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open, </div> ) : ( <div className="flex flex-col"> - {orderedNodes.map((node, nodeIndex) => { + {(() => { + const chatsExpanded = projectExpandedMap[CHAT_DRAFT_PROJECT_ID] ?? true; + const chatsLabel = t('mobile.sessions.section.chats'); + return ( + <section> + <div className="flex min-h-12 w-full items-center"> + <button + type="button" + className="flex min-h-12 min-w-0 flex-1 items-center gap-2 px-3 py-1.5 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset" + onClick={() => { + if (revealedRowId) { + handleRowKeyRevealedChange(revealedRowId, false); + return; + } + toggleProject(CHAT_DRAFT_PROJECT_ID, chatsExpanded); + }} + aria-expanded={chatsExpanded} + aria-label={ + chatsExpanded + ? t('sessions.sidebar.group.collapseAria', { label: chatsLabel }) + : t('sessions.sidebar.group.expandAria', { label: chatsLabel }) + } + style={{ touchAction: 'manipulation' }} + > + <span className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-[var(--surface-muted)] text-muted-foreground"> + <Icon name="chat-4" className="size-4" /> + </span> + <span className="block min-w-0 flex-1 truncate typography-ui-label font-semibold text-foreground"> + {chatsLabel} + </span> + <span className="shrink-0 typography-micro text-muted-foreground tabular-nums"> + {chatRootCount} + </span> + </button> + </div> + {chatsExpanded ? ( + <div className="pb-2"> + {chatsBucket.sessions.length > 0 ? ( + renderBucketSessions(chatsBucketKey, chatsBucket, PROJECT_SESSION_INDENT) + ) : ( + <p className="px-3 pb-1 typography-micro text-muted-foreground" style={{ paddingLeft: PROJECT_SESSION_INDENT }}> + {t('sessions.sidebar.activity.chatsEmpty')} + </p> + )} + </div> + ) : null} + </section> + ); + })()} + {orderedNodes.map((node) => { const projectExpanded = isProjectExpanded(node); const buckets = normalizedQuery ? node.buckets.filter((bucket) => @@ -1614,7 +1686,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open, return ( <section key={node.project.id} - className={cn(nodeIndex > 0 && 'border-t border-border/70')} + className="border-t border-border/70" > <MobileSwipeActionsRow actionsWidth={96} @@ -1712,7 +1784,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open, return ( <> {rootBucket && rootBucket.sessions.length > 0 - ? renderBucketSessions(node, rootBucket, PROJECT_SESSION_INDENT) + ? renderBucketSessions(`${node.project.id}::${rootBucket.key}`, rootBucket, PROJECT_SESSION_INDENT) : null} {worktreeBuckets.map((bucket) => { const worktreeExpanded = isWorktreeExpanded(node, bucket); @@ -1787,7 +1859,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open, </button> </MobileSwipeActionsRow> {worktreeExpanded - ? renderBucketSessions(node, bucket, PROJECT_SESSION_INDENT) + ? renderBucketSessions(`${node.project.id}::${bucket.key}`, bucket, PROJECT_SESSION_INDENT) : null} </div> ); diff --git a/packages/ui/src/apps/VSCodeApp.tsx b/packages/ui/src/apps/VSCodeApp.tsx index 737a0239..43e3f6f4 100644 --- a/packages/ui/src/apps/VSCodeApp.tsx +++ b/packages/ui/src/apps/VSCodeApp.tsx @@ -14,6 +14,7 @@ import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling'; import { useRouter } from '@/hooks/useRouter'; import { useWindowTitle } from '@/hooks/useWindowTitle'; +import { useRootScrollLock } from '@/hooks/useRootScrollLock'; import { opencodeClient } from '@/lib/opencode/client'; import type { RuntimeAPIs } from '@/lib/api/types'; import { runtimeFetch } from '@/lib/runtime-fetch'; @@ -57,6 +58,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) { useAppFontEffects(); usePushVisibilityBeacon({ enabled: true }); useWindowTitle(); + useRootScrollLock(); useRouter(); useGlobalSessionsPolling(panelType !== 'agentManager'); diff --git a/packages/ui/src/components/browser/BrowserPane.tsx b/packages/ui/src/components/browser/BrowserPane.tsx index f5b5440f..3828ca2b 100644 --- a/packages/ui/src/components/browser/BrowserPane.tsx +++ b/packages/ui/src/components/browser/BrowserPane.tsx @@ -339,6 +339,25 @@ const WebviewBrowser: React.FC<BrowserPaneProps> = ({ initialUrl, directory, tab } if (action === 'browser.capture') { + // A user may close the panel after browser.open. Chromium then removes + // the zero-width webview's composited surface and capturePage() fails + // with UnknownVizError. Reveal this existing browser tab again and let + // the layout paint before asking Electron for the image. + useUIStore.getState().openContextBrowser(directory, webview.getURL()); + const surfaceDeadline = Date.now() + 1_200; + let previousWidth = 0; + let stableSamples = 0; + while (stableSamples < 2 && Date.now() < surfaceDeadline) { + const width = webview.getBoundingClientRect().width; + stableSamples = width >= 2 && Math.abs(width - previousWidth) < 0.5 + ? stableSamples + 1 + : 0; + previousWidth = width; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + await new Promise<void>((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); // Wait for a settled page first: a screenshot of a half-painted layout is // worse than none, because it looks like a finished one. await waitForIdle(); @@ -450,7 +469,7 @@ const WebviewBrowser: React.FC<BrowserPaneProps> = ({ initialUrl, directory, tab await waitForIdle(); } return result; - }, [annotationHost, loadUrl, waitForIdle]); + }, [annotationHost, directory, loadUrl, waitForIdle]); React.useEffect( () => registerBrowserController({ run: runControlAction }), diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 5b2880b0..1b2e473a 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -90,7 +90,18 @@ import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from import { assignImageAttachmentFilenames, buildAttachmentCitationText, + nextPastedContextFilename, } from './attachmentCitations'; +import { + createPastedContextFile, + isLargePlainTextPaste, +} from './composer/largeTextPaste'; +import { + LARGE_TEXT_PASTE_TOAST_CLASSNAME, + beginLargeTextPasteOffer, + resolveLargeTextPasteOffer, +} from './composer/largeTextPasteOffer'; +import type { LargeTextPasteBehavior } from '@/stores/useUIStore'; import type { FileMentionAutocompleteInputSource } from './fileMentionAutocompleteState'; import { classifyMention, @@ -315,6 +326,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ const messageRef = React.useRef(message); const currentChatDraftIdentityRef = React.useRef<ChatDraftIdentity | null>(initialDraftIdentityRef.current); const pendingPastedAttachmentFilenamesRef = React.useRef<Set<string>>(new Set()); + const largeTextPasteToastIdRef = React.useRef<string | number | null>(null); + const largeTextPasteOfferIdRef = React.useRef(0); // TODO: port sendMessage to session-actions (complex — creates sessions, handles attachments, etc.) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -409,6 +422,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ const inputBarOffset = useUIStore((state) => state.inputBarOffset); const persistChatDraft = useUIStore((state) => state.persistChatDraft); const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled); + const largeTextPasteBehavior = useUIStore((state) => state.largeTextPasteBehavior); const isExpandedInput = useUIStore((state) => state.isExpandedInput); const setExpandedInput = useUIStore((state) => state.setExpandedInput); const setTimelineDialogOpen = useUIStore((state) => state.setTimelineDialogOpen); @@ -1766,21 +1780,24 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ if (!editor) { // No mounted editor (collapsed mobile pill): append to the state // the editor will be seeded from. - const nextValue = message + text; + const nextValue = messageRef.current + text; setMessage(nextValue); updateAutocompleteState(nextValue, nextValue.length, inputSource, text); return; } const { start, end } = editor.getSelection(); - const nextValue = `${message.substring(0, start)}${text}${message.substring(end)}`; + // Read the live document — delayed toast actions must not use a + // paste-time React `message` closure. + const currentMessage = editor.getValue(); + const nextValue = `${currentMessage.substring(0, start)}${text}${currentMessage.substring(end)}`; const cursorPosition = start + text.length; // One dispatch places both the text and the caret, so there is no // frame where the caret sits at a stale offset. editor.insertText(text); updateAutocompleteState(nextValue, cursorPosition, inputSource, text); - }, [message, updateAutocompleteState]); + }, [updateAutocompleteState]); const clearDropTextSuppression = React.useCallback(() => { suppressNextFileDropTextInsertRef.current = false; @@ -1921,14 +1938,131 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ const imageFiles = Array.from(fileMap.values()); const pastedText = e.clipboardData.getData('text'); + const sessionReady = Boolean(currentSessionId || newSessionDraftOpen); + if (imageFiles.length === 0) { - if (pastedText.includes('@')) { - markFileMentionPasteSuppression(); + const behavior: LargeTextPasteBehavior = largeTextPasteBehavior; + const shouldOfferLargePaste = sessionReady + && inputMode === 'normal' + && behavior !== 'inline' + && isLargePlainTextPaste(pastedText); + + if (!shouldOfferLargePaste) { + if (pastedText.includes('@')) { + markFileMentionPasteSuppression(); + } + return; } + + // Must run synchronously — ComposerEditor does not consume paste. + e.preventDefault(); + + const pasteInline = () => { + if (pastedText.includes('@')) { + markFileMentionPasteSuppression(); + } + insertTextAtSelection( + pastedText, + getFileMentionInputSourceForInsertedText(pastedText), + ); + }; + + const attachAsFile = async () => { + // Read live attachment + composer state at action time — the ask + // toast can outlive the paste while the user types or attaches more. + const liveAttachedFiles = useInputStore.getState().attachedFiles; + const filename = nextPastedContextFilename([ + ...liveAttachedFiles.map((file) => file.filename), + ...pendingPastedAttachmentFilenamesRef.current, + ]); + const citationText = buildAttachmentCitationText([filename]); + const editor = composerRef.current; + const currentMessage = editor?.getValue() ?? messageRef.current; + const selectionStart = editor?.getSelection().start ?? currentMessage.length; + const selectionEnd = editor?.getSelection().end ?? currentMessage.length; + const insertionText = withInlineInsertionBoundaries( + citationText, + currentMessage.slice(0, selectionStart), + currentMessage.slice(selectionEnd), + ); + + insertTextAtSelection( + insertionText, + getFileMentionInputSourceForInsertedText(insertionText), + ); + + const file = createPastedContextFile(pastedText, filename); + pendingPastedAttachmentFilenamesRef.current.add(filename); + try { + await addAttachedFile(file); + } catch (error) { + console.error('Clipboard text attach failed', error); + toast.error( + error instanceof Error + ? error.message + : t('chat.chatInput.toast.clipboardTextAttachFailed'), + ); + } finally { + pendingPastedAttachmentFilenamesRef.current.delete(filename); + } + }; + + if (behavior === 'attach') { + await attachAsFile(); + return; + } + + const offerId = beginLargeTextPasteOffer(largeTextPasteOfferIdRef.current); + largeTextPasteOfferIdRef.current = offerId; + + if (largeTextPasteToastIdRef.current !== null) { + // Invalidate first so a synchronous onDismiss from dismiss() + // cannot apply the superseded paste. + toast.dismiss(largeTextPasteToastIdRef.current); + largeTextPasteToastIdRef.current = null; + } + + const resolveLargePaste = (action: 'attach' | 'inline') => { + const resolution = resolveLargeTextPasteOffer( + largeTextPasteOfferIdRef.current, + offerId, + ); + largeTextPasteOfferIdRef.current = resolution.nextOfferId; + if (!resolution.accepted) { + return; + } + largeTextPasteToastIdRef.current = null; + if (action === 'attach') { + void attachAsFile(); + return; + } + pasteInline(); + }; + + largeTextPasteToastIdRef.current = toast.info( + t('chat.chatInput.toast.largeTextPaste.title'), + { + duration: Infinity, + className: LARGE_TEXT_PASTE_TOAST_CLASSNAME, + action: { + label: t('chat.chatInput.toast.largeTextPaste.attach'), + onClick: () => resolveLargePaste('attach'), + }, + cancel: { + label: t('chat.chatInput.toast.largeTextPaste.inline'), + onClick: () => resolveLargePaste('inline'), + }, + onDismiss: () => { + // Dismissing without a choice keeps the paste — insert inline + // so clipboard content is not lost. + resolveLargePaste('inline'); + }, + }, + ); return; } - if (!currentSessionId && !newSessionDraftOpen) { + if (!sessionReady) { if (pastedText.includes('@')) { markFileMentionPasteSuppression(); } @@ -1969,7 +2103,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ pendingPastedAttachmentFilenamesRef.current.delete(filename); } } - }, [addAttachedFile, attachedFiles, currentSessionId, inputMode, markFileMentionPasteSuppression, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]); + }, [addAttachedFile, attachedFiles, currentSessionId, inputMode, largeTextPasteBehavior, markFileMentionPasteSuppression, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]); const handleFileSelect = (file: { name: string; path: string; relativePath?: string }) => { diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index a3d918b2..d09b0004 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -633,7 +633,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ ]; const prevAgentNameRef = React.useRef<string | undefined>(undefined); - const explicitAgentSwitchRef = React.useRef<string | null>(null); const latestLoadedUserChoiceRestoreRef = React.useRef<string | null>(null); const currentSessionDirectory = currentSessionId ? getDirectoryForSession(currentSessionId) : undefined; @@ -1051,9 +1050,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ prevAgentNameRef.current = currentAgentName; if (currentAgentName && currentSessionId) { - const shouldPreferAgentModel = explicitAgentSwitchRef.current === currentAgentName; - explicitAgentSwitchRef.current = null; - await new Promise<void>((resolve) => { const timer = setTimeout(resolve, 50); abortController.signal.addEventListener('abort', () => { @@ -1066,33 +1062,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ return; } - const selectedAgent = shouldPreferAgentModel - ? agents.find((agent) => agent.name === currentAgentName) - : undefined; - if (selectedAgent?.model?.providerID && selectedAgent.model.modelID) { - const result = tryApplyModelSelection( - selectedAgent.model.providerID, - selectedAgent.model.modelID, - currentAgentName, - ); - if (result === 'applied' || result === 'provider-missing') { - if (result === 'applied') { - saveSessionModelSelection( - currentSessionId, - selectedAgent.model.providerID, - selectedAgent.model.modelID, - ); - saveAgentModelForSession( - currentSessionId, - currentAgentName, - selectedAgent.model.providerID, - selectedAgent.model.modelID, - ); - } - return; - } - } - const persistedChoice = getAgentModelForSession(currentSessionId, currentAgentName); if (persistedChoice) { @@ -1118,12 +1087,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ abortController.abort(); }; }, [ - agents, currentAgentName, currentSessionId, getAgentModelForSession, - saveAgentModelForSession, - saveSessionModelSelection, tryApplyModelSelection, contextHydrated, ]); @@ -1212,7 +1178,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ const handleAgentChange = React.useCallback((agentName: string, options?: { closeModelSelector?: boolean }) => { try { - explicitAgentSwitchRef.current = agentName; setAgent(agentName); addRecentAgent(agentName); if (options?.closeModelSelector ?? true) { diff --git a/packages/ui/src/components/chat/__tests__/attachmentCitations.test.ts b/packages/ui/src/components/chat/__tests__/attachmentCitations.test.ts index 96221b61..92d88ecd 100644 --- a/packages/ui/src/components/chat/__tests__/attachmentCitations.test.ts +++ b/packages/ui/src/components/chat/__tests__/attachmentCitations.test.ts @@ -5,6 +5,7 @@ import { buildAttachmentCitationText, findAttachmentCitationRanges, isGenericImageFilename, + nextPastedContextFilename, } from '../attachmentCitations'; describe('attachment citations', () => { @@ -53,4 +54,10 @@ describe('attachment citations', () => { ['desktop.jpg'], )).toEqual([{ start: 8, end: 21 }]); }); + + test('assigns sequential pasted-context filenames', () => { + expect(nextPastedContextFilename([])).toBe('pasted-context-1.txt'); + expect(nextPastedContextFilename(['pasted-context-1.txt', 'notes.md'])).toBe('pasted-context-2.txt'); + expect(nextPastedContextFilename(['PASTED-CONTEXT-2.TXT'])).toBe('pasted-context-1.txt'); + }); }); diff --git a/packages/ui/src/components/chat/attachmentCitations.ts b/packages/ui/src/components/chat/attachmentCitations.ts index 1faf6925..e3e380e1 100644 --- a/packages/ui/src/components/chat/attachmentCitations.ts +++ b/packages/ui/src/components/chat/attachmentCitations.ts @@ -144,6 +144,20 @@ export const assignImageAttachmentFilenames = ( }); }; +/** Next unused `pasted-context-N.txt` name for a large text paste attachment. */ +export const nextPastedContextFilename = (existingFilenames: string[]): string => { + const used = new Set(existingFilenames.map(normalizeFilenameKey)); + + for (let index = 1; index < Number.MAX_SAFE_INTEGER; index += 1) { + const candidate = `pasted-context-${index}.txt`; + if (!used.has(normalizeFilenameKey(candidate))) { + return candidate; + } + } + + return `pasted-context-${Date.now()}.txt`; +}; + export const buildAttachmentCitationText = (filenames: string[]): string => ( filenames.map((filename) => `[${filename}]`).join(' ') ); diff --git a/packages/ui/src/components/chat/components/TurnItem.tsx b/packages/ui/src/components/chat/components/TurnItem.tsx index d90d0f99..fcf7de6d 100644 --- a/packages/ui/src/components/chat/components/TurnItem.tsx +++ b/packages/ui/src/components/chat/components/TurnItem.tsx @@ -18,13 +18,13 @@ const TurnItem: React.FC<TurnItemProps> = ({ turn, stickyUserHeader = true, rend data-scroll-spy-id={turn.turnId} > {stickyUserHeader ? ( - <div className="sticky top-0 z-20 relative bg-[var(--surface-background)] [overflow-anchor:none]"> + <div className="sticky top-0 z-20 relative bg-[var(--surface-background)] pb-4 sm:pb-8 [overflow-anchor:none]"> <div className="relative z-10"> {renderMessage(turn.userMessage)} </div> <div aria-hidden="true" - className="pointer-events-none absolute inset-x-0 top-full z-0 h-4 bg-gradient-to-b from-[var(--surface-background)] to-transparent sm:h-8" + className="pointer-events-none absolute inset-x-0 bottom-0 z-0 h-4 bg-gradient-to-b from-[var(--surface-background)] to-transparent sm:h-8" /> </div> ) : ( diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index a3d0854e..03264fdf 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -28,6 +28,18 @@ existing mobile fixed-position rules unchanged. | `attachments/` | Files: paths, drop payloads | | `ui/` | Presentation | | `text.ts` | How inserted text meets the text already there | +| `largeTextPaste.ts` | Detect large plain-text pastes and build virtual `.txt` files | +| `largeTextPasteOffer.ts` | Ask-toast offer id begin/resolve (supersede + double-apply guards) | + +`ChatInput.handlePaste` owns paste orchestration: URL-over-selection markdown +links, clipboard images (attach + citation), and large plain-text pastes. +Large pastes (about 2,000 characters or 25 lines) follow the composer setting +`largeTextPasteBehavior` (`ask` / `attach` / `inline`). Attaching creates an +in-memory `text/plain` file named `pasted-context-N.txt`, inserts a bracket +citation, and sends it through the same attachment pipeline as a manually +picked `.txt` file. Ask-toast actions read live composer/attachment state so +typing or other attaches between paste and choice stay consistent. Short text, +images, and URL wraps keep their existing paths. ## The prompt language @@ -60,6 +72,15 @@ copy. exactly what gets sent, so nothing downstream serializes a rich document model back into a prompt. +The document is not, however, the string it was given: CodeMirror normalizes +line endings, so a `\r\n` pair becomes one break and the document ends up +shorter than the inserted string. **Never derive a caret position from the +length of text you are inserting** — a caret past the end makes `dispatch` +throw, the transaction never applies, and the un-normalized text stays in React +state to crash again on the next restore. Every edit that moves the caret goes +through `replaceWithCaret` (`editor/documentEdits.ts`), which measures the +change instead of the string. + The composer previously painted a transparent `<textarea>` over a mirror `<div>`. That restricted highlighting to styles which do not change glyph advance width — colour, background, underline — because anything else made the @@ -170,8 +191,8 @@ hardware. The package has no DOM test environment, so coverage stops at the state and logic layers: the language, the submit assembly, path and drop handling, text -splicing, message history, and the CodeMirror language extension at the -`EditorState` level. +splicing, large-paste detection, paste-offer invalidation, message history, and +the CodeMirror language extension at the `EditorState` level. Rendering, focus, keyboard behavior, IME and WKWebView are **not covered by tests** and are verified by hand. Do not report a change to them as validated diff --git a/packages/ui/src/components/chat/composer/__tests__/largeTextPaste.test.ts b/packages/ui/src/components/chat/composer/__tests__/largeTextPaste.test.ts new file mode 100644 index 00000000..b205394e --- /dev/null +++ b/packages/ui/src/components/chat/composer/__tests__/largeTextPaste.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test'; + +import { + LARGE_TEXT_PASTE_CHAR_THRESHOLD, + LARGE_TEXT_PASTE_LINE_THRESHOLD, + createPastedContextFile, + isLargePlainTextPaste, +} from '../largeTextPaste'; + +describe('large text paste helpers', () => { + test('treats short text as not large', () => { + expect(isLargePlainTextPaste('hello world')).toBe(false); + expect(isLargePlainTextPaste('line1\nline2\nline3')).toBe(false); + }); + + test('treats empty and whitespace-only pastes as not large', () => { + expect(isLargePlainTextPaste('')).toBe(false); + expect(isLargePlainTextPaste(' \n\t ')).toBe(false); + }); + + test('detects pastes at the character threshold', () => { + const text = 'a'.repeat(LARGE_TEXT_PASTE_CHAR_THRESHOLD); + expect(isLargePlainTextPaste(text)).toBe(true); + expect(isLargePlainTextPaste(text.slice(0, -1))).toBe(false); + }); + + test('detects pastes at the line threshold', () => { + const lines = Array.from({ length: LARGE_TEXT_PASTE_LINE_THRESHOLD }, (_, index) => `line ${index}`); + expect(isLargePlainTextPaste(lines.join('\n'))).toBe(true); + expect(isLargePlainTextPaste(lines.slice(0, -1).join('\n'))).toBe(false); + }); + + test('honors custom thresholds', () => { + expect(isLargePlainTextPaste('abcdef', { charThreshold: 5 })).toBe(true); + expect(isLargePlainTextPaste('a\nb\nc', { lineThreshold: 3 })).toBe(true); + expect(isLargePlainTextPaste('a\nb', { lineThreshold: 3, charThreshold: 100 })).toBe(false); + }); + + test('creates a text/plain file with the given name', async () => { + const file = createPastedContextFile('architecture notes', 'pasted-context-1.txt'); + expect(file.name).toBe('pasted-context-1.txt'); + expect(file.type.startsWith('text/plain')).toBe(true); + expect(await file.text()).toBe('architecture notes'); + }); +}); diff --git a/packages/ui/src/components/chat/composer/__tests__/largeTextPasteOffer.test.ts b/packages/ui/src/components/chat/composer/__tests__/largeTextPasteOffer.test.ts new file mode 100644 index 00000000..2b453a31 --- /dev/null +++ b/packages/ui/src/components/chat/composer/__tests__/largeTextPasteOffer.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'bun:test'; + +import { + LARGE_TEXT_PASTE_TOAST_CLASSNAME, + beginLargeTextPasteOffer, + resolveLargeTextPasteOffer, +} from '../largeTextPasteOffer'; + +describe('large text paste offer state', () => { + test('begin allocates the next offer id', () => { + expect(beginLargeTextPasteOffer(0)).toBe(1); + expect(beginLargeTextPasteOffer(3)).toBe(4); + }); + + test('resolve accepts a matching active offer and invalidates it', () => { + expect(resolveLargeTextPasteOffer(2, 2)).toEqual({ + accepted: true, + nextOfferId: 3, + }); + }); + + test('resolve rejects a superseded offer without advancing', () => { + expect(resolveLargeTextPasteOffer(5, 4)).toEqual({ + accepted: false, + nextOfferId: 5, + }); + }); + + test('second resolve after accept is rejected (double-apply guard)', () => { + const first = resolveLargeTextPasteOffer(1, 1); + expect(first.accepted).toBe(true); + expect(resolveLargeTextPasteOffer(first.nextOfferId, 1)).toEqual({ + accepted: false, + nextOfferId: first.nextOfferId, + }); + }); + + test('begin then resolve of the old id is rejected', () => { + const previous = 2; + const next = beginLargeTextPasteOffer(previous); + expect(resolveLargeTextPasteOffer(next, previous)).toEqual({ + accepted: false, + nextOfferId: next, + }); + expect(resolveLargeTextPasteOffer(next, next).accepted).toBe(true); + }); + + test('toast class widens only from the sm breakpoint', () => { + const classes = LARGE_TEXT_PASTE_TOAST_CLASSNAME.split(/\s+/); + expect(classes).toContain('sm:!min-w-[22rem]'); + expect(classes).toContain('sm:!w-auto'); + expect(classes).toContain('[&_[data-icon]]:!hidden'); + expect(classes.includes('!min-w-[22rem]')).toBe(false); + expect(classes.includes('!w-auto')).toBe(false); + }); +}); diff --git a/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx b/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx index ad5372e7..5c4db849 100644 --- a/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx +++ b/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx @@ -36,6 +36,7 @@ import { cn } from '@/lib/utils'; import type { ComposerLanguageContext } from '../language/tokenize'; import type { ComposerAutoCorrect } from './autocorrect'; import { composerLanguage, setLanguageContext } from './composerLanguage'; +import { replaceWithCaret } from './documentEdits'; import type { ComposerEditorViewStore } from './viewStore'; import { composerEditorTheme, composerSelectionExtension } from './theme'; import { handleComposerHostMouseDown } from './hostMouseDown'; @@ -351,17 +352,14 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi // A stale value echo can differ from CodeMirror's newer document, // and replacing it would interrupt the IME session and move the caret. if (view.compositionStarted) return; - view.dispatch({ - changes: { from: 0, to: current.length, insert: value }, - // An external rewrite (draft restore, history navigation, - // "add to chat", dictation insert) lands the caret at the END, - // matching what a plain textarea did when its value was - // replaced. Every rewrite that reaches here appends or - // replaces wholesale; keeping the old caret instead left it - // stranded before the inserted text, and the next insertion - // or keystroke landed inside the previous one. - selection: { anchor: value.length }, - }); + // An external rewrite (draft restore, history navigation, + // "add to chat", dictation insert) lands the caret at the END, + // matching what a plain textarea did when its value was replaced. + // Every rewrite that reaches here appends or replaces wholesale; + // keeping the old caret instead left it stranded before the + // inserted text, and the next insertion or keystroke landed inside + // the previous one. + view.dispatch(replaceWithCaret(view.state, 0, current.length, value)); // A large insert can push the caret below the fold, and a // transaction-time `scrollIntoView` cannot reach it: wrapped-line // heights are still estimates during the update, and the @@ -515,18 +513,18 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi if (!view || !text) return; const { from, to } = view.state.selection.main; view.dispatch({ - changes: { from, to, insert: text }, - selection: { anchor: from + text.length }, + ...replaceWithCaret(view.state, from, to, text), userEvent: 'input.type', }); }, replaceRange(from, to, text, selectionStart, selectionEnd = selectionStart) { const view = viewRef.current; if (!view) return; - const anchor = selectionStart ?? from + text.length; + const caret = selectionStart === undefined + ? undefined + : { anchor: selectionStart, head: selectionEnd ?? selectionStart }; view.dispatch({ - changes: { from, to, insert: text }, - selection: { anchor, head: selectionEnd ?? anchor }, + ...replaceWithCaret(view.state, from, to, text, caret), userEvent: 'input.type', }); }, diff --git a/packages/ui/src/components/chat/composer/editor/__tests__/documentEdits.test.ts b/packages/ui/src/components/chat/composer/editor/__tests__/documentEdits.test.ts new file mode 100644 index 00000000..2721de08 --- /dev/null +++ b/packages/ui/src/components/chat/composer/editor/__tests__/documentEdits.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'bun:test'; +import { EditorState } from '@codemirror/state'; + +import { replaceWithCaret } from '../documentEdits'; + +const apply = (doc: string, from: number, to: number, insert: string, caret?: { anchor: number; head: number }) => { + const state = EditorState.create({ doc }); + const next = state.update(replaceWithCaret(state, from, to, insert, caret)).state; + return { text: next.doc.toString(), selection: next.selection.main }; +}; + +describe('replaceWithCaret', () => { + test('puts the caret at the end of a wholesale replacement', () => { + const { text, selection } = apply('old', 0, 3, 'a new draft'); + + expect(text).toBe('a new draft'); + expect(selection.anchor).toBe(11); + expect(selection.head).toBe(11); + }); + + // Issue #3013: CodeMirror collapses `\r\n` into one line break, so a caret + // taken from the JS string length falls outside the document and dispatch + // throws `RangeError: Selection points outside of document`. + test('keeps the caret inside the document when CRLF is normalized away', () => { + const { text, selection } = apply('a', 0, 1, 'x\r\ny'); + + expect(text).toBe('x\ny'); + expect(selection.anchor).toBe(3); + }); + + test('survives a draft made only of CRLF breaks', () => { + const { text, selection } = apply('a', 0, 1, '\r\n\r\n\r\n'); + + expect(text).toBe('\n\n\n'); + expect(selection.anchor).toBe(3); + }); + + test('places the caret after text inserted at the selection', () => { + const { text, selection } = apply('hello world', 5, 5, ',\r\n there'); + + expect(text).toBe('hello,\n there world'); + expect(selection.anchor).toBe(13); + }); + + test('honours an explicit caret', () => { + const { selection } = apply('hello', 0, 5, 'goodbye', { anchor: 2, head: 4 }); + + expect(selection.anchor).toBe(2); + expect(selection.head).toBe(4); + }); + + test('clamps an explicit caret that the normalized document cannot hold', () => { + const { text, selection } = apply('a', 0, 1, 'x\r\ny', { anchor: 4, head: 4 }); + + expect(text).toBe('x\ny'); + expect(selection.anchor).toBe(3); + }); +}); diff --git a/packages/ui/src/components/chat/composer/editor/__tests__/writebackCompositionGuard.test.ts b/packages/ui/src/components/chat/composer/editor/__tests__/writebackCompositionGuard.test.ts index 94df2c8f..cd2efc69 100644 --- a/packages/ui/src/components/chat/composer/editor/__tests__/writebackCompositionGuard.test.ts +++ b/packages/ui/src/components/chat/composer/editor/__tests__/writebackCompositionGuard.test.ts @@ -19,7 +19,7 @@ describe('composer value writeback composition guard (issue #2527)', () => { const effect = writebackEffect(); const equalityCheck = effect.indexOf('if (current === value) return;'); const compositionGuard = effect.indexOf('if (view.compositionStarted) return;'); - const dispatch = effect.indexOf('view.dispatch({'); + const dispatch = effect.indexOf('view.dispatch('); expect(equalityCheck).toBeGreaterThan(-1); expect(compositionGuard).toBeGreaterThan(equalityCheck); diff --git a/packages/ui/src/components/chat/composer/editor/documentEdits.ts b/packages/ui/src/components/chat/composer/editor/documentEdits.ts new file mode 100644 index 00000000..d35f2bdb --- /dev/null +++ b/packages/ui/src/components/chat/composer/editor/documentEdits.ts @@ -0,0 +1,33 @@ +import type { EditorState, TransactionSpec } from '@codemirror/state'; + +/** + * Replace a document range and leave the caret inside the resulting document. + * + * CodeMirror normalizes line endings on the way in: a `\r\n` pair becomes one + * line break, so the inserted string is longer than the text it produces. A + * caret derived from the JavaScript string therefore lands past the end of the + * document and `dispatch` throws `RangeError: Selection points outside of + * document`. The transaction never applies, so the un-normalized text stays in + * React state, gets persisted as a draft, and crashes the chat again on every + * restore (issue #3013). + * + * Deriving the caret from the change set instead keeps it correct for whatever + * CodeMirror actually inserted, without this module having to know the + * normalization rules. + */ +export const replaceWithCaret = ( + state: EditorState, + from: number, + to: number, + insert: string, + caret?: { anchor: number; head: number }, +): TransactionSpec => { + const changes = state.changes({ from, to, insert }); + const clamp = (position: number): number => Math.min(Math.max(position, 0), changes.newLength); + // What CodeMirror inserted, measured on the document rather than on the + // string: the new length minus everything the change left untouched. + const insertedLength = changes.newLength - (state.doc.length - (to - from)); + const anchor = caret ? clamp(caret.anchor) : from + insertedLength; + const head = caret ? clamp(caret.head) : anchor; + return { changes, selection: { anchor, head } }; +}; diff --git a/packages/ui/src/components/chat/composer/largeTextPaste.ts b/packages/ui/src/components/chat/composer/largeTextPaste.ts new file mode 100644 index 00000000..b9660de7 --- /dev/null +++ b/packages/ui/src/components/chat/composer/largeTextPaste.ts @@ -0,0 +1,55 @@ +/** + * Large plain-text paste → virtual file attachment helpers. + * + * Detect when clipboard text is large enough that inserting it into the + * composer would clutter the prompt, and build an in-memory text/plain File + * the attachment pipeline can send like any other .txt attachment. + */ + +export const LARGE_TEXT_PASTE_CHAR_THRESHOLD = 2000; +export const LARGE_TEXT_PASTE_LINE_THRESHOLD = 25; + +const countLines = (text: string): number => { + let lines = 1; + for (let index = 0; index < text.length; index += 1) { + if (text.charCodeAt(index) === 10) { + lines += 1; + } + } + return lines; +}; + +/** + * Whether pasted plain text should be offered (or auto-handled) as a file + * attachment instead of being inserted into the composer. + * + * Empty / whitespace-only pastes are never large. Thresholds are OR'd: + * character count or line count is enough. + */ +export const isLargePlainTextPaste = ( + text: string, + options?: { + charThreshold?: number; + lineThreshold?: number; + }, +): boolean => { + if (!text || !text.trim()) { + return false; + } + + const charThreshold = options?.charThreshold ?? LARGE_TEXT_PASTE_CHAR_THRESHOLD; + const lineThreshold = options?.lineThreshold ?? LARGE_TEXT_PASTE_LINE_THRESHOLD; + + if (text.length >= charThreshold) { + return true; + } + + return countLines(text) >= lineThreshold; +}; + +export const createPastedContextFile = (text: string, filename: string): File => ( + new File([text], filename, { + type: 'text/plain', + lastModified: Date.now(), + }) +); diff --git a/packages/ui/src/components/chat/composer/largeTextPasteOffer.ts b/packages/ui/src/components/chat/composer/largeTextPasteOffer.ts new file mode 100644 index 00000000..4dc53e63 --- /dev/null +++ b/packages/ui/src/components/chat/composer/largeTextPasteOffer.ts @@ -0,0 +1,31 @@ +/** + * Offer-id state for the large-text paste ask toast. + * + * The toast can outlive the paste event (duration Infinity), and a second + * large paste can supersede an unanswered offer. These helpers keep that + * invalidation pure so ChatInput only wires toast UI to attach/inline actions. + */ + +/** Allocate a new offer id, superseding any unanswered previous offer. */ +export const beginLargeTextPasteOffer = (activeOfferId: number): number => ( + activeOfferId + 1 +); + +/** + * Attempt to resolve an offer. Returns whether this call won the race, and the + * next active id. A superseded or already-resolved offer is rejected so + * dismiss/action cannot double-apply. + */ +export const resolveLargeTextPasteOffer = ( + activeOfferId: number, + offerId: number, +) => { + if (offerId !== activeOfferId) { + return { accepted: false, nextOfferId: activeOfferId }; + } + return { accepted: true, nextOfferId: activeOfferId + 1 }; +}; + +/** Toast chrome: widen on desktop only; leave mobile full-width to Sonner. */ +export const LARGE_TEXT_PASTE_TOAST_CLASSNAME = + '[&_[data-icon]]:!hidden sm:!min-w-[22rem] sm:!w-auto'; diff --git a/packages/ui/src/components/chat/lib/scroll/timelineScrollIntent.test.ts b/packages/ui/src/components/chat/lib/scroll/timelineScrollIntent.test.ts new file mode 100644 index 00000000..084bd420 --- /dev/null +++ b/packages/ui/src/components/chat/lib/scroll/timelineScrollIntent.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from 'bun:test'; + +import { isFollowReleaseKey, isMiddleButtonPan, nestedScrollableConsumesWheelUp } from './timelineScrollIntent'; + +const key = ( + k: string, + modifiers: Partial<Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'>> = {}, +) => ({ key: k, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...modifiers }); + +describe('isFollowReleaseKey', () => { + test('upward navigation keys release follow', () => { + for (const k of ['ArrowUp', 'PageUp', 'Home']) expect(isFollowReleaseKey(key(k))).toBe(true); + expect(isFollowReleaseKey(key(' ', { shiftKey: true }))).toBe(true); + }); + + test('downward keys, plain space, and modified shortcuts do not', () => { + for (const k of ['ArrowDown', 'PageDown', 'End', ' ', 'Pause', 'Enter']) { + expect(isFollowReleaseKey(key(k))).toBe(false); + } + expect(isFollowReleaseKey(key('Home', { ctrlKey: true }))).toBe(false); + expect(isFollowReleaseKey(key('ArrowUp', { metaKey: true }))).toBe(false); + expect(isFollowReleaseKey(key('ArrowUp', { altKey: true }))).toBe(false); + }); +}); + +// The helpers only use Element#closest, scrollTop, and identity, so a minimal +// DOM stand-in built on EventTarget is enough — no renderer or jsdom. +class FakeElement extends EventTarget { + scrollTop = 0; + constructor(private readonly scrollable: boolean, private readonly parent: FakeElement | null = null) { + super(); + } + closest(selector: string): FakeElement | null { + if (selector !== '[data-scrollable]') throw new Error(`unexpected selector ${selector}`); + if (this.scrollable) return this; + return this.parent?.closest(selector) ?? null; + } +} +// SAFETY: the helpers narrow with `instanceof Element` / `instanceof HTMLElement`; +// registering the fakes under those globals keeps the narrowing honest in bun. +const installDomGlobals = () => { + const previous = { Element: globalThis.Element, HTMLElement: globalThis.HTMLElement }; + Object.assign(globalThis, { Element: FakeElement, HTMLElement: FakeElement }); + return () => Object.assign(globalThis, previous); +}; +// With the globals above installed, FakeElement IS the HTMLElement the helpers +// narrow to; reading it back through the global bridges the static type without +// asserting anything the runtime does not hold. +const asRoot = (element: FakeElement): HTMLElement => { + if (!(element instanceof globalThis.HTMLElement)) throw new Error('DOM globals not installed'); + return element; +}; + +describe('nested scroller handling', () => { + test('an upward wheel over a nested scroller with room above stays there', () => { + const restore = installDomGlobals(); + try { + const root = new FakeElement(false); + const box = new FakeElement(true, root); + const inner = new FakeElement(false, box); + box.scrollTop = 40; + expect(nestedScrollableConsumesWheelUp(asRoot(root), inner)).toBe(true); + box.scrollTop = 0; + expect(nestedScrollableConsumesWheelUp(asRoot(root), inner)).toBe(false); + expect(nestedScrollableConsumesWheelUp(asRoot(root), new FakeElement(false, root))).toBe(false); + } finally { + restore(); + } + }); + + test('a middle-button press pans the timeline unless it lands in a nested scroller', () => { + const restore = installDomGlobals(); + try { + const root = new FakeElement(false); + const row = new FakeElement(false, root); + const box = new FakeElement(true, root); + expect(isMiddleButtonPan(asRoot(root), { button: 1, target: row })).toBe(true); + expect(isMiddleButtonPan(asRoot(root), { button: 1, target: box })).toBe(false); + expect(isMiddleButtonPan(asRoot(root), { button: 0, target: row })).toBe(false); + } finally { + restore(); + } + }); +}); diff --git a/packages/ui/src/components/chat/lib/scroll/timelineScrollIntent.ts b/packages/ui/src/components/chat/lib/scroll/timelineScrollIntent.ts new file mode 100644 index 00000000..c6709f8e --- /dev/null +++ b/packages/ui/src/components/chat/lib/scroll/timelineScrollIntent.ts @@ -0,0 +1,41 @@ +// Gesture classification for the chat timeline's follow opt-out. +// +// The timeline releases live follow on REAL upward gestures only. Wheel and +// touch carry their direction; this module answers the same question for the +// inputs that do not: which keys mean "scroll up", when a middle-button press +// starts a pan, and when an upward wheel belongs to a nested scroller (a tool +// output box) that can still consume it. Pure functions, no DOM ownership, +// so the rules are testable without a renderer. + +// A nested scroller inside the timeline marks itself with this attribute +// (see ToolPart). Wheel-up over it scrolls the box, not the conversation, for +// as long as the box has room above. +const NESTED_SCROLLABLE_SELECTOR = '[data-scrollable]'; + +export const isFollowReleaseKey = ( + event: Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>, +): boolean => { + // Modified keys are shortcuts, not navigation. + if (event.altKey || event.ctrlKey || event.metaKey) return false; + if (event.key === ' ') return event.shiftKey; + return event.key === 'ArrowUp' || event.key === 'PageUp' || event.key === 'Home'; +}; + +const nestedScrollable = (root: HTMLElement, target: EventTarget | null): HTMLElement | null => { + if (!(target instanceof Element)) return null; + const nested = target.closest(NESTED_SCROLLABLE_SELECTOR); + return nested instanceof HTMLElement && nested !== root ? nested : null; +}; + +// An upward wheel over a nested scroller that still has content above stays +// with that scroller; the timeline must not treat it as leaving the end. +export const nestedScrollableConsumesWheelUp = (root: HTMLElement, target: EventTarget | null): boolean => { + const nested = nestedScrollable(root, target); + return nested !== null && nested.scrollTop > 0; +}; + +// Middle-button press starts the platform's autoscroll pan (Windows/Linux +// Chromium); the pan then scrolls without wheel events, so the press itself is +// the gesture. Inside a nested scroller the pan belongs to that scroller. +export const isMiddleButtonPan = (root: HTMLElement, event: Pick<MouseEvent, 'button' | 'target'>): boolean => + event.button === 1 && nestedScrollable(root, event.target) === null; diff --git a/packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts b/packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts index 86921e31..e88bbd7b 100644 --- a/packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts +++ b/packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts @@ -97,6 +97,16 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit input.assistantMessages.forEach((message) => { const finish = getMessageFinish(message); const messageHasTool = message.parts.some((part) => part.type === 'tool'); + // A turn blocked on a question never reaches finish === 'stop' (the + // user must answer first). Treating the text the model produced + // before the question as 'justification' would bury it inside the + // collapsible Activity group — the context stays invisible until the + // turn completes (OPE-199). Keep it inline like OpenCode. + const messageHasQuestion = message.parts.some((part) => ( + part.type === 'tool' + && typeof part.tool === 'string' + && part.tool === 'question' + )); const messageIsCompactionSummary = isCompactionSummaryMessage(message); message.parts.forEach((part, partIndex) => { @@ -137,6 +147,7 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit input.showTextJustificationActivity && part.type === 'text' && text + && !messageHasQuestion && ( messageIsCompactionSummary || ( diff --git a/packages/ui/src/components/chat/lib/turns/projectTurnRecords.test.ts b/packages/ui/src/components/chat/lib/turns/projectTurnRecords.test.ts index f7d9f22b..f5831138 100644 --- a/packages/ui/src/components/chat/lib/turns/projectTurnRecords.test.ts +++ b/packages/ui/src/components/chat/lib/turns/projectTurnRecords.test.ts @@ -221,4 +221,34 @@ describe('projectTurnRecords', () => { const finalActivity = turn?.activityParts.find((activity) => activity.messageId === 'a2'); expect(finalActivity).toBe(undefined); }); + + test('keeps text inline (not justification) when a message is blocked on a pending question', () => { + const user = createMessageEntry({ id: 'u1', role: 'user', createdAt: 1 }); + user.parts = [{ id: 'p1', type: 'text', text: 'prompt' } as Part]; + const assistant = createMessageEntry({ id: 'a1', role: 'assistant', parentID: 'u1', createdAt: 2 }); + // The turn is blocked waiting for the user's answer: no finish and a + // pending question tool part, with context text before the question. + assistant.parts = [ + { id: 'ap1', type: 'text', text: 'context before the question' } as Part, + { + id: 'ap2', + type: 'tool', + callID: 'c1', + tool: 'question', + state: { status: 'pending' }, + } as Part, + ]; + + const projection = projectTurnRecords([user, assistant], { + showTextJustificationActivity: true, + }); + + const turn = projection.turns[0]; + expect(turn).toBeDefined(); + const textActivity = turn?.activityParts.find((activity) => activity.partIndex === 0); + expect(textActivity?.kind).not.toBe('justification'); + // The question tool itself still participates in the activity group. + const questionActivity = turn?.activityParts.find((activity) => activity.partIndex === 1); + expect(questionActivity?.kind).toBe('tool'); + }); }); diff --git a/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts b/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts index a06b81fc..48417866 100644 --- a/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts +++ b/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts @@ -1,6 +1,10 @@ /// <reference lib="webworker" /> -import { bundledLanguages, createHighlighter, type BundledLanguage, type ThemedToken } from 'shiki'; +import { bundledLanguages, createHighlighter, type BundledLanguage, type LanguageRegistration, type ThemedToken } from 'shiki'; +import { + isTemplateCallLanguageId, + sanitizeTemplateCallGrammar, +} from '../../../lib/shiki/sanitizeTemplateCallGrammar'; import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition'; import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol'; @@ -60,11 +64,30 @@ self.onmessage = (event: MessageEvent<MarkdownWorkerRequest>) => { type Instance = Awaited<ReturnType<typeof createHighlighter>>; +type BundledLanguageModule = { default: LanguageRegistration[] }; + +/** + * Load a language, neutralizing the catastrophic JS/TS `template-call` rule + * before it reaches the Oniguruma scanner (see sanitizeTemplateCallGrammar). + */ +const loadLanguageSafe = async (instance: Instance, lang: BundledLanguage): Promise<void> => { + if (!isTemplateCallLanguageId(lang)) { + await instance.loadLanguage(bundledLanguages[lang]); + return; + } + + // SAFETY: every Shiki bundled-language module default-exports its grammar + // array; `lang` is narrowed to a bundled id above. + const mod = (await bundledLanguages[lang]()) as BundledLanguageModule; + const grammars = mod.default.map((grammar) => sanitizeTemplateCallGrammar(grammar)); + await instance.loadLanguage(...grammars); +}; + const resolveLanguage = async (instance: Instance, requested: string): Promise<string> => { let lang = requested in bundledLanguages ? requested : 'text'; if (lang !== 'text' && !instance.getLoadedLanguages().includes(lang)) { try { - await instance.loadLanguage(bundledLanguages[lang as BundledLanguage]); + await loadLanguageSafe(instance, lang as BundledLanguage); } catch { lang = 'text'; } diff --git a/packages/ui/src/components/chat/markdown/markdown-worker-timeout.ts b/packages/ui/src/components/chat/markdown/markdown-worker-timeout.ts new file mode 100644 index 00000000..1e82763c --- /dev/null +++ b/packages/ui/src/components/chat/markdown/markdown-worker-timeout.ts @@ -0,0 +1,6 @@ +/** + * Safety-net budget for a single Shiki worker tokenize request. + * Healthy files finish well under this; catastrophic Oniguruma backtracking + * must not run unbounded (openchamber/openchamber#2587). + */ +export const HIGHLIGHT_REQUEST_TIMEOUT_MS = 5_000; diff --git a/packages/ui/src/components/chat/markdown/markdown-worker.hang.test.ts b/packages/ui/src/components/chat/markdown/markdown-worker.hang.test.ts new file mode 100644 index 00000000..df591b2c --- /dev/null +++ b/packages/ui/src/components/chat/markdown/markdown-worker.hang.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from 'bun:test'; + +import { HIGHLIGHT_REQUEST_TIMEOUT_MS } from './markdown-worker-timeout'; + +describe('markdown-worker hang safety', () => { + test('exposes a finite highlight timeout budget', () => { + // Catastrophic Oniguruma backtracking must not run unbounded; the main + // thread terminates the worker after this budget (openchamber/openchamber#2587). + expect(HIGHLIGHT_REQUEST_TIMEOUT_MS).toBeGreaterThan(0); + expect(HIGHLIGHT_REQUEST_TIMEOUT_MS).toBeLessThan(15_001); + }); +}); diff --git a/packages/ui/src/components/chat/markdown/markdown-worker.ts b/packages/ui/src/components/chat/markdown/markdown-worker.ts index b93eb6dc..5408c140 100644 --- a/packages/ui/src/components/chat/markdown/markdown-worker.ts +++ b/packages/ui/src/components/chat/markdown/markdown-worker.ts @@ -7,12 +7,20 @@ import { utf16Bytes, } from './highlightResultCache'; import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol'; +import { HIGHLIGHT_REQUEST_TIMEOUT_MS } from './markdown-worker-timeout'; -// Main-thread client for the markdown Shiki worker. Moves syntax tokenization +// Main-thread client for the markdown Shiki Web Worker. Moves syntax tokenization // off the UI thread: a closed code block is shipped to the worker, which returns // ready-to-splice Shiki HTML. On any failure (no worker support, worker crash, -// tokenization error) the promise resolves to `null` and the caller keeps the -// escaped plain-text code — highlighting never falls back onto the main thread. +// tokenization error, or hang timeout) the promise resolves to `null` and the +// caller keeps the escaped plain-text code — highlighting never falls back onto +// the main thread. +// +// The per-request timeout exists because TextMate grammars can enter catastrophic +// backtracking on the Oniguruma WASM engine (openchamber/openchamber#2587). +// Matching is synchronous inside the worker, so the only way to reclaim its heap +// is to terminate it from this thread once a request exceeds the budget. A timed +// out request resolves `null` like any other failure, so nothing is memoized. // // Results are memoized by content fingerprint (+ lang / theme). Unchanged // content must not re-enter the worker — that was the sustained ~40 msg/s @@ -31,6 +39,11 @@ import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } type PendingResolver = (response: MarkdownWorkerResponse | null) => void; +type PendingEntry = { + resolve: PendingResolver; + timer: ReturnType<typeof setTimeout>; +}; + type CachedHighlight = | { type: 'highlight'; html: string } | { type: 'highlightLines'; lines: string[] } @@ -50,11 +63,15 @@ let worker: Worker | undefined; let workerCreation: Promise<Worker | undefined> | undefined; let workerObjectUrl: string | undefined; let nextId = 0; -const pending = new Map<number, PendingResolver>(); +const pending = new Map<number, PendingEntry>(); // Theme names whose full definition we've already shipped to the live worker, so // repeat tokenization sends only the name (not the whole theme object) again. const sentThemes = new Set<string>(); +const clearPendingTimers = (): void => { + pending.forEach((entry) => clearTimeout(entry.timer)); +}; + const entryBytes = (key: string, value: CachedHighlight): number => { const keyBytes = utf16Bytes(key); if (value.type === 'highlight') return keyBytes + utf16Bytes(value.html); @@ -67,7 +84,8 @@ const entryBytes = (key: string, value: CachedHighlight): number => { }; const failAll = (): void => { - pending.forEach((resolve) => resolve(null)); + clearPendingTimers(); + pending.forEach((entry) => entry.resolve(null)); pending.clear(); sentThemes.clear(); // Drop in-flight waiters; cached results remain valid (pure fn of inputs). @@ -95,10 +113,11 @@ const createWorker = async (): Promise<Worker | undefined> => { const instance = new Worker(workerUrl, { type: 'module' }); worker = instance; instance.onmessage = (event: MessageEvent<MarkdownWorkerResponse>) => { - const resolve = pending.get(event.data.id); - if (!resolve) return; + const entry = pending.get(event.data.id); + if (!entry) return; + clearTimeout(entry.timer); pending.delete(event.data.id); - resolve(event.data); + entry.resolve(event.data); }; instance.onerror = failAll; instance.onmessageerror = failAll; @@ -127,7 +146,14 @@ const request = async (payload: (id: number) => MarkdownWorkerRequest): Promise< if (!instance) return Promise.resolve(null); const id = ++nextId; return new Promise<MarkdownWorkerResponse | null>((resolve) => { - pending.set(id, resolve); + const timer = setTimeout(() => { + if (!pending.has(id)) return; + // Hung tokenize (e.g. catastrophic backtracking): kill the worker so the + // WASM heap is freed instead of growing until the renderer OOMs. + console.warn(`Shiki worker highlight timed out after ${HIGHLIGHT_REQUEST_TIMEOUT_MS}ms; terminating worker`); + failAll(); + }, HIGHLIGHT_REQUEST_TIMEOUT_MS); + pending.set(id, { resolve, timer }); instance.postMessage(payload(id)); }); }; diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index c02d4fc3..b7f69f5e 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -1630,7 +1630,16 @@ const AssistantMessageBody = React.memo(({ && hasAnchoredActivitySegments && Boolean(toggleActivityGroup); - const shouldDeferSortedInlineText = isSortedRenderMode && !hasStopFinish; + // A message that asked a question is blocked until the user answers — it + // never reaches finish === 'stop', so the normal "defer text until final + // output" rule would hide the context the model produced before the + // question indefinitely (OPE-199). Render such messages' text inline, + // matching OpenCode's display. + const hasQuestionTool = React.useMemo(() => { + return toolParts.some((toolPart) => toolPart.tool === 'question'); + }, [toolParts]); + + const shouldDeferSortedInlineText = isSortedRenderMode && !hasStopFinish && !hasQuestionTool; const showErrorMessage = Boolean(errorMessage); const isPeekSurface = chatSurfaceMode === 'peek'; const shouldShowMessageActions = hasCopyableText && !isPeekSurface; diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx index f3a8f530..0e4cd205 100644 --- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -20,6 +20,12 @@ import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } fro import { focusChatInput } from '@/components/chat/composer/editor/dom'; import { registerActiveSelectionToolbar } from '@/lib/addSelectionToChat'; import { collectSelectionOverlayRects } from '@/lib/selectionOverlayRects'; +import { + DESKTOP_MENU_FALLBACK_HEIGHT_PX, + DESKTOP_MENU_FALLBACK_WIDTH_PX, + getDesktopClampedX, + getDesktopClampedY, +} from './selectionMenuPosition'; interface TextSelectionMenuProps { containerRef: React.RefObject<HTMLElement | null>; @@ -43,8 +49,6 @@ const normalizeDistilledInsight = (insight: string): string => ( insight.trim().replace(/^[-*+]\s+/, '').slice(0, PROJECT_NOTE_BODY_MAX_LENGTH) ); -const DESKTOP_MENU_SIDE_MARGIN_PX = 8; -const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280; export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerRef }) => { const { t } = useI18n(); const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false }); @@ -103,6 +107,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR const [isAddingToNotes, setIsAddingToNotes] = React.useState(false); const menuRef = React.useRef<HTMLDivElement>(null); const menuWidthRef = React.useRef(DESKTOP_MENU_FALLBACK_WIDTH_PX); + const menuHeightRef = React.useRef(DESKTOP_MENU_FALLBACK_HEIGHT_PX); const pendingSelectionRef = React.useRef<SelectionPayload | null>(null); const openRafRef = React.useRef<number | null>(null); const mouseUpTimeoutRef = React.useRef<number | null>(null); @@ -196,23 +201,13 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR isMenuVisibleRef.current = false; }, []); - const getDesktopClampedX = React.useCallback((anchorX: number) => { - if (typeof window === 'undefined') { - return anchorX; - } + const getClampedX = React.useCallback((anchorX: number) => ( + getDesktopClampedX(anchorX, window.innerWidth, menuWidthRef.current) + ), []); - const viewportWidth = window.innerWidth; - const menuWidth = menuWidthRef.current; - const halfWidth = menuWidth / 2; - const minX = DESKTOP_MENU_SIDE_MARGIN_PX + halfWidth; - const maxX = viewportWidth - DESKTOP_MENU_SIDE_MARGIN_PX - halfWidth; - - if (minX > maxX) { - return viewportWidth / 2; - } - - return Math.min(Math.max(anchorX, minX), maxX); - }, []); + const getClampedY = React.useCallback((anchorY: number) => ( + getDesktopClampedY(anchorY, window.innerHeight, menuHeightRef.current) + ), []); const addMarkdownToChat = React.useCallback((markdownText: string) => { const markdownBlock = wrapMarkdownSelectionForChat(markdownText); @@ -241,8 +236,10 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR // Position menu above the selection const menuX = isMobile ? rect.left + rect.width / 2 - : getDesktopClampedX(rect.left + rect.width / 2); - const menuY = rect.top - 10; + : getClampedX(rect.left + rect.width / 2); + const menuY = isMobile + ? rect.top - 10 + : getClampedY(rect.top - 10); setSelectedText(plainText); setSelectedTextMarkdown(markdownText); @@ -264,7 +261,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR openRafRef.current = null; }); } - }, [addMarkdownToChat, getDesktopClampedX, hideMenu, isMobile, position.show]); + }, [addMarkdownToChat, getClampedX, getClampedY, hideMenu, isMobile, position.show]); React.useLayoutEffect(() => { if (!position.show || isMobile || !menuRef.current) { @@ -272,16 +269,25 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR } const measuredWidth = menuRef.current.offsetWidth; - if (!Number.isFinite(measuredWidth) || measuredWidth <= 0 || measuredWidth === menuWidthRef.current) { + const measuredHeight = menuRef.current.offsetHeight; + const widthChanged = Number.isFinite(measuredWidth) && measuredWidth > 0 && measuredWidth !== menuWidthRef.current; + const heightChanged = Number.isFinite(measuredHeight) && measuredHeight > 0 && measuredHeight !== menuHeightRef.current; + if (!widthChanged && !heightChanged) { return; } - menuWidthRef.current = measuredWidth; + if (widthChanged) { + menuWidthRef.current = measuredWidth; + } + if (heightChanged) { + menuHeightRef.current = measuredHeight; + } setPosition((prev) => ({ ...prev, - x: getDesktopClampedX(prev.x), + x: getClampedX(prev.x), + y: getClampedY(prev.y), })); - }, [getDesktopClampedX, isMobile, position.show]); + }, [getClampedX, getClampedY, isMobile, position.show]); // The desktop popup hangs above its anchor, so a tall comment box near the // top of the chat can climb over the app header. On the desktop shell the @@ -310,7 +316,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR const handleViewportResize = () => { setPosition((prev) => ({ ...prev, - x: getDesktopClampedX(prev.x), + x: getClampedX(prev.x), + y: getClampedY(prev.y), })); }; @@ -318,7 +325,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR return () => { window.removeEventListener('resize', handleViewportResize); }; - }, [getDesktopClampedX, isMobile, position.show]); + }, [getClampedX, getClampedY, isMobile, position.show]); const handleSelectionChange = React.useCallback(() => { // While the comment input is open, clicking or typing in it collapses the diff --git a/packages/ui/src/components/chat/message/__tests__/selectionMenuPosition.test.ts b/packages/ui/src/components/chat/message/__tests__/selectionMenuPosition.test.ts new file mode 100644 index 00000000..eb3f1a0f --- /dev/null +++ b/packages/ui/src/components/chat/message/__tests__/selectionMenuPosition.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test'; +import { + DESKTOP_MENU_FALLBACK_HEIGHT_PX, + DESKTOP_MENU_FALLBACK_WIDTH_PX, + DESKTOP_MENU_SIDE_MARGIN_PX, + getDesktopClampedX, + getDesktopClampedY, +} from '../selectionMenuPosition'; + +const VIEWPORT_WIDTH = 1024; +const VIEWPORT_HEIGHT = 768; +const MENU_WIDTH = DESKTOP_MENU_FALLBACK_WIDTH_PX; +const MENU_HEIGHT = DESKTOP_MENU_FALLBACK_HEIGHT_PX; + +// Regression coverage for issue #2257: selecting a long assistant response +// across a scroll boundary makes range.getBoundingClientRect().top negative, +// and the unclamped anchor (rect.top - 10) placed the menu above the viewport. +describe('getDesktopClampedY (issue #2257)', () => { + test('keeps the menu on screen when the selection starts above the viewport', () => { + const clamped = getDesktopClampedY(-210, VIEWPORT_HEIGHT, MENU_HEIGHT); + expect(clamped).toBe(DESKTOP_MENU_SIDE_MARGIN_PX + MENU_HEIGHT); + }); + + test('keeps the menu fully visible for selections near the top edge', () => { + // The menu renders with translate(-50%, -100%), so it extends upward from + // the anchor; anchors smaller than margin + menu height clip the menu. + const clamped = getDesktopClampedY(5, VIEWPORT_HEIGHT, MENU_HEIGHT); + expect(clamped).toBe(DESKTOP_MENU_SIDE_MARGIN_PX + MENU_HEIGHT); + }); + + test('clamps anchors below the viewport back to the bottom margin', () => { + const clamped = getDesktopClampedY(VIEWPORT_HEIGHT + 500, VIEWPORT_HEIGHT, MENU_HEIGHT); + expect(clamped).toBe(VIEWPORT_HEIGHT - DESKTOP_MENU_SIDE_MARGIN_PX); + }); + + test('leaves in-viewport anchors unchanged', () => { + expect(getDesktopClampedY(300, VIEWPORT_HEIGHT, MENU_HEIGHT)).toBe(300); + expect(getDesktopClampedY(MENU_HEIGHT + DESKTOP_MENU_SIDE_MARGIN_PX, VIEWPORT_HEIGHT, MENU_HEIGHT)) + .toBe(MENU_HEIGHT + DESKTOP_MENU_SIDE_MARGIN_PX); + }); + + test('falls back to the viewport middle when the viewport is shorter than the menu', () => { + const tinyViewportHeight = MENU_HEIGHT; + expect(getDesktopClampedY(10, tinyViewportHeight, MENU_HEIGHT)).toBe(tinyViewportHeight / 2); + }); +}); + +describe('getDesktopClampedX', () => { + test('clamps anchors past the left edge to the left margin', () => { + const clamped = getDesktopClampedX(-500, VIEWPORT_WIDTH, MENU_WIDTH); + expect(clamped).toBe(DESKTOP_MENU_SIDE_MARGIN_PX + MENU_WIDTH / 2); + }); + + test('clamps anchors past the right edge to the right margin', () => { + const clamped = getDesktopClampedX(VIEWPORT_WIDTH + 500, VIEWPORT_WIDTH, MENU_WIDTH); + expect(clamped).toBe(VIEWPORT_WIDTH - DESKTOP_MENU_SIDE_MARGIN_PX - MENU_WIDTH / 2); + }); + + test('leaves in-viewport anchors unchanged', () => { + expect(getDesktopClampedX(VIEWPORT_WIDTH / 2, VIEWPORT_WIDTH, MENU_WIDTH)).toBe(VIEWPORT_WIDTH / 2); + }); + + test('falls back to the viewport middle when the viewport is narrower than the menu', () => { + const tinyViewportWidth = MENU_WIDTH / 2; + expect(getDesktopClampedX(10, tinyViewportWidth, MENU_WIDTH)).toBe(tinyViewportWidth / 2); + }); +}); diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md index 821d462e..6ca59ba8 100644 --- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md @@ -89,6 +89,7 @@ Use this doc when you ask an agent to change tool/header/description behavior. - 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 output viewport grows with the content up to `46vh`, then scrolls and follows new output until the user scrolls up; following resumes 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`). +- Reasoning streaming presentation derives from the live stream phase (`streaming`/`cooldown`), never from missing persisted timing: a cached part without `time.end` is not live, and a part whose `time.end` is set never streams (issue #2020). ## "I want to change description for Perplexity" (example recipe) diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx index 90691c57..ac55bf68 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx @@ -1,9 +1,11 @@ import React from 'react'; import { describe, expect, test } from 'bun:test'; import { renderToStaticMarkup } from 'react-dom/server'; +import type { Part } from '@opencode-ai/sdk/v2'; import { I18nProvider } from '@/lib/i18n'; -import { ReasoningTimelineBlock } from './ReasoningPart'; +import ReasoningPart, { ReasoningTimelineBlock } from './ReasoningPart'; +import type { StreamPhase } from '../types'; // A reasoning text whose summary (first 120 chars) fits in the header but // whose expanded body content should only appear when the disclosure is open. @@ -113,3 +115,80 @@ describe('ReasoningTimelineBlock', () => { expect(markup).not.toContain('<!-- -->'); }); }); + +// Regression tests for issue #2020: a persisted reasoning part must not be +// presented as live streaming just because cached data lacks `time.end` or a +// stream phase. Live activity derives from the live stream phase only. +describe('ReasoningPart streaming gating (issue #2020)', () => { + // Short enough (< 80 chars) that the collapsed header summary contains the + // complete text, letting us assert full content on first paint. + const SHORT_REASONING = 'Persisted reasoning text that is already fully available.'; + + const BUSY_INDICATOR = 'animate-busy-pulse'; + + const makeReasoningPart = (time?: { start?: number; end?: number }): Part => + ({ + id: 'prt_reasoning_2020', + sessionID: 'ses_2020', + messageID: 'msg_2020', + type: 'reasoning', + text: SHORT_REASONING, + time, + }) as unknown as Part; + + // Server rendering reads the UI store's initial state, which is + // chatRenderMode 'live' — the mode in which the streaming presentation is + // reachable and the issue reproduces. + const renderPart = (part: Part, streamPhase?: StreamPhase): string => + renderToStaticMarkup( + <I18nProvider> + <ReasoningPart part={part} messageId="msg_2020" streamPhase={streamPhase} /> + </I18nProvider>, + ); + + test('reasoning without time.end and without a live stream phase renders complete, not streaming', () => { + // Freshly opened completed session: cached part never received `time.end` + // and no message-level stream phase is available. The full text is already + // local, so the block must render as finished content on first paint. + const markup = renderPart(makeReasoningPart({ start: 1_000 }), undefined); + + expect(markup).not.toContain(BUSY_INDICATOR); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain(SHORT_REASONING); + }); + + test('reasoning without time.end in a completed message renders complete, not streaming', () => { + const markup = renderPart(makeReasoningPart({ start: 1_000 }), 'completed'); + + expect(markup).not.toContain(BUSY_INDICATOR); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain(SHORT_REASONING); + }); + + test('reasoning with time.end is never treated as streaming, even when the phase claims streaming', () => { + const markup = renderPart(makeReasoningPart({ start: 1_000, end: 2_000 }), 'streaming'); + + expect(markup).not.toContain(BUSY_INDICATOR); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain(SHORT_REASONING); + }); + + test('live in-progress reasoning still renders as streaming', () => { + // Genuinely live: the message-level stream phase reports streaming and the + // part has not ended. The block auto-expands and shows the busy indicator. + const markup = renderPart(makeReasoningPart({ start: 1_000 }), 'streaming'); + + expect(markup).toContain(BUSY_INDICATOR); + expect(markup).toContain('aria-expanded="true"'); + }); + + test('remounting a completed reasoning part does not re-trigger the streaming presentation', () => { + const part = makeReasoningPart({ start: 1_000 }); + const first = renderPart(part, undefined); + const second = renderPart(part, undefined); + + expect(second).toBe(first); + expect(second).not.toContain(BUSY_INDICATOR); + expect(second).toContain(SHORT_REASONING); + }); +}); diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx index 1922100c..169c0eb7 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx @@ -261,7 +261,11 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({ }; }, []); - if (!text || text.trim().length === 0) { + // While genuinely streaming, the busy header must appear as soon as + // reasoning starts even before the block-level reveal (commitStreamedText) + // has committed a first complete line — otherwise "Thinking…" never shows + // for the first moments of a short, single-paragraph response. + if (!isStreaming && (!text || text.trim().length === 0)) { return null; } @@ -430,8 +434,12 @@ const ReasoningPart = React.memo(({ const rawText = partWithText.text || partWithText.content || ''; const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]); const time = partWithText.time; - const canBeStreaming = streamPhase === undefined || streamPhase !== 'completed'; - const isStreaming = chatRenderMode === 'live' && canBeStreaming && typeof time?.end !== 'number'; + // Live activity derives from the live stream phase, never from the absence + // of persisted timing data: cached parts may lack `time.end` even though + // the message finished long ago (issue #2020). A part that has ended is + // never streaming, even while the rest of the message still streams. + const isLiveStreamPhase = streamPhase === 'streaming' || streamPhase === 'cooldown'; + const isStreaming = chatRenderMode === 'live' && isLiveStreamPhase && typeof time?.end !== 'number'; const throttledTextRaw = useStreamingTextThrottle({ text: textContent, isStreaming, @@ -441,9 +449,11 @@ const ReasoningPart = React.memo(({ // never mutates in place. const throttledText = isStreaming ? commitStreamedText(throttledTextRaw) : throttledTextRaw; - // Show reasoning even if time.end isn't set yet (during streaming) - // Only hide if there's no text content - if (!throttledText || throttledText.trim().length === 0) { + // Show reasoning even if time.end isn't set yet (during streaming). + // While genuinely streaming, keep the block mounted even before the + // block-level reveal commits a first line, so the busy header appears + // immediately instead of waiting on committed text. + if (!isStreaming && (!throttledText || throttledText.trim().length === 0)) { return null; } diff --git a/packages/ui/src/components/chat/message/selectionMenuPosition.ts b/packages/ui/src/components/chat/message/selectionMenuPosition.ts new file mode 100644 index 00000000..7a431e6d --- /dev/null +++ b/packages/ui/src/components/chat/message/selectionMenuPosition.ts @@ -0,0 +1,29 @@ +export const DESKTOP_MENU_SIDE_MARGIN_PX = 8; +export const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280; +export const DESKTOP_MENU_FALLBACK_HEIGHT_PX = 38; + +export const getDesktopClampedX = (anchorX: number, viewportWidth: number, menuWidth: number): number => { + const halfWidth = menuWidth / 2; + const minX = DESKTOP_MENU_SIDE_MARGIN_PX + halfWidth; + const maxX = viewportWidth - DESKTOP_MENU_SIDE_MARGIN_PX - halfWidth; + + if (minX > maxX) { + return viewportWidth / 2; + } + + return Math.min(Math.max(anchorX, minX), maxX); +}; + +// The desktop menu renders with `transform: translate(-50%, -100%)`, so the +// anchor Y marks the menu's bottom edge and the menu extends `menuHeight` +// upward from it. The minimum keeps the whole menu below the top margin. +export const getDesktopClampedY = (anchorY: number, viewportHeight: number, menuHeight: number): number => { + const minY = DESKTOP_MENU_SIDE_MARGIN_PX + menuHeight; + const maxY = viewportHeight - DESKTOP_MENU_SIDE_MARGIN_PX; + + if (minY > maxY) { + return viewportHeight / 2; + } + + return Math.min(Math.max(anchorY, minY), maxY); +}; diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts index f02f026d..faf2d60e 100644 --- a/packages/ui/src/components/icon/sprite.ts +++ b/packages/ui/src/components/icon/sprite.ts @@ -2,8 +2,6 @@ // Do not edit manually. Run the script to update. export const iconSpriteData = { - "linear": `<g transform="translate(1.5 1.5) scale(0.21)"><path fill="currentColor" d="M1.22541 61.5228c-.2225-.9485.90748-1.5459 1.59638-.857L39.3342 97.1782c.6889.6889.0915 1.8189-.857 1.5964C20.0515 94.4522 5.54779 79.9485 1.22541 61.5228ZM.00189135 46.8891c-.01764375.2833.08887215.5599.28957165.7606L52.3503 99.7085c.2007.2007.4773.3075.7606.2896 2.3692-.1476 4.6938-.46 6.9624-.9259.7645-.157 1.0301-1.0963.4782-1.6481L2.57595 39.4485c-.55186-.5519-1.49117-.2863-1.648174.4782-.465915 2.2686-.77832 4.5932-.92588465 6.9624ZM4.21093 29.7054c-.16649.3738-.08169.8106.20765 1.1l64.77602 64.776c.2894.2894.7262.3742 1.1.2077 1.7861-.7956 3.5171-1.6927 5.1855-2.684.5521-.328.6373-1.0867.1832-1.5407L8.43566 24.3367c-.45409-.4541-1.21271-.3689-1.54074.1832-.99132 1.6686-1.88843 3.3994-2.68399 5.1855ZM12.6587 18.074c-.3701-.3701-.393-.9637-.0443-1.3541C21.7795 6.45931 35.1114 0 49.9519 0 77.5927 0 100 22.4073 100 50.0481c0 14.8405-6.4593 28.1724-16.7199 37.3375-.3903.3487-.984.3258-1.3542-.0443L12.6587 18.074Z"/></g>`, - "cloudflare": `<g transform="translate(0.2 0.2) scale(0.18)"><path fill="currentColor" d="M87.295 89.022c.763-2.617.472-5.015-.8-6.796-1.163-1.635-3.125-2.58-5.488-2.689l-44.737-.581c-.291 0-.545-.145-.691-.363s-.182-.509-.109-.8c.145-.436.581-.763 1.054-.8l45.137-.581c5.342-.254 11.157-4.579 13.192-9.885l2.58-6.723c.109-.291.145-.581.073-.872-2.906-13.158-14.644-22.97-28.672-22.97-12.938 0-23.913 8.359-27.838 19.952a13.35 13.35 0 0 0-9.267-2.58c-6.215.618-11.193 5.597-11.811 11.811-.145 1.599-.036 3.162.327 4.615C10.104 70.051 2 78.337 2 88.549c0 .909.073 1.817.182 2.726a.895.895 0 0 0 .872.763h82.57c.472 0 .909-.327 1.054-.8l.617-2.216z"/><path fill="currentColor" d="M101.542 60.275c-.4 0-.836 0-1.236.036-.291 0-.545.218-.654.509l-1.744 6.069c-.763 2.617-.472 5.015.8 6.796 1.163 1.635 3.125 2.58 5.488 2.689l9.522.581c.291 0 .545.145.691.363.145.218.182.545.109.8-.145.436-.581.763-1.054.8l-9.924.582c-5.379.254-11.157 4.579-13.192 9.885l-.727 1.853c-.145.363.109.727.509.727h34.089c.4 0 .763-.254.872-.654.581-2.108.909-4.325.909-6.614 0-13.447-10.975-24.422-24.458-24.422"/></g>`, "add": `<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z" fill="currentColor"/>`, "add-circle": `<path d="M11 11V7H13V11H17V13H13V17H11V13H7V11H11ZM12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20Z" fill="currentColor"/>`, "ai-agent": `<path d="M12 2C17.5228 2 22 6.47715 22 12C22 14.7096 20.9205 17.1697 19.1709 18.9697C17.3551 20.8376 14.8124 22 12 22C9.18756 22 6.64488 20.8376 4.8291 18.9697C3.07949 17.1697 2 14.7096 2 12C2 6.47715 6.47715 2 12 2ZM12 16C10.0022 16 8.20124 16.8375 6.9248 18.1816C8.30642 19.3175 10.0724 20 12 20C13.9274 20 15.6927 19.3173 17.0742 18.1816C15.7978 16.8377 13.9975 16 12 16ZM12 4C7.58172 4 4 7.58172 4 12C4 13.7701 4.57462 15.4044 5.54785 16.7295C7.1822 15.0483 9.46797 14 12 14C14.5318 14 16.8169 15.0485 18.4512 16.7295C19.4246 15.4043 20 13.7703 20 12C20 7.58172 16.4183 4 12 4ZM11.5293 5.31934C11.7058 4.89329 12.2943 4.89329 12.4707 5.31934L12.7236 5.93066C13.1556 6.97343 13.9615 7.80622 14.9746 8.25684L15.6924 8.5752C16.1029 8.75796 16.1028 9.35627 15.6924 9.53906L14.9326 9.87695C13.9448 10.3163 13.1534 11.1193 12.7139 12.1279L12.4668 12.6934C12.2864 13.1074 11.7137 13.1074 11.5332 12.6934L11.2871 12.1279C10.8476 11.1193 10.0552 10.3163 9.06738 9.87695L8.30762 9.53906C7.89719 9.35628 7.89717 8.75795 8.30762 8.5752L9.02539 8.25684C10.0385 7.80623 10.8445 6.97345 11.2764 5.93066L11.5293 5.31934Z" fill="currentColor"/>`, @@ -33,7 +31,6 @@ export const iconSpriteData = { "book-marked": `<path d="M3 18.5V5C3 3.34315 4.34315 2 6 2H20C20.5523 2 21 2.44772 21 3V21C21 21.5523 20.5523 22 20 22H6.5C4.567 22 3 20.433 3 18.5ZM19 20V17H6.5C5.67157 17 5 17.6716 5 18.5C5 19.3284 5.67157 20 6.5 20H19ZM10 4H6C5.44772 4 5 4.44772 5 5V15.3368C5.45463 15.1208 5.9632 15 6.5 15H19V4H17V12L13.5 10L10 12V4Z" fill="currentColor"/>`, "book-open": `<path d="M13 21V23H11V21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H9C10.1947 3 11.2671 3.52375 12 4.35418C12.7329 3.52375 13.8053 3 15 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H13ZM20 19V5H15C13.8954 5 13 5.89543 13 7V19H20ZM11 19V7C11 5.89543 10.1046 5 9 5H4V19H11Z" fill="currentColor"/>`, "booklet": `<path d="M20.0049 2C21.1068 2 22 2.89821 22 3.9908V20.0092C22 21.1087 21.1074 22 20.0049 22H4V18H2V16H4V13H2V11H4V8H2V6H4V2H20.0049ZM8 4H6V20H8V4ZM20 4H10V20H20V4Z" fill="currentColor"/>`, - "braces": `<path d="M4 18V14.3C4 13.4716 3.32843 12.8 2.5 12.8H2V11.2H2.5C3.32843 11.2 4 10.5284 4 9.7V6C4 4.34315 5.34315 3 7 3H8V5H7C6.44772 5 6 5.44772 6 6V10.1C6 10.9858 5.42408 11.7372 4.62623 12C5.42408 12.2628 6 13.0142 6 13.9V18C6 18.5523 6.44772 19 7 19H8V21H7C5.34315 21 4 19.6569 4 18ZM20 14.3V18C20 19.6569 18.6569 21 17 21H16V19H17C17.5523 19 18 18.5523 18 18V13.9C18 13.0142 18.5759 12.2628 19.3738 12C18.5759 11.7372 18 10.9858 18 10.1V6C18 5.44772 17.5523 5 17 5H16V3H17C18.6569 3 20 4.34315 20 6V9.7C20 10.5284 20.6716 11.2 21.5 11.2H22V12.8H21.5C20.6716 12.8 20 13.4716 20 14.3Z" fill="currentColor"/>`, "brain": `<path d="M9 4C10.1046 4 11 4.89543 11 6V12.8271C10.1058 12.1373 8.96602 11.7305 7.6644 11.5136L7.3356 13.4864C8.71622 13.7165 9.59743 14.1528 10.1402 14.7408C10.67 15.3147 11 16.167 11 17.5C11 18.8807 9.88071 20 8.5 20C7.11929 20 6 18.8807 6 17.5V17.1493C6.43007 17.2926 6.87634 17.4099 7.3356 17.4864L7.6644 15.5136C6.92149 15.3898 6.1752 15.1144 5.42909 14.7599C4.58157 14.3573 4 13.499 4 12.5C4 11.6653 4.20761 11.0085 4.55874 10.5257C4.90441 10.0504 5.4419 9.6703 6.24254 9.47014L7 9.28078V6C7 4.89543 7.89543 4 9 4ZM12 3.35418C11.2671 2.52376 10.1947 2 9 2C6.79086 2 5 3.79086 5 6V7.77422C4.14895 8.11644 3.45143 8.64785 2.94126 9.34933C2.29239 10.2415 2 11.3347 2 12.5C2 14.0652 2.79565 15.4367 4 16.2422V17.5C4 19.9853 6.01472 22 8.5 22C9.91363 22 11.175 21.3482 12 20.3287C12.825 21.3482 14.0864 22 15.5 22C17.9853 22 20 19.9853 20 17.5V16.2422C21.2044 15.4367 22 14.0652 22 12.5C22 11.3347 21.7076 10.2415 21.0587 9.34933C20.5486 8.64785 19.8511 8.11644 19 7.77422V6C19 3.79086 17.2091 2 15 2C13.8053 2 12.7329 2.52376 12 3.35418ZM18 17.1493V17.5C18 18.8807 16.8807 20 15.5 20C14.1193 20 13 18.8807 13 17.5C13 16.167 13.33 15.3147 13.8598 14.7408C14.4026 14.1528 15.2838 13.7165 16.6644 13.4864L16.3356 11.5136C15.034 11.7305 13.8942 12.1373 13 12.8271V6C13 4.89543 13.8954 4 15 4C16.1046 4 17 4.89543 17 6V9.28078L17.7575 9.47014C18.5581 9.6703 19.0956 10.0504 19.4413 10.5257C19.7924 11.0085 20 11.6653 20 12.5C20 13.499 19.4184 14.3573 18.5709 14.7599C17.8248 15.1144 17.0785 15.3898 16.3356 15.5136L16.6644 17.4864C17.1237 17.4099 17.5699 17.2926 18 17.1493Z" fill="currentColor"/>`, "brain-4": `<path d="M19.5 4.7832V7.6709L22 9.11426V14.8867L19.499 16.3311L19.5 19.2178L14.5 22.1045L12 20.6611L9.5 22.1045L4.5 19.2178V16.3311L2 14.8877L2.00098 9.11328L4.5 7.66992V4.78418L9.5 1.89746L11.999 3.34082L14.501 1.89648L19.5 4.7832ZM13 5.07227L12.999 8.42285L15.9639 10.1338L14.9639 11.8662L11 9.57715V5.07324L9.5 4.20703L6.49902 5.93848V8.8252L4 10.2676V13.7334L6.5 15.1768V18.0635L9.5 19.7959L11 18.9287L11.001 15.5771L8.03613 13.8652L9.03613 12.1338L13.001 14.4229V18.9297L14.5 19.7959L17.5 18.0625V15.1768L20 13.7324V10.2695L17.499 8.8252L17.5 5.9375L14.501 4.20605L13 5.07227Z" fill="currentColor"/>`, "brain-ai-3": `<path d="M19.5 4.7832V7.6709L22 9.11426V14.8867L19.499 16.3311L19.5 19.2178L14.5 22.1045L12 20.6611L9.5 22.1045L4.5 19.2178V16.3311L2 14.8877L2.00098 9.11328L4.5 7.66992V4.78418L9.5 1.89746L11.999 3.34082L14.501 1.89648L19.5 4.7832ZM13 5.07227V7H11V5.07324L9.5 4.20703L6.49902 5.93848V8.8252L4 10.2676V13.7334L6.5 15.1768V18.0635L9.5 19.7959L11 18.9287V17H13V18.9297L14.5 19.7959L17.5 18.0625V15.1768L20 13.7324V10.2695L17.499 8.8252L17.5 5.9375L14.501 4.20605L13 5.07227ZM14.2646 13.1602C14.3529 12.9473 14.6472 12.9473 14.7354 13.1602L14.8623 13.4648C15.0783 13.986 15.4807 14.4027 15.9873 14.6279L16.3457 14.7871C16.5511 14.8784 16.5511 15.1773 16.3457 15.2686L15.9658 15.4375C15.4721 15.6571 15.0761 16.0586 14.8564 16.5625L14.7334 16.8447C14.6432 17.0517 14.3569 17.0517 14.2666 16.8447L14.1436 16.5625C13.9239 16.0586 13.5279 15.6571 13.0342 15.4375L12.6543 15.2686C12.4489 15.1773 12.4489 14.8784 12.6543 14.7871L13.0127 14.6279C13.5193 14.4027 13.9217 13.986 14.1377 13.4648L14.2646 13.1602ZM9.58789 7.7793C9.74239 7.40671 10.2577 7.4067 10.4121 7.7793L10.6338 8.31445C11.0118 9.22695 11.7161 9.95624 12.6025 10.3506L13.2305 10.6289C13.5899 10.7887 13.5897 11.3117 13.2305 11.4717L12.5654 11.7676C11.7013 12.152 11.0086 12.8548 10.624 13.7373L10.4082 14.2324C10.2504 14.5948 9.74973 14.5948 9.5918 14.2324L9.37598 13.7373C8.99143 12.8548 8.29875 12.152 7.43457 11.7676L6.76953 11.4717C6.41033 11.3117 6.41022 10.7887 6.76953 10.6289L7.39746 10.3506C8.2839 9.95624 8.98832 9.22697 9.36621 8.31445L9.58789 7.7793Z" fill="currentColor"/>`, @@ -61,13 +58,13 @@ export const iconSpriteData = { "close-circle": `<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM12 10.5858L14.8284 7.75736L16.2426 9.17157L13.4142 12L16.2426 14.8284L14.8284 16.2426L12 13.4142L9.17157 16.2426L7.75736 14.8284L10.5858 12L7.75736 9.17157L9.17157 7.75736L12 10.5858Z" fill="currentColor"/>`, "cloud": `<path d="M12 2C15.866 2 19 5.13401 19 9C19 9.11351 18.9973 9.22639 18.992 9.33857C21.3265 10.16 23 12.3846 23 15C23 18.3137 20.3137 21 17 21H7C3.68629 21 1 18.3137 1 15C1 12.3846 2.67346 10.16 5.00804 9.33857C5.0027 9.22639 5 9.11351 5 9C5 5.13401 8.13401 2 12 2ZM12 4C9.23858 4 7 6.23858 7 9C7 9.08147 7.00193 9.16263 7.00578 9.24344L7.07662 10.7309L5.67183 11.2252C4.0844 11.7837 3 13.2889 3 15C3 17.2091 4.79086 19 7 19H17C19.2091 19 21 17.2091 21 15C21 12.79 19.21 11 17 11C15.233 11 13.7337 12.1457 13.2042 13.7347L11.3064 13.1021C12.1005 10.7185 14.35 9 17 9C17 6.23858 14.7614 4 12 4Z" fill="currentColor"/>`, "cloud-off": `<path d="M3.51472 2.10051L22.6066 21.1924L21.1924 22.6066L19.1782 20.5924C18.503 20.8556 17.7684 21 17 21H7C3.68629 21 1 18.3137 1 15C1 12.3846 2.67346 10.16 5.00804 9.33857C5.0027 9.22639 5 9.11351 5 9C5 8.22228 5.12683 7.47418 5.36094 6.77527L2.10051 3.51472L3.51472 2.10051ZM7 9C7 9.08147 7.00193 9.16263 7.00578 9.24344L7.07662 10.7309L5.67183 11.2252C4.0844 11.7837 3 13.2889 3 15C3 17.2091 4.79086 19 7 19H17C17.1858 19 17.3687 18.9873 17.5478 18.9628L7.03043 8.44519C7.01032 8.62736 7 8.81247 7 9ZM12 2C15.866 2 19 5.13401 19 9C19 9.11351 18.9973 9.22639 18.992 9.33857C21.3265 10.16 23 12.3846 23 15C23 16.0883 22.7103 17.1089 22.2037 17.9889L20.7111 16.4955C20.8974 16.0335 21 15.5287 21 15C21 12.79 19.21 11 17 11C16.4711 11 15.9661 11.1027 15.5039 11.2892L14.0111 9.7964C14.8912 9.28978 15.9118 9 17 9C17 6.23858 14.7614 4 12 4C10.9295 4 9.93766 4.33639 9.12428 4.90922L7.69418 3.48056C8.88169 2.55284 10.3763 2 12 2Z" fill="currentColor"/>`, + "cloudflare": `<g transform="translate(0.2 0.2) scale(0.18)"><path fill="currentColor" d="M87.295 89.022c.763-2.617.472-5.015-.8-6.796-1.163-1.635-3.125-2.58-5.488-2.689l-44.737-.581c-.291 0-.545-.145-.691-.363s-.182-.509-.109-.8c.145-.436.581-.763 1.054-.8l45.137-.581c5.342-.254 11.157-4.579 13.192-9.885l2.58-6.723c.109-.291.145-.581.073-.872-2.906-13.158-14.644-22.97-28.672-22.97-12.938 0-23.913 8.359-27.838 19.952a13.35 13.35 0 0 0-9.267-2.58c-6.215.618-11.193 5.597-11.811 11.811-.145 1.599-.036 3.162.327 4.615C10.104 70.051 2 78.337 2 88.549c0 .909.073 1.817.182 2.726a.895.895 0 0 0 .872.763h82.57c.472 0 .909-.327 1.054-.8l.617-2.216z"/><path fill="currentColor" d="M101.542 60.275c-.4 0-.836 0-1.236.036-.291 0-.545.218-.654.509l-1.744 6.069c-.763 2.617-.472 5.015.8 6.796 1.163 1.635 3.125 2.58 5.488 2.689l9.522.581c.291 0 .545.145.691.363.145.218.182.545.109.8-.145.436-.581.763-1.054.8l-9.924.582c-5.379.254-11.157 4.579-13.192 9.885l-.727 1.853c-.145.363.109.727.509.727h34.089c.4 0 .763-.254.872-.654.581-2.108.909-4.325.909-6.614 0-13.447-10.975-24.422-24.458-24.422"/></g>`, "code": `<path d="M23 12L15.9289 19.0711L14.5147 17.6569L20.1716 12L14.5147 6.34317L15.9289 4.92896L23 12ZM3.82843 12L9.48528 17.6569L8.07107 19.0711L1 12L8.07107 4.92896L9.48528 6.34317L3.82843 12Z" fill="currentColor"/>`, "code-ai": `<path d="M17.7134 10.1281L17.4668 10.6938C17.2864 11.1079 16.7136 11.1079 16.5331 10.6938L16.2866 10.1281C15.8471 9.11947 15.0555 8.31641 14.0677 7.87708L13.308 7.53922C12.8973 7.35653 12.8973 6.75881 13.308 6.57612L14.0252 6.25714C15.0384 5.80651 15.8442 4.97373 16.2761 3.93083L16.5293 3.31953C16.7058 2.89349 17.2942 2.89349 17.4706 3.31953L17.7238 3.93083C18.1558 4.97373 18.9616 5.80651 19.9748 6.25714L20.6919 6.57612C21.1027 6.75881 21.1027 7.35653 20.6919 7.53922L19.9323 7.87708C18.9445 8.31641 18.1529 9.11947 17.7134 10.1281ZM2.82843 12.0001L7.07107 16.2428L5.65685 17.657L0 12.0001L5.65685 6.34326L7.07107 7.75748L2.82843 12.0001ZM18.3429 17.6572L23.9998 12.0003L21.1714 9.17188L19.7571 10.5861L21.1714 12.0003L16.9287 16.2429L18.3429 17.6572Z" fill="currentColor"/>`, "code-box": `<path d="M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM4 5V19H20V5H4ZM20 12L16.4645 15.5355L15.0503 14.1213L17.1716 12L15.0503 9.87868L16.4645 8.46447L20 12ZM6.82843 12L8.94975 14.1213L7.53553 15.5355L4 12L7.53553 8.46447L8.94975 9.87868L6.82843 12ZM11.2443 17H9.11597L12.7557 7H14.884L11.2443 17Z" fill="currentColor"/>`, "code-sslash": `<path d="M24 12L18.3431 17.6569L16.9289 16.2426L21.1716 12L16.9289 7.75736L18.3431 6.34315L24 12ZM2.82843 12L7.07107 16.2426L5.65685 17.6569L0 12L5.65685 6.34315L7.07107 7.75736L2.82843 12ZM9.78845 21H7.66009L14.2116 3H16.3399L9.78845 21Z" fill="currentColor"/>`, "collapse-vertical": `<path d="M11.9995 13.4995 16.9492 18.4493 15.535 19.8635 12.9995 17.3279 12.9995 22.9995H10.9995L10.9995 17.3279 8.46643 19.861 7.05222 18.4468 11.9995 13.4995ZM10.9995.999512 10.9995 6.67035 8.46448 4.13535 7.05026 5.54956 12 10.4995 16.9497 5.54977 15.5355 4.13555 12.9995 6.67157V.999512L10.9995.999512Z" fill="currentColor"/>`, "command": `<path d="M10 8H14V6.5C14 4.567 15.567 3 17.5 3C19.433 3 21 4.567 21 6.5C21 8.433 19.433 10 17.5 10H16V14H17.5C19.433 14 21 15.567 21 17.5C21 19.433 19.433 21 17.5 21C15.567 21 14 19.433 14 17.5V16H10V17.5C10 19.433 8.433 21 6.5 21C4.567 21 3 19.433 3 17.5C3 15.567 4.567 14 6.5 14H8V10H6.5C4.567 10 3 8.433 3 6.5C3 4.567 4.567 3 6.5 3C8.433 3 10 4.567 10 6.5V8ZM8 8V6.5C8 5.67157 7.32843 5 6.5 5C5.67157 5 5 5.67157 5 6.5C5 7.32843 5.67157 8 6.5 8H8ZM8 16H6.5C5.67157 16 5 16.6716 5 17.5C5 18.3284 5.67157 19 6.5 19C7.32843 19 8 18.3284 8 17.5V16ZM16 8H17.5C18.3284 8 19 7.32843 19 6.5C19 5.67157 18.3284 5 17.5 5C16.6716 5 16 5.67157 16 6.5V8ZM16 16V17.5C16 18.3284 16.6716 19 17.5 19C18.3284 19 19 18.3284 19 17.5C19 16.6716 18.3284 16 17.5 16H16ZM10 10V14H14V10H10Z" fill="currentColor"/>`, - "command-code": `<path fill="currentColor" d="M5.8 5.8h4.8v4.8h-4.8Z M13.4 5.8h4.8v4.8h-4.8Z M10.6 10.6h2.8v2.8h-2.8Z M5.8 13.4h4.8v4.8h-4.8Z M13.4 13.4h4.8v4.8h-4.8Z"/>`, "compass-3": `<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM16.5 7.5L14 14L7.5 16.5L10 10L16.5 7.5ZM12 13C12.5523 13 13 12.5523 13 12C13 11.4477 12.5523 11 12 11C11.4477 11 11 11.4477 11 12C11 12.5523 11.4477 13 12 13Z" fill="currentColor"/>`, "computer": `<path d="M4 16H20V5H4V16ZM13 18V20H17V22H7V20H11V18H2.9918C2.44405 18 2 17.5511 2 16.9925V4.00748C2 3.45107 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44892 22 4.00748V16.9925C22 17.5489 21.5447 18 21.0082 18H13Z" fill="currentColor"/>`, "contract-up-down": `<path d="M5.79285 5.20718 12 11.4143 18.2071 5.20718 16.7928 3.79297 12 8.58586 7.20706 3.79297 5.79285 5.20718ZM18.2072 18.7928 12.0001 12.5857 5.793 18.7928 7.20721 20.207 12.0001 15.4141 16.793 20.207 18.2072 18.7928Z" fill="currentColor"/>`, @@ -87,6 +84,9 @@ export const iconSpriteData = { "emotion-happy": `<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM7 13H9C9 14.6569 10.3431 16 12 16C13.6569 16 15 14.6569 15 13H17C17 15.7614 14.7614 18 12 18C9.23858 18 7 15.7614 7 13ZM8 11C7.17157 11 6.5 10.3284 6.5 9.5C6.5 8.67157 7.17157 8 8 8C8.82843 8 9.5 8.67157 9.5 9.5C9.5 10.3284 8.82843 11 8 11ZM16 11C15.1716 11 14.5 10.3284 14.5 9.5C14.5 8.67157 15.1716 8 16 8C16.8284 8 17.5 8.67157 17.5 9.5C17.5 10.3284 16.8284 11 16 11Z" fill="currentColor"/>`, "equalizer-2": `<path d="M5 7C5 6.17157 5.67157 5.5 6.5 5.5C7.32843 5.5 8 6.17157 8 7C8 7.82843 7.32843 8.5 6.5 8.5C5.67157 8.5 5 7.82843 5 7ZM6.5 3.5C4.567 3.5 3 5.067 3 7C3 8.933 4.567 10.5 6.5 10.5C8.433 10.5 10 8.933 10 7C10 5.067 8.433 3.5 6.5 3.5ZM12 8H20V6H12V8ZM16 17C16 16.1716 16.6716 15.5 17.5 15.5C18.3284 15.5 19 16.1716 19 17C19 17.8284 18.3284 18.5 17.5 18.5C16.6716 18.5 16 17.8284 16 17ZM17.5 13.5C15.567 13.5 14 15.067 14 17C14 18.933 15.567 20.5 17.5 20.5C19.433 20.5 21 18.933 21 17C21 15.067 19.433 13.5 17.5 13.5ZM4 16V18H12V16H4Z" fill="currentColor"/>`, "error-warning": `<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM11 7H13V13H11V7Z" fill="currentColor"/>`, + "expand-horizontal": `<path d="M0.5 12L5.44975 7.05029L6.86396 8.46451L4.32843 11H10V13H4.32843L6.86148 15.5331L5.44727 16.9473L0.5 12ZM14 13H19.6708L17.1358 15.535L18.55 16.9493L23.5 11.9996L18.5503 7.0498L17.136 8.46402L19.6721 11H14V13Z" fill="currentColor"/>`, + "expand-left": `<path d="M10.071 4.92896L11.4852 6.34317L6.82834 11L16.0002 11.0002L16.0002 13.0002L6.82839 13L11.4852 17.6569L10.071 19.0711L2.99994 12L10.071 4.92896ZM18.0001 19V4.99997H20.0001V19H18.0001Z" fill="currentColor"/>`, + "expand-right": `<path d="M17.1717 11L12.5148 6.34317L13.929 4.92896L21.0001 12L13.929 19.0711L12.5148 17.6569L17.1716 13L7.9998 13.0002L7.99978 11.0002L17.1717 11ZM3.99985 19L3.99985 4.99997H5.99985V19H3.99985Z" fill="currentColor"/>`, "expand-up-down": `<path d="M18.2072 9.0428 12.0001 2.83569 5.793 9.0428 7.20721 10.457 12.0001 5.66412 16.793 10.457 18.2072 9.0428ZM5.79285 14.9572 12 21.1643 18.2071 14.9572 16.7928 13.543 12 18.3359 7.20706 13.543 5.79285 14.9572Z" fill="currentColor"/>`, "external-link": `<path d="M10 6V8H5V19H16V14H18V20C18 20.5523 17.5523 21 17 21H4C3.44772 21 3 20.5523 3 20V7C3 6.44772 3.44772 6 4 6H10ZM21 3V11H19L18.9999 6.413L11.2071 14.2071L9.79289 12.7929L17.5849 5H13V3H21Z" fill="currentColor"/>`, "eye": `<path d="M12.0003 3C17.3924 3 21.8784 6.87976 22.8189 12C21.8784 17.1202 17.3924 21 12.0003 21C6.60812 21 2.12215 17.1202 1.18164 12C2.12215 6.87976 6.60812 3 12.0003 3ZM12.0003 19C16.2359 19 19.8603 16.052 20.7777 12C19.8603 7.94803 16.2359 5 12.0003 5C7.7646 5 4.14022 7.94803 3.22278 12C4.14022 16.052 7.7646 19 12.0003 19ZM12.0003 16.5C9.51498 16.5 7.50026 14.4853 7.50026 12C7.50026 9.51472 9.51498 7.5 12.0003 7.5C14.4855 7.5 16.5003 9.51472 16.5003 12C16.5003 14.4853 14.4855 16.5 12.0003 16.5ZM12.0003 14.5C13.381 14.5 14.5003 13.3807 14.5003 12C14.5003 10.6193 13.381 9.5 12.0003 9.5C10.6196 9.5 9.50026 10.6193 9.50026 12C9.50026 13.3807 10.6196 14.5 12.0003 14.5Z" fill="currentColor"/>`, @@ -153,6 +153,7 @@ export const iconSpriteData = { "layout-right": `<path d="M21 3C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21ZM15 5H4V19H15V5ZM20 5H17V19H20V5Z" fill="currentColor"/>`, "leaf": `<path d="M20.998 3V5C20.998 14.6274 15.6255 19 8.99805 19L5.24077 18.9999C5.0786 19.912 4.99805 20.907 4.99805 22H2.99805C2.99805 20.6373 3.11376 19.3997 3.34381 18.2682C3.1133 16.9741 2.99805 15.2176 2.99805 13C2.99805 7.47715 7.4752 3 12.998 3C14.998 3 16.998 4 20.998 3ZM12.998 5C8.57977 5 4.99805 8.58172 4.99805 13C4.99805 13.3624 5.00125 13.7111 5.00759 14.0459C6.26198 12.0684 8.09902 10.5048 10.5019 9.13176L11.4942 10.8682C8.6393 12.4996 6.74554 14.3535 5.77329 16.9998L8.99805 17C15.0132 17 18.8692 13.0269 18.9949 5.38766C17.6229 5.52113 16.3481 5.436 14.7754 5.20009C13.6243 5.02742 13.3988 5 12.998 5Z" fill="currentColor"/>`, "lightbulb": `<path d="M9.97308 18H11V13H13V18H14.0269C14.1589 16.7984 14.7721 15.8065 15.7676 14.7226C15.8797 14.6006 16.5988 13.8564 16.6841 13.7501C17.5318 12.6931 18 11.385 18 10C18 6.68629 15.3137 4 12 4C8.68629 4 6 6.68629 6 10C6 11.3843 6.46774 12.6917 7.31462 13.7484C7.40004 13.855 8.12081 14.6012 8.23154 14.7218C9.22766 15.8064 9.84103 16.7984 9.97308 18ZM10 20V21H14V20H10ZM5.75395 14.9992C4.65645 13.6297 4 11.8915 4 10C4 5.58172 7.58172 2 12 2C16.4183 2 20 5.58172 20 10C20 11.8925 19.3428 13.6315 18.2443 15.0014C17.624 15.7748 16 17 16 18.5V21C16 22.1046 15.1046 23 14 23H10C8.89543 23 8 22.1046 8 21V18.5C8 17 6.37458 15.7736 5.75395 14.9992Z" fill="currentColor"/>`, + "linear": `<g transform="translate(1.5 1.5) scale(0.21)"><path fill="currentColor" d="M1.22541 61.5228c-.2225-.9485.90748-1.5459 1.59638-.857L39.3342 97.1782c.6889.6889.0915 1.8189-.857 1.5964C20.0515 94.4522 5.54779 79.9485 1.22541 61.5228ZM.00189135 46.8891c-.01764375.2833.08887215.5599.28957165.7606L52.3503 99.7085c.2007.2007.4773.3075.7606.2896 2.3692-.1476 4.6938-.46 6.9624-.9259.7645-.157 1.0301-1.0963.4782-1.6481L2.57595 39.4485c-.55186-.5519-1.49117-.2863-1.648174.4782-.465915 2.2686-.77832 4.5932-.92588465 6.9624ZM4.21093 29.7054c-.16649.3738-.08169.8106.20765 1.1l64.77602 64.776c.2894.2894.7262.3742 1.1.2077 1.7861-.7956 3.5171-1.6927 5.1855-2.684.5521-.328.6373-1.0867.1832-1.5407L8.43566 24.3367c-.45409-.4541-1.21271-.3689-1.54074.1832-.99132 1.6686-1.88843 3.3994-2.68399 5.1855ZM12.6587 18.074c-.3701-.3701-.393-.9637-.0443-1.3541C21.7795 6.45931 35.1114 0 49.9519 0 77.5927 0 100 22.4073 100 50.0481c0 14.8405-6.4593 28.1724-16.7199 37.3375-.3903.3487-.984.3258-1.3542-.0443L12.6587 18.074Z"/></g>`, "link-unlink-m": `<path d="M17.657 14.8284L16.2428 13.4142L17.657 12C19.2191 10.4379 19.2191 7.90526 17.657 6.34316C16.0949 4.78106 13.5622 4.78106 12.0001 6.34316L10.5859 7.75737L9.17171 6.34316L10.5859 4.92895C12.9291 2.5858 16.7281 2.5858 19.0712 4.92895C21.4143 7.27209 21.4143 11.0711 19.0712 13.4142L17.657 14.8284ZM14.8286 17.6569L13.4143 19.0711C11.0712 21.4142 7.27221 21.4142 4.92907 19.0711C2.58592 16.7279 2.58592 12.9289 4.92907 10.5858L6.34328 9.17159L7.75749 10.5858L6.34328 12C4.78118 13.5621 4.78118 16.0948 6.34328 17.6569C7.90538 19.219 10.438 19.219 12.0001 17.6569L13.4143 16.2427L14.8286 17.6569ZM14.8286 7.75737L16.2428 9.17159L9.17171 16.2427L7.75749 14.8284L14.8286 7.75737ZM5.77539 2.29291L7.70724 1.77527L8.74252 5.63897L6.81067 6.15661L5.77539 2.29291ZM15.2578 18.3611L17.1896 17.8434L18.2249 21.7071L16.293 22.2248L15.2578 18.3611ZM2.29303 5.77527L6.15673 6.81054L5.63909 8.7424L1.77539 7.70712L2.29303 5.77527ZM18.3612 15.2576L22.2249 16.2929L21.7072 18.2248L17.8435 17.1895L18.3612 15.2576Z" fill="currentColor"/>`, "list-check-2": `<path d="M11 4H21V6H11V4ZM11 8H17V10H11V8ZM11 14H21V16H11V14ZM11 18H17V20H11V18ZM3 4H9V10H3V4ZM5 6V8H7V6H5ZM3 14H9V20H3V14ZM5 16V18H7V16H5Z" fill="currentColor"/>`, "list-check-3": `<path d="M8.00008 6V9H5.00008V6H8.00008ZM3.00008 4V11H10.0001V4H3.00008ZM13.0001 4H21.0001V6H13.0001V4ZM13.0001 11H21.0001V13H13.0001V11ZM13.0001 18H21.0001V20H13.0001V18ZM10.7072 16.2071L9.29297 14.7929L6.00008 18.0858L4.20718 16.2929L2.79297 17.7071L6.00008 20.9142L10.7072 16.2071Z" fill="currentColor"/>`, @@ -187,7 +188,6 @@ export const iconSpriteData = { "picture-in-picture-2": `<path d="M21 3C21.5523 3 22 3.44772 22 4V11H20V5H4V19H10V21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21ZM21 13C21.5523 13 22 13.4477 22 14V20C22 20.5523 21.5523 21 21 21H13C12.4477 21 12 20.5523 12 20V14C12 13.4477 12.4477 13 13 13H21ZM20 15H14V19H20V15ZM6.70711 6.29289L8.95689 8.54289L11 6.5V12H5.5L7.54289 9.95689L5.29289 7.70711L6.70711 6.29289Z" fill="currentColor"/>`, "pie-chart": `<path d="M9 2.4578V4.58152C6.06817 5.76829 4 8.64262 4 12C4 16.4183 7.58172 20 12 20C15.3574 20 18.2317 17.9318 19.4185 15H21.5422C20.2679 19.0571 16.4776 22 12 22C6.47715 22 2 17.5228 2 12C2 7.52236 4.94289 3.73207 9 2.4578ZM12 2C17.5228 2 22 6.47715 22 12C22 12.3375 21.9833 12.6711 21.9506 13H11V2.04938C11.3289 2.01672 11.6625 2 12 2ZM13 4.06189V11H19.9381C19.4869 7.38128 16.6187 4.51314 13 4.06189Z" fill="currentColor"/>`, "play": `<path d="M16.3944 12.0001L10 7.7371V16.263L16.3944 12.0001ZM19.376 12.4161L8.77735 19.4818C8.54759 19.635 8.23715 19.5729 8.08397 19.3432C8.02922 19.261 8 19.1645 8 19.0658V4.93433C8 4.65818 8.22386 4.43433 8.5 4.43433C8.59871 4.43433 8.69522 4.46355 8.77735 4.5183L19.376 11.584C19.6057 11.7372 19.6678 12.0477 19.5146 12.2774C19.478 12.3323 19.4309 12.3795 19.376 12.4161Z" fill="currentColor"/>`, - "play-list-add": `<path d="M2 18H12V20H2V18ZM2 11H22V13H2V11ZM2 4H22V6H2V4ZM18 18V15H20V18H23V20H20V23H18V20H15V18H18Z" fill="currentColor"/>`, "plug": `<path d="M13 18V20H19V22H13C11.8954 22 11 21.1046 11 20V18H8C5.79086 18 4 16.2091 4 14V7C4 6.44772 4.44772 6 5 6H8V2H10V6H14V2H16V6H19C19.5523 6 20 6.44772 20 7V14C20 16.2091 18.2091 18 16 18H13ZM8 16H16C17.1046 16 18 15.1046 18 14V11H6V14C6 15.1046 6.89543 16 8 16ZM18 8H6V9H18V8ZM12 14.5C11.4477 14.5 11 14.0523 11 13.5C11 12.9477 11.4477 12.5 12 12.5C12.5523 12.5 13 12.9477 13 13.5C13 14.0523 12.5523 14.5 12 14.5Z" fill="currentColor"/>`, "plug-2": `<path d="M13 18V20H19V22H13C11.8954 22 11 21.1046 11 20V18H8C5.79086 18 4 16.2091 4 14V7C4 6.44772 4.44772 6 5 6H7V2H9V6H15V2H17V6H19C19.5523 6 20 6.44772 20 7V14C20 16.2091 18.2091 18 16 18H13ZM8 16H16C17.1046 16 18 15.1046 18 14V11H6V14C6 15.1046 6.89543 16 8 16ZM18 8H6V9H18V8ZM12 14.5C11.4477 14.5 11 14.0523 11 13.5C11 12.9477 11.4477 12.5 12 12.5C12.5523 12.5 13 12.9477 13 13.5C13 14.0523 12.5523 14.5 12 14.5ZM11 2H13V5H11V2Z" fill="currentColor"/>`, "pulse": `<path d="M9 7.53861L15 21.5386L18.6594 13H23V11H17.3406L15 16.4614L9 2.46143L5.3406 11H1V13H6.6594L9 7.53861Z" fill="currentColor"/>`, @@ -232,7 +232,6 @@ export const iconSpriteData = { "target": `<path d="M12 1.99999C12.5523 1.99999 13 2.4477 13 2.99999C12.9999 3.55224 12.5522 3.99999 12 3.99999C7.58172 3.99999 4 7.58171 4 12C4.00004 16.4182 7.58174 20 12 20C16.4182 20 19.9999 16.4182 20 12C20 11.4477 20.4477 11 21 11C21.5523 11 22 11.4477 22 12C21.9999 17.5228 17.5228 22 12 22C6.47717 22 2.00004 17.5228 2 12C2 6.47714 6.47715 1.99999 12 1.99999ZM12 5.99999C12.5523 5.99999 13 6.4477 13 6.99999C12.9999 7.55224 12.5522 7.99999 12 7.99999C9.79085 7.99999 7.99999 9.79085 7.99999 12C8.00004 14.2091 9.79088 16 12 16C14.2091 16 15.9999 14.2091 16 12C16 11.4477 16.4477 11 17 11C17.5523 11 18 11.4477 18 12C17.9999 15.3137 15.3137 18 12 18C8.68631 18 6.00004 15.3137 6 12C6 8.68628 8.68629 5.99999 12 5.99999ZM17.6562 2.10057C18.0468 1.71005 18.6807 1.71005 19.0713 2.10057C19.4614 2.49105 19.4615 3.12419 19.0713 3.51463L18.3633 4.22069L18.3642 4.22167C17.9737 4.61219 17.9737 5.2452 18.3642 5.63573C18.7548 6.02612 19.3878 6.02621 19.7783 5.63573L20.4853 4.9287C20.8759 4.53839 21.5089 4.53826 21.8994 4.9287C22.2899 5.31915 22.2897 5.95222 21.8994 6.34276L19.7783 8.46483C19.5909 8.65223 19.3363 8.75671 19.0713 8.75682H16.6572L12.707 12.707C12.3165 13.0974 11.6834 13.0974 11.293 12.707C10.9025 12.3165 10.9026 11.6835 11.293 11.293L15.2422 7.34374V4.9287C15.2422 4.66356 15.3477 4.40916 15.5351 4.22167L17.6562 2.10057Z" fill="currentColor"/>`, "target-fill": `<path d="M12 2C12.5523 2 13 2.44772 13 3C13 3.55228 12.5523 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 11.4477 20.4477 11 21 11C21.5523 11 22 11.4477 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2ZM12 6C12.5523 6 13 6.44772 13 7C13 7.55228 12.5523 8 12 8C9.79086 8 8 9.79086 8 12C8 14.2091 9.79086 16 12 16C14.2091 16 16 14.2091 16 12C16 11.4477 16.4477 11 17 11C17.5523 11 18 11.4477 18 12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12C6 8.68629 8.68629 6 12 6ZM18.5713 2.10059C18.8474 2.1006 19.0712 2.32449 19.0713 2.60059V4.42969C19.0716 4.70553 19.2954 4.92866 19.5713 4.92871H21.3994C21.6754 4.92871 21.8992 5.15275 21.8994 5.42871V6.34375L20.0107 8.23242C19.6358 8.60719 19.1268 8.81824 18.5967 8.81836H16.5967L12.707 12.707C12.3165 13.0974 11.6835 13.0975 11.293 12.707C10.9027 12.3165 10.9026 11.6834 11.293 11.293L15.1826 7.4043V5.4043C15.1826 4.87411 15.3928 4.36526 15.7676 3.99023L17.6572 2.10059H18.5713Z" fill="currentColor"/>`, "task": `<path d="M19 4H5V20H19V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H19.9997C20.5519 2 20.9996 2.44772 20.9997 3L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918ZM11.2929 13.1213L15.5355 8.87868L16.9497 10.2929L11.2929 15.9497L7.40381 12.0607L8.81802 10.6464L11.2929 13.1213Z" fill="currentColor"/>`, - "telegram-fill": `<path d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12ZM12.3584 9.38246C11.3857 9.78702 9.4418 10.6244 6.5266 11.8945C6.05321 12.0827 5.80524 12.2669 5.78266 12.4469C5.74451 12.7513 6.12561 12.8711 6.64458 13.0343C6.71517 13.0565 6.78832 13.0795 6.8633 13.1039C7.37388 13.2698 8.06071 13.464 8.41776 13.4717C8.74164 13.4787 9.10313 13.3452 9.50222 13.0711C12.226 11.2325 13.632 10.3032 13.7203 10.2832C13.7826 10.269 13.8689 10.2513 13.9273 10.3032C13.9858 10.3552 13.98 10.4536 13.9739 10.48C13.9361 10.641 12.4401 12.0318 11.666 12.7515C11.4351 12.9661 11.2101 13.1853 10.9833 13.4039C10.509 13.8611 10.1533 14.204 11.003 14.764C11.8644 15.3317 12.7323 15.8982 13.5724 16.4971C13.9867 16.7925 14.359 17.0579 14.8188 17.0156C15.0861 16.991 15.3621 16.7397 15.5022 15.9903C15.8335 14.2193 16.4847 10.3821 16.6352 8.80083C16.6484 8.6623 16.6318 8.485 16.6185 8.40717C16.6052 8.32934 16.5773 8.21844 16.4762 8.13635C16.3563 8.03913 16.1714 8.01863 16.0887 8.02009C15.7125 8.02672 15.1355 8.22737 12.3584 9.38246Z" fill="currentColor"/>`, "terminal": `<path d="M10.9999 12L3.92886 19.0711L2.51465 17.6569L8.1715 12L2.51465 6.34317L3.92886 4.92896L10.9999 12ZM10.9999 19H20.9999V21H10.9999V19Z" fill="currentColor"/>`, "terminal-box": `<path d="M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM4 5V19H20V5H4ZM12 15H18V17H12V15ZM8.66685 12L5.83842 9.17157L7.25264 7.75736L11.4953 12L7.25264 16.2426L5.83842 14.8284L8.66685 12Z" fill="currentColor"/>`, "terminal-window": `<path d="M20 9V5H4V9H20ZM20 11H4V19H20V11ZM3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM5 12H8V17H5V12ZM5 6H7V8H5V6ZM9 6H11V8H9V6Z" fill="currentColor"/>`, diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 7e05d80a..508c7896 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { DiffViewIcon } from '@/components/icons/DiffIcon'; import { Button } from '@/components/ui/button'; +import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { PullRequestView } from '@/components/views/PullRequestView'; import { TerminalView } from '@/components/views/TerminalView'; @@ -979,6 +980,50 @@ export const ContextPanel: React.FC = () => { const isFileTabActive = activeTab?.mode === 'file'; + const closeContextPanelTabs = useUIStore((state) => state.closeContextPanelTabs); + const renderTabContextMenu = React.useCallback( + (args: { id: string; index: number; allIds: string[]; close: () => void }): React.ReactNode => { + if (!directoryKey) { + return null; + } + const { id, index, allIds, close } = args; + const closeOthers = () => closeContextPanelTabs(directoryKey, allIds.filter((tabId) => tabId !== id)); + const closeToLeft = () => closeContextPanelTabs(directoryKey, allIds.slice(0, index)); + const closeToRight = () => closeContextPanelTabs(directoryKey, allIds.slice(index + 1)); + const closeAll = () => closeContextPanelTabs(directoryKey, allIds); + const hasOthers = allIds.length > 1; + const isFirst = index === 0; + const isLast = index === allIds.length - 1; + return ( + <> + <ContextMenuItem onClick={close}> + <Icon name="close" className="mr-2 size-4" /> + {t('contextPanel.tab.menu.close')} + </ContextMenuItem> + <ContextMenuSeparator /> + <ContextMenuItem onClick={closeOthers} disabled={!hasOthers}> + <Icon name="expand-horizontal" className="mr-2 size-4" /> + {t('contextPanel.tab.menu.closeOthers')} + </ContextMenuItem> + <ContextMenuItem onClick={closeToLeft} disabled={isFirst}> + <Icon name="expand-left" className="mr-2 size-4" /> + {t('contextPanel.tab.menu.closeToLeft')} + </ContextMenuItem> + <ContextMenuItem onClick={closeToRight} disabled={isLast}> + <Icon name="expand-right" className="mr-2 size-4" /> + {t('contextPanel.tab.menu.closeToRight')} + </ContextMenuItem> + <ContextMenuSeparator /> + <ContextMenuItem onClick={closeAll} disabled={!hasOthers}> + <Icon name="close-circle" className="mr-2 size-4" /> + {t('contextPanel.tab.menu.closeAll')} + </ContextMenuItem> + </> + ); + }, + [closeContextPanelTabs, directoryKey, t], + ); + const header = ( <header className="flex h-10 items-stretch border-b border-border"> {isMultiInstanceMode ? ( @@ -1005,6 +1050,7 @@ export const ContextPanel: React.FC = () => { }} layoutMode="scrollable" variant="default" + tabContextMenu={renderTabContextMenu} /> ) : ( <div className="flex min-w-0 flex-1 items-center gap-1.5 px-3"> diff --git a/packages/ui/src/components/layout/SidebarFilesTree.tsx b/packages/ui/src/components/layout/SidebarFilesTree.tsx index 624083c2..815140ba 100644 --- a/packages/ui/src/components/layout/SidebarFilesTree.tsx +++ b/packages/ui/src/components/layout/SidebarFilesTree.tsx @@ -47,6 +47,7 @@ import { isFilesystemError } from '@/lib/api/files-errors'; import { notifyFileContentInvalidated } from '@/lib/fileContentInvalidation'; import { isBrowserClientRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; +import { recordFileTreeDragStart, shouldTreatFileTreeDragEndAsClick } from './fileTreeDragClick'; type FileNode = { name: string; @@ -388,12 +389,20 @@ const FileRow: React.FC<FileRowProps> = ({ ); const handleDragStart = React.useCallback((e: React.DragEvent) => { + recordFileTreeDragStart(e); const path = getRelativePath(root, node.path); if (!path || path === '.') return; e.dataTransfer.setData('application/x-openchamber-file-path', path); e.dataTransfer.effectAllowed = 'copy'; }, [node.path, root]); + const handleDragEnd = React.useCallback((e: React.DragEvent) => { + // A micro-drag suppressed the click this gesture was meant to be (#2368). + if (shouldTreatFileTreeDragEndAsClick(e)) { + handleInteraction(); + } + }, [handleInteraction]); + const handleExternalDragOver = React.useCallback((event: React.DragEvent) => { if (!canUpload || !uploadDirectory || !hasExternalFiles(event.dataTransfer)) return; event.preventDefault(); @@ -434,12 +443,12 @@ const FileRow: React.FC<FileRowProps> = ({ onContextMenu={handleContextMenu} draggable onDragStart={handleDragStart} + onDragEnd={handleDragEnd} className={cn( 'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors pr-8 select-none', isDropTarget ? 'bg-interactive-selection ring-2 ring-inset ring-primary' - : (isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40'), - 'cursor-grab active:cursor-grabbing' + : (isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40') )} > {isDir ? ( @@ -1423,13 +1432,20 @@ export const SidebarFilesTree: React.FC = () => { onClick={() => handleOpenFile(node)} draggable onDragStart={(e) => { + recordFileTreeDragStart(e); const path = node.relativePath || getRelativePath(root ?? '', node.path); if (!path || path === '.') return; e.dataTransfer.setData('application/x-openchamber-file-path', path); e.dataTransfer.effectAllowed = 'copy'; }} + onDragEnd={(e) => { + // A micro-drag suppressed the click this gesture was meant to be (#2368). + if (shouldTreatFileTreeDragEndAsClick(e)) { + void handleOpenFile(node); + } + }} className={cn( - 'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors cursor-grab active:cursor-grabbing', + 'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors', isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40' )} title={node.path} diff --git a/packages/ui/src/components/layout/__tests__/issue-3175-browserCaptureRevealsPanel.test.ts b/packages/ui/src/components/layout/__tests__/issue-3175-browserCaptureRevealsPanel.test.ts index 434a743c..af366c2c 100644 --- a/packages/ui/src/components/layout/__tests__/issue-3175-browserCaptureRevealsPanel.test.ts +++ b/packages/ui/src/components/layout/__tests__/issue-3175-browserCaptureRevealsPanel.test.ts @@ -15,6 +15,7 @@ import { useUIStore } from '@/stores/useUIStore'; const __dirname = dirname(fileURLToPath(import.meta.url)); const contextPanelSource = readFileSync(join(__dirname, '..', 'ContextPanel.tsx'), 'utf-8'); +const browserPaneSource = readFileSync(join(__dirname, '..', '..', 'browser', 'BrowserPane.tsx'), 'utf-8'); const DIRECTORY = '/path/to/repository'; beforeEach(() => { @@ -40,4 +41,10 @@ describe('issue #3175 browser capture while the context panel is closed', () => expect(panel.tabs[0]?.mode).toBe('browser'); expect(panel.tabs[0]?.targetPath).toBe('https://example.com'); }); + + test('reveals the browser again if it was closed before capture', () => { + expect(browserPaneSource).toContain( + 'openContextBrowser(directory, webview.getURL())', + ); + }); }); diff --git a/packages/ui/src/components/layout/fileTreeDragClick.test.ts b/packages/ui/src/components/layout/fileTreeDragClick.test.ts new file mode 100644 index 00000000..bb254d9c --- /dev/null +++ b/packages/ui/src/components/layout/fileTreeDragClick.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; + +import { + recordFileTreeDragStart, + resetFileTreeDragClickState, + shouldTreatFileTreeDragEndAsClick, +} from './fileTreeDragClick'; + +const dragEnd = (clientX: number, clientY: number, dropEffect = 'none') => ({ + clientX, + clientY, + dataTransfer: { dropEffect }, +}); + +beforeEach(() => { + resetFileTreeDragClickState(); +}); + +describe('file tree drag-click fallback (#2368)', () => { + test('a micro-drag that ends where it began is recovered as a click', () => { + // Chromium starts a native drag after ~4px of pointer travel and then + // suppresses the click event for the rest of the gesture. On macOS + // trackpads a plain click routinely slips past that threshold, which is + // the "clicking a folder does nothing" symptom of issue #2368. + recordFileTreeDragStart({ clientX: 100, clientY: 200 }); + + expect(shouldTreatFileTreeDragEndAsClick(dragEnd(102, 201))).toBe(true); + }); + + test('a zero-travel drag end is recovered as a click', () => { + recordFileTreeDragStart({ clientX: 100, clientY: 200 }); + + expect(shouldTreatFileTreeDragEndAsClick(dragEnd(100, 200))).toBe(true); + }); + + test('a drag released far from its origin is not a click', () => { + recordFileTreeDragStart({ clientX: 100, clientY: 200 }); + + expect(shouldTreatFileTreeDragEndAsClick(dragEnd(180, 230))).toBe(false); + }); + + test('slop boundary: within the radius is a click, beyond it is not', () => { + recordFileTreeDragStart({ clientX: 100, clientY: 200 }); + expect(shouldTreatFileTreeDragEndAsClick(dragEnd(108, 208))).toBe(true); + + recordFileTreeDragStart({ clientX: 100, clientY: 200 }); + expect(shouldTreatFileTreeDragEndAsClick(dragEnd(109, 200))).toBe(false); + }); + + test('a drag dropped onto a target is never a click', () => { + // Dragging a file into the chat input inserts an @mention; a completed + // drop must not additionally toggle or open the row. + recordFileTreeDragStart({ clientX: 100, clientY: 200 }); + + expect(shouldTreatFileTreeDragEndAsClick(dragEnd(101, 200, 'copy'))).toBe(false); + }); + + test('a drag end without a recorded start is ignored', () => { + expect(shouldTreatFileTreeDragEndAsClick(dragEnd(100, 200))).toBe(false); + }); + + test('the recorded origin is consumed by the first drag end', () => { + recordFileTreeDragStart({ clientX: 100, clientY: 200 }); + + expect(shouldTreatFileTreeDragEndAsClick(dragEnd(100, 200))).toBe(true); + expect(shouldTreatFileTreeDragEndAsClick(dragEnd(100, 200))).toBe(false); + }); + + test('a missing dataTransfer still recovers a near-origin drag as a click', () => { + recordFileTreeDragStart({ clientX: 100, clientY: 200 }); + + expect( + shouldTreatFileTreeDragEndAsClick({ clientX: 101, clientY: 201, dataTransfer: null }), + ).toBe(true); + }); +}); diff --git a/packages/ui/src/components/layout/fileTreeDragClick.ts b/packages/ui/src/components/layout/fileTreeDragClick.ts new file mode 100644 index 00000000..5ff80d09 --- /dev/null +++ b/packages/ui/src/components/layout/fileTreeDragClick.ts @@ -0,0 +1,70 @@ +/** + * Click-reliability fallback for file tree rows that are both clickable and + * draggable (issue #2368). + * + * A native HTML5 drag starts after only a few pixels of pointer travel + * (4px in Chromium), and once `dragstart` fires the browser suppresses the + * `click` event for that gesture entirely. On macOS trackpads and Magic + * Mouse a plain click very often slips past that threshold, so rows that + * carry `draggable` (to drag file references into the chat input) randomly + * ignored clicks: folders neither expanded nor collapsed and files did not + * open. + * + * Arming `draggable` only after a pointer-move threshold is not a fix: + * Chromium decides drag eligibility on the first mouse move after mousedown + * and never re-evaluates, so a drag whose first movement stays below the + * threshold would never start (verified against headless Chromium). + * + * Instead the row stays draggable, and a drag that ends where it began — + * within a small slop radius and without dropping onto any target — is + * treated as the click it was meant to be. The two paths are mutually + * exclusive: when the browser suppresses `click` it fired `dragstart`, and + * when `click` fires no drag ever started, so the row action runs exactly + * once per gesture. + * + * Module-level state is safe here because the platform allows only one + * native drag at a time. + */ + +/** + * Chromium starts a native drag at 4px of travel, so a suppressed click's + * dragstart→dragend distance is near zero. The slop only needs to absorb + * the remaining wobble between drag start and release; a deliberate drag + * released mid-flight travels far beyond it. + */ +const DRAG_CLICK_SLOP_PX = 8; + +type DragPointerEvent = { + clientX: number; + clientY: number; +}; + +let pendingDragOrigin: { x: number; y: number } | null = null; + +/** Record where a file row drag started. Call from the row's `dragstart`. */ +export const recordFileTreeDragStart = (event: DragPointerEvent): void => { + pendingDragOrigin = { x: event.clientX, y: event.clientY }; +}; + +/** + * True when the drag that just ended was an accidental micro-drag that + * swallowed a click: it was never dropped onto a target and it ended within + * `DRAG_CLICK_SLOP_PX` of where it started. Consumes the recorded origin. + */ +export const shouldTreatFileTreeDragEndAsClick = ( + event: DragPointerEvent & { dataTransfer: { dropEffect: string } | null }, +): boolean => { + const origin = pendingDragOrigin; + pendingDragOrigin = null; + if (!origin) return false; + if (event.dataTransfer && event.dataTransfer.dropEffect !== 'none') return false; + return ( + Math.abs(event.clientX - origin.x) <= DRAG_CLICK_SLOP_PX + && Math.abs(event.clientY - origin.y) <= DRAG_CLICK_SLOP_PX + ); +}; + +/** Reset module state. Intended for tests. */ +export const resetFileTreeDragClickState = (): void => { + pendingDragOrigin = null; +}; diff --git a/packages/ui/src/components/sections/agents/AgentsPage.tsx b/packages/ui/src/components/sections/agents/AgentsPage.tsx index 3a759e0a..cdcb1599 100644 --- a/packages/ui/src/components/sections/agents/AgentsPage.tsx +++ b/packages/ui/src/components/sections/agents/AgentsPage.tsx @@ -450,7 +450,7 @@ export const AgentsPage: React.FC = () => { inputMode="decimal" placeholder="—" emptyLabel="—" - className="w-16" + className="w-20" /> {temperature !== undefined && ( <Button @@ -488,7 +488,7 @@ export const AgentsPage: React.FC = () => { inputMode="decimal" placeholder="—" emptyLabel="—" - className="w-16" + className="w-20" /> {topP !== undefined && ( <Button diff --git a/packages/ui/src/components/sections/behavior/BehaviorPage.tsx b/packages/ui/src/components/sections/behavior/BehaviorPage.tsx index 758c954c..e23e64f3 100644 --- a/packages/ui/src/components/sections/behavior/BehaviorPage.tsx +++ b/packages/ui/src/components/sections/behavior/BehaviorPage.tsx @@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { toast } from '@/components/ui'; import { useI18n, type I18nKey } from '@/lib/i18n'; +import { cn } from '@/lib/utils'; import { reportSettingsSaveState } from '@/lib/persistence'; import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs'; import { @@ -375,7 +376,7 @@ export const BehaviorPage: React.FC = () => { onValueChange={(value) => setResponseStylePreset(value)} disabled={isLoading || !responseStyleEnabled} > - <SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}> + <SelectTrigger size={SETTINGS_SELECT_SIZE} className={cn(SETTINGS_SELECT_ROW_TRIGGER_CLASS, 'max-w-72')}> <SelectValue> {(value) => { if (value === 'custom') return t('settings.behavior.page.responseStyle.option.custom'); diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 72cf9227..d1959d72 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -212,6 +212,7 @@ const ChatSectionContent: React.FC = () => { 'followUpBehavior', 'persistDraft', 'inputSpellcheck', + 'largeTextPaste', ]} /> ); diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 20f973ed..8ea583c3 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -3,7 +3,7 @@ import { runtimeFetch } from '@/lib/runtime-fetch'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import type { ThemeMode } from '@/types/theme'; -import { useUIStore } from '@/stores/useUIStore'; +import { useUIStore, type LargeTextPasteBehavior } from '@/stores/useUIStore'; import { useMessageQueueStore, type FollowUpBehavior } from '@/stores/messageQueueStore'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; @@ -263,11 +263,26 @@ const FOLLOW_UP_BEHAVIOR_OPTIONS: Option<FollowUpBehavior>[] = [ }, ]; +const LARGE_TEXT_PASTE_BEHAVIOR_OPTIONS: Option<LargeTextPasteBehavior>[] = [ + { + id: 'ask', + labelKey: 'settings.openchamber.visual.option.largeTextPaste.ask.label', + }, + { + id: 'attach', + labelKey: 'settings.openchamber.visual.option.largeTextPaste.attach.label', + }, + { + id: 'inline', + labelKey: 'settings.openchamber.visual.option.largeTextPaste.inline.label', + }, +]; + const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => { return mode === 'markdown' ? 'markdown' : 'plain'; }; -type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'autoSaveEnabled' | 'sessionTabs'; +type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'largeTextPaste' | 'reportUsage' | 'autoSaveEnabled' | 'sessionTabs'; const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [ { id: 'left', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsLeft' }, @@ -362,6 +377,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps> const setPersistChatDraft = useUIStore(state => state.setPersistChatDraft); const inputSpellcheckEnabled = useUIStore(state => state.inputSpellcheckEnabled); const setInputSpellcheckEnabled = useUIStore(state => state.setInputSpellcheckEnabled); + const largeTextPasteBehavior = useUIStore(state => state.largeTextPasteBehavior); + const setLargeTextPasteBehavior = useUIStore(state => state.setLargeTextPasteBehavior); const showToolFileIcons = useUIStore(state => state.showToolFileIcons); const setShowToolFileIcons = useUIStore(state => state.setShowToolFileIcons); const showTurnChangedFiles = useUIStore(state => state.showTurnChangedFiles); @@ -639,6 +656,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps> || shouldShow('reasoning') || shouldShow('followUpBehavior') || shouldShow('persistDraft') + || shouldShow('largeTextPaste') || shouldShow('showToolFileIcons') || shouldShow('expandedTools') || (!isMobile && shouldShow('inputSpellcheck')); @@ -661,6 +679,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps> || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('persistDraft') + || shouldShow('largeTextPaste') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) @@ -1198,7 +1217,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps> controlClassName="w-full" > <Select value={uiFont} onValueChange={(value) => setUiFont(value as UiFontOption)}> - <SelectTrigger aria-label={t('settings.openchamber.visual.field.selectInterfaceFontAria')} size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_TRIGGER_CLASS}> + <SelectTrigger aria-label={t('settings.openchamber.visual.field.selectInterfaceFontAria')} size={SETTINGS_SELECT_SIZE} className={cn(SETTINGS_SELECT_TRIGGER_CLASS, 'max-w-full')}> <SelectValue>{UI_FONT_OPTIONS.find((option) => option.id === uiFont)?.label}</SelectValue> </SelectTrigger> <SelectContent> @@ -1228,7 +1247,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps> controlClassName="w-full" > <Select value={monoFont} onValueChange={(value) => setMonoFont(value as MonoFontOption)}> - <SelectTrigger aria-label={t('settings.openchamber.visual.field.selectCodeFontAria')} size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_TRIGGER_CLASS}> + <SelectTrigger aria-label={t('settings.openchamber.visual.field.selectCodeFontAria')} size={SETTINGS_SELECT_SIZE} className={cn(SETTINGS_SELECT_TRIGGER_CLASS, 'max-w-full')}> <SelectValue>{CODE_FONT_OPTIONS.find((option) => option.id === monoFont)?.label}</SelectValue> </SelectTrigger> <SelectContent> @@ -1269,6 +1288,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps> min={50} max={200} step={5} + className="w-20" aria-label={t('settings.openchamber.visual.field.fontSizePercentageAria')} /> <span className={SETTINGS_NUMBER_UNIT_CLASS}>%</span> @@ -1299,6 +1319,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps> min={9} max={52} step={1} + className="w-20" /> <span className={SETTINGS_NUMBER_UNIT_CLASS}>px</span> <Button size="sm" @@ -1328,6 +1349,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps> min={9} max={32} step={1} + className="w-20" /> <span className={SETTINGS_NUMBER_UNIT_CLASS}>px</span> <Button size="sm" @@ -1362,6 +1384,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps> min={50} max={200} step={5} + className="w-20" /> <span className={SETTINGS_NUMBER_UNIT_CLASS}>%</span> <Button size="sm" @@ -1392,6 +1415,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps> min={0} max={100} step={5} + className="w-20" /> <span className={SETTINGS_NUMBER_UNIT_CLASS}>px</span> <Button size="sm" @@ -1976,7 +2000,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps> </SettingsSection> )} - {(shouldShow('persistDraft') || (!isMobile && shouldShow('inputSpellcheck'))) && ( + {(shouldShow('persistDraft') || shouldShow('largeTextPaste') || (!isMobile && shouldShow('inputSpellcheck'))) && ( <SettingsSection title={t('settings.openchamber.visual.section.composer')} settingsItem="chat.composer" @@ -2001,6 +2025,26 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps> settingsItem="chat.spellcheck" /> )} + + {shouldShow('largeTextPaste') && ( + <SettingsControlGroup + title={t('settings.openchamber.visual.field.largeTextPaste')} + info={t('settings.openchamber.visual.field.largeTextPasteHint')} + settingsItem="chat.large-text-paste" + > + <SettingsRadioGroup aria-label={t('settings.openchamber.visual.field.largeTextPasteAria')}> + {LARGE_TEXT_PASTE_BEHAVIOR_OPTIONS.map((option) => ( + <SettingsRadioOption + key={option.id} + selected={largeTextPasteBehavior === option.id} + onSelect={() => setLargeTextPasteBehavior(option.id)} + label={tUnsafe(option.labelKey)} + ariaLabel={t('settings.openchamber.visual.field.largeTextPasteOptionAria', { option: tUnsafe(option.labelKey) })} + /> + ))} + </SettingsRadioGroup> + </SettingsControlGroup> + )} </SettingsSection> )} </> diff --git a/packages/ui/src/components/sections/openchamber/PasskeySettings.tsx b/packages/ui/src/components/sections/openchamber/PasskeySettings.tsx index 5dcbc15c..ac794708 100644 --- a/packages/ui/src/components/sections/openchamber/PasskeySettings.tsx +++ b/packages/ui/src/components/sections/openchamber/PasskeySettings.tsx @@ -219,7 +219,7 @@ export const PasskeySettings: React.FC = () => { {passkeys.map((passkey) => ( <SettingsFieldRow key={passkey.id} - label={<span className="truncate">{passkey.label}</span>} + label={<span title={passkey.label}>{passkey.label}</span>} alignEnd={false} controlClassName="justify-between sm:flex-1" > diff --git a/packages/ui/src/components/sections/openchamber/SessionRetentionSettings.tsx b/packages/ui/src/components/sections/openchamber/SessionRetentionSettings.tsx index 7e24b7d7..0a6de54d 100644 --- a/packages/ui/src/components/sections/openchamber/SessionRetentionSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/SessionRetentionSettings.tsx @@ -87,7 +87,7 @@ export const SessionRetentionSettings: React.FC = () => { max={MAX_DAYS} step={1} aria-label={t('settings.openchamber.sessionRetention.field.retentionPeriodAria')} - className="w-20 tabular-nums" + className="w-24 tabular-nums" /> <span className="typography-ui-label text-muted-foreground">{t('settings.openchamber.sessionRetention.field.days')}</span> <Button diff --git a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx index b5d2f7f5..9ceddfe1 100644 --- a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx @@ -1051,13 +1051,13 @@ export const VoiceSettings: React.FC = () => { {/* Speech Rate */} <SettingsFieldRow label={t('settings.voice.page.field.speechRate')}> {!isMobile && <input type="range" min={0.5} max={2} step={0.1} value={speechRate} onChange={(e) => setSpeechRate(Number(e.target.value))} className={sliderClass} />} - <NumberInput value={speechRate} onValueChange={setSpeechRate} min={0.5} max={2} step={0.1} className="w-16 tabular-nums" /> + <NumberInput value={speechRate} onValueChange={setSpeechRate} min={0.5} max={2} step={0.1} className="w-20 tabular-nums" /> </SettingsFieldRow> {/* Speech Pitch */} <SettingsFieldRow label={t('settings.voice.page.field.speechPitch')}> {!isMobile && <input type="range" min={0.5} max={2} step={0.1} value={speechPitch} onChange={(e) => setSpeechPitch(Number(e.target.value))} className={sliderClass} />} - <NumberInput value={speechPitch} onValueChange={setSpeechPitch} min={0.5} max={2} step={0.1} className="w-16 tabular-nums" /> + <NumberInput value={speechPitch} onValueChange={setSpeechPitch} min={0.5} max={2} step={0.1} className="w-20 tabular-nums" /> </SettingsFieldRow> {/* Speech Volume */} diff --git a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx index 4bc0c087..643dcaee 100644 --- a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx +++ b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx @@ -2322,7 +2322,7 @@ export const RemoteInstancesPage: React.FC = () => { min={5} max={240} step={1} - className="w-16 tabular-nums" + className="w-20 tabular-nums" value={draft.connectionTimeoutSec} onValueChange={(next) => { updateDraft((current) => ({ @@ -2350,7 +2350,7 @@ export const RemoteInstancesPage: React.FC = () => { min={1} max={65535} step={1} - className="w-20 tabular-nums" + className="w-32 tabular-nums" value={draft.remoteOpenchamber.preferredPort} onValueChange={(next) => { updateDraft((current) => ({ @@ -2517,7 +2517,7 @@ export const RemoteInstancesPage: React.FC = () => { min={1} max={65535} step={1} - className="w-20 tabular-nums" + className="w-32 tabular-nums" value={draft.localForward.preferredLocalPort} onValueChange={(next) => { updateDraft((current) => ({ @@ -2775,7 +2775,7 @@ export const RemoteInstancesPage: React.FC = () => { min={1} max={65535} step={1} - className="w-16 tabular-nums" + className="w-32 tabular-nums" value={forward.localPort} onValueChange={(next) => { updateForward((item) => ({ @@ -2817,7 +2817,7 @@ export const RemoteInstancesPage: React.FC = () => { min={1} max={65535} step={1} - className="w-16 tabular-nums" + className="w-32 tabular-nums" value={forward.remotePort} onValueChange={(next) => { updateForward((item) => ({ diff --git a/packages/ui/src/components/sections/shared/SettingsSection.tsx b/packages/ui/src/components/sections/shared/SettingsSection.tsx index 7672b8a2..5cc520f9 100644 --- a/packages/ui/src/components/sections/shared/SettingsSection.tsx +++ b/packages/ui/src/components/sections/shared/SettingsSection.tsx @@ -310,8 +310,8 @@ export const SettingsFieldRow: React.FC<SettingsFieldRowProps> = ({ )} > <div className="min-w-0 @xl:w-56 @xl:shrink-0"> - <div className="flex items-center gap-1.5"> - <div className={SETTINGS_FIELD_LABEL_CLASS}>{label}</div> + <div className="flex min-w-0 items-center gap-1.5"> + <div className={cn('min-w-0 truncate', SETTINGS_FIELD_LABEL_CLASS)}>{label}</div> {info != null ? <SettingsInfoHint>{info}</SettingsInfoHint> : null} </div> {description != null ? ( diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index 68f2fba7..9a93f7ea 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -42,9 +42,12 @@ existing data; it is never treated as an authoritative empty list. Web and desktop show managed Chats before optional Recent activity. Chats use their shared managed root for folders and never expose worktree actions. Project -display can be all projects or one selected project. VS Code excludes worktrees -and managed Chats, while retaining its workspace-scoped grouped list and inline -archived buckets. +display can be all projects or one selected project. The mobile sessions sheet +(`apps/MobileSessionsSheet.tsx`) partitions the same way through +`partitionSidebarSessions` and lists Chats as a collapsible section above the +project tree, with no Recent projection. VS Code excludes worktrees and managed +Chats, while retaining its workspace-scoped grouped list and inline archived +buckets. Directory demand always includes known project roots and worktrees. Visibility only changes priority. Row mounts must not start bootstrap work. Selection and @@ -71,3 +74,4 @@ make every row observe unrelated streaming updates. - Empty successful lists, unresolved loads, and failed loads are separate UI states. Failed groups expose Retry and retain prior data. - Directory permission failures remain visible even when stale sessions are retained. Flat groups inspect every represented root/worktree directory; local Desktop may open the native picker for the exact failed directory, while other runtimes keep the ordinary Retry action. - Pins and folder assignments are not pruned from the first startup snapshot or from optimistic mutations. Confirmed local deletion and routed external deletion clean immediately; a later authoritative omission after an established baseline covers missed external delete events. +- Pending-permission/question row badges fade with the same hover/menu-open rule as the date label, except on always-visible-actions rows, which reserve permanent padding and keep the badges shown (`selectRowBadgeVisibilityClass` in `sessions/sessionNodeItemUtils.ts`). diff --git a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.test.ts b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.test.ts new file mode 100644 index 00000000..dc77851c --- /dev/null +++ b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; + +const source = readFileSync(new URL('./SessionNodeItem.tsx', import.meta.url), 'utf8'); + +describe('SessionNodeItem recent-activity timestamp', () => { + test('the recent activity rows render the compact timestamp in the inline metadata slot', () => { + // The right-slot guard must open for recent rows even when no activity, + // goal glyph, or branch marker is present. + const guard = source.indexOf("showActivityDuration || sessionGoalGlyph || showInlineBranchMarker || renderContext === 'recent'"); + expect(guard).toBeGreaterThan(-1); + // The recent-only block sits inside that slot… + const guardOpen = source.indexOf("{renderContext === 'recent' ? (", guard); + expect(guardOpen).toBeGreaterThan(guard); + // …and the compact label rendered there is the first one after it. + const label = source.indexOf('{sessionCompactUpdatedLabel}', guardOpen); + expect(label).toBeGreaterThan(guardOpen); + // The only later occurrence is the pre-existing row tooltip (which shows + // the full date), not a second inline render. + const tooltipLabel = source.indexOf('{sessionCompactUpdatedLabel}', label + 1); + expect(tooltipLabel).toBeGreaterThan(label); + expect(source.indexOf('title={sessionUpdatedLabel}', tooltipLabel - 80)).toBeGreaterThan(-1); + }); + + test('the timestamp shares the hover-fade of the other metadata so revealed actions never overlap it', () => { + const guard = source.indexOf("showActivityDuration || sessionGoalGlyph || showInlineBranchMarker || renderContext === 'recent'"); + // The slot content fades out while the row is hovered (hideOnHoverClass) + // and while the row menu is open — the same span that now carries the + // recent timestamp. + const hideOnHover = source.indexOf('hideOnHoverClass', guard); + expect(hideOnHover).toBeGreaterThan(guard); + expect(hideOnHover).toBeLessThan(source.indexOf("{renderContext === 'recent' ? (", guard)); + }); + + test('the compact label uses the existing i18n-backed relative time helper', () => { + // formatSessionCompactDateLabel (already used by touch runtimes and the + // row tooltip) is the source of the label — no new formatting code. + expect(source.indexOf('const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp);')).toBeGreaterThan(-1); + expect(source.indexOf('{sessionCompactUpdatedLabel}')).toBeGreaterThan(-1); + }); +}); diff --git a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx index c19be866..761a7cba 100644 --- a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx @@ -26,7 +26,7 @@ import { useGlobalSessionStatus, useSessionPermissions, useSessionQuestionCount import { useSessionMessageRecordsForExport } from '@/sync/use-sync'; import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store'; import { DraggableSessionRow } from '../folders/sessionFolderDnd'; -import { canShowSessionWorktreeMenu, getSessionWorktreeMenuDisabled, nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils'; +import { canShowSessionWorktreeMenu, getSessionWorktreeMenuDisabled, nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes, selectRowBadgeVisibilityClass } from './sessionNodeItemUtils'; import type { SessionNode } from '../types'; import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from '../utils'; import { useProjectsStore } from '@/stores/useProjectsStore'; @@ -685,6 +685,14 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode const pendingQuestionLabel = pendingQuestionCount === 1 ? t('sessions.sidebar.session.status.questionPendingSingle') : t('sessions.sidebar.session.status.questionPendingMany', { count: pendingQuestionCount }); + // Actions are permanently visible (with matching permanent padding) only in + // the non-VSCode alwaysShowActions layout; every other layout hover-reveals + // them over the row's right edge, where the badges live (#2284). + const badgeVisibilityClass = selectRowBadgeVisibilityClass({ + actionsAlwaysVisible: alwaysShowActions && !isVSCode, + menuOpen: isSessionMenuOpen, + hideOnHoverClass, + }); const showUnreadStatus = !isMovingToWorktree && !isStreaming && needsAttention && !isActive; const showStatusMarker = isStreaming || showUnreadStatus; // Both states are the same static dot; only the color separates "running" @@ -1390,7 +1398,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode </> )} </span> - ) : (showActivityDuration || sessionGoalGlyph || showInlineBranchMarker) ? ( + ) : (showActivityDuration || sessionGoalGlyph || showInlineBranchMarker || renderContext === 'recent') ? ( <div className="relative ml-1 flex h-4 flex-shrink-0 items-center justify-end"> <span className={cn( 'inline-flex items-center gap-1 whitespace-nowrap text-right transition-opacity duration-150', @@ -1414,19 +1422,31 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode style={prIconColor ? { color: prIconColor } : undefined} /> ) : null} + {/* The recent activity list shows its compact + timestamp inline (touch runtimes already get + it through the alwaysShowActions branch); + it shares the slot with the goal/branch + metadata and hides on hover exactly like + them, so the revealed row actions never + overlap it. */} + {renderContext === 'recent' ? ( + <span className="flex-shrink-0 text-[0.72rem] leading-none text-muted-foreground/75 tabular-nums"> + {sessionCompactUpdatedLabel} + </span> + ) : null} </> )} </span> </div> ) : null} {pendingPermissionCount > 0 ? ( - <span className="inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0" title={t('sessions.sidebar.session.status.permissionRequired')} aria-label={t('sessions.sidebar.session.status.permissionRequired')}> + <span className={cn('inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0', badgeVisibilityClass)} title={t('sessions.sidebar.session.status.permissionRequired')} aria-label={t('sessions.sidebar.session.status.permissionRequired')}> <Icon name="shield" className="h-3 w-3" /> <span className="leading-none">{pendingPermissionCount}</span> </span> ) : null} {pendingQuestionCount > 0 ? ( - <span className="inline-flex items-center gap-1 rounded bg-status-info/10 px-1 py-0.5 text-[0.7rem] text-status-info flex-shrink-0" title={pendingQuestionLabel} aria-label={pendingQuestionLabel}> + <span className={cn('inline-flex items-center gap-1 rounded bg-status-info/10 px-1 py-0.5 text-[0.7rem] text-status-info flex-shrink-0', badgeVisibilityClass)} title={pendingQuestionLabel} aria-label={pendingQuestionLabel}> <Icon name="question" className="h-3 w-3" /> <span className="leading-none">{pendingQuestionCount}</span> </span> diff --git a/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.test.ts b/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.test.ts index 93128ec0..9c775b35 100644 --- a/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.test.ts +++ b/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.test.ts @@ -9,6 +9,7 @@ import { nodeHasPinnedMembershipChange, selectFolderRootNodes, selectQuestionBadgeSessionScopes, + selectRowBadgeVisibilityClass, } from './sessionNodeItemUtils'; import type { SessionNode } from '../types'; @@ -166,6 +167,45 @@ describe('selectFolderRootNodes', () => { }); }); +describe('selectRowBadgeVisibilityClass', () => { + const hideOnHoverClass = 'group-hover:opacity-0 group-focus-within:opacity-0'; + + test('hides the badge while hover-revealed actions are shown, like the date label (#2284)', () => { + const className = selectRowBadgeVisibilityClass({ + actionsAlwaysVisible: false, + menuOpen: false, + hideOnHoverClass, + }); + + expect(className).toContain(hideOnHoverClass); + expect(className).toContain('transition-opacity'); + }); + + test('hides the badge while the row menu keeps the actions visible without hover', () => { + const className = selectRowBadgeVisibilityClass({ + actionsAlwaysVisible: false, + menuOpen: true, + hideOnHoverClass, + }); + + expect(className).toContain('opacity-0'); + expect(className).not.toContain('group-hover'); + }); + + test('keeps the badge always visible when actions have reserved permanent padding', () => { + expect(selectRowBadgeVisibilityClass({ + actionsAlwaysVisible: true, + menuOpen: false, + hideOnHoverClass, + })).toBe(''); + expect(selectRowBadgeVisibilityClass({ + actionsAlwaysVisible: true, + menuOpen: true, + hideOnHoverClass, + })).toBe(''); + }); +}); + describe('getSessionWorktreeMenuDisabled', () => { test('shares the parent trigger disabled contract with the new worktree action', () => { expect(getSessionWorktreeMenuDisabled({ diff --git a/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.ts b/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.ts index 171c36cb..71caff37 100644 --- a/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.ts +++ b/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.ts @@ -333,6 +333,25 @@ export const nodeHasPinnedMembershipChange = ( return visit(prevNode, nextNode); }; +/** + * Visibility classes for the row's right-edge badges (pending permissions / + * questions). The hover actions paint over the row's right edge, and they are + * also forced visible while the row menu is open — without hover, so the + * hover reveal padding does not apply and the actions would cover the badges. + * The badges therefore yield exactly like the date/branch metadata label: + * hidden while the actions are hover-revealed or the menu is open. Rows with + * always-visible actions reserve permanent padding instead, so their badges + * never conflict and must stay visible. + */ +export const selectRowBadgeVisibilityClass = (input: { + actionsAlwaysVisible: boolean; + menuOpen: boolean; + hideOnHoverClass: string; +}): string => { + if (input.actionsAlwaysVisible) return ''; + return `transition-opacity duration-150 ${input.menuOpen ? 'opacity-0' : input.hideOnHoverClass}`; +}; + /** * Resolve the session id whose sidebar menu is open, or null if no * menu is open. Only one row can have its menu open at a time. diff --git a/packages/ui/src/components/ui/MemoryDebugPanel.tsx b/packages/ui/src/components/ui/MemoryDebugPanel.tsx index 81227de3..c61b34ea 100644 --- a/packages/ui/src/components/ui/MemoryDebugPanel.tsx +++ b/packages/ui/src/components/ui/MemoryDebugPanel.tsx @@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Icon } from "@/components/icon/Icon"; +import type { IconName } from '@/components/icon/icons'; import { useI18n } from '@/lib/i18n'; interface DebugPanelProps { @@ -21,6 +22,17 @@ interface DebugPanelProps { type DebugTab = 'memory' | 'streaming' | 'requests'; +function getDebugTabIcon(tab: DebugTab): IconName { + switch (tab) { + case 'memory': + return 'database-2'; + case 'streaming': + return 'bar-chart-box'; + case 'requests': + return 'pulse'; + } +} + const formatDuration = (durationMs: number): string => { if (durationMs < 1000) { return `${Math.round(durationMs)}ms`; @@ -302,7 +314,7 @@ const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => { <div className="mb-3 flex items-center justify-between gap-2"> <div className="flex items-center gap-2"> <Icon - name={activeTab === 'memory' ? 'database-2' : activeTab === 'streaming' ? 'bar-chart-box' : 'pulse'} + name={getDebugTabIcon(activeTab)} className="h-4 w-4 text-[var(--surface-foreground)]" /> <h3 className="typography-ui-label font-semibold text-[var(--surface-foreground)]">{t('memoryDebugPanel.title')}</h3> diff --git a/packages/ui/src/components/ui/sortable-tabs-strip.tsx b/packages/ui/src/components/ui/sortable-tabs-strip.tsx index e7474c59..ec506db9 100644 --- a/packages/ui/src/components/ui/sortable-tabs-strip.tsx +++ b/packages/ui/src/components/ui/sortable-tabs-strip.tsx @@ -21,6 +21,7 @@ import { cn } from '@/lib/utils'; import { useUIStore } from '@/stores/useUIStore'; import { useDeviceInfo } from '@/lib/device'; import { Icon } from "@/components/icon/Icon"; +import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu'; export type SortableTabsStripItem = { id: string; @@ -49,6 +50,15 @@ type SortableTabsStripProps = { (e.g. a sliding mobile drawer): creating a composited layer mid-slide flickers in WKWebView. Tab-switch animation stays (layout transition). */ nonCompositedIndicator?: boolean; + /** Per-tab right-click context menu. Return the menu items for the given tab, + or null/undefined to disable the context menu for that tab. */ + tabContextMenu?: (args: { + id: string; + index: number; + isActive: boolean; + allIds: string[]; + close: () => void; + }) => React.ReactNode; className?: string; }; @@ -106,6 +116,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({ animateActivePill, activePillLowercase = true, nonCompositedIndicator = false, + tabContextMenu, className, }) => { const { t } = useI18n(); @@ -445,7 +456,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({ aria-hidden /> ) : null} - {items.map((item) => { + {items.map((item, index) => { const isActive = item.id === activeId; const showInactiveIconOnly = inactiveTabsIconOnly && usesActivePillIndicator && !isActive && Boolean(item.icon); const shouldShowLabel = !showInactiveIconOnly; @@ -479,9 +490,18 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({ } } : undefined; - return ( - <Wrapper key={item.id} id={item.id} className={wrapperClassName}> - <div + const tabMenuItems = !isMobile && tabContextMenu + ? tabContextMenu({ + id: item.id, + index, + isActive, + allIds: itemIDs, + close: () => onClose?.(item.id), + }) + : null; + + const tabElement = ( + <div ref={(element) => setTabRef(item.id, element)} onAuxClick={handleAuxClick} onMouseDown={handleMouseDown} @@ -636,6 +656,24 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({ </button> ) : null} </div> + ); + + return ( + <Wrapper key={item.id} id={item.id} className={wrapperClassName}> + {tabMenuItems ? ( + <ContextMenu> + <ContextMenuTrigger + render={(triggerProps) => ( + <div {...triggerProps} className={cn('flex h-full min-w-0', triggerProps.className)}> + {tabElement} + </div> + )} + /> + <ContextMenuContent className="w-52">{tabMenuItems}</ContextMenuContent> + </ContextMenu> + ) : ( + tabElement + )} </Wrapper> ); })} diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 73219d05..1f8765dc 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -24,6 +24,7 @@ import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor'; import { GoToLineDialog } from './GoToLineDialog'; +import { MarkdownPreviewSearch } from './MarkdownPreviewSearch'; import { PreviewToggleButton } from './PreviewToggleButton'; import { JsonTreeView } from '@/components/ui/JsonTreeView'; import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; @@ -968,6 +969,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => { const [copiedContent, setCopiedContent] = React.useState(false); const [copiedPath, setCopiedPath] = React.useState(false); const [isGoToLineOpen, setIsGoToLineOpen] = React.useState(false); + // In-preview find for the rendered Markdown preview (Ctrl/Cmd+F). + const [mdPreviewFindOpen, setMdPreviewFindOpen] = React.useState(false); + const [mdPreviewFindFocusNonce, setMdPreviewFindFocusNonce] = React.useState(0); + const mdPreviewContainerRef = React.useRef<HTMLDivElement | null>(null); + const mdFullscreenPreviewContainerRef = React.useRef<HTMLDivElement | null>(null); const canCreateFile = Boolean(files.writeFile); const canCreateFolder = Boolean(files.createDirectory); @@ -2915,6 +2921,34 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => { setIsGoToLineOpen(true); }); + // Ctrl/Cmd+F opens the in-preview find bar for the rendered Markdown + // preview. In edit mode CodeMirror owns the shortcut, so this handler is + // active only while the preview is shown. + React.useEffect(() => { + if (!isMarkdown || getMdViewMode() !== 'preview') { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (!(event.metaKey || event.ctrlKey) || event.shiftKey || event.altKey) { + return; + } + if (event.key.toLowerCase() !== 'f') { + return; + } + const target = event.target; + if (target instanceof Element && target.closest('[role="dialog"]')) { + return; + } + event.preventDefault(); + setMdPreviewFindOpen(true); + setMdPreviewFindFocusNonce((value) => value + 1); + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [getMdViewMode, isMarkdown]); + const editorFontSize = useUIStore((state) => state.editorFontSize); const editorExtensions = React.useMemo(() => { @@ -3366,6 +3400,23 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => { /> )} + {isMarkdown && getMdViewMode() === 'preview' && ( + withTooltip(t('filesView.editor.findInFile'), + <Button + variant="ghost" + size="sm" + onClick={() => { + setMdPreviewFindOpen(true); + setMdPreviewFindFocusNonce((value) => value + 1); + }} + className="size-6 p-0 text-foreground opacity-100 transition-opacity hover:bg-transparent focus-visible:bg-transparent active:bg-transparent" + title={t('filesView.editor.findInFile')} + > + <Icon name="search" className="size-4" /> + </Button> + ) + )} + {isMarkdown && getMdViewMode() === 'preview' && showMessageTTSButtons && ( <Tooltip> <TooltipTrigger asChild> @@ -3842,34 +3893,50 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => { </div> </ErrorBoundary> ) : selectedFile && isMarkdown && getMdViewMode() === 'preview' ? ( - <div className="oc-file-preview h-full overflow-auto p-3" ref={markdownPreviewRef}> - <FilePreviewCommentMenu - containerRef={markdownPreviewRef} - filePath={selectedFile.path} - fileContent={fileContent} - /> - {fileContent.length > 500 * 1024 && ( - <div className="mb-3 rounded-md border border-status-warning/20 bg-status-warning/10 px-3 py-2 text-sm text-status-warning"> - {t('filesView.warning.largeFilePreviewLimited', { sizeKb: Math.round(fileContent.length / 1024) })} - </div> - )} - <ErrorBoundary - fallback={ - <div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2"> - <div className="mb-1 font-medium text-destructive">{t('filesView.error.previewUnavailable')}</div> - <div className="text-sm text-muted-foreground"> - {t('filesView.error.switchToEditMode')} - </div> - </div> - } + <div className="relative h-full min-h-0"> + <div + className="oc-file-preview h-full overflow-auto p-3" + ref={(node) => { + markdownPreviewRef.current = node; + mdPreviewContainerRef.current = node; + }} > - <SimpleMarkdownRenderer - content={fileContent} - className="typography-markdown-body" - stripFrontmatter - enableFileReferences={false} + <FilePreviewCommentMenu + containerRef={markdownPreviewRef} + filePath={selectedFile.path} + fileContent={fileContent} /> - </ErrorBoundary> + {fileContent.length > 500 * 1024 && ( + <div className="mb-3 rounded-md border border-status-warning/20 bg-status-warning/10 px-3 py-2 text-sm text-status-warning"> + {t('filesView.warning.largeFilePreviewLimited', { sizeKb: Math.round(fileContent.length / 1024) })} + </div> + )} + <ErrorBoundary + fallback={ + <div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2"> + <div className="mb-1 font-medium text-destructive">{t('filesView.error.previewUnavailable')}</div> + <div className="text-sm text-muted-foreground"> + {t('filesView.error.switchToEditMode')} + </div> + </div> + } + > + <SimpleMarkdownRenderer + content={fileContent} + className="typography-markdown-body" + stripFrontmatter + enableFileReferences={false} + /> + </ErrorBoundary> + </div> + {!isFullscreen && ( + <MarkdownPreviewSearch + containerRef={mdPreviewContainerRef} + open={mdPreviewFindOpen} + onOpenChange={setMdPreviewFindOpen} + focusNonce={mdPreviewFindFocusNonce} + /> + )} </div> ) : selectedFile && isHtml && htmlViewMode === 'preview' ? ( isHtmlAssetAuthLoading ? ( @@ -4213,7 +4280,20 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => { ) : null} </div> ) : isMarkdown && getMdViewMode() === 'preview' ? ( - <div className="oc-file-preview h-full overflow-auto p-4" ref={markdownPreviewRef}> + <div + className="oc-file-preview h-full overflow-auto p-4" + ref={(node) => { + markdownPreviewRef.current = node; + mdFullscreenPreviewContainerRef.current = node; + }} + > + <MarkdownPreviewSearch + containerRef={mdFullscreenPreviewContainerRef} + open={mdPreviewFindOpen} + onOpenChange={setMdPreviewFindOpen} + focusNonce={mdPreviewFindFocusNonce} + className="right-4 top-16" + /> {selectedFile ? ( <FilePreviewCommentMenu containerRef={markdownPreviewRef} diff --git a/packages/ui/src/components/views/MarkdownPreviewSearch.test.ts b/packages/ui/src/components/views/MarkdownPreviewSearch.test.ts new file mode 100644 index 00000000..11c0e3a4 --- /dev/null +++ b/packages/ui/src/components/views/MarkdownPreviewSearch.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'bun:test'; + +import { findMatchRanges } from './markdownPreviewFind'; + +describe('findMatchRanges', () => { + test('returns no ranges for an empty or whitespace-only query', () => { + expect(findMatchRanges('hello world', '')).toEqual([]); + expect(findMatchRanges('hello world', ' ')).toEqual([]); + }); + + test('returns no ranges when the query does not occur', () => { + expect(findMatchRanges('hello world', 'nope')).toEqual([]); + }); + + test('finds all non-overlapping occurrences', () => { + expect(findMatchRanges('the quick brown fox jumps over the lazy dog', 'the')).toEqual([ + { start: 0, end: 3 }, + { start: 31, end: 34 }, + ]); + }); + + test('matches case-insensitively', () => { + expect(findMatchRanges('Hello HELLO hello', 'hello')).toEqual([ + { start: 0, end: 5 }, + { start: 6, end: 11 }, + { start: 12, end: 17 }, + ]); + }); + + test('scans non-overlapping matches like standard find-in-page', () => { + expect(findMatchRanges('aaaa', 'aaa')).toEqual([{ start: 0, end: 3 }]); + }); + + test('trims the query before matching', () => { + expect(findMatchRanges('alpha beta', ' beta ')).toEqual([{ start: 6, end: 10 }]); + }); + + test('handles a query longer than the text', () => { + expect(findMatchRanges('abc', 'abcdef')).toEqual([]); + }); +}); diff --git a/packages/ui/src/components/views/MarkdownPreviewSearch.tsx b/packages/ui/src/components/views/MarkdownPreviewSearch.tsx new file mode 100644 index 00000000..7113b62b --- /dev/null +++ b/packages/ui/src/components/views/MarkdownPreviewSearch.tsx @@ -0,0 +1,344 @@ +import React from 'react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Icon } from '@/components/icon/Icon'; +import { useI18n } from '@/lib/i18n'; +import { cn } from '@/lib/utils'; +import { findMatchRanges } from './markdownPreviewFind'; + +/** + * In-preview text search for the rendered Markdown file preview. + * + * The preview renders as plain DOM (no iframe/shadow root), so browser-native + * find works on web — but the Electron desktop shell has no find-in-page + * implementation at all, and CodeMirror's search only exists in edit mode. + * This widget provides the find shortcut behavior (Ctrl/Cmd+F) and a compact + * search bar with match highlighting, navigation, and a live count, scoped to + * the preview container. + * + * The rendered DOM is owned by the markdown renderer (block-level morphdom + * reconciliation), so highlights are re-applied whenever the renderer mutates + * the container (theme or content changes) via a MutationObserver; mutations + * produced by this widget itself are ignored. + */ +const MARK_ATTR = 'data-md-find'; +const CURRENT_MARK_ATTR = 'data-md-find-current'; +const MARK_CLASS = 'rounded-[2px] bg-status-warning/30 text-foreground'; +const CURRENT_MARK_CLASS = 'rounded-[2px] bg-status-warning/60 text-foreground'; +/** Keystrokes re-walk the whole preview, so coalesce bursts of typing. */ +const SEARCH_DEBOUNCE_MS = 120; + +const isMarkElement = (node: Node): boolean => { + return node instanceof Element && node.hasAttribute(MARK_ATTR); +}; + +const clearHighlights = (container: HTMLElement): void => { + container.querySelectorAll(`mark[${MARK_ATTR}]`).forEach((mark) => { + const parent = mark.parentNode; + if (!parent) { + return; + } + parent.replaceChild(document.createTextNode(mark.textContent ?? ''), mark); + parent.normalize(); + }); +}; + +const applySearch = (container: HTMLElement, query: string): HTMLElement[] => { + clearHighlights(container); + + const normalized = query.trim().toLowerCase(); + if (!normalized) { + return []; + } + + const marks: HTMLElement[] = []; + const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + const parent = node.parentElement; + if (!parent) { + return NodeFilter.FILTER_REJECT; + } + // Skipping svg (mermaid) keeps the highlight pass from corrupting + // diagram rendering; script/style content is never visible anyway. + if (parent.closest('svg, script, style')) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + }, + }); + + const textNodes: Text[] = []; + while (walker.nextNode()) { + const node = walker.currentNode; + if (node instanceof Text) { + textNodes.push(node); + } + } + + for (const node of textNodes) { + const text = node.nodeValue ?? ''; + if (!text) { + continue; + } + const ranges = findMatchRanges(text, normalized); + if (ranges.length === 0) { + continue; + } + + const parent = node.parentNode; + if (!parent) { + continue; + } + const fragment = document.createDocumentFragment(); + let cursor = 0; + for (const range of ranges) { + if (range.start > cursor) { + fragment.appendChild(document.createTextNode(text.slice(cursor, range.start))); + } + const mark = document.createElement('mark'); + mark.setAttribute(MARK_ATTR, ''); + mark.className = MARK_CLASS; + mark.textContent = text.slice(range.start, range.end); + fragment.appendChild(mark); + marks.push(mark); + cursor = range.end; + } + if (cursor < text.length) { + fragment.appendChild(document.createTextNode(text.slice(cursor))); + } + parent.replaceChild(fragment, node); + } + + return marks; +}; + +type MarkdownPreviewSearchProps = { + /** The scrollable preview container whose rendered text is searched. */ + containerRef: React.RefObject<HTMLDivElement | null>; + open: boolean; + onOpenChange: (open: boolean) => void; + /** Bumped every time the find shortcut is pressed to re-focus the input. */ + focusNonce: number; + /** Layout overrides for the floating bar (position, offsets). */ + className?: string; +}; + +export const MarkdownPreviewSearch: React.FC<MarkdownPreviewSearchProps> = ({ + containerRef, + open, + onOpenChange, + focusNonce, + className, +}) => { + const { t } = useI18n(); + const [query, setQuery] = React.useState(''); + const [total, setTotal] = React.useState(0); + const [index, setIndex] = React.useState(0); + const inputRef = React.useRef<HTMLInputElement | null>(null); + const marksRef = React.useRef<HTMLElement[]>([]); + const queryRef = React.useRef(query); + queryRef.current = query; + const debounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null); + // Focus returns here when the bar closes, so Escape does not strand focus. + const returnFocusRef = React.useRef<HTMLElement | null>(null); + + const runSearch = React.useCallback((nextQuery: string) => { + const container = containerRef.current; + if (!container) { + marksRef.current = []; + setTotal(0); + setIndex(0); + return; + } + marksRef.current = applySearch(container, nextQuery); + setTotal(marksRef.current.length); + setIndex(0); + }, [containerRef]); + + const scheduleSearch = React.useCallback((nextQuery: string) => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + debounceRef.current = setTimeout(() => { + debounceRef.current = null; + runSearch(nextQuery); + }, SEARCH_DEBOUNCE_MS); + }, [runSearch]); + + React.useEffect(() => () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + }, []); + + const close = React.useCallback(() => { + onOpenChange(false); + const target = returnFocusRef.current; + returnFocusRef.current = null; + if (target?.isConnected) { + target.focus(); + } + }, [onOpenChange]); + + // Re-apply highlights when the renderer re-morphs the container (theme or + // content changes), ignoring mutations this widget produces itself. Only + // active while the bar is open; closing clears the highlights. + React.useEffect(() => { + const container = containerRef.current; + if (!open || !container) { + return; + } + const observer = new MutationObserver((records) => { + const fromUs = records.some((record) => { + if (record.target instanceof Element && record.target.hasAttribute(MARK_ATTR)) { + return true; + } + return [...record.addedNodes].some((node) => isMarkElement(node)); + }); + if (fromUs) { + return; + } + runSearch(queryRef.current); + }); + observer.observe(container, { childList: true, subtree: true, characterData: true }); + return () => { + observer.disconnect(); + clearHighlights(container); + }; + }, [containerRef, open, runSearch]); + + // Focus the input when the bar opens, remembering what to restore on close. + React.useEffect(() => { + if (!open) { + return; + } + const previous = document.activeElement; + if (previous instanceof HTMLElement && !returnFocusRef.current) { + returnFocusRef.current = previous; + } + inputRef.current?.focus(); + }, [open]); + + // Pressing the find shortcut again re-focuses and re-selects the query. + React.useEffect(() => { + if (open && focusNonce > 0) { + inputRef.current?.focus(); + inputRef.current?.select(); + } + }, [open, focusNonce]); + + // Keep the current-match highlight and scroll it into view. + React.useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + container.querySelectorAll(`mark[${CURRENT_MARK_ATTR}]`).forEach((mark) => { + mark.removeAttribute(CURRENT_MARK_ATTR); + mark.className = MARK_CLASS; + }); + if (total === 0) { + return; + } + const current = marksRef.current[Math.min(Math.max(index, 0), total - 1)]; + if (!current) { + return; + } + current.setAttribute(CURRENT_MARK_ATTR, ''); + current.className = CURRENT_MARK_CLASS; + current.scrollIntoView({ block: 'nearest' }); + }, [containerRef, index, total]); + + const goToNext = React.useCallback(() => { + setIndex((current) => (total === 0 ? 0 : (current + 1) % total)); + }, [total]); + + const goToPrevious = React.useCallback(() => { + setIndex((current) => (total === 0 ? 0 : (current - 1 + total) % total)); + }, [total]); + + const handleKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => { + if (event.key === 'Enter') { + event.preventDefault(); + if (event.shiftKey) { + goToPrevious(); + } else { + goToNext(); + } + } else if (event.key === 'Escape') { + event.preventDefault(); + close(); + } + }, [close, goToNext, goToPrevious]); + + if (!open) { + return null; + } + + return ( + <div className={cn('absolute right-3 top-3 z-10 flex items-center gap-1 rounded-lg border border-border/60 bg-[var(--surface-elevated)] px-1.5 py-1 shadow-lg', className)}> + <Icon name="search" className="ml-0.5 size-3.5 text-muted-foreground" /> + <Input + ref={inputRef} + value={query} + onChange={(event) => { + setQuery(event.target.value); + scheduleSearch(event.target.value); + }} + onKeyDown={handleKeyDown} + placeholder={t('filesView.preview.find.placeholder')} + aria-label={t('filesView.preview.find.placeholder')} + className="h-7 w-40 rounded-md px-2 py-0 text-sm md:w-56" + /> + <span + className="min-w-12 px-1 text-center typography-micro text-muted-foreground tabular-nums" + aria-live="polite" + aria-label={total > 0 + ? t('filesView.preview.find.countAria', { current: index + 1, total }) + : undefined} + > + {query.trim() && total === 0 + ? t('filesView.preview.find.noMatches') + : total > 0 + ? `${index + 1}/${total}` + : ''} + </span> + <Button + type="button" + variant="ghost" + size="sm" + className="size-6 p-0 text-muted-foreground" + onClick={goToPrevious} + title={t('filesView.preview.find.previousAria')} + aria-label={t('filesView.preview.find.previousAria')} + disabled={total === 0} + > + <Icon name="arrow-up" className="size-3.5" /> + </Button> + <Button + type="button" + variant="ghost" + size="sm" + className="size-6 p-0 text-muted-foreground" + onClick={goToNext} + title={t('filesView.preview.find.nextAria')} + aria-label={t('filesView.preview.find.nextAria')} + disabled={total === 0} + > + <Icon name="arrow-down" className="size-3.5" /> + </Button> + <Button + type="button" + variant="ghost" + size="sm" + className="size-6 p-0 text-muted-foreground" + onClick={close} + title={t('filesView.preview.find.closeAria')} + aria-label={t('filesView.preview.find.closeAria')} + > + <Icon name="close" className="size-3.5" /> + </Button> + </div> + ); +}; diff --git a/packages/ui/src/components/views/markdownPreviewFind.ts b/packages/ui/src/components/views/markdownPreviewFind.ts new file mode 100644 index 00000000..0e876a08 --- /dev/null +++ b/packages/ui/src/components/views/markdownPreviewFind.ts @@ -0,0 +1,23 @@ +/** + * Case-insensitive substring match ranges over a single text string, using + * the same non-overlapping `String.prototype.indexOf` scan semantics as + * standard find-in-page (e.g. "aaa" in "aaaa" yields a single [0,3]). + */ +export const findMatchRanges = (text: string, query: string): Array<{ start: number; end: number }> => { + const normalized = query.trim().toLowerCase(); + const ranges: Array<{ start: number; end: number }> = []; + if (!normalized) { + return ranges; + } + const lower = text.toLowerCase(); + let cursor = 0; + while (true) { + const index = lower.indexOf(normalized, cursor); + if (index === -1) { + break; + } + ranges.push({ start: index, end: index + normalized.length }); + cursor = index + normalized.length; + } + return ranges; +}; diff --git a/packages/ui/src/hooks/keyboard-shortcut-dom.test.ts b/packages/ui/src/hooks/keyboard-shortcut-dom.test.ts index f475c3ae..e967f7d4 100644 --- a/packages/ui/src/hooks/keyboard-shortcut-dom.test.ts +++ b/packages/ui/src/hooks/keyboard-shortcut-dom.test.ts @@ -1,6 +1,10 @@ import { expect, test } from 'bun:test'; +import { Window } from 'happy-dom'; -import { hasOpenDropdown, shouldStopDropdownImeEscape } from './keyboard-shortcut-dom'; +import { hasOpenDropdown, isEditableEventTarget, shouldStopDropdownImeEscape } from './keyboard-shortcut-dom'; + +const domWindow = new Window(); +Object.assign(globalThis, { document: domWindow.document, HTMLElement: domWindow.HTMLElement }); test('does not treat an unrelated visible listbox as an open dropdown', () => { const promptNavigator = {} as Element; @@ -35,3 +39,19 @@ test('stops IME Escape before an open dropdown dismiss listener', () => { expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: false, keyCode: 27 }, true)).toBe(false); expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: true, keyCode: 0 }, false)).toBe(false); }); + +test('treats inputs, textareas, selects, and contenteditable elements as editable targets', () => { + expect(isEditableEventTarget(document.createElement('input'))).toBe(true); + expect(isEditableEventTarget(document.createElement('textarea'))).toBe(true); + expect(isEditableEventTarget(document.createElement('select'))).toBe(true); + + const editableDiv = document.createElement('div'); + Object.defineProperty(editableDiv, 'isContentEditable', { value: true }); + expect(isEditableEventTarget(editableDiv)).toBe(true); +}); + +test('does not treat a plain element or non-element target as editable', () => { + expect(isEditableEventTarget(document.createElement('div'))).toBe(false); + expect(isEditableEventTarget(document.createElement('button'))).toBe(false); + expect(isEditableEventTarget(null)).toBe(false); +}); diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index 46da713f..dcccdf37 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -13,6 +13,11 @@ import { type TimelineListMeasurementState, type TimelineScrollMode, } from '@/components/chat/lib/scroll/timelineScrollAnchoring'; +import { + isFollowReleaseKey, + isMiddleButtonPan, + nestedScrollableConsumesWheelUp, +} from '@/components/chat/lib/scroll/timelineScrollIntent'; // ────────────────────────────────────────────────────────────────────────── // Chat timeline scroll ownership. @@ -318,6 +323,14 @@ export const useChatTimelineScroll = ({ } }, [clearAnchor, clearGoToBottomReasserts, hideScrollButton]); + // User preference: with auto-follow off, streaming growth never moves the + // viewport. Sending from the live edge still parks the new message at the + // top, but no glide or end-follow correction runs afterwards; sending from + // mid-history leaves the viewport untouched. + const streamingAutoFollowEnabled = useUIStore((state) => state.streamingAutoFollowEnabled); + const streamingAutoFollowEnabledRef = React.useRef(streamingAutoFollowEnabled); + streamingAutoFollowEnabledRef.current = streamingAutoFollowEnabled; + // Sending arms the anchor. The message id is not known here (the optimistic // row is created by the store), so the next new user message id claims it. // Whether the send-time anchor positioning may animate. Sending from the @@ -328,6 +341,11 @@ export const useChatTimelineScroll = ({ const anchorPositionInstantRef = React.useRef(false); const scrollToBottomOnSend = React.useCallback(() => { + // With auto-follow off, a reader who scrolled away from the end stays + // exactly where they are: the sent message is not anchored and the + // scroll-to-bottom pill (already showing) leads to it. From the live + // edge, sending anchors the new turn as usual. + if (!streamingAutoFollowEnabledRef.current && !isAtEndRef.current) return; anchorPositionInstantRef.current = !isAtEndRef.current; isAtEndRef.current = true; setUserOwnsScroll(false); @@ -540,13 +558,6 @@ export const useChatTimelineScroll = ({ first: null, second: null, }); - // User preference: with auto-follow off, streaming growth never moves the - // viewport — the anchored user message still parks at the top on send, but - // no glide or end-follow correction runs afterwards. - const streamingAutoFollowEnabled = useUIStore((state) => state.streamingAutoFollowEnabled); - const streamingAutoFollowEnabledRef = React.useRef(streamingAutoFollowEnabled); - streamingAutoFollowEnabledRef.current = streamingAutoFollowEnabled; - // While the list width is resizing, every pinning write fights the // per-frame row re-measure and the pinned viewport shakes. Corrections // stand down for the whole resize and the visible content is held by the @@ -588,6 +599,27 @@ export const useChatTimelineScroll = ({ }; }, [scrollNode]); + // Keep the live edge in view after content growth. Within a viewport of + // the end the remaining distance is glided so a revealed block and the + // scroll read as one motion; further behind, the viewport first jumps to + // one screen above the end and glides only that last screen, so the + // reader is never left staring at a gap several screens tall. Writes go + // to the scroll node directly: routing each chunk through the list's + // scrollToEnd bookkeeping roughly doubled frame production when measured. + // A user gesture interrupts the native smooth scroll on its own, and the + // gesture handler drops live follow so no later correction re-engages. + const followEnd = React.useCallback(() => { + const node = scrollRef.current; + if (!node) return; + const end = node.scrollHeight - node.clientHeight; + const distance = end - node.scrollTop; + if (distance <= 1) return; + if (distance > node.clientHeight) { + node.scrollTop = end - node.clientHeight; + } + node.scrollTo({ top: end, behavior: 'smooth' }); + }, []); + const onTimelineDataChange = React.useCallback(() => { if (widthResizingRef.current) return; @@ -643,12 +675,18 @@ export const useChatTimelineScroll = ({ } if (!isLiveFollowActive()) return; - // Since @legendapp/list 3.3.x, maintainScrollAtEnd follows content - // growth on its own — including a tail row growing in place — and - // releases when the user scrolls away. Following the end therefore - // needs no correction here; this handler only serves the - // anchored-turn glide below. - if (modeRef.current === 'following-end') return; + // Following the end is owned here, not left to the list's + // maintainScrollAtEnd. The list's animated maintain is single-flight: + // growth that lands while a glide is still in flight is dropped until + // the next trigger, and its re-pin threshold is a tenth of the + // viewport. In a narrow viewport (the VS Code sidebar) one revealed + // block is several viewports tall, so every block left the reader a + // second behind and multiple screens above the live edge — measured + // at 45% of the stream time spent 500-1600px behind at 420x640. + if (modeRef.current === 'following-end') { + followEnd(); + return; + } const frames = dataChangeFramesRef.current; if (frames.first !== null) cancelAnimationFrame(frames.first); @@ -697,7 +735,7 @@ export const useChatTimelineScroll = ({ }); }); - }, [isLiveFollowActive, scheduleShowScrollButton]); + }, [followEnd, isLiveFollowActive, scheduleShowScrollButton]); // The streaming tail grows inside one row without changing the entries // array, so data-change callbacks are silent for the entire stream. The @@ -739,8 +777,12 @@ export const useChatTimelineScroll = ({ onManualNavigationRef.current(); }; const handleWheel = (event: WheelEvent) => { - // Scrolling toward the end is not opting out of follow. - if (event.deltaY < 0 && canScrollUp()) gesture(); + // Scrolling toward the end is not opting out of follow, and an + // upward wheel that a nested scroller still consumes never + // reaches the timeline. + if (event.deltaY < 0 && !nestedScrollableConsumesWheelUp(scrollNode, event.target) && canScrollUp()) { + gesture(); + } }; // Touch mirrors wheel by finger direction, not by having already left // the end: while a stream keeps re-pinning the viewport, waiting for @@ -764,14 +806,19 @@ export const useChatTimelineScroll = ({ touchLastY = null; }; const handlePointerDown = (event: PointerEvent) => { - // The scrollbar track is the scroll node itself; a tap on a row - // only breaks follow when the viewport already left the end. + // A middle-button pan scrolls without wheel events (and is the + // only scroll gesture for wheel-less mice), so the press is the + // opt-out. Otherwise the scrollbar track is the scroll node + // itself; a tap on a row only breaks follow when the viewport + // already left the end. + if (isMiddleButtonPan(scrollNode, event)) { + if (canScrollUp()) gesture(); + return; + } if ((event.target === scrollNode || !isAtEndRef.current) && canScrollUp()) gesture(); }; const handleKeyDown = (event: KeyboardEvent) => { - if ((event.key === 'PageUp' || event.key === 'Home' || event.key === 'ArrowUp') && canScrollUp()) { - gesture(); - } + if (isFollowReleaseKey(event) && canScrollUp()) gesture(); }; const handleScroll = () => { queueSave(); diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 61ad272d..2549cbcd 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -493,6 +493,7 @@ export const useKeyboardShortcuts = () => { && !event.repeat && eventMatchesShortcutPrefix(event, switchSurfacePrefix, heldKeysRef.current) ) { + if (isEditableEventTarget(event.target)) return; const state = useUIStore.getState(); if (!state.isMobile && effectiveDirectory) { const directory = normalizeContextPanelDirectoryKey(effectiveDirectory); diff --git a/packages/ui/src/hooks/useRootScrollLock.test.ts b/packages/ui/src/hooks/useRootScrollLock.test.ts new file mode 100644 index 00000000..7a194d11 --- /dev/null +++ b/packages/ui/src/hooks/useRootScrollLock.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'bun:test'; + +import { isRootScrollTarget, resetRootScroll } from './useRootScrollLock'; + +type FakeElement = EventTarget & { id: string; scrollTop: number; scrollLeft: number }; + +const element = (id: string): FakeElement => Object.assign(new EventTarget(), { id, scrollTop: 0, scrollLeft: 0 }); + +/** Installs a minimal stand-in for `document` for the duration of `run`. */ +const withDocument = (setup: { root?: FakeElement }, run: () => void) => { + const fakeDocument = { + documentElement: element('html'), + body: element('body'), + getElementById: (id: string) => (setup.root && setup.root.id === id ? setup.root : null), + }; + // The hook only reads documentElement/body/getElementById from `document`; + // this stand-in provides exactly those members for a DOM-less test process. + const hadDocument = 'document' in globalThis; + const previous = hadDocument ? globalThis.document : undefined; + Reflect.set(globalThis, 'document', fakeDocument); + try { + run(); + } finally { + if (hadDocument) Reflect.set(globalThis, 'document', previous); + else Reflect.deleteProperty(globalThis, 'document'); + } +}; + +describe('resetRootScroll', () => { + test('snaps every root scroll offset back to zero and reports the reset', () => { + const root = element('root'); + withDocument({ root }, () => { + document.documentElement.scrollTop = 48; + document.body.scrollLeft = 12; + root.scrollTop = 200; + expect(resetRootScroll()).toBe(true); + expect(document.documentElement.scrollTop).toBe(0); + expect(document.body.scrollLeft).toBe(0); + expect(root.scrollTop).toBe(0); + }); + }); + + test('reports nothing to do when the root is already at zero', () => { + withDocument({}, () => { + expect(resetRootScroll()).toBe(false); + }); + }); +}); + +describe('isRootScrollTarget', () => { + test('recognises the document, html and body as root scroll sources', () => { + withDocument({}, () => { + expect(isRootScrollTarget(document)).toBe(true); + expect(isRootScrollTarget(document.documentElement)).toBe(true); + expect(isRootScrollTarget(document.body)).toBe(true); + }); + }); + + test('ignores scroll events from inner containers', () => { + withDocument({}, () => { + expect(isRootScrollTarget(element('chat-timeline'))).toBe(false); + }); + }); +}); diff --git a/packages/ui/src/hooks/useRootScrollLock.ts b/packages/ui/src/hooks/useRootScrollLock.ts new file mode 100644 index 00000000..6e90acee --- /dev/null +++ b/packages/ui/src/hooks/useRootScrollLock.ts @@ -0,0 +1,51 @@ +import React from 'react'; + +/** + * The document root (`html`, `body`, `#root`) is `overflow: hidden` and must + * never scroll — every scrollable area lives in a dedicated container. Chromium + * still scrolls hidden-overflow ancestors programmatically, most visibly when + * a textarea caret moves out of view (PageUp/PageDown in the prompt box, or a + * long prompt being typed) and the browser scrolls it into view. Once that + * happens the whole app shifts up, hides the title bar, and nothing the user + * does with the wheel or keyboard can scroll it back. + * + * Snap every root scroll straight back to zero. + */ + +const rootScrollTargets = (): HTMLElement[] => { + const targets = [document.documentElement, document.body]; + const appRoot = document.getElementById('root'); + if (appRoot) targets.push(appRoot); + return targets; +}; + +export const resetRootScroll = (): boolean => { + let reset = false; + for (const target of rootScrollTargets()) { + if (target.scrollTop !== 0) { + target.scrollTop = 0; + reset = true; + } + if (target.scrollLeft !== 0) { + target.scrollLeft = 0; + reset = true; + } + } + return reset; +}; + +export const isRootScrollTarget = (target: EventTarget | null): boolean => + target === document || rootScrollTargets().some((element) => element === target); + +export const useRootScrollLock = (): void => { + React.useEffect(() => { + const handleScroll = (event: Event) => { + if (isRootScrollTarget(event.target)) resetRootScroll(); + }; + // Capture: the root's own scroll events don't bubble to inner listeners, + // and scroll events from inner containers are filtered out above. + document.addEventListener('scroll', handleScroll, { capture: true, passive: true }); + resetRootScroll(); + return () => document.removeEventListener('scroll', handleScroll, { capture: true }); + }, []); +}; diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 2b18806f..3da4af7f 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -5,6 +5,7 @@ import type { DraftStarterRef } from '@/lib/draftStarters'; import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { isVSCodeBootstrapPresent } from '@/lib/vscodeBootstrap'; type ManagedRemoteTunnelPreset = { id: string; @@ -573,6 +574,12 @@ export const startDesktopWindowDrag = async (): Promise<boolean> => { }; export const isVSCodeRuntime = (): boolean => { + // Prefer extension-host bootstrap config: it is injected in webview HTML + // before any store module evaluates, so startup does not depend on + // RuntimeAPIs registration order (see #2359). + if (isVSCodeBootstrapPresent()) { + return true; + } const apis = getRegisteredRuntimeAPIs(); return apis?.runtime?.isVSCode === true; }; diff --git a/packages/ui/src/lib/desktop.vscodeRuntime.test.ts b/packages/ui/src/lib/desktop.vscodeRuntime.test.ts new file mode 100644 index 00000000..fb02417f --- /dev/null +++ b/packages/ui/src/lib/desktop.vscodeRuntime.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test'; + +type RuntimeApisStub = { runtime?: { isVSCode?: boolean } } | null; + +let registeredRuntimeApis: RuntimeApisStub = null; + +mock.module('@/contexts/runtimeAPIRegistry', () => ({ + getRegisteredRuntimeAPIs: (): RuntimeApisStub => registeredRuntimeApis, +})); + +const { isVSCodeRuntime } = await import('./desktop'); + +describe('desktop isVSCodeRuntime bootstrap detection', () => { + afterEach(() => { + registeredRuntimeApis = null; + delete (globalThis as { window?: unknown }).window; + }); + + test('detects VS Code from bootstrap config before RuntimeAPIs register', () => { + registeredRuntimeApis = null; + (globalThis as { window: unknown }).window = { + __VSCODE_CONFIG__: { + workspaceFolder: '/Users/me/project-a', + workspaceFolders: [{ name: 'project-a', path: '/Users/me/project-a' }], + }, + }; + + expect(isVSCodeRuntime()).toBe(true); + }); + + test('falls back to registered RuntimeAPIs when bootstrap is absent', () => { + registeredRuntimeApis = { + runtime: { isVSCode: true }, + }; + (globalThis as { window: unknown }).window = {}; + + expect(isVSCodeRuntime()).toBe(true); + }); + + test('does not classify an unregistered web runtime as VS Code', () => { + registeredRuntimeApis = null; + (globalThis as { window: unknown }).window = {}; + + expect(isVSCodeRuntime()).toBe(false); + }); +}); diff --git a/packages/ui/src/lib/gitApiHttp.test.ts b/packages/ui/src/lib/gitApiHttp.test.ts index 4a58391c..4dc618b3 100644 --- a/packages/ui/src/lib/gitApiHttp.test.ts +++ b/packages/ui/src/lib/gitApiHttp.test.ts @@ -1,10 +1,30 @@ import { describe, expect, test } from 'bun:test'; import { + abortMerge, + abortRebase, + applyGitStash, + checkoutBranch, + checkoutCommit, + cherryPick, + continueMerge, + continueRebase, + createBranch, + deleteGitBranch, + deleteRemoteBranch, + dropGitStash, getGitBranches, getGitStatus, gitFetch, + merge, + popGitStash, + rebase, + removeRemote, + renameBranch, + resetToCommit, + revertCommit, stageGitFile, stageGitFiles, + stashGitChanges, unstageGitFile, unstageGitFiles, } from './gitApiHttp'; @@ -169,6 +189,200 @@ describe('gitApiHttp status cache', () => { }); }); +const statusPayload = (overrides: Record<string, unknown> = {}) => ({ + current: 'main', + tracking: null, + ahead: 0, + behind: 0, + files: [], + isClean: true, + ...overrides, +}); + +const jsonResponse = (payload: unknown) => new Response(JSON.stringify(payload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, +}); + +const installStatusMutationFetchMock = () => { + const mock = { + statusUrls: [] as string[], + behind: 0, + }; + globalThis.fetch = (async (input) => { + const url = String(input); + if (url.startsWith('/api/git/status')) { + mock.statusUrls.push(url); + return jsonResponse(statusPayload({ behind: mock.behind })); + } + return jsonResponse({ success: true }); + }) as typeof fetch; + return mock; +}; + +/** + * Seeds the status cache, performs the mutation, and asserts the next status + * read issues a fresh request that observes the post-mutation state instead of + * serving the pre-mutation cache entry. + */ +const expectStatusInvalidatedBy = async ( + directory: string, + mutate: () => Promise<unknown> +): Promise<void> => { + const mock = installStatusMutationFetchMock(); + + const seeded = await getGitStatus(directory); + expect(seeded.behind).toBe(0); + + mock.behind = 2; + const cached = await getGitStatus(directory); + expect(cached.behind).toBe(0); + expect(mock.statusUrls).toHaveLength(1); + + await mutate(); + + const refreshed = await getGitStatus(directory); + expect(refreshed.behind).toBe(2); + expect(mock.statusUrls).toHaveLength(2); +}; + +describe('gitApiHttp post-mutation status invalidation (#2281)', () => { + test('checkout and branch mutations invalidate cached status', async () => { + installWindowMock(); + try { + await expectStatusInvalidatedBy('/repo-2281-checkout', () => checkoutBranch('/repo-2281-checkout', 'feature')); + await expectStatusInvalidatedBy('/repo-2281-create-branch', () => createBranch('/repo-2281-create-branch', 'feature/new')); + await expectStatusInvalidatedBy('/repo-2281-rename-branch', () => renameBranch('/repo-2281-rename-branch', 'old', 'new')); + await expectStatusInvalidatedBy('/repo-2281-delete-branch', () => deleteGitBranch('/repo-2281-delete-branch', { branch: 'feature/old' })); + } finally { + restoreMocks(); + } + }); + + test('stash lifecycle mutations invalidate cached status', async () => { + installWindowMock(); + try { + await expectStatusInvalidatedBy('/repo-2281-stash', () => stashGitChanges('/repo-2281-stash', { message: 'WIP' })); + await expectStatusInvalidatedBy('/repo-2281-stash-apply', () => applyGitStash('/repo-2281-stash-apply', { ref: 'stash@{0}' })); + await expectStatusInvalidatedBy('/repo-2281-stash-pop', () => popGitStash('/repo-2281-stash-pop', { ref: 'stash@{0}' })); + await expectStatusInvalidatedBy('/repo-2281-stash-drop', () => dropGitStash('/repo-2281-stash-drop', { ref: 'stash@{0}' })); + } finally { + restoreMocks(); + } + }); + + test('merge and rebase lifecycle mutations invalidate cached status', async () => { + installWindowMock(); + try { + await expectStatusInvalidatedBy('/repo-2281-merge', () => merge('/repo-2281-merge', { branch: 'feature' })); + await expectStatusInvalidatedBy('/repo-2281-merge-abort', () => abortMerge('/repo-2281-merge-abort')); + await expectStatusInvalidatedBy('/repo-2281-merge-continue', () => continueMerge('/repo-2281-merge-continue')); + await expectStatusInvalidatedBy('/repo-2281-rebase', () => rebase('/repo-2281-rebase', { onto: 'main' })); + await expectStatusInvalidatedBy('/repo-2281-rebase-abort', () => abortRebase('/repo-2281-rebase-abort')); + await expectStatusInvalidatedBy('/repo-2281-rebase-continue', () => continueRebase('/repo-2281-rebase-continue')); + } finally { + restoreMocks(); + } + }); + + test('history mutations invalidate cached status', async () => { + installWindowMock(); + try { + await expectStatusInvalidatedBy('/repo-2281-checkout-commit', () => checkoutCommit('/repo-2281-checkout-commit', 'abc123')); + await expectStatusInvalidatedBy('/repo-2281-cherry-pick', () => cherryPick('/repo-2281-cherry-pick', 'abc123')); + await expectStatusInvalidatedBy('/repo-2281-revert-commit', () => revertCommit('/repo-2281-revert-commit', 'abc123')); + await expectStatusInvalidatedBy('/repo-2281-reset', () => resetToCommit('/repo-2281-reset', 'abc123', 'mixed')); + } finally { + restoreMocks(); + } + }); + + test('remote-side mutations invalidate cached status', async () => { + installWindowMock(); + try { + await expectStatusInvalidatedBy('/repo-2281-delete-remote-branch', () => deleteRemoteBranch('/repo-2281-delete-remote-branch', { branch: 'feature', remote: 'origin' })); + await expectStatusInvalidatedBy('/repo-2281-remove-remote', () => removeRemote('/repo-2281-remove-remote', { remote: 'origin' })); + } finally { + restoreMocks(); + } + }); + + test('a failed mutation does not invalidate cached status', async () => { + installWindowMock(); + const statusUrls: string[] = []; + globalThis.fetch = (async (input) => { + const url = String(input); + if (url.startsWith('/api/git/status')) { + statusUrls.push(url); + return jsonResponse(statusPayload()); + } + return new Response(JSON.stringify({ error: 'checkout failed' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + + try { + const directory = '/repo-2281-failed-checkout'; + await getGitStatus(directory); + + const error = await captureError(async () => { + await checkoutBranch(directory, 'feature'); + }); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('checkout failed'); + + await getGitStatus(directory); + expect(statusUrls).toHaveLength(1); + } finally { + restoreMocks(); + } + }); + + test('a status request admitted before a mutation cannot satisfy the post-mutation refresh', async () => { + installWindowMock(); + const statusResolvers: Array<(response: Response) => void> = []; + const statusUrls: string[] = []; + globalThis.fetch = (async (input) => { + const url = String(input); + if (url.startsWith('/api/git/status')) { + statusUrls.push(url); + return new Promise<Response>((resolve) => { + statusResolvers.push(resolve); + }); + } + return jsonResponse({ success: true }); + }) as typeof fetch; + + try { + const directory = '/repo-2281-deferred'; + const preMutationRead = getGitStatus(directory); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(statusUrls).toHaveLength(1); + + await checkoutBranch(directory, 'feature'); + + const postMutationRead = getGitStatus(directory); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(statusUrls).toHaveLength(2); + + statusResolvers[1](jsonResponse(statusPayload({ current: 'feature' }))); + statusResolvers[0](jsonResponse(statusPayload({ current: 'main' }))); + + const [preMutationStatus, postMutationStatus] = await Promise.all([preMutationRead, postMutationRead]); + expect(preMutationStatus.current).toBe('main'); + expect(postMutationStatus.current).toBe('feature'); + + // The late pre-mutation response must not repopulate the cache. + const cachedRead = await getGitStatus(directory); + expect(cachedRead.current).toBe('feature'); + expect(statusUrls).toHaveLength(2); + } finally { + restoreMocks(); + } + }); +}); + describe('gitApiHttp request priority', () => { test('leaves low-level reads outside the background policy', async () => { installWindowMock(); diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 2c0c6603..3827927a 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -38,6 +38,7 @@ import type { import { runtimeFetch } from './runtime-fetch'; import { getRuntimeUrlResolver } from './runtime-url'; import { getRuntimeKey } from './runtime-switch'; +import { notifyGitStatusInvalidated } from './gitStatusInvalidation'; const API_BASE = '/api/git'; const GIT_STATUS_CACHE_TTL_MS = 1200; @@ -66,6 +67,16 @@ const invalidateGitStatusCache = (directory: string): void => { gitStatusCache.delete(statusKey); gitStatusInFlight.delete(statusKey); } + notifyGitStatusInvalidated(directory); +}; + +// Shared success path for status-affecting mutations. The payload is parsed +// before invalidating so a failed mutation (non-ok response handled by the +// caller, or a malformed body) cannot publish a false state change. +const completeStatusMutation = async <T>(directory: string, response: Response): Promise<T> => { + const result = await response.json() as T; + invalidateGitStatusCache(directory); + return result; }; function buildUrl( @@ -464,7 +475,7 @@ export async function deleteGitBranch(directory: string, payload: GitDeleteBranc throw new Error(error.error || 'Failed to delete branch'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }> { @@ -483,7 +494,7 @@ export async function deleteRemoteBranch(directory: string, payload: GitDeleteRe throw new Error(error.error || 'Failed to delete remote branch'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function removeRemote(directory: string, payload: GitRemoveRemotePayload): Promise<{ success: boolean }> { @@ -503,7 +514,7 @@ export async function removeRemote(directory: string, payload: GitRemoveRemotePa throw new Error(error.error || 'Failed to remove remote'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function generateCommitMessage( @@ -710,9 +721,7 @@ export async function createGitCommit( const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to create commit'); } - const result = await response.json(); - invalidateGitStatusCache(directory); - return result; + return completeStatusMutation(directory, response); } export async function gitPush( @@ -728,9 +737,7 @@ export async function gitPush( const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to push'); } - const result = await response.json(); - invalidateGitStatusCache(directory); - return result; + return completeStatusMutation(directory, response); } export async function gitPull( @@ -746,9 +753,7 @@ export async function gitPull( const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to pull'); } - const result = await response.json(); - invalidateGitStatusCache(directory); - return result; + return completeStatusMutation(directory, response); } export async function gitFetch( @@ -764,9 +769,7 @@ export async function gitFetch( const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to fetch'); } - const result = await response.json(); - invalidateGitStatusCache(directory); - return result; + return completeStatusMutation(directory, response); } export async function listGitStashes(directory: string): Promise<{ stashes: GitStashEntry[] }> { @@ -801,7 +804,7 @@ export async function stashGitChanges(directory: string, options: { message?: st const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to stash changes'); } - return response.json(); + return completeStatusMutation(directory, response); } const postStashRef = async (directory: string, path: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> => { @@ -814,7 +817,7 @@ const postStashRef = async (directory: string, path: string, options: { ref: str const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || `Failed to ${path}`); } - return response.json(); + return completeStatusMutation(directory, response); }; export const applyGitStash = (directory: string, options: { ref: string }) => postStashRef(directory, 'stash/apply', options); @@ -831,7 +834,7 @@ export async function checkoutBranch(directory: string, branch: string): Promise const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to checkout branch'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function createBranch( @@ -848,7 +851,7 @@ export async function createBranch( const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to create branch'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function renameBranch( @@ -865,7 +868,7 @@ export async function renameBranch( const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to rename branch'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function getGitLog( @@ -1068,7 +1071,7 @@ export async function rebase( const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to rebase'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function abortRebase(directory: string): Promise<{ success: boolean }> { @@ -1079,7 +1082,7 @@ export async function abortRebase(directory: string): Promise<{ success: boolean const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to abort rebase'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function merge( @@ -1095,7 +1098,7 @@ export async function merge( const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to merge'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function checkoutCommit( @@ -1111,7 +1114,7 @@ export async function checkoutCommit( const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to checkout commit'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function cherryPick( @@ -1127,7 +1130,7 @@ export async function cherryPick( const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to cherry-pick'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function revertCommit( @@ -1143,7 +1146,7 @@ export async function revertCommit( const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to revert commit'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function resetToCommit( @@ -1161,7 +1164,7 @@ export async function resetToCommit( const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to reset'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function abortMerge(directory: string): Promise<{ success: boolean }> { @@ -1172,7 +1175,7 @@ export async function abortMerge(directory: string): Promise<{ success: boolean const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to abort merge'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> { @@ -1183,7 +1186,7 @@ export async function continueRebase(directory: string): Promise<{ success: bool const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to continue rebase'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> { @@ -1194,7 +1197,7 @@ export async function continueMerge(directory: string): Promise<{ success: boole const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to continue merge'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function stash( diff --git a/packages/ui/src/lib/gitStatusInvalidation.ts b/packages/ui/src/lib/gitStatusInvalidation.ts new file mode 100644 index 00000000..1bd672ff --- /dev/null +++ b/packages/ui/src/lib/gitStatusInvalidation.ts @@ -0,0 +1,34 @@ +/** + * Minimal notification channel for git status invalidation. + * + * A runtime adapter that caches git status (currently only the HTTP adapter in + * `gitApiHttp.ts`) must call `notifyGitStatusInvalidated` whenever a successful + * status-affecting mutation invalidates its cache. `useGitStore` subscribes and + * bumps its per-directory status mutation revision so an immediate refresh + * cannot join an in-flight status request admitted before the mutation, and a + * stale response cannot commit over newer authoritative state. + * + * Runtime parity: the VS Code bridge adapter performs no client-side status + * caching (every `getGitStatus` is a fresh bridge request), so it has no cache + * to invalidate and does not emit this signal today. Any adapter that adds + * caching must emit on invalidation. + */ + +type GitStatusInvalidationListener = (directory: string) => void; + +const listeners = new Set<GitStatusInvalidationListener>(); + +export const subscribeGitStatusInvalidations = ( + listener: GitStatusInvalidationListener +): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +export const notifyGitStatusInvalidated = (directory: string): void => { + for (const listener of listeners) { + listener(directory); + } +}; diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index cf91d54d..1302d87a 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1882,7 +1882,7 @@ export const settingsDict = { 'settings.openchamber.visual.section.streaming': 'Streaming', 'settings.openchamber.visual.field.streamingAutoFollow': 'Neuen Inhalten beim Streaming folgen', 'settings.openchamber.visual.field.streamingAutoFollowAria': 'Neuen Inhalten automatisch folgen, während eine Antwort gestreamt wird', - 'settings.openchamber.visual.field.streamingAutoFollowInfo': 'Während eine Antwort eintrifft, folgt die Ansicht laufend dem neuesten Inhalt. Deaktivieren, um die Ansicht ruhig zu halten und manuell zu scrollen.', + 'settings.openchamber.visual.field.streamingAutoFollowInfo': 'Während eine Antwort eintrifft, folgt die Ansicht laufend dem neuesten Inhalt. Deaktivieren, um die Ansicht ruhig zu halten und manuell zu scrollen; das Senden einer Nachricht aus der Mitte des Chats lässt die Ansicht dann ebenfalls an Ort und Stelle.', 'settings.openchamber.visual.section.messageAppearance': 'Nachrichten-Erscheinungsbild', 'settings.openchamber.visual.section.toolsAndFiles': 'Werkzeuge & Dateien', 'settings.openchamber.visual.section.composer': 'Komponist', @@ -2002,6 +2002,13 @@ export const settingsDict = { 'settings.openchamber.visual.field.persistDraftMessages': 'Entwurfsnachrichten speichern', 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'Rechtschreibprüfung in Texteingaben aktivieren', 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Rechtschreibprüfung in Texteingaben aktivieren', + 'settings.openchamber.visual.field.largeTextPaste': 'Großes Texteinfügen', + 'settings.openchamber.visual.field.largeTextPasteHint': 'Beim Einfügen von mehr als etwa 2.000 Zeichen oder 25 Zeilen wählen, ob der Text als Datei angehängt, direkt eingefügt oder jedes Mal nachgefragt werden soll.', + 'settings.openchamber.visual.field.largeTextPasteAria': 'Verhalten bei großem Texteinfügen', + 'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Großes Texteinfügen: {option}', + 'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Jedes Mal fragen', + 'settings.openchamber.visual.option.largeTextPaste.attach.label': 'Als Datei anhängen', + 'settings.openchamber.visual.option.largeTextPaste.inline.label': 'Direkt einfügen', 'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': 'Anonyme Nutzungsberichte senden', 'settings.openchamber.visual.field.sendAnonymousUsageReports': 'Anonyme Nutzungsberichte senden', 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'Hilft uns zu verstehen, welche App-Versionen aktiv genutzt werden, damit wir Verbesserungen priorisieren können. Es werden nur die App-Version, Plattform und Laufzeit gesammelt - keine persönlichen Daten oder Code.', diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 5f35569d..459b19b6 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -103,6 +103,7 @@ export const dict = { 'mobile.sessions.section.worktrees': 'Worktrees', 'mobile.sessions.section.otherProjects': 'Projekt wechseln', 'mobile.sessions.section.projects': 'Projekte', + 'mobile.sessions.section.chats': 'Chats', 'mobile.sessions.empty.noProjectsTitle': 'Noch keine Projekte', 'mobile.sessions.empty.noProjectsDescription': 'Füge ein Projekt hinzu, um mit deinem Code zu chatten.', 'mobile.sessions.empty.noSessionsTitle': 'Noch keine Sitzungen', @@ -1114,6 +1115,11 @@ export const dict = { 'contextPanel.browser.annotate.submit': 'Anhängen', 'contextPanel.browser.trustNotice': 'Seiten, die hier geöffnet werden, laufen mit vollständigem Zugriff auf OpenChamber — erforderlich für Inspect und Screenshots. Öffnen Sie nur Seiten, denen Sie vertrauen: Eine bösartige Seite könnte Ihre Daten lesen oder in Ihrem Namen handeln.', 'contextPanel.tab.closeTabAria': '{label}-Registerkarte schließen', + 'contextPanel.tab.menu.close': 'Schließen', + 'contextPanel.tab.menu.closeOthers': 'Andere schließen', + 'contextPanel.tab.menu.closeToLeft': 'Tabs links daneben schließen', + 'contextPanel.tab.menu.closeToRight': 'Tabs rechts daneben schließen', + 'contextPanel.tab.menu.closeAll': 'Alle Tabs schließen', 'contextPanel.actions.collapsePanel': 'Panel einklappen', 'contextPanel.actions.expandPanel': 'Panel ausklappen', 'contextPanel.actions.closePanel': 'Panel schließen', @@ -1243,6 +1249,12 @@ export const dict = { 'filesView.editor.disableLineWrap': 'Zeilenumbruch deaktivieren', 'filesView.editor.enableLineWrap': 'Zeilenumbruch aktivieren', 'filesView.editor.findInFile': 'In Datei suchen', + 'filesView.preview.find.placeholder': 'In Vorschau suchen', + 'filesView.preview.find.nextAria': 'Nächster Treffer', + 'filesView.preview.find.previousAria': 'Vorheriger Treffer', + 'filesView.preview.find.closeAria': 'Suche schließen', + 'filesView.preview.find.noMatches': 'Keine Treffer', + 'filesView.preview.find.countAria': '{current} von {total}', 'filesView.editor.goToLine': 'Gehe zu Zeile', 'filesView.editor.switchToEditMode': 'Zum Bearbeitungsmodus wechseln', 'filesView.editor.switchToPreviewMode': 'Zum Vorschau-Modus wechseln', @@ -2110,6 +2122,10 @@ export const dict = { 'chat.chatInput.toast.messageSendFailed': 'Nachricht konnte nicht gesendet werden. Anhänge wurden wiederhergestellt.', 'chat.chatInput.toast.noModelSelected': 'Wähle vor dem Senden einen Anbieter und ein Modell aus.', 'chat.chatInput.toast.clipboardAttachFailed': 'Fehler beim Anhängen des Bildes aus der Zwischenablage', + 'chat.chatInput.toast.clipboardTextAttachFailed': 'Fehler beim Anhängen des eingefügten Texts als Datei', + 'chat.chatInput.toast.largeTextPaste.title': 'Großer Text erkannt', + 'chat.chatInput.toast.largeTextPaste.attach': 'Als Datei anhängen', + 'chat.chatInput.toast.largeTextPaste.inline': 'Direkt einfügen', 'chat.chatInput.toast.addedFileMentions': '{count} Datei(er) hinzugefügt', 'chat.chatInput.toast.attachFileFailed': 'Fehler beim Anhängen der Datei', 'chat.chatInput.toast.attachNamedFailed': 'Fehler beim Anhängen von {name}', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 8990d2ec..29f19526 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1955,7 +1955,7 @@ export const settingsDict = { 'settings.openchamber.visual.section.streaming': 'Streaming', 'settings.openchamber.visual.field.streamingAutoFollow': 'Follow new content while streaming', 'settings.openchamber.visual.field.streamingAutoFollowAria': 'Automatically follow new content while a response streams', - 'settings.openchamber.visual.field.streamingAutoFollowInfo': 'While a reply streams in, the view keeps gliding to the newest content. Turn this off to keep the view still and scroll manually.', + 'settings.openchamber.visual.field.streamingAutoFollowInfo': 'While a reply streams in, the view keeps gliding to the newest content. Turn this off to keep the view still and scroll manually; sending a message while scrolled up then also leaves the view where it is.', 'settings.openchamber.visual.section.messageAppearance': 'Message Appearance', 'settings.openchamber.visual.section.toolsAndFiles': 'Tools & Files', 'settings.openchamber.visual.section.composer': 'Composer', @@ -2085,6 +2085,13 @@ export const settingsDict = { 'settings.openchamber.visual.field.persistDraftMessages': 'Persist Draft Messages', 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'Enable spellcheck in text inputs', 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Enable Spellcheck in Text Inputs', + 'settings.openchamber.visual.field.largeTextPaste': 'Large text paste', + 'settings.openchamber.visual.field.largeTextPasteHint': 'When pasting more than about 2,000 characters or 25 lines, choose whether to attach the text as a file, paste it inline, or ask each time.', + 'settings.openchamber.visual.field.largeTextPasteAria': 'Large text paste behavior', + 'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Large text paste: {option}', + 'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Ask each time', + 'settings.openchamber.visual.option.largeTextPaste.attach.label': 'Attach as file', + 'settings.openchamber.visual.option.largeTextPaste.inline.label': 'Paste inline', 'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': 'Send anonymous usage reports', 'settings.openchamber.visual.field.sendAnonymousUsageReports': 'Send anonymous usage reports', 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'Helps us understand which app versions are actively used so we can prioritize improvements. Only app version, platform, and runtime are collected - no personal data or code.', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 740994e7..e50a93c4 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -130,6 +130,7 @@ export const dict = { 'mobile.sessions.section.worktrees': 'Worktrees', 'mobile.sessions.section.otherProjects': 'Switch project', 'mobile.sessions.section.projects': 'Projects', + 'mobile.sessions.section.chats': 'Chats', 'mobile.sessions.empty.noProjectsTitle': 'No projects yet', 'mobile.sessions.empty.noProjectsDescription': 'Add a project to start chatting with your code.', 'mobile.sessions.empty.noSessionsTitle': 'No sessions yet', @@ -1307,6 +1308,11 @@ export const dict = { 'contextPanel.browser.annotate.submit': 'Attach', 'contextPanel.browser.trustNotice': 'Pages opened here run with full access to OpenChamber — needed for inspect and screenshots. Only open sites you trust: a malicious page could read your data or act on your behalf.', 'contextPanel.tab.closeTabAria': 'Close {label} tab', + 'contextPanel.tab.menu.close': 'Close', + 'contextPanel.tab.menu.closeOthers': 'Close others', + 'contextPanel.tab.menu.closeToLeft': 'Close tabs to the left', + 'contextPanel.tab.menu.closeToRight': 'Close tabs to the right', + 'contextPanel.tab.menu.closeAll': 'Close all tabs', 'contextPanel.actions.collapsePanel': 'Collapse panel', 'contextPanel.actions.expandPanel': 'Expand panel', 'contextPanel.actions.closePanel': 'Close panel', @@ -1438,6 +1444,12 @@ export const dict = { 'filesView.editor.disableLineWrap': 'Disable line wrap', 'filesView.editor.enableLineWrap': 'Enable line wrap', 'filesView.editor.findInFile': 'Find in file', + 'filesView.preview.find.placeholder': 'Find in preview', + 'filesView.preview.find.nextAria': 'Next match', + 'filesView.preview.find.previousAria': 'Previous match', + 'filesView.preview.find.closeAria': 'Close search', + 'filesView.preview.find.noMatches': 'No matches', + 'filesView.preview.find.countAria': '{current} of {total}', 'filesView.editor.goToLine': 'Go to line', 'filesView.editor.switchToEditMode': 'Switch to edit mode', 'filesView.editor.switchToPreviewMode': 'Switch to preview mode', @@ -2327,6 +2339,10 @@ export const dict = { 'chat.chatInput.toast.messageSendFailed': 'Message failed to send. Attachments restored.', 'chat.chatInput.toast.noModelSelected': 'Select a provider and model before sending.', 'chat.chatInput.toast.clipboardAttachFailed': 'Failed to attach image from clipboard', + 'chat.chatInput.toast.clipboardTextAttachFailed': 'Failed to attach pasted text as a file', + 'chat.chatInput.toast.largeTextPaste.title': 'Large text detected', + 'chat.chatInput.toast.largeTextPaste.attach': 'Attach as file', + 'chat.chatInput.toast.largeTextPaste.inline': 'Paste inline', 'chat.chatInput.toast.addedFileMentions': 'Added {count} file mention(s)', 'chat.chatInput.toast.attachFileFailed': 'Failed to attach file', 'chat.chatInput.toast.attachNamedFailed': 'Failed to attach {name}', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 6cf7245c..e5125e1a 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1932,7 +1932,7 @@ export const settingsDict = { "settings.openchamber.visual.section.streaming": "Streaming", "settings.openchamber.visual.field.streamingAutoFollow": "Seguir el contenido nuevo durante el streaming", "settings.openchamber.visual.field.streamingAutoFollowAria": "Seguir automáticamente el contenido nuevo mientras se transmite una respuesta", - "settings.openchamber.visual.field.streamingAutoFollowInfo": "Mientras llega una respuesta, la vista se desplaza hacia el contenido más reciente. Desactívalo para mantener la vista quieta y desplazarte manualmente.", + "settings.openchamber.visual.field.streamingAutoFollowInfo": "Mientras llega una respuesta, la vista se desplaza hacia el contenido más reciente. Desactívalo para mantener la vista quieta y desplazarte manualmente; enviar un mensaje desde la mitad del chat tampoco moverá la vista.", "settings.openchamber.visual.section.messageAppearance": "Apariencia de los mensajes", "settings.openchamber.visual.section.toolsAndFiles": "Herramientas y archivos", "settings.openchamber.visual.section.composer": "Compositor", @@ -2062,6 +2062,13 @@ export const settingsDict = { "settings.openchamber.visual.field.persistDraftMessages": "Conservar borradores de mensajes", "settings.openchamber.visual.field.enableSpellcheckInTextInputsAria": "Habilitar ortografía en campos de texto", "settings.openchamber.visual.field.enableSpellcheckInTextInputs": "Habilitar ortografía en campos de texto", + "settings.openchamber.visual.field.largeTextPaste": "Pegado de texto grande", + "settings.openchamber.visual.field.largeTextPasteHint": "Al pegar más de unos 2000 caracteres o 25 líneas, elige si adjuntar el texto como archivo, pegarlo en línea o preguntar cada vez.", + "settings.openchamber.visual.field.largeTextPasteAria": "Comportamiento del pegado de texto grande", + "settings.openchamber.visual.field.largeTextPasteOptionAria": "Pegado de texto grande: {option}", + "settings.openchamber.visual.option.largeTextPaste.ask.label": "Preguntar cada vez", + "settings.openchamber.visual.option.largeTextPaste.attach.label": "Adjuntar como archivo", + "settings.openchamber.visual.option.largeTextPaste.inline.label": "Pegar en línea", "settings.openchamber.visual.field.sendAnonymousUsageReportsAria": "Enviar informes anónimos de uso", "settings.openchamber.visual.field.sendAnonymousUsageReports": "Enviar informes anónimos de uso", "settings.openchamber.visual.field.sendAnonymousUsageReportsHint": "Nos ayuda a entender qué versiones de la aplicación se usan activamente para priorizar mejoras. Solo se recopilan la versión de la aplicación, la plataforma y el entorno de ejecución ; no se recopilan datos personales ni código.", diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 2b6c39cd..02f74de9 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -131,6 +131,7 @@ export const dict: Record<I18nKey, string> = { "mobile.sessions.section.worktrees": "Worktrees", "mobile.sessions.section.otherProjects": "Cambiar de proyecto", "mobile.sessions.section.projects": "Proyectos", + "mobile.sessions.section.chats": "Chats", "mobile.sessions.empty.noProjectsTitle": "Sin proyectos", "mobile.sessions.empty.noProjectsDescription": "Agrega un proyecto para empezar a chatear con tu código.", "mobile.sessions.empty.noSessionsTitle": "Sin sesiones", @@ -1308,6 +1309,11 @@ export const dict: Record<I18nKey, string> = { "contextPanel.browser.annotate.submit": "Adjuntar", "contextPanel.browser.trustNotice": "Las páginas que abras aquí se ejecutan con acceso completo a OpenChamber: necesario para la inspección y las capturas. Abre solo sitios de confianza: una página maliciosa podría leer tus datos o actuar en tu nombre.", "contextPanel.tab.closeTabAria": "Cerrar pestaña {label}", + "contextPanel.tab.menu.close": "Cerrar", + "contextPanel.tab.menu.closeOthers": "Cerrar otras", + "contextPanel.tab.menu.closeToLeft": "Cerrar pestañas a la izquierda", + "contextPanel.tab.menu.closeToRight": "Cerrar pestañas a la derecha", + "contextPanel.tab.menu.closeAll": "Cerrar todas las pestañas", "contextPanel.actions.collapsePanel": "Colapsar panel", "contextPanel.actions.expandPanel": "Expandir panel", "contextPanel.actions.closePanel": "Cerrar panel", @@ -1404,6 +1410,12 @@ export const dict: Record<I18nKey, string> = { "filesView.editor.disableLineWrap": "Desactivar ajuste de línea", "filesView.editor.enableLineWrap": "Activar ajuste de línea", "filesView.editor.findInFile": "Buscar en el archivo", + "filesView.preview.find.placeholder": "Buscar en la vista previa", + "filesView.preview.find.nextAria": "Siguiente coincidencia", + "filesView.preview.find.previousAria": "Coincidencia anterior", + "filesView.preview.find.closeAria": "Cerrar búsqueda", + "filesView.preview.find.noMatches": "Sin coincidencias", + "filesView.preview.find.countAria": "{current} de {total}", "filesView.editor.goToLine": "Ir a línea", "filesView.editor.switchToEditMode": "Cambiar al modo de edición", "filesView.editor.switchToPreviewMode": "Cambiar al modo de vista previa", @@ -2293,6 +2305,10 @@ export const dict: Record<I18nKey, string> = { "chat.chatInput.toast.messageSendFailed": "El mensaje no se pudo enviar. Los adjuntos se restauraron.", "chat.chatInput.toast.noModelSelected": "Selecciona un proveedor y un modelo antes de enviar.", "chat.chatInput.toast.clipboardAttachFailed": "No se pudo adjuntar la imagen desde el portapapeles", + "chat.chatInput.toast.clipboardTextAttachFailed": "No se pudo adjuntar el texto pegado como archivo", + "chat.chatInput.toast.largeTextPaste.title": "Texto grande detectado", + "chat.chatInput.toast.largeTextPaste.attach": "Adjuntar como archivo", + "chat.chatInput.toast.largeTextPaste.inline": "Pegar en línea", "chat.chatInput.toast.addedFileMentions": "Se añadieron {count} mención(es) de archivo", "chat.chatInput.toast.attachFileFailed": "No se pudo adjuntar el archivo", "chat.chatInput.toast.attachNamedFailed": "No se pudo adjuntar {name}", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 4e1af53b..7d374725 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1846,7 +1846,7 @@ export const settingsDict = { 'settings.openchamber.visual.section.streaming': 'Streaming', 'settings.openchamber.visual.field.streamingAutoFollow': 'Suivre le nouveau contenu pendant le streaming', 'settings.openchamber.visual.field.streamingAutoFollowAria': 'Suivre automatiquement le nouveau contenu pendant la diffusion d’une réponse', - 'settings.openchamber.visual.field.streamingAutoFollowInfo': 'Pendant qu’une réponse arrive, la vue glisse vers le contenu le plus récent. Désactivez pour garder la vue immobile et défiler manuellement.', + 'settings.openchamber.visual.field.streamingAutoFollowInfo': 'Pendant qu’une réponse arrive, la vue glisse vers le contenu le plus récent. Désactivez pour garder la vue immobile et défiler manuellement ; envoyer un message depuis le milieu de la conversation laisse alors aussi la vue en place.', 'settings.openchamber.visual.section.messageAppearance': 'Apparence des messages', 'settings.openchamber.visual.section.toolsAndFiles': 'Outils et fichiers', 'settings.openchamber.visual.section.composer': 'Zone de saisie', @@ -1967,6 +1967,13 @@ export const settingsDict = { 'settings.openchamber.visual.field.persistDraftMessages': 'Conserver les brouillons de messages', 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'Activer la vérification orthographique dans les saisies de texte', 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Activer la vérification orthographique dans les entrées de texte', + 'settings.openchamber.visual.field.largeTextPaste': 'Collage de texte volumineux', + 'settings.openchamber.visual.field.largeTextPasteHint': 'Lors d’un collage de plus d’environ 2 000 caractères ou 25 lignes, choisir de joindre le texte comme fichier, de le coller en ligne ou de demander à chaque fois.', + 'settings.openchamber.visual.field.largeTextPasteAria': 'Comportement du collage de texte volumineux', + 'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Collage de texte volumineux : {option}', + 'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Demander à chaque fois', + 'settings.openchamber.visual.option.largeTextPaste.attach.label': 'Joindre comme fichier', + 'settings.openchamber.visual.option.largeTextPaste.inline.label': 'Coller en ligne', 'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': 'Envoyer des rapports d\'utilisation anonymes', 'settings.openchamber.visual.field.sendAnonymousUsageReports': 'Envoyer des rapports d\'utilisation anonymes', 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'Nous aide à comprendre quelles versions de l\'application sont activement utilisées afin que nous puissions prioriser les améliorations. Seules la version de l’application, la plate-forme et le runtime sont collectés – aucune donnée personnelle ni code.', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 2d1489fc..18d3634d 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1075,6 +1075,11 @@ export const dict = { 'contextPanel.browser.empty': 'Navigateur Internet', 'contextPanel.browser.emptyHint': 'Entrez une adresse ci-dessus pour commencer à naviguer sur le Web', 'contextPanel.tab.closeTabAria': 'Fermer l\'onglet {label}', + 'contextPanel.tab.menu.close': 'Fermer', + 'contextPanel.tab.menu.closeOthers': 'Fermer les autres', + 'contextPanel.tab.menu.closeToLeft': 'Fermer les onglets à gauche', + 'contextPanel.tab.menu.closeToRight': 'Fermer les onglets à droite', + 'contextPanel.tab.menu.closeAll': 'Fermer tous les onglets', 'contextPanel.actions.collapsePanel': 'Réduire le panneau', 'contextPanel.actions.expandPanel': 'Agrandir le panneau', 'contextPanel.actions.closePanel': 'Fermer le panneau', @@ -1205,6 +1210,12 @@ export const dict = { 'filesView.editor.disableLineWrap': 'Désactiver le retour à la ligne', 'filesView.editor.enableLineWrap': 'Activer le retour à la ligne', 'filesView.editor.findInFile': 'Rechercher dans le fichier', + 'filesView.preview.find.placeholder': 'Rechercher dans l\'aperçu', + 'filesView.preview.find.nextAria': 'Correspondance suivante', + 'filesView.preview.find.previousAria': 'Correspondance précédente', + 'filesView.preview.find.closeAria': 'Fermer la recherche', + 'filesView.preview.find.noMatches': 'Aucune correspondance', + 'filesView.preview.find.countAria': '{current} sur {total}', 'filesView.editor.goToLine': 'Aller à la ligne', 'filesView.editor.switchToEditMode': 'Passer en mode édition', 'filesView.editor.switchToPreviewMode': 'Passer en mode aperçu', @@ -2040,6 +2051,10 @@ export const dict = { 'chat.chatInput.toast.messageSendFailed': 'Le message n\'a pas pu être envoyé. Pièces jointes restaurées.', 'chat.chatInput.toast.noModelSelected': 'Sélectionnez un fournisseur et un modèle avant d\'envoyer.', 'chat.chatInput.toast.clipboardAttachFailed': 'Échec de la pièce jointe de l\'image du presse-papiers', + 'chat.chatInput.toast.clipboardTextAttachFailed': 'Échec de la pièce jointe du texte collé comme fichier', + 'chat.chatInput.toast.largeTextPaste.title': 'Texte volumineux détecté', + 'chat.chatInput.toast.largeTextPaste.attach': 'Joindre comme fichier', + 'chat.chatInput.toast.largeTextPaste.inline': 'Coller en ligne', 'chat.chatInput.toast.addedFileMentions': 'Ajout des mentions du fichier {count}', 'chat.chatInput.toast.attachFileFailed': 'Impossible de joindre le fichier', 'chat.chatInput.toast.attachNamedFailed': 'Échec de la connexion du {name}', @@ -2920,6 +2935,7 @@ export const dict = { 'mobile.sessions.section.worktrees': 'Worktrees', 'mobile.sessions.section.otherProjects': 'Changer de projet', 'mobile.sessions.section.projects': 'Projets', + 'mobile.sessions.section.chats': 'Discussions', 'mobile.sessions.empty.noProjectsTitle': 'Aucun projet pour le moment', 'mobile.sessions.empty.noProjectsDescription': 'Ajoutez un projet pour commencer à discuter avec votre code.', 'mobile.sessions.empty.noSessionsTitle': 'Aucune session pour le moment', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 4f664809..1dcf5291 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1965,7 +1965,7 @@ export const settingsDict = { 'settings.openchamber.visual.section.streaming': 'ストリーミング', 'settings.openchamber.visual.field.streamingAutoFollow': '応答のストリーミング中に新しい内容を追従', 'settings.openchamber.visual.field.streamingAutoFollowAria': '応答のストリーミング中に新しい内容へ自動スクロールする', - 'settings.openchamber.visual.field.streamingAutoFollowInfo': '応答の受信中、ビューは常に最新の内容へスクロールします。オフにするとビューは動かず、手動でスクロールできます。', + 'settings.openchamber.visual.field.streamingAutoFollowInfo': '応答の受信中、ビューは常に最新の内容へスクロールします。オフにするとビューは動かず、手動でスクロールできます。その場合、チャットの途中からメッセージを送信してもビューは移動しません。', 'settings.openchamber.visual.section.messageAppearance': 'メッセージの外観', 'settings.openchamber.visual.section.toolsAndFiles': 'ツールとファイル', 'settings.openchamber.visual.section.composer': '入力欄', @@ -2095,6 +2095,13 @@ export const settingsDict = { 'settings.openchamber.visual.field.persistDraftMessages': '下書きメッセージを保持', 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'テキスト入力のスペルチェックを有効化', 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'テキスト入力のスペルチェックを有効化', + 'settings.openchamber.visual.field.largeTextPaste': '大きなテキストの貼り付け', + 'settings.openchamber.visual.field.largeTextPasteHint': '約 2,000 文字または 25 行を超えるテキストを貼り付けるとき、ファイルとして添付するか、そのまま貼り付けるか、毎回確認するかを選べます。', + 'settings.openchamber.visual.field.largeTextPasteAria': '大きなテキスト貼り付けの動作', + 'settings.openchamber.visual.field.largeTextPasteOptionAria': '大きなテキストの貼り付け: {option}', + 'settings.openchamber.visual.option.largeTextPaste.ask.label': '毎回確認する', + 'settings.openchamber.visual.option.largeTextPaste.attach.label': 'ファイルとして添付', + 'settings.openchamber.visual.option.largeTextPaste.inline.label': 'そのまま貼り付け', 'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': '匿名使用状況レポートを送信', 'settings.openchamber.visual.field.sendAnonymousUsageReports': '匿名使用状況レポートを送信', 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'どのアプリバージョンがアクティブに使用されているかを把握し、改善の優先順位を決めるのに役立ちます。収集されるのはアプリバージョン、プラットフォーム、ランタイムのみで、個人データやコードは収集されません。', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 7aabdc4c..c3b2ccff 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -131,6 +131,7 @@ export const dict: Record<I18nKey, string> = { 'mobile.sessions.section.worktrees': 'ワークツリー', 'mobile.sessions.section.otherProjects': 'プロジェクトを切り替え', 'mobile.sessions.section.projects': 'プロジェクト', + 'mobile.sessions.section.chats': 'チャット', 'mobile.sessions.empty.noProjectsTitle': 'まだプロジェクトがありません', 'mobile.sessions.empty.noProjectsDescription': 'プロジェクトを追加してコードとチャットを始めましょう。', 'mobile.sessions.empty.noSessionsTitle': 'まだセッションがありません', @@ -1304,6 +1305,11 @@ export const dict: Record<I18nKey, string> = { 'contextPanel.browser.annotate.submit': '添付', 'contextPanel.browser.trustNotice': 'ここで開かれたページはOpenChamberへの完全なアクセス権を持ちます — 検査とスクリーンショットに必要です。信頼できるサイトのみを開いてください: 悪意のあるページがデータを読み取ったりあなたの代わりに行動したりする可能性があります。', 'contextPanel.tab.closeTabAria': '{label}タブを閉じる', + 'contextPanel.tab.menu.close': '閉じる', + 'contextPanel.tab.menu.closeOthers': '他を閉じる', + 'contextPanel.tab.menu.closeToLeft': '左のタブを閉じる', + 'contextPanel.tab.menu.closeToRight': '右のタブを閉じる', + 'contextPanel.tab.menu.closeAll': 'すべてのタブを閉じる', 'contextPanel.actions.collapsePanel': 'パネルを折りたたむ', 'contextPanel.actions.expandPanel': 'パネルを展開', 'contextPanel.actions.closePanel': 'パネルを閉じる', @@ -1434,6 +1440,12 @@ export const dict: Record<I18nKey, string> = { 'filesView.editor.disableLineWrap': '行の折り返しを無効にする', 'filesView.editor.enableLineWrap': '行の折り返しを有効にする', 'filesView.editor.findInFile': 'ファイル内を検索', + 'filesView.preview.find.placeholder': 'プレビュー内を検索', + 'filesView.preview.find.nextAria': '次の一致', + 'filesView.preview.find.previousAria': '前の一致', + 'filesView.preview.find.closeAria': '検索を閉じる', + 'filesView.preview.find.noMatches': '一致なし', + 'filesView.preview.find.countAria': '{total}件中{current}件目', 'filesView.editor.goToLine': '指定行に移動', 'filesView.editor.switchToEditMode': '編集モードに切り替え', 'filesView.editor.switchToPreviewMode': 'プレビューモードに切り替え', @@ -2323,6 +2335,10 @@ export const dict: Record<I18nKey, string> = { 'chat.chatInput.toast.messageSendFailed': 'メッセージの送信に失敗しました。添付ファイルは復元されました。', 'chat.chatInput.toast.noModelSelected': '送信する前にプロバイダーとモデルを選択してください。', 'chat.chatInput.toast.clipboardAttachFailed': 'クリップボードからの画像添付に失敗しました', + 'chat.chatInput.toast.clipboardTextAttachFailed': '貼り付けたテキストのファイル添付に失敗しました', + 'chat.chatInput.toast.largeTextPaste.title': '大きなテキストを検出', + 'chat.chatInput.toast.largeTextPaste.attach': 'ファイルとして添付', + 'chat.chatInput.toast.largeTextPaste.inline': 'そのまま貼り付け', 'chat.chatInput.toast.addedFileMentions': '{count}件のファイルメンションを追加しました', 'chat.chatInput.toast.attachFileFailed': 'ファイルの添付に失敗しました', 'gitView.commit.aiHighlights.insertAria': '挿入のariaラベル', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 32d73070..ef0d8e6a 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1932,7 +1932,7 @@ export const settingsDict = { 'settings.openchamber.visual.section.streaming': '스트리밍', 'settings.openchamber.visual.field.streamingAutoFollow': '스트리밍 중 새 내용 따라가기', 'settings.openchamber.visual.field.streamingAutoFollowAria': '응답 스트리밍 중 새 내용으로 자동 스크롤', - 'settings.openchamber.visual.field.streamingAutoFollowInfo': '응답이 스트리밍되는 동안 화면이 최신 내용으로 계속 이동합니다. 끄면 화면이 고정되어 직접 스크롤할 수 있습니다.', + 'settings.openchamber.visual.field.streamingAutoFollowInfo': '응답이 스트리밍되는 동안 화면이 최신 내용으로 계속 이동합니다. 끄면 화면이 고정되어 직접 스크롤할 수 있으며, 채팅 중간에서 메시지를 보내도 화면이 이동하지 않습니다.', 'settings.openchamber.visual.section.messageAppearance': '메시지 모양', 'settings.openchamber.visual.section.toolsAndFiles': '도구 및 파일', 'settings.openchamber.visual.section.composer': '입력창', @@ -2062,6 +2062,13 @@ export const settingsDict = { 'settings.openchamber.visual.field.persistDraftMessages': '초안 메시지 유지', 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': '텍스트 입력에서 맞춤법 검사 활성화', 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': '텍스트 입력에서 맞춤법 검사 활성화', + 'settings.openchamber.visual.field.largeTextPaste': '긴 텍스트 붙여넣기', + 'settings.openchamber.visual.field.largeTextPasteHint': '약 2,000자 또는 25줄을 넘는 텍스트를 붙여넣을 때 파일로 첨부할지, 본문에 붙여넣을지, 매번 물어볼지 선택합니다.', + 'settings.openchamber.visual.field.largeTextPasteAria': '긴 텍스트 붙여넣기 동작', + 'settings.openchamber.visual.field.largeTextPasteOptionAria': '긴 텍스트 붙여넣기: {option}', + 'settings.openchamber.visual.option.largeTextPaste.ask.label': '매번 묻기', + 'settings.openchamber.visual.option.largeTextPaste.attach.label': '파일로 첨부', + 'settings.openchamber.visual.option.largeTextPaste.inline.label': '본문에 붙여넣기', 'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': '익명 사용량 보고서 보내기', 'settings.openchamber.visual.field.sendAnonymousUsageReports': '익명 사용량 보고서 보내기', 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': '활성 사용 앱 버전을 파악해 개선 우선순위를 정하는 데 도움이 됩니다. 앱 버전, 플랫폼, 런타임만 수집되며 개인 데이터나 코드는 수집되지 않습니다.', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index f96567d5..dacdea11 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -131,6 +131,7 @@ export const dict: Record<I18nKey, string> = { 'mobile.sessions.section.worktrees': '워크트리', 'mobile.sessions.section.otherProjects': '프로젝트 전환', 'mobile.sessions.section.projects': '프로젝트', + 'mobile.sessions.section.chats': '채팅', 'mobile.sessions.empty.noProjectsTitle': '프로젝트 없음', 'mobile.sessions.empty.noProjectsDescription': '코드와 채팅을 시작하려면 프로젝트를 추가하세요.', 'mobile.sessions.empty.noSessionsTitle': '세션 없음', @@ -1357,6 +1358,11 @@ export const dict: Record<I18nKey, string> = { 'chat.messageBody.actions.openPreviewAria': '미리보기 열기', 'chat.messageBody.actions.openPreview': '미리보기 열기', 'contextPanel.tab.closeTabAria': '{label} 탭 닫기', + 'contextPanel.tab.menu.close': '닫기', + 'contextPanel.tab.menu.closeOthers': '다른 탭 닫기', + 'contextPanel.tab.menu.closeToLeft': '왼쪽 탭 닫기', + 'contextPanel.tab.menu.closeToRight': '오른쪽 탭 닫기', + 'contextPanel.tab.menu.closeAll': '모든 탭 닫기', 'contextPanel.actions.collapsePanel': '접기 패널', 'contextPanel.actions.expandPanel': '펼치기 패널', 'contextPanel.actions.closePanel': '패널 닫기', @@ -1440,6 +1446,12 @@ export const dict: Record<I18nKey, string> = { 'filesView.editor.disableLineWrap': '줄 바꿈 끄기', 'filesView.editor.enableLineWrap': '줄 바꿈 켜기', 'filesView.editor.findInFile': '파일에서 찾기', + 'filesView.preview.find.placeholder': '미리보기에서 찾기', + 'filesView.preview.find.nextAria': '다음 일치 항목', + 'filesView.preview.find.previousAria': '이전 일치 항목', + 'filesView.preview.find.closeAria': '검색 닫기', + 'filesView.preview.find.noMatches': '일치 항목 없음', + 'filesView.preview.find.countAria': '{total}개 중 {current}번째', 'filesView.editor.goToLine': '줄로 이동', 'filesView.editor.switchToEditMode': '편집 모드로 전환', 'filesView.editor.switchToPreviewMode': '미리보기 모드로 전환', @@ -2327,6 +2339,10 @@ export const dict: Record<I18nKey, string> = { 'chat.chatInput.toast.messageSendFailed': '메시지 전송에 실패했습니다. 첨부 파일을 복원했습니다.', 'chat.chatInput.toast.noModelSelected': '전송하기 전에 제공업체와 모델을 선택하세요.', 'chat.chatInput.toast.clipboardAttachFailed': '클립보드 이미지 첨부 실패', + 'chat.chatInput.toast.clipboardTextAttachFailed': '붙여넣은 텍스트를 파일로 첨부하지 못했습니다', + 'chat.chatInput.toast.largeTextPaste.title': '긴 텍스트 감지됨', + 'chat.chatInput.toast.largeTextPaste.attach': '파일로 첨부', + 'chat.chatInput.toast.largeTextPaste.inline': '본문에 붙여넣기', 'chat.chatInput.toast.addedFileMentions': '파일 멘션 {count}개 추가됨', 'chat.chatInput.toast.attachFileFailed': '첨부 파일 실패', 'chat.chatInput.toast.attachNamedFailed': '첨부 {name} 실패', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 6ca6a1f1..b6953d2b 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1066,6 +1066,13 @@ export const settingsDict = { 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Włącz sprawdzanie pisowni w polach tekstowych', 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'Włącz sprawdzanie pisowni w polach tekstowych', + 'settings.openchamber.visual.field.largeTextPaste': 'Wklejanie dużego tekstu', + 'settings.openchamber.visual.field.largeTextPasteHint': 'Przy wklejaniu ponad około 2000 znaków lub 25 wierszy wybierz, czy dołączyć tekst jako plik, wkleić go w treści, czy pytać za każdym razem.', + 'settings.openchamber.visual.field.largeTextPasteAria': 'Zachowanie przy wklejaniu dużego tekstu', + 'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Wklejanie dużego tekstu: {option}', + 'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Pytaj za każdym razem', + 'settings.openchamber.visual.option.largeTextPaste.attach.label': 'Dołącz jako plik', + 'settings.openchamber.visual.option.largeTextPaste.inline.label': 'Wklej w treści', 'settings.openchamber.visual.field.fontSizePercentageAria': 'Procentowy rozmiar czcionki', 'settings.openchamber.visual.field.inputBarOffset': 'Przesunięcie paska wpisywania', 'settings.openchamber.visual.field.inputBarOffsetTooltip': 'Podnieś pasek wpisywania, aby uniknąć zasłaniania przez systemowe elementy ekranu, takie jak pasek gestów.', @@ -1236,7 +1243,7 @@ export const settingsDict = { 'settings.openchamber.visual.section.streaming': 'Streaming', 'settings.openchamber.visual.field.streamingAutoFollow': 'Podążaj za nową treścią podczas streamingu', 'settings.openchamber.visual.field.streamingAutoFollowAria': 'Automatycznie podążaj za nową treścią podczas streamowania odpowiedzi', - 'settings.openchamber.visual.field.streamingAutoFollowInfo': 'Podczas napływania odpowiedzi widok płynnie podąża za najnowszą treścią. Wyłącz, aby widok pozostał nieruchomy i przewijać ręcznie.', + 'settings.openchamber.visual.field.streamingAutoFollowInfo': 'Podczas napływania odpowiedzi widok płynnie podąża za najnowszą treścią. Wyłącz, aby widok pozostał nieruchomy i przewijać ręcznie; wysłanie wiadomości ze środka czatu również nie przesunie wtedy widoku.', 'settings.openchamber.visual.section.messageAppearance': 'Wygląd wiadomości', 'settings.openchamber.visual.section.toolsAndFiles': 'Narzędzia i pliki', 'settings.openchamber.visual.section.composer': 'Pole wiadomości', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 3dd3046a..95e5828e 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -132,6 +132,7 @@ export const dict: Record<I18nKey, string> = { 'mobile.sessions.section.worktrees': 'Worktrees', 'mobile.sessions.section.otherProjects': 'Zmień projekt', 'mobile.sessions.section.projects': 'Projekty', + 'mobile.sessions.section.chats': 'Czaty', 'mobile.sessions.empty.noProjectsTitle': 'Brak projektów', 'mobile.sessions.empty.noProjectsDescription': 'Dodaj projekt, aby zacząć rozmawiać ze swoim kodem.', 'mobile.sessions.empty.noSessionsTitle': 'Brak sesji', @@ -1299,6 +1300,10 @@ export const dict: Record<I18nKey, string> = { 'chat.chatInput.toast.unsupportedAttachmentModalities': 'Model {model} nie obsługuje danych wejściowych {modalities} wymaganych przez {files}. Nadal możesz wysłać wiadomość, ale te załączniki mogą zostać zignorowane.', 'chat.chatInput.toast.attachmentsTooLarge': 'Załączniki są zbyt duże, aby je wysłać. Spróbuj zmniejszyć liczbę lub rozmiar obrazów.', 'chat.chatInput.toast.clipboardAttachFailed': 'Nie udało się dołączyć obrazu ze schowka', + 'chat.chatInput.toast.clipboardTextAttachFailed': 'Nie udało się dołączyć wklejonego tekstu jako pliku', + 'chat.chatInput.toast.largeTextPaste.title': 'Wykryto duży tekst', + 'chat.chatInput.toast.largeTextPaste.attach': 'Dołącz jako plik', + 'chat.chatInput.toast.largeTextPaste.inline': 'Wklej w treści', 'chat.chatInput.toast.compactFailed': 'Nie udało się skompaktować sesji', 'chat.chatInput.toast.messageSendFailed': 'Nie udało się wysłać wiadomości. Załączniki zostały przywrócone.', 'chat.chatInput.toast.noModelSelected': 'Wybierz dostawcę i model przed wysłaniem.', @@ -1691,6 +1696,11 @@ export const dict: Record<I18nKey, string> = { 'contextPanel.preview.upstreamUnreachable': 'Serwer deweloperski nie odpowiada.', 'contextPanel.preview.upstreamUnreachableHint': 'Upewnij się, że serwer deweloperski nadal działa, a następnie ponów próbę.', 'contextPanel.tab.closeTabAria': 'Zamknij kartę {label}', + 'contextPanel.tab.menu.close': 'Zamknij', + 'contextPanel.tab.menu.closeOthers': 'Zamknij pozostałe', + 'contextPanel.tab.menu.closeToLeft': 'Zamknij karty po lewej', + 'contextPanel.tab.menu.closeToRight': 'Zamknij karty po prawej', + 'contextPanel.tab.menu.closeAll': 'Zamknij wszystkie karty', 'contextSidebar.actions.copied': 'Skopiowano', 'contextSidebar.actions.copy': 'Kopiuj', 'contextSidebar.actions.copyJson': 'Kopiuj JSON', @@ -1954,6 +1964,12 @@ export const dict: Record<I18nKey, string> = { 'filesView.editor.enableLineWrap': 'Włącz zawijanie linii', 'filesView.editor.exitFullscreen': 'Wyjdź z pełnego ekranu', 'filesView.editor.findInFile': 'Znajdź w pliku', + 'filesView.preview.find.placeholder': 'Szukaj w podglądzie', + 'filesView.preview.find.nextAria': 'Następne dopasowanie', + 'filesView.preview.find.previousAria': 'Poprzednie dopasowanie', + 'filesView.preview.find.closeAria': 'Zamknij wyszukiwanie', + 'filesView.preview.find.noMatches': 'Brak dopasowań', + 'filesView.preview.find.countAria': '{current} z {total}', 'filesView.editor.fullscreen': 'Pełny ekran', 'filesView.editor.goToLine': 'Przejdź do linii', 'filesView.editor.htmlPreviewTitle': 'Podgląd HTML', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 47ccf4f8..276df6df 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1932,7 +1932,7 @@ export const settingsDict = { "settings.openchamber.visual.section.streaming": "Streaming", "settings.openchamber.visual.field.streamingAutoFollow": "Seguir o novo conteúdo durante o streaming", "settings.openchamber.visual.field.streamingAutoFollowAria": "Seguir automaticamente o novo conteúdo enquanto uma resposta é transmitida", - "settings.openchamber.visual.field.streamingAutoFollowInfo": "Enquanto uma resposta chega, a visualização acompanha o conteúdo mais recente. Desative para manter a visualização parada e rolar manualmente.", + "settings.openchamber.visual.field.streamingAutoFollowInfo": "Enquanto uma resposta chega, a visualização acompanha o conteúdo mais recente. Desative para manter a visualização parada e rolar manualmente; enviar uma mensagem do meio da conversa também deixará a visualização onde está.", "settings.openchamber.visual.section.messageAppearance": "Aparência das mensagens", "settings.openchamber.visual.section.toolsAndFiles": "Ferramentas e arquivos", "settings.openchamber.visual.section.composer": "Campo de mensagem", @@ -2062,6 +2062,13 @@ export const settingsDict = { "settings.openchamber.visual.field.persistDraftMessages": "Manter rascunhos de mensagens", "settings.openchamber.visual.field.enableSpellcheckInTextInputsAria": "Ativar ortografia em campos de texto", "settings.openchamber.visual.field.enableSpellcheckInTextInputs": "Ativar ortografia em campos de texto", + "settings.openchamber.visual.field.largeTextPaste": "Colagem de texto grande", + "settings.openchamber.visual.field.largeTextPasteHint": "Ao colar mais de cerca de 2.000 caracteres ou 25 linhas, escolha anexar o texto como arquivo, colar no corpo da mensagem ou perguntar sempre.", + "settings.openchamber.visual.field.largeTextPasteAria": "Comportamento da colagem de texto grande", + "settings.openchamber.visual.field.largeTextPasteOptionAria": "Colagem de texto grande: {option}", + "settings.openchamber.visual.option.largeTextPaste.ask.label": "Perguntar sempre", + "settings.openchamber.visual.option.largeTextPaste.attach.label": "Anexar como arquivo", + "settings.openchamber.visual.option.largeTextPaste.inline.label": "Colar no corpo", "settings.openchamber.visual.field.sendAnonymousUsageReportsAria": "Enviar relatórios anônimos de uso", "settings.openchamber.visual.field.sendAnonymousUsageReports": "Enviar relatórios anônimos de uso", "settings.openchamber.visual.field.sendAnonymousUsageReportsHint": "Ajuda-nos a entender quais versões do aplicativo são usadas ativamente para priorizar melhorias. Coletamos apenas a versão do aplicativo, a plataforma e o ambiente de execução; não coletamos dados pessoais nem código.", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index cc95fbd8..c7afaf00 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -131,6 +131,7 @@ export const dict: Record<I18nKey, string> = { "mobile.sessions.section.worktrees": "Worktrees", "mobile.sessions.section.otherProjects": "Trocar de projeto", "mobile.sessions.section.projects": "Projetos", + "mobile.sessions.section.chats": "Conversas", "mobile.sessions.empty.noProjectsTitle": "Sem projetos", "mobile.sessions.empty.noProjectsDescription": "Adicione um projeto para começar a conversar com seu código.", "mobile.sessions.empty.noSessionsTitle": "Sem sessões", @@ -1308,6 +1309,11 @@ export const dict: Record<I18nKey, string> = { "contextPanel.browser.annotate.submit": "Anexar", "contextPanel.browser.trustNotice": "As páginas abertas aqui são executadas com acesso total ao OpenChamber — necessário para inspeção e capturas de tela. Abra apenas sites confiáveis: uma página maliciosa pode ler seus dados ou agir em seu nome.", "contextPanel.tab.closeTabAria": "Fechar aba {label}", + "contextPanel.tab.menu.close": "Fechar", + "contextPanel.tab.menu.closeOthers": "Fechar outras", + "contextPanel.tab.menu.closeToLeft": "Fechar abas à esquerda", + "contextPanel.tab.menu.closeToRight": "Fechar abas à direita", + "contextPanel.tab.menu.closeAll": "Fechar todas as abas", "contextPanel.actions.collapsePanel": "Recolher painel", "contextPanel.actions.expandPanel": "Expandir painel", "contextPanel.actions.closePanel": "Fechar painel", @@ -1404,6 +1410,12 @@ export const dict: Record<I18nKey, string> = { "filesView.editor.disableLineWrap": "Desativar ajuste de linha", "filesView.editor.enableLineWrap": "Ativar ajuste de linha", "filesView.editor.findInFile": "Buscar no arquivo", + "filesView.preview.find.placeholder": "Buscar na pré-visualização", + "filesView.preview.find.nextAria": "Próxima correspondência", + "filesView.preview.find.previousAria": "Correspondência anterior", + "filesView.preview.find.closeAria": "Fechar busca", + "filesView.preview.find.noMatches": "Sem correspondências", + "filesView.preview.find.countAria": "{current} de {total}", "filesView.editor.goToLine": "Ir para linha", "filesView.editor.switchToEditMode": "Alternar para o modo de edição", "filesView.editor.switchToPreviewMode": "Alternar para o modo de visualização", @@ -2293,6 +2305,10 @@ export const dict: Record<I18nKey, string> = { "chat.chatInput.toast.messageSendFailed": "A mensagem não pôde ser enviada. Os anexos foram restaurados.", "chat.chatInput.toast.noModelSelected": "Selecione um provedor e um modelo antes de enviar.", "chat.chatInput.toast.clipboardAttachFailed": "Não foi possível anexar a imagem da área de transferência", + "chat.chatInput.toast.clipboardTextAttachFailed": "Não foi possível anexar o texto colado como arquivo", + "chat.chatInput.toast.largeTextPaste.title": "Texto grande detectado", + "chat.chatInput.toast.largeTextPaste.attach": "Anexar como arquivo", + "chat.chatInput.toast.largeTextPaste.inline": "Colar no corpo", "chat.chatInput.toast.addedFileMentions": "Foram adicionadas {count} menção(es) de arquivo", "chat.chatInput.toast.attachFileFailed": "Não foi possível anexar o arquivo", "chat.chatInput.toast.attachNamedFailed": "Não foi possível anexar {name}", diff --git a/packages/ui/src/lib/i18n/messages/tr.settings.ts b/packages/ui/src/lib/i18n/messages/tr.settings.ts index 9095f19c..34125588 100644 --- a/packages/ui/src/lib/i18n/messages/tr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr.settings.ts @@ -2000,6 +2000,13 @@ export const settingsDict = { 'settings.openchamber.visual.field.queueMessagesByDefaultTooltip': 'Etkinleştirildiğinde Enter mesajları kuyruğa ekler. Göndermek için {modifier}+Enter kullanın.', 'settings.openchamber.visual.field.persistDraftMessagesAria': 'Taslak mesajları kalıcı olarak sakla', 'settings.openchamber.visual.field.persistDraftMessages': 'Taslak mesajları kalıcı olarak sakla', + 'settings.openchamber.visual.field.largeTextPaste': 'Büyük metin yapıştırma', + 'settings.openchamber.visual.field.largeTextPasteHint': 'Yaklaşık 2.000 karakterden veya 25 satırdan fazlasını yapıştırırken metnin dosya olarak eklenmesini mi, satır içi yapıştırılmasını mı yoksa her seferinde sorulmasını mı istediğinizi seçin.', + 'settings.openchamber.visual.field.largeTextPasteAria': 'Büyük metin yapıştırma davranışı', + 'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Büyük metin yapıştırma: {option}', + 'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Her seferinde sor', + 'settings.openchamber.visual.option.largeTextPaste.attach.label': 'Dosya olarak ekle', + 'settings.openchamber.visual.option.largeTextPaste.inline.label': 'Satır içi yapıştır', 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'Metin girişlerinde yazım denetimini etkinleştir', 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Metin girişlerinde yazım denetimini etkinleştir', 'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': 'Anonim kullanım raporları gönder', @@ -2205,7 +2212,7 @@ export const settingsDict = { 'settings.openchamber.visual.section.streaming': 'Streaming', 'settings.openchamber.visual.field.streamingAutoFollow': 'Streaming sırasında yeni içeriği takip et', 'settings.openchamber.visual.field.streamingAutoFollowAria': 'Bir yanıt akarken yeni içeriği otomatik olarak takip et', - 'settings.openchamber.visual.field.streamingAutoFollowInfo': 'Bir yanıt akarken görünüm en yeni içeriğe doğru kayar. Görünümün sabit kalması için bunu kapatın ve elle kaydırın.', + 'settings.openchamber.visual.field.streamingAutoFollowInfo': 'Bir yanıt akarken görünüm en yeni içeriğe doğru kayar. Görünümün sabit kalması için bunu kapatın ve elle kaydırın; bu durumda sohbetin ortasından mesaj göndermek de görünümü yerinden oynatmaz.', 'settings.openchamber.visual.field.sessionTabsGroup': 'Session sekmeleri', 'settings.openchamber.visual.field.sessionTabs': 'Session\'ları başlıkta sekme olarak göster', 'settings.openchamber.visual.field.sessionTabsAria': 'Başlıktaki session sekmelerini aç/kapat', diff --git a/packages/ui/src/lib/i18n/messages/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts index 4229756a..c10e49cd 100644 --- a/packages/ui/src/lib/i18n/messages/tr.ts +++ b/packages/ui/src/lib/i18n/messages/tr.ts @@ -117,6 +117,7 @@ export const dict = { 'mobile.sessions.section.worktrees': 'Worktree\'ler', 'mobile.sessions.section.otherProjects': 'Proje değiştir', 'mobile.sessions.section.projects': 'Projeler', + 'mobile.sessions.section.chats': 'Sohbetler', 'mobile.sessions.empty.noProjectsTitle': 'Henüz proje yok', 'mobile.sessions.empty.noProjectsDescription': 'Kodunuzla sohbet etmeye başlamak için bir proje ekleyin.', 'mobile.sessions.empty.noSessionsTitle': 'Henüz session yok', @@ -1283,6 +1284,11 @@ export const dict = { 'contextPanel.browser.annotate.submit': 'Ekle', 'contextPanel.browser.trustNotice': 'Burada açılan sayfalar OpenChamber\'a tam erişimle çalışır — inceleme ve ekran görüntüleri için gereklidir. Yalnızca güvendiğiniz siteleri açın: kötü niyetli bir sayfa verilerinizi okuyabilir veya sizin adınıza hareket edebilir.', 'contextPanel.tab.closeTabAria': '{label} sekmesini kapat', + 'contextPanel.tab.menu.close': 'Kapat', + 'contextPanel.tab.menu.closeOthers': 'Diğerlerini kapat', + 'contextPanel.tab.menu.closeToLeft': 'Soldaki sekmeleri kapat', + 'contextPanel.tab.menu.closeToRight': 'Sağdaki sekmeleri kapat', + 'contextPanel.tab.menu.closeAll': 'Tüm sekmeleri kapat', 'contextPanel.actions.collapsePanel': 'Paneli daralt', 'contextPanel.actions.expandPanel': 'Paneli genişlet', 'contextPanel.actions.closePanel': 'Paneli kapat', @@ -1413,6 +1419,12 @@ export const dict = { 'filesView.editor.disableLineWrap': 'Satır kaydırmayı devre dışı bırak', 'filesView.editor.enableLineWrap': 'Satır kaydırmayı etkinleştir', 'filesView.editor.findInFile': 'Dosyada bul', + 'filesView.preview.find.placeholder': 'Önizlemede bul', + 'filesView.preview.find.nextAria': 'Sonraki eşleşme', + 'filesView.preview.find.previousAria': 'Önceki eşleşme', + 'filesView.preview.find.closeAria': 'Aramayı kapat', + 'filesView.preview.find.noMatches': 'Eşleşme yok', + 'filesView.preview.find.countAria': '{total} içinde {current}', 'filesView.editor.goToLine': 'Satıra git', 'filesView.editor.switchToEditMode': 'Düzenleme moduna geç', 'filesView.editor.switchToPreviewMode': 'Önizleme moduna geç', @@ -2267,6 +2279,10 @@ export const dict = { 'chat.chatInput.toast.sendAttachmentsFailed': 'Ekler gönderilemedi. Daha az dosya veya daha küçük görseller deneyin.', 'chat.chatInput.toast.messageSendFailed': 'Mesaj gönderilemedi. Ekler geri yüklendi.', 'chat.chatInput.toast.clipboardAttachFailed': 'Panodan görsel eklenemedi', + 'chat.chatInput.toast.clipboardTextAttachFailed': 'Yapıştırılan metin dosya olarak eklenemedi', + 'chat.chatInput.toast.largeTextPaste.title': 'Büyük metin algılandı', + 'chat.chatInput.toast.largeTextPaste.attach': 'Dosya olarak ekle', + 'chat.chatInput.toast.largeTextPaste.inline': 'Satır içi yapıştır', 'chat.chatInput.toast.addedFileMentions': '{count} dosya bahsi eklendi', 'chat.chatInput.toast.attachFileFailed': 'Dosya eklenemedi', 'chat.chatInput.toast.attachNamedFailed': '{name} eklenemedi', diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index feeafc91..34cdc172 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1932,7 +1932,7 @@ export const settingsDict = { "settings.openchamber.visual.section.streaming": "Стримінг", "settings.openchamber.visual.field.streamingAutoFollow": "Слідкувати за новим вмістом під час стримінгу", "settings.openchamber.visual.field.streamingAutoFollowAria": "Автоматично слідкувати за новим вмістом під час стримінгу відповіді", - "settings.openchamber.visual.field.streamingAutoFollowInfo": "Поки відповідь надходить, вигляд плавно рухається до найновішого вмісту. Вимкніть, щоб вигляд залишався нерухомим і гортати вручну.", + "settings.openchamber.visual.field.streamingAutoFollowInfo": "Поки відповідь надходить, вигляд плавно рухається до найновішого вмісту. Вимкніть, щоб вигляд залишався нерухомим і гортати вручну; тоді й надсилання повідомлення з середини чату не зсуватиме вигляд.", "settings.openchamber.visual.section.messageAppearance": "Вигляд повідомлень", "settings.openchamber.visual.section.toolsAndFiles": "Інструменти та файли", "settings.openchamber.visual.section.composer": "Поле вводу", @@ -2062,6 +2062,13 @@ export const settingsDict = { "settings.openchamber.visual.field.persistDraftMessages": "Зберігати чернетки повідомлень", "settings.openchamber.visual.field.enableSpellcheckInTextInputsAria": "Увімкнути перевірку орфографії під час введення тексту", "settings.openchamber.visual.field.enableSpellcheckInTextInputs": "Увімкнути перевірку орфографії в текстових полях", + "settings.openchamber.visual.field.largeTextPaste": "Вставлення великого тексту", + "settings.openchamber.visual.field.largeTextPasteHint": "Під час вставлення понад приблизно 2000 символів або 25 рядків виберіть, чи долучити текст як файл, вставити його в повідомлення чи запитувати щоразу.", + "settings.openchamber.visual.field.largeTextPasteAria": "Поведінка вставлення великого тексту", + "settings.openchamber.visual.field.largeTextPasteOptionAria": "Вставлення великого тексту: {option}", + "settings.openchamber.visual.option.largeTextPaste.ask.label": "Запитувати щоразу", + "settings.openchamber.visual.option.largeTextPaste.attach.label": "Долучити як файл", + "settings.openchamber.visual.option.largeTextPaste.inline.label": "Вставити в повідомлення", "settings.openchamber.visual.field.sendAnonymousUsageReportsAria": "Надсилати анонімні звіти про використання", "settings.openchamber.visual.field.sendAnonymousUsageReports": "Надсилати анонімні звіти про використання", "settings.openchamber.visual.field.sendAnonymousUsageReportsHint": "Допомагає нам зрозуміти, які версії застосунків активно використовуються, щоб ми могли визначити пріоритети покращень. Збираються лише версія застосунку, платформа та середовище виконання – без особистих даних чи коду.", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index f849cebf..116de513 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -131,6 +131,7 @@ export const dict: Record<I18nKey, string> = { "mobile.sessions.section.worktrees": "Worktrees", "mobile.sessions.section.otherProjects": "Інші проєкти", "mobile.sessions.section.projects": "Проєкти", + "mobile.sessions.section.chats": "Чати", "mobile.sessions.empty.noProjectsTitle": "Ще немає проєктів", "mobile.sessions.empty.noProjectsDescription": "Додай проєкт, щоб почати спілкування з кодом.", "mobile.sessions.empty.noSessionsTitle": "Ще немає сесій", @@ -1308,6 +1309,11 @@ export const dict: Record<I18nKey, string> = { "contextPanel.browser.annotate.submit": "Додати", "contextPanel.browser.trustNotice": "Сторінки, відкриті тут, працюють із повним доступом до OpenChamber — це потрібно для inspect і скріншотів. Відкривайте лише сайти, яким довіряєте: шкідлива сторінка може прочитати ваші дані чи діяти від вашого імені.", "contextPanel.tab.closeTabAria": "Закрити вкладку {label}", + "contextPanel.tab.menu.close": "Закрити", + "contextPanel.tab.menu.closeOthers": "Закрити інші", + "contextPanel.tab.menu.closeToLeft": "Закрити вкладки ліворуч", + "contextPanel.tab.menu.closeToRight": "Закрити вкладки праворуч", + "contextPanel.tab.menu.closeAll": "Закрити всі вкладки", "contextPanel.actions.collapsePanel": "Згорнути панель", "contextPanel.actions.expandPanel": "Розгорнути панель", "contextPanel.actions.closePanel": "Закрити панель", @@ -1404,6 +1410,12 @@ export const dict: Record<I18nKey, string> = { "filesView.editor.disableLineWrap": "Вимкнути перенос рядків", "filesView.editor.enableLineWrap": "Увімкнути перенос рядків", "filesView.editor.findInFile": "Знайти у файлі", + "filesView.preview.find.placeholder": "Пошук у попередньому перегляді", + "filesView.preview.find.nextAria": "Наступний збіг", + "filesView.preview.find.previousAria": "Попередній збіг", + "filesView.preview.find.closeAria": "Закрити пошук", + "filesView.preview.find.noMatches": "Збігів немає", + "filesView.preview.find.countAria": "{current} із {total}", "filesView.editor.goToLine": "Перейти до рядка", "filesView.editor.switchToEditMode": "Перемкнутися в режим редагування", "filesView.editor.switchToPreviewMode": "Перемкнутися в режим попереднього перегляду", @@ -2293,6 +2305,10 @@ export const dict: Record<I18nKey, string> = { "chat.chatInput.toast.messageSendFailed": "Не вдалося надіслати повідомлення. Вкладення відновлено.", "chat.chatInput.toast.noModelSelected": "Виберіть постачальника та модель перед надсиланням.", "chat.chatInput.toast.clipboardAttachFailed": "Не вдалося вкласти зображення з буфера обміну", + "chat.chatInput.toast.clipboardTextAttachFailed": "Не вдалося долучити вставлений текст як файл", + "chat.chatInput.toast.largeTextPaste.title": "Виявлено великий текст", + "chat.chatInput.toast.largeTextPaste.attach": "Долучити як файл", + "chat.chatInput.toast.largeTextPaste.inline": "Вставити в повідомлення", "chat.chatInput.toast.addedFileMentions": "Додано згадки файлів {count}", "chat.chatInput.toast.attachFileFailed": "Не вдалося прикріпити файл", "chat.chatInput.toast.attachNamedFailed": "Не вдалося прикріпити {name}", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 8bebdd26..f910b3d2 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1932,7 +1932,7 @@ export const settingsDict = { 'settings.openchamber.visual.section.streaming': '流式输出', 'settings.openchamber.visual.field.streamingAutoFollow': '流式输出时跟随新内容', 'settings.openchamber.visual.field.streamingAutoFollowAria': '在回复流式输出时自动跟随新内容', - 'settings.openchamber.visual.field.streamingAutoFollowInfo': '回复流式输出时,视图会持续滚动到最新内容。关闭后视图保持不动,可手动滚动。', + 'settings.openchamber.visual.field.streamingAutoFollowInfo': '回复流式输出时,视图会持续滚动到最新内容。关闭后视图保持不动,可手动滚动;此时从聊天中间发送消息也不会移动视图。', 'settings.openchamber.visual.section.messageAppearance': '消息外观', 'settings.openchamber.visual.section.toolsAndFiles': '工具和文件', 'settings.openchamber.visual.section.composer': '输入框', @@ -2062,6 +2062,13 @@ export const settingsDict = { 'settings.openchamber.visual.field.persistDraftMessages': '保留草稿消息', 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': '在文本输入框启用拼写检查', 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': '在文本输入框启用拼写检查', + 'settings.openchamber.visual.field.largeTextPaste': '粘贴大段文本', + 'settings.openchamber.visual.field.largeTextPasteHint': '粘贴超过约 2000 个字符或 25 行时,可选择附加为文件、直接粘贴到输入框,或每次询问。', + 'settings.openchamber.visual.field.largeTextPasteAria': '大段文本粘贴行为', + 'settings.openchamber.visual.field.largeTextPasteOptionAria': '大段文本粘贴:{option}', + 'settings.openchamber.visual.option.largeTextPaste.ask.label': '每次询问', + 'settings.openchamber.visual.option.largeTextPaste.attach.label': '附加为文件', + 'settings.openchamber.visual.option.largeTextPaste.inline.label': '直接粘贴', 'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': '发送匿名使用报告', 'settings.openchamber.visual.field.sendAnonymousUsageReports': '发送匿名使用报告', 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': '帮助我们了解哪些应用版本正在被积极使用,以便优先改进。仅收集应用版本、平台和运行时信息,不收集个人数据或代码。', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index d6b062c5..10afee0e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -131,6 +131,7 @@ export const dict: Record<I18nKey, string> = { 'mobile.sessions.section.worktrees': '工作树', 'mobile.sessions.section.otherProjects': '切换项目', 'mobile.sessions.section.projects': '项目', + 'mobile.sessions.section.chats': '聊天', 'mobile.sessions.empty.noProjectsTitle': '暂无项目', 'mobile.sessions.empty.noProjectsDescription': '添加项目以开始与代码对话。', 'mobile.sessions.empty.noSessionsTitle': '暂无会话', @@ -1308,6 +1309,11 @@ export const dict: Record<I18nKey, string> = { 'contextPanel.browser.annotate.submit': '附加', 'contextPanel.browser.trustNotice': '在此打开的页面以对 OpenChamber 的完全访问权限运行 — 检查和截图需要此权限。仅打开你信任的站点:恶意页面可能读取你的数据或以你的身份执行操作。', 'contextPanel.tab.closeTabAria': '关闭 {label} 标签', + 'contextPanel.tab.menu.close': '关闭', + 'contextPanel.tab.menu.closeOthers': '关闭其他', + 'contextPanel.tab.menu.closeToLeft': '关闭左侧标签', + 'contextPanel.tab.menu.closeToRight': '关闭右侧标签', + 'contextPanel.tab.menu.closeAll': '关闭所有标签', 'contextPanel.actions.collapsePanel': '折叠面板', 'contextPanel.actions.expandPanel': '展开面板', 'contextPanel.actions.closePanel': '关闭面板', @@ -1404,6 +1410,12 @@ export const dict: Record<I18nKey, string> = { 'filesView.editor.disableLineWrap': '关闭自动换行', 'filesView.editor.enableLineWrap': '开启自动换行', 'filesView.editor.findInFile': '文件内查找', + 'filesView.preview.find.placeholder': '在预览中查找', + 'filesView.preview.find.nextAria': '下一个匹配', + 'filesView.preview.find.previousAria': '上一个匹配', + 'filesView.preview.find.closeAria': '关闭搜索', + 'filesView.preview.find.noMatches': '无匹配项', + 'filesView.preview.find.countAria': '第 {current} 个,共 {total} 个', 'filesView.editor.goToLine': '跳转到行', 'filesView.editor.switchToEditMode': '切换到编辑模式', 'filesView.editor.switchToPreviewMode': '切换到预览模式', @@ -2293,6 +2305,10 @@ export const dict: Record<I18nKey, string> = { 'chat.chatInput.toast.messageSendFailed': '消息发送失败,附件已恢复。', 'chat.chatInput.toast.noModelSelected': '发送前请先选择提供商和模型。', 'chat.chatInput.toast.clipboardAttachFailed': '从剪贴板附加图片失败', + 'chat.chatInput.toast.clipboardTextAttachFailed': '无法将粘贴的文本附加为文件', + 'chat.chatInput.toast.largeTextPaste.title': '检测到大段文本', + 'chat.chatInput.toast.largeTextPaste.attach': '附加为文件', + 'chat.chatInput.toast.largeTextPaste.inline': '直接粘贴', 'chat.chatInput.toast.addedFileMentions': '已添加 {count} 个文件提及', 'chat.chatInput.toast.attachFileFailed': '附加文件失败', 'chat.chatInput.toast.attachNamedFailed': '附加 {name} 失败', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index c38af529..fb6e7243 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1839,7 +1839,7 @@ export const settingsDict = { 'settings.openchamber.visual.section.streaming': '串流', 'settings.openchamber.visual.field.streamingAutoFollow': '串流時跟隨新內容', 'settings.openchamber.visual.field.streamingAutoFollowAria': '回覆串流時自動跟隨新內容', - 'settings.openchamber.visual.field.streamingAutoFollowInfo': '回覆串流時,畫面會持續捲動到最新內容。關閉後畫面保持不動,可手動捲動。', + 'settings.openchamber.visual.field.streamingAutoFollowInfo': '回覆串流時,畫面會持續捲動到最新內容。關閉後畫面保持不動,可手動捲動;此時從聊天中間傳送訊息也不會移動畫面。', 'settings.openchamber.visual.section.messageAppearance': '訊息外觀', 'settings.openchamber.visual.section.toolsAndFiles': '工具與檔案', 'settings.openchamber.visual.section.composer': '輸入框', @@ -1969,6 +1969,13 @@ export const settingsDict = { 'settings.openchamber.visual.field.persistDraftMessages': '保留草稿訊息', 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': '在文字輸入方塊啟用拼寫檢查', 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': '在文字輸入方塊啟用拼寫檢查', + 'settings.openchamber.visual.field.largeTextPaste': '貼上大段文字', + 'settings.openchamber.visual.field.largeTextPasteHint': '貼上超過約 2000 個字元或 25 行時,可選擇附加為檔案、直接貼到輸入框,或每次詢問。', + 'settings.openchamber.visual.field.largeTextPasteAria': '大段文字貼上行為', + 'settings.openchamber.visual.field.largeTextPasteOptionAria': '大段文字貼上:{option}', + 'settings.openchamber.visual.option.largeTextPaste.ask.label': '每次詢問', + 'settings.openchamber.visual.option.largeTextPaste.attach.label': '附加為檔案', + 'settings.openchamber.visual.option.largeTextPaste.inline.label': '直接貼上', 'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': '送出匿名使用報告', 'settings.openchamber.visual.field.sendAnonymousUsageReports': '送出匿名使用報告', 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': '協助我們了解哪些應用程式版本仍在被積極使用,以便優先改進。僅收集應用程式版本、平台與執行階段資訊,不收集個人資料或程式碼。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 1fa68075..d60c8e9c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -131,6 +131,7 @@ export const dict: Record<I18nKey, string> = { 'mobile.sessions.section.worktrees': '工作樹', 'mobile.sessions.section.otherProjects': '切換專案', 'mobile.sessions.section.projects': '專案', + 'mobile.sessions.section.chats': '聊天', 'mobile.sessions.empty.noProjectsTitle': '尚無專案', 'mobile.sessions.empty.noProjectsDescription': '新增專案即可開始與程式碼聊天。', 'mobile.sessions.empty.noSessionsTitle': '尚無會話', @@ -1320,6 +1321,11 @@ export const dict: Record<I18nKey, string> = { 'contextPanel.browser.annotate.submit': '附加', 'contextPanel.browser.trustNotice': '在此開啟的頁面以對 OpenChamber 的完整存取權限執行 — 檢查與截圖需要此權限。僅開啟你信任的網站:惡意頁面可能讀取你的資料或以你的身分執行操作。', 'contextPanel.tab.closeTabAria': '關閉 {label} 分頁', + 'contextPanel.tab.menu.close': '關閉', + 'contextPanel.tab.menu.closeOthers': '關閉其他', + 'contextPanel.tab.menu.closeToLeft': '關閉左側分頁', + 'contextPanel.tab.menu.closeToRight': '關閉右側分頁', + 'contextPanel.tab.menu.closeAll': '關閉所有分頁', 'contextPanel.actions.collapsePanel': '摺疊面板', 'contextPanel.actions.expandPanel': '展開面板', 'contextPanel.actions.closePanel': '關閉面板', @@ -1415,6 +1421,12 @@ export const dict: Record<I18nKey, string> = { 'filesView.editor.disableLineWrap': '關閉自動換行', 'filesView.editor.enableLineWrap': '開啟自動換行', 'filesView.editor.findInFile': '檔案內尋找', + 'filesView.preview.find.placeholder': '在預覽中尋找', + 'filesView.preview.find.nextAria': '下一個相符項目', + 'filesView.preview.find.previousAria': '上一個相符項目', + 'filesView.preview.find.closeAria': '關閉搜尋', + 'filesView.preview.find.noMatches': '無相符項目', + 'filesView.preview.find.countAria': '第 {current} 個,共 {total} 個', 'filesView.editor.goToLine': '跳轉到行', 'filesView.editor.switchToEditMode': '切換到編輯模式', 'filesView.editor.switchToPreviewMode': '切換到預覽模式', @@ -2297,6 +2309,10 @@ export const dict: Record<I18nKey, string> = { 'chat.chatInput.toast.messageSendFailed': '訊息傳送失敗,附件已恢復。', 'chat.chatInput.toast.noModelSelected': '傳送前請先選擇提供者與模型。', 'chat.chatInput.toast.clipboardAttachFailed': '從剪貼簿附加圖片失敗', + 'chat.chatInput.toast.clipboardTextAttachFailed': '無法將貼上的文字附加為檔案', + 'chat.chatInput.toast.largeTextPaste.title': '偵測到大段文字', + 'chat.chatInput.toast.largeTextPaste.attach': '附加為檔案', + 'chat.chatInput.toast.largeTextPaste.inline': '直接貼上', 'chat.chatInput.toast.addedFileMentions': '已加入 {count} 個檔案提及', 'chat.chatInput.toast.attachFileFailed': '附加檔案失敗', 'chat.chatInput.toast.attachNamedFailed': '附加 {name} 失敗', diff --git a/packages/ui/src/lib/persistence.test.ts b/packages/ui/src/lib/persistence.test.ts index 61e5c370..41a412b3 100644 --- a/packages/ui/src/lib/persistence.test.ts +++ b/packages/ui/src/lib/persistence.test.ts @@ -844,3 +844,87 @@ describe('updateDesktopSettings', () => { expect(saveCalls.some((changes) => changes.autoSaveEnabled === true)).toBe(true); }); }); + +describe('unload lifecycle flush (#2197)', () => { + beforeEach(() => { + getWindow(); + registerRuntimeAPIs(null); + invalidateSettingsCache(); + }); + + test('flushes a pending debounced settings save on pagehide without a double write', async () => { + const saveCalls: Array<Partial<SettingsPayload>> = []; + registerSettingsSave(async (changes) => { + saveCalls.push(changes); + return {}; + }); + + const update = updateDesktopSettings({ showDeletionDialog: false }); + expect(saveCalls).toEqual([]); + + getWindow().dispatchEvent(new Event('pagehide')); + + // The flush must hand the pending changes to the settings backend + // synchronously inside the lifecycle listener — an unloading window has + // no later turn for the debounce timer. + expect(saveCalls).toEqual([{ showDeletionDialog: false }]); + + await update; + await delay(300); + // The canceled debounce timer must not replay the same write. + expect(saveCalls).toHaveLength(1); + }); + + test('flushes a pending debounced settings save on beforeunload without a double write', async () => { + const saveCalls: Array<Partial<SettingsPayload>> = []; + registerSettingsSave(async (changes) => { + saveCalls.push(changes); + return {}; + }); + + const update = updateDesktopSettings({ gitChangesViewMode: 'tree' }); + expect(saveCalls).toEqual([]); + + getWindow().dispatchEvent(new Event('beforeunload')); + + expect(saveCalls).toEqual([{ gitChangesViewMode: 'tree' }]); + + await update; + await delay(300); + expect(saveCalls).toHaveLength(1); + }); + + test('persists a showDeletionDialog toggle followed by an immediate unload', async () => { + const saveCalls: Array<Partial<SettingsPayload>> = []; + registerSettingsSave(async (changes) => { + saveCalls.push(changes); + return {}; + }); + startAppearanceAutoSave(); + + try { + useUIStore.getState().setShowDeletionDialog(false); + getWindow().dispatchEvent(new Event('pagehide')); + + expect(saveCalls.some((changes) => changes.showDeletionDialog === false)).toBe(true); + } finally { + useUIStore.getState().setShowDeletionDialog(true); + // Let the restore write drain so it cannot leak into other tests. + await delay(300); + } + }); + + test('ignores lifecycle events when no settings write is pending', async () => { + const saveCalls: Array<Partial<SettingsPayload>> = []; + registerSettingsSave(async (changes) => { + saveCalls.push(changes); + return {}; + }); + + getWindow().dispatchEvent(new Event('pagehide')); + getWindow().dispatchEvent(new Event('beforeunload')); + await delay(50); + + expect(saveCalls).toEqual([]); + }); +}); diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 09db0255..c5f78cf1 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -1772,6 +1772,21 @@ const isSettingsRuntimeContextCurrent = (context: SettingsRuntimeContext): boole context.generation === _settingsRuntimeGeneration && context.runtimeKey === getRuntimeKey() ); +// Best-effort flush of the pending debounced settings write at a lifecycle +// boundary. Clearing the timer before flushing means the write happens exactly +// once — the flush consumes the pending changes, so a timer that already fired +// cannot double-write. A hard process kill (crash, task-manager kill) can +// still lose the in-flight request; this narrows the loss window to the +// request itself instead of the whole debounce interval (#2197). +const flushPendingSettingsBeforeSuspend = (): void => { + if (!_pendingSettingsChanges) return; + if (_settingsFlushTimer) { + clearTimeout(_settingsFlushTimer); + _settingsFlushTimer = null; + } + void _flushSettingsUpdate(); +}; + const ensureSettingsRuntimeLifecycle = (): void => { if (_settingsLifecycleInitialized || typeof window === 'undefined') return; _settingsLifecycleInitialized = true; @@ -1789,6 +1804,22 @@ const ensureSettingsRuntimeLifecycle = (): void => { _settingsCache = null; _settingsInflight = null; }); + + // Mirror the deferred safe-storage lifecycle: without these listeners, a + // settings change made within SETTINGS_DEBOUNCE_MS of closing the window is + // silently dropped, and the stale server snapshot wins on next startup. + try { + window.addEventListener('pagehide', flushPendingSettingsBeforeSuspend, { capture: true }); + window.addEventListener('beforeunload', flushPendingSettingsBeforeSuspend, { capture: true }); + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') flushPendingSettingsBeforeSuspend(); + }); + document.addEventListener('freeze', flushPendingSettingsBeforeSuspend); + } + } catch { + // Restricted environments can reject listeners; the debounce timer still flushes. + } }; const fetchWebSettings = async (context = captureSettingsRuntimeContext()): Promise<DesktopSettings | null> => { diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 2716e7ca..cf8fe7b9 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -351,7 +351,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ id: 'chat.composer', page: 'chat', titleKey: 'settings.openchamber.visual.section.composer', - keywords: ['input', 'draft', 'spellcheck'], + keywords: ['input', 'draft', 'spellcheck', 'paste'], }, { id: 'chat.spellcheck', @@ -360,6 +360,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ keywords: ['spelling', 'input'], isAvailable: (ctx) => !ctx.isMobile, }, + { + id: 'chat.large-text-paste', + page: 'chat', + titleKey: 'settings.openchamber.visual.field.largeTextPaste', + descriptionKey: 'settings.openchamber.visual.field.largeTextPasteHint', + keywords: ['paste', 'clipboard', 'attachment', 'large', 'text', 'file'], + }, { id: 'sessions.default-model', page: 'sessions', diff --git a/packages/ui/src/lib/shiki/sanitizeTemplateCallGrammar.test.ts b/packages/ui/src/lib/shiki/sanitizeTemplateCallGrammar.test.ts new file mode 100644 index 00000000..aaa1e0a7 --- /dev/null +++ b/packages/ui/src/lib/shiki/sanitizeTemplateCallGrammar.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from 'bun:test'; +import { bundledLanguages, createHighlighter, type LanguageRegistration } from 'shiki'; + +import { + hasCatastrophicTemplateCall, + isTemplateCallLanguageId, + sanitizeTemplateCallGrammar, + TEMPLATE_CALL_LANGUAGE_IDS, +} from './sanitizeTemplateCallGrammar'; + +type BundledLanguageModule = { default: LanguageRegistration[] }; + +const loadBundledGrammar = async (id: (typeof TEMPLATE_CALL_LANGUAGE_IDS)[number]): Promise<LanguageRegistration> => { + // SAFETY: `id` comes from TEMPLATE_CALL_LANGUAGE_IDS, and every Shiki bundled + // language module default-exports its grammar array. + const mod = (await bundledLanguages[id]()) as BundledLanguageModule; + return mod.default[0]; +}; + +describe('sanitizeTemplateCallGrammar', () => { + test('detects template-call on bundled JS/TS grammars', async () => { + for (const id of TEMPLATE_CALL_LANGUAGE_IDS) { + const grammar = await loadBundledGrammar(id); + expect(isTemplateCallLanguageId(id)).toBe(true); + expect(hasCatastrophicTemplateCall(grammar)).toBe(true); + } + }); + + test('clears template-call patterns without dropping the repository key', async () => { + const grammar = await loadBundledGrammar('javascript'); + const patched = sanitizeTemplateCallGrammar(grammar); + + expect(hasCatastrophicTemplateCall(patched)).toBe(false); + expect(patched.repository?.['template-call']).toEqual({ patterns: [] }); + // Original left intact (structured clone / spread, not mutate-in-place). + expect(hasCatastrophicTemplateCall(grammar)).toBe(true); + }); + + test('is a no-op when template-call is already empty', () => { + const grammar = { + name: 'javascript', + scopeName: 'source.js', + patterns: [], + repository: { 'template-call': { patterns: [] } }, + } satisfies LanguageRegistration; + expect(sanitizeTemplateCallGrammar(grammar)).toBe(grammar); + }); + + test('highlights template-literal fixtures within a tight budget after sanitize', async () => { + // SAFETY: the javascript bundle default-exports its grammar array. + const mod = (await bundledLanguages.javascript()) as BundledLanguageModule; + const patched = mod.default.map((grammar) => sanitizeTemplateCallGrammar(grammar)); + + const highlighter = await createHighlighter({ + themes: ['github-dark'], + langs: patched, + }); + + // Representative content from openchamber/openchamber#2587, scaled to ~14KB. + const fixture = `const snapshot = { source: \`\${session.source}\`, fetchedAt: \`\${Date.now()}\` }; +const label = \`Account \${index + 1}\`; +function render(account) { + return html\`<div class="\${account.cls}">\${account.name}</div>\`; +} +`.repeat(80); + + expect(fixture.length).toBeGreaterThan(10_000); + + const started = performance.now(); + const html = highlighter.codeToHtml(fixture, { lang: 'javascript', theme: 'github-dark' }); + const elapsedMs = performance.now() - started; + highlighter.dispose(); + + expect(html.length).toBeGreaterThan(0); + // Catastrophic backtracking hangs for seconds–minutes; healthy tokenize is well under 1s. + expect(elapsedMs).toBeLessThan(2_000); + }); +}); \ No newline at end of file diff --git a/packages/ui/src/lib/shiki/sanitizeTemplateCallGrammar.ts b/packages/ui/src/lib/shiki/sanitizeTemplateCallGrammar.ts new file mode 100644 index 00000000..76d6ac33 --- /dev/null +++ b/packages/ui/src/lib/shiki/sanitizeTemplateCallGrammar.ts @@ -0,0 +1,45 @@ +/** + * Neutralize the JavaScript/TypeScript TextMate `template-call` rule. + * + * Upstream grammars use a triple-nested `{()[]}` lookahead to detect tagged + * templates with type arguments (`foo<T>\`...\``). On the Oniguruma WASM engine + * shipped with Shiki — which does not expose `setRetryLimit` / match-stack + * limits — that pattern can enter exponential backtracking on ordinary + * backtick template literals, grow the WASM heap without bound, and OOM the + * renderer (openchamber/openchamber#2587). + * + * Clearing `template-call` is safe: the plain `#template` rule still highlights + * backticks and simple tagged templates. Only the rare `ident<TypeArgs>\`...\`` + * form loses its specialized type-argument coloring and falls through to + * normal tokenization. + */ + +type GrammarRepository = Record<string, { patterns?: unknown[] } | undefined>; + +export type TemplateCallGrammar = { + name?: string; + repository?: GrammarRepository; +}; + +const TEMPLATE_CALL_KEY = 'template-call'; + +export const hasCatastrophicTemplateCall = (grammar: TemplateCallGrammar): boolean => { + const patterns = grammar.repository?.[TEMPLATE_CALL_KEY]?.patterns; + return Array.isArray(patterns) && patterns.length > 0; +}; + +export const sanitizeTemplateCallGrammar = <T extends TemplateCallGrammar>(grammar: T): T => { + if (!hasCatastrophicTemplateCall(grammar)) return grammar; + + const repository = { ...grammar.repository }; + repository[TEMPLATE_CALL_KEY] = { patterns: [] }; + return { ...grammar, repository }; +}; + +/** Language ids whose bundled grammars ship the catastrophic `template-call` rule. */ +export const TEMPLATE_CALL_LANGUAGE_IDS = ['javascript', 'typescript', 'jsx', 'tsx'] as const; + +export type TemplateCallLanguageId = (typeof TEMPLATE_CALL_LANGUAGE_IDS)[number]; + +export const isTemplateCallLanguageId = (lang: string): lang is TemplateCallLanguageId => + TEMPLATE_CALL_LANGUAGE_IDS.some((id) => id === lang); diff --git a/packages/ui/src/lib/vscodeBootstrap.test.ts b/packages/ui/src/lib/vscodeBootstrap.test.ts new file mode 100644 index 00000000..3e67c3b8 --- /dev/null +++ b/packages/ui/src/lib/vscodeBootstrap.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { getVSCodeBootstrapConfig, isVSCodeBootstrapPresent } from './vscodeBootstrap'; + +describe('VS Code bootstrap config', () => { + afterEach(() => { + delete (globalThis as { window?: unknown }).window; + }); + + test('reads extension-host __VSCODE_CONFIG__ before RuntimeAPIs exist', () => { + (globalThis as { window: unknown }).window = { + __VSCODE_CONFIG__: { + workspaceFolder: '/workspace/project-one', + workspaceFolders: [{ name: 'project-one', path: '/workspace/project-one' }], + }, + }; + + expect(getVSCodeBootstrapConfig()).toEqual({ + workspaceFolder: '/workspace/project-one', + workspaceFolders: [{ name: 'project-one', path: '/workspace/project-one' }], + }); + expect(isVSCodeBootstrapPresent()).toBe(true); + }); + + test('treats missing window/bootstrap as not VS Code', () => { + expect(getVSCodeBootstrapConfig()).toBeNull(); + expect(isVSCodeBootstrapPresent()).toBe(false); + expect(isVSCodeBootstrapPresent(null)).toBe(false); + }); +}); diff --git a/packages/ui/src/lib/vscodeBootstrap.ts b/packages/ui/src/lib/vscodeBootstrap.ts new file mode 100644 index 00000000..1c9e7a8b --- /dev/null +++ b/packages/ui/src/lib/vscodeBootstrap.ts @@ -0,0 +1,20 @@ +/** + * Extension-host bootstrap config injected into the VS Code webview HTML + * before any bundled module evaluates. Prefer this over RuntimeAPIs for + * early VS Code detection during store module initialization. + */ +export interface VSCodeBootstrapConfig { + workspaceFolder?: unknown; + workspaceFolders?: unknown; +} + +export const getVSCodeBootstrapConfig = (): VSCodeBootstrapConfig | null => { + if (typeof window === 'undefined') { + return null; + } + return (window as unknown as { __VSCODE_CONFIG__?: VSCodeBootstrapConfig }).__VSCODE_CONFIG__ ?? null; +}; + +export const isVSCodeBootstrapPresent = ( + bootstrapConfig: VSCodeBootstrapConfig | null = getVSCodeBootstrapConfig(), +): boolean => Boolean(bootstrapConfig); diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index ed68eff9..4fe6039a 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -84,7 +84,7 @@ Permission auto-accept policy is authoritative in the active Web server or VS Co Shared safe storage treats durable failures per key. A quota or access failure creates an ephemeral override or tombstone for that key without disabling reads and writes for unrelated keys; later writes retry the durable backend. Deferred adapters retain failed operations for a later flush, and malformed Zustand JSON is removed and treated as missing so hydration can recover. -Project and UI settings use successful settings synchronization as authority. Omitted fields in a complete snapshot reset to canonical client defaults, including an omitted project list becoming empty; transport or settings-load failure dispatches no synchronization event and preserves current state. Settings save responses are partial patches and must not clear unrelated in-memory preferences or local mirrors. +Project and UI settings use successful settings synchronization as authority. Omitted fields in a complete snapshot reset to canonical client defaults, including an omitted project list becoming empty; transport or settings-load failure dispatches no synchronization event and preserves current state. Settings save responses are partial patches and must not clear unrelated in-memory preferences or local mirrors. Debounced settings writes flush best-effort on page hide, document hidden, app freeze, and unload — canceling the pending timer so the write happens exactly once — because a write lost inside the debounce window lets the stale server snapshot override the change on next startup; a hard process kill can still lose the in-flight request. Project ordering defaults to manual. Session display persistence v3 migrates the previously shipped `recent` project order to `manual` while preserving every other explicit sort mode. @@ -147,10 +147,12 @@ Important properties: - `directories: Map<string, DirectoryGitState>` is the source of truth - loading state is per-directory, not global - `ensureStatus()` and `ensureAll()` are the preferred entry points for consumers -- in-flight dedupe exists for status and `ensureAll()` +- in-flight dedupe exists for status and `ensureAll()`; status dedupe is scoped to the per-directory status mutation revision, so a refresh requested after a mutation never joins a pre-mutation in-flight request - runtime reset replaces all live entries with that runtime's persisted branch seeds and invalidates old completions - status, branches, log, identity, repository probes, and prefetch diffs commit through runtime and per-channel generations - status mutations advance a revision so older refreshes cannot undo optimistic or confirmed index changes +- a successful status-affecting git mutation also advances that revision: the HTTP adapter's cache invalidation notifies the store through `lib/gitStatusInvalidation.ts` (the VS Code bridge adapter has no client-side status cache, so it emits nothing today) +- `fetchAll({ force: true })` forces the status fetch as well as the log refresh - branch persistence is versioned, bounded, runtime-scoped, and claims the ambiguous legacy cache once - diff data has per-directory and aggregate count/UTF-8-byte limits; oversized single entries are rejected @@ -312,6 +314,7 @@ Expected model: - `GitView` / `DiffView` ensure current-directory Git state when visible - explicit Git actions refresh status/branches/log as needed +- every status-affecting git mutation invalidates the HTTP adapter's status cache on its success path (failed mutations invalidate nothing), so the follow-up refresh is authoritative instead of the pre-mutation cache entry - a mounted file-mutating tool issues a one-shot Git refresh hint when it transitions from active to successfully finalized; remounting historical completed tools does not replay the hint - a successful dirty save from the in-app file editor issues a path-scoped Git refresh hint; clean autosave checks remain no-ops - refresh hints with authoritative file paths invalidate only those cached and currently rendered diffs before status refresh; pathless tools request status reconciliation without broadly remounting DiffView diff --git a/packages/ui/src/stores/useConfigStore.test.ts b/packages/ui/src/stores/useConfigStore.test.ts index 1cd6e4d1..99a8f0d4 100644 --- a/packages/ui/src/stores/useConfigStore.test.ts +++ b/packages/ui/src/stores/useConfigStore.test.ts @@ -698,6 +698,30 @@ describe('useConfigStore provider persistence', () => { expect(state.currentModelId).toBe('model-a'); }); + test('[issue-2531] setAgent keeps the manual model when switching to an agent without an override', () => { + const sessionId = 'ses_2531_mode_switch'; + useSessionUIStore.setState({ currentSessionId: sessionId }); + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + providers: [provider('deepseek', 'deepseek-v4-pro'), provider('kimi', 'kimi-k3')], + agents: [testAgent('build'), testAgent('plan')], + settingsDefaultModel: 'deepseek/deepseek-v4-pro', + currentProviderId: 'kimi', + currentModelId: 'kimi-k3', + currentAgentName: 'build', + selectionSource: 'manual', + currentVariant: undefined, + directoryScoped: {}, + }); + + useConfigStore.getState().setAgent('plan'); + + const state = useConfigStore.getState(); + expect(state.currentAgentName).toBe('plan'); + expect(state.currentProviderId).toBe('kimi'); + expect(state.currentModelId).toBe('kimi-k3'); + }); + test('loadAgents does not fetch OpenCode config directly', async () => { useConfigStore.setState({ activeDirectoryKey: DIRECTORY, diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 46ac3379..d61cc083 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -2435,6 +2435,9 @@ export const useConfigStore = create<ConfigStore>()( currentProviderId, currentModelId, } = get(); + // Captured before the first set below, which unconditionally + // marks the selection as manual. + const hadManualSelection = get().selectionSource === "manual"; set((state) => { const directoryKey = state.activeDirectoryKey; @@ -2554,8 +2557,7 @@ export const useConfigStore = create<ConfigStore>()( // Prefer a session-level manual override for this agent over the // agent's configured default. Re-applying setAgent after subtask // completion / rematerialization must not clobber the override - // (issue #2404). Explicit agent-picker switches still force the - // agent default via ModelControls' shouldPreferAgentModel path. + // (issue #2404). if (currentSessionId) { const existingAgentModel = useSelectionStore.getState().getAgentModelForSession(currentSessionId, agentName); if (existingAgentModel && hasProviderModel(providers, existingAgentModel.providerId, existingAgentModel.modelId)) { @@ -2584,6 +2586,14 @@ export const useConfigStore = create<ConfigStore>()( } } + // The user has a live manual model selection and the target + // agent configures no model of its own. Switching modes or + // agents must not reset the selection to the settings default + // (issue #2531) — mode switches are not model changes. + if (hadManualSelection && currentProviderId && currentModelId) { + return; + } + // If the agent has no preferred model, use settings default. if (settingsDefaultModel) { const parsed = parseModelString(settingsDefaultModel); diff --git a/packages/ui/src/stores/useDirectoryStore.ts b/packages/ui/src/stores/useDirectoryStore.ts index b0b32af5..865fa97a 100644 --- a/packages/ui/src/stores/useDirectoryStore.ts +++ b/packages/ui/src/stores/useDirectoryStore.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { opencodeClient } from '@/lib/opencode/client'; import { getDesktopHomeDirectory, isVSCodeRuntime } from '@/lib/desktop'; +import { getVSCodeBootstrapConfig } from '@/lib/vscodeBootstrap'; import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; import { updateDesktopSettings } from '@/lib/persistence'; import { useFileSearchStore } from '@/stores/useFileSearchStore'; @@ -227,7 +228,7 @@ const getVsCodeWorkspaceFolder = (): string | null => { if (!isVSCodeRuntime()) { return null; } - const workspaceFolder = (window as unknown as { __VSCODE_CONFIG__?: { workspaceFolder?: unknown } }).__VSCODE_CONFIG__?.workspaceFolder; + const workspaceFolder = getVSCodeBootstrapConfig()?.workspaceFolder; if (typeof workspaceFolder !== 'string' || workspaceFolder.trim().length === 0) { return null; } diff --git a/packages/ui/src/stores/useGitStore.test.ts b/packages/ui/src/stores/useGitStore.test.ts index 2512989a..15e22afd 100644 --- a/packages/ui/src/stores/useGitStore.test.ts +++ b/packages/ui/src/stores/useGitStore.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test } from 'bun:test'; import type { GitStatus } from '@/lib/api/types'; import { useGitStore } from './useGitStore'; import { getRuntimeKey } from '@/lib/runtime-switch'; +import { notifyGitStatusInvalidated } from '@/lib/gitStatusInvalidation'; type Deferred<T> = { promise: Promise<T>; @@ -126,6 +127,85 @@ describe('useGitStore', () => { expect(lightResult).toBe(fullResult); }); + test('deduplicates concurrent status requests when no mutation occurs', async () => { + setDirectoryStatus(createStatus()); + let statusCalls = 0; + const request = createDeferred<GitStatus>(); + const git = createGitApi(() => { + statusCalls += 1; + return request.promise; + }); + + const first = useGitStore.getState().fetchStatus('/repo', git, { silent: true }); + const second = useGitStore.getState().fetchStatus('/repo', git, { silent: true }); + await Promise.resolve(); + + expect(statusCalls).toBe(1); + + request.resolve(createStatus()); + await Promise.all([first, second]); + expect(statusCalls).toBe(1); + }); + + test('a refresh after a mutation does not join the pre-mutation in-flight status request', async () => { + setDirectoryStatus(createStatus()); + const requests: Deferred<GitStatus>[] = []; + let statusCalls = 0; + const git = createGitApi(() => { + statusCalls += 1; + const request = createDeferred<GitStatus>(); + requests.push(request); + return request.promise; + }); + + const preMutation = useGitStore.getState().fetchStatus('/repo', git, { silent: true }); + await Promise.resolve(); + expect(statusCalls).toBe(1); + + // A successful git mutation invalidates the adapter status cache, which + // notifies the store that the in-flight request predates the mutation. + notifyGitStatusInvalidated('/repo'); + + const postMutation = useGitStore.getState().fetchStatus('/repo', git, { silent: true }); + await Promise.resolve(); + expect(statusCalls).toBe(2); + + requests[1].resolve({ ...createStatus(), current: 'feature' }); + await postMutation; + expect(useGitStore.getState().getDirectoryState('/repo')?.status?.current).toBe('feature'); + + // The late pre-mutation response cannot overwrite the newer authoritative one. + requests[0].resolve(createStatus()); + await preMutation; + expect(useGitStore.getState().getDirectoryState('/repo')?.status?.current).toBe('feature'); + }); + + test('fetchAll({ force: true }) forces a fresh status fetch past the in-flight dedup', async () => { + setDirectoryStatus(createStatus()); + const requests: Deferred<GitStatus>[] = []; + let statusCalls = 0; + const git = createGitApi(() => { + statusCalls += 1; + const request = createDeferred<GitStatus>(); + requests.push(request); + return request.promise; + }); + + const inFlight = useGitStore.getState().fetchStatus('/repo', git, { silent: true }); + await Promise.resolve(); + expect(statusCalls).toBe(1); + + const all = useGitStore.getState().fetchAll('/repo', git, { force: true }); + await Promise.resolve(); + expect(statusCalls).toBe(2); + + requests[1].resolve({ ...createStatus(), current: 'feature' }); + requests[0].resolve(createStatus()); + await Promise.allSettled([inFlight, all]); + + expect(useGitStore.getState().getDirectoryState('/repo')?.status?.current).toBe('feature'); + }); + test('does not let an older status fetch undo an optimistic mutation', async () => { const initial = createStatus(undefined, [{ path: 'src/index.ts', index: ' ', working_dir: 'M' }]); setDirectoryStatus(initial); diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts index aec942ba..dbffb679 100644 --- a/packages/ui/src/stores/useGitStore.ts +++ b/packages/ui/src/stores/useGitStore.ts @@ -9,6 +9,7 @@ import type { } from '@/lib/api/types'; import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { getRuntimeKey } from '@/lib/runtime-switch'; +import { subscribeGitStatusInvalidations } from '@/lib/gitStatusInvalidation'; const LOG_STALE_THRESHOLD = 10000; const REPO_CHECK_STALE_THRESHOLD = 60_000; @@ -57,7 +58,7 @@ interface GitStore { setActiveDirectory: (directory: string | null) => void; getDirectoryState: (directory: string) => DirectoryGitState | null; - fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean; mode?: 'light' }) => Promise<boolean>; + fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean; mode?: 'light'; force?: boolean }) => Promise<boolean>; fetchBranches: (directory: string, git: GitAPI) => Promise<void>; fetchLog: (directory: string, git: GitAPI, maxCount?: number) => Promise<void>; fetchIdentity: (directory: string, git: GitAPI) => Promise<void>; @@ -99,7 +100,7 @@ interface GitAPI { const inFlightDiffFetchesByDirectory = new Map<string, Set<string>>(); const diffFetchGenerationByDirectory = new Map<string, number>(); -const inFlightStatusFetches = new Map<string, Promise<boolean>>(); +const inFlightStatusFetches = new Map<string, { promise: Promise<boolean>; statusMutationRevision: number }>(); const inFlightEnsureAllByDirectory = new Map<string, Promise<void>>(); const requestGenerationByChannel = new Map<string, number>(); const statusMutationRevisionByDirectory = new Map<string, number>(); @@ -150,6 +151,18 @@ const bumpStatusMutationRevision = (runtimeKey: string, directory: string): void statusMutationRevisionByDirectory.set(key, (statusMutationRevisionByDirectory.get(key) ?? 0) + 1); }; +const getStatusMutationRevision = (runtimeKey: string, directory: string): number => + statusMutationRevisionByDirectory.get(runtimeDirectoryKey(runtimeKey, directory)) ?? 0; + +// A successful status-affecting git mutation invalidates the runtime adapter's +// status cache (see lib/gitStatusInvalidation.ts). Bump the per-directory +// mutation revision so a status request admitted before the mutation can +// neither be joined by a post-mutation refresh nor commit its stale payload +// over the refreshed state. +subscribeGitStatusInvalidations((directory) => { + bumpStatusMutationRevision(getRuntimeKey(), directory); +}); + const getDiffFetchGeneration = (directory: string): number => diffFetchGenerationByDirectory.get(runtimeDirectoryKey(getRuntimeKey(), directory)) ?? 0; @@ -590,10 +603,16 @@ export const useGitStore = create<GitStore>()( const statusFetchMode: GitStatusFetchMode = options.mode ?? 'full'; const runtimeKey = getRuntimeKey(); const statusFetchKey = getStatusFetchKey(runtimeKey, directory, statusFetchMode); - const existing = inFlightStatusFetches.get(statusFetchKey) - ?? (statusFetchMode === 'light' ? inFlightStatusFetches.get(getStatusFetchKey(runtimeKey, directory, 'full')) : undefined); - if (existing) { - return existing; + const statusMutationRevision = getStatusMutationRevision(runtimeKey, directory); + if (!options.force) { + const existing = inFlightStatusFetches.get(statusFetchKey) + ?? (statusFetchMode === 'light' ? inFlightStatusFetches.get(getStatusFetchKey(runtimeKey, directory, 'full')) : undefined); + // Join an in-flight request only when it was admitted at the current + // mutation revision; a request that predates a mutation must not + // satisfy the post-mutation refresh. + if (existing && existing.statusMutationRevision === statusMutationRevision) { + return existing.promise; + } } const token = startRequest(directory, 'status', true); @@ -727,12 +746,12 @@ export const useGitStore = create<GitStore>()( return statusChanged; })(); - inFlightStatusFetches.set(statusFetchKey, fetchPromise); + inFlightStatusFetches.set(statusFetchKey, { promise: fetchPromise, statusMutationRevision }); try { return await fetchPromise; } finally { - if (inFlightStatusFetches.get(statusFetchKey) === fetchPromise) { + if (inFlightStatusFetches.get(statusFetchKey)?.promise === fetchPromise) { inFlightStatusFetches.delete(statusFetchKey); } } @@ -936,8 +955,11 @@ export const useGitStore = create<GitStore>()( const { force = false, silentIfCached = false } = options; const now = Date.now(); + // `force` applies to status as well as log: a forced refresh must not + // resolve from an in-flight status request admitted earlier. await get().fetchStatus(directory, git, { silent: silentIfCached && Boolean(dirState?.status), + force, }); const updatedDirState = get().directories.get(directory); diff --git a/packages/ui/src/stores/useUIStore.contextPanel.test.ts b/packages/ui/src/stores/useUIStore.contextPanel.test.ts index 749272f8..8a08c113 100644 --- a/packages/ui/src/stores/useUIStore.contextPanel.test.ts +++ b/packages/ui/src/stores/useUIStore.contextPanel.test.ts @@ -319,6 +319,75 @@ describe('useUIStore closeContextPanelTab surface stability', () => { }); }); +describe('useUIStore closeContextPanelTabs bulk', () => { + const directory = '/repo'; + + test('closing every tab of the only surface closes the panel', () => { + useUIStore.getState().openContextBrowser(directory, 'https://a.test'); + useUIStore.getState().openContextBrowser(directory, 'https://b.test'); + useUIStore.getState().openContextBrowser(directory, 'https://c.test'); + + const state0 = useUIStore.getState().contextPanelByDirectory[directory]; + const ids = state0?.tabs.map((tab) => tab.id) ?? []; + useUIStore.getState().closeContextPanelTabs(directory, ids); + + const state = useUIStore.getState().contextPanelByDirectory[directory]; + expect(state?.tabs).toHaveLength(0); + expect(state?.isOpen).toBe(false); + }); + + test('closing all tabs of the active surface closes the panel but keeps other surfaces in state', () => { + useUIStore.getState().openContextPanelTab(directory, { mode: 'terminal' }); + useUIStore.getState().openContextFile(directory, '/repo/a.ts'); + useUIStore.getState().openContextFile(directory, '/repo/b.ts'); + + const state0 = useUIStore.getState().contextPanelByDirectory[directory]; + const fileIds = state0?.tabs.filter((tab) => tab.mode === 'file').map((tab) => tab.id) ?? []; + useUIStore.getState().closeContextPanelTabs(directory, fileIds); + + const state = useUIStore.getState().contextPanelByDirectory[directory]; + expect(state?.tabs.map((tab) => tab.mode)).toEqual(['terminal']); + expect(state?.activeTabId).toBe('terminal'); + // Matches the single-close rule: emptying the active surface closes the panel. + expect(state?.isOpen).toBe(false); + }); + + test('closing only inactive-mode tabs leaves the active tab and panel intact', () => { + useUIStore.getState().openContextFile(directory, '/repo/a.ts'); + useUIStore.getState().openContextPanelTab(directory, { mode: 'terminal' }); + + const state0 = useUIStore.getState().contextPanelByDirectory[directory]; + const fileTab = state0?.tabs.find((tab) => tab.mode === 'file'); + useUIStore.getState().closeContextPanelTabs(directory, [fileTab?.id as string]); + + const state = useUIStore.getState().contextPanelByDirectory[directory]; + expect(state?.activeTabId).toBe('terminal'); + expect(state?.isOpen).toBe(true); + }); + + test('closing a subset of the active surface including the active tab keeps a remaining same-mode tab', () => { + useUIStore.getState().openContextPanelTab(directory, { mode: 'terminal' }); + useUIStore.getState().openContextFile(directory, '/repo/a.ts'); + useUIStore.getState().openContextFile(directory, '/repo/b.ts'); + useUIStore.getState().openContextFile(directory, '/repo/c.ts'); + + const state0 = useUIStore.getState().contextPanelByDirectory[directory]; + const fileTabs = state0?.tabs.filter((tab) => tab.mode === 'file') ?? []; + const keptFile = fileTabs.find((tab) => tab.targetPath === '/repo/a.ts'); + const closedIds = fileTabs.filter((tab) => tab.id !== keptFile?.id).map((tab) => tab.id); + expect(state0?.tabs.find((tab) => tab.id === state0.activeTabId)?.targetPath).toBe('/repo/c.ts'); + + useUIStore.getState().closeContextPanelTabs(directory, closedIds); + + const state = useUIStore.getState().contextPanelByDirectory[directory]; + const activeTab = state?.tabs.find((tab) => tab.id === state.activeTabId); + expect(activeTab?.mode).toBe('file'); + expect(activeTab?.targetPath).toBe('/repo/a.ts'); + expect(state?.isOpen).toBe(true); + expect(state?.tabs.some((tab) => tab.mode === 'terminal')).toBe(true); + }); +}); + describe('useUIStore per-surface panel widths', () => { const directory = '/repo'; diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 214bc2a1..3049ccd8 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -25,6 +25,16 @@ export type WeekStartPreference = 'auto' | 'sunday' | 'monday'; export type DesktopWindowControlsPosition = 'left' | 'right'; export type DesktopWindowControlsStyle = 'classic' | 'traffic-lights'; export type FileEditorKeymap = 'default' | 'vim'; +export type LargeTextPasteBehavior = 'ask' | 'attach' | 'inline'; + +export const DEFAULT_LARGE_TEXT_PASTE_BEHAVIOR: LargeTextPasteBehavior = 'ask'; + +export const normalizeLargeTextPasteBehavior = (value: unknown): LargeTextPasteBehavior => { + if (value === 'attach' || value === 'inline' || value === 'ask') { + return value; + } + return DEFAULT_LARGE_TEXT_PASTE_BEHAVIOR; +}; function normalizeFileEditorKeymap(value: unknown): FileEditorKeymap { return value === 'vim' ? 'vim' : 'default'; @@ -472,14 +482,19 @@ const upsertContextPanelTab = ( }; }; -const closeContextPanelTab = ( +const closeContextPanelTabs = ( current: ContextPanelDirectoryState, - tabID: string, + tabIds: readonly string[], ): ContextPanelDirectoryState => { - const closedTab = current.tabs.find((tab) => tab.id === tabID) ?? null; - const nextTabs = current.tabs.filter((tab) => tab.id !== tabID); + const closed = new Set(tabIds); + const closedTabs = current.tabs.filter((tab) => closed.has(tab.id)); + const nextTabs = current.tabs.filter((tab) => !closed.has(tab.id)); + if (nextTabs.length === current.tabs.length) { + return current; + } - if (current.activeTabId !== tabID) { + const activeClosed = current.activeTabId ? closed.has(current.activeTabId) : false; + if (!activeClosed) { return { ...current, tabs: nextTabs, @@ -489,10 +504,11 @@ const closeContextPanelTab = ( }; } - // Closing the active tab stays inside the active surface: activate the most - // recent remaining tab of the same mode, and when it was the last one just - // close the panel instead of jumping to another surface. - const sameModeTabs = closedTab ? nextTabs.filter((tab) => tab.mode === closedTab.mode) : []; + // Closing the active tab stays inside its surface: activate the most recent + // remaining tab of the same mode, and when none remain just close the panel + // instead of jumping to another surface. + const activeMode = closedTabs.find((tab) => tab.id === current.activeTabId)?.mode ?? null; + const sameModeTabs = activeMode ? nextTabs.filter((tab) => tab.mode === activeMode) : []; const nextSameModeTab = sameModeTabs.length > 0 ? sameModeTabs.reduce((best, tab) => (tab.touchedAt >= best.touchedAt ? tab : best)) : null; @@ -822,6 +838,7 @@ interface UIStore { /** Active tab of the project context panel (notes/todos/plans). */ projectContextTab: string; inputSpellcheckEnabled: boolean; + largeTextPasteBehavior: LargeTextPasteBehavior; wideChatLayoutEnabled: boolean; codeBlockLineWrap: boolean; showToolFileIcons: boolean; @@ -864,6 +881,7 @@ interface UIStore { setActiveContextPanelTab: (directory: string, tabID: string) => void; reorderContextPanelTabs: (directory: string, activeTabID: string, overTabID: string) => void; closeContextPanelTab: (directory: string, tabID: string) => void; + closeContextPanelTabs: (directory: string, tabIds: readonly string[]) => void; closeContextPanel: (directory: string) => void; toggleContextPanelExpanded: (directory: string) => void; setContextPanelWidth: (directory: string, mode: ContextPanelMode, width: number) => void; @@ -996,6 +1014,7 @@ interface UIStore { setProjectContextSidebarWidth: (width: number) => void; setProjectContextTab: (value: string) => void; setInputSpellcheckEnabled: (value: boolean) => void; + setLargeTextPasteBehavior: (value: LargeTextPasteBehavior) => void; setWideChatLayoutEnabled: (value: boolean) => void; setCodeBlockLineWrap: (value: boolean) => void; setShowToolFileIcons: (value: boolean) => void; @@ -1158,6 +1177,7 @@ export const useUIStore = create<UIStore>()( projectContextSidebarWidth: 168, projectContextTab: 'notes', inputSpellcheckEnabled: false, + largeTextPasteBehavior: DEFAULT_LARGE_TEXT_PASTE_BEHAVIOR, wideChatLayoutEnabled: false, codeBlockLineWrap: true, showToolFileIcons: true, @@ -1480,34 +1500,43 @@ export const useUIStore = create<UIStore>()( }, closeContextPanelTab: (directory, tabID) => { + get().closeContextPanelTabs(directory, [tabID]); + }, + + closeContextPanelTabs: (directory, tabIds) => { const normalizedDirectory = normalizeDirectoryPath((directory || '').trim()); - const normalizedTabID = (tabID || '').trim(); - if (!normalizedDirectory || !normalizedTabID) { + const normalizedTabIds = (tabIds ?? []) + .map((id) => (id || '').trim()) + .filter((id) => id.length > 0); + if (!normalizedDirectory || normalizedTabIds.length === 0) { return; } - const closingTab = get().contextPanelByDirectory[normalizedDirectory]?.tabs - .find((tab) => tab.id === normalizedTabID); + const closedTabs = normalizedTabIds + .map((id) => get().contextPanelByDirectory[normalizedDirectory]?.tabs.find((tab) => tab.id === id)) + .filter((tab): tab is ContextPanelTab => Boolean(tab)); set((state) => { const prev = state.contextPanelByDirectory[normalizedDirectory]; const current = touchContextPanelState(prev); - if (!current.tabs.some((tab) => tab.id === normalizedTabID)) { + if (!current.tabs.some((tab) => normalizedTabIds.includes(tab.id))) { return state; } const byDirectory = { ...state.contextPanelByDirectory, - [normalizedDirectory]: closeContextPanelTab(current, normalizedTabID), + [normalizedDirectory]: closeContextPanelTabs(current, normalizedTabIds), }; return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) }; }); - // Keep the editor's own open-file state in sync so a reopened - // editor surface does not resurrect the closed file. - if (closingTab?.mode === 'file' && closingTab.targetPath) { - useFilesViewTabsStore.getState().removeOpenPath(normalizedDirectory, closingTab.targetPath); + // Keep the editor's own open-file state in sync so closed files do not + // resurrect when the editor surface reopens. + for (const tab of closedTabs) { + if (tab.mode === 'file' && tab.targetPath) { + useFilesViewTabsStore.getState().removeOpenPath(normalizedDirectory, tab.targetPath); + } } }, @@ -2373,6 +2402,9 @@ export const useUIStore = create<UIStore>()( setInputSpellcheckEnabled: (value) => { set({ inputSpellcheckEnabled: value }); }, + setLargeTextPasteBehavior: (value) => { + set({ largeTextPasteBehavior: normalizeLargeTextPasteBehavior(value) }); + }, setWideChatLayoutEnabled: (value) => { set({ wideChatLayoutEnabled: value }); }, @@ -2681,6 +2713,7 @@ export const useUIStore = create<UIStore>()( } state.fileEditorKeymap = normalizeFileEditorKeymap(state.fileEditorKeymap); + state.largeTextPasteBehavior = normalizeLargeTextPasteBehavior(state.largeTextPasteBehavior); if (typeof state.autoSaveEnabled !== 'boolean') { state.autoSaveEnabled = true; @@ -2778,6 +2811,7 @@ export const useUIStore = create<UIStore>()( agentMemoryViewedAt: state.agentMemoryViewedAt, projectContextSidebarWidth: state.projectContextSidebarWidth, inputSpellcheckEnabled: state.inputSpellcheckEnabled, + largeTextPasteBehavior: state.largeTextPasteBehavior, wideChatLayoutEnabled: state.wideChatLayoutEnabled, codeBlockLineWrap: state.codeBlockLineWrap, showToolFileIcons: state.showToolFileIcons, diff --git a/packages/ui/src/stores/utils/vscodeRuntime.test.ts b/packages/ui/src/stores/utils/vscodeRuntime.test.ts index aebe538c..a1cb518f 100644 --- a/packages/ui/src/stores/utils/vscodeRuntime.test.ts +++ b/packages/ui/src/stores/utils/vscodeRuntime.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; +import type { RuntimeAPIs } from '@/lib/api/types'; import { isVSCodeRuntime } from './vscodeRuntime'; describe('VS Code runtime detection', () => { @@ -9,6 +10,13 @@ describe('VS Code runtime detection', () => { })).toBe(true); }); + test('uses registered runtime APIs when bootstrap is absent', () => { + const runtimeApis = { + runtime: { platform: 'vscode', isDesktop: false, isVSCode: true }, + } as RuntimeAPIs; + expect(isVSCodeRuntime(runtimeApis, null)).toBe(true); + }); + test('does not classify an unregistered web runtime as VS Code', () => { expect(isVSCodeRuntime(null, null)).toBe(false); }); diff --git a/packages/ui/src/stores/utils/vscodeRuntime.ts b/packages/ui/src/stores/utils/vscodeRuntime.ts index 91446ce6..2e7a5d7b 100644 --- a/packages/ui/src/stores/utils/vscodeRuntime.ts +++ b/packages/ui/src/stores/utils/vscodeRuntime.ts @@ -1,18 +1,14 @@ import type { RuntimeAPIs } from '@/lib/api/types'; +import { + getVSCodeBootstrapConfig, + isVSCodeBootstrapPresent, + type VSCodeBootstrapConfig, +} from '@/lib/vscodeBootstrap'; -export interface VSCodeBootstrapConfig { - workspaceFolder?: unknown; - workspaceFolders?: unknown; -} - -export const getVSCodeBootstrapConfig = (): VSCodeBootstrapConfig | null => { - if (typeof window === 'undefined') { - return null; - } - return (window as unknown as { __VSCODE_CONFIG__?: VSCodeBootstrapConfig }).__VSCODE_CONFIG__ ?? null; -}; +export type { VSCodeBootstrapConfig }; +export { getVSCodeBootstrapConfig }; export const isVSCodeRuntime = ( runtimeApis: RuntimeAPIs | null, - bootstrapConfig = getVSCodeBootstrapConfig(), -): boolean => Boolean(bootstrapConfig || runtimeApis?.runtime?.isVSCode); + bootstrapConfig: VSCodeBootstrapConfig | null = getVSCodeBootstrapConfig(), +): boolean => Boolean(isVSCodeBootstrapPresent(bootstrapConfig) || runtimeApis?.runtime?.isVSCode); diff --git a/packages/ui/src/stores/vscodeStoreInit.2359.test.ts b/packages/ui/src/stores/vscodeStoreInit.2359.test.ts new file mode 100644 index 00000000..6571c162 --- /dev/null +++ b/packages/ui/src/stores/vscodeStoreInit.2359.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test'; + +/** + * Integration-style coverage for #2359: store modules evaluate before + * RuntimeAPIs registration, with only extension-host __VSCODE_CONFIG__ present + * and a stale lastDirectory in storage. + */ + +const WORKSPACE = '/tmp/oc-ws-project-a'; +const STALE = '/tmp/oc-ws-other'; + +const storage = new Map<string, string>([ + ['lastDirectory', STALE], + ['homeDirectory', STALE], +]); + +const installWindow = () => { + (globalThis as { window: unknown }).window = { + __VSCODE_CONFIG__: { + workspaceFolder: WORKSPACE, + workspaceFolders: [{ name: 'oc-ws-project-a', path: WORKSPACE }], + }, + __OPENCHAMBER_HOME__: WORKSPACE, + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + storage.set(key, String(value)); + }, + removeItem: (key: string) => { + storage.delete(key); + }, + }, + matchMedia: () => ({ matches: false, addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {} }), + }; + (globalThis as { localStorage: unknown }).localStorage = (globalThis as { window: { localStorage: unknown } }).window.localStorage; +}; + +mock.module('@/contexts/runtimeAPIRegistry', () => ({ + getRegisteredRuntimeAPIs: () => null, +})); + +mock.module('@/lib/opencode/client', () => ({ + opencodeClient: { + setDirectory: () => undefined, + getDirectory: () => WORKSPACE, + getFilesystemHome: async () => WORKSPACE, + getSystemInfo: async () => ({ homeDirectory: WORKSPACE }), + }, +})); + +mock.module('@/lib/persistence', () => ({ + updateDesktopSettings: async () => undefined, +})); + +mock.module('@/lib/runtime-switch', () => ({ + subscribeRuntimeEndpointChanged: () => () => undefined, + getRuntimeApiBaseUrl: () => 'http://127.0.0.1:9', + getRuntimeKey: () => 'test', +})); + +mock.module('@/stores/useFileSearchStore', () => ({ + useFileSearchStore: { + getState: () => ({ clearCache: () => undefined }), + }, +})); + +describe('VS Code store init before RuntimeAPIs (#2359)', () => { + afterEach(() => { + delete (globalThis as { window?: unknown }).window; + delete (globalThis as { localStorage?: unknown }).localStorage; + }); + + test('desktop isVSCodeRuntime prefers bootstrap config', async () => { + installWindow(); + const { isVSCodeRuntime } = await import('@/lib/desktop'); + expect(isVSCodeRuntime()).toBe(true); + }); + + test('projects helper derives workspace projects without RuntimeAPIs', async () => { + installWindow(); + const { getVSCodeBootstrapConfig, isVSCodeRuntime } = await import('@/stores/utils/vscodeRuntime'); + const config = getVSCodeBootstrapConfig(); + expect(isVSCodeRuntime(null, config)).toBe(true); + expect(config?.workspaceFolder).toBe(WORKSPACE); + }); +}); diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index ba70199a..18e59a04 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -56,7 +56,7 @@ So: | `selection-store.ts` | Model/agent/variant selections | App UI state | | `voice-store.ts` | Voice state | App UI state | -Local chat attachments are normalized by `attachment-files.ts` before entering `input-store.ts`. PNG, JPEG, GIF, WebP, and PDF retain their media type; HEIC/HEIF is converted to JPEG; recognized text/code formats and unknown files whose first 4 KB are text are sent as `text/plain`; binary files outside the supported media types are rejected. Jupyter notebooks become readable markdown with non-text outputs omitted. HAR credentials, cookies, and sensitive URL parameters are redacted, while request/response body text is omitted. SVG and Draw.io files are attached as source text, not executable/rendered content. Browser and VS Code pickers expose the same allowlist, while drag-and-drop may still accept an unknown extension after content inspection. +Local chat attachments are normalized by `attachment-files.ts` before entering `input-store.ts`. PNG, JPEG, GIF, WebP, and PDF retain their media type; HEIC/HEIF is converted to JPEG; recognized text/code formats and unknown files whose first 4 KB are text are sent as `text/plain`; binary files outside the supported media types are rejected. Jupyter notebooks become readable markdown with non-text outputs omitted. HAR credentials, cookies, and sensitive URL parameters are redacted, while request/response body text is omitted. SVG and Draw.io files are attached as source text, not executable/rendered content. Browser and VS Code pickers expose the same allowlist, while drag-and-drop may still accept an unknown extension after content inspection. Large plain-text clipboard pastes can become in-memory `text/plain` attachments named `pasted-context-N.txt` through the composer paste path; they use the same normalization and send pipeline as manually attached `.txt` files. Office and OpenDocument packages are metadata-validated before asynchronous extraction, with limits of 20 MB compressed input, 5,000 archive entries, 25 MB per entry, 8 MB per XML part, and 100 MB total uncompressed content. Unsafe or non-canonical archive paths reject the whole attachment, and only XML, relationship, and supported image entries are decompressed and retained. Extracted text, including its explicit truncation notice, is bounded to 500,000 characters so compact but dense Office files cannot consume an entire model context window. XLSX dense rows are serialized as quoted TSV under a single source range instead of repeating every cell address; highly sparse rows retain explicit cell coordinates so distant cells do not generate vast empty TSV spans. Confirmed Office/OpenDocument `@file` mentions are loaded through the runtime filesystem route before submit and use this same extraction pipeline instead of being forwarded as `text/plain` `file://` parts that OpenCode rejects as binary. A failed mention load or extraction leaves the composer intact, and a runtime switch discards preparation from the previous runtime. At most 50 signature-validated PNG, JPEG, GIF, or WebP images and 40 MB of image bytes are retained, with a 20 MB per-image limit; unsupported, invalid, omitted, and truncated content remains explicit in the extracted text. Images whose citations fall beyond text truncation are not attached. Extracted document content remains a `text/plain` file attachment with the original document filename, rather than becoming visible user-message text. Supported embedded images become separate image file parts; the extracted text contains `[filename]` citations at the source paragraph, slide object, spreadsheet cell anchor, or OpenDocument text position. Generated image filenames are re-evaluated if the composer changes during asynchronous preparation, avoiding collisions. The store publishes all generated parts atomically only after every data URL is ready. @@ -220,6 +220,10 @@ The event pipeline delivers each ordered per-directory flush as one reducer batc Streaming lifecycle derivation has two paths. Directory attach, switch, bootstrap, and reconnect may perform a full reconciliation. Normal store publications reconcile only sessions whose `session_status` or `message` bucket changed; part-only events update the affected streaming message heartbeat directly and must not rescan all busy sessions. +A trailing assistant message that the server stamped `time.completed` is never marked as streaming: the stamp means the whole response (text plus every tool call) finished, so even while the session stays busy for the next step of the turn, the typing indicator and the streaming part-update suspension must not linger on finished content. The message-level streaming state (`streamingMessageIds` / `messageStreamStates`) is therefore a *message* lifecycle, not a turn lifecycle — it is completed by an explicit `time.completed`, by a newer trailing message, or by the session leaving `busy`. + +When an assistant `message.updated` event carries `time.completed` and the store still believes the session busy, sync schedules one deferred status check (`maybePollStatusAfterMessageCompletion`, ~750ms). The status is re-read when the timer fires, so a normal turn whose `session.idle` lands inside that window issues no request at all; only a still-busy session spends a directory status poll, sharing the watchdog's one-in-flight-per-directory guard. The invariant is unchanged from the watchdog escalation: the monotonic pass confirms or raises active status and never lowers it, and an authoritative resync runs only when the snapshot disagrees with a store that still believes the session busy. This narrows the stuck-spinner window after a lost `session.idle` from a watchdog interval to one round-trip; the 5s watchdog poll remains the backstop. + Incomplete-session materialization is deduplicated by runtime, directory, and session for the full cooldown window, including after a fast success or failure. A settled-running-tool recovery may supersede a different request in that window so an earlier pre-settlement refresh cannot consume the only terminal recovery signal. Deferred recovery is dropped if its captured runtime is no longer active. If recovery requests a tail refresh while an older load is in flight, one refresh runs after that load instead of losing the newer authority demand. Completion retains the cooldown marker until expiry, and an older completion cannot clear a newer request marker. Recovery starts after the current ordered event batch and rechecks whether local state already contains the requested entity before starting HTTP. An explicit empty part bucket is authoritative fetched-empty state, not a missing snapshot. This prevents repeated orphan/missing-part events from creating message-tail and status request storms while preserving later recovery. When `session.idle` or `session.error` settles a session but the trailing assistant message still contains a `pending` or `running` tool, sync refreshes that session tail. This narrowly reconciles a missed terminal tool-part event without refetching normally completed turns or stale tools from older turns. A stale refresh or delayed part event cannot regress a locally observed terminal tool to an active status. diff --git a/packages/ui/src/sync/__tests__/message-completion-status-poll.test.ts b/packages/ui/src/sync/__tests__/message-completion-status-poll.test.ts new file mode 100644 index 00000000..09aa3d58 --- /dev/null +++ b/packages/ui/src/sync/__tests__/message-completion-status-poll.test.ts @@ -0,0 +1,141 @@ +/** + * Tests for the deferred status poll fired when an assistant message completes + * (issue OPE-193): the busy spinner must not linger for up to a full watchdog + * poll interval after a turn completed when the session.idle event was delayed + * or lost — and a normal turn, whose session.idle arrives promptly, must not + * cost a single extra request. + */ +import { beforeEach, describe, expect, mock, test } from "bun:test" +import { create, type StoreApi } from "zustand" +import type { SessionStatus } from "@opencode-ai/sdk/v2/client" +import { INITIAL_STATE } from "../types" +import type { DirectoryStore } from "../child-store" + +type StatusSnapshot = Record<string, SessionStatus | undefined> + +let respondWithSnapshot: () => Promise<StatusSnapshot | null> = () => Promise.resolve({ ses_1: { type: "idle" } }) +const statusSnapshotCalls: string[] = [] + +mock.module("@/lib/opencode/client", () => ({ + opencodeClient: { + getSessionStatusForDirectory: mock((directory: string) => { + statusSnapshotCalls.push(directory) + return respondWithSnapshot() + }), + }, +})) + +mock.module("@/lib/runtime-switch", () => ({ + getRuntimeKey: () => "test-runtime", +})) + +import { maybePollStatusAfterMessageCompletion, MESSAGE_COMPLETION_STATUS_POLL_DELAY_MS } from "../sync-context" + +const createStore = (status: SessionStatus): StoreApi<DirectoryStore> => { + return create<DirectoryStore>()((set) => ({ + ...INITIAL_STATE, + session_status: { ses_1: status }, + patch: (partial) => set(partial), + replace: (next) => set(next), + })) +} + +const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)) + +/** Past the deferral, plus room for the background-network task chain. */ +const waitForPollSettled = async (): Promise<void> => { + await sleep(MESSAGE_COMPLETION_STATUS_POLL_DELAY_MS + 50) + await sleep(50) +} + +describe("maybePollStatusAfterMessageCompletion (issue OPE-193)", () => { + beforeEach(() => { + respondWithSnapshot = () => Promise.resolve({ ses_1: { type: "idle" } }) + statusSnapshotCalls.length = 0 + }) + + test("does not poll when the store believes the session is already idle", async () => { + const store = createStore({ type: "idle" }) + + maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1") + await waitForPollSettled() + + expect(statusSnapshotCalls).toEqual([]) + expect(store.getState().session_status?.ses_1?.type).toBe("idle") + }) + + test("does not poll without a directory or session id", async () => { + const store = createStore({ type: "busy" }) + + maybePollStatusAfterMessageCompletion("", store, "ses_1") + maybePollStatusAfterMessageCompletion("global", store, "ses_1") + maybePollStatusAfterMessageCompletion("/test/project", store, "") + await waitForPollSettled() + + expect(statusSnapshotCalls).toEqual([]) + }) + + test("issues no request when session.idle arrives inside the deferral window", async () => { + const store = createStore({ type: "busy" }) + + maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1") + // The turn's own session.idle event lands well before the timer fires. + await sleep(50) + store.getState().patch({ session_status: { ses_1: { type: "idle" } } }) + await waitForPollSettled() + + expect(statusSnapshotCalls).toEqual([]) + }) + + test("settles a busy session to idle when the idle event never arrives", async () => { + const store = createStore({ type: "busy" }) + + maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1") + // Nothing settles the session inside the window; the poll must run. + expect(statusSnapshotCalls).toEqual([]) + + await waitForPollSettled() + + expect(statusSnapshotCalls).toEqual(["/test/project", "/test/project"]) + expect(store.getState().session_status?.ses_1?.type).toBe("idle") + }) + + test("keeps the session busy when the snapshot confirms it is still active", async () => { + const store = createStore({ type: "busy" }) + respondWithSnapshot = () => Promise.resolve({ ses_1: { type: "busy" } }) + + maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1") + await waitForPollSettled() + + // Monotonic poll confirms busy; the snapshot is not idle, so no + // authoritative escalation runs. + expect(statusSnapshotCalls).toEqual(["/test/project"]) + expect(store.getState().session_status?.ses_1?.type).toBe("busy") + }) + + test("preserves the busy status when the status fetch fails", async () => { + const store = createStore({ type: "busy" }) + respondWithSnapshot = () => Promise.resolve(null) + + maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1") + await waitForPollSettled() + + expect(statusSnapshotCalls).toEqual(["/test/project"]) + // Failure is not treated as authoritative empty: the busy status stays + // until the watchdog poll (or a live event) corrects it. + expect(store.getState().session_status?.ses_1?.type).toBe("busy") + }) + + test("schedules one check for a burst of completions on the same session", async () => { + const store = createStore({ type: "busy" }) + + maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1") + maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1") + maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1") + await waitForPollSettled() + + // One monotonic poll plus its authoritative escalation, not three. + expect(statusSnapshotCalls).toEqual(["/test/project", "/test/project"]) + expect(store.getState().session_status?.ses_1?.type).toBe("idle") + }) +}) diff --git a/packages/ui/src/sync/streaming.test.ts b/packages/ui/src/sync/streaming.test.ts index 326f07d6..0062b65c 100644 --- a/packages/ui/src/sync/streaming.test.ts +++ b/packages/ui/src/sync/streaming.test.ts @@ -16,8 +16,16 @@ import { const message = (id: string, role: "user" | "assistant"): Message => ({ id, role, + time: { created: 1 }, } as unknown as Message) +const completedAssistantMessage = (id: string): Message => { + const base = message(id, "assistant") + // SAFETY: test fixture — the streaming reducers read only `id`, `role`, and + // `time.completed`, which this literal provides. + return { ...base, time: { created: 1, completed: 100 } } as Message +} + const stateWithMessages = (messages: Message[], status: SessionStatus = { type: "busy" } as SessionStatus): State => ({ ...INITIAL_STATE, session_status: { @@ -163,4 +171,72 @@ describe("updateStreamingState", () => { expect(streaming.messageStreamStates.get("msg_assistant_1")?.phase).toBe("completed") expect(streaming.messageStreamStates.get("msg_assistant_2")?.phase).toBe("streaming") }) + + test("completes a streaming message when the trailing assistant message finishes while the session stays busy", () => { + updateStreamingState(stateWithMessages([ + message("msg_user_1", "user"), + message("msg_assistant_1", "assistant"), + ])) + expect(useStreamingStore.getState().streamingMessageIds.get("ses_1")).toBe("msg_assistant_1") + + // The message completed (time.completed) but the turn keeps running + // (next step / tool phase) — the finished message must not stay marked + // as streaming with the typing indicator and part-update suspension on it. + updateStreamingState(stateWithMessages([ + message("msg_user_1", "user"), + completedAssistantMessage("msg_assistant_1"), + ])) + + const streaming = useStreamingStore.getState() + expect(streaming.streamingMessageIds.get("ses_1")).toBeNull() + expect(streaming.messageStreamStates.get("msg_assistant_1")?.phase).toBe("completed") + }) + + test("does not mark an already-completed trailing assistant message as streaming", () => { + updateStreamingState(stateWithMessages([ + message("msg_user_1", "user"), + completedAssistantMessage("msg_assistant_1"), + ])) + + const streaming = useStreamingStore.getState() + expect(streaming.streamingMessageIds.get("ses_1") ?? null).toBeNull() + expect(streaming.messageStreamStates.has("msg_assistant_1")).toBe(false) + }) + + test("incrementally clears the streaming marker when the trailing message completes while busy", () => { + const previous = stateWithMessages([ + message("msg_user_1", "user"), + message("msg_assistant_1", "assistant"), + ]) + updateStreamingState(previous, 10) + expect(useStreamingStore.getState().streamingMessageIds.get("ses_1")).toBe("msg_assistant_1") + + const next = stateWithMessages([ + message("msg_user_1", "user"), + completedAssistantMessage("msg_assistant_1"), + ]) + updateChangedStreamingSessions(next, previous, 20) + + const streaming = useStreamingStore.getState() + expect(streaming.streamingMessageIds.get("ses_1")).toBeNull() + expect(streaming.messageStreamStates.get("msg_assistant_1")?.phase).toBe("completed") + }) + + test("keeps the next assistant message streaming after an intermediate message completed while busy", () => { + const previous = stateWithMessages([ + message("msg_user_1", "user"), + completedAssistantMessage("msg_assistant_1"), + ]) + updateStreamingState(previous, 10) + expect(useStreamingStore.getState().streamingMessageIds.get("ses_1") ?? null).toBeNull() + + const next = stateWithMessages([ + message("msg_user_1", "user"), + completedAssistantMessage("msg_assistant_1"), + message("msg_assistant_2", "assistant"), + ]) + updateChangedStreamingSessions(next, previous, 20) + + expect(useStreamingStore.getState().streamingMessageIds.get("ses_1")).toBe("msg_assistant_2") + }) }) diff --git a/packages/ui/src/sync/streaming.ts b/packages/ui/src/sync/streaming.ts index ab62ac99..5ca44ca3 100644 --- a/packages/ui/src/sync/streaming.ts +++ b/packages/ui/src/sync/streaming.ts @@ -58,6 +58,18 @@ const findTrailingAssistantMessage = (messages: Message[] | undefined): Message return null } +/** + * The server stamps `time.completed` on an assistant message only after its + * whole response (text + every tool call) finished. A completed trailing + * message therefore means the message itself is done even when the turn keeps + * running (next step, follow-up tool phase) — it must not stay marked as + * streaming, or the typing indicator and the part-update suspension linger on + * finished content until the session settles. + */ +const isTrailingMessageComplete = (message: Message): boolean => { + return message.role === "assistant" && message.time.completed !== undefined +} + export function updateStreamingState(state: State, now = Date.now()) { countSyncPerformance("streamingFullReconciliations") const currentStore = useStreamingStore.getState() @@ -108,6 +120,18 @@ export function updateStreamingState(state: State, now = Date.now()) { continue } + // The trailing assistant message already finished (time.completed), so + // nothing is streaming right now even though the session stays busy for + // the rest of the turn. Complete any previously streaming message instead + // of re-marking the finished one as streaming. + if (isTrailingMessageComplete(streamingMsg)) { + const prevId = currentStreamingIds.get(sessionID) + if (prevId) { + completeStreamingMessage(sessionID, prevId) + } + continue + } + const prevId = currentStreamingIds.get(sessionID) if (prevId !== streamingMsg.id) changed = true nextStreamingIds.set(sessionID, streamingMsg.id) @@ -222,6 +246,14 @@ export function updateChangedStreamingSessions(state: State, previous: State, no continue } + // Completed trailing message while the turn keeps running: nothing is + // streaming — clear the marker and any previous streaming message instead + // of keeping the finished message flagged as streaming. + if (isTrailingMessageComplete(streamingMessage)) { + if (previousMessageID) complete(sessionID, previousMessageID) + continue + } + if (previousMessageID && previousMessageID !== streamingMessage.id) { complete(sessionID, previousMessageID) } diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 718b1b29..2b6215d2 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -341,6 +341,18 @@ type PendingSessionMaterialization = { const SESSION_MATERIALIZATION_COOLDOWN_MS = 5_000 const pendingSessionMaterializations = new Map<string, PendingSessionMaterialization>() +// One in-flight directory status fetch at a time, shared by the active-session +// watchdog poll and the deferred completion poll so the two cannot overlap on +// the same directory. +const statusPollingDirectories = new Set<string>() + +// Deferred completion polls awaiting their delay, keyed by directory+session so +// a burst of completing messages schedules one check. +const pendingMessageCompletionPolls = new Map<string, ReturnType<typeof setTimeout>>() + +// How long to wait for the turn's own `session.idle` before spending a request. +export const MESSAGE_COMPLETION_STATUS_POLL_DELAY_MS = 750 + function enqueueSessionMaterialization( directory: string, sessionID: string, @@ -731,6 +743,63 @@ async function resyncDirectorySessionStatuses( return nextStatuses } +/** + * Re-check the session status shortly after an assistant message completes. + * The turn-ending `session.idle` event can be delayed or lost; left alone, the + * busy spinner keeps showing until the next watchdog poll tick (up to ~5s) and + * its escalation (up to ~10s). + * + * The check is deferred by `MESSAGE_COMPLETION_STATUS_POLL_DELAY_MS`, and the + * status is read again when the timer fires: a normal turn whose `session.idle` + * arrives inside that window settles on its own and issues no request at all. + * Only a session the store still believes busy costs one status fetch, which + * mirrors the watchdog escalation — the monotonic pass confirms/raises busy but + * never lowers it, and when the snapshot reports the session idle while the + * store still believes it busy, an authoritative resync settles the status. + * + * Bounded: one scheduled check per session, one in-flight status fetch per + * directory (shared with the watchdog poll), best-effort — the watchdog poll + * remains the backstop. + */ +export function maybePollStatusAfterMessageCompletion( + directory: string, + store: StoreApi<DirectoryStore>, + sessionID: string, +): void { + if (!directory || directory === "global" || !sessionID) return + const current = store.getState().session_status?.[sessionID] + if (!current || current.type === "idle") return + + const pendingKey = `${directory}\u0000${sessionID}` + if (pendingMessageCompletionPolls.has(pendingKey)) return + + const timer = setTimeout(() => { + pendingMessageCompletionPolls.delete(pendingKey) + const latest = store.getState().session_status?.[sessionID] + if (!latest || latest.type === "idle") return + if (statusPollingDirectories.has(directory)) return + + statusPollingDirectories.add(directory) + void (async () => { + try { + const statuses = await runBackgroundNetworkTask(() => + resyncDirectorySessionStatuses(directory, store, [sessionID], "monotonic")) + if (!statuses) return + if (needsSnapshotAfterStatusPoll(store.getState(), sessionID, statuses[sessionID])) { + await runBackgroundNetworkTask(() => + resyncDirectorySessionStatuses(directory, store, [sessionID], "authoritative")) + } + } catch { + // Best-effort — the watchdog poll retries on its own cadence. + } finally { + statusPollingDirectories.delete(directory) + } + })() + }, MESSAGE_COMPLETION_STATUS_POLL_DELAY_MS) + + pendingMessageCompletionPolls.set(pendingKey, timer) +} + // After a monotonic poll, decide whether to escalate to a full authoritative // resync: the store believes the session is active but the snapshot reports it // idle/absent — a suspected missed idle that the monotonic poll deliberately @@ -1854,6 +1923,12 @@ export function handleEvent( messageID, }) } + // An assistant message that finished is strong evidence the turn may + // have ended; if the session.idle event was delayed or lost, settle the + // busy status immediately instead of waiting for the next watchdog poll. + if (info.role === "assistant" && typeof info.time?.completed === "number") { + maybePollStatusAfterMessageCompletion(resolvedDirectory, store, sessionID) + } } } else { const sessionID = getSessionIdFromPayload(payload) ?? undefined @@ -2069,7 +2144,6 @@ export function SyncProvider(props: { const lastChildDiscoveryAtByDirectoryRef = useRef(new Map<string, number>()) const resyncingDirectoriesRef = useRef(new Set<string>()) const blockingRequestResyncingDirectoriesRef = useRef(new Set<string>()) - const statusPollingDirectoriesRef = useRef(new Set<string>()) const pipelineReconnectRef = useRef<((reason?: string) => void) | null>(null) const pipelineHasConnectedRef = useRef(false) const pipelineDisconnectedBeforeFirstConnectRef = useRef(false) @@ -2432,7 +2506,7 @@ export function SyncProvider(props: { store: StoreApi<DirectoryStore>, candidateSessionIds: string[], ) => { - const polling = statusPollingDirectoriesRef.current + const polling = statusPollingDirectories if (polling.has(directory)) return polling.add(directory) try { @@ -2490,7 +2564,7 @@ export function SyncProvider(props: { .finally(() => { running = false if (stopped) { - statusPollingDirectoriesRef.current.clear() + statusPollingDirectories.clear() } }) } diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 2d079e7a..17e48784 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,8 +1,25 @@ ## [Unreleased] -- Picking a remote branch such as `origin/main` in the Git branch selector now switches you to that branch instead of leaving the repository on a detached `HEAD` with no branch name. -- GitHub Copilot usage now shows a single AI Credits window, matching Copilot's token-based quota, in place of the old Chat Requests and Completions windows (thanks to @jakoss). -- The context usage readout now reports the session cost including everything its subagents spent, matching the work status panel instead of showing a lower figure. +- **Turkish interface:** OpenChamber can now be used in Turkish (thanks to @fitzgpt). +- **`/btw` side questions:** a btw session now answers the side question instead of carrying on with the parent's plan, and forks at the last completed turn so a reply that is still streaming is never inherited (thanks to @pocharlies). +- Chat scrolling: with "Follow new content while streaming" off, sending while scrolled up leaves the view where it is; a middle-button pan or Shift+Space stops auto-follow like the wheel does (thanks to @pascalandr); PageUp/PageDown in the prompt box no longer shifts the whole panel up. +- Chat no longer crashes or freezes on: very large tool results, which are capped before rendering (thanks to @JSap0914); a code block with JavaScript template strings that sent the highlighter into endless backtracking (thanks to @makeittech); a diff with a truncated header (thanks to @pascalandr); and a draft or recalled message with Windows line endings, which threw "Selection points outside of document" on every visit (thanks to @yulia-ivashko). +- Chat: a session no longer looks frozen after the webview reloads or is opened late — pending permission and question cards come back (thanks to @yangyaofei) — nor after dismissing the agent's questions and sending a new task (thanks to @bashrusakh). +- Context usage now reports the session cost including everything its subagents spent (thanks to @igorvelho), and undoing or redoing a parent session keeps its subagents at the same point in history (thanks to @alexandrereyes). +- Chat rendering: question prompts render Markdown (thanks to @pascalandr); bare links next to CJK or full-width punctuation no longer absorb it (thanks to @gaojunran); inline code, chips, and model-picker highlights stay readable in high-contrast themes (thanks to @difagume and @bashrusakh); a completed reasoning block shows in full instead of replaying, the text-selection menu stays inside the viewport, and the sticky user-message header no longer fades over the reply (thanks to @makeittech). +- Chat actions: tool cards with a file path get a quick-open button that opens the file in the editor (thanks to @robertoberto); sending without a selected model explains what is missing (thanks to @rvaldemar); `/init` stays in slash-command autocomplete after the conversation starts (thanks to @Dawnfz-Lenfeng); copying a message keeps Markdown spacing (thanks to @ChangeHow); a manually chosen model survives switching between Build and Plan (thanks to @makeittech). +- Composer: pasting a large block of text now offers to attach it as a `pasted-context-N.txt` file instead of flooding the input, with a reference left at the caret; Settings → Chat can make it always attach or always paste inline (thanks to @makeittech). +- Chat: the text the model writes before asking a question is shown right away instead of staying hidden until the turn ends (thanks to @makeittech). +- Chat: when the turn-ending signal from OpenCode is lost, the working spinner now clears within about a second instead of up to ten (thanks to @makeittech). +- Composer: typing three backticks leaves the caret inside the completed code fence, empty inputs keep a visible caret, and platform autocorrect behavior is preserved (thanks to @franzudev, @TTTPOB, and @IbrahimKhan12). +- GitHub Copilot usage now shows a single AI Credits window, matching Copilot's token-based quota (thanks to @jakoss). +- Updating OpenCode no longer fails with a bare "Bad Request": the extension names the release to install and shows OpenCode's own reason when an update is refused (thanks to @mdatsev and @yulia-ivashko). +- "Add Project" now adds the chosen folder to the workspace instead of failing (thanks to @bashrusakh), and the extension starts in the current workspace folder instead of one restored from storage (thanks to @makeittech). +- Multi-Run groups can now contain more than five models (thanks to @tomzx). +- Sidebar: pending permission and question badges are no longer covered by the hover actions (thanks to @makeittech); worktree branch search hides non-matching branches (thanks to @bashrusakh). +- Settings: number fields and selects no longer clip at large font sizes (thanks to @makeittech), and Windows skill paths are classified correctly, so disabled and duplicate skills are hidden as intended (thanks to @Ttungx). +- Windows: closing VS Code now stops the managed OpenCode process instead of leaving it running (thanks to @a0000001). +- The extension reuses its OpenCode output channel across managed-server restarts instead of creating duplicates (thanks to @TTTPOB). ## [1.21.0] - 2026-08-26 diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index fc758a7f..7d0abcff 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -763,82 +763,24 @@ async function getGitBranchesRaw(directory: string): Promise<GitBranchResult> { return { all, current, branches }; } -const gitRefExists = async (directory: string, ref: string): Promise<boolean> => { - const result = await execGit(['show-ref', '--verify', '--quiet', ref], directory); - return result.exitCode === 0; -}; - -/** - * The branch selector lists remote-tracking branches beside local ones, so - * picking `origin/main` means "work on main", not "detach HEAD at the remote's - * commit" — which is what a literal checkout of a remote-tracking ref does. - * Resolve such a pick to the local branch, creating it with tracking when it - * does not exist yet. Anything we cannot resolve is checked out as requested, - * leaving git's own DWIM behavior intact. - */ -const resolveBranchCheckoutTarget = async ( - directory: string, - branch: string -): Promise<{ branch: string; remoteRef: string | null }> => { - const requested = String(branch || '').trim(); - const asRequested = { branch: requested, remoteRef: null }; - if (!requested) { - return asRequested; - } - - if (await gitRefExists(directory, `refs/heads/${requested}`)) { - return asRequested; - } - - const remoteRef = requested.replace(/^remotes\//, ''); - if (!(await gitRefExists(directory, `refs/remotes/${remoteRef}`))) { - return asRequested; - } - - const remotesResult = await execGit(['remote'], directory); - const remotes = remotesResult.exitCode === 0 - ? remotesResult.stdout.split('\n').map((line) => line.trim()).filter(Boolean) - : []; - const remote = remotes.find((name) => remoteRef.startsWith(`${name}/`)); - if (!remote) { - return asRequested; - } - - const localBranch = remoteRef.slice(remote.length + 1); - // `origin/HEAD` names no branch of its own; it is a pointer to one. - if (!localBranch || localBranch === 'HEAD') { - return asRequested; - } - - const localExists = await gitRefExists(directory, `refs/heads/${localBranch}`); - return { branch: localBranch, remoteRef: localExists ? null : remoteRef }; -}; - /** * Checkout a branch */ export async function checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }> { - const target = await resolveBranchCheckoutTarget(directory, branch); - - if (target.remoteRef) { - const tracked = await execGit(['checkout', '-b', target.branch, '--track', target.remoteRef], directory); - return { success: tracked.exitCode === 0, branch: target.branch }; - } - const repo = await getRepository(directory); if (repo) { try { - await repo.checkout(target.branch); - return { success: true, branch: target.branch }; + await repo.checkout(branch); + return { success: true, branch }; } catch (error) { console.error('[GitService] Failed to checkout branch:', error); } } // Fallback to raw git - const result = await execGit(['checkout', target.branch], directory); - return { success: result.exitCode === 0, branch: target.branch }; + const result = await execGit(['checkout', branch], directory); + return { success: result.exitCode === 0, branch }; } /** diff --git a/packages/vscode/src/opencode-upgrade-runtime.test.ts b/packages/vscode/src/opencode-upgrade-runtime.test.ts index 6d872f52..5f1be9f2 100644 --- a/packages/vscode/src/opencode-upgrade-runtime.test.ts +++ b/packages/vscode/src/opencode-upgrade-runtime.test.ts @@ -75,16 +75,72 @@ describe('VS Code OpenCode upgrades', () => { assert.equal((request?.headers as Record<string, string>).Authorization, 'Basic test'); }); + test('names the latest release when the caller sends no target', async () => { + const { manager } = createManager(); + let upgradeBody: unknown; + // SAFETY: the stub answers the only two call shapes this test exercises — + // a URL string and an init bag — which is all `fetch` is used with here. + globalThis.fetch = (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => { + const url = String(input); + if (url.includes('registry.npmjs.org')) return new Response(JSON.stringify({ version: '1.18.23' })); + if (url.includes('api.github.com')) return new Response(JSON.stringify({ tag_name: 'v1.18.23' })); + upgradeBody = JSON.parse(String(init?.body)); + return new Response(JSON.stringify({ success: true, version: '1.18.23' })); + }) as typeof fetch; + + assert.equal((await upgradeManagedOpenCode(manager)).status, 200); + assert.deepEqual(upgradeBody, { target: '1.18.23' }); + }); + + test('fails without calling the updater when the latest release cannot be resolved', async () => { + const { manager, getRestartCount } = createManager(); + // SAFETY: the stub answers the only call shape this test exercises — a URL + // string — and fails loudly if the updater is reached at all. + globalThis.fetch = (async (input: Parameters<typeof fetch>[0]) => { + if (String(input).endsWith('/global/upgrade')) throw new Error('the updater must not be called without a target'); + return new Response('nope', { status: 503 }); + }) as typeof fetch; + + const result = await upgradeManagedOpenCode(manager); + assert.equal(result.status, 502); + assert.equal(result.body.code, 'OPENCODE_UPGRADE_TARGET_UNRESOLVED'); + assert.equal(getRestartCount(), 0); + }); + + test('surfaces the rejection OpenCode reported instead of the bare HTTP status', async () => { + const { manager } = createManager(); + // SAFETY: the stub ignores its arguments and answers every call with the + // rejection shape under test, so no call signature is misrepresented. + globalThis.fetch = (async () => new Response( + JSON.stringify({ name: 'BadRequest', data: { message: 'Expected a semantic version', kind: 'Payload' } }), + { status: 400 }, + )) as typeof fetch; + + assert.deepEqual(await upgradeManagedOpenCode(manager, '1.18.9'), { + status: 400, + body: { success: false, error: 'Expected a semantic version' }, + }); + }); + test('serializes concurrent managed upgrades', async () => { const { manager } = createManager(); let release: (response: Response) => void = () => {}; - globalThis.fetch = (() => new Promise<Response>((resolve) => { release = resolve; })) as typeof fetch; + let upgradeCalled: () => void = () => {}; + const upgradeReached = new Promise<void>((resolve) => { upgradeCalled = resolve; }); + globalThis.fetch = ((input: Parameters<typeof fetch>[0]) => { + if (!String(input).endsWith('/global/upgrade')) { + return Promise.resolve(new Response(JSON.stringify({ version: '1.18.9' }))); + } + upgradeCalled(); + return new Promise<Response>((resolve) => { release = resolve; }); + }) as typeof fetch; const first = upgradeManagedOpenCode(manager); const second = await upgradeManagedOpenCode(manager); assert.equal(second.status, 409); assert.equal(second.body.code, 'OPENCODE_UPGRADE_IN_PROGRESS'); + await upgradeReached; release(new Response(JSON.stringify({ success: true }))); assert.equal((await first).status, 200); }); diff --git a/packages/vscode/src/opencode-upgrade-runtime.ts b/packages/vscode/src/opencode-upgrade-runtime.ts index 883957f1..7ea0433e 100644 --- a/packages/vscode/src/opencode-upgrade-runtime.ts +++ b/packages/vscode/src/opencode-upgrade-runtime.ts @@ -77,6 +77,19 @@ const fetchLatestVersion = async (): Promise<string> => { return versions.sort((left, right) => compareVersions(right, left))[0]; }; +// OpenCode reports a rejected upgrade as `{ name, data: { message, kind } }`, +// which carries no `error` field. Reading only `error` left the user with the +// bare HTTP status text ("Bad Request") and nothing to act on. +const readUpgradeErrorMessage = ( + payload: { error?: unknown; message?: unknown; data?: { message?: unknown } } | null, + response: Response, +): string => { + for (const candidate of [payload?.error, payload?.data?.message, payload?.message]) { + if (typeof candidate === 'string' && candidate.trim().length > 0) return candidate.trim(); + } + return response.statusText || 'Failed to upgrade OpenCode'; +}; + export const getOpenCodeUpgradeStatus = async (manager?: OpenCodeUpgradeManager): Promise<Record<string, unknown>> => { const upgrade = getCapability(manager); const apiUrl = getApiUrl(manager); @@ -107,16 +120,33 @@ export const upgradeManagedOpenCode = async (manager: OpenCodeUpgradeManager | u if (openCodeUpgradePromise) { return { status: 409, body: { success: false, code: 'OPENCODE_UPGRADE_IN_PROGRESS', error: 'An OpenCode upgrade is already in progress.' } }; } - const targetVersion = typeof target === 'string' ? target.trim() : ''; + const requestedTarget = typeof target === 'string' ? target.trim() : ''; const operation = (async (): Promise<UpgradeResult> => { + // The lookup runs inside the operation so the in-flight lock above already + // holds while the release version is resolved. + let targetVersion = requestedTarget; + if (!targetVersion) { + try { + targetVersion = await fetchLatestVersion(); + } catch (error) { + return { + status: 502, + body: { + success: false, + code: 'OPENCODE_UPGRADE_TARGET_UNRESOLVED', + error: `Could not determine which OpenCode version to install: ${error instanceof Error ? error.message : String(error)}`, + }, + }; + } + } try { const response = await fetch(new URL('global/upgrade', apiUrl).toString(), { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...manager.getOpenCodeAuthHeaders() }, - body: JSON.stringify(targetVersion ? { target: targetVersion } : {}), + body: JSON.stringify({ target: targetVersion }), }); - const payload = await response.json().catch(() => null) as { error?: unknown } | null; - if (!response.ok) return { status: response.status, body: { success: false, error: typeof payload?.error === 'string' ? payload.error : response.statusText || 'Failed to upgrade OpenCode' } }; + const payload = await response.json().catch(() => null) as { error?: unknown; message?: unknown; data?: { message?: unknown } } | null; + if (!response.ok) return { status: response.status, body: { success: false, error: readUpgradeErrorMessage(payload, response) } }; try { await manager.restart(); } catch (error) { diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 7c174659..7fffdc21 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -76,6 +76,7 @@ import { configureOpenCodeRuntimeProviders, resetOpenCodeRuntimeProviders } from import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js'; import { createSessionAssistRuntime } from './lib/session-assist/runtime.js'; import { createSessionGoalRuntime } from './lib/session-goal/runtime.js'; +import { applySmallModelOverrideToOpenCodeConfig } from './lib/small-model/config-injection.js'; import { createContextObligatoryRuntime } from './lib/context-obligatory/runtime.js'; import { createSessionKnowledgeRuntime } from './lib/session-knowledge/runtime.js'; import { createScheduledTasksRuntime } from './lib/scheduled-tasks/runtime.js'; @@ -1204,11 +1205,25 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({ const managedEnv = includeControl || includeWeb || includeMemory ? await (agentToolRuntime?.prepareManagedOpenCodeEnv({ includeControl, includeWeb, includeMemory }) || {}) : {}; - if (settings?.optimizeSystemPrompt !== true) return managedEnv; + const envWithSystemPrompt = settings?.optimizeSystemPrompt === true + ? { + ...managedEnv, + ...(await systemPromptRuntime.prepareManagedOpenCodeEnv( + managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT, + )), + } + : managedEnv; - const configContent = managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT; - const systemPromptEnv = await systemPromptRuntime.prepareManagedOpenCodeEnv(configContent); - return { ...managedEnv, ...systemPromptEnv }; + // Apply the explicit Small Model override to the managed OpenCode config + // so OpenCode's own title/summary generation uses the user's chosen model. + const configContent = envWithSystemPrompt.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT; + const withSmallModel = applySmallModelOverrideToOpenCodeConfig({ + configContent, + smallModelUseDefault: settings?.smallModelUseDefault, + smallModelOverride: settings?.smallModelOverride, + }); + if (withSmallModel === configContent) return envWithSystemPrompt; + return { ...envWithSystemPrompt, OPENCODE_CONFIG_CONTENT: withSmallModel }; }, }); diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index 818d71ad..4ccaf7ad 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -121,7 +121,7 @@ The following functions are internal helpers used by exported functions: - `rebaseInProgress`: Object with `{ headName, onto }` if rebase in progress. ### Branches Response -- `all`: Local branches plus remote-tracking branches that still exist on their remote. A remote that fails to answer keeps its branches in the list: "we could not ask" must not be reported as "these branches are gone", because callers use this list to decide whether a base branch exists at all. +- `all`: Local branches plus every branch each reachable remote reports via `ls-remote --heads`, formatted as `remotes/<remote>/<branch>`. This is a union: local remote-tracking refs deleted on the remote are pruned, and branches that exist on the remote without a local tracking ref (never fetched) are still included, so a freshly pushed branch appears without requiring a fetch. A remote that fails to answer keeps its locally known branches in the list: "we could not ask" must not be reported as "these branches are gone", because callers use this list to decide whether a base branch exists at all. - `current`: Current branch name. - `branches`: Per-branch detail keyed by branch name, as reported by `git branch`. - `defaultBranches`: Each remote's default branch, keyed by remote name. Read from the local `remotes/<name>/HEAD` symbolic ref; for a remote that has none — clone writes it, a hand-added remote may not — the remote itself is asked once with `ls-remote --symref`. A remote that answers neither is absent rather than guessed, and consumers fall back to conventional branch names. Omitted entirely by runtimes that do not provide this Git metadata. diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index f22f4ba3..112a440f 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -3747,7 +3747,7 @@ async function filterActiveRemoteBranches(git, remoteBranches) { } })); - return remoteBranches.filter(remoteBranch => { + const activeBranches = remoteBranches.filter(remoteBranch => { const match = remoteBranch.match(/^remotes\/[^\/]+\/(.+)$/); if (!match) return false; const remoteName = remoteBranch.split('/')[1]; @@ -3755,6 +3755,25 @@ async function filterActiveRemoteBranches(git, remoteBranches) { if (unreachableRemotes.has(remoteName)) return true; return branchesByRemote.get(remoteName)?.has(branchName) ?? false; }); + + // A branch pushed to the remote that was never fetched locally has no + // remote-tracking ref, so `git branch` never reports it — but ls-remote + // just told us it exists. Add those so a freshly pushed branch shows up + // without requiring a fetch first (#2098). Unreachable remotes have no + // ls-remote data and therefore add nothing here; their local view above + // is preserved unchanged. + const seenBranches = new Set(activeBranches); + for (const [remoteName, actualRemoteBranches] of branchesByRemote) { + for (const branchName of actualRemoteBranches) { + const qualifiedBranch = `remotes/${remoteName}/${branchName}`; + if (!seenBranches.has(qualifiedBranch)) { + seenBranches.add(qualifiedBranch); + activeBranches.push(qualifiedBranch); + } + } + } + + return activeBranches; } catch (error) { console.warn('Failed to filter active remote branches, returning all:', error.message); return remoteBranches; diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index 4df73b3a..cf996cd7 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -1429,6 +1429,47 @@ describe.runIf(canRunGit())('getBranches', () => { // decide whether a base branch exists at all. expect(branches.all).toContain('remotes/origin/react'); }); + + it('includes remote branches with no local tracking ref and prunes refs deleted on the remote (#2098)', async () => { + const remote = createTempDir(); + runGit(remote, ['init', '--bare', '--initial-branch=main']); + + const repository = createTempDir(); + runGit(repository, ['init', '-b', 'main']); + runGit(repository, ['config', 'user.email', 'test@example.com']); + runGit(repository, ['config', 'user.name', 'Test']); + fs.writeFileSync(path.join(repository, 'README.md'), '# Test\n'); + runGit(repository, ['add', 'README.md']); + runGit(repository, ['commit', '-m', 'init']); + runGit(repository, ['remote', 'add', 'origin', remote]); + runGit(repository, ['push', '-u', 'origin', 'main']); + runGit(repository, ['checkout', '-b', 'feature-known']); + runGit(repository, ['push', '-u', 'origin', 'feature-known']); + // This tracking ref will go stale: the collaborator deletes the branch on + // the remote below, and the list must prune it. + runGit(repository, ['checkout', '-b', 'feature-stale']); + runGit(repository, ['push', '-u', 'origin', 'feature-stale']); + runGit(repository, ['checkout', 'main']); + runGit(repository, ['branch', '-D', 'feature-stale']); + + // A collaborator pushes a branch straight to the remote and deletes + // another; this repository never fetches, so it has no local + // remote-tracking ref for feature-remote-only. + const collaborator = createTempDir(); + runGit(collaborator, ['clone', remote, '.']); + runGit(collaborator, ['config', 'user.email', 'test@example.com']); + runGit(collaborator, ['config', 'user.name', 'Test']); + runGit(collaborator, ['checkout', '-b', 'feature-remote-only']); + runGit(collaborator, ['push', 'origin', 'feature-remote-only']); + runGit(collaborator, ['push', 'origin', ':feature-stale']); + + const branches = await getBranches(repository); + + expect(branches.all).toContain('remotes/origin/feature-remote-only'); + expect(branches.all).toContain('remotes/origin/feature-known'); + expect(branches.all).toContain('feature-known'); + expect(branches.all).not.toContain('remotes/origin/feature-stale'); + }); }); describe.runIf(canRunGit())('getRangeDiff', () => { diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index 003c97f4..e662de5b 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -5,6 +5,12 @@ import path from 'node:path'; import { clearAppImageArgv0FromProcessEnv } from '../inherited-env.js'; import { mergePathValues } from './path-utils.js'; +// Login-shell probes source the user's rc files. A slow or interactive rc +// (nvm, pyenv, a prompt waiting for input) must not hold server startup +// hostage: a probe that overruns is abandoned and resolution falls through +// to the next candidate. Electron's own login-shell probe uses the same bound. +const SHELL_PROBE_TIMEOUT_MS = 5_000; + export const createOpenCodeEnvRuntime = (deps) => { const { state, @@ -208,6 +214,7 @@ export const createOpenCodeEnvRuntime = (deps) => { stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 10 * 1024 * 1024, windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, }); if (result.status !== 0) { @@ -460,6 +467,7 @@ export const createOpenCodeEnvRuntime = (deps) => { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, }); if (result.status === 0) { const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; @@ -527,6 +535,7 @@ export const createOpenCodeEnvRuntime = (deps) => { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, }); if (result.status === 0) { const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; @@ -608,6 +617,7 @@ export const createOpenCodeEnvRuntime = (deps) => { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, }); if (result.status === 0) { const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; diff --git a/packages/web/server/lib/opencode/env-runtime.test.js b/packages/web/server/lib/opencode/env-runtime.test.js index d02529a4..56c10268 100644 --- a/packages/web/server/lib/opencode/env-runtime.test.js +++ b/packages/web/server/lib/opencode/env-runtime.test.js @@ -334,6 +334,29 @@ describe('OpenCode env runtime', () => { }); }); + it('bounds every login-shell probe and falls through when one overruns', () => { + setPlatform('darwin'); + process.env.PATH = createTempDir('openchamber-empty-path-'); + process.env.SHELL = '/bin/zsh'; + delete process.env.OPENCODE_BINARY; + const shellCalls = []; + const { runtime } = createRuntime({}, { + homedir: () => createTempDir('openchamber-empty-home-'), + spawnSync: (command, args, options) => { + shellCalls.push({ command, args, options }); + // What spawnSync reports when `timeout` fires: no status, an error. + return { status: null, signal: 'SIGTERM', error: new Error('spawnSync ETIMEDOUT'), stdout: '', stderr: '' }; + }, + }); + + expect(runtime.resolveOpencodeCliPath()).toBeNull(); + expect(shellCalls.length).toBeGreaterThan(0); + for (const call of shellCalls) { + expect(call.args).toContain('-lic'); + expect(call.options.timeout).toBe(5_000); + } + }); + it('does not auto-detect the Windows OpenCode desktop app as a CLI', () => { setPlatform('win32'); const localAppData = createTempDir('openchamber-localappdata-'); diff --git a/packages/web/server/lib/opencode/routes-upgrade.test.js b/packages/web/server/lib/opencode/routes-upgrade.test.js index cb18b833..f25d2895 100644 --- a/packages/web/server/lib/opencode/routes-upgrade.test.js +++ b/packages/web/server/lib/opencode/routes-upgrade.test.js @@ -9,6 +9,13 @@ afterEach(() => { globalThis.fetch = originalFetch; }); +const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, +}); + +const supportedCapability = { supported: true, manager: 'opencode', reason: null }; + const createApp = (overrides = {}) => { const app = express(); app.use(express.json()); @@ -67,22 +74,99 @@ describe('OpenCode upgrade routes', () => { }); }); + it('names the latest release as the upgrade target when the caller sends none', async () => { + const requests = []; + globalThis.fetch = vi.fn(async (url, init) => { + requests.push({ url: String(url), body: init?.body ? JSON.parse(init.body) : null }); + if (String(url).includes('registry.npmjs.org')) { + return jsonResponse({ version: '1.18.23' }); + } + if (String(url).includes('api.github.com')) { + return jsonResponse({ tag_name: 'v1.18.23' }); + } + return jsonResponse({ success: true, version: '1.18.23' }); + }); + const { app } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability }); + + await request(app) + .post('/api/opencode/upgrade') + .send({}) + .expect(200, { success: true, version: '1.18.23', restarted: true }); + + const upgradeRequest = requests.find((entry) => entry.url.includes('/global/upgrade')); + expect(upgradeRequest?.body).toEqual({ target: '1.18.23' }); + }); + + it('keeps an explicitly requested target instead of resolving the latest release', async () => { + const requests = []; + globalThis.fetch = vi.fn(async (url, init) => { + requests.push({ url: String(url), body: init?.body ? JSON.parse(init.body) : null }); + return jsonResponse({ success: true, version: '1.18.20' }); + }); + const { app } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability }); + + await request(app) + .post('/api/opencode/upgrade') + .send({ target: '1.18.20' }) + .expect(200); + + expect(requests).toHaveLength(1); + expect(requests[0].url).toContain('/global/upgrade'); + expect(requests[0].body).toEqual({ target: '1.18.20' }); + }); + + it('fails without calling the updater when the latest release cannot be resolved', async () => { + globalThis.fetch = vi.fn(async (url) => { + if (String(url).includes('/global/upgrade')) { + throw new Error('the updater must not be called without a target'); + } + return new Response('nope', { status: 503 }); + }); + const { app, dependencies } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability }); + + const response = await request(app) + .post('/api/opencode/upgrade') + .send({}) + .expect(502); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe('OPENCODE_UPGRADE_TARGET_UNRESOLVED'); + expect(response.body.error).toContain('Could not determine which OpenCode version to install'); + expect(dependencies.refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled(); + }); + + it('surfaces the rejection OpenCode reported instead of the bare HTTP status', async () => { + globalThis.fetch = vi.fn(async (url) => { + if (String(url).includes('/global/upgrade')) { + return jsonResponse( + { name: 'BadRequest', data: { message: 'Expected a semantic version', kind: 'Payload' } }, + 400, + ); + } + return jsonResponse({ version: '1.18.23' }); + }); + const { app } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability }); + + await request(app) + .post('/api/opencode/upgrade') + .send({}) + .expect(400, { success: false, error: 'Expected a semantic version' }); + }); + it('serializes supported upgrades and preserves the in-flight lock', async () => { let releaseUpgrade; const upstreamResponse = new Promise((resolve) => { - releaseUpgrade = () => resolve(new Response(JSON.stringify({ success: true, version: '1.18.9' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - })); + releaseUpgrade = () => resolve(jsonResponse({ success: true, version: '1.18.9' })); }); - globalThis.fetch = vi.fn(() => upstreamResponse); - const { app, dependencies } = createApp({ - getOpenCodeUpgradeCapability: () => ({ - supported: true, - manager: 'opencode', - reason: null, - }), + const upgradeCalls = vi.fn(); + globalThis.fetch = vi.fn((url) => { + if (String(url).includes('/global/upgrade')) { + upgradeCalls(); + return upstreamResponse; + } + return Promise.resolve(jsonResponse({ version: '1.18.9' })); }); + const { app, dependencies } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability }); const first = request(app) .post('/api/opencode/upgrade') @@ -94,7 +178,7 @@ describe('OpenCode upgrade routes', () => { }) .then((response) => response); await vi.waitFor(() => { - expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(upgradeCalls).toHaveBeenCalledTimes(1); }); await request(app) diff --git a/packages/web/server/lib/opencode/routes.js b/packages/web/server/lib/opencode/routes.js index 7c48b060..fdb7d95c 100644 --- a/packages/web/server/lib/opencode/routes.js +++ b/packages/web/server/lib/opencode/routes.js @@ -164,6 +164,41 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return return versions.sort((left, right) => compareVersions(right, left))[0]; }; + // OpenCode's `/global/upgrade` requires an explicit semver target and rejects + // a bodyless call, so "update to the latest" has to name the version. The + // release lookup is the same one the upgrade-status check already uses to + // decide there is anything to offer. + const resolveOpenCodeUpgradeTarget = async (requestedTarget) => { + if (typeof requestedTarget === 'string' && requestedTarget.trim().length > 0) { + return { resolved: true, target: requestedTarget.trim() }; + } + try { + const latest = await fetchLatestOpenCodeVersion(); + if (!latest) { + return { resolved: false, reason: 'The latest OpenCode version could not be determined.' }; + } + return { resolved: true, target: latest }; + } catch (error) { + return { + resolved: false, + reason: error instanceof Error ? error.message : 'The latest OpenCode version could not be determined.', + }; + } + }; + + // OpenCode reports a rejected upgrade as `{ name, data: { message, kind } }`, + // which carries no `error` field. Reading only `error` left the user with the + // bare HTTP status text ("Bad Request") and nothing to act on. + const readOpenCodeUpgradeErrorMessage = (payload, response) => { + const candidates = [payload?.error, payload?.data?.message, payload?.message]; + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return candidate.trim(); + } + } + return response.statusText || 'Failed to upgrade OpenCode'; + }; + const pruneExpiredPendingMcpAuthContexts = () => { const now = Date.now(); for (const [state, entry] of pendingMcpAuthContextByState.entries()) { @@ -218,10 +253,23 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return }); } - const target = typeof req.body?.target === 'string' && req.body.target.trim().length > 0 - ? req.body.target.trim() - : undefined; + const requestedTarget = req.body?.target; + // The target lookup reaches the network, so it runs inside the operation: + // the in-flight lock is taken synchronously above, and a second click + // cannot slip past while the release version is being resolved. const upgradeOperation = (async () => { + const targetResolution = await resolveOpenCodeUpgradeTarget(requestedTarget); + if (!targetResolution.resolved) { + return { + status: 502, + body: { + success: false, + code: 'OPENCODE_UPGRADE_TARGET_UNRESOLVED', + error: `Could not determine which OpenCode version to install: ${targetResolution.reason}`, + }, + }; + } + const response = await fetch(buildOpenCodeUrl('/global/upgrade', ''), { method: 'POST', headers: { @@ -229,7 +277,7 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return Accept: 'application/json', ...getOpenCodeAuthHeaders(), }, - body: JSON.stringify(target ? { target } : {}), + body: JSON.stringify({ target: targetResolution.target }), }); const payload = await response.json().catch(() => null); if (!response.ok) { @@ -237,7 +285,7 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return status: response.status, body: { success: false, - error: payload?.error || response.statusText || 'Failed to upgrade OpenCode', + error: readOpenCodeUpgradeErrorMessage(payload, response), }, }; } diff --git a/packages/web/server/lib/small-model/DOCUMENTATION.md b/packages/web/server/lib/small-model/DOCUMENTATION.md index 51bfe98f..9e80347c 100644 --- a/packages/web/server/lib/small-model/DOCUMENTATION.md +++ b/packages/web/server/lib/small-model/DOCUMENTATION.md @@ -116,9 +116,11 @@ other runtime API. endpoint, (3) the endpoint OpenCode resolved at runtime, or (4) the provider's `api` field from the models.dev catalog. The credential follows the same shape: config `options.apiKey`, then the runtime credential, then - the auth.json entry. Configured API keys honor OpenCode's `{env:NAME}` and - `{file:path}` substitutions; file contents and resolved credentials remain - server-side. + the auth.json entry. `provider.<id>.options.headers` is sent with the + request and overrides the bearer default, so gateways that authenticate on + their own header work here exactly as they do in a chat turn. Configured API + keys and header values honor OpenCode's `{env:NAME}` and `{file:path}` + substitutions; file contents and resolved credentials remain server-side. - The runtime credential is refused for providers listed in `OWN_CREDENTIAL_HANDLING`. Their branches need the stored entry rather than a bearer token: the clearest case is the ChatGPT-plan `openai` login, whose @@ -135,6 +137,17 @@ other runtime API. - `routes.js` — `GET /api/small-model` (resolution preview) and `POST /api/small-model/generate` (`{ prompt, system?, maxOutputTokens?, model?, directory? }` → `{ text, providerID, modelID, source }`). +- `config-injection.js` — applies the Settings → Chat → Small Model override + to the config injected into the **managed OpenCode process** + (`OPENCODE_CONFIG_CONTENT`), so OpenCode's own internal `small_model` + consumers — session title and summary generation — use the user's explicit + choice instead of OpenCode's fallback chain. Only an explicit override + (`smallModelUseDefault === false` with a non-empty `smallModelOverride`) is + injected; "use default" leaves the config untouched so OpenCode's own + resolution stays authoritative. Wired into `getManagedOpenCodeEnv` in + `server/index.js`; the pure helper is unit-tested in + `config-injection.test.js`. External OpenCode servers are unaffected (they + are not launched with this env). ## Which providers the pickers may offer diff --git a/packages/web/server/lib/small-model/call.js b/packages/web/server/lib/small-model/call.js index 09261516..9a7d4e95 100644 --- a/packages/web/server/lib/small-model/call.js +++ b/packages/web/server/lib/small-model/call.js @@ -2,7 +2,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { readAuthFile, writeAuthFile } from '../opencode/auth.js'; -import { readConfig, readConfigLayers } from '../opencode/shared.js'; +import { readConfig, readConfigLayers, isPlainObject } from '../opencode/shared.js'; import { getCatalogProvider } from './catalog.js'; import { getAuthEntryForProvider } from './resolve.js'; import { getRuntimeProvider } from './runtime-providers.js'; @@ -19,6 +19,18 @@ const DEFAULT_MAX_OUTPUT_TOKENS = 4_000; const USER_AGENT = 'opencode/1.0 openchamber'; +const mergeHeadersCaseInsensitive = (base, overrides) => { + const merged = { ...base }; + for (const [name, value] of Object.entries(overrides || {})) { + const existingName = Object.keys(merged).find((key) => key.toLowerCase() === name.toLowerCase()); + if (existingName) { + delete merged[existingName]; + } + merged[name] = value; + } + return merged; +}; + const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token'; const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; const CODEX_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses'; @@ -157,11 +169,10 @@ const callOpenaiCompatible = async ({ baseURL, headers, modelID, prompt, system, }); const response = await fetch(`${trimmedBase}/chat/completions`, { method: 'POST', - headers: { + headers: mergeHeadersCaseInsensitive({ 'Content-Type': 'application/json', Accept: 'application/json', - ...headers, - }, + }, headers), body: JSON.stringify({ model: modelID, messages: [ @@ -510,7 +521,7 @@ const callCodexResponses = async ({ accessToken, accountId, modelID, prompt, sys // Custom provider configuration support // --------------------------------------------------------------------------- -const resolveConfigApiKey = (value, workingDirectory, providerID) => { +const resolveConfigValue = (value, workingDirectory, providerID, headerName = null) => { const envMatch = value.match(/^\{env:([^}]+)\}$/i); if (envMatch) { return process.env[envMatch[1].trim()]?.trim() || null; @@ -531,7 +542,12 @@ const resolveConfigApiKey = (value, workingDirectory, providerID) => { { config: layers.customConfig, filePath: layers.paths.customPath }, { config: layers.projectConfig, filePath: layers.paths.projectPath }, { config: layers.userConfig, filePath: layers.paths.userPath }, - ].find(({ config }) => config?.provider?.[providerID]?.options?.apiKey === value); + ].find(({ config }) => { + const options = config?.provider?.[providerID]?.options; + return headerName + ? options?.headers?.[headerName] === value + : options?.apiKey === value; + }); resolvedPath = path.resolve(source?.filePath ? path.dirname(source.filePath) : workingDirectory || process.cwd(), configuredPath); } @@ -540,10 +556,33 @@ const resolveConfigApiKey = (value, workingDirectory, providerID) => { if (!key) throw new Error('empty file'); return key; } catch { - throw new Error(`Failed to resolve configured apiKey file for provider "${providerID}"`); + throw new Error(`Failed to resolve configured ${headerName ? `header "${headerName}"` : 'apiKey'} file for provider "${providerID}"`); } }; +/** + * `options.headers` from the provider config, with the same `{env:…}`/`{file:…}` + * substitutions the API key gets. + * + * OpenCode sends these on every request, so dropping them here would have the + * small model authenticating differently from the request path against the same + * URL. Gateways fronted by an API-management layer reject a bearer-only request + * outright, because the header is the credential rather than a supplement to it. + */ +const readConfiguredHeaders = (providerCfg, workingDirectory, providerID) => { + const configured = providerCfg?.options?.headers; + if (!isPlainObject(configured)) return null; + const headers = {}; + for (const [name, value] of Object.entries(configured)) { + // Config headers are strings; a malformed entry is skipped rather than + // stringified into a header the gateway would reject. + if (String(value) !== value) continue; + const resolved = resolveConfigValue(value.trim(), workingDirectory, providerID, name); + if (resolved) headers[name] = resolved; + } + return Object.keys(headers).length ? headers : null; +}; + const readProviderConfig = (workingDirectory, providerID) => { try { const config = readConfig(workingDirectory); @@ -551,9 +590,10 @@ const readProviderConfig = (workingDirectory, providerID) => { if (!providerCfg || typeof providerCfg !== 'object') return null; const baseURL = typeof providerCfg?.options?.baseURL === 'string' ? providerCfg.options.baseURL.trim() : null; const rawApiKey = typeof providerCfg?.options?.apiKey === 'string' ? providerCfg.options.apiKey.trim() : null; - const apiKey = rawApiKey ? resolveConfigApiKey(rawApiKey, workingDirectory, providerID) : null; + const apiKey = rawApiKey ? resolveConfigValue(rawApiKey, workingDirectory, providerID) : null; return { baseURL, + headers: readConfiguredHeaders(providerCfg, workingDirectory, providerID), // Shape the config-supplied key as a regular api-key auth entry so it // can win the precedence check below and flow through the dispatch's // `entry.type === 'api' ? entry.key : ...` branch unchanged. @@ -753,7 +793,9 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider return callOpenaiCompatible({ baseURL, - headers: { Authorization: `Bearer ${apiKey}` }, + // Configured headers last: a gateway that authenticates on its own header + // must be able to override the bearer default rather than sit beside it. + headers: mergeHeadersCaseInsensitive({ Authorization: `Bearer ${apiKey}` }, providerConfig?.headers), modelID, prompt, system, diff --git a/packages/web/server/lib/small-model/call.test.js b/packages/web/server/lib/small-model/call.test.js index 9b9533bc..7c2a5315 100644 --- a/packages/web/server/lib/small-model/call.test.js +++ b/packages/web/server/lib/small-model/call.test.js @@ -5,11 +5,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // readConfig reads merged opencode config layers from disk; mock it so each // test controls the provider config without touching the filesystem. call.js -// imports only readConfig from shared.js, so the rest of that module is left -// untouched for this file. +// imports the config readers and a plain-object predicate from shared.js, so +// the rest of that module is left untouched for this file. vi.mock('../opencode/shared.js', () => ({ readConfig: vi.fn(), readConfigLayers: vi.fn(), + // Pure predicate with no disk access — the real implementation, so header + // parsing is exercised rather than stubbed. + isPlainObject: (value) => value instanceof Object && !Array.isArray(value), })); vi.mock('./runtime-providers.js', () => ({ getRuntimeProvider: vi.fn(async () => null) })); @@ -67,6 +70,7 @@ describe('callSmallModel — custom provider config', () => { globalThis.fetch = originalFetch; vi.restoreAllMocks(); delete process.env.OPENCHAMBER_TEST_PROVIDER_KEY; + delete process.env.OPENCHAMBER_TEST_GATEWAY_KEY; }); describe('config-supplied credentials (no auth.json entry)', () => { @@ -122,6 +126,105 @@ describe('callSmallModel — custom provider config', () => { expect(lastCall(fetchMock).init.headers.Authorization).toBe('Bearer sk-env-key'); }); + it('sends configured provider headers alongside the bearer token', async () => { + process.env.OPENCHAMBER_TEST_GATEWAY_KEY = 'sub-key'; + readConfig.mockReturnValue({ + provider: { + custom: { + options: { + apiKey: 'sk-config', + baseURL: 'https://proxy.example.test/v1', + headers: { + 'Ocp-Apim-Subscription-Key': '{env:OPENCHAMBER_TEST_GATEWAY_KEY}', + 'x-tenant': 'team', + }, + }, + }, + }, + }); + fetchMock.mockResolvedValue(ok('hello')); + + await callSmallModel({ + auth: {}, + catalog: {}, + workingDirectory: '/proj', + providerID: 'custom', + modelID: 'model', + prompt: 'hi', + }); + + const { init } = lastCall(fetchMock); + expect(init.headers['Ocp-Apim-Subscription-Key']).toBe('sub-key'); + expect(init.headers['x-tenant']).toBe('team'); + expect(init.headers.Authorization).toBe('Bearer sk-config'); + }); + + it('resolves a relative header file from the config layer that defines it', async () => { + const configPath = '/config/opencode.json'; + const secretPath = '/config/gateway-key'; + vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { + if (filePath === secretPath) return 'sub-key\n'; + throw new Error(`Unexpected file read: ${filePath}`); + }); + const provider = { + custom: { + options: { + apiKey: 'sk-config', + baseURL: 'https://proxy.example.test/v1', + headers: { 'x-gateway-key': '{file:./gateway-key}' }, + }, + }, + }; + readConfig.mockReturnValue({ provider }); + readConfigLayers.mockReturnValue({ + customConfig: {}, + projectConfig: {}, + userConfig: { provider }, + paths: { customPath: null, projectPath: '/project/opencode.json', userPath: configPath }, + }); + fetchMock.mockResolvedValue(ok('hello')); + + await callSmallModel({ + auth: {}, + catalog: {}, + workingDirectory: '/project', + providerID: 'custom', + modelID: 'model', + prompt: 'hi', + }); + + expect(lastCall(fetchMock).init.headers['x-gateway-key']).toBe('sub-key'); + expect(fs.readFileSync).toHaveBeenCalledWith(secretPath, 'utf8'); + }); + + it('overrides Authorization without depending on header-name casing', async () => { + readConfig.mockReturnValue({ + provider: { + custom: { + options: { + apiKey: 'sk-config', + baseURL: 'https://proxy.example.test/v1', + headers: { authorization: 'Basic gateway-token' }, + }, + }, + }, + }); + fetchMock.mockResolvedValue(ok('hello')); + + await callSmallModel({ + auth: {}, + catalog: {}, + workingDirectory: '/project', + providerID: 'custom', + modelID: 'model', + prompt: 'hi', + }); + + const headers = lastCall(fetchMock).init.headers; + expect(headers.authorization).toBe('Basic gateway-token'); + expect(headers.Authorization).toBeUndefined(); + }); + it('uses apiKey and baseURL from provider config when no auth.json entry exists', async () => { readConfig.mockReturnValue({ provider: { diff --git a/packages/web/server/lib/small-model/config-injection.js b/packages/web/server/lib/small-model/config-injection.js new file mode 100644 index 00000000..3738ec8e --- /dev/null +++ b/packages/web/server/lib/small-model/config-injection.js @@ -0,0 +1,51 @@ +/** + * Applies the user's explicit Small Model override (Settings → Chat → Small + * Model) to the configuration injected into the managed OpenCode process. + * + * OpenCode's own session-title and summary generation reads `small_model` + * from its config layers. Previously the OpenChamber settings override only + * fed OpenChamber's own `/api/small-model/generate` utility service, so a + * configured Small Model never reached OpenCode's title generation and + * sessions kept their fallback/untitled state. Injecting the override as + * `small_model` in the managed `OPENCODE_CONFIG_CONTENT` closes that gap for + * the managed server. + * + * Only an explicit override applies (`smallModelUseDefault === false` with a + * non-empty `smallModelOverride`). "Use default" leaves the config untouched, + * so OpenCode's own resolution chain (config `small_model`, then its family + * scan) stays authoritative — this mirrors the precedence documented in + * `packages/web/server/lib/small-model/DOCUMENTATION.md`. + * + * Malformed user config is left untouched rather than rewritten: OpenCode's + * own loader is the right place to surface it, and silently rewriting it + * would hide the error. + */ +export const applySmallModelOverrideToOpenCodeConfig = ({ + configContent, + smallModelUseDefault, + smallModelOverride, +}) => { + if (smallModelUseDefault !== false) { + return configContent; + } + const override = typeof smallModelOverride === 'string' ? smallModelOverride.trim() : ''; + if (!override) { + return configContent; + } + + const current = (() => { + if (typeof configContent !== 'string' || configContent.trim().length === 0) { + return {}; + } + try { + return JSON.parse(configContent); + } catch { + return null; + } + })(); + if (current === null || typeof current !== 'object' || Array.isArray(current)) { + return configContent; + } + + return JSON.stringify({ ...current, small_model: override }); +}; diff --git a/packages/web/server/lib/small-model/config-injection.test.js b/packages/web/server/lib/small-model/config-injection.test.js new file mode 100644 index 00000000..22342038 --- /dev/null +++ b/packages/web/server/lib/small-model/config-injection.test.js @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { applySmallModelOverrideToOpenCodeConfig } from './config-injection.js'; + +describe('applySmallModelOverrideToOpenCodeConfig', () => { + it('leaves config unchanged when use-default is not explicitly disabled', () => { + const config = '{"model":"anthropic/claude-sonnet-4-5"}'; + expect( + applySmallModelOverrideToOpenCodeConfig({ + configContent: config, + smallModelUseDefault: true, + smallModelOverride: 'anthropic/claude-haiku-4-5', + }), + ).toBe(config); + expect( + applySmallModelOverrideToOpenCodeConfig({ + configContent: config, + smallModelUseDefault: undefined, + smallModelOverride: 'anthropic/claude-haiku-4-5', + }), + ).toBe(config); + }); + + it('leaves config unchanged when the override is empty or whitespace', () => { + const config = '{"model":"anthropic/claude-sonnet-4-5"}'; + expect( + applySmallModelOverrideToOpenCodeConfig({ + configContent: config, + smallModelUseDefault: false, + smallModelOverride: ' ', + }), + ).toBe(config); + expect( + applySmallModelOverrideToOpenCodeConfig({ + configContent: config, + smallModelUseDefault: false, + smallModelOverride: undefined, + }), + ).toBe(config); + }); + + it('injects small_model into an empty config', () => { + const result = applySmallModelOverrideToOpenCodeConfig({ + configContent: undefined, + smallModelUseDefault: false, + smallModelOverride: 'anthropic/claude-haiku-4-5', + }); + expect(JSON.parse(result)).toEqual({ small_model: 'anthropic/claude-haiku-4-5' }); + }); + + it('injects small_model while preserving existing config keys and plugins', () => { + const result = applySmallModelOverrideToOpenCodeConfig({ + configContent: '{"model":"anthropic/claude-sonnet-4-5","plugin":["file:///tool.js"]}', + smallModelUseDefault: false, + smallModelOverride: 'google/gemini-2.5-flash', + }); + expect(JSON.parse(result)).toEqual({ + model: 'anthropic/claude-sonnet-4-5', + plugin: ['file:///tool.js'], + small_model: 'google/gemini-2.5-flash', + }); + }); + + it('replaces an existing small_model with the override', () => { + const result = applySmallModelOverrideToOpenCodeConfig({ + configContent: '{"small_model":"anthropic/claude-haiku-4-5"}', + smallModelUseDefault: false, + smallModelOverride: 'google/gemini-2.5-flash', + }); + expect(JSON.parse(result)).toEqual({ small_model: 'google/gemini-2.5-flash' }); + }); + + it('leaves malformed config untouched instead of rewriting it', () => { + const config = '{not-valid-json'; + expect( + applySmallModelOverrideToOpenCodeConfig({ + configContent: config, + smallModelUseDefault: false, + smallModelOverride: 'anthropic/claude-haiku-4-5', + }), + ).toBe(config); + const arrayConfig = '["not","an","object"]'; + expect( + applySmallModelOverrideToOpenCodeConfig({ + configContent: arrayConfig, + smallModelUseDefault: false, + smallModelOverride: 'anthropic/claude-haiku-4-5', + }), + ).toBe(arrayConfig); + }); +});